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",
|
||||
"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",
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
@ -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">
|
||||
|
|
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;
|
||||
}
|
||||
|
||||
export function editingInputIsPlainText(
|
||||
editingInput: EditingInputAPI,
|
||||
): editingInput is PlainTextInputAPI {
|
||||
return editingInput.name === "plain-text";
|
||||
}
|
||||
|
||||
export const parsingInstructions: string[] = [];
|
||||
export const closeHTMLTags = writable(true);
|
||||
|
||||
|
|
|
@ -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,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue