mirror of
https://github.com/ankitects/anki.git
synced 2025-09-23 16:26:40 -04:00
Move more image occlusion calls
This commit is contained in:
parent
3b42cd0e54
commit
13c2dd201a
9 changed files with 271 additions and 147 deletions
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
134
qt/aqt/editor.py
134
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 <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,
|
||||
):
|
||||
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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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<CheckMediaResponse> {
|
||||
|
@ -40,6 +43,16 @@ impl crate::services::MediaService for Collection {
|
|||
.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<()> {
|
||||
self.media()?.remove_files(&input.fnames)
|
||||
}
|
||||
|
@ -66,4 +79,14 @@ impl crate::services::MediaService for Collection {
|
|||
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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<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 : [];
|
||||
}
|
||||
|
||||
|
@ -83,17 +104,23 @@ const QIMAGE_FORMATS = [
|
|||
"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) {
|
||||
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<string | null> {
|
|||
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<stri
|
|||
desiredName: filename,
|
||||
data: imageDataToUint8Array(data),
|
||||
})).val;
|
||||
return filenameToLink(filename);
|
||||
return filename;
|
||||
}
|
||||
|
||||
async function pastedImageFilename(data: ImageData, ext: string): Promise<string> {
|
||||
|
@ -184,7 +211,7 @@ async function inlinedImageToFilename(src: string): Promise<string> {
|
|||
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<boolean>): Promise<string | null> {
|
||||
const urls = getUrls(data);
|
||||
async function processUrls(data: DataTransfer | ClipboardItem, _extended: Promise<boolean>): Promise<string | null> {
|
||||
const urls = await getUrls(data);
|
||||
if (urls.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
@ -221,18 +248,20 @@ async function processUrls(data: DataTransfer, _extended: Promise<boolean>): Pro
|
|||
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> {
|
||||
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<boolean>): Promise<string | null> {
|
||||
|
@ -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<boolean>) => Promise<string | null>)[];
|
||||
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<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;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue