Anki/qt/aqt/editor.py
2025-08-27 03:39:20 +03:00

564 lines
18 KiB
Python

# 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 json
import mimetypes
import os
from collections.abc import Callable
from dataclasses import dataclass
from random import randrange
from typing import Any
from anki.cards import Card
from anki.hooks import runFilter
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
def on_editor_ready(func: Callable) -> Callable:
@functools.wraps(func)
def decorated(self: NewEditor, *args: Any, **kwargs: Any) -> None:
if self._ready:
func(self, *args, **kwargs)
else:
self._ready_callbacks.append(lambda: func(self, *args, **kwargs))
return decorated
@dataclass
class NoteInfo:
"Used to hold partial note info fetched from the webview"
id: NoteId | None
mid: NotetypeId
fields: list[str]
def __post_init__(self) -> None:
if self.id is not None:
self.id = NoteId(int(self.id))
if self.mid is not None:
self.mid = NotetypeId(int(self.mid))
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.
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.nid: NoteId | 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
# current card, for card layout
self.card: Card | None = None
self.state: EditorState = EditorState.INITIAL
self._ready = False
self._ready_callbacks: list[Callable[[], None]] = []
self._init_links()
self.setupOuter()
self.add_webview()
self.setupWeb()
self.setupShortcuts()
# 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 = NewEditorWebView(self.widget, self)
self.web.set_bridge_command(self.onBridgeCmd, self)
self.web.hide_while_preserving_layout()
self.outerLayout.addWidget(self.web, 1)
def setupWeb(self) -> None:
editor_key = self.mw.pm.editor_key(self.editorMode)
self.web.load_sveltekit_page(f"editor/?mode={editor_key}")
self.web.allow_drops = True
def _set_ready(self) -> None:
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}")
gui_hooks.editor_did_init(self)
self._ready = True
for cb in self._ready_callbacks:
cb()
# 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[[NewEditor], 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: NewEditor) -> 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'<img class="topbut" src="{iconstr}">'
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"""<button tabindex=-1
{id_attribute_assignment}
class="anki-addon-button {class_attribute}"
type="button"
title="{title_attribute}"
data-cantoggle="{int(toggleable)}"
data-command="{cmd}"
>
{image_element}
{label_element}
</button>"""
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
def on_note_info(note_info: NoteInfo) -> None:
note_type = self.mw.col.models.get(note_info.mid)
assert note_type is not None
FieldDialog(self.mw, note_type, parent=self.parentWindow)
self.get_note_info(on_note_info)
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
def on_note_info(note_info: NoteInfo) -> None:
if note_info.id:
note = self.mw.col.get_note(note_info.id)
else:
note = Note(self.mw.col, note_info.mid)
note.fields = note_info.fields
CardLayout(
self.mw,
note,
ord=ord,
parent=self.parentWindow,
fill_empty=False,
)
if is_win:
self.parentWindow.activateWindow()
self.get_note_info(on_note_info)
# JS->Python bridge
######################################################################
def onBridgeCmd(self, cmd: str) -> Any:
# focus lost or key/button pressed?
if cmd.startswith("blur") or cmd.startswith("key"):
(type, _) = cmd.split(":", 1)
if type == "blur":
self.currentField = None
else:
pass
# focused into field?
elif cmd.startswith("focus"):
(type, num) = cmd.split(":", 1)
self.last_field_index = self.currentField = int(num)
elif cmd.startswith("saveTags"):
pass
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 == "editorReady":
self._set_ready()
elif cmd in self._links:
return self._links[cmd](self)
else:
print("uncaught cmd", cmd)
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_nid(
self,
nid: NoteId | None,
mid: int,
focus_to: int | None = None,
) -> None:
"Make note with ID `nid` the current note."
self.nid = nid
self.currentField = None
self.load_note(mid, focus_to=focus_to)
def set_note(
self,
note: Note | None,
hide: bool = True,
focusTo: int | None = None,
) -> None:
"Make NOTE the current note."
self.currentField = None
if note:
self.nid = note.id
self.load_note(mid=note.mid, focus_to=focusTo)
elif hide:
self.widget.hide()
@on_editor_ready
def load_note(
self,
mid: int,
original_note_id: NoteId | None = None,
focus_to: int | None = None,
) -> None:
self.widget.show()
def oncallback(arg: Any) -> None:
if not self.nid:
return
# we currently do this synchronously to ensure we load before the
# sidebar on browser startup
if focus_to is not None:
self.web.setFocus()
gui_hooks.editor_did_load_note(self)
assert self.mw.pm.profile is not None
load_args = dict(
nid=self.nid,
notetypeId=mid,
focusTo=focus_to,
originalNoteId=original_note_id,
reviewerCardId=self.mw.reviewer.card.id if self.mw.reviewer.card else None,
initial=True,
)
js = f"loadNote({json.dumps(load_args)});"
self.web.evalWithCallback(
f'require("anki/ui").loaded.then(() => {{ {js} }})', oncallback
)
def reload_note(self) -> None:
self.web.eval("reloadNote();")
def reload_note_if_empty(self) -> None:
self.web.eval("reloadNoteIfEmpty();")
def call_after_note_saved(
self, callback: Callable, keepFocus: bool = False
) -> None:
"Save unsaved edits then call callback()."
if not self.nid:
# 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 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
setNote = set_note
# Paste/drag&drop
######################################################################
def onPaste(self) -> None:
self.web.onPaste()
def onCut(self) -> None:
self.web.onCut()
def onCopy(self) -> None:
self.web.onCopy()
# 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)
else:
self.setup_mask_editor_for_existing_note(image_path=image_path)
except Exception as e:
showWarning(str(e))
def setup_mask_editor_for_new_note(self, image_path: str):
"""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.
"""
self.web.eval(
'require("anki/ui").loaded.then(() =>'
f"setupMaskEditorForNewNote({json.dumps(image_path)})"
"); "
)
def setup_mask_editor_for_existing_note(self, 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:
image_path: (Optional) Absolute path to image that should replace current
image
"""
self.web.eval(
'require("anki/ui").loaded.then(() =>'
f"setupMaskEditorForExistingNote({json.dumps(image_path)})"
"); "
)
# Links from HTML
######################################################################
def _init_links(self) -> None:
self._links: dict[str, Callable] = dict(
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:
def wrapped_on_done(note_info: dict[str, Any]) -> None:
on_done(NoteInfo(**note_info))
self.web.evalWithCallback("getNoteInfo()", wrapped_on_done)
# Pasting, drag & drop, and keyboard layouts
######################################################################
class NewEditorWebView(AnkiWebView):
def __init__(self, parent: QWidget, editor: NewEditor) -> None:
AnkiWebView.__init__(self, kind=AnkiWebViewKind.EDITOR)
self.editor = editor
self.setAcceptDrops(True)
self.settings().setAttribute( # type: ignore
QWebEngineSettings.WebAttribute.JavascriptCanPaste, True
)
self.settings().setAttribute( # type: ignore
QWebEngineSettings.WebAttribute.JavascriptCanAccessClipboard, True
)
gui_hooks.editor_web_view_did_init(self)
def onCut(self) -> None:
self.triggerPageAction(QWebEnginePage.WebAction.Cut)
def onCopy(self) -> None:
self.triggerPageAction(QWebEnginePage.WebAction.Copy)
def onPaste(self) -> None:
self.triggerPageAction(QWebEnginePage.WebAction.Paste)
self.triggerPageAction(QWebEnginePage.WebAction.Paste)