diff --git a/package.json b/package.json index d08655bad..dc6f6bd2c 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,8 @@ "lodash-es": "^4.17.21", "lru-cache": "^10.2.0", "marked": "^5.1.0", - "mathjax": "^3.1.2" + "mathjax": "^3.1.2", + "svelte-contextmenu": "^1.0.2" }, "resolutions": { "canvas": "npm:empty-npm-package@1.0.0", diff --git a/proto/anki/frontend.proto b/proto/anki/frontend.proto index 9c8ce622f..9e4d2de13 100644 --- a/proto/anki/frontend.proto +++ b/proto/anki/frontend.proto @@ -35,6 +35,8 @@ service FrontendService { returns (ConvertPastedImageResponse); rpc retrieveUrl(generic.String) returns (RetrieveUrlResponse); rpc openFilePicker(openFilePickerRequest) returns (generic.String); + rpc openMedia(generic.String) returns (generic.Empty); + rpc showInMediaFolder(generic.String) returns (generic.Empty); // Profile config rpc GetProfileConfigJson(generic.String) returns (generic.Json); diff --git a/qt/aqt/editor.py b/qt/aqt/editor.py index c0aa30452..ee135ec93 100644 --- a/qt/aqt/editor.py +++ b/qt/aqt/editor.py @@ -25,8 +25,6 @@ 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 @@ -35,20 +33,12 @@ from anki.hooks import runFilter from anki.httpclient import HttpClient from anki.models import NotetypeDict, StockNotetype from anki.notes import Note, NoteId -from anki.utils import checksum, is_mac, is_win, namedtmp +from anki.utils import checksum, is_win, namedtmp from aqt import AnkiQt, gui_hooks from aqt.operations.notetype import update_notetype_legacy from aqt.qt import * from aqt.sound import av_player -from aqt.utils import ( - KeyboardModifiersPressed, - getFile, - openFolder, - shortcut, - show_in_folder, - showWarning, - tr, -) +from aqt.utils import KeyboardModifiersPressed, getFile, shortcut, showWarning, tr from aqt.webview import AnkiWebView, AnkiWebViewKind pics = ("jpg", "jpeg", "png", "gif", "svg", "webp", "ico", "avif") @@ -855,8 +845,11 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too def onPaste(self) -> None: self.web.onPaste() - def onCutOrCopy(self) -> None: - self.web.user_cut_or_copied() + def onCut(self) -> None: + self.web.onCut() + + def onCopy(self) -> None: + self.web.onCopy() # Image occlusion ###################################################################### @@ -915,7 +908,8 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too attach=Editor.onAddMedia, record=Editor.onRecSound, paste=Editor.onPaste, - cutOrCopy=Editor.onCutOrCopy, + cut=Editor.onCut, + copy=Editor.onCopy, ) @property @@ -956,10 +950,6 @@ class EditorWebView(AnkiWebView): ) 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: @@ -990,19 +980,6 @@ class EditorWebView(AnkiWebView): 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 @@ -1029,7 +1006,7 @@ class EditorWebView(AnkiWebView): self.editor.doPaste(html, internal, extended) def onPaste(self) -> None: - self._onPaste(QClipboard.Mode.Clipboard) + self.triggerPageAction(QWebEnginePage.WebAction.Paste) def onMiddleClickPaste(self) -> None: self._onPaste(QClipboard.Mode.Selection) @@ -1169,53 +1146,9 @@ class EditorWebView(AnkiWebView): 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)) - - if is_win or is_mac: - 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 diff --git a/qt/aqt/mediasrv.py b/qt/aqt/mediasrv.py index ce30e3aad..4cda76eab 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -724,6 +724,28 @@ async def open_file_picker() -> bytes: return generic_pb2.String(val=filename if filename else "").SerializeToString() +def open_media() -> bytes: + from aqt.utils import openFolder + + req = generic_pb2.String() + req.ParseFromString(request.data) + path = os.path.join(aqt.mw.col.media.dir(), req.val) + aqt.mw.taskman.run_on_main(lambda: openFolder(path)) + + return b"" + + +def show_in_media_folder() -> bytes: + from aqt.utils import show_in_folder + + req = generic_pb2.String() + req.ParseFromString(request.data) + path = os.path.join(aqt.mw.col.media.dir(), req.val) + aqt.mw.taskman.run_on_main(lambda: show_in_folder(path)) + + return b"" + + post_handler_list = [ congrats_info, get_deck_configs_for_update, @@ -748,6 +770,8 @@ post_handler_list = [ convert_pasted_image, retrieve_url, open_file_picker, + open_media, + show_in_media_folder, ] @@ -806,6 +830,7 @@ exposed_backend_list = [ "add_media_file", "add_media_from_path", "get_absolute_media_path", + "extract_media_files", ] diff --git a/ts/routes/editor/NoteEditor.svelte b/ts/routes/editor/NoteEditor.svelte index 49fcb8532..d707d9706 100644 --- a/ts/routes/editor/NoteEditor.svelte +++ b/ts/routes/editor/NoteEditor.svelte @@ -13,7 +13,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import FieldState from "./FieldState.svelte"; import LabelContainer from "./LabelContainer.svelte"; import LabelName from "./LabelName.svelte"; - import type { EditorMode } from "./types"; + import { EditorState, type EditorMode } from "./types"; + import ContextMenu, { Item } from "svelte-contextmenu"; export interface NoteEditorAPI { fields: EditorFieldAPI[]; @@ -21,6 +22,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html focusedField: Writable; focusedInput: Writable; toolbar: EditorToolbarAPI; + state: Writable; + lastIOImagePath: Writable; } import { registerPackage } from "@tslib/runtime-require"; @@ -28,6 +31,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html filenameToLink, openFilePickerForImageOcclusion, readImageFromClipboard, + extractImagePathFromHtml, } from "./rich-text-input/data-transfer"; import contextProperty from "$lib/sveltelib/context-property"; import lifecycleHooks from "$lib/sveltelib/lifecycle-hooks"; @@ -81,7 +85,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import RichTextInput, { editingInputIsRichText } from "./rich-text-input"; import RichTextBadge from "./RichTextBadge.svelte"; import type { NotetypeIdAndModTime, SessionOptions } from "./types"; - import { EditorState } from "./types"; + + let contextMenu: ContextMenu; + const [onContextMenu, contextMenuItems] = setupContextMenu(); function quoteFontFamily(fontFamily: string): string { // generic families (e.g. sans-serif) must not be quoted @@ -546,11 +552,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import ButtonGroupItem from "$lib/components/ButtonGroupItem.svelte"; import PreviewButton from "./PreviewButton.svelte"; import { NoteFieldsCheckResponse_State, type Note } from "@generated/anki/notes_pb"; + import { setupContextMenu } from "./context-menu.svelte"; $: isIOImageLoaded = false; $: ioImageLoadedStore.set(isIOImageLoaded); let imageOcclusionMode: IOMode | undefined; let ioFields = new ImageOcclusionFieldIndexes({}); + const lastIOImagePath: Writable = writable(null); async function pickIOImage() { imageOcclusionMode = undefined; @@ -606,6 +614,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html }) ).val, ); + $lastIOImagePath = await extractImagePathFromHtml(imageFieldHtml); setupMaskEditorInner({ html: imageFieldHtml, mode: { @@ -625,6 +634,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html }) ).val, ); + $lastIOImagePath = await extractImagePathFromHtml(imageFieldHtml); resetIOImage(imagePath, () => {}); setImageField(imageFieldHtml); } @@ -639,7 +649,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html async function setupMaskEditorFromClipboard() { const path = await readImageFromClipboard(); - console.log("setupMaskEditorFromClipboard path", path); if (path) { setupMaskEditor(path); } else { @@ -697,8 +706,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // Signal editor UI state changes to add-ons - let editorState: EditorState = EditorState.Initial; - let lastEditorState: EditorState = editorState; + const editorState: Writable = writable(EditorState.Initial); + let lastEditorState: EditorState = $editorState; function getEditorState( ioMaskEditorVisible: boolean, @@ -802,7 +811,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html } if (isImageOcclusion) { const imageField = note!.fields[ioFields.image]; - // TODO: last_io_image_path + $lastIOImagePath = await extractImagePathFromHtml(imageField); if (mode !== "add") { setupMaskEditorInner({ html: imageField, @@ -827,9 +836,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html return note!.id; } - $: signalEditorState(editorState); + $: signalEditorState($editorState); - $: editorState = getEditorState( + $: $editorState = getEditorState( $ioMaskEditorVisible, isImageOcclusion, isIOImageLoaded, @@ -886,7 +895,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html ...oldEditorAdapter, }); - editorState = getEditorState( + $editorState = getEditorState( $ioMaskEditorVisible, isImageOcclusion, isIOImageLoaded, @@ -911,6 +920,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html focusedInput, toolbar: toolbar as EditorToolbarAPI, fields, + state: editorState, + lastIOImagePath, }; setContextProperty(api); @@ -940,7 +951,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html Serves as a pre-slotted convenience component which combines all the common components and functionality for general note editing. --> -
+