diff --git a/qt/aqt/__init__.py b/qt/aqt/__init__.py
index 53bdc3c92..74c4919a7 100644
--- a/qt/aqt/__init__.py
+++ b/qt/aqt/__init__.py
@@ -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],
diff --git a/qt/aqt/addcards.py b/qt/aqt/addcards.py
index 3b0421b30..8ea8b808e 100644
--- a/qt/aqt/addcards.py
+++ b/qt/aqt/addcards.py
@@ -14,6 +14,7 @@ from anki.models import NotetypeId
from anki.notes import Note, NoteId
from anki.utils import html_to_text_line, is_mac
from aqt import AnkiQt, gui_hooks
+from aqt.addcards_legacy import *
from aqt.deckchooser import DeckChooser
from aqt.notetypechooser import NotetypeChooser
from aqt.qt import *
@@ -30,7 +31,7 @@ from aqt.utils import (
)
-class AddCards(QMainWindow):
+class NewAddCards(QMainWindow):
def __init__(self, mw: AnkiQt) -> None:
super().__init__(None, Qt.WindowType.Window)
self._close_event_has_cleaned_up = False
@@ -79,7 +80,7 @@ class AddCards(QMainWindow):
self.setAndFocusNote(new_note)
def setupEditor(self) -> None:
- self.editor = aqt.editor.Editor(
+ self.editor = aqt.editor.NewEditor(
self.mw,
self.form.fieldsArea,
self,
@@ -244,7 +245,7 @@ class AddCards(QMainWindow):
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()
diff --git a/qt/aqt/addcards_legacy.py b/qt/aqt/addcards_legacy.py
new file mode 100644
index 000000000..86e8a25b1
--- /dev/null
+++ b/qt/aqt/addcards_legacy.py
@@ -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, 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: OpChanges) -> None:
+ # only used for detecting changed sticky fields on close
+ self._last_added_note = note
+
+ self.addHistory(note)
+
+ tooltip(tr.adding_added(), 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
diff --git a/qt/aqt/browser/browser.py b/qt/aqt/browser/browser.py
index 6e7af72cd..6be4d33de 100644
--- a/qt/aqt/browser/browser.py
+++ b/qt/aqt/browser/browser.py
@@ -27,7 +27,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
@@ -77,7 +76,7 @@ from aqt.utils import (
tr,
)
-from ..addcards import AddCards
+from ..addcards import NewAddCards as AddCards
from ..changenotetype import change_notetype_dialog
from .card_info import BrowserCardInfo
from .find_and_replace import FindAndReplaceDialog
@@ -111,7 +110,7 @@ class MockModel:
class Browser(QMainWindow):
mw: AnkiQt
col: Collection
- editor: Editor | None
+ editor: aqt.editor.NewEditor | None
table: Table
def __init__(
@@ -267,7 +266,7 @@ class Browser(QMainWindow):
return None
def add_card(self, deck_id: DeckId):
- add_cards = cast(AddCards, aqt.dialogs.open("AddCards", self.mw))
+ add_cards = cast(AddCards, aqt.dialogs.open("NewAddCards", self.mw))
add_cards.set_deck(deck_id)
if note_type_id := self.get_active_note_type_id():
@@ -392,7 +391,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
@@ -592,12 +591,14 @@ 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,
@@ -806,7 +807,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
@@ -1264,3 +1265,4 @@ class Browser(QMainWindow):
line_edit = self.form.searchEdit.lineEdit()
assert line_edit is not None
return line_edit
+ return line_edit
diff --git a/qt/aqt/editcurrent.py b/qt/aqt/editcurrent.py
index 4cd8dcb0c..1b42c5d83 100644
--- a/qt/aqt/editcurrent.py
+++ b/qt/aqt/editcurrent.py
@@ -7,11 +7,12 @@ from collections.abc import Callable
import aqt.editor
from anki.collection import OpChanges
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
@@ -22,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,
@@ -46,7 +47,7 @@ class EditCurrent(QMainWindow):
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:
diff --git a/qt/aqt/editcurrent_legacy.py b/qt/aqt/editcurrent_legacy.py
new file mode 100644
index 000000000..d4e969c21
--- /dev/null
+++ b/qt/aqt/editcurrent_legacy.py
@@ -0,0 +1,94 @@
+# 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
+ onReset = on_operation_did_execute
diff --git a/qt/aqt/editor.py b/qt/aqt/editor.py
index 5610d64ed..394751065 100644
--- a/qt/aqt/editor.py
+++ b/qt/aqt/editor.py
@@ -10,7 +10,6 @@ import mimetypes
import os
from collections.abc import Callable
from dataclasses import dataclass
-from enum import Enum
from random import randrange
from typing import Any
@@ -21,58 +20,16 @@ from anki.models import NotetypeId
from anki.notes import Note, NoteId
from anki.utils import is_win
from aqt import AnkiQt, gui_hooks
+from aqt.editor_legacy import *
from aqt.qt import *
from aqt.sound import av_player
from aqt.utils import shortcut, showWarning
from aqt.webview import AnkiWebView, AnkiWebViewKind
-pics = ("jpg", "jpeg", "png", "gif", "svg", "webp", "ico", "avif")
-audio = (
- "3gp",
- "aac",
- "avi",
- "flac",
- "flv",
- "m4a",
- "mkv",
- "mov",
- "mp3",
- "mp4",
- "mpeg",
- "mpg",
- "oga",
- "ogg",
- "ogv",
- "ogx",
- "opus",
- "spx",
- "swf",
- "wav",
- "webm",
-)
-
-
-class EditorMode(Enum):
- ADD_CARDS = 0
- EDIT_CURRENT = 1
- BROWSER = 2
-
-
-class EditorState(Enum):
- """
- Current input state of the editing UI.
- """
-
- INITIAL = -1
- FIELDS = 0
- IO_PICKER = 1
- IO_MASKS = 2
- IO_FIELDS = 3
-
def on_editor_ready(func: Callable) -> Callable:
@functools.wraps(func)
- def decorated(self: Editor, *args: Any, **kwargs: Any) -> None:
+ def decorated(self: NewEditor, *args: Any, **kwargs: Any) -> None:
if self._ready:
func(self, *args, **kwargs)
else:
@@ -96,7 +53,7 @@ class NoteInfo:
self.mid = NotetypeId(int(self.mid))
-class Editor:
+class NewEditor:
"""The screen that embeds an editing widget should listen for changes via
the `operation_did_execute` hook, and call set_note() when the editor needs
redrawing.
@@ -152,7 +109,7 @@ class Editor:
self.outerLayout = l
def add_webview(self) -> None:
- self.web = EditorWebView(self.widget, self)
+ self.web = NewEditorWebView(self.widget, self)
self.web.set_bridge_command(self.onBridgeCmd, self)
self.web.hide_while_preserving_layout()
self.outerLayout.addWidget(self.web, 1)
@@ -213,7 +170,7 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
self,
icon: str | None,
cmd: str,
- func: Callable[[Editor], None],
+ func: Callable[[NewEditor], None],
tip: str = "",
label: str = "",
id: str | None = None,
@@ -224,7 +181,7 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
) -> str:
"""Assign func to bridge cmd, register shortcut, return button"""
- def wrapped_func(editor: Editor) -> None:
+ def wrapped_func(editor: NewEditor) -> None:
self.call_after_note_saved(functools.partial(func, editor), keepFocus=True)
self._links[cmd] = wrapped_func
@@ -553,11 +510,11 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
def _init_links(self) -> None:
self._links: dict[str, Callable] = dict(
- fields=Editor.onFields,
- cards=Editor.onCardLayout,
- paste=Editor.onPaste,
- cut=Editor.onCut,
- copy=Editor.onCopy,
+ fields=NewEditor.onFields,
+ cards=NewEditor.onCardLayout,
+ paste=NewEditor.onPaste,
+ cut=NewEditor.onCut,
+ copy=NewEditor.onCopy,
)
def get_note_info(self, on_done: Callable[[NoteInfo], None]) -> None:
@@ -571,8 +528,8 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
######################################################################
-class EditorWebView(AnkiWebView):
- def __init__(self, parent: QWidget, editor: Editor) -> None:
+class NewEditorWebView(AnkiWebView):
+ def __init__(self, parent: QWidget, editor: NewEditor) -> None:
AnkiWebView.__init__(self, kind=AnkiWebViewKind.EDITOR)
self.editor = editor
self.setAcceptDrops(True)
@@ -592,3 +549,4 @@ class EditorWebView(AnkiWebView):
def onPaste(self) -> None:
self.triggerPageAction(QWebEnginePage.WebAction.Paste)
+ self.triggerPageAction(QWebEnginePage.WebAction.Paste)
diff --git a/qt/aqt/editor_legacy.py b/qt/aqt/editor_legacy.py
new file mode 100644
index 000000000..138deed7a
--- /dev/null
+++ b/qt/aqt/editor_legacy.py
@@ -0,0 +1,1790 @@
+# Copyright: Ankitects Pty Ltd and contributors
+# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+from __future__ import annotations
+
+import base64
+import functools
+import html
+import itertools
+import json
+import mimetypes
+import os
+import re
+import urllib.error
+import urllib.parse
+import urllib.request
+import warnings
+from collections.abc import Callable
+from enum import Enum
+from random import randrange
+from typing import Any, Iterable, Match, cast
+
+import bs4
+import requests
+from bs4 import BeautifulSoup
+
+import aqt
+import aqt.forms
+import aqt.operations
+import aqt.sound
+from anki._legacy import deprecated
+from anki.cards import Card
+from anki.collection import Config, SearchNode
+from anki.consts import MODEL_CLOZE
+from anki.hooks import runFilter
+from anki.httpclient import HttpClient
+from anki.models import NotetypeDict, NotetypeId, StockNotetype
+from anki.notes import Note, NoteFieldsCheckResult, NoteId
+from anki.utils import checksum, is_lin, is_win, namedtmp
+from aqt import AnkiQt, colors, gui_hooks
+from aqt.operations import QueryOp
+from aqt.operations.note import update_note
+from aqt.operations.notetype import update_notetype_legacy
+from aqt.qt import *
+from aqt.sound import av_player
+from aqt.theme import theme_manager
+from aqt.utils import (
+ HelpPage,
+ KeyboardModifiersPressed,
+ disable_help_button,
+ getFile,
+ openFolder,
+ openHelp,
+ qtMenuShortcutWorkaround,
+ restoreGeom,
+ saveGeom,
+ shortcut,
+ show_in_folder,
+ showInfo,
+ showWarning,
+ tooltip,
+ tr,
+)
+from aqt.webview import AnkiWebView, AnkiWebViewKind
+
+pics = ("jpg", "jpeg", "png", "gif", "svg", "webp", "ico", "avif")
+audio = (
+ "3gp",
+ "aac",
+ "avi",
+ "flac",
+ "flv",
+ "m4a",
+ "mkv",
+ "mov",
+ "mp3",
+ "mp4",
+ "mpeg",
+ "mpg",
+ "oga",
+ "ogg",
+ "ogv",
+ "ogx",
+ "opus",
+ "spx",
+ "swf",
+ "wav",
+ "webm",
+)
+
+
+class EditorMode(Enum):
+ ADD_CARDS = 0
+ EDIT_CURRENT = 1
+ BROWSER = 2
+
+
+class EditorState(Enum):
+ """
+ Current input state of the editing UI.
+ """
+
+ INITIAL = -1
+ FIELDS = 0
+ IO_PICKER = 1
+ IO_MASKS = 2
+ IO_FIELDS = 3
+
+
+class Editor:
+ """The screen that embeds an editing widget should listen for changes via
+ the `operation_did_execute` hook, and call set_note() when the editor needs
+ redrawing.
+
+ The editor will cause that hook to be fired when it saves changes. To avoid
+ an unwanted refresh, the parent widget should check if handler
+ corresponds to this editor instance, and ignore the change if it does.
+ """
+
+ def __init__(
+ self,
+ mw: AnkiQt,
+ widget: QWidget,
+ parentWindow: QWidget,
+ addMode: bool | None = None,
+ *,
+ editor_mode: EditorMode = EditorMode.EDIT_CURRENT,
+ ) -> None:
+ self.mw = mw
+ self.widget = widget
+ self.parentWindow = parentWindow
+ self.note: Note | None = None
+ # legacy argument provided?
+ if addMode is not None:
+ editor_mode = EditorMode.ADD_CARDS if addMode else EditorMode.EDIT_CURRENT
+ self.addMode = editor_mode is EditorMode.ADD_CARDS
+ self.editorMode = editor_mode
+ self.currentField: int | None = None
+ # Similar to currentField, but not set to None on a blur. May be
+ # outside the bounds of the current notetype.
+ self.last_field_index: int | None = None
+ # used when creating a copy of an existing note
+ self.orig_note_id: NoteId | None = None
+ # current card, for card layout
+ self.card: Card | None = None
+ self.state: EditorState = EditorState.INITIAL
+ # used for the io mask editor's context menu
+ self.last_io_image_path: str | None = None
+ self._init_links()
+ self.setupOuter()
+ self.add_webview()
+ self.setupWeb()
+ self.setupShortcuts()
+ gui_hooks.editor_did_init(self)
+
+ # Initial setup
+ ############################################################
+
+ def setupOuter(self) -> None:
+ l = QVBoxLayout()
+ l.setContentsMargins(0, 0, 0, 0)
+ l.setSpacing(0)
+ self.widget.setLayout(l)
+ self.outerLayout = l
+
+ def add_webview(self) -> None:
+ self.web = EditorWebView(self.widget, self)
+ self.web.set_bridge_command(self.onBridgeCmd, self)
+ self.outerLayout.addWidget(self.web, 1)
+
+ def setupWeb(self) -> None:
+ if self.editorMode == EditorMode.ADD_CARDS:
+ mode = "add"
+ elif self.editorMode == EditorMode.BROWSER:
+ mode = "browse"
+ else:
+ mode = "review"
+
+ # then load page
+ self.web.stdHtml(
+ "",
+ css=["css/editor.css"],
+ js=[
+ "js/mathjax.js",
+ "js/editor.js",
+ ],
+ context=self,
+ default_css=False,
+ )
+ self.web.eval(f"setupEditor('{mode}')")
+ self.web.show()
+
+ lefttopbtns: list[str] = []
+ gui_hooks.editor_did_init_left_buttons(lefttopbtns, self)
+
+ lefttopbtns_defs = [
+ f"uiPromise.then((noteEditor) => noteEditor.toolbar.notetypeButtons.appendButton({{ component: editorToolbar.Raw, props: {{ html: {json.dumps(button)} }} }}, -1));"
+ for button in lefttopbtns
+ ]
+ lefttopbtns_js = "\n".join(lefttopbtns_defs)
+
+ righttopbtns: list[str] = []
+ gui_hooks.editor_did_init_buttons(righttopbtns, self)
+ # legacy filter
+ righttopbtns = runFilter("setupEditorButtons", righttopbtns, self)
+
+ righttopbtns_defs = ", ".join([json.dumps(button) for button in righttopbtns])
+ righttopbtns_js = (
+ f"""
+require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].toolbar.toolbar.append({{
+ component: editorToolbar.AddonButtons,
+ id: "addons",
+ props: {{ buttons: [ {righttopbtns_defs} ] }},
+}}));
+"""
+ if len(righttopbtns) > 0
+ else ""
+ )
+
+ self.web.eval(f"{lefttopbtns_js} {righttopbtns_js}")
+
+ # Top buttons
+ ######################################################################
+
+ def resourceToData(self, path: str) -> str:
+ """Convert a file (specified by a path) into a data URI."""
+ if not os.path.exists(path):
+ raise FileNotFoundError
+ mime, _ = mimetypes.guess_type(path)
+ with open(path, "rb") as fp:
+ data = fp.read()
+ data64 = b"".join(base64.encodebytes(data).splitlines())
+ return f"data:{mime};base64,{data64.decode('ascii')}"
+
+ def addButton(
+ self,
+ icon: str | None,
+ cmd: str,
+ func: Callable[[Editor], None],
+ tip: str = "",
+ label: str = "",
+ id: str | None = None,
+ toggleable: bool = False,
+ keys: str | None = None,
+ disables: bool = True,
+ rightside: bool = True,
+ ) -> str:
+ """Assign func to bridge cmd, register shortcut, return button"""
+
+ def wrapped_func(editor: Editor) -> None:
+ self.call_after_note_saved(functools.partial(func, editor), keepFocus=True)
+
+ self._links[cmd] = wrapped_func
+
+ if keys:
+
+ def on_activated() -> None:
+ wrapped_func(self)
+
+ if toggleable:
+ # generate a random id for triggering toggle
+ id = id or str(randrange(1_000_000))
+
+ def on_hotkey() -> None:
+ on_activated()
+ self.web.eval(
+ f'toggleEditorButton(document.getElementById("{id}"));'
+ )
+
+ else:
+ on_hotkey = on_activated
+
+ QShortcut( # type: ignore
+ QKeySequence(keys),
+ self.widget,
+ activated=on_hotkey,
+ )
+
+ btn = self._addButton(
+ icon,
+ cmd,
+ tip=tip,
+ label=label,
+ id=id,
+ toggleable=toggleable,
+ disables=disables,
+ rightside=rightside,
+ )
+ return btn
+
+ def _addButton(
+ self,
+ icon: str | None,
+ cmd: str,
+ tip: str = "",
+ label: str = "",
+ id: str | None = None,
+ toggleable: bool = False,
+ disables: bool = True,
+ rightside: bool = True,
+ ) -> str:
+ title_attribute = tip
+
+ if icon:
+ if icon.startswith("qrc:/"):
+ iconstr = icon
+ elif os.path.isabs(icon):
+ iconstr = self.resourceToData(icon)
+ else:
+ iconstr = f"/_anki/imgs/{icon}.png"
+ image_element = f'
'
+ else:
+ image_element = ""
+
+ if not label and icon:
+ label_element = ""
+ elif label:
+ label_element = label
+ else:
+ label_element = cmd
+
+ title_attribute = shortcut(title_attribute)
+ id_attribute_assignment = f"id={id}" if id else ""
+ class_attribute = "linkb" if rightside else "rounded"
+ if not disables:
+ class_attribute += " perm"
+
+ return f""""""
+
+ def setupShortcuts(self) -> None:
+ # if a third element is provided, enable shortcut even when no field selected
+ cuts: list[tuple] = []
+ gui_hooks.editor_did_init_shortcuts(cuts, self)
+ for row in cuts:
+ if len(row) == 2:
+ keys, fn = row
+ fn = self._addFocusCheck(fn)
+ else:
+ keys, fn, _ = row
+ QShortcut(QKeySequence(keys), self.widget, activated=fn) # type: ignore
+
+ def _addFocusCheck(self, fn: Callable) -> Callable:
+ def checkFocus() -> None:
+ if self.currentField is None:
+ return
+ fn()
+
+ return checkFocus
+
+ def onFields(self) -> None:
+ self.call_after_note_saved(self._onFields)
+
+ def _onFields(self) -> None:
+ from aqt.fields import FieldDialog
+
+ FieldDialog(self.mw, self.note_type(), parent=self.parentWindow)
+
+ def onCardLayout(self) -> None:
+ self.call_after_note_saved(self._onCardLayout)
+
+ def _onCardLayout(self) -> None:
+ from aqt.clayout import CardLayout
+
+ if self.card:
+ ord = self.card.ord
+ else:
+ ord = 0
+
+ assert self.note is not None
+ CardLayout(
+ self.mw,
+ self.note,
+ ord=ord,
+ parent=self.parentWindow,
+ fill_empty=False,
+ )
+ if is_win:
+ self.parentWindow.activateWindow()
+
+ # JS->Python bridge
+ ######################################################################
+
+ def onBridgeCmd(self, cmd: str) -> Any:
+ if not self.note:
+ # shutdown
+ return
+
+ # focus lost or key/button pressed?
+ if cmd.startswith("blur") or cmd.startswith("key"):
+ (type, ord_str, nid_str, txt) = cmd.split(":", 3)
+ ord = int(ord_str)
+ try:
+ nid = int(nid_str)
+ except ValueError:
+ nid = 0
+ if nid != self.note.id:
+ print("ignored late blur")
+ return
+
+ try:
+ self.note.fields[ord] = self.mungeHTML(txt)
+ except IndexError:
+ print("ignored late blur after notetype change")
+ return
+
+ if not self.addMode:
+ self._save_current_note()
+ if type == "blur":
+ self.currentField = None
+ # run any filters
+ if gui_hooks.editor_did_unfocus_field(False, self.note, ord):
+ # something updated the note; update it after a subsequent focus
+ # event has had time to fire
+ self.mw.progress.timer(
+ 100, self.loadNoteKeepingFocus, False, parent=self.widget
+ )
+ else:
+ self._check_and_update_duplicate_display_async()
+ else:
+ gui_hooks.editor_did_fire_typing_timer(self.note)
+ self._check_and_update_duplicate_display_async()
+
+ # focused into field?
+ elif cmd.startswith("focus"):
+ (type, num) = cmd.split(":", 1)
+ self.last_field_index = self.currentField = int(num)
+ gui_hooks.editor_did_focus_field(self.note, self.currentField)
+
+ elif cmd.startswith("toggleStickyAll"):
+ model = self.note_type()
+ flds = model["flds"]
+
+ any_sticky = any([fld["sticky"] for fld in flds])
+ result = []
+ for fld in flds:
+ if not any_sticky or fld["sticky"]:
+ fld["sticky"] = not fld["sticky"]
+
+ result.append(fld["sticky"])
+
+ update_notetype_legacy(parent=self.mw, notetype=model).run_in_background(
+ initiator=self
+ )
+
+ return result
+
+ elif cmd.startswith("toggleSticky"):
+ (type, num) = cmd.split(":", 1)
+ ord = int(num)
+
+ model = self.note_type()
+ fld = model["flds"][ord]
+ new_state = not fld["sticky"]
+ fld["sticky"] = new_state
+
+ update_notetype_legacy(parent=self.mw, notetype=model).run_in_background(
+ initiator=self
+ )
+
+ return new_state
+
+ elif cmd.startswith("lastTextColor"):
+ (_, textColor) = cmd.split(":", 1)
+ assert self.mw.pm.profile is not None
+ self.mw.pm.profile["lastTextColor"] = textColor
+
+ elif cmd.startswith("lastHighlightColor"):
+ (_, highlightColor) = cmd.split(":", 1)
+ assert self.mw.pm.profile is not None
+ self.mw.pm.profile["lastHighlightColor"] = highlightColor
+
+ elif cmd.startswith("saveTags"):
+ (type, tagsJson) = cmd.split(":", 1)
+ self.note.tags = json.loads(tagsJson)
+
+ gui_hooks.editor_did_update_tags(self.note)
+ if not self.addMode:
+ self._save_current_note()
+
+ elif cmd.startswith("setTagsCollapsed"):
+ (type, collapsed_string) = cmd.split(":", 1)
+ collapsed = collapsed_string == "true"
+ self.setTagsCollapsed(collapsed)
+
+ elif cmd.startswith("editorState"):
+ (_, new_state_id, old_state_id) = cmd.split(":", 2)
+ self.signal_state_change(
+ EditorState(int(new_state_id)), EditorState(int(old_state_id))
+ )
+
+ elif cmd.startswith("ioImageLoaded"):
+ (_, path_or_nid_data) = cmd.split(":", 1)
+ path_or_nid = json.loads(path_or_nid_data)
+ if self.addMode:
+ gui_hooks.editor_mask_editor_did_load_image(self, path_or_nid)
+ else:
+ gui_hooks.editor_mask_editor_did_load_image(
+ self, NoteId(int(path_or_nid))
+ )
+
+ elif cmd in self._links:
+ return self._links[cmd](self)
+
+ else:
+ print("uncaught cmd", cmd)
+
+ def mungeHTML(self, txt: str) -> str:
+ return gui_hooks.editor_will_munge_html(txt, self)
+
+ def signal_state_change(
+ self, new_state: EditorState, old_state: EditorState
+ ) -> None:
+ self.state = new_state
+ gui_hooks.editor_state_did_change(self, new_state, old_state)
+
+ # Setting/unsetting the current note
+ ######################################################################
+
+ def set_note(
+ self,
+ note: Note | None,
+ hide: bool = True,
+ focusTo: int | None = None,
+ ) -> None:
+ "Make NOTE the current note."
+ self.note = note
+ self.currentField = None
+ if self.note:
+ self.loadNote(focusTo=focusTo)
+ elif hide:
+ self.widget.hide()
+
+ def loadNoteKeepingFocus(self) -> None:
+ self.loadNote(self.currentField)
+
+ def set_cloze_button(self) -> None:
+ action = "show" if self.note_type()["type"] == MODEL_CLOZE else "hide"
+ self.web.eval(
+ 'require("anki/ui").loaded.then(() =>'
+ f'require("anki/NoteEditor").instances[0].toolbar.toolbar.{action}("cloze")'
+ "); "
+ )
+
+ def set_image_occlusion_button(self) -> None:
+ action = "show" if self.current_notetype_is_image_occlusion() else "hide"
+ self.web.eval(
+ 'require("anki/ui").loaded.then(() =>'
+ f'require("anki/NoteEditor").instances[0].toolbar.toolbar.{action}("image-occlusion-button")'
+ "); "
+ )
+
+ def loadNote(self, focusTo: int | None = None) -> None:
+ if not self.note:
+ return
+
+ data = [
+ (fld, self.mw.col.media.escape_media_filenames(val))
+ for fld, val in self.note.items()
+ ]
+
+ note_type = self.note_type()
+ flds = note_type["flds"]
+ collapsed = [fld["collapsed"] for fld in flds]
+ cloze_fields_ords = self.mw.col.models.cloze_fields(self.note.mid)
+ cloze_fields = [ord in cloze_fields_ords for ord in range(len(flds))]
+ plain_texts = [fld.get("plainText", False) for fld in flds]
+ descriptions = [fld.get("description", "") for fld in flds]
+ notetype_meta = {"id": self.note.mid, "modTime": note_type["mod"]}
+
+ self.widget.show()
+
+ note_fields_status = self.note.fields_check()
+
+ def oncallback(arg: Any) -> None:
+ if not self.note:
+ return
+ self.setupForegroundButton()
+ # we currently do this synchronously to ensure we load before the
+ # sidebar on browser startup
+ self._update_duplicate_display(note_fields_status)
+ if focusTo is not None:
+ self.web.setFocus()
+ self.set_cloze_button()
+ self.set_image_occlusion_button()
+ gui_hooks.editor_did_load_note(self)
+
+ assert self.mw.pm.profile is not None
+ text_color = self.mw.pm.profile.get("lastTextColor", "#0000ff")
+ highlight_color = self.mw.pm.profile.get("lastHighlightColor", "#0000ff")
+
+ js = f"""
+ saveSession();
+ setFields({json.dumps(data)});
+ setIsImageOcclusion({json.dumps(self.current_notetype_is_image_occlusion())});
+ setNotetypeMeta({json.dumps(notetype_meta)});
+ setCollapsed({json.dumps(collapsed)});
+ setClozeFields({json.dumps(cloze_fields)});
+ setPlainTexts({json.dumps(plain_texts)});
+ setDescriptions({json.dumps(descriptions)});
+ setFonts({json.dumps(self.fonts())});
+ focusField({json.dumps(focusTo)});
+ setNoteId({json.dumps(self.note.id)});
+ setColorButtons({json.dumps([text_color, highlight_color])});
+ setTags({json.dumps(self.note.tags)});
+ setTagsCollapsed({json.dumps(self.mw.pm.tags_collapsed(self.editorMode))});
+ setMathjaxEnabled({json.dumps(self.mw.col.get_config("renderMathjax", True))});
+ setShrinkImages({json.dumps(self.mw.col.get_config("shrinkEditorImages", True))});
+ setCloseHTMLTags({json.dumps(self.mw.col.get_config("closeHTMLTags", True))});
+ triggerChanges();
+ """
+
+ if self.addMode:
+ sticky = [field["sticky"] for field in self.note_type()["flds"]]
+ js += " setSticky(%s);" % json.dumps(sticky)
+
+ if self.current_notetype_is_image_occlusion():
+ io_field_indices = self.mw.backend.get_image_occlusion_fields(self.note.mid)
+ image_field = self.note.fields[io_field_indices.image]
+ self.last_io_image_path = self.extract_img_path_from_html(image_field)
+
+ if self.editorMode is not EditorMode.ADD_CARDS:
+ io_options = self._create_edit_io_options(note_id=self.note.id)
+ js += " setupMaskEditor(%s);" % json.dumps(io_options)
+ elif orig_note_id := self.orig_note_id:
+ self.orig_note_id = None
+ io_options = self._create_clone_io_options(orig_note_id)
+ js += " setupMaskEditor(%s);" % json.dumps(io_options)
+
+ js = gui_hooks.editor_will_load_note(js, self.note, self)
+ self.web.evalWithCallback(
+ f'require("anki/ui").loaded.then(() => {{ {js} }})', oncallback
+ )
+
+ def _save_current_note(self) -> None:
+ "Call after note is updated with data from webview."
+ if not self.note:
+ return
+
+ update_note(parent=self.widget, note=self.note).run_in_background(
+ initiator=self
+ )
+
+ def fonts(self) -> list[tuple[str, int, bool]]:
+ return [
+ (gui_hooks.editor_will_use_font_for_field(f["font"]), f["size"], f["rtl"])
+ for f in self.note_type()["flds"]
+ ]
+
+ def call_after_note_saved(
+ self, callback: Callable, keepFocus: bool = False
+ ) -> None:
+ "Save unsaved edits then call callback()."
+ if not self.note:
+ # calling code may not expect the callback to fire immediately
+ self.mw.progress.single_shot(10, callback)
+ return
+ self.web.evalWithCallback("saveNow(%d)" % keepFocus, lambda res: callback())
+
+ saveNow = call_after_note_saved
+
+ def _check_and_update_duplicate_display_async(self) -> None:
+ note = self.note
+ if not note:
+ return
+
+ def on_done(result: NoteFieldsCheckResult.V) -> None:
+ if self.note != note:
+ return
+ self._update_duplicate_display(result)
+
+ QueryOp(
+ parent=self.parentWindow,
+ op=lambda _: note.fields_check(),
+ success=on_done,
+ ).run_in_background()
+
+ checkValid = _check_and_update_duplicate_display_async
+
+ def _update_duplicate_display(self, result: NoteFieldsCheckResult.V) -> None:
+ assert self.note is not None
+ cols = [""] * len(self.note.fields)
+ cloze_hint = ""
+ if result == NoteFieldsCheckResult.DUPLICATE:
+ cols[0] = "dupe"
+ elif result == NoteFieldsCheckResult.NOTETYPE_NOT_CLOZE:
+ cloze_hint = tr.adding_cloze_outside_cloze_notetype()
+ elif result == NoteFieldsCheckResult.FIELD_NOT_CLOZE:
+ cloze_hint = tr.adding_cloze_outside_cloze_field()
+
+ self.web.eval(
+ 'require("anki/ui").loaded.then(() => {'
+ f"setBackgrounds({json.dumps(cols)});\n"
+ f"setClozeHint({json.dumps(cloze_hint)});\n"
+ "}); "
+ )
+
+ def showDupes(self) -> None:
+ assert self.note is not None
+ aqt.dialogs.open(
+ "Browser",
+ self.mw,
+ search=(
+ SearchNode(
+ dupe=SearchNode.Dupe(
+ notetype_id=self.note_type()["id"],
+ first_field=self.note.fields[0],
+ )
+ ),
+ ),
+ )
+
+ def fieldsAreBlank(self, previousNote: Note | None = None) -> bool:
+ if not self.note:
+ return True
+ m = self.note_type()
+ for c, f in enumerate(self.note.fields):
+ f = f.replace("
", "").strip()
+ notChangedvalues = {"", "
"}
+ if previousNote and m["flds"][c]["sticky"]:
+ notChangedvalues.add(previousNote.fields[c].replace("
", "").strip())
+ if f not in notChangedvalues:
+ return False
+ return True
+
+ def cleanup(self) -> None:
+ av_player.stop_and_clear_queue_if_caller(self.editorMode)
+ self.set_note(None)
+ # prevent any remaining evalWithCallback() events from firing after C++ object deleted
+ if self.web:
+ self.web.cleanup()
+ self.web = None # type: ignore
+
+ # legacy
+
+ setNote = set_note
+
+ # Tag handling
+ ######################################################################
+
+ def setupTags(self) -> None:
+ import aqt.tagedit
+
+ g = QGroupBox(self.widget)
+ g.setStyleSheet("border: 0")
+ tb = QGridLayout()
+ tb.setSpacing(12)
+ tb.setContentsMargins(2, 6, 2, 6)
+ # tags
+ l = QLabel(tr.editing_tags())
+ tb.addWidget(l, 1, 0)
+ self.tags = aqt.tagedit.TagEdit(self.widget)
+ qconnect(self.tags.lostFocus, self.on_tag_focus_lost)
+ self.tags.setToolTip(shortcut(tr.editing_jump_to_tags_with_ctrlandshiftandt()))
+ border = theme_manager.var(colors.BORDER)
+ self.tags.setStyleSheet(f"border: 1px solid {border}")
+ tb.addWidget(self.tags, 1, 1)
+ g.setLayout(tb)
+ self.outerLayout.addWidget(g)
+
+ def updateTags(self) -> None:
+ if self.tags.col != self.mw.col:
+ self.tags.setCol(self.mw.col)
+ if not self.tags.text() or not self.addMode:
+ assert self.note is not None
+ self.tags.setText(self.note.string_tags().strip())
+
+ def on_tag_focus_lost(self) -> None:
+ assert self.note is not None
+ self.note.tags = self.mw.col.tags.split(self.tags.text())
+ gui_hooks.editor_did_update_tags(self.note)
+ if not self.addMode:
+ self._save_current_note()
+
+ def blur_tags_if_focused(self) -> None:
+ if not self.note:
+ return
+ if self.tags.hasFocus():
+ self.widget.setFocus()
+
+ def hideCompleters(self) -> None:
+ self.tags.hideCompleter()
+
+ def onFocusTags(self) -> None:
+ self.tags.setFocus()
+
+ # legacy
+
+ def saveAddModeVars(self) -> None:
+ pass
+
+ saveTags = blur_tags_if_focused
+
+ # Audio/video/images
+ ######################################################################
+
+ def onAddMedia(self) -> None:
+ """Show a file selection screen, then add the selected media.
+ This expects initial setup to have been done by TemplateButtons.svelte."""
+ extension_filter = " ".join(
+ f"*.{extension}" for extension in sorted(itertools.chain(pics, audio))
+ )
+ filter = f"{tr.editing_media()} ({extension_filter})"
+
+ def accept(file: str) -> None:
+ self.resolve_media(file)
+
+ getFile(
+ parent=self.widget,
+ title=tr.editing_add_media(),
+ cb=cast(Callable[[Any], None], accept),
+ filter=filter,
+ key="media",
+ )
+
+ self.parentWindow.activateWindow()
+
+ def addMedia(self, path: str, canDelete: bool = False) -> None:
+ """Legacy routine used by add-ons to add a media file and update the current field.
+ canDelete is ignored."""
+
+ try:
+ html = self._addMedia(path)
+ except Exception as e:
+ showWarning(str(e))
+ return
+
+ self.web.eval(f"setFormat('inserthtml', {json.dumps(html)});")
+
+ def resolve_media(self, path: str) -> None:
+ """Finish inserting media into a field.
+ This expects initial setup to have been done by TemplateButtons.svelte."""
+ try:
+ html = self._addMedia(path)
+ except Exception as e:
+ showWarning(str(e))
+ return
+
+ self.web.eval(
+ f'require("anki/TemplateButtons").resolveMedia({json.dumps(html)})'
+ )
+
+ def _addMedia(self, path: str, canDelete: bool = False) -> str:
+ """Add to media folder and return local img or sound tag."""
+ # copy to media folder
+ fname = self.mw.col.media.add_file(path)
+ # return a local html link
+ return self.fnameToLink(fname)
+
+ def _addMediaFromData(self, fname: str, data: bytes) -> str:
+ return self.mw.col.media._legacy_write_data(fname, data)
+
+ def onRecSound(self) -> None:
+ aqt.sound.record_audio(
+ self.parentWindow,
+ self.mw,
+ True,
+ self.resolve_media,
+ )
+
+ # Media downloads
+ ######################################################################
+
+ def urlToLink(self, url: str, allowed_suffixes: Iterable[str] = ()) -> str:
+ fname = (
+ self.urlToFile(url, allowed_suffixes)
+ if allowed_suffixes
+ else self.urlToFile(url)
+ )
+ if not fname:
+ return '{}'.format(
+ url, html.escape(urllib.parse.unquote(url))
+ )
+ return self.fnameToLink(fname)
+
+ def fnameToLink(self, fname: str) -> str:
+ ext = fname.split(".")[-1].lower()
+ if ext in pics:
+ name = urllib.parse.quote(fname.encode("utf8"))
+ return f'
'
+ else:
+ av_player.play_file_with_caller(fname, self.editorMode)
+ return f"[sound:{html.escape(fname, quote=False)}]"
+
+ def urlToFile(
+ self, url: str, allowed_suffixes: Iterable[str] = pics + audio
+ ) -> str | None:
+ l = url.lower()
+ for suffix in allowed_suffixes:
+ if l.endswith(f".{suffix}"):
+ return self._retrieveURL(url)
+ # not a supported type
+ return None
+
+ def isURL(self, s: str) -> bool:
+ s = s.lower()
+ return (
+ s.startswith("http://")
+ or s.startswith("https://")
+ or s.startswith("ftp://")
+ or s.startswith("file://")
+ )
+
+ def inlinedImageToFilename(self, txt: str) -> str:
+ prefix = "data:image/"
+ suffix = ";base64,"
+ for ext in ("jpg", "jpeg", "png", "gif"):
+ fullPrefix = prefix + ext + suffix
+ if txt.startswith(fullPrefix):
+ b64data = txt[len(fullPrefix) :].strip()
+ data = base64.b64decode(b64data, validate=True)
+ if ext == "jpeg":
+ ext = "jpg"
+ return self._addPastedImage(data, ext)
+
+ return ""
+
+ def inlinedImageToLink(self, src: str) -> str:
+ fname = self.inlinedImageToFilename(src)
+ if fname:
+ return self.fnameToLink(fname)
+
+ return ""
+
+ def _pasted_image_filename(self, data: bytes, ext: str) -> str:
+ csum = checksum(data)
+ return f"paste-{csum}.{ext}"
+
+ def _read_pasted_image(self, mime: QMimeData) -> str:
+ image = QImage(mime.imageData())
+ buffer = QBuffer()
+ buffer.open(QBuffer.OpenModeFlag.ReadWrite)
+ if self.mw.col.get_config_bool(Config.Bool.PASTE_IMAGES_AS_PNG):
+ ext = "png"
+ quality = 50
+ else:
+ ext = "jpg"
+ quality = 80
+ image.save(buffer, ext, quality)
+ buffer.reset()
+ data = bytes(buffer.readAll()) # type: ignore
+ fname = self._pasted_image_filename(data, ext)
+ path = namedtmp(fname)
+ with open(path, "wb") as file:
+ file.write(data)
+
+ return path
+
+ def _addPastedImage(self, data: bytes, ext: str) -> str:
+ # hash and write
+ fname = self._pasted_image_filename(data, ext)
+ return self._addMediaFromData(fname, data)
+
+ def _retrieveURL(self, url: str) -> str | None:
+ "Download file into media folder and return local filename or None."
+ local = url.lower().startswith("file://")
+ # fetch it into a temporary folder
+ self.mw.progress.start(immediate=not local, parent=self.parentWindow)
+ content_type = None
+ error_msg: str | None = None
+ try:
+ if local:
+ # urllib doesn't understand percent-escaped utf8, but requires things like
+ # '#' to be escaped.
+ url = urllib.parse.unquote(url)
+ url = url.replace("%", "%25")
+ url = url.replace("#", "%23")
+ req = urllib.request.Request(
+ url, None, {"User-Agent": "Mozilla/5.0 (compatible; Anki)"}
+ )
+ with urllib.request.urlopen(req) as response:
+ filecontents = response.read()
+ else:
+ with HttpClient() as client:
+ client.timeout = 30
+ with client.get(url) as response:
+ if response.status_code != 200:
+ error_msg = tr.qt_misc_unexpected_response_code(
+ val=response.status_code,
+ )
+ return None
+ filecontents = response.content
+ content_type = response.headers.get("content-type")
+ except (urllib.error.URLError, requests.exceptions.RequestException) as e:
+ error_msg = tr.editing_an_error_occurred_while_opening(val=str(e))
+ return None
+ finally:
+ self.mw.progress.finish()
+ if error_msg:
+ showWarning(error_msg)
+ # strip off any query string
+ url = re.sub(r"\?.*?$", "", url)
+ fname = os.path.basename(urllib.parse.unquote(url))
+ if not fname.strip():
+ fname = "paste"
+ if content_type:
+ fname = self.mw.col.media.add_extension_based_on_mime(fname, content_type)
+
+ return self.mw.col.media.write_data(fname, filecontents)
+
+ # Paste/drag&drop
+ ######################################################################
+
+ removeTags = ["script", "iframe", "object", "style"]
+
+ def _pastePreFilter(self, html: str, internal: bool) -> str:
+ # https://anki.tenderapp.com/discussions/ankidesktop/39543-anki-is-replacing-the-character-by-when-i-exit-the-html-edit-mode-ctrlshiftx
+ if html.find(">") < 0:
+ return html
+
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore", UserWarning)
+ doc = BeautifulSoup(html, "html.parser")
+
+ if not internal:
+ for tag_name in self.removeTags:
+ for node in doc(tag_name):
+ node.decompose()
+
+ # convert p tags to divs
+ for node in doc("p"):
+ if hasattr(node, "name"):
+ node.name = "div"
+
+ for element in doc("img"):
+ if not isinstance(element, bs4.Tag):
+ continue
+ tag = element
+ try:
+ src = tag["src"]
+ except KeyError:
+ # for some bizarre reason, mnemosyne removes src elements
+ # from missing media
+ continue
+
+ # in internal pastes, rewrite mediasrv references to relative
+ if internal:
+ m = re.match(r"http://127.0.0.1:\d+/(.*)$", str(src))
+ if m:
+ tag["src"] = m.group(1)
+ # in external pastes, download remote media
+ elif isinstance(src, str) and self.isURL(src):
+ fname = self._retrieveURL(src)
+ if fname:
+ tag["src"] = fname
+ elif isinstance(src, str) and src.startswith("data:image/"):
+ # and convert inlined data
+ tag["src"] = self.inlinedImageToFilename(str(src))
+
+ html = str(doc)
+ return html
+
+ def doPaste(self, html: str, internal: bool, extended: bool = False) -> None:
+ html = self._pastePreFilter(html, internal)
+ if extended:
+ ext = "true"
+ else:
+ ext = "false"
+ self.web.eval(f"pasteHTML({json.dumps(html)}, {json.dumps(internal)}, {ext});")
+ gui_hooks.editor_did_paste(self, html, internal, extended)
+
+ def doDrop(
+ self, html: str, internal: bool, extended: bool, cursor_pos: QPoint
+ ) -> None:
+ def pasteIfField(ret: bool) -> None:
+ if ret:
+ self.doPaste(html, internal, extended)
+
+ zoom = self.web.zoomFactor()
+ x, y = int(cursor_pos.x() / zoom), int(cursor_pos.y() / zoom)
+
+ self.web.evalWithCallback(f"focusIfField({x}, {y});", pasteIfField)
+
+ def onPaste(self) -> None:
+ self.web.onPaste()
+
+ def onCutOrCopy(self) -> None:
+ self.web.user_cut_or_copied()
+
+ # Image occlusion
+ ######################################################################
+
+ def current_notetype_is_image_occlusion(self) -> bool:
+ if not self.note:
+ return False
+
+ return (
+ self.note_type().get("originalStockKind", None)
+ == StockNotetype.OriginalStockKind.ORIGINAL_STOCK_KIND_IMAGE_OCCLUSION
+ )
+
+ def setup_mask_editor(self, image_path: str) -> None:
+ try:
+ if self.editorMode == EditorMode.ADD_CARDS:
+ self.setup_mask_editor_for_new_note(
+ image_path=image_path, notetype_id=0
+ )
+ else:
+ assert self.note is not None
+ self.setup_mask_editor_for_existing_note(
+ note_id=self.note.id, image_path=image_path
+ )
+ except Exception as e:
+ showWarning(str(e))
+
+ def select_image_and_occlude(self) -> None:
+ """Show a file selection screen, then get selected image path."""
+ extension_filter = " ".join(
+ f"*.{extension}" for extension in sorted(itertools.chain(pics))
+ )
+ filter = f"{tr.editing_media()} ({extension_filter})"
+
+ getFile(
+ parent=self.widget,
+ title=tr.editing_add_media(),
+ cb=cast(Callable[[Any], None], self.setup_mask_editor),
+ filter=filter,
+ key="media",
+ )
+
+ self.parentWindow.activateWindow()
+
+ def extract_img_path_from_html(self, html: str) -> str | None:
+ assert self.note is not None
+ # with allowed_suffixes=pics, all non-pics will be rendered as s and won't be included here
+ if not (images := self.mw.col.media.files_in_str(self.note.mid, html)):
+ return None
+ image_path = urllib.parse.unquote(images[0])
+ return os.path.join(self.mw.col.media.dir(), image_path)
+
+ def select_image_from_clipboard_and_occlude(self) -> None:
+ """Set up the mask editor for the image in the clipboard."""
+
+ clipboard = self.mw.app.clipboard()
+ assert clipboard is not None
+ mime = clipboard.mimeData()
+ assert mime is not None
+ # try checking for urls first, fallback to image data
+ if (
+ (html := self.web._processUrls(mime, allowed_suffixes=pics))
+ and (path := self.extract_img_path_from_html(html))
+ ) or (mime.hasImage() and (path := self._read_pasted_image(mime))):
+ self.setup_mask_editor(path)
+ self.parentWindow.activateWindow()
+ else:
+ showWarning(tr.editing_no_image_found_on_clipboard())
+ return
+
+ def setup_mask_editor_for_new_note(
+ self,
+ image_path: str,
+ notetype_id: NotetypeId | int = 0,
+ ):
+ """Set-up IO mask editor for adding new notes
+ Presupposes that active editor notetype is an image occlusion notetype
+ Args:
+ image_path: Absolute path to image.
+ notetype_id: ID of note type to use. Provided ID must belong to an
+ image occlusion notetype. Set this to 0 to auto-select the first
+ found image occlusion notetype in the user's collection.
+ """
+ image_field_html = self._addMedia(image_path)
+ self.last_io_image_path = self.extract_img_path_from_html(image_field_html)
+ io_options = self._create_add_io_options(
+ image_path=image_path,
+ image_field_html=image_field_html,
+ notetype_id=notetype_id,
+ )
+ self._setup_mask_editor(io_options)
+
+ def setup_mask_editor_for_existing_note(
+ self, note_id: NoteId, image_path: str | None = None
+ ):
+ """Set-up IO mask editor for editing existing notes
+ Presupposes that active editor notetype is an image occlusion notetype
+ Args:
+ note_id: ID of note to edit.
+ image_path: (Optional) Absolute path to image that should replace current
+ image
+ """
+ io_options = self._create_edit_io_options(note_id)
+ if image_path:
+ image_field_html = self._addMedia(image_path)
+ self.last_io_image_path = self.extract_img_path_from_html(image_field_html)
+ self.web.eval(f"resetIOImage({json.dumps(image_path)})")
+ self.web.eval(f"setImageField({json.dumps(image_field_html)})")
+ self._setup_mask_editor(io_options)
+
+ def reset_image_occlusion(self) -> None:
+ self.web.eval("resetIOImageLoaded()")
+
+ def update_occlusions_field(self) -> None:
+ self.web.eval("saveOcclusions()")
+
+ def _setup_mask_editor(self, io_options: dict):
+ self.web.eval(
+ 'require("anki/ui").loaded.then(() =>'
+ f"setupMaskEditor({json.dumps(io_options)})"
+ "); "
+ )
+
+ @staticmethod
+ def _create_add_io_options(
+ image_path: str, image_field_html: str, notetype_id: NotetypeId | int = 0
+ ) -> dict:
+ return {
+ "mode": {"kind": "add", "imagePath": image_path, "notetypeId": notetype_id},
+ "html": image_field_html,
+ }
+
+ @staticmethod
+ def _create_clone_io_options(orig_note_id: NoteId) -> dict:
+ return {
+ "mode": {"kind": "add", "clonedNoteId": orig_note_id},
+ }
+
+ @staticmethod
+ def _create_edit_io_options(note_id: NoteId) -> dict:
+ return {"mode": {"kind": "edit", "noteId": note_id}}
+
+ # Legacy editing routines
+ ######################################################################
+
+ _js_legacy = "this routine has been moved into JS, and will be removed soon"
+
+ @deprecated(info=_js_legacy)
+ def onHtmlEdit(self) -> None:
+ field = self.currentField
+ self.call_after_note_saved(lambda: self._onHtmlEdit(field))
+
+ @deprecated(info=_js_legacy)
+ def _onHtmlEdit(self, field: int) -> None:
+ assert self.note is not None
+ d = QDialog(self.widget, Qt.WindowType.Window)
+ form = aqt.forms.edithtml.Ui_Dialog()
+ form.setupUi(d)
+ restoreGeom(d, "htmlEditor")
+ disable_help_button(d)
+ qconnect(
+ form.buttonBox.helpRequested, lambda: openHelp(HelpPage.EDITING_FEATURES)
+ )
+ font = QFont("Courier")
+ font.setStyleHint(QFont.StyleHint.TypeWriter)
+ form.textEdit.setFont(font)
+ form.textEdit.setPlainText(self.note.fields[field])
+ d.show()
+ form.textEdit.moveCursor(QTextCursor.MoveOperation.End)
+ d.exec()
+ html = form.textEdit.toPlainText()
+ if html.find(">") > -1:
+ # filter html through beautifulsoup so we can strip out things like a
+ # leading
+ html_escaped = self.mw.col.media.escape_media_filenames(html)
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore", UserWarning)
+ html_escaped = str(BeautifulSoup(html_escaped, "html.parser"))
+ html = self.mw.col.media.escape_media_filenames(
+ html_escaped, unescape=True
+ )
+ self.note.fields[field] = html
+ if not self.addMode:
+ self._save_current_note()
+ self.loadNote(focusTo=field)
+ saveGeom(d, "htmlEditor")
+
+ @deprecated(info=_js_legacy)
+ def toggleBold(self) -> None:
+ self.web.eval("setFormat('bold');")
+
+ @deprecated(info=_js_legacy)
+ def toggleItalic(self) -> None:
+ self.web.eval("setFormat('italic');")
+
+ @deprecated(info=_js_legacy)
+ def toggleUnderline(self) -> None:
+ self.web.eval("setFormat('underline');")
+
+ @deprecated(info=_js_legacy)
+ def toggleSuper(self) -> None:
+ self.web.eval("setFormat('superscript');")
+
+ @deprecated(info=_js_legacy)
+ def toggleSub(self) -> None:
+ self.web.eval("setFormat('subscript');")
+
+ @deprecated(info=_js_legacy)
+ def removeFormat(self) -> None:
+ self.web.eval("setFormat('removeFormat');")
+
+ @deprecated(info=_js_legacy)
+ def onCloze(self) -> None:
+ self.call_after_note_saved(self._onCloze, keepFocus=True)
+
+ @deprecated(info=_js_legacy)
+ def _onCloze(self) -> None:
+ # check that the model is set up for cloze deletion
+ if self.note_type()["type"] != MODEL_CLOZE:
+ if self.addMode:
+ tooltip(tr.editing_warning_cloze_deletions_will_not_work())
+ else:
+ showInfo(tr.editing_to_make_a_cloze_deletion_on())
+ return
+ # find the highest existing cloze
+ highest = 0
+ assert self.note is not None
+ for _, val in list(self.note.items()):
+ m = re.findall(r"\{\{c(\d+)::", val)
+ if m:
+ highest = max(highest, sorted(int(x) for x in m)[-1])
+ # reuse last?
+ if not KeyboardModifiersPressed().alt:
+ highest += 1
+ # must start at 1
+ highest = max(1, highest)
+ self.web.eval("wrap('{{c%d::', '}}');" % highest)
+
+ def setupForegroundButton(self) -> None:
+ assert self.mw.pm.profile is not None
+ self.fcolour = self.mw.pm.profile.get("lastColour", "#00f")
+
+ # use last colour
+ @deprecated(info=_js_legacy)
+ def onForeground(self) -> None:
+ self._wrapWithColour(self.fcolour)
+
+ # choose new colour
+ @deprecated(info=_js_legacy)
+ def onChangeCol(self) -> None:
+ if is_lin:
+ new = QColorDialog.getColor(
+ QColor(self.fcolour),
+ None,
+ None,
+ QColorDialog.ColorDialogOption.DontUseNativeDialog,
+ )
+ else:
+ new = QColorDialog.getColor(QColor(self.fcolour), None)
+ # native dialog doesn't refocus us for some reason
+ self.parentWindow.activateWindow()
+ if new.isValid():
+ self.fcolour = new.name()
+ self.onColourChanged()
+ self._wrapWithColour(self.fcolour)
+
+ @deprecated(info=_js_legacy)
+ def _updateForegroundButton(self) -> None:
+ pass
+
+ @deprecated(info=_js_legacy)
+ def onColourChanged(self) -> None:
+ self._updateForegroundButton()
+ assert self.mw.pm.profile is not None
+ self.mw.pm.profile["lastColour"] = self.fcolour
+
+ @deprecated(info=_js_legacy)
+ def _wrapWithColour(self, colour: str) -> None:
+ self.web.eval(f"setFormat('forecolor', '{colour}')")
+
+ @deprecated(info=_js_legacy)
+ def onAdvanced(self) -> None:
+ m = QMenu(self.mw)
+
+ for text, handler, shortcut in (
+ (tr.editing_mathjax_inline(), self.insertMathjaxInline, "Ctrl+M, M"),
+ (tr.editing_mathjax_block(), self.insertMathjaxBlock, "Ctrl+M, E"),
+ (
+ tr.editing_mathjax_chemistry(),
+ self.insertMathjaxChemistry,
+ "Ctrl+M, C",
+ ),
+ (tr.editing_latex(), self.insertLatex, "Ctrl+T, T"),
+ (tr.editing_latex_equation(), self.insertLatexEqn, "Ctrl+T, E"),
+ (tr.editing_latex_math_env(), self.insertLatexMathEnv, "Ctrl+T, M"),
+ (tr.editing_edit_html(), self.onHtmlEdit, "Ctrl+Shift+X"),
+ ):
+ a = m.addAction(text)
+ assert a is not None
+ qconnect(a.triggered, handler)
+ a.setShortcut(QKeySequence(shortcut))
+
+ qtMenuShortcutWorkaround(m)
+
+ m.exec(QCursor.pos())
+
+ @deprecated(info=_js_legacy)
+ def insertLatex(self) -> None:
+ self.web.eval("wrap('[latex]', '[/latex]');")
+
+ @deprecated(info=_js_legacy)
+ def insertLatexEqn(self) -> None:
+ self.web.eval("wrap('[$]', '[/$]');")
+
+ @deprecated(info=_js_legacy)
+ def insertLatexMathEnv(self) -> None:
+ self.web.eval("wrap('[$$]', '[/$$]');")
+
+ @deprecated(info=_js_legacy)
+ def insertMathjaxInline(self) -> None:
+ self.web.eval("wrap('\\\\(', '\\\\)');")
+
+ @deprecated(info=_js_legacy)
+ def insertMathjaxBlock(self) -> None:
+ self.web.eval("wrap('\\\\[', '\\\\]');")
+
+ @deprecated(info=_js_legacy)
+ def insertMathjaxChemistry(self) -> None:
+ self.web.eval("wrap('\\\\(\\\\ce{', '}\\\\)');")
+
+ def toggleMathjax(self) -> None:
+ self.mw.col.set_config(
+ "renderMathjax", not self.mw.col.get_config("renderMathjax", False)
+ )
+ # hackily redraw the page
+ self.setupWeb()
+ self.loadNoteKeepingFocus()
+
+ def toggleShrinkImages(self) -> None:
+ self.mw.col.set_config(
+ "shrinkEditorImages",
+ not self.mw.col.get_config("shrinkEditorImages", True),
+ )
+
+ def toggleCloseHTMLTags(self) -> None:
+ self.mw.col.set_config(
+ "closeHTMLTags",
+ not self.mw.col.get_config("closeHTMLTags", True),
+ )
+
+ def setTagsCollapsed(self, collapsed: bool) -> None:
+ aqt.mw.pm.set_tags_collapsed(self.editorMode, collapsed)
+
+ # Links from HTML
+ ######################################################################
+
+ def _init_links(self) -> None:
+ self._links: dict[str, Callable] = dict(
+ fields=Editor.onFields,
+ cards=Editor.onCardLayout,
+ bold=Editor.toggleBold,
+ italic=Editor.toggleItalic,
+ underline=Editor.toggleUnderline,
+ super=Editor.toggleSuper,
+ sub=Editor.toggleSub,
+ clear=Editor.removeFormat,
+ colour=Editor.onForeground,
+ changeCol=Editor.onChangeCol,
+ cloze=Editor.onCloze,
+ attach=Editor.onAddMedia,
+ record=Editor.onRecSound,
+ more=Editor.onAdvanced,
+ dupes=Editor.showDupes,
+ paste=Editor.onPaste,
+ cutOrCopy=Editor.onCutOrCopy,
+ htmlEdit=Editor.onHtmlEdit,
+ mathjaxInline=Editor.insertMathjaxInline,
+ mathjaxBlock=Editor.insertMathjaxBlock,
+ mathjaxChemistry=Editor.insertMathjaxChemistry,
+ toggleMathjax=Editor.toggleMathjax,
+ toggleShrinkImages=Editor.toggleShrinkImages,
+ toggleCloseHTMLTags=Editor.toggleCloseHTMLTags,
+ addImageForOcclusion=Editor.select_image_and_occlude,
+ addImageForOcclusionFromClipboard=Editor.select_image_from_clipboard_and_occlude,
+ )
+
+ def note_type(self) -> NotetypeDict:
+ assert self.note is not None
+ note_type = self.note.note_type()
+ assert note_type is not None
+ return note_type
+
+
+# Pasting, drag & drop, and keyboard layouts
+######################################################################
+
+
+class EditorWebView(AnkiWebView):
+ def __init__(self, parent: QWidget, editor: Editor) -> None:
+ AnkiWebView.__init__(self, kind=AnkiWebViewKind.EDITOR)
+ self.editor = editor
+ self.setAcceptDrops(True)
+ self._store_field_content_on_next_clipboard_change = False
+ # when we detect the user copying from a field, we store the content
+ # here, and use it when they paste, so we avoid filtering field content
+ self._internal_field_text_for_paste: str | None = None
+ self._last_known_clipboard_mime: QMimeData | None = None
+ clip = self.editor.mw.app.clipboard()
+ assert clip is not None
+ clip.dataChanged.connect(self._on_clipboard_change)
+ gui_hooks.editor_web_view_did_init(self)
+
+ def user_cut_or_copied(self) -> None:
+ self._store_field_content_on_next_clipboard_change = True
+ self._internal_field_text_for_paste = None
+
+ def _on_clipboard_change(
+ self, mode: QClipboard.Mode = QClipboard.Mode.Clipboard
+ ) -> None:
+ self._last_known_clipboard_mime = self._clipboard().mimeData(mode)
+ if self._store_field_content_on_next_clipboard_change:
+ # if the flag was set, save the field data
+ self._internal_field_text_for_paste = self._get_clipboard_html_for_field(
+ mode
+ )
+ self._store_field_content_on_next_clipboard_change = False
+ elif self._internal_field_text_for_paste != self._get_clipboard_html_for_field(
+ mode
+ ):
+ # if we've previously saved the field, blank it out if the clipboard state has changed
+ self._internal_field_text_for_paste = None
+
+ def _get_clipboard_html_for_field(self, mode: QClipboard.Mode) -> str | None:
+ clip = self._clipboard()
+ if not (mime := clip.mimeData(mode)):
+ return None
+ if not mime.hasHtml():
+ return None
+ return mime.html()
+
+ def onCut(self) -> None:
+ self.triggerPageAction(QWebEnginePage.WebAction.Cut)
+
+ def onCopy(self) -> None:
+ self.triggerPageAction(QWebEnginePage.WebAction.Copy)
+
+ def on_copy_image(self) -> None:
+ self.triggerPageAction(QWebEnginePage.WebAction.CopyImageToClipboard)
+
+ def _opened_context_menu_on_image(self) -> bool:
+ if not hasattr(self, "lastContextMenuRequest"):
+ return False
+ context_menu_request = self.lastContextMenuRequest()
+ assert context_menu_request is not None
+ return (
+ context_menu_request.mediaType()
+ == context_menu_request.MediaType.MediaTypeImage
+ )
+
+ def _wantsExtendedPaste(self) -> bool:
+ strip_html = self.editor.mw.col.get_config_bool(
+ Config.Bool.PASTE_STRIPS_FORMATTING
+ )
+ if KeyboardModifiersPressed().shift:
+ strip_html = not strip_html
+ return not strip_html
+
+ def _onPaste(self, mode: QClipboard.Mode) -> None:
+ # Since _on_clipboard_change doesn't always trigger properly on macOS, we do a double check if any changes were made before pasting
+ clipboard = self._clipboard()
+ if self._last_known_clipboard_mime != clipboard.mimeData(mode):
+ self._on_clipboard_change(mode)
+ extended = self._wantsExtendedPaste()
+ if html := self._internal_field_text_for_paste:
+ print("reuse internal")
+ self.editor.doPaste(html, True, extended)
+ else:
+ if not (mime := clipboard.mimeData(mode=mode)):
+ return
+ print("use clipboard")
+ html, internal = self._processMime(mime, extended)
+ if html:
+ self.editor.doPaste(html, internal, extended)
+
+ def onPaste(self) -> None:
+ self._onPaste(QClipboard.Mode.Clipboard)
+
+ def onMiddleClickPaste(self) -> None:
+ self._onPaste(QClipboard.Mode.Selection)
+
+ def dragEnterEvent(self, evt: QDragEnterEvent | None) -> None:
+ assert evt is not None
+ evt.accept()
+
+ def dropEvent(self, evt: QDropEvent | None) -> None:
+ assert evt is not None
+ extended = self._wantsExtendedPaste()
+ mime = evt.mimeData()
+ assert mime is not None
+
+ if (
+ self.editor.state is EditorState.IO_PICKER
+ and (html := self._processUrls(mime, allowed_suffixes=pics))
+ and (path := self.editor.extract_img_path_from_html(html))
+ ):
+ self.editor.setup_mask_editor(path)
+ return
+
+ evt_pos = evt.position()
+ cursor_pos = QPoint(int(evt_pos.x()), int(evt_pos.y()))
+
+ if evt.source() and mime.hasHtml():
+ # don't filter html from other fields
+ html, internal = mime.html(), True
+ else:
+ html, internal = self._processMime(mime, extended, drop_event=True)
+
+ if not html:
+ return
+
+ self.editor.doDrop(html, internal, extended, cursor_pos)
+
+ # returns (html, isInternal)
+ def _processMime(
+ self, mime: QMimeData, extended: bool = False, drop_event: bool = False
+ ) -> tuple[str, bool]:
+ # print("html=%s image=%s urls=%s txt=%s" % (
+ # mime.hasHtml(), mime.hasImage(), mime.hasUrls(), mime.hasText()))
+ # print("html", mime.html())
+ # print("urls", mime.urls())
+ # print("text", mime.text())
+
+ internal = False
+
+ mime = gui_hooks.editor_will_process_mime(
+ mime, self, internal, extended, drop_event
+ )
+
+ # try various content types in turn
+ if mime.hasHtml():
+ html_content = mime.html()[11:] if internal else mime.html()
+ return html_content, internal
+
+ # given _processUrls' extra allowed_suffixes kwarg, placate the typechecker
+ def process_url(mime: QMimeData, extended: bool = False) -> str | None:
+ return self._processUrls(mime, extended)
+
+ # favour url if it's a local link
+ if (
+ mime.hasUrls()
+ and (urls := mime.urls())
+ and urls[0].toString().startswith("file://")
+ ):
+ types = (process_url, self._processImage, self._processText)
+ else:
+ types = (self._processImage, process_url, self._processText)
+
+ for fn in types:
+ html = fn(mime, extended)
+ if html:
+ return html, True
+ return "", False
+
+ def _processUrls(
+ self,
+ mime: QMimeData,
+ extended: bool = False,
+ allowed_suffixes: Iterable[str] = (),
+ ) -> str | None:
+ if not mime.hasUrls():
+ return None
+
+ buf = ""
+ for qurl in mime.urls():
+ url = qurl.toString()
+ # chrome likes to give us the URL twice with a \n
+ if lines := url.splitlines():
+ url = lines[0]
+ buf += self.editor.urlToLink(url, allowed_suffixes=allowed_suffixes)
+
+ return buf
+
+ def _processText(self, mime: QMimeData, extended: bool = False) -> str | None:
+ if not mime.hasText():
+ return None
+
+ txt = mime.text()
+ processed = []
+ lines = txt.split("\n")
+
+ for line in lines:
+ for token in re.split(r"(\S+)", line):
+ # inlined data in base64?
+ if extended and token.startswith("data:image/"):
+ processed.append(self.editor.inlinedImageToLink(token))
+ elif extended and self.editor.isURL(token):
+ # if the user is pasting an image or sound link, convert it to local, otherwise paste as a hyperlink
+ link = self.editor.urlToLink(token)
+ processed.append(link)
+ else:
+ token = html.escape(token).replace("\t", " " * 4)
+
+ # if there's more than one consecutive space,
+ # use non-breaking spaces for the second one on
+ def repl(match: Match) -> str:
+ return f"{match.group(1).replace(' ', ' ')} "
+
+ token = re.sub(" ( +)", repl, token)
+ processed.append(token)
+
+ processed.append("
")
+ # remove last
+ processed.pop()
+ return "".join(processed)
+
+ def _processImage(self, mime: QMimeData, extended: bool = False) -> str | None:
+ if not mime.hasImage():
+ return None
+ path = self.editor._read_pasted_image(mime)
+ fname = self.editor._addMedia(path)
+
+ return fname
+
+ def contextMenuEvent(self, evt: QContextMenuEvent | None) -> None:
+ m = QMenu(self)
+ if self.hasSelection():
+ self._add_cut_action(m)
+ self._add_copy_action(m)
+ a = m.addAction(tr.editing_paste())
+ assert a is not None
+ qconnect(a.triggered, self.onPaste)
+ if self.editor.state is EditorState.IO_MASKS and (
+ path := self.editor.last_io_image_path
+ ):
+ self._add_image_menu_with_path(m, path)
+ elif self._opened_context_menu_on_image():
+ self._add_image_menu(m)
+ gui_hooks.editor_will_show_context_menu(self, m)
+ m.popup(QCursor.pos())
+
+ def _add_cut_action(self, menu: QMenu) -> None:
+ a = menu.addAction(tr.editing_cut())
+ assert a is not None
+ qconnect(a.triggered, self.onCut)
+
+ def _add_copy_action(self, menu: QMenu) -> None:
+ a = menu.addAction(tr.actions_copy())
+ assert a is not None
+ qconnect(a.triggered, self.onCopy)
+
+ def _add_image_menu(self, menu: QMenu) -> None:
+ a = menu.addAction(tr.editing_copy_image())
+ assert a is not None
+ qconnect(a.triggered, self.on_copy_image)
+
+ context_menu_request = self.lastContextMenuRequest()
+ assert context_menu_request is not None
+ url = context_menu_request.mediaUrl()
+ file_name = url.fileName()
+ path = os.path.join(self.editor.mw.col.media.dir(), file_name)
+ self._add_image_menu_with_path(menu, path)
+
+ def _add_image_menu_with_path(self, menu: QMenu, path: str) -> None:
+ a = menu.addAction(tr.editing_open_image())
+ assert a is not None
+ qconnect(a.triggered, lambda: openFolder(path))
+
+ a = menu.addAction(tr.editing_show_in_folder())
+ assert a is not None
+ qconnect(a.triggered, lambda: show_in_folder(path))
+
+ def _clipboard(self) -> QClipboard:
+ clipboard = self.editor.mw.app.clipboard()
+ assert clipboard is not None
+ return clipboard
+
+
+# QFont returns "Kozuka Gothic Pro L" but WebEngine expects "Kozuka Gothic Pro Light"
+# - there may be other cases like a trailing 'Bold' that need fixing, but will
+# wait for further reports first.
+def fontMungeHack(font: str) -> str:
+ return re.sub(" L$", " Light", font)
+
+
+def munge_html(txt: str, editor: Editor) -> str:
+ return "" if txt in ("
", "
") else txt
+
+
+def remove_null_bytes(txt: str, editor: Editor) -> str:
+ # misbehaving apps may include a null byte in the text
+ return txt.replace("\x00", "")
+
+
+def reverse_url_quoting(txt: str, editor: Editor) -> str:
+ # reverse the url quoting we added to get images to display
+ return editor.mw.col.media.escape_media_filenames(txt, unescape=True)
+
+
+gui_hooks.editor_will_use_font_for_field.append(fontMungeHack)
+gui_hooks.editor_will_munge_html.append(munge_html) # type: ignore
+gui_hooks.editor_will_munge_html.append(remove_null_bytes) # type: ignore
+gui_hooks.editor_will_munge_html.append(reverse_url_quoting) # type: ignore
+gui_hooks.editor_will_munge_html.append(reverse_url_quoting) # type: ignore
diff --git a/qt/aqt/main.py b/qt/aqt/main.py
index c707d1b2a..0ec2aa75c 100644
--- a/qt/aqt/main.py
+++ b/qt/aqt/main.py
@@ -1280,14 +1280,20 @@ title="{}" {}>{}""".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="{}" {}>{}""".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)
diff --git a/qt/aqt/mediasrv.py b/qt/aqt/mediasrv.py
index 052dac291..4328b1189 100644
--- a/qt/aqt/mediasrv.py
+++ b/qt/aqt/mediasrv.py
@@ -608,10 +608,10 @@ def editor_op_changes_request(endpoint: str) -> bytes:
response.ParseFromString(output)
def handle_on_main() -> None:
- from aqt.editor import Editor
+ from aqt.editor import NewEditor
handler = aqt.mw.app.activeWindow()
- if handler and isinstance(getattr(handler, "editor", None), Editor):
+ if handler and isinstance(getattr(handler, "editor", None), NewEditor):
handler = handler.editor # type: ignore
on_op_finished(aqt.mw, response, handler)
@@ -808,10 +808,10 @@ def close_add_cards() -> bytes:
req.ParseFromString(request.data)
def handle_on_main() -> None:
- from aqt.addcards import AddCards
+ from aqt.addcards import NewAddCards
window = aqt.mw.app.activeWindow()
- if isinstance(window, AddCards):
+ 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))
@@ -820,10 +820,10 @@ def close_add_cards() -> bytes:
def close_edit_current() -> bytes:
def handle_on_main() -> None:
- from aqt.editcurrent import EditCurrent
+ from aqt.editcurrent import NewEditCurrent
window = aqt.mw.app.activeWindow()
- if isinstance(window, EditCurrent):
+ if isinstance(window, NewEditCurrent):
window.close()
aqt.mw.taskman.run_on_main(lambda: QTimer.singleShot(0, handle_on_main))
@@ -1070,3 +1070,5 @@ def _extract_dynamic_get_request(path: str) -> DynamicRequest | None:
return legacy_page_data
else:
return None
+ return None
+ return None
diff --git a/qt/tools/genhooks_gui.py b/qt/tools/genhooks_gui.py
index 33838c46b..8c3a7f994 100644
--- a/qt/tools/genhooks_gui.py
+++ b/qt/tools/genhooks_gui.py
@@ -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.