Move more image occlusion calls

This commit is contained in:
Abdo 2025-06-11 21:16:56 +03:00
parent 3b42cd0e54
commit 13c2dd201a
9 changed files with 271 additions and 147 deletions

View file

@ -34,6 +34,7 @@ service FrontendService {
rpc convertPastedImage(ConvertPastedImageRequest) rpc convertPastedImage(ConvertPastedImageRequest)
returns (ConvertPastedImageResponse); returns (ConvertPastedImageResponse);
rpc retrieveUrl(generic.String) returns (RetrieveUrlResponse); rpc retrieveUrl(generic.String) returns (RetrieveUrlResponse);
rpc openFilePicker(openFilePickerRequest) returns (generic.String);
// Profile config // Profile config
rpc GetProfileConfigJson(generic.String) returns (generic.Json); rpc GetProfileConfigJson(generic.String) returns (generic.Json);
@ -74,3 +75,10 @@ message SetSettingJsonRequest {
string key = 1; string key = 1;
bytes value_json = 2; bytes value_json = 2;
} }
message openFilePickerRequest {
string title = 1;
string key = 2;
string filter_description = 3;
repeated string extensions = 4;
}

View file

@ -13,11 +13,14 @@ import "anki/notetypes.proto";
service MediaService { service MediaService {
rpc CheckMedia(generic.Empty) returns (CheckMediaResponse); rpc CheckMedia(generic.Empty) returns (CheckMediaResponse);
rpc AddMediaFile(AddMediaFileRequest) returns (generic.String); rpc AddMediaFile(AddMediaFileRequest) returns (generic.String);
rpc AddMediaFromPath(AddMediaFromPathRequest) returns (generic.String);
rpc TrashMediaFiles(TrashMediaFilesRequest) returns (generic.Empty); rpc TrashMediaFiles(TrashMediaFilesRequest) returns (generic.Empty);
rpc EmptyTrash(generic.Empty) returns (generic.Empty); rpc EmptyTrash(generic.Empty) returns (generic.Empty);
rpc RestoreTrash(generic.Empty) returns (generic.Empty); rpc RestoreTrash(generic.Empty) returns (generic.Empty);
rpc ExtractStaticMediaFiles(notetypes.NotetypeId) rpc ExtractStaticMediaFiles(notetypes.NotetypeId)
returns (generic.StringList); 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 // Implicitly includes any of the above methods that are not listed in the
@ -40,3 +43,7 @@ message AddMediaFileRequest {
string desired_name = 1; string desired_name = 1;
bytes data = 2; bytes data = 2;
} }
message AddMediaFromPathRequest {
string path = 1;
}

View file

@ -130,7 +130,7 @@ distro==1.9.0 ; sys_platform != "darwin" and sys_platform != "win32" \
--hash=sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed \ --hash=sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed \
--hash=sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2 --hash=sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2
# via -r requirements.anki.in # via -r requirements.anki.in
flask==3.0.3 \ flask[async]==3.0.3 \
--hash=sha256:34e815dfaa43340d1d15a5c3a02b8476004037eb4840b34910c6e21679d288f3 \ --hash=sha256:34e815dfaa43340d1d15a5c3a02b8476004037eb4840b34910c6e21679d288f3 \
--hash=sha256:ceb27b0af3823ea2737928a4d99d125a06175b8512c445cbd9a9ce200ef76842 --hash=sha256:ceb27b0af3823ea2737928a4d99d125a06175b8512c445cbd9a9ce200ef76842
# via # via

View file

@ -176,7 +176,7 @@ exceptiongroup==1.2.2 \
--hash=sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b \ --hash=sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b \
--hash=sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc --hash=sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc
# via pytest # via pytest
flask==3.0.3 \ flask[async]==3.0.3 \
--hash=sha256:34e815dfaa43340d1d15a5c3a02b8476004037eb4840b34910c6e21679d288f3 \ --hash=sha256:34e815dfaa43340d1d15a5c3a02b8476004037eb4840b34910c6e21679d288f3 \
--hash=sha256:ceb27b0af3823ea2737928a4d99d125a06175b8512c445cbd9a9ce200ef76842 --hash=sha256:ceb27b0af3823ea2737928a4d99d125a06175b8512c445cbd9a9ce200ef76842
# via # via

View file

@ -873,131 +873,37 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
def setup_mask_editor(self, image_path: str) -> None: def setup_mask_editor(self, image_path: str) -> None:
try: try:
if self.editorMode == EditorMode.ADD_CARDS: if self.editorMode == EditorMode.ADD_CARDS:
self.setup_mask_editor_for_new_note( self.setup_mask_editor_for_new_note(image_path=image_path)
image_path=image_path, notetype_id=0
)
else: else:
assert self.note is not None assert self.note is not None
self.setup_mask_editor_for_existing_note( self.setup_mask_editor_for_existing_note(image_path=image_path)
note_id=self.note.id, image_path=image_path
)
except Exception as e: except Exception as e:
showWarning(str(e)) showWarning(str(e))
def select_image_and_occlude(self) -> None: def setup_mask_editor_for_new_note(self, image_path: str):
"""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 <a>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,
):
"""Set-up IO mask editor for adding new notes """Set-up IO mask editor for adding new notes
Presupposes that active editor notetype is an image occlusion notetype Presupposes that active editor notetype is an image occlusion notetype
Args: Args:
image_path: Absolute path to image. 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( self.web.eval(
'require("anki/ui").loaded.then(() =>' 'require("anki/ui").loaded.then(() =>'
f"setupMaskEditor({json.dumps(io_options)})" f"setupMaskEditorForNewNote({json.dumps(image_path)})"
"); " "); "
) )
@staticmethod def setup_mask_editor_for_existing_note(self, image_path: str | None = None):
def _create_add_io_options( """Set-up IO mask editor for editing existing notes
image_path: str, image_field_html: str, notetype_id: NotetypeId | int = 0 Presupposes that active editor notetype is an image occlusion notetype
) -> dict: Args:
return { image_path: (Optional) Absolute path to image that should replace current
"mode": {"kind": "add", "imagePath": image_path, "notetypeId": notetype_id}, image
"html": image_field_html, """
} self.web.eval(
'require("anki/ui").loaded.then(() =>'
@staticmethod f"setupMaskEditorForExistingNote({json.dumps(image_path)})"
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}}
# Links from HTML # Links from HTML
###################################################################### ######################################################################
@ -1010,8 +916,6 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
record=Editor.onRecSound, record=Editor.onRecSound,
paste=Editor.onPaste, paste=Editor.onPaste,
cutOrCopy=Editor.onCutOrCopy, cutOrCopy=Editor.onCutOrCopy,
addImageForOcclusion=Editor.select_image_and_occlude,
addImageForOcclusionFromClipboard=Editor.select_image_from_clipboard_and_occlude,
) )
@property @property
@ -1044,6 +948,12 @@ class EditorWebView(AnkiWebView):
clip = self.editor.mw.app.clipboard() clip = self.editor.mw.app.clipboard()
assert clip is not None assert clip is not None
clip.dataChanged.connect(self._on_clipboard_change) 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) gui_hooks.editor_web_view_did_init(self)
def user_cut_or_copied(self) -> None: def user_cut_or_copied(self) -> None:

View file

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import enum import enum
import logging import logging
import mimetypes import mimetypes
@ -693,6 +694,34 @@ def retrieve_url() -> bytes:
).SerializeToString() ).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 = [ post_handler_list = [
congrats_info, congrats_info,
get_deck_configs_for_update, get_deck_configs_for_update,
@ -716,6 +745,7 @@ post_handler_list = [
get_config_json, get_config_json,
convert_pasted_image, convert_pasted_image,
retrieve_url, retrieve_url,
open_file_picker,
] ]
@ -772,6 +802,8 @@ exposed_backend_list = [
"get_config_bool", "get_config_bool",
# MediaService # MediaService
"add_media_file", "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 # convert bytes/None into response
def wrapped() -> Response: def wrapped() -> Response:
try: 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 = flask.make_response(data)
response.headers["Content-Type"] = "application/binary" response.headers["Content-Type"] = "application/binary"
else: else:

View file

@ -1,9 +1,11 @@
use std::collections::HashSet; use std::collections::HashSet;
use std::path::Path;
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use anki_proto::generic; use anki_proto::generic;
use anki_proto::media::AddMediaFileRequest; use anki_proto::media::AddMediaFileRequest;
use anki_proto::media::AddMediaFromPathRequest;
use anki_proto::media::CheckMediaResponse; use anki_proto::media::CheckMediaResponse;
use anki_proto::media::TrashMediaFilesRequest; use anki_proto::media::TrashMediaFilesRequest;
@ -12,6 +14,7 @@ use crate::error;
use crate::error::OrNotFound; use crate::error::OrNotFound;
use crate::notes::service::to_i64s; use crate::notes::service::to_i64s;
use crate::notetype::NotetypeId; use crate::notetype::NotetypeId;
use crate::text::extract_media_refs;
impl crate::services::MediaService for Collection { impl crate::services::MediaService for Collection {
fn check_media(&mut self) -> error::Result<CheckMediaResponse> { fn check_media(&mut self) -> error::Result<CheckMediaResponse> {
@ -40,6 +43,16 @@ impl crate::services::MediaService for Collection {
.into()) .into())
} }
fn add_media_from_path(&mut self, input: AddMediaFromPathRequest) -> error::Result<generic::String> {
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<()> { fn trash_media_files(&mut self, input: TrashMediaFilesRequest) -> error::Result<()> {
self.media()?.remove_files(&input.fnames) self.media()?.remove_files(&input.fnames)
} }
@ -66,4 +79,14 @@ impl crate::services::MediaService for Collection {
Ok(files.into_iter().collect::<Vec<_>>().into()) Ok(files.into_iter().collect::<Vec<_>>().into())
} }
fn extract_media_files(&mut self, html: anki_proto::generic::String) -> error::Result<generic::StringList> {
let files = extract_media_refs(&html.val).iter().map(|r| r.fname_decoded.to_string()).collect::<Vec<_>>();
Ok(files.into())
}
fn get_absolute_media_path(&mut self, path: anki_proto::generic::String) -> error::Result<generic::String> {
Ok(self.media()?.media_folder.join(path.val).to_string_lossy().to_string().into())
}
} }

View file

@ -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 { registerPackage } from "@tslib/runtime-require";
import { filenameToLink, openFilePickerForImageOcclusion, readImageFromClipboard } 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";
@ -520,6 +520,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
decodeIriPaths, decodeIriPaths,
noteFieldsCheck, noteFieldsCheck,
addNote, addNote,
addMediaFromPath,
} from "@generated/backend"; } from "@generated/backend";
import { wrapInternal } from "@tslib/wrap"; import { wrapInternal } from "@tslib/wrap";
import { getProfileConfig, getMeta, setMeta, getColConfig } from "@tslib/profile"; 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 PreviewButton from "./PreviewButton.svelte";
import { NoteFieldsCheckResponse_State, type Note } from "@generated/anki/notes_pb"; import { NoteFieldsCheckResponse_State, type Note } from "@generated/anki/notes_pb";
$: 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({});
function pickIOImage() { async function pickIOImage() {
imageOcclusionMode = undefined; imageOcclusionMode = undefined;
bridgeCommand("addImageForOcclusion"); const filename = await openFilePickerForImageOcclusion();
if(!filename) {
return;
}
setupMaskEditor(filename);
} }
function pickIOImageFromClipboard() { async function pickIOImageFromClipboard() {
imageOcclusionMode = undefined; 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; imageOcclusionMode = undefined;
await tick(); await tick();
imageOcclusionMode = options.mode; imageOcclusionMode = options.mode;
@ -582,6 +595,47 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
isIOImageLoaded = true; 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) { function setImageField(html) {
fieldStores[ioFields.image].set(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]; const imageField = note!.fields[ioFields.image];
// TODO: last_io_image_path // TODO: last_io_image_path
if (mode !== "add") { if (mode !== "add") {
setupMaskEditor({ setupMaskEditorInner({
html: imageField, html: imageField,
mode: { mode: {
kind: "edit", kind: "edit",
@ -747,7 +801,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}, },
}); });
} else if (originalNoteId) { } else if (originalNoteId) {
setupMaskEditor({ setupMaskEditorInner({
html: imageField, html: imageField,
mode: { mode: {
kind: "add", kind: "add",
@ -811,6 +865,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
triggerChanges, triggerChanges,
setIsImageOcclusion, setIsImageOcclusion,
setupMaskEditor, setupMaskEditor,
setupMaskEditorForNewNote,
setupMaskEditorForExistingNote,
setImageField, setImageField,
resetIOImageLoaded, resetIOImageLoaded,
saveOcclusions, saveOcclusions,

View file

@ -2,7 +2,16 @@
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { ConfigKey_Bool } from "@generated/anki/config_pb"; 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 { shiftPressed } from "@tslib/keys";
import { pasteHTML } from "../old-editor-adapter"; import { pasteHTML } from "../old-editor-adapter";
@ -57,8 +66,20 @@ function escapeHtml(text: string, quote = true): string {
} }
return text; return text;
} }
function getUrls(data: DataTransfer): string[] {
const urls = data.getData("text/uri-list").split("\n"); async function getUrls(data: DataTransfer | ClipboardItem): Promise<string[]> {
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 : []; return urls[0] ? urls : [];
} }
@ -83,17 +104,23 @@ const QIMAGE_FORMATS = [
"image/x-xpixmap", "image/x-xpixmap",
]; ];
async function getImageData(data: DataTransfer): Promise<ImageData | null> { async function getImageData(data: DataTransfer | ClipboardItem): Promise<ImageData | null> {
for (const type of QIMAGE_FORMATS) { for (const type of QIMAGE_FORMATS) {
const image = data.getData(type); try {
if (image) { const image = data instanceof DataTransfer
return image; ? data.getData(type)
} else { : new Uint8Array(await (await data.getType(type)).arrayBuffer());
for (const file of data.files ?? []) { if (image) {
if (file.type === type) { return image;
return new Uint8Array(await file.arrayBuffer()); } 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; return null;
@ -120,7 +147,7 @@ async function urlToFile(url: string): Promise<string | null> {
return null; return null;
} }
function filenameToLink(filename: string): string { export function filenameToLink(filename: string): string {
const filenameParts = filename.split("."); const filenameParts = filename.split(".");
const ext = filenameParts[filenameParts.length - 1].toLowerCase(); const ext = filenameParts[filenameParts.length - 1].toLowerCase();
if (imageSuffixes.includes(ext)) { if (imageSuffixes.includes(ext)) {
@ -157,7 +184,7 @@ async function addMediaFromData(filename: string, data: ImageData): Promise<stri
desiredName: filename, desiredName: filename,
data: imageDataToUint8Array(data), data: imageDataToUint8Array(data),
})).val; })).val;
return filenameToLink(filename); return filename;
} }
async function pastedImageFilename(data: ImageData, ext: string): Promise<string> { async function pastedImageFilename(data: ImageData, ext: string): Promise<string> {
@ -184,7 +211,7 @@ async function inlinedImageToFilename(src: string): Promise<string> {
if (ext === "jpeg") { if (ext === "jpeg") {
ext = "jpg"; ext = "jpg";
} }
return await addPastedImage(data, ext); return filenameToLink(await addPastedImage(data, ext));
} }
} }
return ""; return "";
@ -205,8 +232,8 @@ function isURL(s: string): boolean {
return prefixes.some(prefix => s.startsWith(prefix)); return prefixes.some(prefix => s.startsWith(prefix));
} }
async function processUrls(data: DataTransfer, _extended: Promise<boolean>): Promise<string | null> { async function processUrls(data: DataTransfer | ClipboardItem, _extended: Promise<boolean>): Promise<string | null> {
const urls = getUrls(data); const urls = await getUrls(data);
if (urls.length === 0) { if (urls.length === 0) {
return null; return null;
} }
@ -221,18 +248,20 @@ async function processUrls(data: DataTransfer, _extended: Promise<boolean>): Pro
return text; return text;
} }
async function getPreferredImageExtension(): Promise<string> {
if (await getConfigBool({ key: ConfigKey_Bool.PASTE_IMAGES_AS_PNG })) {
return "png";
}
return "jpg";
}
async function processImages(data: DataTransfer, _extended: Promise<boolean>): Promise<string | null> { async function processImages(data: DataTransfer, _extended: Promise<boolean>): Promise<string | null> {
const image = await getImageData(data); const image = await getImageData(data);
if (!image) { if (!image) {
return null; return null;
} }
let ext: string; const ext = await getPreferredImageExtension();
if (await getConfigBool({ key: ConfigKey_Bool.PASTE_IMAGES_AS_PNG })) { return filenameToLink(await addPastedImage(image, ext, true));
ext = "png";
} else {
ext = "jpg";
}
return await addPastedImage(image, ext, true);
} }
async function processText(data: DataTransfer, extended: Promise<boolean>): Promise<string | null> { async function processText(data: DataTransfer, extended: Promise<boolean>): Promise<string | null> {
@ -277,7 +306,7 @@ async function processDataTransferEvent(
if (html) { if (html) {
return { html, internal: false }; return { html, internal: false };
} }
const urls = getUrls(data); const urls = await getUrls(data);
let handlers: ((data: DataTransfer, extended: Promise<boolean>) => Promise<string | null>)[]; let handlers: ((data: DataTransfer, extended: Promise<boolean>) => Promise<string | null>)[];
if (urls.length > 0 && urls[0].startsWith("file://")) { if (urls.length > 0 && urls[0].startsWith("file://")) {
handlers = [processUrls, processImages, processText]; handlers = [processUrls, processImages, processText];
@ -359,3 +388,44 @@ export async function handleKeydown(event: KeyboardEvent) {
export function handleCutOrCopy(event: ClipboardEvent) { export function handleCutOrCopy(event: ClipboardEvent) {
lastInternalFieldText = getHtml(event.clipboardData!); lastInternalFieldText = getHtml(event.clipboardData!);
} }
const FILE_PICKER_MEDIA_KEY = "media";
export async function openFilePickerForImageOcclusion(): Promise<string> {
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<string | null> {
const images = (await extractMediaFiles({ val: html })).vals;
if (images.length === 0) {
return null;
}
return images[0];
}
export async function readImageFromClipboard(): Promise<string | null> {
// 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;
}