From 13c2dd201abb3f3c5229268f27f120fbfe7b6076 Mon Sep 17 00:00:00 2001 From: Abdo Date: Wed, 11 Jun 2025 21:16:56 +0300 Subject: [PATCH] Move more image occlusion calls --- proto/anki/frontend.proto | 8 ++ proto/anki/media.proto | 7 + python/requirements.bundle.txt | 2 +- python/requirements.dev.txt | 2 +- qt/aqt/editor.py | 134 +++--------------- qt/aqt/mediasrv.py | 52 ++++++- rslib/src/media/service.rs | 23 +++ ts/routes/editor/NoteEditor.svelte | 72 ++++++++-- .../editor/rich-text-input/data-transfer.ts | 118 +++++++++++---- 9 files changed, 271 insertions(+), 147 deletions(-) diff --git a/proto/anki/frontend.proto b/proto/anki/frontend.proto index 1036e6b26..9c8ce622f 100644 --- a/proto/anki/frontend.proto +++ b/proto/anki/frontend.proto @@ -34,6 +34,7 @@ service FrontendService { rpc convertPastedImage(ConvertPastedImageRequest) returns (ConvertPastedImageResponse); rpc retrieveUrl(generic.String) returns (RetrieveUrlResponse); + rpc openFilePicker(openFilePickerRequest) returns (generic.String); // Profile config rpc GetProfileConfigJson(generic.String) returns (generic.Json); @@ -74,3 +75,10 @@ message SetSettingJsonRequest { string key = 1; bytes value_json = 2; } + +message openFilePickerRequest { + string title = 1; + string key = 2; + string filter_description = 3; + repeated string extensions = 4; +} diff --git a/proto/anki/media.proto b/proto/anki/media.proto index 76d42931a..ee2fb5e1d 100644 --- a/proto/anki/media.proto +++ b/proto/anki/media.proto @@ -13,11 +13,14 @@ import "anki/notetypes.proto"; service MediaService { rpc CheckMedia(generic.Empty) returns (CheckMediaResponse); rpc AddMediaFile(AddMediaFileRequest) returns (generic.String); + rpc AddMediaFromPath(AddMediaFromPathRequest) returns (generic.String); rpc TrashMediaFiles(TrashMediaFilesRequest) returns (generic.Empty); rpc EmptyTrash(generic.Empty) returns (generic.Empty); rpc RestoreTrash(generic.Empty) returns (generic.Empty); rpc ExtractStaticMediaFiles(notetypes.NotetypeId) returns (generic.StringList); + rpc ExtractMediaFiles(generic.String) returns (generic.StringList); + rpc GetAbsoluteMediaPath(generic.String) returns (generic.String); } // Implicitly includes any of the above methods that are not listed in the @@ -40,3 +43,7 @@ message AddMediaFileRequest { string desired_name = 1; bytes data = 2; } + +message AddMediaFromPathRequest { + string path = 1; +} diff --git a/python/requirements.bundle.txt b/python/requirements.bundle.txt index 1540ba074..8aab16467 100644 --- a/python/requirements.bundle.txt +++ b/python/requirements.bundle.txt @@ -130,7 +130,7 @@ distro==1.9.0 ; sys_platform != "darwin" and sys_platform != "win32" \ --hash=sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed \ --hash=sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2 # via -r requirements.anki.in -flask==3.0.3 \ +flask[async]==3.0.3 \ --hash=sha256:34e815dfaa43340d1d15a5c3a02b8476004037eb4840b34910c6e21679d288f3 \ --hash=sha256:ceb27b0af3823ea2737928a4d99d125a06175b8512c445cbd9a9ce200ef76842 # via diff --git a/python/requirements.dev.txt b/python/requirements.dev.txt index 671a8ee9b..293ee343d 100644 --- a/python/requirements.dev.txt +++ b/python/requirements.dev.txt @@ -176,7 +176,7 @@ exceptiongroup==1.2.2 \ --hash=sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b \ --hash=sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc # via pytest -flask==3.0.3 \ +flask[async]==3.0.3 \ --hash=sha256:34e815dfaa43340d1d15a5c3a02b8476004037eb4840b34910c6e21679d288f3 \ --hash=sha256:ceb27b0af3823ea2737928a4d99d125a06175b8512c445cbd9a9ce200ef76842 # via diff --git a/qt/aqt/editor.py b/qt/aqt/editor.py index 6b3cbc3bd..8424b3b35 100644 --- a/qt/aqt/editor.py +++ b/qt/aqt/editor.py @@ -873,131 +873,37 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too def setup_mask_editor(self, image_path: str) -> None: try: if self.editorMode == EditorMode.ADD_CARDS: - self.setup_mask_editor_for_new_note( - image_path=image_path, notetype_id=0 - ) + self.setup_mask_editor_for_new_note(image_path=image_path) else: assert self.note is not None - self.setup_mask_editor_for_existing_note( - note_id=self.note.id, image_path=image_path - ) + self.setup_mask_editor_for_existing_note(image_path=image_path) except Exception as e: showWarning(str(e)) - def select_image_and_occlude(self) -> None: - """Show a file selection screen, then get selected image path.""" - extension_filter = " ".join( - f"*.{extension}" for extension in sorted(itertools.chain(pics)) - ) - filter = f"{tr.editing_media()} ({extension_filter})" - - file = getFile( - parent=self.widget, - title=tr.editing_add_media(), - cb=cast(Callable[[Any], None], self.setup_mask_editor), - filter=filter, - key="media", - ) - - self.parentWindow.activateWindow() - - def extract_img_path_from_html(self, html: str) -> str | None: - assert self.note is not None - # with allowed_suffixes=pics, all non-pics will be rendered as s and won't be included here - if not (images := self.mw.col.media.files_in_str(self.note.mid, html)): - return None - image_path = urllib.parse.unquote(images[0]) - return os.path.join(self.mw.col.media.dir(), image_path) - - def select_image_from_clipboard_and_occlude(self) -> None: - """Set up the mask editor for the image in the clipboard.""" - - clipboard = self.mw.app.clipboard() - assert clipboard is not None - mime = clipboard.mimeData() - assert mime is not None - # try checking for urls first, fallback to image data - if ( - (html := self.web._processUrls(mime, allowed_suffixes=pics)) - and (path := self.extract_img_path_from_html(html)) - ) or (mime.hasImage() and (path := self._read_pasted_image(mime))): - self.setup_mask_editor(path) - self.parentWindow.activateWindow() - else: - showWarning(tr.editing_no_image_found_on_clipboard()) - return - - def setup_mask_editor_for_new_note( - self, - image_path: str, - notetype_id: NotetypeId | int = 0, - ): + def setup_mask_editor_for_new_note(self, image_path: str): """Set-up IO mask editor for adding new notes Presupposes that active editor notetype is an image occlusion notetype Args: image_path: Absolute path to image. - notetype_id: ID of note type to use. Provided ID must belong to an - image occlusion notetype. Set this to 0 to auto-select the first - found image occlusion notetype in the user's collection. """ - image_field_html = self._addMedia(image_path) - self.last_io_image_path = self.extract_img_path_from_html(image_field_html) - io_options = self._create_add_io_options( - image_path=image_path, - image_field_html=image_field_html, - notetype_id=notetype_id, - ) - self._setup_mask_editor(io_options) - - def setup_mask_editor_for_existing_note( - self, note_id: NoteId, image_path: str | None = None - ): - """Set-up IO mask editor for editing existing notes - Presupposes that active editor notetype is an image occlusion notetype - Args: - note_id: ID of note to edit. - image_path: (Optional) Absolute path to image that should replace current - image - """ - io_options = self._create_edit_io_options(note_id) - if image_path: - image_field_html = self._addMedia(image_path) - self.last_io_image_path = self.extract_img_path_from_html(image_field_html) - self.web.eval(f"resetIOImage({json.dumps(image_path)})") - self.web.eval(f"setImageField({json.dumps(image_field_html)})") - self._setup_mask_editor(io_options) - - def reset_image_occlusion(self) -> None: - self.web.eval("resetIOImageLoaded()") - - def update_occlusions_field(self) -> None: - self.web.eval("saveOcclusions()") - - def _setup_mask_editor(self, io_options: dict): self.web.eval( 'require("anki/ui").loaded.then(() =>' - f"setupMaskEditor({json.dumps(io_options)})" + f"setupMaskEditorForNewNote({json.dumps(image_path)})" "); " ) - @staticmethod - def _create_add_io_options( - image_path: str, image_field_html: str, notetype_id: NotetypeId | int = 0 - ) -> dict: - return { - "mode": {"kind": "add", "imagePath": image_path, "notetypeId": notetype_id}, - "html": image_field_html, - } - - @staticmethod - def _create_clone_io_options(orig_note_id: NoteId) -> dict: - return { - "mode": {"kind": "add", "clonedNoteId": orig_note_id}, - } - - @staticmethod - def _create_edit_io_options(note_id: NoteId) -> dict: - return {"mode": {"kind": "edit", "noteId": note_id}} + def setup_mask_editor_for_existing_note(self, image_path: str | None = None): + """Set-up IO mask editor for editing existing notes + Presupposes that active editor notetype is an image occlusion notetype + Args: + image_path: (Optional) Absolute path to image that should replace current + image + """ + self.web.eval( + 'require("anki/ui").loaded.then(() =>' + f"setupMaskEditorForExistingNote({json.dumps(image_path)})" + "); " + ) # Links from HTML ###################################################################### @@ -1010,8 +916,6 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too record=Editor.onRecSound, paste=Editor.onPaste, cutOrCopy=Editor.onCutOrCopy, - addImageForOcclusion=Editor.select_image_and_occlude, - addImageForOcclusionFromClipboard=Editor.select_image_from_clipboard_and_occlude, ) @property @@ -1044,6 +948,12 @@ class EditorWebView(AnkiWebView): clip = self.editor.mw.app.clipboard() assert clip is not None clip.dataChanged.connect(self._on_clipboard_change) + self.settings().setAttribute( + QWebEngineSettings.WebAttribute.JavascriptCanPaste, True + ) + self.settings().setAttribute( + QWebEngineSettings.WebAttribute.JavascriptCanAccessClipboard, True + ) gui_hooks.editor_web_view_did_init(self) def user_cut_or_copied(self) -> None: diff --git a/qt/aqt/mediasrv.py b/qt/aqt/mediasrv.py index c32e55685..8ae7f03d6 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -3,6 +3,7 @@ from __future__ import annotations +import asyncio import enum import logging import mimetypes @@ -693,6 +694,34 @@ def retrieve_url() -> bytes: ).SerializeToString() +async def open_file_picker() -> bytes: + req = frontend_pb2.openFilePickerRequest() + req.ParseFromString(request.data) + + loop = asyncio.get_event_loop() + future = loop.create_future() + + def on_main() -> None: + from aqt.utils import getFile + + def cb(filename: str | None) -> None: + loop.call_soon_threadsafe(future.set_result, filename) + + getFile( + parent=aqt.mw.app.activeWindow(), + title=req.title, + cb=cb, + filter=f"{req.filter_description} ({' '.join(f'*.{ext}' for ext in req.extensions)})", + key=req.key, + ) + + aqt.mw.taskman.run_on_main(on_main) + + filename = await future + + return generic_pb2.String(val=filename if filename else "").SerializeToString() + + post_handler_list = [ congrats_info, get_deck_configs_for_update, @@ -716,6 +745,7 @@ post_handler_list = [ get_config_json, convert_pasted_image, retrieve_url, + open_file_picker, ] @@ -772,6 +802,8 @@ exposed_backend_list = [ "get_config_bool", # MediaService "add_media_file", + "add_media_from_path", + "get_absolute_media_path", ] @@ -800,7 +832,25 @@ def _extract_collection_post_request(path: str) -> DynamicRequest | NotFound: # convert bytes/None into response def wrapped() -> Response: try: - if data := handler(): + import inspect + + if inspect.iscoroutinefunction(handler): + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + import concurrent.futures + + with concurrent.futures.ThreadPoolExecutor() as executor: + future = executor.submit(asyncio.run, handler()) + data = future.result() + else: + data = loop.run_until_complete(handler()) + except RuntimeError: + data = asyncio.run(handler()) + else: + result = handler() + data = result + if data: response = flask.make_response(data) response.headers["Content-Type"] = "application/binary" else: diff --git a/rslib/src/media/service.rs b/rslib/src/media/service.rs index b2ec99d1d..b69f4014d 100644 --- a/rslib/src/media/service.rs +++ b/rslib/src/media/service.rs @@ -1,9 +1,11 @@ use std::collections::HashSet; +use std::path::Path; // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::generic; use anki_proto::media::AddMediaFileRequest; +use anki_proto::media::AddMediaFromPathRequest; use anki_proto::media::CheckMediaResponse; use anki_proto::media::TrashMediaFilesRequest; @@ -12,6 +14,7 @@ use crate::error; use crate::error::OrNotFound; use crate::notes::service::to_i64s; use crate::notetype::NotetypeId; +use crate::text::extract_media_refs; impl crate::services::MediaService for Collection { fn check_media(&mut self) -> error::Result { @@ -40,6 +43,16 @@ impl crate::services::MediaService for Collection { .into()) } + fn add_media_from_path(&mut self, input: AddMediaFromPathRequest) -> error::Result { + let base_name = Path::new(&input.path).file_name().unwrap_or_default().to_str().unwrap_or_default(); + let data = std::fs::read(&input.path)?; + Ok(self + .media()? + .add_file(&base_name, &data)? + .to_string() + .into()) + } + fn trash_media_files(&mut self, input: TrashMediaFilesRequest) -> error::Result<()> { self.media()?.remove_files(&input.fnames) } @@ -66,4 +79,14 @@ impl crate::services::MediaService for Collection { Ok(files.into_iter().collect::>().into()) } + + + fn extract_media_files(&mut self, html: anki_proto::generic::String) -> error::Result { + let files = extract_media_refs(&html.val).iter().map(|r| r.fname_decoded.to_string()).collect::>(); + Ok(files.into()) + } + + fn get_absolute_media_path(&mut self, path: anki_proto::generic::String) -> error::Result { + Ok(self.media()?.media_folder.join(path.val).to_string_lossy().to_string().into()) + } } diff --git a/ts/routes/editor/NoteEditor.svelte b/ts/routes/editor/NoteEditor.svelte index 309642dce..3086a1095 100644 --- a/ts/routes/editor/NoteEditor.svelte +++ b/ts/routes/editor/NoteEditor.svelte @@ -24,7 +24,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html } import { registerPackage } from "@tslib/runtime-require"; - + import { filenameToLink, openFilePickerForImageOcclusion, readImageFromClipboard } from "./rich-text-input/data-transfer"; import contextProperty from "$lib/sveltelib/context-property"; import lifecycleHooks from "$lib/sveltelib/lifecycle-hooks"; @@ -520,6 +520,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html decodeIriPaths, noteFieldsCheck, addNote, + addMediaFromPath, } from "@generated/backend"; import { wrapInternal } from "@tslib/wrap"; import { getProfileConfig, getMeta, setMeta, getColConfig } from "@tslib/profile"; @@ -542,22 +543,34 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import PreviewButton from "./PreviewButton.svelte"; import { NoteFieldsCheckResponse_State, type Note } from "@generated/anki/notes_pb"; + $: isIOImageLoaded = false; $: ioImageLoadedStore.set(isIOImageLoaded); let imageOcclusionMode: IOMode | undefined; let ioFields = new ImageOcclusionFieldIndexes({}); - function pickIOImage() { + async function pickIOImage() { imageOcclusionMode = undefined; - bridgeCommand("addImageForOcclusion"); + const filename = await openFilePickerForImageOcclusion(); + if(!filename) { + return; + } + setupMaskEditor(filename); } - function pickIOImageFromClipboard() { + async function pickIOImageFromClipboard() { imageOcclusionMode = undefined; - bridgeCommand("addImageForOcclusionFromClipboard"); + await setupMaskEditorFromClipboard(); } - async function setupMaskEditor(options: { html: string; mode: IOMode }) { + async function setupMaskEditor(filename: string) { + if(mode == "add") { + setupMaskEditorForNewNote(filename); + } else { + setupMaskEditorForExistingNote(filename); + } + } + async function setupMaskEditorInner(options: { html: string; mode: IOMode }) { imageOcclusionMode = undefined; await tick(); imageOcclusionMode = options.mode; @@ -582,6 +595,47 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html isIOImageLoaded = true; } + async function setupMaskEditorForNewNote(imagePath: string) { + const imageFieldHtml = filenameToLink((await addMediaFromPath({ + path: imagePath, + })).val); + setupMaskEditorInner({ + html: imageFieldHtml, + mode: { + kind: "add", + imagePath: imagePath, + notetypeId: notetypeMeta.id, + }, + }); + } + + async function setupMaskEditorForExistingNote(imagePath: string | null = null) { + if (imagePath) { + const imageFieldHtml = filenameToLink((await addMediaFromPath({ + path: imagePath, + })).val); + resetIOImage(imagePath, () => {}); + setImageField(imageFieldHtml); + } + setupMaskEditorInner({ + html: note!.fields[ioFields.image], + mode: { + kind: "edit", + noteId: note!.id, + }, + }); + } + + async function setupMaskEditorFromClipboard() { + const path = await readImageFromClipboard(); + console.log("setupMaskEditorFromClipboard path", path); + if(path) { + setupMaskEditor(path); + } else { + alert(tr.editingNoImageFoundOnClipboard()); + } + } + function setImageField(html) { fieldStores[ioFields.image].set(html); } @@ -739,7 +793,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html const imageField = note!.fields[ioFields.image]; // TODO: last_io_image_path if (mode !== "add") { - setupMaskEditor({ + setupMaskEditorInner({ html: imageField, mode: { kind: "edit", @@ -747,7 +801,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html }, }); } else if (originalNoteId) { - setupMaskEditor({ + setupMaskEditorInner({ html: imageField, mode: { kind: "add", @@ -811,6 +865,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html triggerChanges, setIsImageOcclusion, setupMaskEditor, + setupMaskEditorForNewNote, + setupMaskEditorForExistingNote, setImageField, resetIOImageLoaded, saveOcclusions, diff --git a/ts/routes/editor/rich-text-input/data-transfer.ts b/ts/routes/editor/rich-text-input/data-transfer.ts index 1da56b194..114df131c 100644 --- a/ts/routes/editor/rich-text-input/data-transfer.ts +++ b/ts/routes/editor/rich-text-input/data-transfer.ts @@ -2,7 +2,16 @@ // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { ConfigKey_Bool } from "@generated/anki/config_pb"; -import { addMediaFile, convertPastedImage, getConfigBool, retrieveUrl as retrieveUrlBackend } from "@generated/backend"; +import { + addMediaFile, + convertPastedImage, + extractMediaFiles, + getAbsoluteMediaPath, + getConfigBool, + openFilePicker, + retrieveUrl as retrieveUrlBackend, +} from "@generated/backend"; +import * as tr from "@generated/ftl"; import { shiftPressed } from "@tslib/keys"; import { pasteHTML } from "../old-editor-adapter"; @@ -57,8 +66,20 @@ function escapeHtml(text: string, quote = true): string { } return text; } -function getUrls(data: DataTransfer): string[] { - const urls = data.getData("text/uri-list").split("\n"); + +async function getUrls(data: DataTransfer | ClipboardItem): Promise { + const mime = "text/uri-list"; + let dataString: string; + if (data instanceof DataTransfer) { + dataString = data.getData(mime); + } else { + try { + dataString = await (await data.getType(mime)).text(); + } catch (e) { + return []; + } + } + const urls = dataString.split("\n"); return urls[0] ? urls : []; } @@ -83,17 +104,23 @@ const QIMAGE_FORMATS = [ "image/x-xpixmap", ]; -async function getImageData(data: DataTransfer): Promise { +async function getImageData(data: DataTransfer | ClipboardItem): Promise { for (const type of QIMAGE_FORMATS) { - const image = data.getData(type); - if (image) { - return image; - } else { - for (const file of data.files ?? []) { - if (file.type === type) { - return new Uint8Array(await file.arrayBuffer()); + try { + const image = data instanceof DataTransfer + ? data.getData(type) + : new Uint8Array(await (await data.getType(type)).arrayBuffer()); + if (image) { + return image; + } else if (data instanceof DataTransfer) { + for (const file of data.files ?? []) { + if (file.type === type) { + return new Uint8Array(await file.arrayBuffer()); + } } } + } catch (e) { + continue; } } return null; @@ -120,7 +147,7 @@ async function urlToFile(url: string): Promise { return null; } -function filenameToLink(filename: string): string { +export function filenameToLink(filename: string): string { const filenameParts = filename.split("."); const ext = filenameParts[filenameParts.length - 1].toLowerCase(); if (imageSuffixes.includes(ext)) { @@ -157,7 +184,7 @@ async function addMediaFromData(filename: string, data: ImageData): Promise { @@ -184,7 +211,7 @@ async function inlinedImageToFilename(src: string): Promise { if (ext === "jpeg") { ext = "jpg"; } - return await addPastedImage(data, ext); + return filenameToLink(await addPastedImage(data, ext)); } } return ""; @@ -205,8 +232,8 @@ function isURL(s: string): boolean { return prefixes.some(prefix => s.startsWith(prefix)); } -async function processUrls(data: DataTransfer, _extended: Promise): Promise { - const urls = getUrls(data); +async function processUrls(data: DataTransfer | ClipboardItem, _extended: Promise): Promise { + const urls = await getUrls(data); if (urls.length === 0) { return null; } @@ -221,18 +248,20 @@ async function processUrls(data: DataTransfer, _extended: Promise): Pro return text; } +async function getPreferredImageExtension(): Promise { + if (await getConfigBool({ key: ConfigKey_Bool.PASTE_IMAGES_AS_PNG })) { + return "png"; + } + return "jpg"; +} + async function processImages(data: DataTransfer, _extended: Promise): Promise { const image = await getImageData(data); if (!image) { return null; } - let ext: string; - if (await getConfigBool({ key: ConfigKey_Bool.PASTE_IMAGES_AS_PNG })) { - ext = "png"; - } else { - ext = "jpg"; - } - return await addPastedImage(image, ext, true); + const ext = await getPreferredImageExtension(); + return filenameToLink(await addPastedImage(image, ext, true)); } async function processText(data: DataTransfer, extended: Promise): Promise { @@ -277,7 +306,7 @@ async function processDataTransferEvent( if (html) { return { html, internal: false }; } - const urls = getUrls(data); + const urls = await getUrls(data); let handlers: ((data: DataTransfer, extended: Promise) => Promise)[]; if (urls.length > 0 && urls[0].startsWith("file://")) { handlers = [processUrls, processImages, processText]; @@ -359,3 +388,44 @@ export async function handleKeydown(event: KeyboardEvent) { export function handleCutOrCopy(event: ClipboardEvent) { lastInternalFieldText = getHtml(event.clipboardData!); } + +const FILE_PICKER_MEDIA_KEY = "media"; + +export async function openFilePickerForImageOcclusion(): Promise { + const filename = (await openFilePicker({ + title: tr.editingAddMedia(), + filterDescription: tr.editingMedia(), + extensions: imageSuffixes, + key: FILE_PICKER_MEDIA_KEY, + })).val; + return filename; +} + +export async function extractImagePathFromHtml(html: string): Promise { + const images = (await extractMediaFiles({ val: html })).vals; + if (images.length === 0) { + return null; + } + return images[0]; +} + +export async function readImageFromClipboard(): Promise { + // TODO: check browser support and available formats + for (const item of await navigator.clipboard.read()) { + let path: string | null = null; + const html = await processUrls(item, Promise.resolve(false)); + if (html) { + path = await extractImagePathFromHtml(html); + } + if (!path) { + const image = await getImageData(item); + if (!image) { + continue; + } + const ext = await getPreferredImageExtension(); + path = await addPastedImage(image, ext, true); + } + return (await getAbsoluteMediaPath({ val: path })).val; + } + return null; +}