mirror of
https://github.com/ankitects/anki.git
synced 2025-09-24 00:36:38 -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)
|
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;
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
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:
|
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:
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue