mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 22:12:21 -04:00
Move context menu logic
This commit is contained in:
parent
d585916313
commit
70a69cfdbe
9 changed files with 1547 additions and 1176 deletions
|
@ -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",
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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">
|
||||||
|
|
122
ts/routes/editor/context-menu.svelte.ts
Normal file
122
ts/routes/editor/context-menu.svelte.ts
Normal 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];
|
||||||
|
}
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue