Move context menu logic

This commit is contained in:
Abdo 2025-06-18 22:25:40 +03:00
parent d585916313
commit 70a69cfdbe
9 changed files with 1547 additions and 1176 deletions

View file

@ -77,7 +77,8 @@
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"lru-cache": "^10.2.0", "lru-cache": "^10.2.0",
"marked": "^5.1.0", "marked": "^5.1.0",
"mathjax": "^3.1.2" "mathjax": "^3.1.2",
"svelte-contextmenu": "^1.0.2"
}, },
"resolutions": { "resolutions": {
"canvas": "npm:empty-npm-package@1.0.0", "canvas": "npm:empty-npm-package@1.0.0",

View file

@ -35,6 +35,8 @@ service FrontendService {
returns (ConvertPastedImageResponse); returns (ConvertPastedImageResponse);
rpc retrieveUrl(generic.String) returns (RetrieveUrlResponse); rpc retrieveUrl(generic.String) returns (RetrieveUrlResponse);
rpc openFilePicker(openFilePickerRequest) returns (generic.String); rpc openFilePicker(openFilePickerRequest) returns (generic.String);
rpc openMedia(generic.String) returns (generic.Empty);
rpc showInMediaFolder(generic.String) returns (generic.Empty);
// Profile config // Profile config
rpc GetProfileConfigJson(generic.String) returns (generic.Json); rpc GetProfileConfigJson(generic.String) returns (generic.Json);

View file

@ -25,8 +25,6 @@ import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
import aqt import aqt
import aqt.forms
import aqt.operations
import aqt.sound import aqt.sound
from anki._legacy import deprecated from anki._legacy import deprecated
from anki.cards import Card from anki.cards import Card
@ -35,20 +33,12 @@ from anki.hooks import runFilter
from anki.httpclient import HttpClient from anki.httpclient import HttpClient
from anki.models import NotetypeDict, StockNotetype from anki.models import NotetypeDict, StockNotetype
from anki.notes import Note, NoteId 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 import AnkiQt, gui_hooks
from aqt.operations.notetype import update_notetype_legacy from aqt.operations.notetype import update_notetype_legacy
from aqt.qt import * from aqt.qt import *
from aqt.sound import av_player from aqt.sound import av_player
from aqt.utils import ( from aqt.utils import KeyboardModifiersPressed, getFile, shortcut, showWarning, tr
KeyboardModifiersPressed,
getFile,
openFolder,
shortcut,
show_in_folder,
showWarning,
tr,
)
from aqt.webview import AnkiWebView, AnkiWebViewKind from aqt.webview import AnkiWebView, AnkiWebViewKind
pics = ("jpg", "jpeg", "png", "gif", "svg", "webp", "ico", "avif") 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: def onPaste(self) -> None:
self.web.onPaste() self.web.onPaste()
def onCutOrCopy(self) -> None: def onCut(self) -> None:
self.web.user_cut_or_copied() self.web.onCut()
def onCopy(self) -> None:
self.web.onCopy()
# Image occlusion # Image occlusion
###################################################################### ######################################################################
@ -915,7 +908,8 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
attach=Editor.onAddMedia, attach=Editor.onAddMedia,
record=Editor.onRecSound, record=Editor.onRecSound,
paste=Editor.onPaste, paste=Editor.onPaste,
cutOrCopy=Editor.onCutOrCopy, cut=Editor.onCut,
copy=Editor.onCopy,
) )
@property @property
@ -956,10 +950,6 @@ class EditorWebView(AnkiWebView):
) )
gui_hooks.editor_web_view_did_init(self) 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( def _on_clipboard_change(
self, mode: QClipboard.Mode = QClipboard.Mode.Clipboard self, mode: QClipboard.Mode = QClipboard.Mode.Clipboard
) -> None: ) -> None:
@ -990,19 +980,6 @@ class EditorWebView(AnkiWebView):
def onCopy(self) -> None: def onCopy(self) -> None:
self.triggerPageAction(QWebEnginePage.WebAction.Copy) 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: def _wantsExtendedPaste(self) -> bool:
strip_html = self.editor.mw.col.get_config_bool( strip_html = self.editor.mw.col.get_config_bool(
Config.Bool.PASTE_STRIPS_FORMATTING Config.Bool.PASTE_STRIPS_FORMATTING
@ -1029,7 +1006,7 @@ class EditorWebView(AnkiWebView):
self.editor.doPaste(html, internal, extended) self.editor.doPaste(html, internal, extended)
def onPaste(self) -> None: def onPaste(self) -> None:
self._onPaste(QClipboard.Mode.Clipboard) self.triggerPageAction(QWebEnginePage.WebAction.Paste)
def onMiddleClickPaste(self) -> None: def onMiddleClickPaste(self) -> None:
self._onPaste(QClipboard.Mode.Selection) self._onPaste(QClipboard.Mode.Selection)
@ -1169,53 +1146,9 @@ class EditorWebView(AnkiWebView):
def contextMenuEvent(self, evt: QContextMenuEvent | None) -> None: def contextMenuEvent(self, evt: QContextMenuEvent | None) -> None:
m = QMenu(self) 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) gui_hooks.editor_will_show_context_menu(self, m)
m.popup(QCursor.pos()) 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: def _clipboard(self) -> QClipboard:
clipboard = self.editor.mw.app.clipboard() clipboard = self.editor.mw.app.clipboard()
assert clipboard is not None assert clipboard is not None

View file

@ -724,6 +724,28 @@ async def open_file_picker() -> bytes:
return generic_pb2.String(val=filename if filename else "").SerializeToString() 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 = [ post_handler_list = [
congrats_info, congrats_info,
get_deck_configs_for_update, get_deck_configs_for_update,
@ -748,6 +770,8 @@ post_handler_list = [
convert_pasted_image, convert_pasted_image,
retrieve_url, retrieve_url,
open_file_picker, open_file_picker,
open_media,
show_in_media_folder,
] ]
@ -806,6 +830,7 @@ exposed_backend_list = [
"add_media_file", "add_media_file",
"add_media_from_path", "add_media_from_path",
"get_absolute_media_path", "get_absolute_media_path",
"extract_media_files",
] ]

View file

@ -13,7 +13,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import FieldState from "./FieldState.svelte"; import FieldState from "./FieldState.svelte";
import LabelContainer from "./LabelContainer.svelte"; import LabelContainer from "./LabelContainer.svelte";
import LabelName from "./LabelName.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 { export interface NoteEditorAPI {
fields: EditorFieldAPI[]; fields: EditorFieldAPI[];
@ -21,6 +22,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
focusedField: Writable<EditorFieldAPI | null>; focusedField: Writable<EditorFieldAPI | null>;
focusedInput: Writable<EditingInputAPI | null>; focusedInput: Writable<EditingInputAPI | null>;
toolbar: EditorToolbarAPI; toolbar: EditorToolbarAPI;
state: Writable<EditorState>;
lastIOImagePath: Writable<string | null>;
} }
import { registerPackage } from "@tslib/runtime-require"; 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, filenameToLink,
openFilePickerForImageOcclusion, openFilePickerForImageOcclusion,
readImageFromClipboard, readImageFromClipboard,
extractImagePathFromHtml,
} from "./rich-text-input/data-transfer"; } from "./rich-text-input/data-transfer";
import contextProperty from "$lib/sveltelib/context-property"; import contextProperty from "$lib/sveltelib/context-property";
import lifecycleHooks from "$lib/sveltelib/lifecycle-hooks"; 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 RichTextInput, { editingInputIsRichText } from "./rich-text-input";
import RichTextBadge from "./RichTextBadge.svelte"; import RichTextBadge from "./RichTextBadge.svelte";
import type { NotetypeIdAndModTime, SessionOptions } from "./types"; import type { NotetypeIdAndModTime, SessionOptions } from "./types";
import { EditorState } from "./types";
let contextMenu: ContextMenu;
const [onContextMenu, contextMenuItems] = setupContextMenu();
function quoteFontFamily(fontFamily: string): string { function quoteFontFamily(fontFamily: string): string {
// generic families (e.g. sans-serif) must not be quoted // 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 ButtonGroupItem from "$lib/components/ButtonGroupItem.svelte";
import PreviewButton from "./PreviewButton.svelte"; import PreviewButton from "./PreviewButton.svelte";
import { NoteFieldsCheckResponse_State, type Note } from "@generated/anki/notes_pb"; import { NoteFieldsCheckResponse_State, type Note } from "@generated/anki/notes_pb";
import { setupContextMenu } from "./context-menu.svelte";
$: isIOImageLoaded = false; $: isIOImageLoaded = false;
$: ioImageLoadedStore.set(isIOImageLoaded); $: ioImageLoadedStore.set(isIOImageLoaded);
let imageOcclusionMode: IOMode | undefined; let imageOcclusionMode: IOMode | undefined;
let ioFields = new ImageOcclusionFieldIndexes({}); let ioFields = new ImageOcclusionFieldIndexes({});
const lastIOImagePath: Writable<string | null> = writable(null);
async function pickIOImage() { async function pickIOImage() {
imageOcclusionMode = undefined; imageOcclusionMode = undefined;
@ -606,6 +614,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}) })
).val, ).val,
); );
$lastIOImagePath = await extractImagePathFromHtml(imageFieldHtml);
setupMaskEditorInner({ setupMaskEditorInner({
html: imageFieldHtml, html: imageFieldHtml,
mode: { mode: {
@ -625,6 +634,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}) })
).val, ).val,
); );
$lastIOImagePath = await extractImagePathFromHtml(imageFieldHtml);
resetIOImage(imagePath, () => {}); resetIOImage(imagePath, () => {});
setImageField(imageFieldHtml); setImageField(imageFieldHtml);
} }
@ -639,7 +649,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
async function setupMaskEditorFromClipboard() { async function setupMaskEditorFromClipboard() {
const path = await readImageFromClipboard(); const path = await readImageFromClipboard();
console.log("setupMaskEditorFromClipboard path", path);
if (path) { if (path) {
setupMaskEditor(path); setupMaskEditor(path);
} else { } 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 // Signal editor UI state changes to add-ons
let editorState: EditorState = EditorState.Initial; const editorState: Writable<EditorState> = writable(EditorState.Initial);
let lastEditorState: EditorState = editorState; let lastEditorState: EditorState = $editorState;
function getEditorState( function getEditorState(
ioMaskEditorVisible: boolean, ioMaskEditorVisible: boolean,
@ -802,7 +811,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
if (isImageOcclusion) { if (isImageOcclusion) {
const imageField = note!.fields[ioFields.image]; const imageField = note!.fields[ioFields.image];
// TODO: last_io_image_path $lastIOImagePath = await extractImagePathFromHtml(imageField);
if (mode !== "add") { if (mode !== "add") {
setupMaskEditorInner({ setupMaskEditorInner({
html: imageField, html: imageField,
@ -827,9 +836,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
return note!.id; return note!.id;
} }
$: signalEditorState(editorState); $: signalEditorState($editorState);
$: editorState = getEditorState( $: $editorState = getEditorState(
$ioMaskEditorVisible, $ioMaskEditorVisible,
isImageOcclusion, isImageOcclusion,
isIOImageLoaded, isIOImageLoaded,
@ -886,7 +895,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
...oldEditorAdapter, ...oldEditorAdapter,
}); });
editorState = getEditorState( $editorState = getEditorState(
$ioMaskEditorVisible, $ioMaskEditorVisible,
isImageOcclusion, isImageOcclusion,
isIOImageLoaded, isIOImageLoaded,
@ -911,6 +920,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
focusedInput, focusedInput,
toolbar: toolbar as EditorToolbarAPI, toolbar: toolbar as EditorToolbarAPI,
fields, fields,
state: editorState,
lastIOImagePath,
}; };
setContextProperty(api); 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 Serves as a pre-slotted convenience component which combines all the common
components and functionality for general note editing. components and functionality for general note editing.
--> -->
<div class="note-editor" bind:this={noteEditor}> <div
class="note-editor"
role="presentation"
bind:this={noteEditor}
on:contextmenu={(event) => {
onContextMenu(event, api, $focusedInput, contextMenu);
}}
>
<EditorToolbar {size} {wrap} api={toolbar}> <EditorToolbar {size} {wrap} api={toolbar}>
<svelte:fragment slot="notetypeButtons"> <svelte:fragment slot="notetypeButtons">
{#if mode === "browser"} {#if mode === "browser"}
@ -1117,6 +1135,19 @@ components and functionality for general note editing.
<TagEditor {tags} on:tagsupdate={saveTags} /> <TagEditor {tags} on:tagsupdate={saveTags} />
</Collapsible> </Collapsible>
{/if} {/if}
<ContextMenu bind:this={contextMenu}>
{#each contextMenuItems as item}
<Item
on:click={() => {
item.action();
$focusedInput?.focus();
}}
>
{item.label}
</Item>
{/each}
</ContextMenu>
</div> </div>
<style lang="scss"> <style lang="scss">

View file

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

View file

@ -17,6 +17,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
codeMirror: CodeMirrorAPI; codeMirror: CodeMirrorAPI;
} }
export function editingInputIsPlainText(
editingInput: EditingInputAPI,
): editingInput is PlainTextInputAPI {
return editingInput.name === "plain-text";
}
export const parsingInstructions: string[] = []; export const parsingInstructions: string[] = [];
export const closeHTMLTags = writable(true); export const closeHTMLTags = writable(true);

View file

@ -406,7 +406,7 @@ export async function extractImagePathFromHtml(html: string): Promise<string | n
if (images.length === 0) { if (images.length === 0) {
return null; return null;
} }
return images[0]; return decodeURI(images[0]);
} }
export async function readImageFromClipboard(): Promise<string | null> { export async function readImageFromClipboard(): Promise<string | null> {
@ -429,3 +429,11 @@ export async function readImageFromClipboard(): Promise<string | null> {
} }
return null; return null;
} }
export async function writeBlobToClipboard(blob: Blob) {
await navigator.clipboard.write([
new ClipboardItem({
[blob.type]: blob,
}),
]);
}

2417
yarn.lock

File diff suppressed because it is too large Load diff