This commit is contained in:
Abdo 2025-09-15 00:34:54 -07:00 committed by GitHub
commit a7044b7db0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
164 changed files with 6952 additions and 3241 deletions

View file

@ -41,11 +41,17 @@ module.exports = {
parser: "svelte-eslint-parser",
parserOptions: {
parser: "@typescript-eslint/parser",
svelteFeatures: {
experimentalGenerics: true,
},
},
rules: {
"svelte/no-at-html-tags": "off",
"svelte/valid-compile": ["error", { "ignoreWarnings": true }],
"@typescript-eslint/no-explicit-any": "off",
"prefer-const": "off",
// TODO: enable this when we update to eslint-plugin-svelte 3
// "svelte/prefer-const": "warn",
},
},
],

View file

@ -191,7 +191,12 @@ fn build_js(build: &mut Build) -> Result<()> {
},
)?;
let files_from_ts = build.inputs_with_suffix(
inputs![":ts:editor", ":ts:reviewer:reviewer.js", ":ts:mathjax"],
inputs![
":ts:editor",
":ts:editable",
":ts:reviewer:reviewer.js",
":ts:mathjax"
],
".js",
);
build.add_action(

View file

@ -170,7 +170,7 @@ fn declare_and_check_other_libraries(build: &mut Build) -> Result<()> {
"components",
inputs![":ts:lib", ":ts:sveltelib", glob!("ts/components/**")],
),
("html-filter", inputs![glob!("ts/html-filter/**")]),
("html-filter", inputs![glob!("ts/lib/html-filter/**")]),
] {
let library_with_ts = format!("ts:{library}");
build.add_dependency(&library_with_ts, inputs.clone());
@ -187,7 +187,7 @@ fn build_and_check_pages(build: &mut Build) -> Result<()> {
let entrypoint = if html {
format!("ts/routes/{name}/index.ts")
} else {
format!("ts/{name}/index.ts")
format!("ts/lib/{name}/index.ts")
};
build.add_action(
&group,
@ -203,12 +203,11 @@ fn build_and_check_pages(build: &mut Build) -> Result<()> {
Ok(())
};
// we use the generated .css file separately
// we use the generated .css file separately in the legacy editor
build_page(
"editable",
false,
inputs![
//
":ts:lib",
":ts:components",
":ts:domlib",
@ -220,21 +219,15 @@ fn build_and_check_pages(build: &mut Build) -> Result<()> {
build_page(
"congrats",
true,
inputs![
//
":ts:lib",
":ts:components",
":sass",
":sveltekit"
],
inputs![":ts:lib", ":ts:components", ":sass", ":sveltekit"],
)?;
Ok(())
}
/// Only used for the legacy editor page.
fn build_and_check_editor(build: &mut Build) -> Result<()> {
let editor_deps = inputs![
//
":ts:lib",
":ts:components",
":ts:domlib",
@ -242,14 +235,14 @@ fn build_and_check_editor(build: &mut Build) -> Result<()> {
":ts:html-filter",
":sass",
":sveltekit",
glob!("ts/{editable,editor,routes/image-occlusion}/**")
glob!("ts/lib/editable,routes/{editor,image-occlusion}/**")
];
build.add_action(
"ts:editor",
EsbuildScript {
script: "ts/bundle_svelte.mjs".into(),
entrypoint: "ts/editor/index.ts".into(),
entrypoint: "ts/routes/editor/index.ts".into(),
output_stem: "ts/editor/editor",
deps: editor_deps.clone(),
extra_exts: &["css"],

View file

@ -10,6 +10,9 @@ package anki.frontend;
import "anki/scheduler.proto";
import "anki/generic.proto";
import "anki/search.proto";
import "anki/notes.proto";
import "anki/notetypes.proto";
import "anki/links.proto";
service FrontendService {
// Returns values from the reviewer
@ -27,6 +30,34 @@ service FrontendService {
rpc deckOptionsRequireClose(generic.Empty) returns (generic.Empty);
// Warns python that the deck option web view is ready to receive requests.
rpc deckOptionsReady(generic.Empty) returns (generic.Empty);
// Editor
rpc UpdateEditorNote(notes.UpdateNotesRequest) returns (generic.Empty);
rpc UpdateEditorNotetype(notetypes.Notetype) returns (generic.Empty);
rpc AddEditorNote(notes.AddNoteRequest) returns (notes.AddNoteResponse);
rpc ConvertPastedImage(ConvertPastedImageRequest)
returns (ConvertPastedImageResponse);
rpc OpenFilePicker(openFilePickerRequest) returns (generic.String);
rpc OpenMedia(generic.String) returns (generic.Empty);
rpc ShowInMediaFolder(generic.String) returns (generic.Empty);
rpc RecordAudio(generic.Empty) returns (generic.String);
rpc CloseAddCards(generic.Bool) returns (generic.Empty);
rpc CloseEditCurrent(generic.Empty) returns (generic.Empty);
rpc OpenLink(generic.String) returns (generic.Empty);
rpc AskUser(AskUserRequest) returns (generic.Bool);
rpc ShowMessageBox(ShowMessageBoxRequest) returns (generic.Empty);
// Profile config
rpc GetProfileConfigJson(generic.String) returns (generic.Json);
rpc SetProfileConfigJson(SetSettingJsonRequest) returns (generic.Empty);
// Metadata
rpc GetMetaJson(generic.String) returns (generic.Json);
rpc SetMetaJson(SetSettingJsonRequest) returns (generic.Empty);
// Clipboard
rpc ReadClipboard(ReadClipboardRequest) returns (ReadClipboardResponse);
rpc WriteClipboard(WriteClipboardRequest) returns (generic.Empty);
}
service BackendFrontendService {}
@ -40,3 +71,64 @@ message SetSchedulingStatesRequest {
string key = 1;
scheduler.SchedulingStates states = 2;
}
message ConvertPastedImageRequest {
bytes data = 1;
string ext = 2;
}
message ConvertPastedImageResponse {
bytes data = 1;
}
message SetSettingJsonRequest {
string key = 1;
bytes value_json = 2;
}
message openFilePickerRequest {
string title = 1;
string key = 2;
string filter_description = 3;
repeated string extensions = 4;
}
message ReadClipboardRequest {
repeated string types = 1;
}
message ReadClipboardResponse {
map<string, bytes> data = 1;
}
message WriteClipboardRequest {
map<string, bytes> data = 1;
}
message Help {
oneof value {
links.HelpPageLinkRequest.HelpPage help_page = 1;
string help_link = 2;
}
}
message AskUserRequest {
string text = 1;
optional Help help = 2;
optional string title = 4;
optional bool default_no = 5;
}
enum MessageBoxType {
INFO = 0;
WARNING = 1;
CRITICAL = 2;
}
message ShowMessageBoxRequest {
string text = 1;
MessageBoxType type = 2;
optional Help help = 3;
optional string title = 4;
optional string text_format = 5;
}

View file

@ -13,16 +13,21 @@ import "anki/notetypes.proto";
service MediaService {
rpc CheckMedia(generic.Empty) returns (CheckMediaResponse);
rpc AddMediaFile(AddMediaFileRequest) returns (generic.String);
rpc AddMediaFromPath(AddMediaFromPathRequest) returns (generic.String);
rpc TrashMediaFiles(TrashMediaFilesRequest) returns (generic.Empty);
rpc EmptyTrash(generic.Empty) returns (generic.Empty);
rpc RestoreTrash(generic.Empty) returns (generic.Empty);
rpc ExtractStaticMediaFiles(notetypes.NotetypeId)
returns (generic.StringList);
rpc ExtractMediaFiles(generic.String) returns (generic.StringList);
rpc GetAbsoluteMediaPath(generic.String) returns (generic.String);
}
// Implicitly includes any of the above methods that are not listed in the
// backend service.
service BackendMediaService {}
service BackendMediaService {
rpc AddMediaFromUrl(AddMediaFromUrlRequest) returns (AddMediaFromUrlResponse);
}
message CheckMediaResponse {
repeated string unused = 1;
@ -40,3 +45,16 @@ message AddMediaFileRequest {
string desired_name = 1;
bytes data = 2;
}
message AddMediaFromPathRequest {
string path = 1;
}
message AddMediaFromUrlRequest {
string url = 1;
}
message AddMediaFromUrlResponse {
optional string filename = 1;
optional string error = 2;
}

View file

@ -125,9 +125,11 @@ from aqt import stats, about, preferences, mediasync # isort:skip
class DialogManager:
_dialogs: dict[str, list] = {
"AddCards": [addcards.AddCards, None],
"NewAddCards": [addcards.NewAddCards, None],
"AddonsDialog": [addons.AddonsDialog, None],
"Browser": [browser.Browser, None],
"EditCurrent": [editcurrent.EditCurrent, None],
"NewEditCurrent": [editcurrent.NewEditCurrent, None],
"FilteredDeckConfigDialog": [filtered_deck.FilteredDeckConfigDialog, None],
"DeckStats": [stats.DeckStats, None],
"NewDeckStats": [stats.NewDeckStats, None],

View file

@ -5,40 +5,33 @@ from __future__ import annotations
from collections.abc import Callable
import aqt.editor
import aqt.forms
from anki._legacy import deprecated
from anki.collection import OpChanges, OpChangesWithCount, SearchNode
from anki.decks import DeckId
from anki.models import NotetypeId
from anki.notes import Note, NoteFieldsCheckResult, NoteId
from anki.utils import html_to_text_line, is_mac
from anki.notes import Note
from anki.utils import is_mac
from aqt import AnkiQt, gui_hooks
from aqt.deckchooser import DeckChooser
from aqt.notetypechooser import NotetypeChooser
from aqt.operations.note import add_note
from aqt.addcards_legacy import *
from aqt.qt import *
from aqt.sound import av_player
from aqt.utils import (
HelpPage,
add_close_shortcut,
ask_user_dialog,
askUser,
downArrow,
openHelp,
restoreGeom,
saveGeom,
shortcut,
showWarning,
tooltip,
tr,
)
class AddCards(QMainWindow):
def __init__(self, mw: AnkiQt) -> None:
class NewAddCards(QMainWindow):
def __init__(
self,
mw: AnkiQt,
deck_id: DeckId | None = None,
notetype_id: NotetypeId | None = None,
) -> None:
super().__init__(None, Qt.WindowType.Window)
self._close_event_has_cleaned_up = False
self._close_callback: Callable[[], None] = self._close
self.mw = mw
self.col = mw.col
form = aqt.forms.addcards.Ui_Dialog()
@ -47,297 +40,52 @@ class AddCards(QMainWindow):
self.setWindowTitle(tr.actions_add())
self.setMinimumHeight(300)
self.setMinimumWidth(400)
self.setup_choosers()
self.setupEditor()
add_close_shortcut(self)
self._load_new_note()
self.setupButtons()
self.history: list[NoteId] = []
self._last_added_note: Note | None = None
gui_hooks.operation_did_execute.append(self.on_operation_did_execute)
self._load_new_note(deck_id, notetype_id)
restoreGeom(self, "add")
gui_hooks.add_cards_did_init(self)
if not is_mac:
self.setMenuBar(None)
self.show()
def set_deck(self, deck_id: DeckId) -> None:
self.deck_chooser.selected_deck_id = deck_id
def set_note_type(self, note_type_id: NotetypeId) -> None:
self.notetype_chooser.selected_notetype_id = note_type_id
def set_note(self, note: Note, deck_id: DeckId | None = None) -> None:
"""Set tags, field contents and notetype according to `note`. Deck is set
to `deck_id` or the deck last used with the notetype.
"""
self.notetype_chooser.selected_notetype_id = note.mid
if deck_id or (deck_id := self.col.default_deck_for_notetype(note.mid)):
self.deck_chooser.selected_deck_id = deck_id
new_note = self._new_note()
new_note.fields = note.fields[:]
new_note.tags = note.tags[:]
self.editor.orig_note_id = note.id
self.setAndFocusNote(new_note)
self.editor.load_note(
mid=note.mid,
original_note_id=note.id,
focus_to=0,
)
def setupEditor(self) -> None:
self.editor = aqt.editor.Editor(
self.editor = aqt.editor.NewEditor(
self.mw,
self.form.fieldsArea,
self,
editor_mode=aqt.editor.EditorMode.ADD_CARDS,
)
def setup_choosers(self) -> None:
defaults = self.col.defaults_for_adding(
current_review_card=self.mw.reviewer.card
)
self.notetype_chooser = NotetypeChooser(
mw=self.mw,
widget=self.form.modelArea,
starting_notetype_id=NotetypeId(defaults.notetype_id),
on_button_activated=self.show_notetype_selector,
on_notetype_changed=self.on_notetype_change,
)
self.deck_chooser = DeckChooser(
self.mw,
self.form.deckArea,
starting_deck_id=DeckId(defaults.deck_id),
on_deck_changed=self.on_deck_changed,
)
def reopen(self, mw: AnkiQt) -> None:
if not self.editor.fieldsAreBlank():
return
defaults = self.col.defaults_for_adding(
current_review_card=self.mw.reviewer.card
)
self.set_note_type(NotetypeId(defaults.notetype_id))
self.set_deck(DeckId(defaults.deck_id))
def reopen(
self,
mw: AnkiQt,
deck_id: DeckId | None = None,
notetype_id: NotetypeId | None = None,
) -> None:
self.editor.reload_note_if_empty(deck_id, notetype_id)
def helpRequested(self) -> None:
openHelp(HelpPage.ADDING_CARD_AND_NOTE)
def setupButtons(self) -> None:
bb = self.form.buttonBox
ar = QDialogButtonBox.ButtonRole.ActionRole
# add
self.addButton = bb.addButton(tr.actions_add(), ar)
qconnect(self.addButton.clicked, self.add_current_note)
self.addButton.setShortcut(QKeySequence("Ctrl+Return"))
# qt5.14+ doesn't handle numpad enter on Windows
self.compat_add_shorcut = QShortcut(QKeySequence("Ctrl+Enter"), self)
qconnect(self.compat_add_shorcut.activated, self.addButton.click)
self.addButton.setToolTip(shortcut(tr.adding_add_shortcut_ctrlandenter()))
# close
self.closeButton = QPushButton(tr.actions_close())
self.closeButton.setAutoDefault(False)
bb.addButton(self.closeButton, QDialogButtonBox.ButtonRole.RejectRole)
qconnect(self.closeButton.clicked, self.close)
# help
self.helpButton = QPushButton(tr.actions_help(), clicked=self.helpRequested) # type: ignore
self.helpButton.setAutoDefault(False)
bb.addButton(self.helpButton, QDialogButtonBox.ButtonRole.HelpRole)
# history
b = bb.addButton(f"{tr.adding_history()} {downArrow()}", ar)
if is_mac:
sc = "Ctrl+Shift+H"
else:
sc = "Ctrl+H"
b.setShortcut(QKeySequence(sc))
b.setToolTip(tr.adding_shortcut(val=shortcut(sc)))
qconnect(b.clicked, self.onHistory)
b.setEnabled(False)
self.historyButton = b
def setAndFocusNote(self, note: Note) -> None:
self.editor.set_note(note, focusTo=0)
def show_notetype_selector(self) -> None:
self.editor.call_after_note_saved(self.notetype_chooser.choose_notetype)
def on_deck_changed(self, deck_id: int) -> None:
gui_hooks.add_cards_did_change_deck(deck_id)
def on_notetype_change(
self, notetype_id: NotetypeId, update_deck: bool = True
def _load_new_note(
self, deck_id: DeckId | None = None, notetype_id: NotetypeId | None = None
) -> None:
# need to adjust current deck?
if update_deck:
if deck_id := self.col.default_deck_for_notetype(notetype_id):
self.deck_chooser.selected_deck_id = deck_id
# only used for detecting changed sticky fields on close
self._last_added_note = None
# copy fields into new note with the new notetype
old_note = self.editor.note
new_note = self._new_note()
if old_note:
old_field_names = list(old_note.keys())
new_field_names = list(new_note.keys())
copied_field_names = set()
for f in new_note.note_type()["flds"]:
field_name = f["name"]
# copy identical non-empty fields
if field_name in old_field_names and old_note[field_name]:
new_note[field_name] = old_note[field_name]
copied_field_names.add(field_name)
new_idx = 0
for old_idx, old_field_value in enumerate(old_field_names):
# skip previously copied identical fields in new note
while (
new_idx < len(new_field_names)
and new_field_names[new_idx] in copied_field_names
):
new_idx += 1
if new_idx >= len(new_field_names):
break
# copy non-empty old fields
if (
old_field_value not in copied_field_names
and old_note.fields[old_idx]
):
new_note.fields[new_idx] = old_note.fields[old_idx]
new_idx += 1
new_note.tags = old_note.tags
# and update editor state
self.editor.note = new_note
self.editor.loadNote(
focusTo=min(self.editor.last_field_index or 0, len(new_note.fields) - 1)
self.editor.load_note(
mid=notetype_id,
deck_id=deck_id,
focus_to=0,
)
gui_hooks.addcards_did_change_note_type(
self, old_note.note_type(), new_note.note_type()
)
def _load_new_note(self, sticky_fields_from: Note | None = None) -> None:
note = self._new_note()
if old_note := sticky_fields_from:
flds = note.note_type()["flds"]
# copy fields from old note
if old_note:
for n in range(min(len(note.fields), len(old_note.fields))):
if flds[n]["sticky"]:
note.fields[n] = old_note.fields[n]
# and tags
note.tags = old_note.tags
self.setAndFocusNote(note)
def on_operation_did_execute(
self, changes: OpChanges, handler: object | None
) -> None:
if (changes.notetype or changes.deck) and handler is not self.editor:
self.on_notetype_change(
NotetypeId(
self.col.defaults_for_adding(
current_review_card=self.mw.reviewer.card
).notetype_id
),
update_deck=False,
)
def _new_note(self) -> Note:
return self.col.new_note(
self.col.models.get(self.notetype_chooser.selected_notetype_id)
)
def addHistory(self, note: Note) -> None:
self.history.insert(0, note.id)
self.history = self.history[:15]
self.historyButton.setEnabled(True)
def onHistory(self) -> None:
m = QMenu(self)
for nid in self.history:
if self.col.find_notes(self.col.build_search_string(SearchNode(nid=nid))):
note = self.col.get_note(nid)
fields = note.fields
txt = html_to_text_line(", ".join(fields))
if len(txt) > 30:
txt = f"{txt[:30]}..."
line = tr.adding_edit(val=txt)
line = gui_hooks.addcards_will_add_history_entry(line, note)
line = line.replace("&", "&&")
# In qt action "&i" means "underline i, trigger this line when i is pressed".
# except for "&&" which is replaced by a single "&"
a = m.addAction(line)
qconnect(a.triggered, lambda b, nid=nid: self.editHistory(nid))
else:
a = m.addAction(tr.adding_note_deleted())
a.setEnabled(False)
gui_hooks.add_cards_will_show_history_menu(self, m)
m.exec(self.historyButton.mapToGlobal(QPoint(0, 0)))
def editHistory(self, nid: NoteId) -> None:
aqt.dialogs.open("Browser", self.mw, search=(SearchNode(nid=nid),))
def add_current_note(self) -> None:
if self.editor.current_notetype_is_image_occlusion():
self.editor.update_occlusions_field()
self.editor.call_after_note_saved(self._add_current_note)
self.editor.reset_image_occlusion()
else:
self.editor.call_after_note_saved(self._add_current_note)
def _add_current_note(self) -> None:
note = self.editor.note
if not self._note_can_be_added(note):
return
target_deck_id = self.deck_chooser.selected_deck_id
def on_success(changes: OpChangesWithCount) -> None:
# only used for detecting changed sticky fields on close
self._last_added_note = note
self.addHistory(note)
tooltip(tr.importing_cards_added(count=changes.count), period=500)
av_player.stop_and_clear_queue()
self._load_new_note(sticky_fields_from=note)
gui_hooks.add_cards_did_add_note(note)
add_note(parent=self, note=note, target_deck_id=target_deck_id).success(
on_success
).run_in_background()
def _note_can_be_added(self, note: Note) -> bool:
result = note.fields_check()
# no problem, duplicate, and confirmed cloze cases
problem = None
if result == NoteFieldsCheckResult.EMPTY:
if self.editor.current_notetype_is_image_occlusion():
problem = tr.notetypes_no_occlusion_created2()
else:
problem = tr.adding_the_first_field_is_empty()
elif result == NoteFieldsCheckResult.MISSING_CLOZE:
if not askUser(tr.adding_you_have_a_cloze_deletion_note()):
return False
elif result == NoteFieldsCheckResult.NOTETYPE_NOT_CLOZE:
problem = tr.adding_cloze_outside_cloze_notetype()
elif result == NoteFieldsCheckResult.FIELD_NOT_CLOZE:
problem = tr.adding_cloze_outside_cloze_field()
# filter problem through add-ons
problem = gui_hooks.add_cards_will_add_note(problem, note)
if problem is not None:
showWarning(problem, help=HelpPage.ADDING_CARD_AND_NOTE)
return False
optional_problems: list[str] = []
gui_hooks.add_cards_might_add_note(optional_problems, note)
if not all(askUser(op) for op in optional_problems):
return False
return True
def keyPressEvent(self, evt: QKeyEvent) -> None:
if evt.key() == Qt.Key.Key_Escape:
@ -354,35 +102,34 @@ class AddCards(QMainWindow):
def _close(self) -> None:
self.editor.cleanup()
self.notetype_chooser.cleanup()
self.deck_chooser.cleanup()
gui_hooks.operation_did_execute.remove(self.on_operation_did_execute)
self.mw.maybeReset()
saveGeom(self, "add")
aqt.dialogs.markClosed("AddCards")
aqt.dialogs.markClosed("NewAddCards")
self._close_event_has_cleaned_up = True
self.mw.deferred_delete_and_garbage_collect(self)
self.close()
def ifCanClose(self, onOk: Callable) -> None:
self._close_callback = onOk
self.editor.web.eval("closeAddCards()")
def _close_if_user_wants_to_discard_changes(self, prompt: bool) -> None:
if not prompt:
self._close_callback()
return
def callback(choice: int) -> None:
if choice == 0:
onOk()
self._close_callback()
def afterSave() -> None:
if self.editor.fieldsAreBlank(self._last_added_note):
return onOk()
ask_user_dialog(
tr.adding_discard_current_input(),
callback=callback,
buttons=[
QMessageBox.StandardButton.Discard,
(tr.adding_keep_editing(), QMessageBox.ButtonRole.RejectRole),
],
)
self.editor.call_after_note_saved(afterSave)
ask_user_dialog(
tr.adding_discard_current_input(),
callback=callback,
buttons=[
QMessageBox.StandardButton.Discard,
(tr.adding_keep_editing(), QMessageBox.ButtonRole.RejectRole),
],
)
def closeWithCallback(self, cb: Callable[[], None]) -> None:
def doClose() -> None:
@ -390,25 +137,3 @@ class AddCards(QMainWindow):
cb()
self.ifCanClose(doClose)
# legacy aliases
@property
def deckChooser(self) -> DeckChooser:
if getattr(self, "form", None):
# show this warning only after Qt form has been initialized,
# or PyQt's introspection triggers it
print("deckChooser is deprecated; use deck_chooser instead")
return self.deck_chooser
addCards = add_current_note
_addCards = _add_current_note
onModelChange = on_notetype_change
@deprecated(info="obsolete")
def addNote(self, note: Note) -> None:
pass
@deprecated(info="does nothing; will go away")
def removeTempNote(self, note: Note) -> None:
pass

414
qt/aqt/addcards_legacy.py Normal file
View file

@ -0,0 +1,414 @@
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from __future__ import annotations
from collections.abc import Callable
import aqt.editor
import aqt.forms
from anki._legacy import deprecated
from anki.collection import OpChanges, OpChangesWithCount, SearchNode
from anki.decks import DeckId
from anki.models import NotetypeId
from anki.notes import Note, NoteFieldsCheckResult, NoteId
from anki.utils import html_to_text_line, is_mac
from aqt import AnkiQt, gui_hooks
from aqt.deckchooser import DeckChooser
from aqt.notetypechooser import NotetypeChooser
from aqt.operations.note import add_note
from aqt.qt import *
from aqt.sound import av_player
from aqt.utils import (
HelpPage,
add_close_shortcut,
ask_user_dialog,
askUser,
downArrow,
openHelp,
restoreGeom,
saveGeom,
shortcut,
showWarning,
tooltip,
tr,
)
class AddCards(QMainWindow):
def __init__(self, mw: AnkiQt) -> None:
super().__init__(None, Qt.WindowType.Window)
self._close_event_has_cleaned_up = False
self.mw = mw
self.col = mw.col
form = aqt.forms.addcards.Ui_Dialog()
form.setupUi(self)
self.form = form
self.setWindowTitle(tr.actions_add())
self.setMinimumHeight(300)
self.setMinimumWidth(400)
self.setup_choosers()
self.setupEditor()
add_close_shortcut(self)
self._load_new_note()
self.setupButtons()
self.history: list[NoteId] = []
self._last_added_note: Note | None = None
gui_hooks.operation_did_execute.append(self.on_operation_did_execute)
restoreGeom(self, "add")
gui_hooks.add_cards_did_init(self)
if not is_mac:
self.setMenuBar(None)
self.show()
def set_deck(self, deck_id: DeckId) -> None:
self.deck_chooser.selected_deck_id = deck_id
def set_note_type(self, note_type_id: NotetypeId) -> None:
self.notetype_chooser.selected_notetype_id = note_type_id
def set_note(self, note: Note, deck_id: DeckId | None = None) -> None:
"""Set tags, field contents and notetype according to `note`. Deck is set
to `deck_id` or the deck last used with the notetype.
"""
self.notetype_chooser.selected_notetype_id = note.mid
if deck_id or (deck_id := self.col.default_deck_for_notetype(note.mid)):
self.deck_chooser.selected_deck_id = deck_id
new_note = self._new_note()
new_note.fields = note.fields[:]
new_note.tags = note.tags[:]
self.editor.orig_note_id = note.id
self.setAndFocusNote(new_note)
def setupEditor(self) -> None:
self.editor = aqt.editor.Editor(
self.mw,
self.form.fieldsArea,
self,
editor_mode=aqt.editor.EditorMode.ADD_CARDS,
)
def setup_choosers(self) -> None:
defaults = self.col.defaults_for_adding(
current_review_card=self.mw.reviewer.card
)
self.notetype_chooser = NotetypeChooser(
mw=self.mw,
widget=self.form.modelArea,
starting_notetype_id=NotetypeId(defaults.notetype_id),
on_button_activated=self.show_notetype_selector,
on_notetype_changed=self.on_notetype_change,
)
self.deck_chooser = DeckChooser(
self.mw,
self.form.deckArea,
starting_deck_id=DeckId(defaults.deck_id),
on_deck_changed=self.on_deck_changed,
)
def reopen(self, mw: AnkiQt) -> None:
if not self.editor.fieldsAreBlank():
return
defaults = self.col.defaults_for_adding(
current_review_card=self.mw.reviewer.card
)
self.set_note_type(NotetypeId(defaults.notetype_id))
self.set_deck(DeckId(defaults.deck_id))
def helpRequested(self) -> None:
openHelp(HelpPage.ADDING_CARD_AND_NOTE)
def setupButtons(self) -> None:
bb = self.form.buttonBox
ar = QDialogButtonBox.ButtonRole.ActionRole
# add
self.addButton = bb.addButton(tr.actions_add(), ar)
qconnect(self.addButton.clicked, self.add_current_note)
self.addButton.setShortcut(QKeySequence("Ctrl+Return"))
# qt5.14+ doesn't handle numpad enter on Windows
self.compat_add_shorcut = QShortcut(QKeySequence("Ctrl+Enter"), self)
qconnect(self.compat_add_shorcut.activated, self.addButton.click)
self.addButton.setToolTip(shortcut(tr.adding_add_shortcut_ctrlandenter()))
# close
self.closeButton = QPushButton(tr.actions_close())
self.closeButton.setAutoDefault(False)
bb.addButton(self.closeButton, QDialogButtonBox.ButtonRole.RejectRole)
qconnect(self.closeButton.clicked, self.close)
# help
self.helpButton = QPushButton(tr.actions_help(), clicked=self.helpRequested) # type: ignore
self.helpButton.setAutoDefault(False)
bb.addButton(self.helpButton, QDialogButtonBox.ButtonRole.HelpRole)
# history
b = bb.addButton(f"{tr.adding_history()} {downArrow()}", ar)
if is_mac:
sc = "Ctrl+Shift+H"
else:
sc = "Ctrl+H"
b.setShortcut(QKeySequence(sc))
b.setToolTip(tr.adding_shortcut(val=shortcut(sc)))
qconnect(b.clicked, self.onHistory)
b.setEnabled(False)
self.historyButton = b
def setAndFocusNote(self, note: Note) -> None:
self.editor.set_note(note, focusTo=0)
def show_notetype_selector(self) -> None:
self.editor.call_after_note_saved(self.notetype_chooser.choose_notetype)
def on_deck_changed(self, deck_id: int) -> None:
gui_hooks.add_cards_did_change_deck(deck_id)
def on_notetype_change(
self, notetype_id: NotetypeId, update_deck: bool = True
) -> None:
# need to adjust current deck?
if update_deck:
if deck_id := self.col.default_deck_for_notetype(notetype_id):
self.deck_chooser.selected_deck_id = deck_id
# only used for detecting changed sticky fields on close
self._last_added_note = None
# copy fields into new note with the new notetype
old_note = self.editor.note
new_note = self._new_note()
if old_note:
old_field_names = list(old_note.keys())
new_field_names = list(new_note.keys())
copied_field_names = set()
for f in new_note.note_type()["flds"]:
field_name = f["name"]
# copy identical non-empty fields
if field_name in old_field_names and old_note[field_name]:
new_note[field_name] = old_note[field_name]
copied_field_names.add(field_name)
new_idx = 0
for old_idx, old_field_value in enumerate(old_field_names):
# skip previously copied identical fields in new note
while (
new_idx < len(new_field_names)
and new_field_names[new_idx] in copied_field_names
):
new_idx += 1
if new_idx >= len(new_field_names):
break
# copy non-empty old fields
if (
old_field_value not in copied_field_names
and old_note.fields[old_idx]
):
new_note.fields[new_idx] = old_note.fields[old_idx]
new_idx += 1
new_note.tags = old_note.tags
# and update editor state
self.editor.note = new_note
self.editor.loadNote(
focusTo=min(self.editor.last_field_index or 0, len(new_note.fields) - 1)
)
gui_hooks.addcards_did_change_note_type(
self, old_note.note_type(), new_note.note_type()
)
def _load_new_note(self, sticky_fields_from: Note | None = None) -> None:
note = self._new_note()
if old_note := sticky_fields_from:
flds = note.note_type()["flds"]
# copy fields from old note
if old_note:
for n in range(min(len(note.fields), len(old_note.fields))):
if flds[n]["sticky"]:
note.fields[n] = old_note.fields[n]
# and tags
note.tags = old_note.tags
self.setAndFocusNote(note)
def on_operation_did_execute(
self, changes: OpChanges, handler: object | None
) -> None:
if (changes.notetype or changes.deck) and handler is not self.editor:
self.on_notetype_change(
NotetypeId(
self.col.defaults_for_adding(
current_review_card=self.mw.reviewer.card
).notetype_id
),
update_deck=False,
)
def _new_note(self) -> Note:
return self.col.new_note(
self.col.models.get(self.notetype_chooser.selected_notetype_id)
)
def addHistory(self, note: Note) -> None:
self.history.insert(0, note.id)
self.history = self.history[:15]
self.historyButton.setEnabled(True)
def onHistory(self) -> None:
m = QMenu(self)
for nid in self.history:
if self.col.find_notes(self.col.build_search_string(SearchNode(nid=nid))):
note = self.col.get_note(nid)
fields = note.fields
txt = html_to_text_line(", ".join(fields))
if len(txt) > 30:
txt = f"{txt[:30]}..."
line = tr.adding_edit(val=txt)
line = gui_hooks.addcards_will_add_history_entry(line, note)
line = line.replace("&", "&&")
# In qt action "&i" means "underline i, trigger this line when i is pressed".
# except for "&&" which is replaced by a single "&"
a = m.addAction(line)
qconnect(a.triggered, lambda b, nid=nid: self.editHistory(nid))
else:
a = m.addAction(tr.adding_note_deleted())
a.setEnabled(False)
gui_hooks.add_cards_will_show_history_menu(self, m)
m.exec(self.historyButton.mapToGlobal(QPoint(0, 0)))
def editHistory(self, nid: NoteId) -> None:
aqt.dialogs.open("Browser", self.mw, search=(SearchNode(nid=nid),))
def add_current_note(self) -> None:
if self.editor.current_notetype_is_image_occlusion():
self.editor.update_occlusions_field()
self.editor.call_after_note_saved(self._add_current_note)
self.editor.reset_image_occlusion()
else:
self.editor.call_after_note_saved(self._add_current_note)
def _add_current_note(self) -> None:
note = self.editor.note
if not self._note_can_be_added(note):
return
target_deck_id = self.deck_chooser.selected_deck_id
def on_success(changes: OpChangesWithCount) -> None:
# only used for detecting changed sticky fields on close
self._last_added_note = note
self.addHistory(note)
tooltip(tr.importing_cards_added(count=changes.count), period=500)
av_player.stop_and_clear_queue()
self._load_new_note(sticky_fields_from=note)
gui_hooks.add_cards_did_add_note(note)
add_note(parent=self, note=note, target_deck_id=target_deck_id).success(
on_success
).run_in_background()
def _note_can_be_added(self, note: Note) -> bool:
result = note.fields_check()
# no problem, duplicate, and confirmed cloze cases
problem = None
if result == NoteFieldsCheckResult.EMPTY:
if self.editor.current_notetype_is_image_occlusion():
problem = tr.notetypes_no_occlusion_created2()
else:
problem = tr.adding_the_first_field_is_empty()
elif result == NoteFieldsCheckResult.MISSING_CLOZE:
if not askUser(tr.adding_you_have_a_cloze_deletion_note()):
return False
elif result == NoteFieldsCheckResult.NOTETYPE_NOT_CLOZE:
problem = tr.adding_cloze_outside_cloze_notetype()
elif result == NoteFieldsCheckResult.FIELD_NOT_CLOZE:
problem = tr.adding_cloze_outside_cloze_field()
# filter problem through add-ons
problem = gui_hooks.add_cards_will_add_note(problem, note)
if problem is not None:
showWarning(problem, help=HelpPage.ADDING_CARD_AND_NOTE)
return False
optional_problems: list[str] = []
gui_hooks.add_cards_might_add_note(optional_problems, note)
if not all(askUser(op) for op in optional_problems):
return False
return True
def keyPressEvent(self, evt: QKeyEvent) -> None:
if evt.key() == Qt.Key.Key_Escape:
self.close()
else:
super().keyPressEvent(evt)
def closeEvent(self, evt: QCloseEvent) -> None:
if self._close_event_has_cleaned_up:
evt.accept()
return
self.ifCanClose(self._close)
evt.ignore()
def _close(self) -> None:
self.editor.cleanup()
self.notetype_chooser.cleanup()
self.deck_chooser.cleanup()
gui_hooks.operation_did_execute.remove(self.on_operation_did_execute)
self.mw.maybeReset()
saveGeom(self, "add")
aqt.dialogs.markClosed("AddCards")
self._close_event_has_cleaned_up = True
self.mw.deferred_delete_and_garbage_collect(self)
self.close()
def ifCanClose(self, onOk: Callable) -> None:
def callback(choice: int) -> None:
if choice == 0:
onOk()
def afterSave() -> None:
if self.editor.fieldsAreBlank(self._last_added_note):
return onOk()
ask_user_dialog(
tr.adding_discard_current_input(),
callback=callback,
buttons=[
QMessageBox.StandardButton.Discard,
(tr.adding_keep_editing(), QMessageBox.ButtonRole.RejectRole),
],
)
self.editor.call_after_note_saved(afterSave)
def closeWithCallback(self, cb: Callable[[], None]) -> None:
def doClose() -> None:
self._close()
cb()
self.ifCanClose(doClose)
# legacy aliases
@property
def deckChooser(self) -> DeckChooser:
if getattr(self, "form", None):
# show this warning only after Qt form has been initialized,
# or PyQt's introspection triggers it
print("deckChooser is deprecated; use deck_chooser instead")
return self.deck_chooser
addCards = add_current_note
_addCards = _add_current_note
onModelChange = on_notetype_change
@deprecated(info="obsolete")
def addNote(self, note: Note) -> None:
pass
@deprecated(info="does nothing; will go away")
def removeTempNote(self, note: Note) -> None:
pass

View file

@ -8,7 +8,7 @@ import json
import math
import re
from collections.abc import Callable, Sequence
from typing import Any, cast
from typing import Any
from markdown import markdown
@ -22,7 +22,7 @@ from anki.cards import Card, CardId
from anki.collection import Collection, Config, OpChanges, SearchNode
from anki.consts import *
from anki.decks import DeckId
from anki.errors import NotFoundError, SearchError
from anki.errors import SearchError
from anki.lang import without_unicode_isolation
from anki.models import NotetypeId
from anki.notes import NoteId
@ -30,7 +30,6 @@ from anki.scheduler.base import ScheduleCardsAsNew
from anki.tags import MARKED_TAG
from anki.utils import is_mac
from aqt import AnkiQt, gui_hooks
from aqt.editor import Editor, EditorWebView
from aqt.errors import show_exception
from aqt.exporting import ExportDialog as LegacyExportDialog
from aqt.import_export.exporting import ExportDialog
@ -80,7 +79,6 @@ from aqt.utils import (
tr,
)
from ..addcards import AddCards
from ..changenotetype import change_notetype_dialog
from .card_info import BrowserCardInfo
from .find_and_replace import FindAndReplaceDialog
@ -114,7 +112,7 @@ class MockModel:
class Browser(QMainWindow):
mw: AnkiQt
col: Collection
editor: Editor | None
editor: aqt.editor.NewEditor | None
table: Table
def __init__(
@ -192,15 +190,7 @@ class Browser(QMainWindow):
# fixme: this will leave the splitter shown, but with no current
# note being edited
assert self.editor is not None
note = self.editor.note
if note:
try:
note.load()
except NotFoundError:
self.editor.set_note(None)
return
self.editor.set_note(note)
self.editor.reload_note()
if changes.browser_table and changes.card:
self.card = self.table.get_single_selected_card()
@ -278,11 +268,10 @@ class Browser(QMainWindow):
return None
def add_card(self, deck_id: DeckId):
add_cards = cast(AddCards, aqt.dialogs.open("AddCards", self.mw))
add_cards.set_deck(deck_id)
args = [self.mw, deck_id]
if note_type_id := self.get_active_note_type_id():
add_cards.set_note_type(note_type_id)
args.append(note_type_id)
aqt.dialogs.open("NewAddCards", *args)
# If in the Browser we open Preview and press Ctrl+W there,
# both Preview and Browser windows get closed by Qt out of the box.
@ -403,7 +392,7 @@ class Browser(QMainWindow):
add_ellipsis_to_action_label(f.action_forget)
add_ellipsis_to_action_label(f.action_grade_now)
def _editor_web_view(self) -> EditorWebView:
def _editor_web_view(self) -> aqt.editor.NewEditorWebView:
assert self.editor is not None
editor_web_view = self.editor.web
assert editor_web_view is not None
@ -605,17 +594,19 @@ class Browser(QMainWindow):
def setupEditor(self) -> None:
QShortcut(QKeySequence("Ctrl+Shift+P"), self, self.onTogglePreview)
def add_preview_button(editor: Editor) -> None:
def add_preview_button(
editor: aqt.editor.Editor | aqt.editor.NewEditor,
) -> None:
editor._links["preview"] = lambda _editor: self.onTogglePreview()
gui_hooks.editor_did_init.remove(add_preview_button)
gui_hooks.editor_did_init.append(add_preview_button)
self.editor = aqt.editor.Editor(
self.editor = aqt.editor.NewEditor(
self.mw,
self.form.fieldsArea,
self,
editor_mode=aqt.editor.EditorMode.BROWSER,
)
gui_hooks.editor_did_init.remove(add_preview_button)
@ensure_editor_saved
def on_all_or_selected_rows_changed(self) -> None:
@ -819,7 +810,7 @@ class Browser(QMainWindow):
assert current_card is not None
deck_id = current_card.current_deck_id()
aqt.dialogs.open("AddCards", self.mw).set_note(note, deck_id)
aqt.dialogs.open("NewAddCards", self.mw).set_note(note, deck_id)
@no_arg_trigger
@skip_if_selection_is_empty
@ -843,7 +834,7 @@ class Browser(QMainWindow):
if self._previewer:
self._previewer.close()
elif self.editor.note:
else:
self._previewer = PreviewDialog(self, self.mw, self._on_preview_closed)
self._previewer.open()
self.toggle_preview_button_state(True)
@ -1265,7 +1256,7 @@ class Browser(QMainWindow):
def cb():
assert self.editor is not None and self.editor.web is not None
self.editor.web.setFocus()
self.editor.loadNote(focusTo=0)
self.editor.reload_note()
assert self.editor is not None
self.editor.call_after_note_saved(cb)

View file

@ -6,13 +6,13 @@ from collections.abc import Callable
import aqt.editor
from anki.collection import OpChanges
from anki.errors import NotFoundError
from aqt import gui_hooks
from aqt.editcurrent_legacy import *
from aqt.qt import *
from aqt.utils import add_close_shortcut, restoreGeom, saveGeom, tr
class EditCurrent(QMainWindow):
class NewEditCurrent(QMainWindow):
def __init__(self, mw: aqt.AnkiQt) -> None:
super().__init__(None, Qt.WindowType.Window)
self.mw = mw
@ -23,7 +23,7 @@ class EditCurrent(QMainWindow):
self.setMinimumWidth(250)
if not is_mac:
self.setMenuBar(None)
self.editor = aqt.editor.Editor(
self.editor = aqt.editor.NewEditor(
self.mw,
self.form.fieldsArea,
self,
@ -33,13 +33,7 @@ class EditCurrent(QMainWindow):
self.editor.card = self.mw.reviewer.card
self.editor.set_note(self.mw.reviewer.card.note(), focusTo=0)
restoreGeom(self, "editcurrent")
close_button = self.form.buttonBox.button(QDialogButtonBox.StandardButton.Close)
assert close_button is not None
close_button.setShortcut(QKeySequence("Ctrl+Return"))
add_close_shortcut(self)
# qt5.14+ doesn't handle numpad enter on Windows
self.compat_add_shorcut = QShortcut(QKeySequence("Ctrl+Enter"), self)
qconnect(self.compat_add_shorcut.activated, close_button.click)
gui_hooks.operation_did_execute.append(self.on_operation_did_execute)
self.show()
@ -47,24 +41,13 @@ class EditCurrent(QMainWindow):
self, changes: OpChanges, handler: object | None
) -> None:
if changes.note_text and handler is not self.editor:
# reload note
note = self.editor.note
try:
assert note is not None
note.load()
except NotFoundError:
# note's been deleted
self.cleanup()
self.close()
return
self.editor.set_note(note)
self.editor.reload_note()
def cleanup(self) -> None:
gui_hooks.operation_did_execute.remove(self.on_operation_did_execute)
self.editor.cleanup()
saveGeom(self, "editcurrent")
aqt.dialogs.markClosed("EditCurrent")
aqt.dialogs.markClosed("NewEditCurrent")
def reopen(self, mw: aqt.AnkiQt) -> None:
if card := self.mw.reviewer.card:

View file

@ -0,0 +1,93 @@
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from __future__ import annotations
from collections.abc import Callable
import aqt.editor
from anki.collection import OpChanges
from anki.errors import NotFoundError
from aqt import gui_hooks
from aqt.qt import *
from aqt.utils import add_close_shortcut, restoreGeom, saveGeom, tr
class EditCurrent(QMainWindow):
def __init__(self, mw: aqt.AnkiQt) -> None:
super().__init__(None, Qt.WindowType.Window)
self.mw = mw
self.form = aqt.forms.editcurrent.Ui_Dialog()
self.form.setupUi(self)
self.setWindowTitle(tr.editing_edit_current())
self.setMinimumHeight(400)
self.setMinimumWidth(250)
if not is_mac:
self.setMenuBar(None)
self.editor = aqt.editor.Editor(
self.mw,
self.form.fieldsArea,
self,
editor_mode=aqt.editor.EditorMode.EDIT_CURRENT,
)
assert self.mw.reviewer.card is not None
self.editor.card = self.mw.reviewer.card
self.editor.set_note(self.mw.reviewer.card.note(), focusTo=0)
restoreGeom(self, "editcurrent")
self.buttonbox = QDialogButtonBox(Qt.Orientation.Horizontal)
self.form.verticalLayout.insertWidget(1, self.buttonbox)
self.buttonbox.addButton(QDialogButtonBox.StandardButton.Close)
qconnect(self.buttonbox.rejected, self.close)
close_button = self.buttonbox.button(QDialogButtonBox.StandardButton.Close)
assert close_button is not None
close_button.setShortcut(QKeySequence("Ctrl+Return"))
add_close_shortcut(self)
# qt5.14+ doesn't handle numpad enter on Windows
self.compat_add_shorcut = QShortcut(QKeySequence("Ctrl+Enter"), self)
qconnect(self.compat_add_shorcut.activated, close_button.click)
gui_hooks.operation_did_execute.append(self.on_operation_did_execute)
self.show()
def on_operation_did_execute(
self, changes: OpChanges, handler: object | None
) -> None:
if changes.note_text and handler is not self.editor:
# reload note
note = self.editor.note
try:
assert note is not None
note.load()
except NotFoundError:
# note's been deleted
self.cleanup()
self.close()
return
self.editor.set_note(note)
def cleanup(self) -> None:
gui_hooks.operation_did_execute.remove(self.on_operation_did_execute)
self.editor.cleanup()
saveGeom(self, "editcurrent")
aqt.dialogs.markClosed("EditCurrent")
def reopen(self, mw: aqt.AnkiQt) -> None:
if card := self.mw.reviewer.card:
self.editor.card = card
self.editor.set_note(card.note())
def closeEvent(self, evt: QCloseEvent | None) -> None:
self.editor.call_after_note_saved(self.cleanup)
def _saveAndClose(self) -> None:
self.cleanup()
self.mw.deferred_delete_and_garbage_collect(self)
self.close()
def closeWithCallback(self, onsuccess: Callable[[], None]) -> None:
def callback() -> None:
self._saveAndClose()
onsuccess()
self.editor.call_after_note_saved(callback)
onReset = on_operation_did_execute

File diff suppressed because it is too large Load diff

1789
qt/aqt/editor_legacy.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -28,16 +28,6 @@
<item>
<widget class="QWidget" name="fieldsArea" native="true"/>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Close</set>
</property>
</widget>
</item>
</layout>
</widget>
<widget class="QMenuBar" name="menubar">
@ -60,22 +50,4 @@
<resources>
<include location="icons.qrc"/>
</resources>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>Dialog</receiver>
<slot>close()</slot>
<hints>
<hint type="sourcelabel">
<x>316</x>
<y>260</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>274</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View file

@ -1280,14 +1280,20 @@ title="{}" {}>{}</button>""".format(
# Other menu operations
##########################################################################
def _open_new_or_legacy_dialog(self, name: str, *args: Any, **kwargs: Any) -> None:
want_old = KeyboardModifiersPressed().shift
if not want_old:
name = f"New{name}"
aqt.dialogs.open(name, self, *args, **kwargs)
def onAddCard(self) -> None:
aqt.dialogs.open("AddCards", self)
self._open_new_or_legacy_dialog("AddCards")
def onBrowse(self) -> None:
aqt.dialogs.open("Browser", self, card=self.reviewer.card)
def onEditCurrent(self) -> None:
aqt.dialogs.open("EditCurrent", self)
self._open_new_or_legacy_dialog("EditCurrent")
def onOverview(self) -> None:
self.moveToState("overview")
@ -1296,11 +1302,7 @@ title="{}" {}>{}</button>""".format(
deck = self._selectedDeck()
if not deck:
return
want_old = KeyboardModifiersPressed().shift
if want_old:
aqt.dialogs.open("DeckStats", self)
else:
aqt.dialogs.open("NewDeckStats", self)
self._open_new_or_legacy_dialog("DeckStats", self)
def onPrefs(self) -> None:
aqt.dialogs.open("Preferences", self)

View file

@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import enum
import logging
import mimetypes
@ -16,6 +17,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from errno import EPROTOTYPE
from http import HTTPStatus
from typing import Any, Generic, cast
import flask
import flask_cors
@ -27,18 +29,25 @@ from waitress.server import create_server
import aqt
import aqt.main
import aqt.operations
from anki import hooks
from anki import frontend_pb2, generic_pb2, hooks
from anki.collection import OpChanges, OpChangesOnly, Progress, SearchNode
from anki.decks import UpdateDeckConfigs
from anki.scheduler.v3 import SchedulingStatesWithContext, SetSchedulingStatesRequest
from anki.utils import dev_mode
from anki.utils import dev_mode, from_json_bytes, to_json_bytes
from aqt.changenotetype import ChangeNotetypeDialog
from aqt.deckoptions import DeckOptionsDialog
from aqt.operations import on_op_finished
from aqt.operations.deck import update_deck_configs as update_deck_configs_op
from aqt.progress import ProgressUpdate
from aqt.qt import *
from aqt.utils import aqt_data_path, show_warning, tr
from aqt.utils import (
aqt_data_path,
askUser,
openLink,
show_info,
show_warning,
tr,
)
# https://forums.ankiweb.net/t/anki-crash-when-using-a-specific-deck/22266
waitress.wasyncore._DISCONNECTED = waitress.wasyncore._DISCONNECTED.union({EPROTOTYPE}) # type: ignore
@ -334,6 +343,7 @@ def is_sveltekit_page(path: str) -> bool:
"import-csv",
"import-page",
"image-occlusion",
"editor",
]
@ -599,6 +609,304 @@ def deck_options_ready() -> bytes:
return b""
def editor_op_changes_request(endpoint: str) -> bytes:
output = raw_backend_request(endpoint)()
response = OpChanges()
response.ParseFromString(output)
def handle_on_main() -> None:
from aqt.editor import NewEditor
handler = aqt.mw.app.activeWindow()
if handler and isinstance(getattr(handler, "editor", None), NewEditor):
handler = handler.editor # type: ignore
on_op_finished(aqt.mw, response, handler)
aqt.mw.taskman.run_on_main(handle_on_main)
return output
def update_editor_note() -> bytes:
return editor_op_changes_request("update_notes")
def update_editor_notetype() -> bytes:
return editor_op_changes_request("update_notetype")
def add_editor_note() -> bytes:
return editor_op_changes_request("add_note")
def get_setting_json(getter: Callable[[str], Any]) -> bytes:
req = generic_pb2.String()
req.ParseFromString(request.data)
value = getter(req.val)
output = generic_pb2.Json(json=to_json_bytes(value)).SerializeToString()
return output
def set_setting_json(setter: Callable[[str, Any], Any]) -> bytes:
req = frontend_pb2.SetSettingJsonRequest()
req.ParseFromString(request.data)
setter(req.key, from_json_bytes(req.value_json))
return b""
def get_profile_config_json() -> bytes:
assert aqt.mw.pm.profile is not None
return get_setting_json(aqt.mw.pm.profile.get)
def set_profile_config_json() -> bytes:
assert aqt.mw.pm.profile is not None
return set_setting_json(aqt.mw.pm.profile.__setitem__)
def get_meta_json() -> bytes:
return get_setting_json(aqt.mw.pm.meta.get)
def set_meta_json() -> bytes:
return set_setting_json(aqt.mw.pm.meta.__setitem__)
def get_config_json() -> bytes:
try:
return get_setting_json(aqt.mw.col.conf.get_immutable)
except KeyError:
return generic_pb2.Json(json=b"null").SerializeToString()
def set_config_json() -> bytes:
return set_setting_json(aqt.mw.col.set_config)
def convert_pasted_image() -> bytes:
req = frontend_pb2.ConvertPastedImageRequest()
req.ParseFromString(request.data)
image = QImage.fromData(req.data)
buffer = QBuffer()
buffer.open(QBuffer.OpenModeFlag.ReadWrite)
if req.ext == "png":
quality = 50
else:
quality = 80
image.save(buffer, req.ext, quality)
buffer.reset()
data = bytes(cast(bytes, buffer.readAll()))
return frontend_pb2.ConvertPastedImageResponse(data=data).SerializeToString()
AsyncRequestReturnType = TypeVar("AsyncRequestReturnType")
class AsyncRequestHandler(Generic[AsyncRequestReturnType]):
def __init__(self, callback: Callable[[AsyncRequestHandler], None]) -> None:
self.callback = callback
self.loop = asyncio.get_event_loop()
self.future = self.loop.create_future()
def run(self) -> None:
aqt.mw.taskman.run_on_main(lambda: self.callback(self))
def set_result(self, result: AsyncRequestReturnType) -> None:
self.loop.call_soon_threadsafe(self.future.set_result, result)
async def get_result(self) -> AsyncRequestReturnType:
return await self.future
async def open_file_picker() -> bytes:
req = frontend_pb2.openFilePickerRequest()
req.ParseFromString(request.data)
def callback(request_handler: AsyncRequestHandler) -> None:
from aqt.utils import getFile
def cb(filename: str | None) -> None:
request_handler.set_result(filename)
window = aqt.mw.app.activeWindow()
assert window is not None
getFile(
parent=window,
title=req.title,
cb=cast(Callable[[Any], None], cb),
filter=f"{req.filter_description} ({' '.join(f'*.{ext}' for ext in req.extensions)})",
key=req.key,
)
request_handler: AsyncRequestHandler[str | None] = AsyncRequestHandler(callback)
request_handler.run()
filename = await request_handler.get_result()
return generic_pb2.String(val=filename if filename else "").SerializeToString()
def open_media() -> bytes:
from aqt.utils import openFolder
req = generic_pb2.String()
req.ParseFromString(request.data)
path = os.path.join(aqt.mw.col.media.dir(), req.val)
aqt.mw.taskman.run_on_main(lambda: openFolder(path))
return b""
def show_in_media_folder() -> bytes:
from aqt.utils import show_in_folder
req = generic_pb2.String()
req.ParseFromString(request.data)
path = os.path.join(aqt.mw.col.media.dir(), req.val)
aqt.mw.taskman.run_on_main(lambda: show_in_folder(path))
return b""
async def record_audio() -> bytes:
def callback(request_handler: AsyncRequestHandler) -> None:
from aqt.sound import record_audio
def cb(path: str | None) -> None:
request_handler.set_result(path)
window = aqt.mw.app.activeWindow()
assert window is not None
record_audio(window, aqt.mw, True, cb)
request_handler: AsyncRequestHandler[str | None] = AsyncRequestHandler(callback)
request_handler.run()
path = await request_handler.get_result()
return generic_pb2.String(val=path if path else "").SerializeToString()
def read_clipboard() -> bytes:
req = frontend_pb2.ReadClipboardRequest()
req.ParseFromString(request.data)
data = {}
clipboard = aqt.mw.app.clipboard()
assert clipboard is not None
mime_data = clipboard.mimeData(QClipboard.Mode.Clipboard)
assert mime_data is not None
for type in req.types:
data[type] = bytes(mime_data.data(type)) # type: ignore
return frontend_pb2.ReadClipboardResponse(data=data).SerializeToString()
def write_clipboard() -> bytes:
req = frontend_pb2.WriteClipboardRequest()
req.ParseFromString(request.data)
clipboard = aqt.mw.app.clipboard()
assert clipboard is not None
mime_data = clipboard.mimeData(QClipboard.Mode.Clipboard)
assert mime_data is not None
for type, data in req.data.items():
mime_data.setData(type, data)
return b""
def close_add_cards() -> bytes:
req = generic_pb2.Bool()
req.ParseFromString(request.data)
def handle_on_main() -> None:
from aqt.addcards import NewAddCards
window = aqt.mw.app.activeWindow()
if isinstance(window, NewAddCards):
window._close_if_user_wants_to_discard_changes(req.val)
aqt.mw.taskman.run_on_main(lambda: QTimer.singleShot(0, handle_on_main))
return b""
def close_edit_current() -> bytes:
def handle_on_main() -> None:
from aqt.editcurrent import NewEditCurrent
window = aqt.mw.app.activeWindow()
if isinstance(window, NewEditCurrent):
window.close()
aqt.mw.taskman.run_on_main(lambda: QTimer.singleShot(0, handle_on_main))
return b""
def open_link() -> bytes:
req = generic_pb2.String()
req.ParseFromString(request.data)
url = req.val
aqt.mw.taskman.run_on_main(lambda: openLink(url))
return b""
async def ask_user() -> bytes:
req = frontend_pb2.AskUserRequest()
req.ParseFromString(request.data)
def callback(request_handler: AsyncRequestHandler) -> None:
kwargs: dict[str, Any] = dict(text=req.text)
if req.HasField("help"):
help_arg: Any
if req.help.WhichOneof("value") == "help_page":
help_arg = req.help.help_page
else:
help_arg = req.help.help_link
kwargs["help"] = help_arg
if req.HasField("title"):
kwargs["title"] = req.title
if req.HasField("default_no"):
kwargs["defaultno"] = req.default_no
answer = askUser(**kwargs)
request_handler.set_result(answer)
request_handler: AsyncRequestHandler[bool] = AsyncRequestHandler(callback)
request_handler.run()
answer = await request_handler.get_result()
return generic_pb2.Bool(val=answer).SerializeToString()
async def show_message_box() -> bytes:
req = frontend_pb2.ShowMessageBoxRequest()
req.ParseFromString(request.data)
def callback(request_handler: AsyncRequestHandler) -> None:
kwargs: dict[str, Any] = dict(text=req.text)
if req.type == frontend_pb2.MessageBoxType.INFO:
icon = QMessageBox.Icon.Information
elif req.type == frontend_pb2.MessageBoxType.WARNING:
icon = QMessageBox.Icon.Warning
elif req.type == frontend_pb2.MessageBoxType.CRITICAL:
icon = QMessageBox.Icon.Critical
kwargs["icon"] = icon
if req.HasField("help"):
help_arg: Any
if req.help.WhichOneof("value") == "help_page":
help_arg = req.help.help_page
else:
help_arg = req.help.help_link
kwargs["help"] = help_arg
if req.HasField("title"):
kwargs["title"] = req.title
if req.HasField("text_format"):
kwargs["text_format"] = req.text_format
show_info(**kwargs)
request_handler.set_result(True)
request_handler: AsyncRequestHandler[bool] = AsyncRequestHandler(callback)
request_handler.run()
answer = await request_handler.get_result()
return generic_pb2.Bool(val=answer).SerializeToString()
post_handler_list = [
congrats_info,
get_deck_configs_for_update,
@ -614,6 +922,26 @@ post_handler_list = [
search_in_browser,
deck_options_require_close,
deck_options_ready,
update_editor_note,
update_editor_notetype,
add_editor_note,
get_profile_config_json,
set_profile_config_json,
get_meta_json,
set_meta_json,
get_config_json,
convert_pasted_image,
open_file_picker,
open_media,
show_in_media_folder,
record_audio,
read_clipboard,
write_clipboard,
close_add_cards,
close_edit_current,
open_link,
ask_user,
show_message_box,
]
@ -630,9 +958,15 @@ exposed_backend_list = [
# NotesService
"get_field_names",
"get_note",
"new_note",
"note_fields_check",
"defaults_for_adding",
"default_deck_for_notetype",
# NotetypesService
"get_notetype",
"get_notetype_names",
"get_change_notetype_info",
"get_cloze_field_ords",
# StatsService
"card_stats",
"get_review_logs",
@ -658,6 +992,21 @@ exposed_backend_list = [
# DeckConfigService
"get_ignored_before_count",
"get_retention_workload",
# CardRenderingService
"encode_iri_paths",
"decode_iri_paths",
"html_to_text_line",
# ConfigService
"set_config_json",
"get_config_bool",
# MediaService
"add_media_file",
"add_media_from_path",
"add_media_from_url",
"get_absolute_media_path",
"extract_media_files",
# CardsService
"get_card",
]
@ -686,7 +1035,25 @@ def _extract_collection_post_request(path: str) -> DynamicRequest | NotFound:
# convert bytes/None into response
def wrapped() -> Response:
try:
if data := handler():
import inspect
if inspect.iscoroutinefunction(handler):
try:
loop = asyncio.get_event_loop()
if loop.is_running():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(asyncio.run, handler())
data = future.result()
else:
data = loop.run_until_complete(handler())
except RuntimeError:
data = asyncio.run(handler())
else:
result = handler()
data = result
if data:
response = flask.make_response(data)
response.headers["Content-Type"] = "application/binary"
else:

View file

@ -1167,7 +1167,7 @@ timerStopped = false;
def on_create_copy(self) -> None:
if self.card:
aqt.dialogs.open("AddCards", self.mw).set_note(
aqt.dialogs.open("NewAddCards", self.mw).set_note(
self.card.note(), self.card.current_deck_id()
)

View file

@ -5,7 +5,7 @@ requires-python = ">=3.9"
license = "AGPL-3.0-or-later"
dependencies = [
"beautifulsoup4",
"flask",
"flask[async]",
"flask_cors",
"jsonschema",
"requests",

View file

@ -1008,12 +1008,15 @@ hooks = [
###################
Hook(
name="add_cards_will_show_history_menu",
args=["addcards: aqt.addcards.AddCards", "menu: QMenu"],
args=[
"addcards: aqt.addcards.AddCards | aqt.addcards.NewAddCards",
"menu: QMenu",
],
legacy_hook="AddCards.onHistory",
),
Hook(
name="add_cards_did_init",
args=["addcards: aqt.addcards.AddCards"],
args=["addcards: aqt.addcards.AddCards | aqt.addcards.NewAddCards"],
),
Hook(
name="add_cards_did_add_note",
@ -1068,7 +1071,7 @@ hooks = [
Hook(
name="addcards_did_change_note_type",
args=[
"addcards: aqt.addcards.AddCards",
"addcards: aqt.addcards.AddCards | aqt.addcards.NewAddCards",
"old: anki.models.NoteType",
"new: anki.models.NoteType",
],
@ -1087,20 +1090,26 @@ hooks = [
###################
Hook(
name="editor_did_init_left_buttons",
args=["buttons: list[str]", "editor: aqt.editor.Editor"],
args=["buttons: list[str]", "editor: aqt.editor.Editor | aqt.editor.NewEditor"],
),
Hook(
name="editor_did_init_buttons",
args=["buttons: list[str]", "editor: aqt.editor.Editor"],
args=["buttons: list[str]", "editor: aqt.editor.Editor | aqt.editor.NewEditor"],
),
Hook(
name="editor_did_init_shortcuts",
args=["shortcuts: list[tuple]", "editor: aqt.editor.Editor"],
args=[
"shortcuts: list[tuple]",
"editor: aqt.editor.Editor | aqt.editor.NewEditor",
],
legacy_hook="setupEditorShortcuts",
),
Hook(
name="editor_will_show_context_menu",
args=["editor_webview: aqt.editor.EditorWebView", "menu: QMenu"],
args=[
"editor_webview: aqt.editor.EditorWebView | aqt.editor.NewEditorWebView",
"menu: QMenu",
],
legacy_hook="EditorWebView.contextMenuEvent",
),
Hook(
@ -1121,7 +1130,7 @@ hooks = [
),
Hook(
name="editor_did_load_note",
args=["editor: aqt.editor.Editor"],
args=["editor: aqt.editor.Editor | aqt.editor.NewEditor"],
legacy_hook="loadNote",
),
Hook(
@ -1131,7 +1140,7 @@ hooks = [
),
Hook(
name="editor_will_munge_html",
args=["txt: str", "editor: aqt.editor.Editor"],
args=["txt: str", "editor: aqt.editor.Editor | aqt.editor.NewEditor"],
return_type="str",
doc="""Allows manipulating the text that will be saved by the editor""",
),
@ -1143,15 +1152,21 @@ hooks = [
),
Hook(
name="editor_web_view_did_init",
args=["editor_web_view: aqt.editor.EditorWebView"],
args=[
"editor_web_view: aqt.editor.EditorWebView | aqt.editor.NewEditorWebView"
],
),
Hook(
name="editor_did_init",
args=["editor: aqt.editor.Editor"],
args=["editor: aqt.editor.Editor | aqt.editor.NewEditor"],
),
Hook(
name="editor_will_load_note",
args=["js: str", "note: anki.notes.Note", "editor: aqt.editor.Editor"],
args=[
"js: str",
"note: anki.notes.Note",
"editor: aqt.editor.Editor | aqt.editor.NewEditor",
],
return_type="str",
doc="""Allows changing the javascript commands to load note before
executing it and do change in the QT editor.""",
@ -1159,7 +1174,7 @@ hooks = [
Hook(
name="editor_did_paste",
args=[
"editor: aqt.editor.Editor",
"editor: aqt.editor.Editor | aqt.editor.NewEditor",
"html: str",
"internal: bool",
"extended: bool",
@ -1170,7 +1185,7 @@ hooks = [
name="editor_will_process_mime",
args=[
"mime: QMimeData",
"editor_web_view: aqt.editor.EditorWebView",
"editor_web_view: aqt.editor.EditorWebView | aqt.editor.NewEditorWebView",
"internal: bool",
"extended: bool",
"drop_event: bool",
@ -1194,7 +1209,7 @@ hooks = [
Hook(
name="editor_state_did_change",
args=[
"editor: aqt.editor.Editor",
"editor: aqt.editor.Editor | aqt.editor.NewEditor",
"new_state: aqt.editor.EditorState",
"old_state: aqt.editor.EditorState",
],
@ -1203,7 +1218,10 @@ hooks = [
),
Hook(
name="editor_mask_editor_did_load_image",
args=["editor: aqt.editor.Editor", "path_or_nid: str | anki.notes.NoteId"],
args=[
"editor: aqt.editor.Editor | aqt.editor.NewEditor",
"path_or_nid: str | anki.notes.NoteId",
],
doc="""Called when the image occlusion mask editor has completed
loading an image.

View file

@ -0,0 +1,40 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use anki_proto::media::AddMediaFromUrlRequest;
use anki_proto::media::AddMediaFromUrlResponse;
use crate::backend::Backend;
use crate::editor::retrieve_url;
use crate::error;
impl crate::services::BackendMediaService for Backend {
fn add_media_from_url(
&self,
input: AddMediaFromUrlRequest,
) -> error::Result<AddMediaFromUrlResponse> {
let rt = self.runtime_handle();
let mut guard = self.col.lock().unwrap();
let col = guard.as_mut().unwrap();
let media = col.media()?;
let fut = async move {
let response = match retrieve_url(&input.url).await {
Ok((filename, data)) => {
media
.add_file(&filename, &data)
.map(|fname| fname.to_string())?;
AddMediaFromUrlResponse {
filename: Some(filename),
error: None,
}
}
Err(e) => AddMediaFromUrlResponse {
filename: None,
error: Some(e.message(col.tr())),
},
};
Ok(response)
};
rt.block_on(fut)
}
}

View file

@ -12,6 +12,7 @@ pub(crate) mod dbproxy;
mod error;
mod i18n;
mod import_export;
mod media;
mod ops;
mod sync;

117
rslib/src/editor.rs Normal file
View file

@ -0,0 +1,117 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use std::path::Path;
use std::time::Duration;
use percent_encoding_iri::percent_decode_str;
use reqwest::Client;
use reqwest::Url;
use crate::error::AnkiError;
use crate::error::Result;
use crate::invalid_input;
/// Download file from URL.
/// Returns (filename, file_contents) tuple.
pub async fn retrieve_url(url: &str) -> Result<(String, Vec<u8>)> {
let is_local = url.to_lowercase().starts_with("file://");
let (file_contents, content_type) = if is_local {
download_local_file(url).await?
} else {
download_remote_file(url).await?
};
let mut parsed_url = match Url::parse(url) {
Ok(url) => url,
Err(e) => invalid_input!("Invalid URL: {}", e),
};
parsed_url.set_query(None);
let mut filename = parsed_url
.path_segments()
.and_then(|mut segments| segments.next_back())
.unwrap_or("")
.to_string();
filename = match percent_decode_str(&filename).decode_utf8() {
Ok(decoded) => decoded.to_string(),
Err(e) => invalid_input!("Failed to decode filename: {}", e),
};
if filename.trim().is_empty() {
filename = "paste".to_string();
}
if let Some(mime_type) = content_type {
filename = add_extension_based_on_mime(&filename, &mime_type);
}
Ok((filename.to_string(), file_contents))
}
async fn download_local_file(url: &str) -> Result<(Vec<u8>, Option<String>)> {
let decoded_url = match percent_decode_str(url).decode_utf8() {
Ok(url) => url,
Err(e) => invalid_input!("Failed to decode file URL: {}", e),
};
let parsed_url = match Url::parse(&decoded_url) {
Ok(url) => url,
Err(e) => invalid_input!("Invalid file URL: {}", e),
};
let file_path = match parsed_url.to_file_path() {
Ok(path) => path,
Err(_) => invalid_input!("Invalid file path in URL"),
};
let file_contents = std::fs::read(&file_path).map_err(|e| AnkiError::FileIoError {
source: anki_io::FileIoError {
path: file_path.clone(),
op: anki_io::FileOp::Read,
source: e,
},
})?;
Ok((file_contents, None))
}
async fn download_remote_file(url: &str) -> Result<(Vec<u8>, Option<String>)> {
let client = Client::builder()
.timeout(Duration::from_secs(30))
.user_agent("Mozilla/5.0 (compatible; Anki)")
.build()?;
let response = client.get(url).send().await?.error_for_status()?;
let content_type = response
.headers()
.get("content-type")
.and_then(|ct| ct.to_str().ok())
.map(|s| s.to_string());
let file_contents = response.bytes().await?.to_vec();
Ok((file_contents, content_type))
}
fn add_extension_based_on_mime(filename: &str, content_type: &str) -> String {
let mut extension = "";
if Path::new(filename).extension().is_none() {
extension = match content_type {
"audio/mpeg" => ".mp3",
"audio/ogg" => ".oga",
"audio/opus" => ".opus",
"audio/wav" => ".wav",
"audio/webm" => ".weba",
"audio/aac" => ".aac",
"image/jpeg" => ".jpg",
"image/png" => ".png",
"image/svg+xml" => ".svg",
"image/webp" => ".webp",
"image/avif" => ".avif",
_ => "",
};
};
filename.to_string() + extension
}

View file

@ -16,6 +16,7 @@ pub mod config;
pub mod dbcheck;
pub mod deckconfig;
pub mod decks;
pub mod editor;
pub mod error;
pub mod findreplace;
pub mod i18n;

View file

@ -1,9 +1,11 @@
use std::collections::HashSet;
use std::path::Path;
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use anki_proto::generic;
use anki_proto::media::AddMediaFileRequest;
use anki_proto::media::AddMediaFromPathRequest;
use anki_proto::media::CheckMediaResponse;
use anki_proto::media::TrashMediaFilesRequest;
@ -12,6 +14,7 @@ use crate::error;
use crate::error::OrNotFound;
use crate::notes::service::to_i64s;
use crate::notetype::NotetypeId;
use crate::text::extract_media_refs;
impl crate::services::MediaService for Collection {
fn check_media(&mut self) -> error::Result<CheckMediaResponse> {
@ -40,6 +43,19 @@ impl crate::services::MediaService for Collection {
.into())
}
fn add_media_from_path(
&mut self,
input: AddMediaFromPathRequest,
) -> error::Result<generic::String> {
let base_name = Path::new(&input.path)
.file_name()
.unwrap_or_default()
.to_str()
.unwrap_or_default();
let data = std::fs::read(&input.path)?;
Ok(self.media()?.add_file(base_name, &data)?.to_string().into())
}
fn trash_media_files(&mut self, input: TrashMediaFilesRequest) -> error::Result<()> {
self.media()?.remove_files(&input.fnames)
}
@ -66,4 +82,28 @@ impl crate::services::MediaService for Collection {
Ok(files.into_iter().collect::<Vec<_>>().into())
}
fn extract_media_files(
&mut self,
html: anki_proto::generic::String,
) -> error::Result<generic::StringList> {
let files = extract_media_refs(&html.val)
.iter()
.map(|r| r.fname_decoded.to_string())
.collect::<Vec<_>>();
Ok(files.into())
}
fn get_absolute_media_path(
&mut self,
path: anki_proto::generic::String,
) -> error::Result<generic::String> {
Ok(self
.media()?
.media_folder
.join(path.val)
.to_string_lossy()
.to_string()
.into())
}
}

View file

@ -1,28 +0,0 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import ButtonGroupItem from "$lib/components/ButtonGroupItem.svelte";
import type { NoteEditorAPI } from "./NoteEditor.svelte";
import NoteEditor from "./NoteEditor.svelte";
import PreviewButton from "./PreviewButton.svelte";
const api: Partial<NoteEditorAPI> = {};
let noteEditor: NoteEditor;
export let uiResolve: (api: NoteEditorAPI) => void;
$: if (noteEditor) {
uiResolve(api as NoteEditorAPI);
}
</script>
<NoteEditor bind:this={noteEditor} {api}>
<svelte:fragment slot="notetypeButtons">
<ButtonGroupItem>
<PreviewButton />
</ButtonGroupItem>
</svelte:fragment>
</NoteEditor>

View file

@ -1,25 +0,0 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import * as tr from "@generated/ftl";
import { bridgeCommand } from "@tslib/bridgecommand";
</script>
<span class="duplicate-link-container">
<a class="duplicate-link" href="/#" on:click={() => bridgeCommand("dupes")}>
{tr.editingShowDuplicates()}
</a>
</span>
<style lang="scss">
.duplicate-link-container {
text-align: center;
flex-grow: 1;
}
.duplicate-link {
color: var(--highlight-color);
}
</style>

View file

@ -1,51 +0,0 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { bridgeCommand } from "@tslib/bridgecommand";
import { registerShortcut } from "@tslib/shortcuts";
import { onDestroy, onMount } from "svelte";
import type { NoteEditorAPI } from "./NoteEditor.svelte";
import NoteEditor from "./NoteEditor.svelte";
import StickyBadge from "./StickyBadge.svelte";
const api: Partial<NoteEditorAPI> = {};
let noteEditor: NoteEditor;
export let uiResolve: (api: NoteEditorAPI) => void;
$: if (noteEditor) {
uiResolve(api as NoteEditorAPI);
}
let stickies: boolean[] = [];
function setSticky(stckies: boolean[]): void {
stickies = stckies;
}
function toggleStickyAll(): void {
bridgeCommand("toggleStickyAll", (values: boolean[]) => (stickies = values));
}
let deregisterSticky: () => void;
export function activateStickyShortcuts() {
deregisterSticky = registerShortcut(toggleStickyAll, "Shift+F9");
}
onMount(() => {
Object.assign(globalThis, {
setSticky,
});
});
onDestroy(() => deregisterSticky);
</script>
<NoteEditor bind:this={noteEditor} {api}>
<svelte:fragment slot="field-state" let:index let:show>
<StickyBadge bind:active={stickies[index]} {index} {show} />
</svelte:fragment>
</NoteEditor>

View file

@ -1,847 +0,0 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script context="module" lang="ts">
import type { Writable } from "svelte/store";
import Collapsible from "$lib/components/Collapsible.svelte";
import type { EditingInputAPI } from "./EditingArea.svelte";
import type { EditorToolbarAPI } from "./editor-toolbar";
import type { EditorFieldAPI } from "./EditorField.svelte";
import FieldState from "./FieldState.svelte";
import LabelContainer from "./LabelContainer.svelte";
import LabelName from "./LabelName.svelte";
export interface NoteEditorAPI {
fields: EditorFieldAPI[];
hoveredField: Writable<EditorFieldAPI | null>;
focusedField: Writable<EditorFieldAPI | null>;
focusedInput: Writable<EditingInputAPI | null>;
toolbar: EditorToolbarAPI;
}
import { registerPackage } from "@tslib/runtime-require";
import contextProperty from "$lib/sveltelib/context-property";
import lifecycleHooks from "$lib/sveltelib/lifecycle-hooks";
const key = Symbol("noteEditor");
const [context, setContextProperty] = contextProperty<NoteEditorAPI>(key);
const [lifecycle, instances, setupLifecycleHooks] = lifecycleHooks<NoteEditorAPI>();
export { context };
registerPackage("anki/NoteEditor", {
context,
lifecycle,
instances,
});
</script>
<script lang="ts">
import * as tr from "@generated/ftl";
import { bridgeCommand } from "@tslib/bridgecommand";
import { onMount, tick } from "svelte";
import { get, writable } from "svelte/store";
import { nodeIsCommonElement } from "@tslib/dom";
import Absolute from "$lib/components/Absolute.svelte";
import Badge from "$lib/components/Badge.svelte";
import Icon from "$lib/components/Icon.svelte";
import { alertIcon } from "$lib/components/icons";
import { TagEditor } from "$lib/tag-editor";
import { commitTagEdits } from "$lib/tag-editor/TagInput.svelte";
import {
type ImageLoadedEvent,
resetIOImage,
} from "../routes/image-occlusion/mask-editor";
import { ChangeTimer } from "../editable/change-timer";
import { clearableArray } from "./destroyable";
import DuplicateLink from "./DuplicateLink.svelte";
import EditorToolbar from "./editor-toolbar";
import type { FieldData } from "./EditorField.svelte";
import EditorField from "./EditorField.svelte";
import Fields from "./Fields.svelte";
import ImageOverlay from "./image-overlay";
import { shrinkImagesByDefault } from "./image-overlay/ImageOverlay.svelte";
import MathjaxOverlay from "./mathjax-overlay";
import { closeMathjaxEditor } from "./mathjax-overlay/MathjaxEditor.svelte";
import Notification from "./Notification.svelte";
import PlainTextInput from "./plain-text-input";
import { closeHTMLTags } from "./plain-text-input/PlainTextInput.svelte";
import PlainTextBadge from "./PlainTextBadge.svelte";
import RichTextInput, { editingInputIsRichText } from "./rich-text-input";
import RichTextBadge from "./RichTextBadge.svelte";
import type { NotetypeIdAndModTime, SessionOptions } from "./types";
import { EditorState } from "./types";
function quoteFontFamily(fontFamily: string): string {
// generic families (e.g. sans-serif) must not be quoted
if (!/^[-a-z]+$/.test(fontFamily)) {
fontFamily = `"${fontFamily}"`;
}
return fontFamily;
}
const size = 1.6;
const wrap = true;
const sessionOptions: SessionOptions = {};
export function saveSession(): void {
if (notetypeMeta) {
sessionOptions[notetypeMeta.id] = {
fieldsCollapsed,
fieldStates: {
richTextsHidden,
plainTextsHidden,
plainTextDefaults,
},
modTimeOfNotetype: notetypeMeta.modTime,
};
}
}
const fieldStores: Writable<string>[] = [];
let fieldNames: string[] = [];
export function setFields(fs: [string, string][]): void {
// this is a bit of a mess -- when moving to Rust calls, we should make
// sure to have two backend endpoints for:
// * the note, which can be set through this view
// * the fieldname, font, etc., which cannot be set
const newFieldNames: string[] = [];
for (const [index, [fieldName]] of fs.entries()) {
newFieldNames[index] = fieldName;
}
for (let i = fieldStores.length; i < newFieldNames.length; i++) {
const newStore = writable("");
fieldStores[i] = newStore;
newStore.subscribe((value) => updateField(i, value));
}
for (
let i = fieldStores.length;
i > newFieldNames.length;
i = fieldStores.length
) {
fieldStores.pop();
}
for (const [index, [, fieldContent]] of fs.entries()) {
fieldStores[index].set(fieldContent);
}
fieldNames = newFieldNames;
}
let fieldsCollapsed: boolean[] = [];
export function setCollapsed(defaultCollapsed: boolean[]): void {
fieldsCollapsed =
sessionOptions[notetypeMeta?.id]?.fieldsCollapsed ?? defaultCollapsed;
}
let clozeFields: boolean[] = [];
export function setClozeFields(defaultClozeFields: boolean[]): void {
clozeFields = defaultClozeFields;
}
let richTextsHidden: boolean[] = [];
let plainTextsHidden: boolean[] = [];
let plainTextDefaults: boolean[] = [];
export function setPlainTexts(defaultPlainTexts: boolean[]): void {
const states = sessionOptions[notetypeMeta?.id]?.fieldStates;
if (states) {
richTextsHidden = states.richTextsHidden;
plainTextsHidden = states.plainTextsHidden;
plainTextDefaults = states.plainTextDefaults;
} else {
plainTextDefaults = defaultPlainTexts;
richTextsHidden = [...defaultPlainTexts];
plainTextsHidden = Array.from(defaultPlainTexts, (v) => !v);
}
}
export function triggerChanges(): void {
// I know this looks quite weird and doesn't seem to do anything
// but if we don't call this after setPlainTexts() and setCollapsed()
// when switching notetypes, existing collapsibles won't react
// automatically to the updated props
tick().then(() => {
fieldsCollapsed = fieldsCollapsed;
plainTextDefaults = plainTextDefaults;
richTextsHidden = richTextsHidden;
plainTextsHidden = plainTextsHidden;
});
}
function setMathjaxEnabled(enabled: boolean): void {
mathjaxConfig.enabled = enabled;
}
let fieldDescriptions: string[] = [];
export function setDescriptions(descriptions: string[]): void {
fieldDescriptions = descriptions.map((d) =>
d.replace(/\\/g, "").replace(/"/g, '\\"'),
);
}
let fonts: [string, number, boolean][] = [];
const fields = clearableArray<EditorFieldAPI>();
export function setFonts(fs: [string, number, boolean][]): void {
fonts = fs;
}
export function focusField(index: number | null): void {
tick().then(() => {
if (typeof index === "number") {
if (!(index in fields)) {
return;
}
fields[index].editingArea?.refocus();
} else {
$focusedInput?.refocus();
}
});
}
const tags = writable<string[]>([]);
export function setTags(ts: string[]): void {
$tags = ts;
}
const tagsCollapsed = writable<boolean>();
export function setTagsCollapsed(collapsed: boolean): void {
$tagsCollapsed = collapsed;
}
function updateTagsCollapsed(collapsed: boolean) {
$tagsCollapsed = collapsed;
bridgeCommand(`setTagsCollapsed:${$tagsCollapsed}`);
}
let noteId: number | null = null;
export function setNoteId(ntid: number): void {
// TODO this is a hack, because it requires the NoteEditor to know implementation details of the PlainTextInput.
// It should be refactored once we work on our own Undo stack
for (const pi of plainTextInputs) {
pi.api.codeMirror.editor.then((editor) => editor.clearHistory());
}
noteId = ntid;
}
let notetypeMeta: NotetypeIdAndModTime;
function setNotetypeMeta({ id, modTime }: NotetypeIdAndModTime): void {
notetypeMeta = { id, modTime };
// Discard the saved state of the fields if the notetype has been modified.
if (sessionOptions[id]?.modTimeOfNotetype !== modTime) {
delete sessionOptions[id];
}
if (isImageOcclusion) {
getImageOcclusionFields({
notetypeId: BigInt(notetypeMeta.id),
}).then((r) => (ioFields = r.fields!));
}
}
function getNoteId(): number | null {
return noteId;
}
let isImageOcclusion = false;
function setIsImageOcclusion(val: boolean) {
isImageOcclusion = val;
$ioMaskEditorVisible = val;
}
let cols: ("dupe" | "")[] = [];
export function setBackgrounds(cls: ("dupe" | "")[]): void {
cols = cls;
}
let hint: string = "";
export function setClozeHint(hnt: string): void {
hint = hnt;
}
$: fieldsData = fieldNames.map((name, index) => ({
name,
plainText: plainTextDefaults[index],
description: fieldDescriptions[index],
fontFamily: quoteFontFamily(fonts[index][0]),
fontSize: fonts[index][1],
direction: fonts[index][2] ? "rtl" : "ltr",
collapsed: fieldsCollapsed[index],
hidden: hideFieldInOcclusionType(index, ioFields),
isClozeField: clozeFields[index],
})) as FieldData[];
let lastSavedTags: string[] | null = null;
function saveTags({ detail }: CustomEvent): void {
tagAmount = detail.tags.filter((tag: string) => tag != "").length;
lastSavedTags = detail.tags;
bridgeCommand(`saveTags:${JSON.stringify(detail.tags)}`);
}
const fieldSave = new ChangeTimer();
function transformContentBeforeSave(content: string): string {
return content.replace(/ data-editor-shrink="(true|false)"/g, "");
}
function updateField(index: number, content: string): void {
fieldSave.schedule(
() =>
bridgeCommand(
`key:${index}:${getNoteId()}:${transformContentBeforeSave(
content,
)}`,
),
600,
);
}
function saveFieldNow(): void {
/* this will always be a key save */
fieldSave.fireImmediately();
}
function saveNow(): void {
closeMathjaxEditor?.();
$commitTagEdits();
saveFieldNow();
}
export function saveOnPageHide() {
if (document.visibilityState === "hidden") {
// will fire on session close and minimize
saveFieldNow();
}
}
export function focusIfField(x: number, y: number): boolean {
const elements = document.elementsFromPoint(x, y);
const first = elements[0].closest(".field-container");
if (!first || !nodeIsCommonElement(first)) {
return false;
}
const index = parseInt(first.dataset?.index ?? "");
if (Number.isNaN(index) || !fields[index] || fieldsCollapsed[index]) {
return false;
}
if (richTextsHidden[index]) {
toggleRichTextInput(index);
} else {
richTextInputs[index].api.refocus();
}
return true;
}
let richTextInputs: RichTextInput[] = [];
$: richTextInputs = richTextInputs.filter(Boolean);
let plainTextInputs: PlainTextInput[] = [];
$: plainTextInputs = plainTextInputs.filter(Boolean);
function toggleRichTextInput(index: number): void {
const hidden = !richTextsHidden[index];
richTextInputs[index].focusFlag.setFlag(!hidden);
richTextsHidden[index] = hidden;
if (hidden) {
plainTextInputs[index].api.refocus();
}
}
function togglePlainTextInput(index: number): void {
const hidden = !plainTextsHidden[index];
plainTextInputs[index].focusFlag.setFlag(!hidden);
plainTextsHidden[index] = hidden;
if (hidden) {
richTextInputs[index].api.refocus();
}
}
function toggleField(index: number): void {
const collapsed = !fieldsCollapsed[index];
fieldsCollapsed[index] = collapsed;
const defaultInput = !plainTextDefaults[index]
? richTextInputs[index]
: plainTextInputs[index];
if (!collapsed) {
defaultInput.api.refocus();
} else if (!plainTextDefaults[index]) {
plainTextsHidden[index] = true;
} else {
richTextsHidden[index] = true;
}
}
const toolbar: Partial<EditorToolbarAPI> = {};
function setShrinkImages(shrinkByDefault: boolean) {
$shrinkImagesByDefault = shrinkByDefault;
}
function setCloseHTMLTags(closeTags: boolean) {
$closeHTMLTags = closeTags;
}
/**
* Enable/Disable add-on buttons that do not have the `perm` class
*/
function setAddonButtonsDisabled(disabled: boolean): void {
document
.querySelectorAll<HTMLButtonElement>("button.linkb:not(.perm)")
.forEach((button) => {
button.disabled = disabled;
});
}
import { ImageOcclusionFieldIndexes } from "@generated/anki/image_occlusion_pb";
import { getImageOcclusionFields } from "@generated/backend";
import { wrapInternal } from "@tslib/wrap";
import Shortcut from "$lib/components/Shortcut.svelte";
import { mathjaxConfig } from "../editable/mathjax-element.svelte";
import ImageOcclusionPage from "../routes/image-occlusion/ImageOcclusionPage.svelte";
import ImageOcclusionPicker from "../routes/image-occlusion/ImageOcclusionPicker.svelte";
import type { IOMode } from "../routes/image-occlusion/lib";
import { exportShapesToClozeDeletions } from "../routes/image-occlusion/shapes/to-cloze";
import {
hideAllGuessOne,
ioImageLoadedStore,
ioMaskEditorVisible,
} from "../routes/image-occlusion/store";
import CollapseLabel from "./CollapseLabel.svelte";
import * as oldEditorAdapter from "./old-editor-adapter";
$: isIOImageLoaded = false;
$: ioImageLoadedStore.set(isIOImageLoaded);
let imageOcclusionMode: IOMode | undefined;
let ioFields = new ImageOcclusionFieldIndexes({});
function pickIOImage() {
imageOcclusionMode = undefined;
bridgeCommand("addImageForOcclusion");
}
function pickIOImageFromClipboard() {
imageOcclusionMode = undefined;
bridgeCommand("addImageForOcclusionFromClipboard");
}
async function setupMaskEditor(options: { html: string; mode: IOMode }) {
imageOcclusionMode = undefined;
await tick();
imageOcclusionMode = options.mode;
if (options.mode.kind === "add" && !("clonedNoteId" in options.mode)) {
fieldStores[ioFields.image].set(options.html);
// the image field is set programmatically and does not need debouncing
// commit immediately to avoid a race condition with the occlusions field
saveFieldNow();
// new image is being added
if (isIOImageLoaded) {
resetIOImage(options.mode.imagePath, (event: ImageLoadedEvent) =>
onImageLoaded(
new CustomEvent("image-loaded", {
detail: event,
}),
),
);
}
}
isIOImageLoaded = true;
}
function setImageField(html) {
fieldStores[ioFields.image].set(html);
}
globalThis.setImageField = setImageField;
function saveOcclusions(): void {
if (isImageOcclusion && globalThis.canvas) {
const occlusionsData = exportShapesToClozeDeletions($hideAllGuessOne);
fieldStores[ioFields.occlusions].set(occlusionsData.clozes);
}
}
// reset for new occlusion in add mode
function resetIOImageLoaded() {
isIOImageLoaded = false;
globalThis.canvas.clear();
globalThis.canvas = undefined;
if (imageOcclusionMode?.kind === "add") {
// canvas.clear indirectly calls saveOcclusions
saveFieldNow();
fieldStores[ioFields.image].set("");
}
const page = document.querySelector(".image-occlusion");
if (page) {
page.remove();
}
}
globalThis.resetIOImageLoaded = resetIOImageLoaded;
/** hide occlusions and image */
function hideFieldInOcclusionType(
index: number,
ioFields: ImageOcclusionFieldIndexes,
) {
if (isImageOcclusion) {
if (index === ioFields.occlusions || index === ioFields.image) {
return true;
}
}
return false;
}
// Signal image occlusion image loading to Python
function onImageLoaded(event: CustomEvent<ImageLoadedEvent>) {
const detail = event.detail;
bridgeCommand(
`ioImageLoaded:${JSON.stringify(detail.path || detail.noteId?.toString())}`,
);
}
// Signal editor UI state changes to add-ons
let editorState: EditorState = EditorState.Initial;
let lastEditorState: EditorState = editorState;
function getEditorState(
ioMaskEditorVisible: boolean,
isImageOcclusion: boolean,
isIOImageLoaded: boolean,
imageOcclusionMode: IOMode | undefined,
): EditorState {
if (isImageOcclusion && ioMaskEditorVisible && !isIOImageLoaded) {
return EditorState.ImageOcclusionPicker;
} else if (imageOcclusionMode && ioMaskEditorVisible) {
return EditorState.ImageOcclusionMasks;
} else if (!ioMaskEditorVisible && isImageOcclusion) {
return EditorState.ImageOcclusionFields;
}
return EditorState.Fields;
}
function signalEditorState(newState: EditorState) {
tick().then(() => {
globalThis.editorState = newState;
bridgeCommand(`editorState:${newState}:${lastEditorState}`);
lastEditorState = newState;
});
}
$: signalEditorState(editorState);
$: editorState = getEditorState(
$ioMaskEditorVisible,
isImageOcclusion,
isIOImageLoaded,
imageOcclusionMode,
);
$: if (isImageOcclusion && $ioMaskEditorVisible && lastSavedTags) {
setTags(lastSavedTags);
lastSavedTags = null;
}
onMount(() => {
function wrap(before: string, after: string): void {
if (!$focusedInput || !editingInputIsRichText($focusedInput)) {
return;
}
$focusedInput.element.then((element) => {
wrapInternal(element, before, after, false);
});
}
Object.assign(globalThis, {
saveSession,
setFields,
setCollapsed,
setClozeFields,
setPlainTexts,
setDescriptions,
setFonts,
focusField,
setTags,
setTagsCollapsed,
setBackgrounds,
setClozeHint,
saveNow,
focusIfField,
getNoteId,
setNoteId,
setNotetypeMeta,
wrap,
setMathjaxEnabled,
setShrinkImages,
setCloseHTMLTags,
triggerChanges,
setIsImageOcclusion,
setupMaskEditor,
saveOcclusions,
...oldEditorAdapter,
});
editorState = getEditorState(
$ioMaskEditorVisible,
isImageOcclusion,
isIOImageLoaded,
imageOcclusionMode,
);
document.addEventListener("visibilitychange", saveOnPageHide);
return () => document.removeEventListener("visibilitychange", saveOnPageHide);
});
let apiPartial: Partial<NoteEditorAPI> = {};
export { apiPartial as api };
const hoveredField: NoteEditorAPI["hoveredField"] = writable(null);
const focusedField: NoteEditorAPI["focusedField"] = writable(null);
const focusedInput: NoteEditorAPI["focusedInput"] = writable(null);
const api: NoteEditorAPI = {
...apiPartial,
hoveredField,
focusedField,
focusedInput,
toolbar: toolbar as EditorToolbarAPI,
fields,
};
setContextProperty(api);
setupLifecycleHooks(api);
$: tagAmount = $tags.length;
</script>
<!--
@component
Serves as a pre-slotted convenience component which combines all the common
components and functionality for general note editing.
Functionality exclusive to specific note-editing views (e.g. in the browser or
the AddCards dialog) should be implemented in the user of this component.
-->
<div class="note-editor">
<EditorToolbar {size} {wrap} api={toolbar}>
<slot slot="notetypeButtons" name="notetypeButtons" />
</EditorToolbar>
{#if hint}
<Absolute bottom right --margin="10px">
<Notification>
<Badge --badge-color="tomato" --icon-align="top">
<Icon icon={alertIcon} />
</Badge>
<span>{@html hint}</span>
</Notification>
</Absolute>
{/if}
{#if imageOcclusionMode && ($ioMaskEditorVisible || imageOcclusionMode?.kind === "add")}
<div style="display: {$ioMaskEditorVisible ? 'block' : 'none'};">
<ImageOcclusionPage
mode={imageOcclusionMode}
on:save={saveOcclusions}
on:image-loaded={onImageLoaded}
/>
</div>
{/if}
{#if $ioMaskEditorVisible && isImageOcclusion && !isIOImageLoaded}
<ImageOcclusionPicker
onPickImage={pickIOImage}
onPickImageFromClipboard={pickIOImageFromClipboard}
/>
{/if}
{#if !$ioMaskEditorVisible}
<Fields>
{#each fieldsData as field, index}
{@const content = fieldStores[index]}
<EditorField
{field}
{content}
{index}
flipInputs={plainTextDefaults[index]}
api={fields[index]}
on:focusin={() => {
$focusedField = fields[index];
setAddonButtonsDisabled(false);
bridgeCommand(`focus:${index}`);
}}
on:focusout={() => {
$focusedField = null;
setAddonButtonsDisabled(true);
bridgeCommand(
`blur:${index}:${getNoteId()}:${transformContentBeforeSave(
get(content),
)}`,
);
}}
on:mouseenter={() => {
$hoveredField = fields[index];
}}
on:mouseleave={() => {
$hoveredField = null;
}}
collapsed={fieldsCollapsed[index]}
dupe={cols[index] === "dupe"}
--description-font-size="{field.fontSize}px"
--description-content={`"${field.description}"`}
>
<svelte:fragment slot="field-label">
<LabelContainer
collapsed={fieldsCollapsed[index]}
on:toggle={() => toggleField(index)}
--icon-align="bottom"
>
<svelte:fragment slot="field-name">
<LabelName>
{field.name}
</LabelName>
</svelte:fragment>
<FieldState>
{#if cols[index] === "dupe"}
<DuplicateLink />
{/if}
<slot
name="field-state"
{field}
{index}
show={fields[index] === $hoveredField ||
fields[index] === $focusedField}
/>
{#if plainTextDefaults[index]}
<RichTextBadge
show={!fieldsCollapsed[index] &&
(fields[index] === $hoveredField ||
fields[index] === $focusedField)}
bind:off={richTextsHidden[index]}
on:toggle={() => toggleRichTextInput(index)}
/>
{:else}
<PlainTextBadge
show={!fieldsCollapsed[index] &&
(fields[index] === $hoveredField ||
fields[index] === $focusedField)}
bind:off={plainTextsHidden[index]}
on:toggle={() => togglePlainTextInput(index)}
/>
{/if}
</FieldState>
</LabelContainer>
</svelte:fragment>
<svelte:fragment slot="rich-text-input">
<Collapsible
collapse={richTextsHidden[index]}
let:collapsed={hidden}
toggleDisplay
>
<RichTextInput
{hidden}
on:focusout={() => {
saveFieldNow();
$focusedInput = null;
}}
bind:this={richTextInputs[index]}
isClozeField={field.isClozeField}
/>
</Collapsible>
</svelte:fragment>
<svelte:fragment slot="plain-text-input">
<Collapsible
collapse={plainTextsHidden[index]}
let:collapsed={hidden}
toggleDisplay
>
<PlainTextInput
{hidden}
fieldCollapsed={fieldsCollapsed[index]}
on:focusout={() => {
saveFieldNow();
$focusedInput = null;
}}
bind:this={plainTextInputs[index]}
/>
</Collapsible>
</svelte:fragment>
</EditorField>
{/each}
<MathjaxOverlay />
<ImageOverlay maxWidth={250} maxHeight={125} />
</Fields>
<Shortcut
keyCombination="Control+Shift+T"
on:action={() => {
updateTagsCollapsed(false);
}}
/>
<CollapseLabel
collapsed={$tagsCollapsed}
tooltip={$tagsCollapsed ? tr.editingExpand() : tr.editingCollapse()}
on:toggle={() => updateTagsCollapsed(!$tagsCollapsed)}
>
{@html `${tagAmount > 0 ? tagAmount : ""} ${tr.editingTags()}`}
</CollapseLabel>
<Collapsible toggleDisplay collapse={$tagsCollapsed}>
<TagEditor {tags} on:tagsupdate={saveTags} />
</Collapsible>
{/if}
</div>
<style lang="scss">
.note-editor {
display: flex;
flex-direction: column;
height: 100%;
}
:global(.image-occlusion) {
position: fixed;
}
:global(.image-occlusion .tab-buttons) {
display: none !important;
}
:global(.image-occlusion .top-tool-bar-container) {
margin-left: 28px !important;
}
:global(.top-tool-bar-container .icon-button) {
height: 36px !important;
line-height: 1;
}
:global(.image-occlusion .tool-bar-container) {
top: unset !important;
margin-top: 2px !important;
}
:global(.image-occlusion .sticky-footer) {
display: none;
}
</style>

View file

@ -1,19 +0,0 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import type { NoteEditorAPI } from "./NoteEditor.svelte";
import NoteEditor from "./NoteEditor.svelte";
const api: Partial<NoteEditorAPI> = {};
let noteEditor: NoteEditor;
export let uiResolve: (api: NoteEditorAPI) => void;
$: if (noteEditor) {
uiResolve(api as NoteEditorAPI);
}
</script>
<NoteEditor bind:this={noteEditor} {api} />

View file

@ -0,0 +1,42 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { mdiBookOutline } from "./icons";
import { getDeckNames } from "@generated/backend";
import ItemChooser from "./ItemChooser.svelte";
import type { DeckNameId } from "@generated/anki/decks_pb";
import * as tr from "@generated/ftl";
interface Props {
selectedDeck: DeckNameId | null;
onChange?: (deck: DeckNameId) => void;
}
let { selectedDeck = $bindable(null), onChange }: Props = $props();
let decks: DeckNameId[] = $state([]);
let itemChooser: ItemChooser<DeckNameId> | null = $state(null);
export function select(notetypeId: bigint) {
itemChooser?.select(notetypeId);
}
$effect(() => {
getDeckNames({ skipEmptyDefault: true, includeFiltered: false }).then(
(response) => {
decks = response.entries;
},
);
});
</script>
<ItemChooser
bind:this={itemChooser}
title={tr.qtMiscChooseDeck()}
bind:selectedItem={selectedDeck}
{onChange}
items={decks}
icon={mdiBookOutline}
keyCombination="Control+D"
tooltip={tr.qtMiscTargetDeckCtrlandd()}
/>

View file

@ -6,16 +6,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import * as tr from "@generated/ftl";
import { renderMarkdown } from "@tslib/helpers";
import Carousel from "bootstrap/js/dist/carousel";
import Modal from "bootstrap/js/dist/modal";
import { createEventDispatcher, getContext, onDestroy, onMount } from "svelte";
import { createEventDispatcher, onMount } from "svelte";
import Modal from "./Modal.svelte";
import { infoCircle } from "$lib/components/icons";
import { registerModalClosingHandler } from "$lib/sveltelib/modal-closing";
import { pageTheme } from "$lib/sveltelib/theme";
import Badge from "./Badge.svelte";
import Col from "./Col.svelte";
import { modalsKey } from "./context-keys";
import HelpSection from "./HelpSection.svelte";
import Icon from "./Icon.svelte";
import Row from "./Row.svelte";
@ -27,50 +25,20 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let helpSections: HelpItem[];
export let fsrs = false;
export const modalKey: string = Math.random().toString(36).substring(2);
const modals = getContext<Map<string, Modal>>(modalsKey);
let modal: Modal;
let carousel: Carousel;
let modalRef: HTMLDivElement;
let modal: Modal;
let carouselRef: HTMLDivElement;
function onOkClicked(): void {
modal.hide();
}
const dispatch = createEventDispatcher();
const { set: setModalOpen, remove: removeModalClosingHandler } =
registerModalClosingHandler(onOkClicked);
function onShown() {
setModalOpen(true);
}
function onHidden() {
setModalOpen(false);
}
onMount(() => {
modalRef.addEventListener("shown.bs.modal", onShown);
modalRef.addEventListener("hidden.bs.modal", onHidden);
modal = new Modal(modalRef, { keyboard: false });
carousel = new Carousel(carouselRef, { interval: false, ride: false });
/* Bootstrap's Carousel.Event interface doesn't seem to work as a type here */
carouselRef.addEventListener("slide.bs.carousel", (e: any) => {
activeIndex = e.to;
});
dispatch("mount", { modal: modal, carousel: carousel });
modals.set(modalKey, modal);
});
onDestroy(() => {
removeModalClosingHandler();
modalRef.removeEventListener("shown.bs.modal", onShown);
modalRef.removeEventListener("hidden.bs.modal", onHidden);
});
let activeIndex = startIndex;
@ -80,119 +48,98 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<Icon icon={infoCircle} />
</Badge>
<div
bind:this={modalRef}
class="modal fade"
tabindex="-1"
aria-labelledby="modalLabel"
aria-hidden="true"
>
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<div style="display: flex;">
<h1 class="modal-title" id="modalLabel">
{title}
</h1>
<button
type="button"
class="btn-close"
class:invert={$pageTheme.isDark}
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
{#if url}
<div class="chapter-redirect">
{@html renderMarkdown(
tr.helpForMoreInfo({
link: `<a href="${url}" title="${tr.helpOpenManualChapter(
{
name: title,
},
)}">${title}</a>`,
}),
)}
</div>
{/if}
<Modal bind:this={modal} dialogClass="modal-lg">
<div slot="header" class="modal-header">
<div style="display: flex;">
<h1 class="modal-title" id="modalLabel">
{title}
</h1>
<button
type="button"
class="btn-close"
class:invert={$pageTheme.isDark}
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
{#if url}
<div class="chapter-redirect">
{@html renderMarkdown(
tr.helpForMoreInfo({
link: `<a href="${url}" title="${tr.helpOpenManualChapter({
name: title,
})}">${title}</a>`,
}),
)}
</div>
<div class="modal-body">
<Row --cols={4}>
<Col --col-size={1}>
<nav>
<div id="nav">
<ul>
{#each helpSections as item, i}
<li>
<button
on:click={() => {
activeIndex = i;
carousel.to(activeIndex);
}}
class:active={i == activeIndex}
class:d-none={fsrs
? item.sched ===
HelpItemScheduler.SM2
: item.sched ==
HelpItemScheduler.FSRS}
>
{item.title}
</button>
</li>
{/each}
</ul>
</div>
</nav>
</Col>
<Col --col-size={3}>
<div
id="helpSectionIndicators"
class="carousel slide"
bind:this={carouselRef}
>
<div class="carousel-inner">
{#each helpSections as item, i}
<div
class="carousel-item"
class:active={i == startIndex}
{/if}
</div>
<div slot="body" class="modal-body">
<Row --cols={4}>
<Col --col-size={1}>
<nav>
<div id="nav">
<ul>
{#each helpSections as item, i}
<li>
<button
on:click={() => {
activeIndex = i;
carousel.to(activeIndex);
}}
class:active={i == activeIndex}
class:d-none={fsrs
? item.sched === HelpItemScheduler.SM2
: item.sched == HelpItemScheduler.FSRS}
>
<HelpSection {item} />
</div>
{/each}
{item.title}
</button>
</li>
{/each}
</ul>
</div>
</nav>
</Col>
<Col --col-size={3}>
<div
id="helpSectionIndicators"
class="carousel slide"
bind:this={carouselRef}
>
<div class="carousel-inner">
{#each helpSections as item, i}
<div
class="carousel-item"
class:active={i == startIndex}
class:d-none={fsrs
? item.sched === HelpItemScheduler.SM2
: item.sched == HelpItemScheduler.FSRS}
>
<HelpSection {item} />
</div>
</div>
</Col>
</Row>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" on:click={onOkClicked}>
{tr.helpOk()}
</button>
</div>
</div>
{/each}
</div>
</div>
</Col>
</Row>
</div>
</div>
<div slot="footer" class="modal-footer">
<button type="button" class="btn btn-primary" on:click={modal.onOkClicked}>
{tr.helpOk()}
</button>
</div>
</Modal>
<style lang="scss">
#nav {
margin-bottom: 1.5rem;
}
.modal {
z-index: 1066;
background-color: rgba($color: black, $alpha: 0.5);
}
.modal-title {
margin-inline-end: 0.75rem;
}
.modal-content {
background-color: var(--canvas);
color: var(--fg);
:global(.modal-content) {
border-radius: var(--border-radius-medium, 10px);
}

View file

@ -0,0 +1,258 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts" generics="Item extends { id: bigint, name: string }">
import { magnifyIcon, mdiClose } from "./icons";
import Icon from "./Icon.svelte";
import IconConstrain from "./IconConstrain.svelte";
import LabelButton from "./LabelButton.svelte";
import Modal from "./Modal.svelte";
import type { IconData } from "./types";
import * as tr from "@generated/ftl";
import Shortcut from "./Shortcut.svelte";
interface Props {
title: string;
selectedItem?: Item | null;
items: Item[];
icon: IconData;
keyCombination: string;
tooltip: string;
onChange?: (item: Item) => void;
}
let {
title,
onChange,
icon,
items,
selectedItem = $bindable(null),
keyCombination,
tooltip,
}: Props = $props();
let modal: Modal | null = $state(null);
let searchQuery = $state("");
let searchInput: HTMLInputElement | null = $state(null);
const filteredItems = $derived(
searchQuery.trim() === ""
? items
: items.filter((item) =>
item.name.toLowerCase().includes(searchQuery.toLowerCase()),
),
);
function onSelect(item: Item) {
selectedItem = item;
onChange?.(item);
modal?.hide();
}
function openModal() {
searchQuery = "";
modal?.show();
}
function toggleModal() {
modal?.toggle();
searchQuery = "";
}
export function select(itemId: bigint) {
const item = items.find((item) => item.id === itemId);
selectedItem = item ? item : null;
}
$effect(() => {
if (!selectedItem && items.length > 0) {
selectedItem = items[0];
}
});
</script>
<LabelButton {tooltip} on:click={openModal} class="chooser-button">
{selectedItem?.name ?? "…"}
</LabelButton>
<Shortcut {keyCombination} on:action={toggleModal} />
<Modal bind:this={modal} onShown={() => searchInput?.focus()} dialogClass="modal-lg">
<div slot="header" class="modal-header">
<IconConstrain iconSize={90}>
<Icon {icon} />
</IconConstrain>
<h5 class="modal-title">{title}</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<div slot="body" class="modal-body">
<div class="search-container">
<div class="search-input-wrapper">
<div class="search-icon">
<IconConstrain iconSize={70}>
<Icon icon={magnifyIcon} />
</IconConstrain>
</div>
<input
type="text"
class="search-input"
placeholder={tr.actionsSearch()}
bind:value={searchQuery}
bind:this={searchInput}
/>
{#if searchQuery}
<button
type="button"
class="clear-search"
onclick={() => (searchQuery = "")}
aria-label="Clear search"
>
<IconConstrain iconSize={60}>
<Icon icon={mdiClose} />
</IconConstrain>
</button>
{/if}
</div>
</div>
<div class="item-grid">
{#each filteredItems as item (item.id)}
<button
class="item-card"
class:selected={selectedItem?.id === item.id}
onclick={() => onSelect(item)}
aria-label="Select {item.name}"
>
<h6 class="item-title">{item.name}</h6>
</button>
{/each}
</div>
</div>
</Modal>
<style lang="scss">
@use "../sass/button-mixins" as button;
:global(.label-button.chooser-button) {
width: 100%;
}
.modal-header {
display: flex;
align-items: center;
gap: 0.75rem;
.modal-title {
margin: 0;
font-weight: 600;
color: var(--fg);
}
}
.search-input-wrapper {
position: relative;
display: flex;
align-items: center;
background: var(--canvas);
border: 1px solid var(--border-subtle);
border-radius: 0.375rem;
transition: border-color 0.2s ease;
&:focus-within {
border-color: var(--border-strong);
}
}
.search-icon {
padding: 0.5rem 0.75rem;
color: var(--fg-subtle);
pointer-events: none;
}
.search-input {
flex: 1;
padding: 0.5rem 0.75rem 0.5rem 0;
border: none;
background: transparent;
color: var(--fg);
font-size: 0.9rem;
outline: none;
&::placeholder {
color: var(--fg-subtle);
}
}
.clear-search {
padding: 0.25rem;
margin-right: 0.5rem;
border: none;
background: transparent;
color: var(--fg-subtle);
border-radius: 0.25rem;
&:hover {
background: var(--canvas-inset);
color: var(--fg);
}
}
.item-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
padding: 0.5rem 0;
}
:global(.item-card) {
@include button.base(
$border: true,
$with-hover: true,
$with-active: true,
$with-disabled: false
);
@include button.border-radius;
padding: 1rem;
text-align: start;
background: var(--canvas-elevated);
border: 1px solid var(--border-subtle);
&:hover {
background: var(--canvas-inset);
border-color: var(--border);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
&.selected {
border-color: var(--border-focus);
}
}
.item-title {
margin: 0 0 0.25rem 0;
font-size: 1rem;
font-weight: 600;
color: inherit;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.modal-body {
padding: 1.5rem;
max-height: 80vh;
overflow-y: auto;
}
.modal-header {
padding: 1.5rem;
border-bottom: 1px solid var(--border-subtle);
background: var(--canvas-elevated);
}
</style>

View file

@ -0,0 +1,102 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import Modal from "bootstrap/js/dist/modal";
import { getContext, onDestroy, onMount } from "svelte";
import { registerModalClosingHandler } from "$lib/sveltelib/modal-closing";
import { modalsKey } from "./context-keys";
export let modalKey: string = Math.random().toString(36).substring(2);
export let dialogClass: string = "";
export let onOkClicked: (() => void) | undefined = undefined;
export let onCancelClicked: (() => void) | undefined = undefined;
export let onShown: (() => void) | undefined = undefined;
export let onHidden: (() => void) | undefined = undefined;
const modals = getContext<Map<string, Modal>>(modalsKey);
let modal: Modal;
let modalRef: HTMLDivElement;
function onOkClicked_(): void {
modal.hide();
onOkClicked?.();
}
function onCancelClicked_(): void {
modal.hide();
onCancelClicked?.();
}
export function show(): void {
modal.show();
}
export function hide(): void {
modal.hide();
}
export function toggle(): void {
modal.toggle();
}
export { onOkClicked_ as acceptHandler, onCancelClicked_ as cancelHandler };
const { set: setModalOpen, remove: removeModalClosingHandler } =
registerModalClosingHandler(onOkClicked_);
function onShown_() {
setModalOpen(true);
onShown?.();
}
function onHidden_() {
setModalOpen(false);
onHidden?.();
}
onMount(() => {
modalRef.addEventListener("shown.bs.modal", onShown_);
modalRef.addEventListener("hidden.bs.modal", onHidden_);
modal = new Modal(modalRef, { keyboard: false });
modals.set(modalKey, modal);
});
onDestroy(() => {
removeModalClosingHandler();
modalRef.removeEventListener("shown.bs.modal", onShown_);
modalRef.removeEventListener("hidden.bs.modal", onHidden_);
});
</script>
<div
bind:this={modalRef}
class="modal fade"
tabindex="-1"
aria-labelledby="modalLabel"
aria-hidden="true"
>
<div class="modal-dialog {dialogClass}">
<div class="modal-content">
<slot name="header" />
<slot name="body" />
<slot name="footer" />
</div>
</div>
</div>
<style lang="scss">
.modal {
z-index: 1066;
background-color: rgba($color: black, $alpha: 0.5);
}
.modal-content {
background-color: var(--canvas);
color: var(--fg);
}
</style>

View file

@ -0,0 +1,41 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import type { NotetypeNameId } from "@generated/anki/notetypes_pb";
import { mdiNewspaper } from "./icons";
import { getNotetypeNames } from "@generated/backend";
import ItemChooser from "./ItemChooser.svelte";
import * as tr from "@generated/ftl";
interface Props {
selectedNotetype: NotetypeNameId | null;
onChange?: (notetype: NotetypeNameId) => void;
}
let { selectedNotetype = $bindable(null), onChange }: Props = $props();
let notetypes: NotetypeNameId[] = $state([]);
let itemChooser: ItemChooser<NotetypeNameId> | null = $state(null);
export function select(notetypeId: bigint) {
itemChooser?.select(notetypeId);
}
$effect(() => {
getNotetypeNames({}).then((response) => {
notetypes = response.entries;
});
});
</script>
<ItemChooser
bind:this={itemChooser}
title={tr.qtMiscChooseNoteType()}
bind:selectedItem={selectedNotetype}
{onChange}
items={notetypes}
icon={mdiNewspaper}
keyCombination="Control+N"
tooltip={tr.qtMiscChangeNoteTypeCtrlandn()}
/>

View file

@ -15,6 +15,8 @@ import AlignVerticalCenter_ from "@mdi/svg/svg/align-vertical-center.svg?compone
import alignVerticalCenter_ from "@mdi/svg/svg/align-vertical-center.svg?url";
import AlignVerticalTop_ from "@mdi/svg/svg/align-vertical-top.svg?component";
import alignVerticalTop_ from "@mdi/svg/svg/align-vertical-top.svg?url";
import BookOutline_ from "@mdi/svg/svg/book-outline.svg?component";
import bookOutline_ from "@mdi/svg/svg/book-outline.svg?url";
import CheckCircle_ from "@mdi/svg/svg/check-circle.svg?component";
import checkCircle_ from "@mdi/svg/svg/check-circle.svg?url";
import ChevronDown_ from "@mdi/svg/svg/chevron-down.svg?component";
@ -111,6 +113,8 @@ import Math_ from "@mdi/svg/svg/math-integral-box.svg?component";
import math_ from "@mdi/svg/svg/math-integral-box.svg?url";
import NewBox_ from "@mdi/svg/svg/new-box.svg?component";
import newBox_ from "@mdi/svg/svg/new-box.svg?url";
import Newspaper_ from "@mdi/svg/svg/newspaper.svg?component";
import newspaper_ from "@mdi/svg/svg/newspaper.svg?url";
import Paperclip_ from "@mdi/svg/svg/paperclip.svg?component";
import paperclip_ from "@mdi/svg/svg/paperclip.svg?url";
import RectangleOutline_ from "@mdi/svg/svg/rectangle-outline.svg?component";
@ -149,6 +153,8 @@ import ArrowLeft_ from "bootstrap-icons/icons/arrow-left.svg?component";
import arrowLeft_ from "bootstrap-icons/icons/arrow-left.svg?url";
import ArrowRight_ from "bootstrap-icons/icons/arrow-right.svg?component";
import arrowRight_ from "bootstrap-icons/icons/arrow-right.svg?url";
import CaretDownFill_ from "bootstrap-icons/icons/caret-down-fill.svg?component";
import caretDownFill_ from "bootstrap-icons/icons/caret-down-fill.svg?url";
import Minus_ from "bootstrap-icons/icons/dash-lg.svg?component";
import minus_ from "bootstrap-icons/icons/dash-lg.svg?url";
import Eraser_ from "bootstrap-icons/icons/eraser.svg?component";
@ -285,3 +291,6 @@ export const mdiUngroup = { url: ungroup_, component: Ungroup_ };
export const mdiVectorPolygonVariant = { url: vectorPolygonVariant_, component: VectorPolygonVariant_ };
export const incrementClozeIcon = { url: incrementCloze_, component: IncrementCloze_ };
export const mdiEarth = { url: earth_, component: Earth_ };
export const caretDownFill = { url: caretDownFill_, component: CaretDownFill_ };
export const mdiNewspaper = { url: newspaper_, component: Newspaper_ };
export const mdiBookOutline = { url: bookOutline_, component: BookOutline_ };

View file

@ -0,0 +1,82 @@
<!-- Copyright: Ankitects Pty Ltd and contributors -->
<!-- License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -->
<script lang="ts">
import { tick } from "svelte";
import type { ContextMenuMouseEvent } from "./types";
let visible = $state(false);
let x = $state(0);
let y = $state(0);
let contextMenuElement = $state<HTMLDivElement>();
const { children } = $props();
export async function show(event: ContextMenuMouseEvent) {
event.preventDefault();
x = event.clientX;
y = event.clientY;
visible = true;
await tick();
const rect = contextMenuElement!.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
if (x + rect.width > viewportWidth) {
x = viewportWidth - rect.width;
}
if (y + rect.height > viewportHeight) {
y = viewportHeight - rect.height;
}
x = Math.max(0, x);
y = Math.max(0, y);
}
function hide() {
visible = false;
}
function handleClickOutside(event: MouseEvent) {
if (
visible &&
contextMenuElement &&
!contextMenuElement.contains(event.target as Node)
) {
hide();
}
}
</script>
<svelte:document on:click={handleClickOutside} />
{#if visible}
<div
bind:this={contextMenuElement}
class="context-menu"
style="left: {x}px; top: {y}px;"
role="menu"
tabindex="0"
onclick={hide}
onkeydown={hide}
>
{@render children?.()}
</div>
{/if}
<style lang="scss">
.context-menu {
position: fixed;
background: var(--canvas);
border: 1px solid var(--border);
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
padding: 4px 0;
min-width: 120px;
z-index: 1000;
font-size: 13px;
outline: none;
}
</style>

View file

@ -0,0 +1,33 @@
<!-- Copyright: Ankitects Pty Ltd and contributors -->
<!-- License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -->
<script lang="ts">
const { click, children } = $props();
</script>
<div
class="context-menu-item"
onclick={click}
onkeydown={click}
role="menuitem"
tabindex="-1"
>
{@render children?.()}
</div>
<style lang="scss">
.context-menu-item {
padding: 8px 16px;
cursor: pointer;
user-select: none;
white-space: nowrap;
&:hover {
background-color: var(--highlight-bg);
}
&:active {
background-color: var(--highlight-bg);
}
}
</style>

View file

@ -0,0 +1,6 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export { default as ContextMenu } from "./ContextMenu.svelte";
export { default as Item } from "./Item.svelte";
export type { ContextMenuAPI, ContextMenuMouseEvent } from "./types";

View file

@ -0,0 +1,12 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export interface ContextMenuMouseEvent {
clientX: number;
clientY: number;
preventDefault(): void;
}
export interface ContextMenuAPI {
show(event: ContextMenuMouseEvent): void;
}

View file

@ -57,21 +57,5 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
></anki-editable>
<style lang="scss">
anki-editable {
display: block;
position: relative;
overflow: auto;
overflow-wrap: anywhere;
/* fallback for iOS */
word-break: break-word;
&:focus {
outline: none;
}
min-height: 1.5em;
}
/* editable-base.scss contains styling targeting user HTML */
@import "./content-editable.scss";
</style>

View file

@ -125,24 +125,5 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/>
<style lang="scss">
:global(anki-mathjax) {
white-space: pre;
}
img {
vertical-align: var(--vertical-center);
}
.block {
display: block;
margin: 1rem auto;
transform: scale(1.1);
}
.empty {
vertical-align: text-bottom;
width: var(--font-size);
height: var(--font-size);
}
@import "./mathjax.scss";
</style>

View file

@ -0,0 +1,15 @@
anki-editable {
display: block;
position: relative;
overflow: auto;
overflow-wrap: anywhere;
/* fallback for iOS */
word-break: break-word;
&:focus {
outline: none;
}
min-height: 1.5em;
}

View file

@ -1,4 +1,4 @@
@use "../lib/sass/scrollbar";
@use "../sass/scrollbar";
* {
max-width: 100%;

View file

@ -2,6 +2,5 @@
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import "./editable-base.scss";
/* only imported for the CSS */
import "./ContentEditable.svelte";
import "./Mathjax.svelte";
import "./content-editable.scss";
import "./mathjax.scss";

View file

@ -0,0 +1,13 @@
:global(anki-mathjax) {
white-space: pre;
}
img {
vertical-align: var(--vertical-center);
}
.block {
display: block;
margin: 1rem auto;
transform: scale(1.1);
}

View file

@ -34,6 +34,12 @@ const allow = (attrs: string[]): FilterMethod => (element: Element): void =>
element,
);
function convertToDiv(element: Element): void {
const div = document.createElement("div");
div.innerHTML = element.innerHTML;
element.replaceWith(div);
}
function unwrapElement(element: Element): void {
element.replaceWith(...element.childNodes);
}
@ -50,7 +56,7 @@ const tagsAllowedBasic: TagsAllowed = {
BR: allowNone,
IMG: allow(["SRC", "ALT"]),
DIV: allowNone,
P: allowNone,
P: convertToDiv,
SUB: allowNone,
SUP: allowNone,
TITLE: removeElement,

View file

@ -33,6 +33,11 @@ const outputHTMLProcessors: Record<FilterMode, (outputHTML: string) => string> =
};
export function filterHTML(html: string, internal: boolean, extended: boolean): string {
// https://anki.tenderapp.com/discussions/ankidesktop/39543-anki-is-replacing-the-character-by-when-i-exit-the-html-edit-mode-ctrlshiftx
if (html.indexOf(">") < 0) {
return html;
}
const template = document.createElement("template");
template.innerHTML = html;

View file

@ -240,6 +240,7 @@ export interface DefaultSlotInterface extends Record<string, unknown> {
show(position: Identifier): Promise<boolean>;
hide(position: Identifier): Promise<boolean>;
toggle(position: Identifier): Promise<boolean>;
setShown(position: Identifier, shown: boolean): Promise<boolean>;
}
export function defaultInterface<T extends SlotHostProps, U extends Element>({
@ -287,12 +288,20 @@ export function defaultInterface<T extends SlotHostProps, U extends Element>({
}, id);
}
function setShown(id: Identifier, shown: boolean): Promise<boolean> {
return updateProps((props: T): T => {
props.detach.set(!shown);
return props;
}, id);
}
return {
insert,
append,
show,
hide,
toggle,
setShown,
};
}

View file

@ -46,4 +46,7 @@ export const HelpPage = {
updating: "https://docs.ankiweb.net/importing/text-files.html#duplicates-and-updating",
html: "https://docs.ankiweb.net/importing/text-files.html#html",
},
Editing: {
adding: "https://docs.ankiweb.net/editing.html#adding-cards-and-notes",
},
};

55
ts/lib/tslib/profile.ts Normal file
View file

@ -0,0 +1,55 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import {
getConfigJson,
getMetaJson,
getProfileConfigJson,
setConfigJson,
setMetaJson,
setProfileConfigJson,
} from "@generated/backend";
async function getSettingJson(key: string, backendGetter: (key: string) => Promise<any>): Promise<any> {
const decoder = new TextDecoder();
const json = decoder.decode((await backendGetter(key)).json);
return JSON.parse(json);
}
async function setSettingJson(
key: string,
value: any,
backendSetter: (key: string, value: any) => Promise<any>,
): Promise<void> {
const encoder = new TextEncoder();
const json = JSON.stringify(value);
await backendSetter(key, encoder.encode(json));
}
export async function getProfileConfig(key: string): Promise<any> {
return getSettingJson(key, async (k) => await getProfileConfigJson({ val: k }));
}
export async function setProfileConfig(key: string, value: any): Promise<void> {
return await setSettingJson(key, value, async (k, v) => await setProfileConfigJson({ key: k, valueJson: v }));
}
export async function getMeta(key: string): Promise<any> {
return getSettingJson(key, async (k) => await getMetaJson({ val: k }));
}
export async function setMeta(key: string, value: any): Promise<void> {
return await setSettingJson(key, value, async (k, v) => await setMetaJson({ key: k, valueJson: v }));
}
export async function getColConfig(key: string): Promise<any> {
return await getSettingJson(key, async (k) => await getConfigJson({ val: k }));
}
export async function setColConfig(key: string, value: any): Promise<void> {
return await setSettingJson(
key,
value,
async (k, v) => await setConfigJson({ key: k, valueJson: v, undoable: true }),
);
}

View file

@ -3,11 +3,7 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import Modal from "bootstrap/js/dist/modal";
import { getContext, onDestroy, onMount } from "svelte";
import { modalsKey } from "$lib/components/context-keys";
import { registerModalClosingHandler } from "$lib/sveltelib/modal-closing";
import Modal from "$lib/components/Modal.svelte";
import { pageTheme } from "$lib/sveltelib/theme";
export let title: string;
@ -16,117 +12,65 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let onOk: (text: string) => void;
$: value = initialValue;
export const modalKey: string = Math.random().toString(36).substring(2);
const modals = getContext<Map<string, Modal>>(modalsKey);
let modalRef: HTMLDivElement;
let modal: Modal;
let inputRef: HTMLInputElement;
let modal: Modal;
export let modalKey: string;
function onOkClicked(): void {
onOk(inputRef.value);
modal.hide();
value = initialValue;
}
function onCancelClicked(): void {
modal.hide();
value = initialValue;
}
function onShown(): void {
inputRef.focus();
setModalOpen(true);
}
function onHidden() {
setModalOpen(false);
}
const { set: setModalOpen, remove: removeModalClosingHandler } =
registerModalClosingHandler(onCancelClicked);
onMount(() => {
modalRef.addEventListener("shown.bs.modal", onShown);
modalRef.addEventListener("hidden.bs.modal", onHidden);
modal = new Modal(modalRef, { keyboard: false });
modals.set(modalKey, modal);
});
onDestroy(() => {
removeModalClosingHandler();
modalRef.removeEventListener("shown.bs.modal", onShown);
modalRef.removeEventListener("hidden.bs.modal", onHidden);
});
</script>
<div
bind:this={modalRef}
class="modal fade"
tabindex="-1"
aria-labelledby="modalLabel"
aria-hidden="true"
>
<div class="modal-dialog">
<div class="modal-content" class:default-colors={$pageTheme.isDark}>
<div class="modal-header">
<h5 class="modal-title" id="modalLabel">{title}</h5>
<button
type="button"
class="btn-close"
class:invert={$pageTheme.isDark}
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<div class="modal-body">
<form on:submit|preventDefault={onOkClicked}>
<div class="mb-3">
<label for="prompt-input" class="col-form-label">
{prompt}:
</label>
<input
id="prompt-input"
bind:this={inputRef}
type="text"
class:nightMode={$pageTheme.isDark}
class="form-control"
bind:value
/>
</div>
</form>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-secondary"
on:click={onCancelClicked}
>
Cancel
</button>
<button type="button" class="btn btn-primary" on:click={onOkClicked}>
OK
</button>
</div>
</div>
<Modal bind:this={modal} bind:modalKey {onOkClicked} {onShown} {onCancelClicked}>
<div slot="header" class="modal-header">
<h5 class="modal-title" id="modalLabel">{title}</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
</div>
<div slot="body" class="modal-body">
<form on:submit|preventDefault={modal.acceptHandler}>
<div class="mb-3">
<label for="prompt-input" class="col-form-label">
{prompt}:
</label>
<input
id="prompt-input"
bind:this={inputRef}
type="text"
class:nightMode={$pageTheme.isDark}
class="form-control"
bind:value
/>
</div>
</form>
</div>
<div slot="footer" class="modal-footer">
<button type="button" class="btn btn-secondary" on:click={modal.cancelHandler}>
Cancel
</button>
<button type="button" class="btn btn-primary" on:click={modal.acceptHandler}>
OK
</button>
</div>
</Modal>
<style lang="scss">
@use "$lib/sass/night-mode" as nightmode;
@use "../../lib/sass/night-mode" as nightmode;
.nightMode {
@include nightmode.input;
}
.default-colors {
background-color: var(--canvas);
color: var(--fg);
}
.invert {
filter: invert(1) grayscale(100%) brightness(200%);
}
</style>

View file

@ -0,0 +1,23 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { onMount } from "svelte";
import { setupEditor } from "./base";
import * as base from "./base";
import { page } from "$app/state";
import type { EditorMode } from "./types";
import { globalExport } from "@tslib/globals";
import { bridgeCommand } from "@tslib/bridgecommand";
const mode = (page.url.searchParams.get("mode") ?? "add") as EditorMode;
globalExport(base);
onMount(() => {
setupEditor(mode).then(() => {
bridgeCommand("editorReady");
});
});
</script>

View file

@ -0,0 +1,33 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import LabelButton from "$lib/components/LabelButton.svelte";
const { children, onClick, tooltip, disabled = false } = $props();
</script>
<div class="action-button">
<LabelButton
primary
on:click={onClick}
{tooltip}
--border-left-radius="5px"
--border-right-radius="5px"
{disabled}
>
<div class="action-text">
{@render children()}
</div>
</LabelButton>
</div>
<style lang="scss">
.action-button {
margin-inline-end: 0.5rem;
}
.action-text {
margin: 0 0.75rem;
}
</style>

View file

@ -0,0 +1,36 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import AddButton from "./AddButton.svelte";
import CloseButton from "./CloseButton.svelte";
import HelpButton from "./HelpButton.svelte";
import HistoryButton from "./HistoryButton.svelte";
import type { EditorMode, HistoryEntry } from "./types";
export let mode: EditorMode;
export let onClose: () => void;
export let onAdd: () => void;
export let onHistory: () => void;
export let history: HistoryEntry[] = [];
</script>
<div class="action-buttons d-flex flex-row-reverse">
{#if mode === "add"}
<HelpButton />
{/if}
{#if mode === "add" || mode === "current"}
<CloseButton {onClose} enableShortcut={mode === "current"} />
{/if}
{#if mode === "add"}
<HistoryButton {onHistory} {history} />
<AddButton {onAdd} />
{/if}
</div>
<style lang="scss">
.action-buttons {
margin-top: 10px;
}
</style>

View file

@ -0,0 +1,18 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import * as tr from "@generated/ftl";
import { getPlatformString } from "@tslib/shortcuts";
import Shortcut from "$lib/components/Shortcut.svelte";
import ActionButton from "./ActionButton.svelte";
export let onAdd: () => void;
const addKeyCombination = "Control+Enter";
</script>
<ActionButton onClick={onAdd} tooltip={getPlatformString(addKeyCombination)}>
{tr.actionsAdd()}
<Shortcut keyCombination={addKeyCombination} on:action={onAdd} />
</ActionButton>

View file

@ -0,0 +1,22 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import * as tr from "@generated/ftl";
import { getPlatformString } from "@tslib/shortcuts";
import Shortcut from "$lib/components/Shortcut.svelte";
import ActionButton from "./ActionButton.svelte";
export let onClose: () => void;
export let enableShortcut: boolean;
const closeKeyCombination = enableShortcut ? "Control+Enter" : "";
</script>
<ActionButton onClick={onClose} tooltip={getPlatformString(closeKeyCombination)}>
{tr.actionsClose()}
{#if enableShortcut}
<Shortcut keyCombination={closeKeyCombination} on:action={onClose} />
{/if}
</ActionButton>

View file

@ -0,0 +1,47 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { searchInBrowser } from "@generated/backend";
import * as tr from "@generated/ftl";
import type { Note } from "@generated/anki/notes_pb";
import { bridgeCommand } from "@tslib/bridgecommand";
export let note: Note | null = null;
export let isLegacy: boolean;
function showDupes(event: MouseEvent) {
if (isLegacy) {
bridgeCommand("dupes");
} else if (note) {
searchInBrowser({
filter: {
case: "dupe",
value: {
notetypeId: note.notetypeId,
firstField: note.fields[0],
},
},
});
}
event.preventDefault();
}
</script>
<span class="duplicate-link-container">
<a class="duplicate-link" href="/#" on:click={showDupes}>
{tr.editingShowDuplicates()}
</a>
</span>
<style lang="scss">
.duplicate-link-container {
text-align: center;
flex-grow: 1;
}
.duplicate-link {
color: var(--highlight-color);
}
</style>

View file

@ -130,7 +130,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</div>
<style lang="scss">
@use "../lib/sass/elevation" as *;
@use "../../lib/sass/elevation" as *;
/* Make sure labels are readable on custom Qt backgrounds */
.field-container {

View file

@ -0,0 +1,23 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import * as tr from "@generated/ftl";
import { HelpPage } from "@tslib/help-page";
import ActionButton from "./ActionButton.svelte";
import { getPlatformString } from "@tslib/shortcuts";
import Shortcut from "$lib/components/Shortcut.svelte";
import { openLink } from "@generated/backend";
const helpKeyCombination = "F1";
function onClick() {
openLink({ val: HelpPage.Editing.adding });
}
</script>
<ActionButton tooltip={getPlatformString(helpKeyCombination)} {onClick}>
{tr.actionsHelp()}
<Shortcut keyCombination={helpKeyCombination} on:action={onClick} />
</ActionButton>

View file

@ -0,0 +1,28 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import * as tr from "@generated/ftl";
import { getPlatformString } from "@tslib/shortcuts";
import Shortcut from "$lib/components/Shortcut.svelte";
import ActionButton from "./ActionButton.svelte";
import type { HistoryEntry } from "./types";
import Icon from "$lib/components/Icon.svelte";
import { caretDownFill } from "$lib/components/icons";
import { isApplePlatform } from "@tslib/platform";
export let onHistory: () => void;
export let history: HistoryEntry[] = [];
const historyKeyCombination = isApplePlatform() ? "Control+Shift+H" : "Control+H";
</script>
<ActionButton
onClick={onHistory}
tooltip={getPlatformString(historyKeyCombination)}
disabled={history.length === 0}
>
{tr.addingHistory()}
<Icon icon={caretDownFill} />
<Shortcut keyCombination={historyKeyCombination} on:action={onHistory} />
</ActionButton>

View file

@ -0,0 +1,137 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import * as tr from "@generated/ftl";
import Modal from "$lib/components/Modal.svelte";
import { pageTheme } from "$lib/sveltelib/theme";
import type { HistoryEntry } from "./types";
import { searchInBrowser } from "@generated/backend";
export let history: HistoryEntry[] = [];
export let modal: Modal;
function onEntryClick(entry: HistoryEntry): void {
searchInBrowser({
filter: {
case: "nids",
value: { ids: [entry.noteId] },
},
});
modal.hide();
}
</script>
<Modal bind:this={modal}>
<div slot="header" class="modal-header">
<h5 class="modal-title" id="modalLabel">{tr.addingHistory()}</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<div slot="body" class="modal-body" class:nightMode={$pageTheme.isDark}>
<ul class="history-list">
{#each history as entry}
<li>
<button
type="button"
class="history-entry"
on:click={() => onEntryClick(entry)}
>
{entry.text}
</button>
</li>
{/each}
</ul>
</div>
<div slot="footer" class="modal-footer">
<button type="button" class="btn btn-secondary" on:click={modal.cancelHandler}>
Cancel
</button>
</div>
</Modal>
<style lang="scss">
.history-list {
list-style: none;
padding: 0;
margin: 0;
max-height: 400px;
overflow-y: auto;
}
.history-list li {
margin-bottom: 8px;
}
.history-list li:last-child {
margin-bottom: 0;
}
.history-entry {
display: block;
width: 100%;
padding: 12px 16px;
background-color: var(--canvas-elevated);
border: 1px solid var(--border);
border-radius: 6px;
transition: all 0.2s ease;
font-size: 14px;
line-height: 1.4;
word-break: break-word;
text-align: left;
color: var(--fg);
text-decoration: none;
cursor: pointer;
position: relative;
}
.history-entry::after {
content: "";
position: absolute;
bottom: 8px;
left: 16px;
right: 16px;
height: 1px;
background-color: var(--link-color);
opacity: 0;
transition: opacity 0.2s ease;
}
.history-entry:hover {
background-color: var(--canvas-elevated-hover);
border-color: var(--border-strong);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
color: var(--link-color);
}
.history-entry:hover::after {
opacity: 0.6;
}
.history-entry:active {
transform: translateY(0px);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}
.history-entry:focus {
outline: 2px solid var(--link-color);
outline-offset: 2px;
}
.nightMode .history-entry {
background-color: var(--canvas-elevated);
border-color: var(--border, rgba(255, 255, 255, 0.15));
color: var(--fg);
}
.nightMode .history-entry:hover {
border-color: var(--border-strong, rgba(255, 255, 255, 0.25));
color: var(--link-color);
}
</style>

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import * as tr from "@generated/ftl";
import { bridgeCommand } from "@tslib/bridgecommand";
import { getPlatformString, registerShortcut } from "@tslib/shortcuts";
import { onEnterOrSpace } from "@tslib/keys";
import { onMount } from "svelte";
@ -15,21 +14,33 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { stickyIconSolid } from "$lib/components/icons";
import { context as editorFieldContext } from "./EditorField.svelte";
import type { Note } from "@generated/anki/notes_pb";
import { getNotetype, updateEditorNotetype } from "@generated/backend";
import { bridgeCommand } from "@tslib/bridgecommand";
const animated = !document.body.classList.contains("reduce-motion");
export let active: boolean;
export let show: boolean;
export let isLegacy: boolean;
const editorField = editorFieldContext.get();
const keyCombination = "F9";
export let index: number;
export let note: Note;
function toggle() {
bridgeCommand(`toggleSticky:${index}`, (value: boolean) => {
active = value;
});
async function toggle() {
if (isLegacy) {
bridgeCommand(`toggleSticky:${index}`, (value: boolean) => {
active = value;
});
} else {
active = !active;
const notetype = await getNotetype({ ntid: note.notetypeId });
notetype.fields[index].config!.sticky = active;
await updateEditorNotetype(notetype);
}
}
function shortcut(target: HTMLElement): () => void {
@ -52,12 +63,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<Badge
tooltip="{tr.editingToggleSticky()} ({getPlatformString(keyCombination)})"
widthMultiplier={0.7}
></Badge>
{#if active}
<Icon icon={stickyIconSolid} />
{:else}
<Icon icon={stickyIconHollow} />
{/if}
>
{#if active}
<Icon icon={stickyIconSolid} />
{:else}
<Icon icon={stickyIconHollow} />
{/if}
</Badge>
</span>
<style lang="scss">

View file

@ -19,10 +19,7 @@ import LabelButton from "$lib/components/LabelButton.svelte";
import WithContext from "$lib/components/WithContext.svelte";
import WithState from "$lib/components/WithState.svelte";
import BrowserEditor from "./BrowserEditor.svelte";
import NoteCreator from "./NoteCreator.svelte";
import * as editorContextKeys from "./NoteEditor.svelte";
import ReviewerEditor from "./ReviewerEditor.svelte";
import NoteEditor, * as editorContextKeys from "./NoteEditor.svelte";
declare global {
interface Selection {
@ -32,8 +29,10 @@ declare global {
}
}
import { modalsKey } from "$lib/components/context-keys";
import { ModuleName } from "@tslib/i18n";
import { mount } from "svelte";
import type { EditorMode } from "./types";
export const editorModules = [
ModuleName.EDITING,
@ -43,6 +42,9 @@ export const editorModules = [
ModuleName.NOTETYPES,
ModuleName.IMPORTING,
ModuleName.UNDO,
ModuleName.ADDING,
ModuleName.QT_MISC,
ModuleName.DECKS,
];
export const components = {
@ -55,33 +57,13 @@ export const components = {
export { editorToolbar } from "./editor-toolbar";
async function setupBrowserEditor(): Promise<void> {
await setupI18n({ modules: editorModules });
mount(BrowserEditor, { target: document.body, props: { uiResolve } });
}
async function setupNoteCreator(): Promise<void> {
await setupI18n({ modules: editorModules });
mount(NoteCreator, { target: document.body, props: { uiResolve } });
}
async function setupReviewerEditor(): Promise<void> {
await setupI18n({ modules: editorModules });
mount(ReviewerEditor, { target: document.body, props: { uiResolve } });
}
export function setupEditor(mode: "add" | "browse" | "review") {
switch (mode) {
case "add":
setupNoteCreator();
break;
case "browse":
setupBrowserEditor();
break;
case "review":
setupReviewerEditor();
break;
default:
alert("unexpected editor type");
export async function setupEditor(mode: EditorMode, isLegacy = false) {
if (!["add", "browser", "current"].includes(mode)) {
alert("unexpected editor type");
return;
}
const context = new Map();
context.set(modalsKey, new Map());
await setupI18n({ modules: editorModules });
mount(NoteEditor, { target: document.body, props: { uiResolve, mode, isLegacy }, context });
}

View file

@ -0,0 +1,120 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { ContextMenu, ContextMenuMouseEvent } from "$lib/context-menu";
import { openMedia, showInMediaFolder } from "@generated/backend";
import * as tr from "@generated/ftl";
import { bridgeCommand } from "@tslib/bridgecommand";
import { getSelection } from "@tslib/cross-browser";
import { get } from "svelte/store";
import type { EditingInputAPI } from "./EditingArea.svelte";
import type { NoteEditorAPI } from "./NoteEditor.svelte";
import { editingInputIsPlainText } from "./plain-text-input";
import { editingInputIsRichText } from "./rich-text-input";
import { writeBlobToClipboard } from "./rich-text-input/data-transfer";
import { EditorState } from "./types";
async function getFieldSelection(focusedInput: EditingInputAPI): Promise<string | null> {
if (editingInputIsRichText(focusedInput)) {
const selection = getSelection(await focusedInput.element);
if (selection && selection.toString()) {
return selection.toString();
}
} else if (editingInputIsPlainText(focusedInput)) {
const selection = (await focusedInput.codeMirror.editor).getSelection();
if (selection) {
return selection;
}
}
return null;
}
function getImageFromMouseEvent(event: ContextMenuMouseEvent, element: HTMLElement): string | null {
const elements = element.getRootNode().elementsFromPoint(event.clientX, event.clientY);
for (const element of elements) {
if (element instanceof HTMLImageElement && (new URL(element.src)).hostname === window.location.hostname) {
return decodeURI(element.getAttribute("src")!);
}
}
return null;
}
interface ContextMenuItem {
label: string;
action: () => void;
}
export function setupContextMenu(): [
(
event: ContextMenuMouseEvent,
noteEditor: NoteEditorAPI,
focusedInput: EditingInputAPI | null,
contextMenu: ContextMenu,
) => Promise<void>,
ContextMenuItem[],
] {
const contextMenuItems: ContextMenuItem[] = $state([]);
async function onContextMenu(
event: ContextMenuMouseEvent,
noteEditor: NoteEditorAPI,
focusedInput: EditingInputAPI | null,
contextMenu: ContextMenu,
) {
contextMenuItems.length = 0;
contextMenuItems.push({
label: tr.editingPaste(),
action: () => {
bridgeCommand("paste");
},
});
const selection = focusedInput ? await getFieldSelection(focusedInput) : null;
if (selection) {
contextMenuItems.push({
label: tr.editingCut(),
action: () => {
bridgeCommand("cut");
},
}, {
label: tr.actionsCopy(),
action: () => {
bridgeCommand("copy");
},
});
}
let imagePath: string | null = null;
if (get(noteEditor.state) === EditorState.ImageOcclusionMasks) {
imagePath = get(noteEditor.lastIOImagePath);
} else if (focusedInput && editingInputIsRichText(focusedInput)) {
imagePath = getImageFromMouseEvent(event, await focusedInput.element);
}
if (imagePath) {
contextMenuItems.push({
label: tr.editingCopyImage(),
action: async () => {
const image = await fetch(imagePath);
const blob = await image.blob();
await writeBlobToClipboard(blob);
},
}, {
label: tr.editingOpenImage(),
action: () => {
openMedia({ val: imagePath });
},
}, {
label: tr.editingShowInFolder(),
action: () => {
showInMediaFolder({ val: imagePath });
},
});
}
if (contextMenuItems.length > 0) {
contextMenu?.show(event);
}
}
return [onContextMenu, contextMenuItems];
}

View file

@ -3,10 +3,10 @@
import { BLOCK_ELEMENTS } from "@tslib/dom";
import { CustomElementArray } from "../editable/decorated";
import { FrameElement } from "../editable/frame-element";
import { FrameEnd, FrameStart } from "../editable/frame-handle";
import { Mathjax } from "../editable/mathjax-element.svelte";
import { CustomElementArray } from "$lib/editable/decorated";
import { FrameElement } from "$lib/editable/frame-element";
import { FrameEnd, FrameStart } from "$lib/editable/frame-handle";
import { Mathjax } from "$lib/editable/mathjax-element.svelte";
import { parsingInstructions } from "./plain-text-input";
const decoratedElements = new CustomElementArray();

View file

@ -1,12 +1,12 @@
/* Copyright: Ankitects Pty Ltd and contributors
* License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */
@import "../lib/sass/base";
@import "../../lib/sass/base";
$btn-disabled-opacity: 0.4;
@import "bootstrap/scss/buttons";
@import "bootstrap/scss/button-group";
@import "../lib/sass/bootstrap-tooltip";
@import "../../lib/sass/bootstrap-tooltip";
html,
body {

Some files were not shown because too many files have changed in this diff Show more