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",
"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",

View file

@ -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);

View file

@ -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

View file

@ -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",
]

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 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<EditorFieldAPI | null>;
focusedInput: Writable<EditingInputAPI | null>;
toolbar: EditorToolbarAPI;
state: Writable<EditorState>;
lastIOImagePath: Writable<string | null>;
}
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<string | null> = 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<EditorState> = 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.
-->
<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}>
<svelte:fragment slot="notetypeButtons">
{#if mode === "browser"}
@ -1117,6 +1135,19 @@ components and functionality for general note editing.
<TagEditor {tags} on:tagsupdate={saveTags} />
</Collapsible>
{/if}
<ContextMenu bind:this={contextMenu}>
{#each contextMenuItems as item}
<Item
on:click={() => {
item.action();
$focusedInput?.focus();
}}
>
{item.label}
</Item>
{/each}
</ContextMenu>
</div>
<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;
}
export function editingInputIsPlainText(
editingInput: EditingInputAPI,
): editingInput is PlainTextInputAPI {
return editingInput.name === "plain-text";
}
export const parsingInstructions: string[] = [];
export const closeHTMLTags = writable(true);

View file

@ -406,7 +406,7 @@ export async function extractImagePathFromHtml(html: string): Promise<string | n
if (images.length === 0) {
return null;
}
return images[0];
return decodeURI(images[0]);
}
export async function readImageFromClipboard(): Promise<string | null> {
@ -429,3 +429,11 @@ export async function readImageFromClipboard(): Promise<string | 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