mirror of
https://github.com/ankitects/anki.git
synced 2025-11-06 12:47:11 -05:00
Merge 0c1aa173f0 into dac26ce671
This commit is contained in:
commit
14870151eb
173 changed files with 7263 additions and 3340 deletions
|
|
@ -41,11 +41,17 @@ module.exports = {
|
||||||
parser: "svelte-eslint-parser",
|
parser: "svelte-eslint-parser",
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
parser: "@typescript-eslint/parser",
|
parser: "@typescript-eslint/parser",
|
||||||
|
svelteFeatures: {
|
||||||
|
experimentalGenerics: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
"svelte/no-at-html-tags": "off",
|
"svelte/no-at-html-tags": "off",
|
||||||
"svelte/valid-compile": ["error", { "ignoreWarnings": true }],
|
"svelte/valid-compile": ["error", { "ignoreWarnings": true }],
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"prefer-const": "off",
|
||||||
|
// TODO: enable this when we update to eslint-plugin-svelte 3
|
||||||
|
// "svelte/prefer-const": "warn",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -191,7 +191,12 @@ fn build_js(build: &mut Build) -> Result<()> {
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
let files_from_ts = build.inputs_with_suffix(
|
let files_from_ts = build.inputs_with_suffix(
|
||||||
inputs![":ts:editor", ":ts:reviewer:reviewer.js", ":ts:mathjax"],
|
inputs![
|
||||||
|
":ts:editor",
|
||||||
|
":ts:editable",
|
||||||
|
":ts:reviewer:reviewer.js",
|
||||||
|
":ts:mathjax"
|
||||||
|
],
|
||||||
".js",
|
".js",
|
||||||
);
|
);
|
||||||
build.add_action(
|
build.add_action(
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,7 @@ fn declare_and_check_other_libraries(build: &mut Build) -> Result<()> {
|
||||||
"components",
|
"components",
|
||||||
inputs![":ts:lib", ":ts:sveltelib", glob!("ts/components/**")],
|
inputs![":ts:lib", ":ts:sveltelib", glob!("ts/components/**")],
|
||||||
),
|
),
|
||||||
("html-filter", inputs![glob!("ts/html-filter/**")]),
|
("html-filter", inputs![glob!("ts/lib/html-filter/**")]),
|
||||||
] {
|
] {
|
||||||
let library_with_ts = format!("ts:{library}");
|
let library_with_ts = format!("ts:{library}");
|
||||||
build.add_dependency(&library_with_ts, inputs.clone());
|
build.add_dependency(&library_with_ts, inputs.clone());
|
||||||
|
|
@ -187,7 +187,7 @@ fn build_and_check_pages(build: &mut Build) -> Result<()> {
|
||||||
let entrypoint = if html {
|
let entrypoint = if html {
|
||||||
format!("ts/routes/{name}/index.ts")
|
format!("ts/routes/{name}/index.ts")
|
||||||
} else {
|
} else {
|
||||||
format!("ts/{name}/index.ts")
|
format!("ts/lib/{name}/index.ts")
|
||||||
};
|
};
|
||||||
build.add_action(
|
build.add_action(
|
||||||
&group,
|
&group,
|
||||||
|
|
@ -203,12 +203,11 @@ fn build_and_check_pages(build: &mut Build) -> Result<()> {
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
};
|
};
|
||||||
// we use the generated .css file separately
|
// we use the generated .css file separately in the legacy editor
|
||||||
build_page(
|
build_page(
|
||||||
"editable",
|
"editable",
|
||||||
false,
|
false,
|
||||||
inputs![
|
inputs![
|
||||||
//
|
|
||||||
":ts:lib",
|
":ts:lib",
|
||||||
":ts:components",
|
":ts:components",
|
||||||
":ts:domlib",
|
":ts:domlib",
|
||||||
|
|
@ -220,21 +219,15 @@ fn build_and_check_pages(build: &mut Build) -> Result<()> {
|
||||||
build_page(
|
build_page(
|
||||||
"congrats",
|
"congrats",
|
||||||
true,
|
true,
|
||||||
inputs![
|
inputs![":ts:lib", ":ts:components", ":sass", ":sveltekit"],
|
||||||
//
|
|
||||||
":ts:lib",
|
|
||||||
":ts:components",
|
|
||||||
":sass",
|
|
||||||
":sveltekit"
|
|
||||||
],
|
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Only used for the legacy editor page.
|
||||||
fn build_and_check_editor(build: &mut Build) -> Result<()> {
|
fn build_and_check_editor(build: &mut Build) -> Result<()> {
|
||||||
let editor_deps = inputs![
|
let editor_deps = inputs![
|
||||||
//
|
|
||||||
":ts:lib",
|
":ts:lib",
|
||||||
":ts:components",
|
":ts:components",
|
||||||
":ts:domlib",
|
":ts:domlib",
|
||||||
|
|
@ -242,14 +235,14 @@ fn build_and_check_editor(build: &mut Build) -> Result<()> {
|
||||||
":ts:html-filter",
|
":ts:html-filter",
|
||||||
":sass",
|
":sass",
|
||||||
":sveltekit",
|
":sveltekit",
|
||||||
glob!("ts/{editable,editor,routes/image-occlusion}/**")
|
glob!("ts/lib/editable,ts/routes/{editor,image-occlusion}/**")
|
||||||
];
|
];
|
||||||
|
|
||||||
build.add_action(
|
build.add_action(
|
||||||
"ts:editor",
|
"ts:editor",
|
||||||
EsbuildScript {
|
EsbuildScript {
|
||||||
script: "ts/bundle_svelte.mjs".into(),
|
script: "ts/bundle_svelte.mjs".into(),
|
||||||
entrypoint: "ts/editor/index.ts".into(),
|
entrypoint: "ts/routes/editor/index.ts".into(),
|
||||||
output_stem: "ts/editor/editor",
|
output_stem: "ts/editor/editor",
|
||||||
deps: editor_deps.clone(),
|
deps: editor_deps.clone(),
|
||||||
extra_exts: &["css"],
|
extra_exts: &["css"],
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,10 @@ message OpChangesOnly {
|
||||||
collection.OpChanges changes = 1;
|
collection.OpChanges changes = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message NestedOpChanges {
|
||||||
|
OpChangesOnly changes = 1;
|
||||||
|
}
|
||||||
|
|
||||||
message OpChangesWithCount {
|
message OpChangesWithCount {
|
||||||
OpChanges changes = 1;
|
OpChanges changes = 1;
|
||||||
uint32 count = 2;
|
uint32 count = 2;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@ package anki.frontend;
|
||||||
import "anki/scheduler.proto";
|
import "anki/scheduler.proto";
|
||||||
import "anki/generic.proto";
|
import "anki/generic.proto";
|
||||||
import "anki/search.proto";
|
import "anki/search.proto";
|
||||||
|
import "anki/notes.proto";
|
||||||
|
import "anki/notetypes.proto";
|
||||||
|
import "anki/links.proto";
|
||||||
|
|
||||||
service FrontendService {
|
service FrontendService {
|
||||||
// Returns values from the reviewer
|
// Returns values from the reviewer
|
||||||
|
|
@ -28,6 +31,33 @@ service FrontendService {
|
||||||
// Warns python that the deck option web view is ready to receive requests.
|
// Warns python that the deck option web view is ready to receive requests.
|
||||||
rpc deckOptionsReady(generic.Empty) returns (generic.Empty);
|
rpc deckOptionsReady(generic.Empty) returns (generic.Empty);
|
||||||
|
|
||||||
|
// Editor
|
||||||
|
rpc ConvertPastedImage(ConvertPastedImageRequest)
|
||||||
|
returns (ConvertPastedImageResponse);
|
||||||
|
rpc OpenFilePicker(openFilePickerRequest) returns (generic.String);
|
||||||
|
rpc OpenMedia(generic.String) returns (generic.Empty);
|
||||||
|
rpc ShowInMediaFolder(generic.String) returns (generic.Empty);
|
||||||
|
rpc RecordAudio(generic.Empty) returns (generic.String);
|
||||||
|
rpc CloseAddCards(generic.Bool) returns (generic.Empty);
|
||||||
|
rpc CloseEditCurrent(generic.Empty) returns (generic.Empty);
|
||||||
|
rpc OpenLink(generic.String) returns (generic.Empty);
|
||||||
|
rpc AskUser(AskUserRequest) returns (generic.Bool);
|
||||||
|
rpc ShowMessageBox(ShowMessageBoxRequest) returns (generic.Empty);
|
||||||
|
rpc OpenFieldsDialog(generic.Empty) returns (generic.Empty);
|
||||||
|
rpc OpenCardsDialog(generic.Empty) returns (generic.Empty);
|
||||||
|
|
||||||
|
// Profile config
|
||||||
|
rpc GetProfileConfigJson(generic.String) returns (generic.Json);
|
||||||
|
rpc SetProfileConfigJson(SetSettingJsonRequest) returns (generic.Empty);
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
rpc GetMetaJson(generic.String) returns (generic.Json);
|
||||||
|
rpc SetMetaJson(SetSettingJsonRequest) returns (generic.Empty);
|
||||||
|
|
||||||
|
// Clipboard
|
||||||
|
rpc ReadClipboard(ReadClipboardRequest) returns (ReadClipboardResponse);
|
||||||
|
rpc WriteClipboard(WriteClipboardRequest) returns (generic.Empty);
|
||||||
|
|
||||||
// Save colour picker's custom colour palette
|
// Save colour picker's custom colour palette
|
||||||
rpc SaveCustomColours(generic.Empty) returns (generic.Empty);
|
rpc SaveCustomColours(generic.Empty) returns (generic.Empty);
|
||||||
}
|
}
|
||||||
|
|
@ -43,3 +73,64 @@ message SetSchedulingStatesRequest {
|
||||||
string key = 1;
|
string key = 1;
|
||||||
scheduler.SchedulingStates states = 2;
|
scheduler.SchedulingStates states = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message ConvertPastedImageRequest {
|
||||||
|
bytes data = 1;
|
||||||
|
string ext = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ConvertPastedImageResponse {
|
||||||
|
bytes data = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ReadClipboardRequest {
|
||||||
|
repeated string types = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ReadClipboardResponse {
|
||||||
|
map<string, bytes> data = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message WriteClipboardRequest {
|
||||||
|
map<string, bytes> data = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Help {
|
||||||
|
oneof value {
|
||||||
|
links.HelpPageLinkRequest.HelpPage help_page = 1;
|
||||||
|
string help_link = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message AskUserRequest {
|
||||||
|
string text = 1;
|
||||||
|
optional Help help = 2;
|
||||||
|
optional string title = 4;
|
||||||
|
optional bool default_no = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MessageBoxType {
|
||||||
|
INFO = 0;
|
||||||
|
WARNING = 1;
|
||||||
|
CRITICAL = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ShowMessageBoxRequest {
|
||||||
|
string text = 1;
|
||||||
|
MessageBoxType type = 2;
|
||||||
|
optional Help help = 3;
|
||||||
|
optional string title = 4;
|
||||||
|
optional string text_format = 5;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,16 +13,21 @@ 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
|
||||||
// backend service.
|
// backend service.
|
||||||
service BackendMediaService {}
|
service BackendMediaService {
|
||||||
|
rpc AddMediaFromUrl(AddMediaFromUrlRequest) returns (AddMediaFromUrlResponse);
|
||||||
|
}
|
||||||
|
|
||||||
message CheckMediaResponse {
|
message CheckMediaResponse {
|
||||||
repeated string unused = 1;
|
repeated string unused = 1;
|
||||||
|
|
@ -40,3 +45,16 @@ message AddMediaFileRequest {
|
||||||
string desired_name = 1;
|
string desired_name = 1;
|
||||||
bytes data = 2;
|
bytes data = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message AddMediaFromPathRequest {
|
||||||
|
string path = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AddMediaFromUrlRequest {
|
||||||
|
string url = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AddMediaFromUrlResponse {
|
||||||
|
optional string filename = 1;
|
||||||
|
optional string error = 2;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ Preferences = config_pb2.Preferences
|
||||||
UndoStatus = collection_pb2.UndoStatus
|
UndoStatus = collection_pb2.UndoStatus
|
||||||
OpChanges = collection_pb2.OpChanges
|
OpChanges = collection_pb2.OpChanges
|
||||||
OpChangesOnly = collection_pb2.OpChangesOnly
|
OpChangesOnly = collection_pb2.OpChangesOnly
|
||||||
|
NestedOpChanges = collection_pb2.NestedOpChanges
|
||||||
OpChangesWithCount = collection_pb2.OpChangesWithCount
|
OpChangesWithCount = collection_pb2.OpChangesWithCount
|
||||||
OpChangesWithId = collection_pb2.OpChangesWithId
|
OpChangesWithId = collection_pb2.OpChangesWithId
|
||||||
OpChangesAfterUndo = collection_pb2.OpChangesAfterUndo
|
OpChangesAfterUndo = collection_pb2.OpChangesAfterUndo
|
||||||
|
|
|
||||||
|
|
@ -125,9 +125,11 @@ from aqt import stats, about, preferences, mediasync # isort:skip
|
||||||
class DialogManager:
|
class DialogManager:
|
||||||
_dialogs: dict[str, list] = {
|
_dialogs: dict[str, list] = {
|
||||||
"AddCards": [addcards.AddCards, None],
|
"AddCards": [addcards.AddCards, None],
|
||||||
|
"NewAddCards": [addcards.NewAddCards, None],
|
||||||
"AddonsDialog": [addons.AddonsDialog, None],
|
"AddonsDialog": [addons.AddonsDialog, None],
|
||||||
"Browser": [browser.Browser, None],
|
"Browser": [browser.Browser, None],
|
||||||
"EditCurrent": [editcurrent.EditCurrent, None],
|
"EditCurrent": [editcurrent.EditCurrent, None],
|
||||||
|
"NewEditCurrent": [editcurrent.NewEditCurrent, None],
|
||||||
"FilteredDeckConfigDialog": [filtered_deck.FilteredDeckConfigDialog, None],
|
"FilteredDeckConfigDialog": [filtered_deck.FilteredDeckConfigDialog, None],
|
||||||
"DeckStats": [stats.DeckStats, None],
|
"DeckStats": [stats.DeckStats, None],
|
||||||
"NewDeckStats": [stats.NewDeckStats, None],
|
"NewDeckStats": [stats.NewDeckStats, None],
|
||||||
|
|
|
||||||
|
|
@ -5,40 +5,28 @@ from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
import aqt.editor
|
|
||||||
import aqt.forms
|
|
||||||
from anki._legacy import deprecated
|
|
||||||
from anki.collection import OpChanges, OpChangesWithCount, SearchNode
|
|
||||||
from anki.decks import DeckId
|
from anki.decks import DeckId
|
||||||
from anki.models import NotetypeId
|
from anki.notes import Note
|
||||||
from anki.notes import Note, NoteFieldsCheckResult, NoteId
|
from anki.utils import is_mac
|
||||||
from anki.utils import html_to_text_line, is_mac
|
|
||||||
from aqt import AnkiQt, gui_hooks
|
from aqt import AnkiQt, gui_hooks
|
||||||
from aqt.deckchooser import DeckChooser
|
from aqt.addcards_legacy import *
|
||||||
from aqt.notetypechooser import NotetypeChooser
|
|
||||||
from aqt.operations.note import add_note
|
|
||||||
from aqt.qt import *
|
from aqt.qt import *
|
||||||
from aqt.sound import av_player
|
|
||||||
from aqt.utils import (
|
from aqt.utils import (
|
||||||
HelpPage,
|
HelpPage,
|
||||||
add_close_shortcut,
|
add_close_shortcut,
|
||||||
ask_user_dialog,
|
ask_user_dialog,
|
||||||
askUser,
|
|
||||||
downArrow,
|
|
||||||
openHelp,
|
openHelp,
|
||||||
restoreGeom,
|
restoreGeom,
|
||||||
saveGeom,
|
saveGeom,
|
||||||
shortcut,
|
|
||||||
showWarning,
|
|
||||||
tooltip,
|
|
||||||
tr,
|
tr,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class AddCards(QMainWindow):
|
class NewAddCards(QMainWindow):
|
||||||
def __init__(self, mw: AnkiQt) -> None:
|
def __init__(self, mw: AnkiQt) -> None:
|
||||||
super().__init__(None, Qt.WindowType.Window)
|
super().__init__(None, Qt.WindowType.Window)
|
||||||
self._close_event_has_cleaned_up = False
|
self._close_event_has_cleaned_up = False
|
||||||
|
self._close_callback: Callable[[], None] = self._close
|
||||||
self.mw = mw
|
self.mw = mw
|
||||||
self.col = mw.col
|
self.col = mw.col
|
||||||
form = aqt.forms.addcards.Ui_Dialog()
|
form = aqt.forms.addcards.Ui_Dialog()
|
||||||
|
|
@ -47,301 +35,52 @@ class AddCards(QMainWindow):
|
||||||
self.setWindowTitle(tr.actions_add())
|
self.setWindowTitle(tr.actions_add())
|
||||||
self.setMinimumHeight(300)
|
self.setMinimumHeight(300)
|
||||||
self.setMinimumWidth(400)
|
self.setMinimumWidth(400)
|
||||||
self.setup_choosers()
|
|
||||||
self.setupEditor()
|
self.setupEditor()
|
||||||
add_close_shortcut(self)
|
add_close_shortcut(self)
|
||||||
self._load_new_note()
|
|
||||||
self.setupButtons()
|
|
||||||
self.history: list[NoteId] = []
|
|
||||||
self._last_added_note: Note | None = None
|
|
||||||
gui_hooks.operation_did_execute.append(self.on_operation_did_execute)
|
|
||||||
restoreGeom(self, "add")
|
restoreGeom(self, "add")
|
||||||
gui_hooks.add_cards_did_init(self)
|
gui_hooks.add_cards_did_init(self)
|
||||||
if not is_mac:
|
if not is_mac:
|
||||||
self.setMenuBar(None)
|
self.setMenuBar(None)
|
||||||
self.show()
|
self.show()
|
||||||
|
|
||||||
def set_deck(self, deck_id: DeckId) -> None:
|
|
||||||
self.deck_chooser.selected_deck_id = deck_id
|
|
||||||
|
|
||||||
def set_note_type(self, note_type_id: NotetypeId) -> None:
|
|
||||||
self.notetype_chooser.selected_notetype_id = note_type_id
|
|
||||||
|
|
||||||
def set_note(self, note: Note, deck_id: DeckId | None = None) -> None:
|
def set_note(self, note: Note, deck_id: DeckId | None = None) -> None:
|
||||||
"""Set tags, field contents and notetype according to `note`. Deck is set
|
"""Set tags, field contents and notetype according to `note`. Deck is set
|
||||||
to `deck_id` or the deck last used with the notetype.
|
to `deck_id` or the deck last used with the notetype.
|
||||||
"""
|
"""
|
||||||
self.notetype_chooser.selected_notetype_id = note.mid
|
self.editor.load_note(
|
||||||
if deck_id or (deck_id := self.col.default_deck_for_notetype(note.mid)):
|
mid=note.mid,
|
||||||
self.deck_chooser.selected_deck_id = deck_id
|
deck_id=deck_id,
|
||||||
|
original_note_id=note.id,
|
||||||
new_note = self._new_note()
|
focus_to=0,
|
||||||
new_note.fields = note.fields[:]
|
)
|
||||||
new_note.tags = note.tags[:]
|
|
||||||
|
|
||||||
self.editor.orig_note_id = note.id
|
|
||||||
self.setAndFocusNote(new_note)
|
|
||||||
|
|
||||||
def setupEditor(self) -> None:
|
def setupEditor(self) -> None:
|
||||||
self.editor = aqt.editor.Editor(
|
self.editor = aqt.editor.NewEditor(
|
||||||
self.mw,
|
self.mw,
|
||||||
self.form.fieldsArea,
|
self.form.fieldsArea,
|
||||||
self,
|
self,
|
||||||
editor_mode=aqt.editor.EditorMode.ADD_CARDS,
|
editor_mode=aqt.editor.EditorMode.ADD_CARDS,
|
||||||
)
|
)
|
||||||
|
|
||||||
def setup_choosers(self) -> None:
|
def reopen(
|
||||||
defaults = self.col.defaults_for_adding(
|
self,
|
||||||
current_review_card=self.mw.reviewer.card
|
mw: AnkiQt,
|
||||||
)
|
deck_id: DeckId | None = None,
|
||||||
|
notetype_id: NotetypeId | None = None,
|
||||||
self.notetype_chooser = NotetypeChooser(
|
) -> None:
|
||||||
mw=self.mw,
|
self.editor.reload_note_if_empty(deck_id, notetype_id)
|
||||||
widget=self.form.modelArea,
|
|
||||||
starting_notetype_id=NotetypeId(defaults.notetype_id),
|
|
||||||
on_button_activated=self.show_notetype_selector,
|
|
||||||
on_notetype_changed=self.on_notetype_change,
|
|
||||||
)
|
|
||||||
self.deck_chooser = DeckChooser(
|
|
||||||
self.mw,
|
|
||||||
self.form.deckArea,
|
|
||||||
starting_deck_id=DeckId(defaults.deck_id),
|
|
||||||
on_deck_changed=self.on_deck_changed,
|
|
||||||
)
|
|
||||||
|
|
||||||
def reopen(self, mw: AnkiQt) -> None:
|
|
||||||
if not self.editor.fieldsAreBlank():
|
|
||||||
return
|
|
||||||
|
|
||||||
defaults = self.col.defaults_for_adding(
|
|
||||||
current_review_card=self.mw.reviewer.card
|
|
||||||
)
|
|
||||||
self.set_note_type(NotetypeId(defaults.notetype_id))
|
|
||||||
self.set_deck(DeckId(defaults.deck_id))
|
|
||||||
|
|
||||||
def helpRequested(self) -> None:
|
def helpRequested(self) -> None:
|
||||||
openHelp(HelpPage.ADDING_CARD_AND_NOTE)
|
openHelp(HelpPage.ADDING_CARD_AND_NOTE)
|
||||||
|
|
||||||
def setupButtons(self) -> None:
|
def load_new_note(
|
||||||
bb = self.form.buttonBox
|
self, deck_id: DeckId | None = None, notetype_id: NotetypeId | None = None
|
||||||
ar = QDialogButtonBox.ButtonRole.ActionRole
|
|
||||||
# add
|
|
||||||
self.addButton = bb.addButton(tr.actions_add(), ar)
|
|
||||||
qconnect(self.addButton.clicked, self.add_current_note)
|
|
||||||
self.addButton.setShortcut(QKeySequence("Ctrl+Return"))
|
|
||||||
# qt5.14+ doesn't handle numpad enter on Windows
|
|
||||||
self.compat_add_shorcut = QShortcut(QKeySequence("Ctrl+Enter"), self)
|
|
||||||
qconnect(self.compat_add_shorcut.activated, self.addButton.click)
|
|
||||||
self.addButton.setToolTip(shortcut(tr.adding_add_shortcut_ctrlandenter()))
|
|
||||||
|
|
||||||
# close
|
|
||||||
self.closeButton = QPushButton(tr.actions_close())
|
|
||||||
self.closeButton.setAutoDefault(False)
|
|
||||||
bb.addButton(self.closeButton, QDialogButtonBox.ButtonRole.RejectRole)
|
|
||||||
qconnect(self.closeButton.clicked, self.close)
|
|
||||||
# help
|
|
||||||
self.helpButton = QPushButton(tr.actions_help(), clicked=self.helpRequested) # type: ignore
|
|
||||||
self.helpButton.setAutoDefault(False)
|
|
||||||
bb.addButton(self.helpButton, QDialogButtonBox.ButtonRole.HelpRole)
|
|
||||||
# history
|
|
||||||
b = bb.addButton(f"{tr.adding_history()} {downArrow()}", ar)
|
|
||||||
if is_mac:
|
|
||||||
sc = "Ctrl+Shift+H"
|
|
||||||
else:
|
|
||||||
sc = "Ctrl+H"
|
|
||||||
b.setShortcut(QKeySequence(sc))
|
|
||||||
b.setToolTip(tr.adding_shortcut(val=shortcut(sc)))
|
|
||||||
qconnect(b.clicked, self.onHistory)
|
|
||||||
b.setEnabled(False)
|
|
||||||
self.historyButton = b
|
|
||||||
|
|
||||||
def setAndFocusNote(self, note: Note) -> None:
|
|
||||||
self.editor.set_note(note, focusTo=0)
|
|
||||||
|
|
||||||
def show_notetype_selector(self) -> None:
|
|
||||||
self.editor.call_after_note_saved(self.notetype_chooser.choose_notetype)
|
|
||||||
|
|
||||||
def on_deck_changed(self, deck_id: int) -> None:
|
|
||||||
gui_hooks.add_cards_did_change_deck(deck_id)
|
|
||||||
|
|
||||||
def on_notetype_change(
|
|
||||||
self, notetype_id: NotetypeId, update_deck: bool = True
|
|
||||||
) -> None:
|
) -> None:
|
||||||
# need to adjust current deck?
|
self.editor.load_note(
|
||||||
if update_deck:
|
mid=notetype_id,
|
||||||
if deck_id := self.col.default_deck_for_notetype(notetype_id):
|
deck_id=deck_id,
|
||||||
self.deck_chooser.selected_deck_id = deck_id
|
focus_to=0,
|
||||||
|
|
||||||
# only used for detecting changed sticky fields on close
|
|
||||||
self._last_added_note = None
|
|
||||||
|
|
||||||
# copy fields into new note with the new notetype
|
|
||||||
old_note = self.editor.note
|
|
||||||
new_note = self._new_note()
|
|
||||||
if old_note:
|
|
||||||
old_field_names = list(old_note.keys())
|
|
||||||
new_field_names = list(new_note.keys())
|
|
||||||
copied_field_names = set()
|
|
||||||
for f in new_note.note_type()["flds"]:
|
|
||||||
field_name = f["name"]
|
|
||||||
# copy identical non-empty fields
|
|
||||||
if field_name in old_field_names and old_note[field_name]:
|
|
||||||
new_note[field_name] = old_note[field_name]
|
|
||||||
copied_field_names.add(field_name)
|
|
||||||
new_idx = 0
|
|
||||||
for old_idx, old_field_value in enumerate(old_field_names):
|
|
||||||
# skip previously copied identical fields in new note
|
|
||||||
while (
|
|
||||||
new_idx < len(new_field_names)
|
|
||||||
and new_field_names[new_idx] in copied_field_names
|
|
||||||
):
|
|
||||||
new_idx += 1
|
|
||||||
if new_idx >= len(new_field_names):
|
|
||||||
break
|
|
||||||
# copy non-empty old fields
|
|
||||||
if (
|
|
||||||
old_field_value not in copied_field_names
|
|
||||||
and old_note.fields[old_idx]
|
|
||||||
):
|
|
||||||
new_note.fields[new_idx] = old_note.fields[old_idx]
|
|
||||||
new_idx += 1
|
|
||||||
|
|
||||||
new_note.tags = old_note.tags
|
|
||||||
|
|
||||||
# and update editor state
|
|
||||||
self.editor.note = new_note
|
|
||||||
self.editor.loadNote(
|
|
||||||
focusTo=min(self.editor.last_field_index or 0, len(new_note.fields) - 1)
|
|
||||||
)
|
)
|
||||||
gui_hooks.addcards_did_change_note_type(
|
|
||||||
self, old_note.note_type(), new_note.note_type()
|
|
||||||
)
|
|
||||||
|
|
||||||
def _load_new_note(self, sticky_fields_from: Note | None = None) -> None:
|
|
||||||
note = self._new_note()
|
|
||||||
if old_note := sticky_fields_from:
|
|
||||||
flds = note.note_type()["flds"]
|
|
||||||
# copy fields from old note
|
|
||||||
if old_note:
|
|
||||||
for n in range(min(len(note.fields), len(old_note.fields))):
|
|
||||||
if flds[n]["sticky"]:
|
|
||||||
note.fields[n] = old_note.fields[n]
|
|
||||||
# and tags
|
|
||||||
note.tags = old_note.tags
|
|
||||||
self.setAndFocusNote(note)
|
|
||||||
|
|
||||||
def on_operation_did_execute(
|
|
||||||
self, changes: OpChanges, handler: object | None
|
|
||||||
) -> None:
|
|
||||||
if (changes.notetype or changes.deck) and handler is not self.editor:
|
|
||||||
self.on_notetype_change(
|
|
||||||
NotetypeId(
|
|
||||||
self.col.defaults_for_adding(
|
|
||||||
current_review_card=self.mw.reviewer.card
|
|
||||||
).notetype_id
|
|
||||||
),
|
|
||||||
update_deck=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _new_note(self) -> Note:
|
|
||||||
return self.col.new_note(
|
|
||||||
self.col.models.get(self.notetype_chooser.selected_notetype_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
def addHistory(self, note: Note) -> None:
|
|
||||||
self.history.insert(0, note.id)
|
|
||||||
self.history = self.history[:15]
|
|
||||||
self.historyButton.setEnabled(True)
|
|
||||||
|
|
||||||
def onHistory(self) -> None:
|
|
||||||
m = QMenu(self)
|
|
||||||
for nid in self.history:
|
|
||||||
if self.col.find_notes(self.col.build_search_string(SearchNode(nid=nid))):
|
|
||||||
note = self.col.get_note(nid)
|
|
||||||
fields = note.fields
|
|
||||||
txt = html_to_text_line(", ".join(fields))
|
|
||||||
if len(txt) > 30:
|
|
||||||
txt = f"{txt[:30]}..."
|
|
||||||
line = tr.adding_edit(val=txt)
|
|
||||||
line = gui_hooks.addcards_will_add_history_entry(line, note)
|
|
||||||
line = line.replace("&", "&&")
|
|
||||||
# In qt action "&i" means "underline i, trigger this line when i is pressed".
|
|
||||||
# except for "&&" which is replaced by a single "&"
|
|
||||||
a = m.addAction(line)
|
|
||||||
qconnect(a.triggered, lambda b, nid=nid: self.editHistory(nid))
|
|
||||||
else:
|
|
||||||
a = m.addAction(tr.adding_note_deleted())
|
|
||||||
a.setEnabled(False)
|
|
||||||
gui_hooks.add_cards_will_show_history_menu(self, m)
|
|
||||||
m.exec(self.historyButton.mapToGlobal(QPoint(0, 0)))
|
|
||||||
|
|
||||||
def editHistory(self, nid: NoteId) -> None:
|
|
||||||
aqt.dialogs.open("Browser", self.mw, search=(SearchNode(nid=nid),))
|
|
||||||
|
|
||||||
def add_current_note(self) -> None:
|
|
||||||
if self.editor.current_notetype_is_image_occlusion():
|
|
||||||
self.editor.update_occlusions_field()
|
|
||||||
self.editor.call_after_note_saved(self._add_current_note)
|
|
||||||
self.editor.reset_image_occlusion()
|
|
||||||
else:
|
|
||||||
self.editor.call_after_note_saved(self._add_current_note)
|
|
||||||
|
|
||||||
def _add_current_note(self) -> None:
|
|
||||||
note = self.editor.note
|
|
||||||
|
|
||||||
# Prevent adding a note that has already been added (e.g., from double-clicking)
|
|
||||||
if note.id != 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
if not self._note_can_be_added(note):
|
|
||||||
return
|
|
||||||
|
|
||||||
target_deck_id = self.deck_chooser.selected_deck_id
|
|
||||||
|
|
||||||
def on_success(changes: OpChangesWithCount) -> None:
|
|
||||||
# only used for detecting changed sticky fields on close
|
|
||||||
self._last_added_note = note
|
|
||||||
|
|
||||||
self.addHistory(note)
|
|
||||||
|
|
||||||
tooltip(tr.importing_cards_added(count=changes.count), period=500)
|
|
||||||
av_player.stop_and_clear_queue()
|
|
||||||
self._load_new_note(sticky_fields_from=note)
|
|
||||||
gui_hooks.add_cards_did_add_note(note)
|
|
||||||
|
|
||||||
add_note(parent=self, note=note, target_deck_id=target_deck_id).success(
|
|
||||||
on_success
|
|
||||||
).run_in_background()
|
|
||||||
|
|
||||||
def _note_can_be_added(self, note: Note) -> bool:
|
|
||||||
result = note.fields_check()
|
|
||||||
# no problem, duplicate, and confirmed cloze cases
|
|
||||||
problem = None
|
|
||||||
if result == NoteFieldsCheckResult.EMPTY:
|
|
||||||
if self.editor.current_notetype_is_image_occlusion():
|
|
||||||
problem = tr.notetypes_no_occlusion_created2()
|
|
||||||
else:
|
|
||||||
problem = tr.adding_the_first_field_is_empty()
|
|
||||||
elif result == NoteFieldsCheckResult.MISSING_CLOZE:
|
|
||||||
if not askUser(tr.adding_you_have_a_cloze_deletion_note()):
|
|
||||||
return False
|
|
||||||
elif result == NoteFieldsCheckResult.NOTETYPE_NOT_CLOZE:
|
|
||||||
problem = tr.adding_cloze_outside_cloze_notetype()
|
|
||||||
elif result == NoteFieldsCheckResult.FIELD_NOT_CLOZE:
|
|
||||||
problem = tr.adding_cloze_outside_cloze_field()
|
|
||||||
|
|
||||||
# filter problem through add-ons
|
|
||||||
problem = gui_hooks.add_cards_will_add_note(problem, note)
|
|
||||||
if problem is not None:
|
|
||||||
showWarning(problem, help=HelpPage.ADDING_CARD_AND_NOTE)
|
|
||||||
return False
|
|
||||||
|
|
||||||
optional_problems: list[str] = []
|
|
||||||
gui_hooks.add_cards_might_add_note(optional_problems, note)
|
|
||||||
if not all(askUser(op) for op in optional_problems):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def keyPressEvent(self, evt: QKeyEvent) -> None:
|
def keyPressEvent(self, evt: QKeyEvent) -> None:
|
||||||
if evt.key() == Qt.Key.Key_Escape:
|
if evt.key() == Qt.Key.Key_Escape:
|
||||||
|
|
@ -358,24 +97,25 @@ class AddCards(QMainWindow):
|
||||||
|
|
||||||
def _close(self) -> None:
|
def _close(self) -> None:
|
||||||
self.editor.cleanup()
|
self.editor.cleanup()
|
||||||
self.notetype_chooser.cleanup()
|
|
||||||
self.deck_chooser.cleanup()
|
|
||||||
gui_hooks.operation_did_execute.remove(self.on_operation_did_execute)
|
|
||||||
self.mw.maybeReset()
|
self.mw.maybeReset()
|
||||||
saveGeom(self, "add")
|
saveGeom(self, "add")
|
||||||
aqt.dialogs.markClosed("AddCards")
|
aqt.dialogs.markClosed("NewAddCards")
|
||||||
self._close_event_has_cleaned_up = True
|
self._close_event_has_cleaned_up = True
|
||||||
self.mw.deferred_delete_and_garbage_collect(self)
|
self.mw.deferred_delete_and_garbage_collect(self)
|
||||||
self.close()
|
self.close()
|
||||||
|
|
||||||
def ifCanClose(self, onOk: Callable) -> None:
|
def ifCanClose(self, onOk: Callable) -> None:
|
||||||
|
self._close_callback = onOk
|
||||||
|
self.editor.web.eval("closeAddCards()")
|
||||||
|
|
||||||
|
def _close_if_user_wants_to_discard_changes(self, prompt: bool) -> None:
|
||||||
|
if not prompt:
|
||||||
|
self._close_callback()
|
||||||
|
return
|
||||||
|
|
||||||
def callback(choice: int) -> None:
|
def callback(choice: int) -> None:
|
||||||
if choice == 0:
|
if choice == 0:
|
||||||
onOk()
|
self._close_callback()
|
||||||
|
|
||||||
def afterSave() -> None:
|
|
||||||
if self.editor.fieldsAreBlank(self._last_added_note):
|
|
||||||
return onOk()
|
|
||||||
|
|
||||||
ask_user_dialog(
|
ask_user_dialog(
|
||||||
tr.adding_discard_current_input(),
|
tr.adding_discard_current_input(),
|
||||||
|
|
@ -386,33 +126,9 @@ class AddCards(QMainWindow):
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
self.editor.call_after_note_saved(afterSave)
|
|
||||||
|
|
||||||
def closeWithCallback(self, cb: Callable[[], None]) -> None:
|
def closeWithCallback(self, cb: Callable[[], None]) -> None:
|
||||||
def doClose() -> None:
|
def doClose() -> None:
|
||||||
self._close()
|
self._close()
|
||||||
cb()
|
cb()
|
||||||
|
|
||||||
self.ifCanClose(doClose)
|
self.ifCanClose(doClose)
|
||||||
|
|
||||||
# legacy aliases
|
|
||||||
|
|
||||||
@property
|
|
||||||
def deckChooser(self) -> DeckChooser:
|
|
||||||
if getattr(self, "form", None):
|
|
||||||
# show this warning only after Qt form has been initialized,
|
|
||||||
# or PyQt's introspection triggers it
|
|
||||||
print("deckChooser is deprecated; use deck_chooser instead")
|
|
||||||
return self.deck_chooser
|
|
||||||
|
|
||||||
addCards = add_current_note
|
|
||||||
_addCards = _add_current_note
|
|
||||||
onModelChange = on_notetype_change
|
|
||||||
|
|
||||||
@deprecated(info="obsolete")
|
|
||||||
def addNote(self, note: Note) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@deprecated(info="does nothing; will go away")
|
|
||||||
def removeTempNote(self, note: Note) -> None:
|
|
||||||
pass
|
|
||||||
|
|
|
||||||
418
qt/aqt/addcards_legacy.py
Normal file
418
qt/aqt/addcards_legacy.py
Normal file
|
|
@ -0,0 +1,418 @@
|
||||||
|
# Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import aqt.editor
|
||||||
|
import aqt.forms
|
||||||
|
from anki._legacy import deprecated
|
||||||
|
from anki.collection import OpChanges, OpChangesWithCount, SearchNode
|
||||||
|
from anki.decks import DeckId
|
||||||
|
from anki.models import NotetypeId
|
||||||
|
from anki.notes import Note, NoteFieldsCheckResult, NoteId
|
||||||
|
from anki.utils import html_to_text_line, is_mac
|
||||||
|
from aqt import AnkiQt, gui_hooks
|
||||||
|
from aqt.deckchooser import DeckChooser
|
||||||
|
from aqt.notetypechooser import NotetypeChooser
|
||||||
|
from aqt.operations.note import add_note
|
||||||
|
from aqt.qt import *
|
||||||
|
from aqt.sound import av_player
|
||||||
|
from aqt.utils import (
|
||||||
|
HelpPage,
|
||||||
|
add_close_shortcut,
|
||||||
|
ask_user_dialog,
|
||||||
|
askUser,
|
||||||
|
downArrow,
|
||||||
|
openHelp,
|
||||||
|
restoreGeom,
|
||||||
|
saveGeom,
|
||||||
|
shortcut,
|
||||||
|
showWarning,
|
||||||
|
tooltip,
|
||||||
|
tr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AddCards(QMainWindow):
|
||||||
|
def __init__(self, mw: AnkiQt) -> None:
|
||||||
|
super().__init__(None, Qt.WindowType.Window)
|
||||||
|
self._close_event_has_cleaned_up = False
|
||||||
|
self.mw = mw
|
||||||
|
self.col = mw.col
|
||||||
|
form = aqt.forms.addcards.Ui_Dialog()
|
||||||
|
form.setupUi(self)
|
||||||
|
self.form = form
|
||||||
|
self.setWindowTitle(tr.actions_add())
|
||||||
|
self.setMinimumHeight(300)
|
||||||
|
self.setMinimumWidth(400)
|
||||||
|
self.setup_choosers()
|
||||||
|
self.setupEditor()
|
||||||
|
add_close_shortcut(self)
|
||||||
|
self._load_new_note()
|
||||||
|
self.setupButtons()
|
||||||
|
self.history: list[NoteId] = []
|
||||||
|
self._last_added_note: Note | None = None
|
||||||
|
gui_hooks.operation_did_execute.append(self.on_operation_did_execute)
|
||||||
|
restoreGeom(self, "add")
|
||||||
|
gui_hooks.add_cards_did_init(self)
|
||||||
|
if not is_mac:
|
||||||
|
self.setMenuBar(None)
|
||||||
|
self.show()
|
||||||
|
|
||||||
|
def set_deck(self, deck_id: DeckId) -> None:
|
||||||
|
self.deck_chooser.selected_deck_id = deck_id
|
||||||
|
|
||||||
|
def set_note_type(self, note_type_id: NotetypeId) -> None:
|
||||||
|
self.notetype_chooser.selected_notetype_id = note_type_id
|
||||||
|
|
||||||
|
def set_note(self, note: Note, deck_id: DeckId | None = None) -> None:
|
||||||
|
"""Set tags, field contents and notetype according to `note`. Deck is set
|
||||||
|
to `deck_id` or the deck last used with the notetype.
|
||||||
|
"""
|
||||||
|
self.notetype_chooser.selected_notetype_id = note.mid
|
||||||
|
if deck_id or (deck_id := self.col.default_deck_for_notetype(note.mid)):
|
||||||
|
self.deck_chooser.selected_deck_id = deck_id
|
||||||
|
|
||||||
|
new_note = self._new_note()
|
||||||
|
new_note.fields = note.fields[:]
|
||||||
|
new_note.tags = note.tags[:]
|
||||||
|
|
||||||
|
self.editor.orig_note_id = note.id
|
||||||
|
self.setAndFocusNote(new_note)
|
||||||
|
|
||||||
|
def setupEditor(self) -> None:
|
||||||
|
self.editor = aqt.editor.Editor(
|
||||||
|
self.mw,
|
||||||
|
self.form.fieldsArea,
|
||||||
|
self,
|
||||||
|
editor_mode=aqt.editor.EditorMode.ADD_CARDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
def setup_choosers(self) -> None:
|
||||||
|
defaults = self.col.defaults_for_adding(
|
||||||
|
current_review_card=self.mw.reviewer.card
|
||||||
|
)
|
||||||
|
|
||||||
|
self.notetype_chooser = NotetypeChooser(
|
||||||
|
mw=self.mw,
|
||||||
|
widget=self.form.modelArea,
|
||||||
|
starting_notetype_id=NotetypeId(defaults.notetype_id),
|
||||||
|
on_button_activated=self.show_notetype_selector,
|
||||||
|
on_notetype_changed=self.on_notetype_change,
|
||||||
|
)
|
||||||
|
self.deck_chooser = DeckChooser(
|
||||||
|
self.mw,
|
||||||
|
self.form.deckArea,
|
||||||
|
starting_deck_id=DeckId(defaults.deck_id),
|
||||||
|
on_deck_changed=self.on_deck_changed,
|
||||||
|
)
|
||||||
|
|
||||||
|
def reopen(self, mw: AnkiQt) -> None:
|
||||||
|
if not self.editor.fieldsAreBlank():
|
||||||
|
return
|
||||||
|
|
||||||
|
defaults = self.col.defaults_for_adding(
|
||||||
|
current_review_card=self.mw.reviewer.card
|
||||||
|
)
|
||||||
|
self.set_note_type(NotetypeId(defaults.notetype_id))
|
||||||
|
self.set_deck(DeckId(defaults.deck_id))
|
||||||
|
|
||||||
|
def helpRequested(self) -> None:
|
||||||
|
openHelp(HelpPage.ADDING_CARD_AND_NOTE)
|
||||||
|
|
||||||
|
def setupButtons(self) -> None:
|
||||||
|
bb = self.form.buttonBox
|
||||||
|
ar = QDialogButtonBox.ButtonRole.ActionRole
|
||||||
|
# add
|
||||||
|
self.addButton = bb.addButton(tr.actions_add(), ar)
|
||||||
|
qconnect(self.addButton.clicked, self.add_current_note)
|
||||||
|
self.addButton.setShortcut(QKeySequence("Ctrl+Return"))
|
||||||
|
# qt5.14+ doesn't handle numpad enter on Windows
|
||||||
|
self.compat_add_shorcut = QShortcut(QKeySequence("Ctrl+Enter"), self)
|
||||||
|
qconnect(self.compat_add_shorcut.activated, self.addButton.click)
|
||||||
|
self.addButton.setToolTip(shortcut(tr.adding_add_shortcut_ctrlandenter()))
|
||||||
|
|
||||||
|
# close
|
||||||
|
self.closeButton = QPushButton(tr.actions_close())
|
||||||
|
self.closeButton.setAutoDefault(False)
|
||||||
|
bb.addButton(self.closeButton, QDialogButtonBox.ButtonRole.RejectRole)
|
||||||
|
qconnect(self.closeButton.clicked, self.close)
|
||||||
|
# help
|
||||||
|
self.helpButton = QPushButton(tr.actions_help(), clicked=self.helpRequested) # type: ignore
|
||||||
|
self.helpButton.setAutoDefault(False)
|
||||||
|
bb.addButton(self.helpButton, QDialogButtonBox.ButtonRole.HelpRole)
|
||||||
|
# history
|
||||||
|
b = bb.addButton(f"{tr.adding_history()} {downArrow()}", ar)
|
||||||
|
if is_mac:
|
||||||
|
sc = "Ctrl+Shift+H"
|
||||||
|
else:
|
||||||
|
sc = "Ctrl+H"
|
||||||
|
b.setShortcut(QKeySequence(sc))
|
||||||
|
b.setToolTip(tr.adding_shortcut(val=shortcut(sc)))
|
||||||
|
qconnect(b.clicked, self.onHistory)
|
||||||
|
b.setEnabled(False)
|
||||||
|
self.historyButton = b
|
||||||
|
|
||||||
|
def setAndFocusNote(self, note: Note) -> None:
|
||||||
|
self.editor.set_note(note, focusTo=0)
|
||||||
|
|
||||||
|
def show_notetype_selector(self) -> None:
|
||||||
|
self.editor.call_after_note_saved(self.notetype_chooser.choose_notetype)
|
||||||
|
|
||||||
|
def on_deck_changed(self, deck_id: int) -> None:
|
||||||
|
gui_hooks.add_cards_did_change_deck(deck_id)
|
||||||
|
|
||||||
|
def on_notetype_change(
|
||||||
|
self, notetype_id: NotetypeId, update_deck: bool = True
|
||||||
|
) -> None:
|
||||||
|
# need to adjust current deck?
|
||||||
|
if update_deck:
|
||||||
|
if deck_id := self.col.default_deck_for_notetype(notetype_id):
|
||||||
|
self.deck_chooser.selected_deck_id = deck_id
|
||||||
|
|
||||||
|
# only used for detecting changed sticky fields on close
|
||||||
|
self._last_added_note = None
|
||||||
|
|
||||||
|
# copy fields into new note with the new notetype
|
||||||
|
old_note = self.editor.note
|
||||||
|
new_note = self._new_note()
|
||||||
|
if old_note:
|
||||||
|
old_field_names = list(old_note.keys())
|
||||||
|
new_field_names = list(new_note.keys())
|
||||||
|
copied_field_names = set()
|
||||||
|
for f in new_note.note_type()["flds"]:
|
||||||
|
field_name = f["name"]
|
||||||
|
# copy identical non-empty fields
|
||||||
|
if field_name in old_field_names and old_note[field_name]:
|
||||||
|
new_note[field_name] = old_note[field_name]
|
||||||
|
copied_field_names.add(field_name)
|
||||||
|
new_idx = 0
|
||||||
|
for old_idx, old_field_value in enumerate(old_field_names):
|
||||||
|
# skip previously copied identical fields in new note
|
||||||
|
while (
|
||||||
|
new_idx < len(new_field_names)
|
||||||
|
and new_field_names[new_idx] in copied_field_names
|
||||||
|
):
|
||||||
|
new_idx += 1
|
||||||
|
if new_idx >= len(new_field_names):
|
||||||
|
break
|
||||||
|
# copy non-empty old fields
|
||||||
|
if (
|
||||||
|
old_field_value not in copied_field_names
|
||||||
|
and old_note.fields[old_idx]
|
||||||
|
):
|
||||||
|
new_note.fields[new_idx] = old_note.fields[old_idx]
|
||||||
|
new_idx += 1
|
||||||
|
|
||||||
|
new_note.tags = old_note.tags
|
||||||
|
|
||||||
|
# and update editor state
|
||||||
|
self.editor.note = new_note
|
||||||
|
self.editor.loadNote(
|
||||||
|
focusTo=min(self.editor.last_field_index or 0, len(new_note.fields) - 1)
|
||||||
|
)
|
||||||
|
gui_hooks.addcards_did_change_note_type(
|
||||||
|
self, old_note.note_type(), new_note.note_type()
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_new_note(self, sticky_fields_from: Note | None = None) -> None:
|
||||||
|
note = self._new_note()
|
||||||
|
if old_note := sticky_fields_from:
|
||||||
|
flds = note.note_type()["flds"]
|
||||||
|
# copy fields from old note
|
||||||
|
if old_note:
|
||||||
|
for n in range(min(len(note.fields), len(old_note.fields))):
|
||||||
|
if flds[n]["sticky"]:
|
||||||
|
note.fields[n] = old_note.fields[n]
|
||||||
|
# and tags
|
||||||
|
note.tags = old_note.tags
|
||||||
|
self.setAndFocusNote(note)
|
||||||
|
|
||||||
|
def on_operation_did_execute(
|
||||||
|
self, changes: OpChanges, handler: object | None
|
||||||
|
) -> None:
|
||||||
|
if (changes.notetype or changes.deck) and handler is not self.editor:
|
||||||
|
self.on_notetype_change(
|
||||||
|
NotetypeId(
|
||||||
|
self.col.defaults_for_adding(
|
||||||
|
current_review_card=self.mw.reviewer.card
|
||||||
|
).notetype_id
|
||||||
|
),
|
||||||
|
update_deck=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _new_note(self) -> Note:
|
||||||
|
return self.col.new_note(
|
||||||
|
self.col.models.get(self.notetype_chooser.selected_notetype_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
def addHistory(self, note: Note) -> None:
|
||||||
|
self.history.insert(0, note.id)
|
||||||
|
self.history = self.history[:15]
|
||||||
|
self.historyButton.setEnabled(True)
|
||||||
|
|
||||||
|
def onHistory(self) -> None:
|
||||||
|
m = QMenu(self)
|
||||||
|
for nid in self.history:
|
||||||
|
if self.col.find_notes(self.col.build_search_string(SearchNode(nid=nid))):
|
||||||
|
note = self.col.get_note(nid)
|
||||||
|
fields = note.fields
|
||||||
|
txt = html_to_text_line(", ".join(fields))
|
||||||
|
if len(txt) > 30:
|
||||||
|
txt = f"{txt[:30]}..."
|
||||||
|
line = tr.adding_edit(val=txt)
|
||||||
|
line = gui_hooks.addcards_will_add_history_entry(line, note)
|
||||||
|
line = line.replace("&", "&&")
|
||||||
|
# In qt action "&i" means "underline i, trigger this line when i is pressed".
|
||||||
|
# except for "&&" which is replaced by a single "&"
|
||||||
|
a = m.addAction(line)
|
||||||
|
qconnect(a.triggered, lambda b, nid=nid: self.editHistory(nid))
|
||||||
|
else:
|
||||||
|
a = m.addAction(tr.adding_note_deleted())
|
||||||
|
a.setEnabled(False)
|
||||||
|
gui_hooks.add_cards_will_show_history_menu(self, m)
|
||||||
|
m.exec(self.historyButton.mapToGlobal(QPoint(0, 0)))
|
||||||
|
|
||||||
|
def editHistory(self, nid: NoteId) -> None:
|
||||||
|
aqt.dialogs.open("Browser", self.mw, search=(SearchNode(nid=nid),))
|
||||||
|
|
||||||
|
def add_current_note(self) -> None:
|
||||||
|
if self.editor.current_notetype_is_image_occlusion():
|
||||||
|
self.editor.update_occlusions_field()
|
||||||
|
self.editor.call_after_note_saved(self._add_current_note)
|
||||||
|
self.editor.reset_image_occlusion()
|
||||||
|
else:
|
||||||
|
self.editor.call_after_note_saved(self._add_current_note)
|
||||||
|
|
||||||
|
def _add_current_note(self) -> None:
|
||||||
|
note = self.editor.note
|
||||||
|
|
||||||
|
# Prevent adding a note that has already been added (e.g., from double-clicking)
|
||||||
|
if note.id != 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self._note_can_be_added(note):
|
||||||
|
return
|
||||||
|
|
||||||
|
target_deck_id = self.deck_chooser.selected_deck_id
|
||||||
|
|
||||||
|
def on_success(changes: OpChangesWithCount) -> None:
|
||||||
|
# only used for detecting changed sticky fields on close
|
||||||
|
self._last_added_note = note
|
||||||
|
|
||||||
|
self.addHistory(note)
|
||||||
|
|
||||||
|
tooltip(tr.importing_cards_added(count=changes.count), period=500)
|
||||||
|
av_player.stop_and_clear_queue()
|
||||||
|
self._load_new_note(sticky_fields_from=note)
|
||||||
|
gui_hooks.add_cards_did_add_note(note)
|
||||||
|
|
||||||
|
add_note(parent=self, note=note, target_deck_id=target_deck_id).success(
|
||||||
|
on_success
|
||||||
|
).run_in_background()
|
||||||
|
|
||||||
|
def _note_can_be_added(self, note: Note) -> bool:
|
||||||
|
result = note.fields_check()
|
||||||
|
# no problem, duplicate, and confirmed cloze cases
|
||||||
|
problem = None
|
||||||
|
if result == NoteFieldsCheckResult.EMPTY:
|
||||||
|
if self.editor.current_notetype_is_image_occlusion():
|
||||||
|
problem = tr.notetypes_no_occlusion_created2()
|
||||||
|
else:
|
||||||
|
problem = tr.adding_the_first_field_is_empty()
|
||||||
|
elif result == NoteFieldsCheckResult.MISSING_CLOZE:
|
||||||
|
if not askUser(tr.adding_you_have_a_cloze_deletion_note()):
|
||||||
|
return False
|
||||||
|
elif result == NoteFieldsCheckResult.NOTETYPE_NOT_CLOZE:
|
||||||
|
problem = tr.adding_cloze_outside_cloze_notetype()
|
||||||
|
elif result == NoteFieldsCheckResult.FIELD_NOT_CLOZE:
|
||||||
|
problem = tr.adding_cloze_outside_cloze_field()
|
||||||
|
|
||||||
|
# filter problem through add-ons
|
||||||
|
problem = gui_hooks.add_cards_will_add_note(problem, note)
|
||||||
|
if problem is not None:
|
||||||
|
showWarning(problem, help=HelpPage.ADDING_CARD_AND_NOTE)
|
||||||
|
return False
|
||||||
|
|
||||||
|
optional_problems: list[str] = []
|
||||||
|
gui_hooks.add_cards_might_add_note(optional_problems, note)
|
||||||
|
if not all(askUser(op) for op in optional_problems):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def keyPressEvent(self, evt: QKeyEvent) -> None:
|
||||||
|
if evt.key() == Qt.Key.Key_Escape:
|
||||||
|
self.close()
|
||||||
|
else:
|
||||||
|
super().keyPressEvent(evt)
|
||||||
|
|
||||||
|
def closeEvent(self, evt: QCloseEvent) -> None:
|
||||||
|
if self._close_event_has_cleaned_up:
|
||||||
|
evt.accept()
|
||||||
|
return
|
||||||
|
self.ifCanClose(self._close)
|
||||||
|
evt.ignore()
|
||||||
|
|
||||||
|
def _close(self) -> None:
|
||||||
|
self.editor.cleanup()
|
||||||
|
self.notetype_chooser.cleanup()
|
||||||
|
self.deck_chooser.cleanup()
|
||||||
|
gui_hooks.operation_did_execute.remove(self.on_operation_did_execute)
|
||||||
|
self.mw.maybeReset()
|
||||||
|
saveGeom(self, "add")
|
||||||
|
aqt.dialogs.markClosed("AddCards")
|
||||||
|
self._close_event_has_cleaned_up = True
|
||||||
|
self.mw.deferred_delete_and_garbage_collect(self)
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def ifCanClose(self, onOk: Callable) -> None:
|
||||||
|
def callback(choice: int) -> None:
|
||||||
|
if choice == 0:
|
||||||
|
onOk()
|
||||||
|
|
||||||
|
def afterSave() -> None:
|
||||||
|
if self.editor.fieldsAreBlank(self._last_added_note):
|
||||||
|
return onOk()
|
||||||
|
|
||||||
|
ask_user_dialog(
|
||||||
|
tr.adding_discard_current_input(),
|
||||||
|
callback=callback,
|
||||||
|
buttons=[
|
||||||
|
QMessageBox.StandardButton.Discard,
|
||||||
|
(tr.adding_keep_editing(), QMessageBox.ButtonRole.RejectRole),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.editor.call_after_note_saved(afterSave)
|
||||||
|
|
||||||
|
def closeWithCallback(self, cb: Callable[[], None]) -> None:
|
||||||
|
def doClose() -> None:
|
||||||
|
self._close()
|
||||||
|
cb()
|
||||||
|
|
||||||
|
self.ifCanClose(doClose)
|
||||||
|
|
||||||
|
# legacy aliases
|
||||||
|
|
||||||
|
@property
|
||||||
|
def deckChooser(self) -> DeckChooser:
|
||||||
|
if getattr(self, "form", None):
|
||||||
|
# show this warning only after Qt form has been initialized,
|
||||||
|
# or PyQt's introspection triggers it
|
||||||
|
print("deckChooser is deprecated; use deck_chooser instead")
|
||||||
|
return self.deck_chooser
|
||||||
|
|
||||||
|
addCards = add_current_note
|
||||||
|
_addCards = _add_current_note
|
||||||
|
onModelChange = on_notetype_change
|
||||||
|
|
||||||
|
@deprecated(info="obsolete")
|
||||||
|
def addNote(self, note: Note) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@deprecated(info="does nothing; will go away")
|
||||||
|
def removeTempNote(self, note: Note) -> None:
|
||||||
|
pass
|
||||||
|
|
@ -8,7 +8,7 @@ import json
|
||||||
import math
|
import math
|
||||||
import re
|
import re
|
||||||
from collections.abc import Callable, Sequence
|
from collections.abc import Callable, Sequence
|
||||||
from typing import Any, cast
|
from typing import Any
|
||||||
|
|
||||||
from markdown import markdown
|
from markdown import markdown
|
||||||
|
|
||||||
|
|
@ -22,7 +22,7 @@ from anki.cards import Card, CardId
|
||||||
from anki.collection import Collection, Config, OpChanges, SearchNode
|
from anki.collection import Collection, Config, OpChanges, SearchNode
|
||||||
from anki.consts import *
|
from anki.consts import *
|
||||||
from anki.decks import DeckId
|
from anki.decks import DeckId
|
||||||
from anki.errors import NotFoundError, SearchError
|
from anki.errors import SearchError
|
||||||
from anki.lang import without_unicode_isolation
|
from anki.lang import without_unicode_isolation
|
||||||
from anki.models import NotetypeId
|
from anki.models import NotetypeId
|
||||||
from anki.notes import NoteId
|
from anki.notes import NoteId
|
||||||
|
|
@ -30,7 +30,8 @@ from anki.scheduler.base import ScheduleCardsAsNew
|
||||||
from anki.tags import MARKED_TAG
|
from anki.tags import MARKED_TAG
|
||||||
from anki.utils import is_mac
|
from anki.utils import is_mac
|
||||||
from aqt import AnkiQt, gui_hooks
|
from aqt import AnkiQt, gui_hooks
|
||||||
from aqt.editor import Editor, EditorWebView
|
from aqt.addcards import NewAddCards
|
||||||
|
from aqt.addcards_legacy import AddCards
|
||||||
from aqt.errors import show_exception
|
from aqt.errors import show_exception
|
||||||
from aqt.exporting import ExportDialog as LegacyExportDialog
|
from aqt.exporting import ExportDialog as LegacyExportDialog
|
||||||
from aqt.import_export.exporting import ExportDialog
|
from aqt.import_export.exporting import ExportDialog
|
||||||
|
|
@ -80,7 +81,6 @@ from aqt.utils import (
|
||||||
tr,
|
tr,
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..addcards import AddCards
|
|
||||||
from ..changenotetype import change_notetype_dialog
|
from ..changenotetype import change_notetype_dialog
|
||||||
from .card_info import BrowserCardInfo
|
from .card_info import BrowserCardInfo
|
||||||
from .find_and_replace import FindAndReplaceDialog
|
from .find_and_replace import FindAndReplaceDialog
|
||||||
|
|
@ -114,7 +114,7 @@ class MockModel:
|
||||||
class Browser(QMainWindow):
|
class Browser(QMainWindow):
|
||||||
mw: AnkiQt
|
mw: AnkiQt
|
||||||
col: Collection
|
col: Collection
|
||||||
editor: Editor | None
|
editor: aqt.editor.NewEditor | None
|
||||||
table: Table
|
table: Table
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
|
|
@ -187,20 +187,6 @@ class Browser(QMainWindow):
|
||||||
focused = current_window() == self
|
focused = current_window() == self
|
||||||
self.table.op_executed(changes, handler, focused)
|
self.table.op_executed(changes, handler, focused)
|
||||||
self.sidebar.op_executed(changes, handler, focused)
|
self.sidebar.op_executed(changes, handler, focused)
|
||||||
if changes.note_text:
|
|
||||||
if handler is not self.editor:
|
|
||||||
# fixme: this will leave the splitter shown, but with no current
|
|
||||||
# note being edited
|
|
||||||
assert self.editor is not None
|
|
||||||
|
|
||||||
note = self.editor.note
|
|
||||||
if note:
|
|
||||||
try:
|
|
||||||
note.load()
|
|
||||||
except NotFoundError:
|
|
||||||
self.editor.set_note(None)
|
|
||||||
return
|
|
||||||
self.editor.set_note(note)
|
|
||||||
|
|
||||||
if changes.browser_table and changes.card:
|
if changes.browser_table and changes.card:
|
||||||
self.card = self.table.get_single_selected_card()
|
self.card = self.table.get_single_selected_card()
|
||||||
|
|
@ -278,11 +264,14 @@ class Browser(QMainWindow):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def add_card(self, deck_id: DeckId):
|
def add_card(self, deck_id: DeckId):
|
||||||
add_cards = cast(AddCards, aqt.dialogs.open("AddCards", self.mw))
|
add_cards = self.mw._open_new_or_legacy_dialog("AddCards")
|
||||||
|
if isinstance(add_cards, AddCards):
|
||||||
add_cards.set_deck(deck_id)
|
add_cards.set_deck(deck_id)
|
||||||
|
|
||||||
if note_type_id := self.get_active_note_type_id():
|
if note_type_id := self.get_active_note_type_id():
|
||||||
add_cards.set_note_type(note_type_id)
|
add_cards.set_note_type(note_type_id)
|
||||||
|
else:
|
||||||
|
assert isinstance(add_cards, NewAddCards)
|
||||||
|
add_cards.load_new_note(deck_id, self.get_active_note_type_id())
|
||||||
|
|
||||||
# If in the Browser we open Preview and press Ctrl+W there,
|
# If in the Browser we open Preview and press Ctrl+W there,
|
||||||
# both Preview and Browser windows get closed by Qt out of the box.
|
# both Preview and Browser windows get closed by Qt out of the box.
|
||||||
|
|
@ -403,7 +392,7 @@ class Browser(QMainWindow):
|
||||||
add_ellipsis_to_action_label(f.action_forget)
|
add_ellipsis_to_action_label(f.action_forget)
|
||||||
add_ellipsis_to_action_label(f.action_grade_now)
|
add_ellipsis_to_action_label(f.action_grade_now)
|
||||||
|
|
||||||
def _editor_web_view(self) -> EditorWebView:
|
def _editor_web_view(self) -> aqt.editor.NewEditorWebView:
|
||||||
assert self.editor is not None
|
assert self.editor is not None
|
||||||
editor_web_view = self.editor.web
|
editor_web_view = self.editor.web
|
||||||
assert editor_web_view is not None
|
assert editor_web_view is not None
|
||||||
|
|
@ -605,17 +594,19 @@ class Browser(QMainWindow):
|
||||||
def setupEditor(self) -> None:
|
def setupEditor(self) -> None:
|
||||||
QShortcut(QKeySequence("Ctrl+Shift+P"), self, self.onTogglePreview)
|
QShortcut(QKeySequence("Ctrl+Shift+P"), self, self.onTogglePreview)
|
||||||
|
|
||||||
def add_preview_button(editor: Editor) -> None:
|
def add_preview_button(
|
||||||
|
editor: aqt.editor.Editor | aqt.editor.NewEditor,
|
||||||
|
) -> None:
|
||||||
editor._links["preview"] = lambda _editor: self.onTogglePreview()
|
editor._links["preview"] = lambda _editor: self.onTogglePreview()
|
||||||
|
gui_hooks.editor_did_init.remove(add_preview_button)
|
||||||
|
|
||||||
gui_hooks.editor_did_init.append(add_preview_button)
|
gui_hooks.editor_did_init.append(add_preview_button)
|
||||||
self.editor = aqt.editor.Editor(
|
self.editor = aqt.editor.NewEditor(
|
||||||
self.mw,
|
self.mw,
|
||||||
self.form.fieldsArea,
|
self.form.fieldsArea,
|
||||||
self,
|
self,
|
||||||
editor_mode=aqt.editor.EditorMode.BROWSER,
|
editor_mode=aqt.editor.EditorMode.BROWSER,
|
||||||
)
|
)
|
||||||
gui_hooks.editor_did_init.remove(add_preview_button)
|
|
||||||
|
|
||||||
@ensure_editor_saved
|
@ensure_editor_saved
|
||||||
def on_all_or_selected_rows_changed(self) -> None:
|
def on_all_or_selected_rows_changed(self) -> None:
|
||||||
|
|
@ -819,7 +810,7 @@ class Browser(QMainWindow):
|
||||||
assert current_card is not None
|
assert current_card is not None
|
||||||
|
|
||||||
deck_id = current_card.current_deck_id()
|
deck_id = current_card.current_deck_id()
|
||||||
aqt.dialogs.open("AddCards", self.mw).set_note(note, deck_id)
|
self.mw._open_new_or_legacy_dialog("AddCards").set_note(note, deck_id)
|
||||||
|
|
||||||
@no_arg_trigger
|
@no_arg_trigger
|
||||||
@skip_if_selection_is_empty
|
@skip_if_selection_is_empty
|
||||||
|
|
@ -843,7 +834,7 @@ class Browser(QMainWindow):
|
||||||
|
|
||||||
if self._previewer:
|
if self._previewer:
|
||||||
self._previewer.close()
|
self._previewer.close()
|
||||||
elif self.editor.note:
|
else:
|
||||||
self._previewer = PreviewDialog(self, self.mw, self._on_preview_closed)
|
self._previewer = PreviewDialog(self, self.mw, self._on_preview_closed)
|
||||||
self._previewer.open()
|
self._previewer.open()
|
||||||
self.toggle_preview_button_state(True)
|
self.toggle_preview_button_state(True)
|
||||||
|
|
@ -1265,7 +1256,7 @@ class Browser(QMainWindow):
|
||||||
def cb():
|
def cb():
|
||||||
assert self.editor is not None and self.editor.web is not None
|
assert self.editor is not None and self.editor.web is not None
|
||||||
self.editor.web.setFocus()
|
self.editor.web.setFocus()
|
||||||
self.editor.loadNote(focusTo=0)
|
self.editor.reload_note()
|
||||||
|
|
||||||
assert self.editor is not None
|
assert self.editor is not None
|
||||||
self.editor.call_after_note_saved(cb)
|
self.editor.call_after_note_saved(cb)
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,12 @@ from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
import aqt.editor
|
from aqt.editcurrent_legacy import *
|
||||||
from anki.collection import OpChanges
|
|
||||||
from anki.errors import NotFoundError
|
|
||||||
from aqt import gui_hooks
|
|
||||||
from aqt.qt import *
|
from aqt.qt import *
|
||||||
from aqt.utils import add_close_shortcut, restoreGeom, saveGeom, tr
|
from aqt.utils import add_close_shortcut, restoreGeom, saveGeom, tr
|
||||||
|
|
||||||
|
|
||||||
class EditCurrent(QMainWindow):
|
class NewEditCurrent(QMainWindow):
|
||||||
def __init__(self, mw: aqt.AnkiQt) -> None:
|
def __init__(self, mw: aqt.AnkiQt) -> None:
|
||||||
super().__init__(None, Qt.WindowType.Window)
|
super().__init__(None, Qt.WindowType.Window)
|
||||||
self.mw = mw
|
self.mw = mw
|
||||||
|
|
@ -23,7 +20,7 @@ class EditCurrent(QMainWindow):
|
||||||
self.setMinimumWidth(250)
|
self.setMinimumWidth(250)
|
||||||
if not is_mac:
|
if not is_mac:
|
||||||
self.setMenuBar(None)
|
self.setMenuBar(None)
|
||||||
self.editor = aqt.editor.Editor(
|
self.editor = aqt.editor.NewEditor(
|
||||||
self.mw,
|
self.mw,
|
||||||
self.form.fieldsArea,
|
self.form.fieldsArea,
|
||||||
self,
|
self,
|
||||||
|
|
@ -33,38 +30,13 @@ class EditCurrent(QMainWindow):
|
||||||
self.editor.card = self.mw.reviewer.card
|
self.editor.card = self.mw.reviewer.card
|
||||||
self.editor.set_note(self.mw.reviewer.card.note(), focusTo=0)
|
self.editor.set_note(self.mw.reviewer.card.note(), focusTo=0)
|
||||||
restoreGeom(self, "editcurrent")
|
restoreGeom(self, "editcurrent")
|
||||||
close_button = self.form.buttonBox.button(QDialogButtonBox.StandardButton.Close)
|
|
||||||
assert close_button is not None
|
|
||||||
close_button.setShortcut(QKeySequence("Ctrl+Return"))
|
|
||||||
add_close_shortcut(self)
|
add_close_shortcut(self)
|
||||||
# qt5.14+ doesn't handle numpad enter on Windows
|
|
||||||
self.compat_add_shorcut = QShortcut(QKeySequence("Ctrl+Enter"), self)
|
|
||||||
qconnect(self.compat_add_shorcut.activated, close_button.click)
|
|
||||||
gui_hooks.operation_did_execute.append(self.on_operation_did_execute)
|
|
||||||
self.show()
|
self.show()
|
||||||
|
|
||||||
def on_operation_did_execute(
|
|
||||||
self, changes: OpChanges, handler: object | None
|
|
||||||
) -> None:
|
|
||||||
if changes.note_text and handler is not self.editor:
|
|
||||||
# reload note
|
|
||||||
note = self.editor.note
|
|
||||||
try:
|
|
||||||
assert note is not None
|
|
||||||
note.load()
|
|
||||||
except NotFoundError:
|
|
||||||
# note's been deleted
|
|
||||||
self.cleanup()
|
|
||||||
self.close()
|
|
||||||
return
|
|
||||||
|
|
||||||
self.editor.set_note(note)
|
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
def cleanup(self) -> None:
|
||||||
gui_hooks.operation_did_execute.remove(self.on_operation_did_execute)
|
|
||||||
self.editor.cleanup()
|
self.editor.cleanup()
|
||||||
saveGeom(self, "editcurrent")
|
saveGeom(self, "editcurrent")
|
||||||
aqt.dialogs.markClosed("EditCurrent")
|
aqt.dialogs.markClosed("NewEditCurrent")
|
||||||
|
|
||||||
def reopen(self, mw: aqt.AnkiQt) -> None:
|
def reopen(self, mw: aqt.AnkiQt) -> None:
|
||||||
if card := self.mw.reviewer.card:
|
if card := self.mw.reviewer.card:
|
||||||
|
|
@ -85,5 +57,3 @@ class EditCurrent(QMainWindow):
|
||||||
onsuccess()
|
onsuccess()
|
||||||
|
|
||||||
self.editor.call_after_note_saved(callback)
|
self.editor.call_after_note_saved(callback)
|
||||||
|
|
||||||
onReset = on_operation_did_execute
|
|
||||||
|
|
|
||||||
93
qt/aqt/editcurrent_legacy.py
Normal file
93
qt/aqt/editcurrent_legacy.py
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
# Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import aqt.editor
|
||||||
|
from anki.collection import OpChanges
|
||||||
|
from anki.errors import NotFoundError
|
||||||
|
from aqt import gui_hooks
|
||||||
|
from aqt.qt import *
|
||||||
|
from aqt.utils import add_close_shortcut, restoreGeom, saveGeom, tr
|
||||||
|
|
||||||
|
|
||||||
|
class EditCurrent(QMainWindow):
|
||||||
|
def __init__(self, mw: aqt.AnkiQt) -> None:
|
||||||
|
super().__init__(None, Qt.WindowType.Window)
|
||||||
|
self.mw = mw
|
||||||
|
self.form = aqt.forms.editcurrent.Ui_Dialog()
|
||||||
|
self.form.setupUi(self)
|
||||||
|
self.setWindowTitle(tr.editing_edit_current())
|
||||||
|
self.setMinimumHeight(400)
|
||||||
|
self.setMinimumWidth(250)
|
||||||
|
if not is_mac:
|
||||||
|
self.setMenuBar(None)
|
||||||
|
self.editor = aqt.editor.Editor(
|
||||||
|
self.mw,
|
||||||
|
self.form.fieldsArea,
|
||||||
|
self,
|
||||||
|
editor_mode=aqt.editor.EditorMode.EDIT_CURRENT,
|
||||||
|
)
|
||||||
|
assert self.mw.reviewer.card is not None
|
||||||
|
self.editor.card = self.mw.reviewer.card
|
||||||
|
self.editor.set_note(self.mw.reviewer.card.note(), focusTo=0)
|
||||||
|
restoreGeom(self, "editcurrent")
|
||||||
|
self.buttonbox = QDialogButtonBox(Qt.Orientation.Horizontal)
|
||||||
|
self.form.verticalLayout.insertWidget(1, self.buttonbox)
|
||||||
|
self.buttonbox.addButton(QDialogButtonBox.StandardButton.Close)
|
||||||
|
qconnect(self.buttonbox.rejected, self.close)
|
||||||
|
close_button = self.buttonbox.button(QDialogButtonBox.StandardButton.Close)
|
||||||
|
assert close_button is not None
|
||||||
|
close_button.setShortcut(QKeySequence("Ctrl+Return"))
|
||||||
|
add_close_shortcut(self)
|
||||||
|
# qt5.14+ doesn't handle numpad enter on Windows
|
||||||
|
self.compat_add_shorcut = QShortcut(QKeySequence("Ctrl+Enter"), self)
|
||||||
|
qconnect(self.compat_add_shorcut.activated, close_button.click)
|
||||||
|
gui_hooks.operation_did_execute.append(self.on_operation_did_execute)
|
||||||
|
self.show()
|
||||||
|
|
||||||
|
def on_operation_did_execute(
|
||||||
|
self, changes: OpChanges, handler: object | None
|
||||||
|
) -> None:
|
||||||
|
if changes.note_text and handler is not self.editor:
|
||||||
|
# reload note
|
||||||
|
note = self.editor.note
|
||||||
|
try:
|
||||||
|
assert note is not None
|
||||||
|
note.load()
|
||||||
|
except NotFoundError:
|
||||||
|
# note's been deleted
|
||||||
|
self.cleanup()
|
||||||
|
self.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
self.editor.set_note(note)
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
gui_hooks.operation_did_execute.remove(self.on_operation_did_execute)
|
||||||
|
self.editor.cleanup()
|
||||||
|
saveGeom(self, "editcurrent")
|
||||||
|
aqt.dialogs.markClosed("EditCurrent")
|
||||||
|
|
||||||
|
def reopen(self, mw: aqt.AnkiQt) -> None:
|
||||||
|
if card := self.mw.reviewer.card:
|
||||||
|
self.editor.card = card
|
||||||
|
self.editor.set_note(card.note())
|
||||||
|
|
||||||
|
def closeEvent(self, evt: QCloseEvent | None) -> None:
|
||||||
|
self.editor.call_after_note_saved(self.cleanup)
|
||||||
|
|
||||||
|
def _saveAndClose(self) -> None:
|
||||||
|
self.cleanup()
|
||||||
|
self.mw.deferred_delete_and_garbage_collect(self)
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def closeWithCallback(self, onsuccess: Callable[[], None]) -> None:
|
||||||
|
def callback() -> None:
|
||||||
|
self._saveAndClose()
|
||||||
|
onsuccess()
|
||||||
|
|
||||||
|
self.editor.call_after_note_saved(callback)
|
||||||
|
|
||||||
|
onReset = on_operation_did_execute
|
||||||
1533
qt/aqt/editor.py
1533
qt/aqt/editor.py
File diff suppressed because it is too large
Load diff
1798
qt/aqt/editor_legacy.py
Normal file
1798
qt/aqt/editor_legacy.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -28,16 +28,6 @@
|
||||||
<item>
|
<item>
|
||||||
<widget class="QWidget" name="fieldsArea" native="true"/>
|
<widget class="QWidget" name="fieldsArea" native="true"/>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
|
||||||
<widget class="QDialogButtonBox" name="buttonBox">
|
|
||||||
<property name="orientation">
|
|
||||||
<enum>Qt::Horizontal</enum>
|
|
||||||
</property>
|
|
||||||
<property name="standardButtons">
|
|
||||||
<set>QDialogButtonBox::Close</set>
|
|
||||||
</property>
|
|
||||||
</widget>
|
|
||||||
</item>
|
|
||||||
</layout>
|
</layout>
|
||||||
</widget>
|
</widget>
|
||||||
<widget class="QMenuBar" name="menubar">
|
<widget class="QMenuBar" name="menubar">
|
||||||
|
|
@ -60,22 +50,4 @@
|
||||||
<resources>
|
<resources>
|
||||||
<include location="icons.qrc"/>
|
<include location="icons.qrc"/>
|
||||||
</resources>
|
</resources>
|
||||||
<connections>
|
|
||||||
<connection>
|
|
||||||
<sender>buttonBox</sender>
|
|
||||||
<signal>rejected()</signal>
|
|
||||||
<receiver>Dialog</receiver>
|
|
||||||
<slot>close()</slot>
|
|
||||||
<hints>
|
|
||||||
<hint type="sourcelabel">
|
|
||||||
<x>316</x>
|
|
||||||
<y>260</y>
|
|
||||||
</hint>
|
|
||||||
<hint type="destinationlabel">
|
|
||||||
<x>286</x>
|
|
||||||
<y>274</y>
|
|
||||||
</hint>
|
|
||||||
</hints>
|
|
||||||
</connection>
|
|
||||||
</connections>
|
|
||||||
</ui>
|
</ui>
|
||||||
|
|
|
||||||
|
|
@ -1280,14 +1280,24 @@ title="{}" {}>{}</button>""".format(
|
||||||
# Other menu operations
|
# Other menu operations
|
||||||
##########################################################################
|
##########################################################################
|
||||||
|
|
||||||
|
def _open_new_or_legacy_dialog(self, name: str, *args: Any, **kwargs: Any) -> Any:
|
||||||
|
want_old = KeyboardModifiersPressed().shift
|
||||||
|
if not want_old:
|
||||||
|
name = f"New{name}"
|
||||||
|
return aqt.dialogs.open(name, self, *args, **kwargs)
|
||||||
|
|
||||||
def onAddCard(self) -> None:
|
def onAddCard(self) -> None:
|
||||||
aqt.dialogs.open("AddCards", self)
|
from aqt.addcards import NewAddCards
|
||||||
|
|
||||||
|
add_cards = self._open_new_or_legacy_dialog("AddCards")
|
||||||
|
if isinstance(add_cards, NewAddCards):
|
||||||
|
add_cards.load_new_note()
|
||||||
|
|
||||||
def onBrowse(self) -> None:
|
def onBrowse(self) -> None:
|
||||||
aqt.dialogs.open("Browser", self, card=self.reviewer.card)
|
aqt.dialogs.open("Browser", self, card=self.reviewer.card)
|
||||||
|
|
||||||
def onEditCurrent(self) -> None:
|
def onEditCurrent(self) -> None:
|
||||||
aqt.dialogs.open("EditCurrent", self)
|
self._open_new_or_legacy_dialog("EditCurrent")
|
||||||
|
|
||||||
def onOverview(self) -> None:
|
def onOverview(self) -> None:
|
||||||
self.moveToState("overview")
|
self.moveToState("overview")
|
||||||
|
|
@ -1296,11 +1306,7 @@ title="{}" {}>{}</button>""".format(
|
||||||
deck = self._selectedDeck()
|
deck = self._selectedDeck()
|
||||||
if not deck:
|
if not deck:
|
||||||
return
|
return
|
||||||
want_old = KeyboardModifiersPressed().shift
|
self._open_new_or_legacy_dialog("DeckStats", self)
|
||||||
if want_old:
|
|
||||||
aqt.dialogs.open("DeckStats", self)
|
|
||||||
else:
|
|
||||||
aqt.dialogs.open("NewDeckStats", self)
|
|
||||||
|
|
||||||
def onPrefs(self) -> None:
|
def onPrefs(self) -> None:
|
||||||
aqt.dialogs.open("Preferences", self)
|
aqt.dialogs.open("Preferences", self)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -16,6 +17,7 @@ from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from errno import EPROTOTYPE
|
from errno import EPROTOTYPE
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
from typing import Any, Generic, cast
|
||||||
|
|
||||||
import flask
|
import flask
|
||||||
import flask_cors
|
import flask_cors
|
||||||
|
|
@ -27,18 +29,31 @@ from waitress.server import create_server
|
||||||
import aqt
|
import aqt
|
||||||
import aqt.main
|
import aqt.main
|
||||||
import aqt.operations
|
import aqt.operations
|
||||||
from anki import hooks
|
from anki import frontend_pb2, generic_pb2, hooks
|
||||||
from anki.collection import OpChanges, OpChangesOnly, Progress, SearchNode
|
from anki.collection import (
|
||||||
|
NestedOpChanges,
|
||||||
|
OpChanges,
|
||||||
|
OpChangesOnly,
|
||||||
|
Progress,
|
||||||
|
SearchNode,
|
||||||
|
)
|
||||||
from anki.decks import UpdateDeckConfigs
|
from anki.decks import UpdateDeckConfigs
|
||||||
from anki.scheduler.v3 import SchedulingStatesWithContext, SetSchedulingStatesRequest
|
from anki.scheduler.v3 import SchedulingStatesWithContext, SetSchedulingStatesRequest
|
||||||
from anki.utils import dev_mode
|
from anki.utils import dev_mode, from_json_bytes, to_json_bytes
|
||||||
from aqt.changenotetype import ChangeNotetypeDialog
|
from aqt.changenotetype import ChangeNotetypeDialog
|
||||||
from aqt.deckoptions import DeckOptionsDialog
|
from aqt.deckoptions import DeckOptionsDialog
|
||||||
from aqt.operations import on_op_finished
|
from aqt.operations import on_op_finished
|
||||||
from aqt.operations.deck import update_deck_configs as update_deck_configs_op
|
from aqt.operations.deck import update_deck_configs as update_deck_configs_op
|
||||||
from aqt.progress import ProgressUpdate
|
from aqt.progress import ProgressUpdate
|
||||||
from aqt.qt import *
|
from aqt.qt import *
|
||||||
from aqt.utils import aqt_data_path, show_warning, tr
|
from aqt.utils import (
|
||||||
|
aqt_data_path,
|
||||||
|
askUser,
|
||||||
|
openLink,
|
||||||
|
show_info,
|
||||||
|
show_warning,
|
||||||
|
tr,
|
||||||
|
)
|
||||||
|
|
||||||
# https://forums.ankiweb.net/t/anki-crash-when-using-a-specific-deck/22266
|
# https://forums.ankiweb.net/t/anki-crash-when-using-a-specific-deck/22266
|
||||||
waitress.wasyncore._DISCONNECTED = waitress.wasyncore._DISCONNECTED.union({EPROTOTYPE}) # type: ignore
|
waitress.wasyncore._DISCONNECTED = waitress.wasyncore._DISCONNECTED.union({EPROTOTYPE}) # type: ignore
|
||||||
|
|
@ -363,6 +378,7 @@ def is_sveltekit_page(path: str) -> bool:
|
||||||
"import-csv",
|
"import-csv",
|
||||||
"import-page",
|
"import-page",
|
||||||
"image-occlusion",
|
"image-occlusion",
|
||||||
|
"editor",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -552,36 +568,6 @@ def import_done() -> bytes:
|
||||||
return b""
|
return b""
|
||||||
|
|
||||||
|
|
||||||
def import_request(endpoint: str) -> bytes:
|
|
||||||
output = raw_backend_request(endpoint)()
|
|
||||||
response = OpChangesOnly()
|
|
||||||
response.ParseFromString(output)
|
|
||||||
|
|
||||||
def handle_on_main() -> None:
|
|
||||||
window = aqt.mw.app.activeModalWidget()
|
|
||||||
on_op_finished(aqt.mw, response, window)
|
|
||||||
|
|
||||||
aqt.mw.taskman.run_on_main(handle_on_main)
|
|
||||||
|
|
||||||
return output
|
|
||||||
|
|
||||||
|
|
||||||
def import_csv() -> bytes:
|
|
||||||
return import_request("import_csv")
|
|
||||||
|
|
||||||
|
|
||||||
def import_anki_package() -> bytes:
|
|
||||||
return import_request("import_anki_package")
|
|
||||||
|
|
||||||
|
|
||||||
def import_json_file() -> bytes:
|
|
||||||
return import_request("import_json_file")
|
|
||||||
|
|
||||||
|
|
||||||
def import_json_string() -> bytes:
|
|
||||||
return import_request("import_json_string")
|
|
||||||
|
|
||||||
|
|
||||||
def search_in_browser() -> bytes:
|
def search_in_browser() -> bytes:
|
||||||
node = SearchNode()
|
node = SearchNode()
|
||||||
node.ParseFromString(request.data)
|
node.ParseFromString(request.data)
|
||||||
|
|
@ -628,6 +614,300 @@ def deck_options_ready() -> bytes:
|
||||||
return b""
|
return b""
|
||||||
|
|
||||||
|
|
||||||
|
def get_setting_json(getter: Callable[[str], Any]) -> bytes:
|
||||||
|
req = generic_pb2.String()
|
||||||
|
req.ParseFromString(request.data)
|
||||||
|
value = getter(req.val)
|
||||||
|
output = generic_pb2.Json(json=to_json_bytes(value)).SerializeToString()
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def set_setting_json(setter: Callable[[str, Any], Any]) -> bytes:
|
||||||
|
req = frontend_pb2.SetSettingJsonRequest()
|
||||||
|
req.ParseFromString(request.data)
|
||||||
|
setter(req.key, from_json_bytes(req.value_json))
|
||||||
|
return b""
|
||||||
|
|
||||||
|
|
||||||
|
def get_profile_config_json() -> bytes:
|
||||||
|
assert aqt.mw.pm.profile is not None
|
||||||
|
return get_setting_json(aqt.mw.pm.profile.get)
|
||||||
|
|
||||||
|
|
||||||
|
def set_profile_config_json() -> bytes:
|
||||||
|
assert aqt.mw.pm.profile is not None
|
||||||
|
return set_setting_json(aqt.mw.pm.profile.__setitem__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_meta_json() -> bytes:
|
||||||
|
return get_setting_json(aqt.mw.pm.meta.get)
|
||||||
|
|
||||||
|
|
||||||
|
def set_meta_json() -> bytes:
|
||||||
|
return set_setting_json(aqt.mw.pm.meta.__setitem__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_config_json() -> bytes:
|
||||||
|
try:
|
||||||
|
return get_setting_json(aqt.mw.col.conf.get_immutable)
|
||||||
|
except KeyError:
|
||||||
|
return generic_pb2.Json(json=b"null").SerializeToString()
|
||||||
|
|
||||||
|
|
||||||
|
def set_config_json() -> bytes:
|
||||||
|
return set_setting_json(aqt.mw.col.set_config)
|
||||||
|
|
||||||
|
|
||||||
|
def convert_pasted_image() -> bytes:
|
||||||
|
req = frontend_pb2.ConvertPastedImageRequest()
|
||||||
|
req.ParseFromString(request.data)
|
||||||
|
image = QImage.fromData(req.data)
|
||||||
|
buffer = QBuffer()
|
||||||
|
buffer.open(QBuffer.OpenModeFlag.ReadWrite)
|
||||||
|
if req.ext == "png":
|
||||||
|
quality = 50
|
||||||
|
else:
|
||||||
|
quality = 80
|
||||||
|
image.save(buffer, req.ext, quality)
|
||||||
|
buffer.reset()
|
||||||
|
data = bytes(cast(bytes, buffer.readAll()))
|
||||||
|
return frontend_pb2.ConvertPastedImageResponse(data=data).SerializeToString()
|
||||||
|
|
||||||
|
|
||||||
|
AsyncRequestReturnType = TypeVar("AsyncRequestReturnType")
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncRequestHandler(Generic[AsyncRequestReturnType]):
|
||||||
|
def __init__(self, callback: Callable[[AsyncRequestHandler], None]) -> None:
|
||||||
|
self.callback = callback
|
||||||
|
self.loop = asyncio.get_running_loop()
|
||||||
|
self.future = self.loop.create_future()
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
aqt.mw.taskman.run_on_main(lambda: self.callback(self))
|
||||||
|
|
||||||
|
def set_result(self, result: AsyncRequestReturnType) -> None:
|
||||||
|
self.loop.call_soon_threadsafe(self.future.set_result, result)
|
||||||
|
|
||||||
|
async def get_result(self) -> AsyncRequestReturnType:
|
||||||
|
return await self.future
|
||||||
|
|
||||||
|
|
||||||
|
async def open_file_picker() -> bytes:
|
||||||
|
req = frontend_pb2.openFilePickerRequest()
|
||||||
|
req.ParseFromString(request.data)
|
||||||
|
|
||||||
|
def callback(request_handler: AsyncRequestHandler) -> None:
|
||||||
|
from aqt.utils import getFile
|
||||||
|
|
||||||
|
def cb(filename: str | None) -> None:
|
||||||
|
request_handler.set_result(filename)
|
||||||
|
|
||||||
|
window = aqt.mw.app.activeWindow()
|
||||||
|
assert window is not None
|
||||||
|
getFile(
|
||||||
|
parent=window,
|
||||||
|
title=req.title,
|
||||||
|
cb=cast(Callable[[Any], None], cb),
|
||||||
|
filter=f"{req.filter_description} ({' '.join(f'*.{ext}' for ext in req.extensions)})",
|
||||||
|
key=req.key,
|
||||||
|
)
|
||||||
|
|
||||||
|
request_handler: AsyncRequestHandler[str | None] = AsyncRequestHandler(callback)
|
||||||
|
request_handler.run()
|
||||||
|
filename = await request_handler.get_result()
|
||||||
|
|
||||||
|
return generic_pb2.String(val=filename if filename else "").SerializeToString()
|
||||||
|
|
||||||
|
|
||||||
|
def open_media() -> bytes:
|
||||||
|
from aqt.utils import openFolder
|
||||||
|
|
||||||
|
req = generic_pb2.String()
|
||||||
|
req.ParseFromString(request.data)
|
||||||
|
path = os.path.join(aqt.mw.col.media.dir(), req.val)
|
||||||
|
aqt.mw.taskman.run_on_main(lambda: openFolder(path))
|
||||||
|
|
||||||
|
return b""
|
||||||
|
|
||||||
|
|
||||||
|
def show_in_media_folder() -> bytes:
|
||||||
|
from aqt.utils import show_in_folder
|
||||||
|
|
||||||
|
req = generic_pb2.String()
|
||||||
|
req.ParseFromString(request.data)
|
||||||
|
path = os.path.join(aqt.mw.col.media.dir(), req.val)
|
||||||
|
aqt.mw.taskman.run_on_main(lambda: show_in_folder(path))
|
||||||
|
|
||||||
|
return b""
|
||||||
|
|
||||||
|
|
||||||
|
async def record_audio() -> bytes:
|
||||||
|
def callback(request_handler: AsyncRequestHandler) -> None:
|
||||||
|
from aqt.sound import record_audio
|
||||||
|
|
||||||
|
def cb(path: str | None) -> None:
|
||||||
|
request_handler.set_result(path)
|
||||||
|
|
||||||
|
window = aqt.mw.app.activeWindow()
|
||||||
|
assert window is not None
|
||||||
|
record_audio(window, aqt.mw, True, cb)
|
||||||
|
|
||||||
|
request_handler: AsyncRequestHandler[str | None] = AsyncRequestHandler(callback)
|
||||||
|
request_handler.run()
|
||||||
|
path = await request_handler.get_result()
|
||||||
|
|
||||||
|
return generic_pb2.String(val=path if path else "").SerializeToString()
|
||||||
|
|
||||||
|
|
||||||
|
def read_clipboard() -> bytes:
|
||||||
|
req = frontend_pb2.ReadClipboardRequest()
|
||||||
|
req.ParseFromString(request.data)
|
||||||
|
data = {}
|
||||||
|
clipboard = aqt.mw.app.clipboard()
|
||||||
|
assert clipboard is not None
|
||||||
|
mime_data = clipboard.mimeData(QClipboard.Mode.Clipboard)
|
||||||
|
assert mime_data is not None
|
||||||
|
for type in req.types:
|
||||||
|
data[type] = bytes(mime_data.data(type)) # type: ignore
|
||||||
|
|
||||||
|
return frontend_pb2.ReadClipboardResponse(data=data).SerializeToString()
|
||||||
|
|
||||||
|
|
||||||
|
def write_clipboard() -> bytes:
|
||||||
|
req = frontend_pb2.WriteClipboardRequest()
|
||||||
|
req.ParseFromString(request.data)
|
||||||
|
clipboard = aqt.mw.app.clipboard()
|
||||||
|
assert clipboard is not None
|
||||||
|
mime_data = clipboard.mimeData(QClipboard.Mode.Clipboard)
|
||||||
|
assert mime_data is not None
|
||||||
|
for type, data in req.data.items():
|
||||||
|
mime_data.setData(type, data)
|
||||||
|
return b""
|
||||||
|
|
||||||
|
|
||||||
|
def close_add_cards() -> bytes:
|
||||||
|
req = generic_pb2.Bool()
|
||||||
|
req.ParseFromString(request.data)
|
||||||
|
|
||||||
|
def handle_on_main() -> None:
|
||||||
|
from aqt.addcards import NewAddCards
|
||||||
|
|
||||||
|
window = aqt.mw.app.activeWindow()
|
||||||
|
if isinstance(window, NewAddCards):
|
||||||
|
window._close_if_user_wants_to_discard_changes(req.val)
|
||||||
|
|
||||||
|
aqt.mw.taskman.run_on_main(lambda: QTimer.singleShot(0, handle_on_main))
|
||||||
|
return b""
|
||||||
|
|
||||||
|
|
||||||
|
def close_edit_current() -> bytes:
|
||||||
|
def handle_on_main() -> None:
|
||||||
|
from aqt.editcurrent import NewEditCurrent
|
||||||
|
|
||||||
|
window = aqt.mw.app.activeWindow()
|
||||||
|
if isinstance(window, NewEditCurrent):
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
aqt.mw.taskman.run_on_main(lambda: QTimer.singleShot(0, handle_on_main))
|
||||||
|
return b""
|
||||||
|
|
||||||
|
|
||||||
|
def open_link() -> bytes:
|
||||||
|
req = generic_pb2.String()
|
||||||
|
req.ParseFromString(request.data)
|
||||||
|
url = req.val
|
||||||
|
aqt.mw.taskman.run_on_main(lambda: openLink(url))
|
||||||
|
return b""
|
||||||
|
|
||||||
|
|
||||||
|
async def ask_user() -> bytes:
|
||||||
|
req = frontend_pb2.AskUserRequest()
|
||||||
|
req.ParseFromString(request.data)
|
||||||
|
|
||||||
|
def callback(request_handler: AsyncRequestHandler) -> None:
|
||||||
|
kwargs: dict[str, Any] = dict(text=req.text)
|
||||||
|
if req.HasField("help"):
|
||||||
|
help_arg: Any
|
||||||
|
if req.help.WhichOneof("value") == "help_page":
|
||||||
|
help_arg = req.help.help_page
|
||||||
|
else:
|
||||||
|
help_arg = req.help.help_link
|
||||||
|
kwargs["help"] = help_arg
|
||||||
|
if req.HasField("title"):
|
||||||
|
kwargs["title"] = req.title
|
||||||
|
if req.HasField("default_no"):
|
||||||
|
kwargs["defaultno"] = req.default_no
|
||||||
|
answer = askUser(**kwargs)
|
||||||
|
request_handler.set_result(answer)
|
||||||
|
|
||||||
|
request_handler: AsyncRequestHandler[bool] = AsyncRequestHandler(callback)
|
||||||
|
request_handler.run()
|
||||||
|
answer = await request_handler.get_result()
|
||||||
|
|
||||||
|
return generic_pb2.Bool(val=answer).SerializeToString()
|
||||||
|
|
||||||
|
|
||||||
|
async def show_message_box() -> bytes:
|
||||||
|
req = frontend_pb2.ShowMessageBoxRequest()
|
||||||
|
req.ParseFromString(request.data)
|
||||||
|
|
||||||
|
def callback(request_handler: AsyncRequestHandler) -> None:
|
||||||
|
kwargs: dict[str, Any] = dict(text=req.text)
|
||||||
|
if req.type == frontend_pb2.MessageBoxType.INFO:
|
||||||
|
icon = QMessageBox.Icon.Information
|
||||||
|
elif req.type == frontend_pb2.MessageBoxType.WARNING:
|
||||||
|
icon = QMessageBox.Icon.Warning
|
||||||
|
elif req.type == frontend_pb2.MessageBoxType.CRITICAL:
|
||||||
|
icon = QMessageBox.Icon.Critical
|
||||||
|
kwargs["icon"] = icon
|
||||||
|
if req.HasField("help"):
|
||||||
|
help_arg: Any
|
||||||
|
if req.help.WhichOneof("value") == "help_page":
|
||||||
|
help_arg = req.help.help_page
|
||||||
|
else:
|
||||||
|
help_arg = req.help.help_link
|
||||||
|
kwargs["help"] = help_arg
|
||||||
|
if req.HasField("title"):
|
||||||
|
kwargs["title"] = req.title
|
||||||
|
if req.HasField("text_format"):
|
||||||
|
kwargs["text_format"] = req.text_format
|
||||||
|
show_info(**kwargs)
|
||||||
|
request_handler.set_result(True)
|
||||||
|
|
||||||
|
request_handler: AsyncRequestHandler[bool] = AsyncRequestHandler(callback)
|
||||||
|
request_handler.run()
|
||||||
|
answer = await request_handler.get_result()
|
||||||
|
|
||||||
|
return generic_pb2.Bool(val=answer).SerializeToString()
|
||||||
|
|
||||||
|
|
||||||
|
def open_fields_dialog() -> bytes:
|
||||||
|
def handle_on_main() -> None:
|
||||||
|
from aqt.editor import NewEditor
|
||||||
|
|
||||||
|
window = aqt.mw.app.activeWindow()
|
||||||
|
assert window is not None
|
||||||
|
if hasattr(window, "editor") and isinstance(window.editor, NewEditor):
|
||||||
|
window.editor.onFields()
|
||||||
|
|
||||||
|
aqt.mw.taskman.run_on_main(handle_on_main)
|
||||||
|
return b""
|
||||||
|
|
||||||
|
|
||||||
|
def open_cards_dialog() -> bytes:
|
||||||
|
def handle_on_main() -> None:
|
||||||
|
from aqt.editor import NewEditor
|
||||||
|
|
||||||
|
window = aqt.mw.app.activeWindow()
|
||||||
|
assert window is not None
|
||||||
|
if hasattr(window, "editor") and isinstance(window.editor, NewEditor):
|
||||||
|
window.editor.onCardLayout()
|
||||||
|
|
||||||
|
aqt.mw.taskman.run_on_main(handle_on_main)
|
||||||
|
return b""
|
||||||
|
|
||||||
|
|
||||||
def save_custom_colours() -> bytes:
|
def save_custom_colours() -> bytes:
|
||||||
colors = [
|
colors = [
|
||||||
QColorDialog.customColor(i).name(QColor.NameFormat.HexRgb)
|
QColorDialog.customColor(i).name(QColor.NameFormat.HexRgb)
|
||||||
|
|
@ -645,13 +925,28 @@ post_handler_list = [
|
||||||
set_scheduling_states,
|
set_scheduling_states,
|
||||||
change_notetype,
|
change_notetype,
|
||||||
import_done,
|
import_done,
|
||||||
import_csv,
|
|
||||||
import_anki_package,
|
|
||||||
import_json_file,
|
|
||||||
import_json_string,
|
|
||||||
search_in_browser,
|
search_in_browser,
|
||||||
deck_options_require_close,
|
deck_options_require_close,
|
||||||
deck_options_ready,
|
deck_options_ready,
|
||||||
|
get_profile_config_json,
|
||||||
|
set_profile_config_json,
|
||||||
|
get_meta_json,
|
||||||
|
set_meta_json,
|
||||||
|
get_config_json,
|
||||||
|
convert_pasted_image,
|
||||||
|
open_file_picker,
|
||||||
|
open_media,
|
||||||
|
show_in_media_folder,
|
||||||
|
record_audio,
|
||||||
|
read_clipboard,
|
||||||
|
write_clipboard,
|
||||||
|
close_add_cards,
|
||||||
|
close_edit_current,
|
||||||
|
open_link,
|
||||||
|
ask_user,
|
||||||
|
show_message_box,
|
||||||
|
open_fields_dialog,
|
||||||
|
open_cards_dialog,
|
||||||
save_custom_colours,
|
save_custom_colours,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -662,17 +957,31 @@ exposed_backend_list = [
|
||||||
"get_custom_colours",
|
"get_custom_colours",
|
||||||
# DeckService
|
# DeckService
|
||||||
"get_deck_names",
|
"get_deck_names",
|
||||||
|
"get_deck",
|
||||||
# I18nService
|
# I18nService
|
||||||
"i18n_resources",
|
"i18n_resources",
|
||||||
# ImportExportService
|
# ImportExportService
|
||||||
"get_csv_metadata",
|
"get_csv_metadata",
|
||||||
"get_import_anki_package_presets",
|
"get_import_anki_package_presets",
|
||||||
|
"import_csv",
|
||||||
|
"import_anki_package",
|
||||||
|
"import_json_file",
|
||||||
|
"import_json_string",
|
||||||
# NotesService
|
# NotesService
|
||||||
"get_field_names",
|
"get_field_names",
|
||||||
"get_note",
|
"get_note",
|
||||||
|
"new_note",
|
||||||
|
"note_fields_check",
|
||||||
|
"defaults_for_adding",
|
||||||
|
"default_deck_for_notetype",
|
||||||
|
"add_note",
|
||||||
|
"update_notes",
|
||||||
|
"update_notetype",
|
||||||
# NotetypesService
|
# NotetypesService
|
||||||
|
"get_notetype",
|
||||||
"get_notetype_names",
|
"get_notetype_names",
|
||||||
"get_change_notetype_info",
|
"get_change_notetype_info",
|
||||||
|
"get_cloze_field_ords",
|
||||||
# StatsService
|
# StatsService
|
||||||
"card_stats",
|
"card_stats",
|
||||||
"get_review_logs",
|
"get_review_logs",
|
||||||
|
|
@ -698,6 +1007,21 @@ exposed_backend_list = [
|
||||||
# DeckConfigService
|
# DeckConfigService
|
||||||
"get_ignored_before_count",
|
"get_ignored_before_count",
|
||||||
"get_retention_workload",
|
"get_retention_workload",
|
||||||
|
# CardRenderingService
|
||||||
|
"encode_iri_paths",
|
||||||
|
"decode_iri_paths",
|
||||||
|
"html_to_text_line",
|
||||||
|
# ConfigService
|
||||||
|
"set_config_json",
|
||||||
|
"get_config_bool",
|
||||||
|
# MediaService
|
||||||
|
"add_media_file",
|
||||||
|
"add_media_from_path",
|
||||||
|
"add_media_from_url",
|
||||||
|
"get_absolute_media_path",
|
||||||
|
"extract_media_files",
|
||||||
|
# CardsService
|
||||||
|
"get_card",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -707,7 +1031,29 @@ def raw_backend_request(endpoint: str) -> Callable[[], bytes]:
|
||||||
|
|
||||||
assert hasattr(RustBackend, f"{endpoint}_raw")
|
assert hasattr(RustBackend, f"{endpoint}_raw")
|
||||||
|
|
||||||
return lambda: getattr(aqt.mw.col._backend, f"{endpoint}_raw")(request.data)
|
def wrapped() -> bytes:
|
||||||
|
output = getattr(aqt.mw.col._backend, f"{endpoint}_raw")(request.data)
|
||||||
|
op_changes_type = int(request.headers.get("Anki-Op-Changes", "0"))
|
||||||
|
if op_changes_type:
|
||||||
|
op_message_types = (OpChanges, OpChangesOnly, NestedOpChanges)
|
||||||
|
try:
|
||||||
|
response = op_message_types[op_changes_type - 1]()
|
||||||
|
response.ParseFromString(output)
|
||||||
|
changes: Any = response
|
||||||
|
for _ in range(op_changes_type - 1):
|
||||||
|
changes = changes.changes
|
||||||
|
except IndexError:
|
||||||
|
raise ValueError(f"unhandled op changes level: {op_changes_type}")
|
||||||
|
|
||||||
|
def handle_on_main() -> None:
|
||||||
|
handler = aqt.mw.app.activeWindow()
|
||||||
|
on_op_finished(aqt.mw, changes, handler)
|
||||||
|
|
||||||
|
aqt.mw.taskman.run_on_main(handle_on_main)
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
|
||||||
# all methods in here require a collection
|
# all methods in here require a collection
|
||||||
|
|
@ -726,7 +1072,14 @@ 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):
|
||||||
|
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:
|
||||||
|
|
|
||||||
|
|
@ -1172,7 +1172,7 @@ timerStopped = false;
|
||||||
|
|
||||||
def on_create_copy(self) -> None:
|
def on_create_copy(self) -> None:
|
||||||
if self.card:
|
if self.card:
|
||||||
aqt.dialogs.open("AddCards", self.mw).set_note(
|
self.mw._open_new_or_legacy_dialog("AddCards").set_note(
|
||||||
self.card.note(), self.card.current_deck_id()
|
self.card.note(), self.card.current_deck_id()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,16 @@ from collections.abc import Callable, Sequence
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import TYPE_CHECKING, Any, Type, cast
|
from typing import TYPE_CHECKING, Any, Type, cast
|
||||||
|
|
||||||
|
from google.protobuf.json_format import MessageToDict
|
||||||
from typing_extensions import TypedDict, Unpack
|
from typing_extensions import TypedDict, Unpack
|
||||||
|
|
||||||
import anki
|
import anki
|
||||||
import anki.lang
|
import anki.lang
|
||||||
from anki._legacy import deprecated
|
from anki._legacy import deprecated
|
||||||
from anki.lang import is_rtl
|
from anki.lang import is_rtl
|
||||||
from anki.utils import hmr_mode, is_lin, is_mac, is_win
|
from anki.utils import hmr_mode, is_lin, is_mac, is_win, to_json_bytes
|
||||||
from aqt import colors, gui_hooks
|
from aqt import colors, gui_hooks
|
||||||
|
from aqt.operations import OpChanges
|
||||||
from aqt.qt import *
|
from aqt.qt import *
|
||||||
from aqt.qt import sip
|
from aqt.qt import sip
|
||||||
from aqt.theme import theme_manager
|
from aqt.theme import theme_manager
|
||||||
|
|
@ -382,6 +384,7 @@ class AnkiWebView(QWebEngineView):
|
||||||
self._filterSet = False
|
self._filterSet = False
|
||||||
gui_hooks.theme_did_change.append(self.on_theme_did_change)
|
gui_hooks.theme_did_change.append(self.on_theme_did_change)
|
||||||
gui_hooks.body_classes_need_update.append(self.on_body_classes_need_update)
|
gui_hooks.body_classes_need_update.append(self.on_body_classes_need_update)
|
||||||
|
gui_hooks.operation_did_execute.append(self.on_operation_did_execute)
|
||||||
|
|
||||||
qconnect(self.loadFinished, self._on_load_finished)
|
qconnect(self.loadFinished, self._on_load_finished)
|
||||||
|
|
||||||
|
|
@ -911,6 +914,7 @@ html {{ {font} }}
|
||||||
|
|
||||||
gui_hooks.theme_did_change.remove(self.on_theme_did_change)
|
gui_hooks.theme_did_change.remove(self.on_theme_did_change)
|
||||||
gui_hooks.body_classes_need_update.remove(self.on_body_classes_need_update)
|
gui_hooks.body_classes_need_update.remove(self.on_body_classes_need_update)
|
||||||
|
gui_hooks.operation_did_execute.remove(self.on_operation_did_execute)
|
||||||
# defer page cleanup so that in-flight requests have a chance to complete first
|
# defer page cleanup so that in-flight requests have a chance to complete first
|
||||||
# https://forums.ankiweb.net/t/error-when-exiting-browsing-when-the-software-is-installed-in-the-path-c-program-files-anki/38363
|
# https://forums.ankiweb.net/t/error-when-exiting-browsing-when-the-software-is-installed-in-the-path-c-program-files-anki/38363
|
||||||
mw.progress.single_shot(5000, lambda: mw.mediaServer.clear_page_html(id(self)))
|
mw.progress.single_shot(5000, lambda: mw.mediaServer.clear_page_html(id(self)))
|
||||||
|
|
@ -960,6 +964,17 @@ html {{ {font} }}
|
||||||
f"""document.body.classList.toggle("reduce-motion", {json.dumps(mw.pm.reduce_motion())}); """
|
f"""document.body.classList.toggle("reduce-motion", {json.dumps(mw.pm.reduce_motion())}); """
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def on_operation_did_execute(
|
||||||
|
self, changes: OpChanges, handler: object | None
|
||||||
|
) -> None:
|
||||||
|
if handler is self.parentWidget():
|
||||||
|
return
|
||||||
|
|
||||||
|
changes_json = to_json_bytes(MessageToDict(changes)).decode()
|
||||||
|
self.eval(
|
||||||
|
f"if(globalThis.anki && globalThis.anki.onOperationDidExecute) globalThis.anki.onOperationDidExecute({changes_json})"
|
||||||
|
)
|
||||||
|
|
||||||
@deprecated(info="use theme_manager.qcolor() instead")
|
@deprecated(info="use theme_manager.qcolor() instead")
|
||||||
def get_window_bg_color(self, night_mode: bool | None = None) -> QColor:
|
def get_window_bg_color(self, night_mode: bool | None = None) -> QColor:
|
||||||
return theme_manager.qcolor(colors.CANVAS)
|
return theme_manager.qcolor(colors.CANVAS)
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ requires-python = ">=3.9"
|
||||||
license = "AGPL-3.0-or-later"
|
license = "AGPL-3.0-or-later"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"beautifulsoup4",
|
"beautifulsoup4",
|
||||||
"flask",
|
"flask[async]",
|
||||||
"flask_cors",
|
"flask_cors",
|
||||||
"jsonschema",
|
"jsonschema",
|
||||||
"requests",
|
"requests",
|
||||||
|
|
|
||||||
|
|
@ -1008,12 +1008,15 @@ hooks = [
|
||||||
###################
|
###################
|
||||||
Hook(
|
Hook(
|
||||||
name="add_cards_will_show_history_menu",
|
name="add_cards_will_show_history_menu",
|
||||||
args=["addcards: aqt.addcards.AddCards", "menu: QMenu"],
|
args=[
|
||||||
|
"addcards: aqt.addcards.AddCards | aqt.addcards.NewAddCards",
|
||||||
|
"menu: QMenu",
|
||||||
|
],
|
||||||
legacy_hook="AddCards.onHistory",
|
legacy_hook="AddCards.onHistory",
|
||||||
),
|
),
|
||||||
Hook(
|
Hook(
|
||||||
name="add_cards_did_init",
|
name="add_cards_did_init",
|
||||||
args=["addcards: aqt.addcards.AddCards"],
|
args=["addcards: aqt.addcards.AddCards | aqt.addcards.NewAddCards"],
|
||||||
),
|
),
|
||||||
Hook(
|
Hook(
|
||||||
name="add_cards_did_add_note",
|
name="add_cards_did_add_note",
|
||||||
|
|
@ -1068,7 +1071,7 @@ hooks = [
|
||||||
Hook(
|
Hook(
|
||||||
name="addcards_did_change_note_type",
|
name="addcards_did_change_note_type",
|
||||||
args=[
|
args=[
|
||||||
"addcards: aqt.addcards.AddCards",
|
"addcards: aqt.addcards.AddCards | aqt.addcards.NewAddCards",
|
||||||
"old: anki.models.NoteType",
|
"old: anki.models.NoteType",
|
||||||
"new: anki.models.NoteType",
|
"new: anki.models.NoteType",
|
||||||
],
|
],
|
||||||
|
|
@ -1087,20 +1090,26 @@ hooks = [
|
||||||
###################
|
###################
|
||||||
Hook(
|
Hook(
|
||||||
name="editor_did_init_left_buttons",
|
name="editor_did_init_left_buttons",
|
||||||
args=["buttons: list[str]", "editor: aqt.editor.Editor"],
|
args=["buttons: list[str]", "editor: aqt.editor.Editor | aqt.editor.NewEditor"],
|
||||||
),
|
),
|
||||||
Hook(
|
Hook(
|
||||||
name="editor_did_init_buttons",
|
name="editor_did_init_buttons",
|
||||||
args=["buttons: list[str]", "editor: aqt.editor.Editor"],
|
args=["buttons: list[str]", "editor: aqt.editor.Editor | aqt.editor.NewEditor"],
|
||||||
),
|
),
|
||||||
Hook(
|
Hook(
|
||||||
name="editor_did_init_shortcuts",
|
name="editor_did_init_shortcuts",
|
||||||
args=["shortcuts: list[tuple]", "editor: aqt.editor.Editor"],
|
args=[
|
||||||
|
"shortcuts: list[tuple]",
|
||||||
|
"editor: aqt.editor.Editor | aqt.editor.NewEditor",
|
||||||
|
],
|
||||||
legacy_hook="setupEditorShortcuts",
|
legacy_hook="setupEditorShortcuts",
|
||||||
),
|
),
|
||||||
Hook(
|
Hook(
|
||||||
name="editor_will_show_context_menu",
|
name="editor_will_show_context_menu",
|
||||||
args=["editor_webview: aqt.editor.EditorWebView", "menu: QMenu"],
|
args=[
|
||||||
|
"editor_webview: aqt.editor.EditorWebView | aqt.editor.NewEditorWebView",
|
||||||
|
"menu: QMenu",
|
||||||
|
],
|
||||||
legacy_hook="EditorWebView.contextMenuEvent",
|
legacy_hook="EditorWebView.contextMenuEvent",
|
||||||
),
|
),
|
||||||
Hook(
|
Hook(
|
||||||
|
|
@ -1121,7 +1130,7 @@ hooks = [
|
||||||
),
|
),
|
||||||
Hook(
|
Hook(
|
||||||
name="editor_did_load_note",
|
name="editor_did_load_note",
|
||||||
args=["editor: aqt.editor.Editor"],
|
args=["editor: aqt.editor.Editor | aqt.editor.NewEditor"],
|
||||||
legacy_hook="loadNote",
|
legacy_hook="loadNote",
|
||||||
),
|
),
|
||||||
Hook(
|
Hook(
|
||||||
|
|
@ -1131,7 +1140,7 @@ hooks = [
|
||||||
),
|
),
|
||||||
Hook(
|
Hook(
|
||||||
name="editor_will_munge_html",
|
name="editor_will_munge_html",
|
||||||
args=["txt: str", "editor: aqt.editor.Editor"],
|
args=["txt: str", "editor: aqt.editor.Editor | aqt.editor.NewEditor"],
|
||||||
return_type="str",
|
return_type="str",
|
||||||
doc="""Allows manipulating the text that will be saved by the editor""",
|
doc="""Allows manipulating the text that will be saved by the editor""",
|
||||||
),
|
),
|
||||||
|
|
@ -1143,15 +1152,21 @@ hooks = [
|
||||||
),
|
),
|
||||||
Hook(
|
Hook(
|
||||||
name="editor_web_view_did_init",
|
name="editor_web_view_did_init",
|
||||||
args=["editor_web_view: aqt.editor.EditorWebView"],
|
args=[
|
||||||
|
"editor_web_view: aqt.editor.EditorWebView | aqt.editor.NewEditorWebView"
|
||||||
|
],
|
||||||
),
|
),
|
||||||
Hook(
|
Hook(
|
||||||
name="editor_did_init",
|
name="editor_did_init",
|
||||||
args=["editor: aqt.editor.Editor"],
|
args=["editor: aqt.editor.Editor | aqt.editor.NewEditor"],
|
||||||
),
|
),
|
||||||
Hook(
|
Hook(
|
||||||
name="editor_will_load_note",
|
name="editor_will_load_note",
|
||||||
args=["js: str", "note: anki.notes.Note", "editor: aqt.editor.Editor"],
|
args=[
|
||||||
|
"js: str",
|
||||||
|
"note: anki.notes.Note",
|
||||||
|
"editor: aqt.editor.Editor | aqt.editor.NewEditor",
|
||||||
|
],
|
||||||
return_type="str",
|
return_type="str",
|
||||||
doc="""Allows changing the javascript commands to load note before
|
doc="""Allows changing the javascript commands to load note before
|
||||||
executing it and do change in the QT editor.""",
|
executing it and do change in the QT editor.""",
|
||||||
|
|
@ -1159,7 +1174,7 @@ hooks = [
|
||||||
Hook(
|
Hook(
|
||||||
name="editor_did_paste",
|
name="editor_did_paste",
|
||||||
args=[
|
args=[
|
||||||
"editor: aqt.editor.Editor",
|
"editor: aqt.editor.Editor | aqt.editor.NewEditor",
|
||||||
"html: str",
|
"html: str",
|
||||||
"internal: bool",
|
"internal: bool",
|
||||||
"extended: bool",
|
"extended: bool",
|
||||||
|
|
@ -1170,7 +1185,7 @@ hooks = [
|
||||||
name="editor_will_process_mime",
|
name="editor_will_process_mime",
|
||||||
args=[
|
args=[
|
||||||
"mime: QMimeData",
|
"mime: QMimeData",
|
||||||
"editor_web_view: aqt.editor.EditorWebView",
|
"editor_web_view: aqt.editor.EditorWebView | aqt.editor.NewEditorWebView",
|
||||||
"internal: bool",
|
"internal: bool",
|
||||||
"extended: bool",
|
"extended: bool",
|
||||||
"drop_event: bool",
|
"drop_event: bool",
|
||||||
|
|
@ -1194,7 +1209,7 @@ hooks = [
|
||||||
Hook(
|
Hook(
|
||||||
name="editor_state_did_change",
|
name="editor_state_did_change",
|
||||||
args=[
|
args=[
|
||||||
"editor: aqt.editor.Editor",
|
"editor: aqt.editor.Editor | aqt.editor.NewEditor",
|
||||||
"new_state: aqt.editor.EditorState",
|
"new_state: aqt.editor.EditorState",
|
||||||
"old_state: aqt.editor.EditorState",
|
"old_state: aqt.editor.EditorState",
|
||||||
],
|
],
|
||||||
|
|
@ -1203,7 +1218,10 @@ hooks = [
|
||||||
),
|
),
|
||||||
Hook(
|
Hook(
|
||||||
name="editor_mask_editor_did_load_image",
|
name="editor_mask_editor_did_load_image",
|
||||||
args=["editor: aqt.editor.Editor", "path_or_nid: str | anki.notes.NoteId"],
|
args=[
|
||||||
|
"editor: aqt.editor.Editor | aqt.editor.NewEditor",
|
||||||
|
"path_or_nid: str | anki.notes.NoteId",
|
||||||
|
],
|
||||||
doc="""Called when the image occlusion mask editor has completed
|
doc="""Called when the image occlusion mask editor has completed
|
||||||
loading an image.
|
loading an image.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ use anki_proto_gen::Method;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use inflections::Inflect;
|
use inflections::Inflect;
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
|
use prost_reflect::MessageDescriptor;
|
||||||
|
|
||||||
pub(crate) fn write_ts_interface(services: &[BackendService]) -> Result<()> {
|
pub(crate) fn write_ts_interface(services: &[BackendService]) -> Result<()> {
|
||||||
let root = Path::new("../../out/ts/lib/generated");
|
let root = Path::new("../../out/ts/lib/generated");
|
||||||
|
|
@ -73,14 +74,16 @@ fn write_ts_method(
|
||||||
input_type,
|
input_type,
|
||||||
output_type,
|
output_type,
|
||||||
comments,
|
comments,
|
||||||
|
op_changes_type,
|
||||||
}: &MethodDetails,
|
}: &MethodDetails,
|
||||||
out: &mut String,
|
out: &mut String,
|
||||||
) {
|
) {
|
||||||
|
let op_changes_type = *op_changes_type as u8;
|
||||||
let comments = format_comments(comments);
|
let comments = format_comments(comments);
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
r#"{comments}export async function {method_name}(input: PlainMessage<{input_type}>, options?: PostProtoOptions): Promise<{output_type}> {{
|
r#"{comments}export async function {method_name}(input: PlainMessage<{input_type}>, options?: PostProtoOptions): Promise<{output_type}> {{
|
||||||
return await postProto("{method_name}", new {input_type}(input), {output_type}, options);
|
return await postProto("{method_name}", new {input_type}(input), {output_type}, options, {op_changes_type});
|
||||||
}}"#
|
}}"#
|
||||||
).unwrap()
|
).unwrap()
|
||||||
}
|
}
|
||||||
|
|
@ -92,11 +95,21 @@ fn format_comments(comments: &Option<String>) -> String {
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
#[repr(u8)]
|
||||||
|
enum OpChangesType {
|
||||||
|
None = 0,
|
||||||
|
OpChanges = 1,
|
||||||
|
OpChangesOnly = 2,
|
||||||
|
NestedOpChanges = 3,
|
||||||
|
}
|
||||||
|
|
||||||
struct MethodDetails {
|
struct MethodDetails {
|
||||||
method_name: String,
|
method_name: String,
|
||||||
input_type: String,
|
input_type: String,
|
||||||
output_type: String,
|
output_type: String,
|
||||||
comments: Option<String>,
|
comments: Option<String>,
|
||||||
|
op_changes_type: OpChangesType,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MethodDetails {
|
impl MethodDetails {
|
||||||
|
|
@ -105,15 +118,46 @@ impl MethodDetails {
|
||||||
let input_type = full_name_to_imported_reference(method.proto.input().full_name());
|
let input_type = full_name_to_imported_reference(method.proto.input().full_name());
|
||||||
let output_type = full_name_to_imported_reference(method.proto.output().full_name());
|
let output_type = full_name_to_imported_reference(method.proto.output().full_name());
|
||||||
let comments = method.comments.clone();
|
let comments = method.comments.clone();
|
||||||
|
let op_changes_type =
|
||||||
|
get_op_changes_type(&method.proto.output(), &method.proto.output(), 1);
|
||||||
Self {
|
Self {
|
||||||
method_name: name,
|
method_name: name,
|
||||||
input_type,
|
input_type,
|
||||||
output_type,
|
output_type,
|
||||||
comments,
|
comments,
|
||||||
|
op_changes_type,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_op_changes_type(
|
||||||
|
root_message: &MessageDescriptor,
|
||||||
|
message: &MessageDescriptor,
|
||||||
|
level: u8,
|
||||||
|
) -> OpChangesType {
|
||||||
|
if message.full_name() == "anki.collection.OpChanges" {
|
||||||
|
match level {
|
||||||
|
0 => OpChangesType::None,
|
||||||
|
1 => OpChangesType::OpChanges,
|
||||||
|
2 => OpChangesType::OpChangesOnly,
|
||||||
|
3 => OpChangesType::NestedOpChanges,
|
||||||
|
_ => panic!(
|
||||||
|
"unhandled op changes level for message {}: {}",
|
||||||
|
root_message.full_name(),
|
||||||
|
level
|
||||||
|
),
|
||||||
|
}
|
||||||
|
} else if let Some(field) = message.get_field(1) {
|
||||||
|
if let Some(field_message) = field.kind().as_message() {
|
||||||
|
get_op_changes_type(root_message, field_message, level + 1)
|
||||||
|
} else {
|
||||||
|
OpChangesType::None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
OpChangesType::None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn record_referenced_type(referenced_packages: &mut HashSet<String>, type_name: &str) {
|
fn record_referenced_type(referenced_packages: &mut HashSet<String>, type_name: &str) {
|
||||||
referenced_packages.insert(type_name.split('.').next().unwrap().to_string());
|
referenced_packages.insert(type_name.split('.').next().unwrap().to_string());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
40
rslib/src/backend/media.rs
Normal file
40
rslib/src/backend/media.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
use anki_proto::media::AddMediaFromUrlRequest;
|
||||||
|
use anki_proto::media::AddMediaFromUrlResponse;
|
||||||
|
|
||||||
|
use crate::backend::Backend;
|
||||||
|
use crate::editor::retrieve_url;
|
||||||
|
use crate::error;
|
||||||
|
|
||||||
|
impl crate::services::BackendMediaService for Backend {
|
||||||
|
fn add_media_from_url(
|
||||||
|
&self,
|
||||||
|
input: AddMediaFromUrlRequest,
|
||||||
|
) -> error::Result<AddMediaFromUrlResponse> {
|
||||||
|
let rt = self.runtime_handle();
|
||||||
|
let mut guard = self.col.lock().unwrap();
|
||||||
|
let col = guard.as_mut().unwrap();
|
||||||
|
let media = col.media()?;
|
||||||
|
let fut = async move {
|
||||||
|
let response = match retrieve_url(&input.url).await {
|
||||||
|
Ok((filename, data)) => {
|
||||||
|
media
|
||||||
|
.add_file(&filename, &data)
|
||||||
|
.map(|fname| fname.to_string())?;
|
||||||
|
AddMediaFromUrlResponse {
|
||||||
|
filename: Some(filename),
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => AddMediaFromUrlResponse {
|
||||||
|
filename: None,
|
||||||
|
error: Some(e.message(col.tr())),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
Ok(response)
|
||||||
|
};
|
||||||
|
rt.block_on(fut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ pub(crate) mod dbproxy;
|
||||||
mod error;
|
mod error;
|
||||||
mod i18n;
|
mod i18n;
|
||||||
mod import_export;
|
mod import_export;
|
||||||
|
mod media;
|
||||||
mod ops;
|
mod ops;
|
||||||
mod sync;
|
mod sync;
|
||||||
|
|
||||||
|
|
|
||||||
117
rslib/src/editor.rs
Normal file
117
rslib/src/editor.rs
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use percent_encoding_iri::percent_decode_str;
|
||||||
|
use reqwest::Client;
|
||||||
|
use reqwest::Url;
|
||||||
|
|
||||||
|
use crate::error::AnkiError;
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::invalid_input;
|
||||||
|
|
||||||
|
/// Download file from URL.
|
||||||
|
/// Returns (filename, file_contents) tuple.
|
||||||
|
pub async fn retrieve_url(url: &str) -> Result<(String, Vec<u8>)> {
|
||||||
|
let is_local = url.to_lowercase().starts_with("file://");
|
||||||
|
let (file_contents, content_type) = if is_local {
|
||||||
|
download_local_file(url).await?
|
||||||
|
} else {
|
||||||
|
download_remote_file(url).await?
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut parsed_url = match Url::parse(url) {
|
||||||
|
Ok(url) => url,
|
||||||
|
Err(e) => invalid_input!("Invalid URL: {}", e),
|
||||||
|
};
|
||||||
|
parsed_url.set_query(None);
|
||||||
|
let mut filename = parsed_url
|
||||||
|
.path_segments()
|
||||||
|
.and_then(|mut segments| segments.next_back())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
filename = match percent_decode_str(&filename).decode_utf8() {
|
||||||
|
Ok(decoded) => decoded.to_string(),
|
||||||
|
Err(e) => invalid_input!("Failed to decode filename: {}", e),
|
||||||
|
};
|
||||||
|
|
||||||
|
if filename.trim().is_empty() {
|
||||||
|
filename = "paste".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(mime_type) = content_type {
|
||||||
|
filename = add_extension_based_on_mime(&filename, &mime_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((filename.to_string(), file_contents))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download_local_file(url: &str) -> Result<(Vec<u8>, Option<String>)> {
|
||||||
|
let decoded_url = match percent_decode_str(url).decode_utf8() {
|
||||||
|
Ok(url) => url,
|
||||||
|
Err(e) => invalid_input!("Failed to decode file URL: {}", e),
|
||||||
|
};
|
||||||
|
|
||||||
|
let parsed_url = match Url::parse(&decoded_url) {
|
||||||
|
Ok(url) => url,
|
||||||
|
Err(e) => invalid_input!("Invalid file URL: {}", e),
|
||||||
|
};
|
||||||
|
|
||||||
|
let file_path = match parsed_url.to_file_path() {
|
||||||
|
Ok(path) => path,
|
||||||
|
Err(_) => invalid_input!("Invalid file path in URL"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let file_contents = std::fs::read(&file_path).map_err(|e| AnkiError::FileIoError {
|
||||||
|
source: anki_io::FileIoError {
|
||||||
|
path: file_path.clone(),
|
||||||
|
op: anki_io::FileOp::Read,
|
||||||
|
source: e,
|
||||||
|
},
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok((file_contents, None))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download_remote_file(url: &str) -> Result<(Vec<u8>, Option<String>)> {
|
||||||
|
let client = Client::builder()
|
||||||
|
.timeout(Duration::from_secs(30))
|
||||||
|
.user_agent("Mozilla/5.0 (compatible; Anki)")
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let response = client.get(url).send().await?.error_for_status()?;
|
||||||
|
let content_type = response
|
||||||
|
.headers()
|
||||||
|
.get("content-type")
|
||||||
|
.and_then(|ct| ct.to_str().ok())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
let file_contents = response.bytes().await?.to_vec();
|
||||||
|
|
||||||
|
Ok((file_contents, content_type))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_extension_based_on_mime(filename: &str, content_type: &str) -> String {
|
||||||
|
let mut extension = "";
|
||||||
|
if Path::new(filename).extension().is_none() {
|
||||||
|
extension = match content_type {
|
||||||
|
"audio/mpeg" => ".mp3",
|
||||||
|
"audio/ogg" => ".oga",
|
||||||
|
"audio/opus" => ".opus",
|
||||||
|
"audio/wav" => ".wav",
|
||||||
|
"audio/webm" => ".weba",
|
||||||
|
"audio/aac" => ".aac",
|
||||||
|
"image/jpeg" => ".jpg",
|
||||||
|
"image/png" => ".png",
|
||||||
|
"image/svg+xml" => ".svg",
|
||||||
|
"image/webp" => ".webp",
|
||||||
|
"image/avif" => ".avif",
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
filename.to_string() + extension
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ pub mod config;
|
||||||
pub mod dbcheck;
|
pub mod dbcheck;
|
||||||
pub mod deckconfig;
|
pub mod deckconfig;
|
||||||
pub mod decks;
|
pub mod decks;
|
||||||
|
pub mod editor;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod findreplace;
|
pub mod findreplace;
|
||||||
pub mod i18n;
|
pub mod i18n;
|
||||||
|
|
|
||||||
|
|
@ -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,19 @@ 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 +82,28 @@ 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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,16 @@ if (!sourcemap) {
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ignoreCssUrlPlugin = {
|
||||||
|
name: "ignore-css-url",
|
||||||
|
setup(build) {
|
||||||
|
// This works around esbuild unconditionally resolving CSS imports that uses Vite's `?url` syntax in the editor
|
||||||
|
build.onResolve({ filter: /.*?\.scss\?url$/ }, (args) => {
|
||||||
|
return { path: args.path, external: true };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
build({
|
build({
|
||||||
bundle: true,
|
bundle: true,
|
||||||
entryPoints: [entrypoint],
|
entryPoints: [entrypoint],
|
||||||
|
|
@ -51,6 +61,7 @@ build({
|
||||||
loader: { ".svg": "text" },
|
loader: { ".svg": "text" },
|
||||||
preserveSymlinks: true,
|
preserveSymlinks: true,
|
||||||
sourcemap: sourcemap ? "inline" : false,
|
sourcemap: sourcemap ? "inline" : false,
|
||||||
|
|
||||||
plugins: [
|
plugins: [
|
||||||
sassPlugin({ loadPaths: ["node_modules"] }),
|
sassPlugin({ loadPaths: ["node_modules"] }),
|
||||||
sveltePlugin({
|
sveltePlugin({
|
||||||
|
|
@ -59,6 +70,7 @@ build({
|
||||||
// let us focus on errors; we can see the warnings with svelte-check
|
// let us focus on errors; we can see the warnings with svelte-check
|
||||||
filterWarnings: (_warning) => false,
|
filterWarnings: (_warning) => false,
|
||||||
}),
|
}),
|
||||||
|
ignoreCssUrlPlugin,
|
||||||
],
|
],
|
||||||
target,
|
target,
|
||||||
// logLevel: "info",
|
// logLevel: "info",
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<!--
|
|
||||||
Copyright: Ankitects Pty Ltd and contributors
|
|
||||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import ButtonGroupItem from "$lib/components/ButtonGroupItem.svelte";
|
|
||||||
|
|
||||||
import type { NoteEditorAPI } from "./NoteEditor.svelte";
|
|
||||||
import NoteEditor from "./NoteEditor.svelte";
|
|
||||||
import PreviewButton from "./PreviewButton.svelte";
|
|
||||||
|
|
||||||
const api: Partial<NoteEditorAPI> = {};
|
|
||||||
let noteEditor: NoteEditor;
|
|
||||||
|
|
||||||
export let uiResolve: (api: NoteEditorAPI) => void;
|
|
||||||
|
|
||||||
$: if (noteEditor) {
|
|
||||||
uiResolve(api as NoteEditorAPI);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<NoteEditor bind:this={noteEditor} {api}>
|
|
||||||
<svelte:fragment slot="notetypeButtons">
|
|
||||||
<ButtonGroupItem>
|
|
||||||
<PreviewButton />
|
|
||||||
</ButtonGroupItem>
|
|
||||||
</svelte:fragment>
|
|
||||||
</NoteEditor>
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<!--
|
|
||||||
Copyright: Ankitects Pty Ltd and contributors
|
|
||||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import * as tr from "@generated/ftl";
|
|
||||||
import { bridgeCommand } from "@tslib/bridgecommand";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<span class="duplicate-link-container">
|
|
||||||
<a class="duplicate-link" href="/#" on:click={() => bridgeCommand("dupes")}>
|
|
||||||
{tr.editingShowDuplicates()}
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.duplicate-link-container {
|
|
||||||
text-align: center;
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.duplicate-link {
|
|
||||||
color: var(--highlight-color);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
<!--
|
|
||||||
Copyright: Ankitects Pty Ltd and contributors
|
|
||||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import { bridgeCommand } from "@tslib/bridgecommand";
|
|
||||||
import { registerShortcut } from "@tslib/shortcuts";
|
|
||||||
import { onDestroy, onMount } from "svelte";
|
|
||||||
|
|
||||||
import type { NoteEditorAPI } from "./NoteEditor.svelte";
|
|
||||||
import NoteEditor from "./NoteEditor.svelte";
|
|
||||||
import StickyBadge from "./StickyBadge.svelte";
|
|
||||||
|
|
||||||
const api: Partial<NoteEditorAPI> = {};
|
|
||||||
let noteEditor: NoteEditor;
|
|
||||||
|
|
||||||
export let uiResolve: (api: NoteEditorAPI) => void;
|
|
||||||
|
|
||||||
$: if (noteEditor) {
|
|
||||||
uiResolve(api as NoteEditorAPI);
|
|
||||||
}
|
|
||||||
|
|
||||||
let stickies: boolean[] = [];
|
|
||||||
|
|
||||||
function setSticky(stckies: boolean[]): void {
|
|
||||||
stickies = stckies;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleStickyAll(): void {
|
|
||||||
bridgeCommand("toggleStickyAll", (values: boolean[]) => (stickies = values));
|
|
||||||
}
|
|
||||||
|
|
||||||
let deregisterSticky: () => void;
|
|
||||||
export function activateStickyShortcuts() {
|
|
||||||
deregisterSticky = registerShortcut(toggleStickyAll, "Shift+F9");
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
Object.assign(globalThis, {
|
|
||||||
setSticky,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => deregisterSticky);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<NoteEditor bind:this={noteEditor} {api}>
|
|
||||||
<svelte:fragment slot="field-state" let:index let:show>
|
|
||||||
<StickyBadge bind:active={stickies[index]} {index} {show} />
|
|
||||||
</svelte:fragment>
|
|
||||||
</NoteEditor>
|
|
||||||
|
|
@ -1,847 +0,0 @@
|
||||||
<!--
|
|
||||||
Copyright: Ankitects Pty Ltd and contributors
|
|
||||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|
||||||
-->
|
|
||||||
<script context="module" lang="ts">
|
|
||||||
import type { Writable } from "svelte/store";
|
|
||||||
|
|
||||||
import Collapsible from "$lib/components/Collapsible.svelte";
|
|
||||||
|
|
||||||
import type { EditingInputAPI } from "./EditingArea.svelte";
|
|
||||||
import type { EditorToolbarAPI } from "./editor-toolbar";
|
|
||||||
import type { EditorFieldAPI } from "./EditorField.svelte";
|
|
||||||
import FieldState from "./FieldState.svelte";
|
|
||||||
import LabelContainer from "./LabelContainer.svelte";
|
|
||||||
import LabelName from "./LabelName.svelte";
|
|
||||||
|
|
||||||
export interface NoteEditorAPI {
|
|
||||||
fields: EditorFieldAPI[];
|
|
||||||
hoveredField: Writable<EditorFieldAPI | null>;
|
|
||||||
focusedField: Writable<EditorFieldAPI | null>;
|
|
||||||
focusedInput: Writable<EditingInputAPI | null>;
|
|
||||||
toolbar: EditorToolbarAPI;
|
|
||||||
}
|
|
||||||
|
|
||||||
import { registerPackage } from "@tslib/runtime-require";
|
|
||||||
|
|
||||||
import contextProperty from "$lib/sveltelib/context-property";
|
|
||||||
import lifecycleHooks from "$lib/sveltelib/lifecycle-hooks";
|
|
||||||
|
|
||||||
const key = Symbol("noteEditor");
|
|
||||||
const [context, setContextProperty] = contextProperty<NoteEditorAPI>(key);
|
|
||||||
const [lifecycle, instances, setupLifecycleHooks] = lifecycleHooks<NoteEditorAPI>();
|
|
||||||
|
|
||||||
export { context };
|
|
||||||
|
|
||||||
registerPackage("anki/NoteEditor", {
|
|
||||||
context,
|
|
||||||
lifecycle,
|
|
||||||
instances,
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import * as tr from "@generated/ftl";
|
|
||||||
import { bridgeCommand } from "@tslib/bridgecommand";
|
|
||||||
import { onMount, tick } from "svelte";
|
|
||||||
import { get, writable } from "svelte/store";
|
|
||||||
import { nodeIsCommonElement } from "@tslib/dom";
|
|
||||||
|
|
||||||
import Absolute from "$lib/components/Absolute.svelte";
|
|
||||||
import Badge from "$lib/components/Badge.svelte";
|
|
||||||
import Icon from "$lib/components/Icon.svelte";
|
|
||||||
import { alertIcon } from "$lib/components/icons";
|
|
||||||
import { TagEditor } from "$lib/tag-editor";
|
|
||||||
import { commitTagEdits } from "$lib/tag-editor/TagInput.svelte";
|
|
||||||
|
|
||||||
import {
|
|
||||||
type ImageLoadedEvent,
|
|
||||||
resetIOImage,
|
|
||||||
} from "../routes/image-occlusion/mask-editor";
|
|
||||||
import { ChangeTimer } from "../editable/change-timer";
|
|
||||||
import { clearableArray } from "./destroyable";
|
|
||||||
import DuplicateLink from "./DuplicateLink.svelte";
|
|
||||||
import EditorToolbar from "./editor-toolbar";
|
|
||||||
import type { FieldData } from "./EditorField.svelte";
|
|
||||||
import EditorField from "./EditorField.svelte";
|
|
||||||
import Fields from "./Fields.svelte";
|
|
||||||
import ImageOverlay from "./image-overlay";
|
|
||||||
import { shrinkImagesByDefault } from "./image-overlay/ImageOverlay.svelte";
|
|
||||||
import MathjaxOverlay from "./mathjax-overlay";
|
|
||||||
import { closeMathjaxEditor } from "./mathjax-overlay/MathjaxEditor.svelte";
|
|
||||||
import Notification from "./Notification.svelte";
|
|
||||||
import PlainTextInput from "./plain-text-input";
|
|
||||||
import { closeHTMLTags } from "./plain-text-input/PlainTextInput.svelte";
|
|
||||||
import PlainTextBadge from "./PlainTextBadge.svelte";
|
|
||||||
import RichTextInput, { editingInputIsRichText } from "./rich-text-input";
|
|
||||||
import RichTextBadge from "./RichTextBadge.svelte";
|
|
||||||
import type { NotetypeIdAndModTime, SessionOptions } from "./types";
|
|
||||||
import { EditorState } from "./types";
|
|
||||||
|
|
||||||
function quoteFontFamily(fontFamily: string): string {
|
|
||||||
// generic families (e.g. sans-serif) must not be quoted
|
|
||||||
if (!/^[-a-z]+$/.test(fontFamily)) {
|
|
||||||
fontFamily = `"${fontFamily}"`;
|
|
||||||
}
|
|
||||||
return fontFamily;
|
|
||||||
}
|
|
||||||
|
|
||||||
const size = 1.6;
|
|
||||||
const wrap = true;
|
|
||||||
|
|
||||||
const sessionOptions: SessionOptions = {};
|
|
||||||
export function saveSession(): void {
|
|
||||||
if (notetypeMeta) {
|
|
||||||
sessionOptions[notetypeMeta.id] = {
|
|
||||||
fieldsCollapsed,
|
|
||||||
fieldStates: {
|
|
||||||
richTextsHidden,
|
|
||||||
plainTextsHidden,
|
|
||||||
plainTextDefaults,
|
|
||||||
},
|
|
||||||
modTimeOfNotetype: notetypeMeta.modTime,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fieldStores: Writable<string>[] = [];
|
|
||||||
let fieldNames: string[] = [];
|
|
||||||
export function setFields(fs: [string, string][]): void {
|
|
||||||
// this is a bit of a mess -- when moving to Rust calls, we should make
|
|
||||||
// sure to have two backend endpoints for:
|
|
||||||
// * the note, which can be set through this view
|
|
||||||
// * the fieldname, font, etc., which cannot be set
|
|
||||||
|
|
||||||
const newFieldNames: string[] = [];
|
|
||||||
|
|
||||||
for (const [index, [fieldName]] of fs.entries()) {
|
|
||||||
newFieldNames[index] = fieldName;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = fieldStores.length; i < newFieldNames.length; i++) {
|
|
||||||
const newStore = writable("");
|
|
||||||
fieldStores[i] = newStore;
|
|
||||||
newStore.subscribe((value) => updateField(i, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
for (
|
|
||||||
let i = fieldStores.length;
|
|
||||||
i > newFieldNames.length;
|
|
||||||
i = fieldStores.length
|
|
||||||
) {
|
|
||||||
fieldStores.pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [index, [, fieldContent]] of fs.entries()) {
|
|
||||||
fieldStores[index].set(fieldContent);
|
|
||||||
}
|
|
||||||
|
|
||||||
fieldNames = newFieldNames;
|
|
||||||
}
|
|
||||||
|
|
||||||
let fieldsCollapsed: boolean[] = [];
|
|
||||||
export function setCollapsed(defaultCollapsed: boolean[]): void {
|
|
||||||
fieldsCollapsed =
|
|
||||||
sessionOptions[notetypeMeta?.id]?.fieldsCollapsed ?? defaultCollapsed;
|
|
||||||
}
|
|
||||||
let clozeFields: boolean[] = [];
|
|
||||||
export function setClozeFields(defaultClozeFields: boolean[]): void {
|
|
||||||
clozeFields = defaultClozeFields;
|
|
||||||
}
|
|
||||||
|
|
||||||
let richTextsHidden: boolean[] = [];
|
|
||||||
let plainTextsHidden: boolean[] = [];
|
|
||||||
let plainTextDefaults: boolean[] = [];
|
|
||||||
|
|
||||||
export function setPlainTexts(defaultPlainTexts: boolean[]): void {
|
|
||||||
const states = sessionOptions[notetypeMeta?.id]?.fieldStates;
|
|
||||||
if (states) {
|
|
||||||
richTextsHidden = states.richTextsHidden;
|
|
||||||
plainTextsHidden = states.plainTextsHidden;
|
|
||||||
plainTextDefaults = states.plainTextDefaults;
|
|
||||||
} else {
|
|
||||||
plainTextDefaults = defaultPlainTexts;
|
|
||||||
richTextsHidden = [...defaultPlainTexts];
|
|
||||||
plainTextsHidden = Array.from(defaultPlainTexts, (v) => !v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function triggerChanges(): void {
|
|
||||||
// I know this looks quite weird and doesn't seem to do anything
|
|
||||||
// but if we don't call this after setPlainTexts() and setCollapsed()
|
|
||||||
// when switching notetypes, existing collapsibles won't react
|
|
||||||
// automatically to the updated props
|
|
||||||
tick().then(() => {
|
|
||||||
fieldsCollapsed = fieldsCollapsed;
|
|
||||||
plainTextDefaults = plainTextDefaults;
|
|
||||||
richTextsHidden = richTextsHidden;
|
|
||||||
plainTextsHidden = plainTextsHidden;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function setMathjaxEnabled(enabled: boolean): void {
|
|
||||||
mathjaxConfig.enabled = enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
let fieldDescriptions: string[] = [];
|
|
||||||
export function setDescriptions(descriptions: string[]): void {
|
|
||||||
fieldDescriptions = descriptions.map((d) =>
|
|
||||||
d.replace(/\\/g, "").replace(/"/g, '\\"'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let fonts: [string, number, boolean][] = [];
|
|
||||||
|
|
||||||
const fields = clearableArray<EditorFieldAPI>();
|
|
||||||
|
|
||||||
export function setFonts(fs: [string, number, boolean][]): void {
|
|
||||||
fonts = fs;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function focusField(index: number | null): void {
|
|
||||||
tick().then(() => {
|
|
||||||
if (typeof index === "number") {
|
|
||||||
if (!(index in fields)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fields[index].editingArea?.refocus();
|
|
||||||
} else {
|
|
||||||
$focusedInput?.refocus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const tags = writable<string[]>([]);
|
|
||||||
export function setTags(ts: string[]): void {
|
|
||||||
$tags = ts;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tagsCollapsed = writable<boolean>();
|
|
||||||
export function setTagsCollapsed(collapsed: boolean): void {
|
|
||||||
$tagsCollapsed = collapsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateTagsCollapsed(collapsed: boolean) {
|
|
||||||
$tagsCollapsed = collapsed;
|
|
||||||
bridgeCommand(`setTagsCollapsed:${$tagsCollapsed}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let noteId: number | null = null;
|
|
||||||
export function setNoteId(ntid: number): void {
|
|
||||||
// TODO this is a hack, because it requires the NoteEditor to know implementation details of the PlainTextInput.
|
|
||||||
// It should be refactored once we work on our own Undo stack
|
|
||||||
for (const pi of plainTextInputs) {
|
|
||||||
pi.api.codeMirror.editor.then((editor) => editor.clearHistory());
|
|
||||||
}
|
|
||||||
noteId = ntid;
|
|
||||||
}
|
|
||||||
|
|
||||||
let notetypeMeta: NotetypeIdAndModTime;
|
|
||||||
function setNotetypeMeta({ id, modTime }: NotetypeIdAndModTime): void {
|
|
||||||
notetypeMeta = { id, modTime };
|
|
||||||
// Discard the saved state of the fields if the notetype has been modified.
|
|
||||||
if (sessionOptions[id]?.modTimeOfNotetype !== modTime) {
|
|
||||||
delete sessionOptions[id];
|
|
||||||
}
|
|
||||||
if (isImageOcclusion) {
|
|
||||||
getImageOcclusionFields({
|
|
||||||
notetypeId: BigInt(notetypeMeta.id),
|
|
||||||
}).then((r) => (ioFields = r.fields!));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNoteId(): number | null {
|
|
||||||
return noteId;
|
|
||||||
}
|
|
||||||
|
|
||||||
let isImageOcclusion = false;
|
|
||||||
function setIsImageOcclusion(val: boolean) {
|
|
||||||
isImageOcclusion = val;
|
|
||||||
$ioMaskEditorVisible = val;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cols: ("dupe" | "")[] = [];
|
|
||||||
export function setBackgrounds(cls: ("dupe" | "")[]): void {
|
|
||||||
cols = cls;
|
|
||||||
}
|
|
||||||
|
|
||||||
let hint: string = "";
|
|
||||||
export function setClozeHint(hnt: string): void {
|
|
||||||
hint = hnt;
|
|
||||||
}
|
|
||||||
|
|
||||||
$: fieldsData = fieldNames.map((name, index) => ({
|
|
||||||
name,
|
|
||||||
plainText: plainTextDefaults[index],
|
|
||||||
description: fieldDescriptions[index],
|
|
||||||
fontFamily: quoteFontFamily(fonts[index][0]),
|
|
||||||
fontSize: fonts[index][1],
|
|
||||||
direction: fonts[index][2] ? "rtl" : "ltr",
|
|
||||||
collapsed: fieldsCollapsed[index],
|
|
||||||
hidden: hideFieldInOcclusionType(index, ioFields),
|
|
||||||
isClozeField: clozeFields[index],
|
|
||||||
})) as FieldData[];
|
|
||||||
|
|
||||||
let lastSavedTags: string[] | null = null;
|
|
||||||
function saveTags({ detail }: CustomEvent): void {
|
|
||||||
tagAmount = detail.tags.filter((tag: string) => tag != "").length;
|
|
||||||
lastSavedTags = detail.tags;
|
|
||||||
bridgeCommand(`saveTags:${JSON.stringify(detail.tags)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fieldSave = new ChangeTimer();
|
|
||||||
|
|
||||||
function transformContentBeforeSave(content: string): string {
|
|
||||||
return content.replace(/ data-editor-shrink="(true|false)"/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateField(index: number, content: string): void {
|
|
||||||
fieldSave.schedule(
|
|
||||||
() =>
|
|
||||||
bridgeCommand(
|
|
||||||
`key:${index}:${getNoteId()}:${transformContentBeforeSave(
|
|
||||||
content,
|
|
||||||
)}`,
|
|
||||||
),
|
|
||||||
600,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveFieldNow(): void {
|
|
||||||
/* this will always be a key save */
|
|
||||||
fieldSave.fireImmediately();
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveNow(): void {
|
|
||||||
closeMathjaxEditor?.();
|
|
||||||
$commitTagEdits();
|
|
||||||
saveFieldNow();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveOnPageHide() {
|
|
||||||
if (document.visibilityState === "hidden") {
|
|
||||||
// will fire on session close and minimize
|
|
||||||
saveFieldNow();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function focusIfField(x: number, y: number): boolean {
|
|
||||||
const elements = document.elementsFromPoint(x, y);
|
|
||||||
const first = elements[0].closest(".field-container");
|
|
||||||
|
|
||||||
if (!first || !nodeIsCommonElement(first)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const index = parseInt(first.dataset?.index ?? "");
|
|
||||||
|
|
||||||
if (Number.isNaN(index) || !fields[index] || fieldsCollapsed[index]) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (richTextsHidden[index]) {
|
|
||||||
toggleRichTextInput(index);
|
|
||||||
} else {
|
|
||||||
richTextInputs[index].api.refocus();
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
let richTextInputs: RichTextInput[] = [];
|
|
||||||
$: richTextInputs = richTextInputs.filter(Boolean);
|
|
||||||
|
|
||||||
let plainTextInputs: PlainTextInput[] = [];
|
|
||||||
$: plainTextInputs = plainTextInputs.filter(Boolean);
|
|
||||||
|
|
||||||
function toggleRichTextInput(index: number): void {
|
|
||||||
const hidden = !richTextsHidden[index];
|
|
||||||
richTextInputs[index].focusFlag.setFlag(!hidden);
|
|
||||||
richTextsHidden[index] = hidden;
|
|
||||||
if (hidden) {
|
|
||||||
plainTextInputs[index].api.refocus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function togglePlainTextInput(index: number): void {
|
|
||||||
const hidden = !plainTextsHidden[index];
|
|
||||||
plainTextInputs[index].focusFlag.setFlag(!hidden);
|
|
||||||
plainTextsHidden[index] = hidden;
|
|
||||||
if (hidden) {
|
|
||||||
richTextInputs[index].api.refocus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleField(index: number): void {
|
|
||||||
const collapsed = !fieldsCollapsed[index];
|
|
||||||
fieldsCollapsed[index] = collapsed;
|
|
||||||
|
|
||||||
const defaultInput = !plainTextDefaults[index]
|
|
||||||
? richTextInputs[index]
|
|
||||||
: plainTextInputs[index];
|
|
||||||
|
|
||||||
if (!collapsed) {
|
|
||||||
defaultInput.api.refocus();
|
|
||||||
} else if (!plainTextDefaults[index]) {
|
|
||||||
plainTextsHidden[index] = true;
|
|
||||||
} else {
|
|
||||||
richTextsHidden[index] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const toolbar: Partial<EditorToolbarAPI> = {};
|
|
||||||
|
|
||||||
function setShrinkImages(shrinkByDefault: boolean) {
|
|
||||||
$shrinkImagesByDefault = shrinkByDefault;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCloseHTMLTags(closeTags: boolean) {
|
|
||||||
$closeHTMLTags = closeTags;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enable/Disable add-on buttons that do not have the `perm` class
|
|
||||||
*/
|
|
||||||
function setAddonButtonsDisabled(disabled: boolean): void {
|
|
||||||
document
|
|
||||||
.querySelectorAll<HTMLButtonElement>("button.linkb:not(.perm)")
|
|
||||||
.forEach((button) => {
|
|
||||||
button.disabled = disabled;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
import { ImageOcclusionFieldIndexes } from "@generated/anki/image_occlusion_pb";
|
|
||||||
import { getImageOcclusionFields } from "@generated/backend";
|
|
||||||
import { wrapInternal } from "@tslib/wrap";
|
|
||||||
|
|
||||||
import Shortcut from "$lib/components/Shortcut.svelte";
|
|
||||||
|
|
||||||
import { mathjaxConfig } from "../editable/mathjax-element.svelte";
|
|
||||||
import ImageOcclusionPage from "../routes/image-occlusion/ImageOcclusionPage.svelte";
|
|
||||||
import ImageOcclusionPicker from "../routes/image-occlusion/ImageOcclusionPicker.svelte";
|
|
||||||
import type { IOMode } from "../routes/image-occlusion/lib";
|
|
||||||
import { exportShapesToClozeDeletions } from "../routes/image-occlusion/shapes/to-cloze";
|
|
||||||
import {
|
|
||||||
hideAllGuessOne,
|
|
||||||
ioImageLoadedStore,
|
|
||||||
ioMaskEditorVisible,
|
|
||||||
} from "../routes/image-occlusion/store";
|
|
||||||
import CollapseLabel from "./CollapseLabel.svelte";
|
|
||||||
import * as oldEditorAdapter from "./old-editor-adapter";
|
|
||||||
|
|
||||||
$: isIOImageLoaded = false;
|
|
||||||
$: ioImageLoadedStore.set(isIOImageLoaded);
|
|
||||||
let imageOcclusionMode: IOMode | undefined;
|
|
||||||
let ioFields = new ImageOcclusionFieldIndexes({});
|
|
||||||
|
|
||||||
function pickIOImage() {
|
|
||||||
imageOcclusionMode = undefined;
|
|
||||||
bridgeCommand("addImageForOcclusion");
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickIOImageFromClipboard() {
|
|
||||||
imageOcclusionMode = undefined;
|
|
||||||
bridgeCommand("addImageForOcclusionFromClipboard");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setupMaskEditor(options: { html: string; mode: IOMode }) {
|
|
||||||
imageOcclusionMode = undefined;
|
|
||||||
await tick();
|
|
||||||
imageOcclusionMode = options.mode;
|
|
||||||
if (options.mode.kind === "add" && !("clonedNoteId" in options.mode)) {
|
|
||||||
fieldStores[ioFields.image].set(options.html);
|
|
||||||
// the image field is set programmatically and does not need debouncing
|
|
||||||
// commit immediately to avoid a race condition with the occlusions field
|
|
||||||
saveFieldNow();
|
|
||||||
|
|
||||||
// new image is being added
|
|
||||||
if (isIOImageLoaded) {
|
|
||||||
resetIOImage(options.mode.imagePath, (event: ImageLoadedEvent) =>
|
|
||||||
onImageLoaded(
|
|
||||||
new CustomEvent("image-loaded", {
|
|
||||||
detail: event,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isIOImageLoaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setImageField(html) {
|
|
||||||
fieldStores[ioFields.image].set(html);
|
|
||||||
}
|
|
||||||
globalThis.setImageField = setImageField;
|
|
||||||
|
|
||||||
function saveOcclusions(): void {
|
|
||||||
if (isImageOcclusion && globalThis.canvas) {
|
|
||||||
const occlusionsData = exportShapesToClozeDeletions($hideAllGuessOne);
|
|
||||||
fieldStores[ioFields.occlusions].set(occlusionsData.clozes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// reset for new occlusion in add mode
|
|
||||||
function resetIOImageLoaded() {
|
|
||||||
isIOImageLoaded = false;
|
|
||||||
globalThis.canvas.clear();
|
|
||||||
globalThis.canvas = undefined;
|
|
||||||
if (imageOcclusionMode?.kind === "add") {
|
|
||||||
// canvas.clear indirectly calls saveOcclusions
|
|
||||||
saveFieldNow();
|
|
||||||
fieldStores[ioFields.image].set("");
|
|
||||||
}
|
|
||||||
const page = document.querySelector(".image-occlusion");
|
|
||||||
if (page) {
|
|
||||||
page.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
globalThis.resetIOImageLoaded = resetIOImageLoaded;
|
|
||||||
|
|
||||||
/** hide occlusions and image */
|
|
||||||
function hideFieldInOcclusionType(
|
|
||||||
index: number,
|
|
||||||
ioFields: ImageOcclusionFieldIndexes,
|
|
||||||
) {
|
|
||||||
if (isImageOcclusion) {
|
|
||||||
if (index === ioFields.occlusions || index === ioFields.image) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Signal image occlusion image loading to Python
|
|
||||||
function onImageLoaded(event: CustomEvent<ImageLoadedEvent>) {
|
|
||||||
const detail = event.detail;
|
|
||||||
bridgeCommand(
|
|
||||||
`ioImageLoaded:${JSON.stringify(detail.path || detail.noteId?.toString())}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Signal editor UI state changes to add-ons
|
|
||||||
|
|
||||||
let editorState: EditorState = EditorState.Initial;
|
|
||||||
let lastEditorState: EditorState = editorState;
|
|
||||||
|
|
||||||
function getEditorState(
|
|
||||||
ioMaskEditorVisible: boolean,
|
|
||||||
isImageOcclusion: boolean,
|
|
||||||
isIOImageLoaded: boolean,
|
|
||||||
imageOcclusionMode: IOMode | undefined,
|
|
||||||
): EditorState {
|
|
||||||
if (isImageOcclusion && ioMaskEditorVisible && !isIOImageLoaded) {
|
|
||||||
return EditorState.ImageOcclusionPicker;
|
|
||||||
} else if (imageOcclusionMode && ioMaskEditorVisible) {
|
|
||||||
return EditorState.ImageOcclusionMasks;
|
|
||||||
} else if (!ioMaskEditorVisible && isImageOcclusion) {
|
|
||||||
return EditorState.ImageOcclusionFields;
|
|
||||||
}
|
|
||||||
return EditorState.Fields;
|
|
||||||
}
|
|
||||||
|
|
||||||
function signalEditorState(newState: EditorState) {
|
|
||||||
tick().then(() => {
|
|
||||||
globalThis.editorState = newState;
|
|
||||||
bridgeCommand(`editorState:${newState}:${lastEditorState}`);
|
|
||||||
lastEditorState = newState;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$: signalEditorState(editorState);
|
|
||||||
|
|
||||||
$: editorState = getEditorState(
|
|
||||||
$ioMaskEditorVisible,
|
|
||||||
isImageOcclusion,
|
|
||||||
isIOImageLoaded,
|
|
||||||
imageOcclusionMode,
|
|
||||||
);
|
|
||||||
|
|
||||||
$: if (isImageOcclusion && $ioMaskEditorVisible && lastSavedTags) {
|
|
||||||
setTags(lastSavedTags);
|
|
||||||
lastSavedTags = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
function wrap(before: string, after: string): void {
|
|
||||||
if (!$focusedInput || !editingInputIsRichText($focusedInput)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$focusedInput.element.then((element) => {
|
|
||||||
wrapInternal(element, before, after, false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.assign(globalThis, {
|
|
||||||
saveSession,
|
|
||||||
setFields,
|
|
||||||
setCollapsed,
|
|
||||||
setClozeFields,
|
|
||||||
setPlainTexts,
|
|
||||||
setDescriptions,
|
|
||||||
setFonts,
|
|
||||||
focusField,
|
|
||||||
setTags,
|
|
||||||
setTagsCollapsed,
|
|
||||||
setBackgrounds,
|
|
||||||
setClozeHint,
|
|
||||||
saveNow,
|
|
||||||
focusIfField,
|
|
||||||
getNoteId,
|
|
||||||
setNoteId,
|
|
||||||
setNotetypeMeta,
|
|
||||||
wrap,
|
|
||||||
setMathjaxEnabled,
|
|
||||||
setShrinkImages,
|
|
||||||
setCloseHTMLTags,
|
|
||||||
triggerChanges,
|
|
||||||
setIsImageOcclusion,
|
|
||||||
setupMaskEditor,
|
|
||||||
saveOcclusions,
|
|
||||||
...oldEditorAdapter,
|
|
||||||
});
|
|
||||||
|
|
||||||
editorState = getEditorState(
|
|
||||||
$ioMaskEditorVisible,
|
|
||||||
isImageOcclusion,
|
|
||||||
isIOImageLoaded,
|
|
||||||
imageOcclusionMode,
|
|
||||||
);
|
|
||||||
|
|
||||||
document.addEventListener("visibilitychange", saveOnPageHide);
|
|
||||||
return () => document.removeEventListener("visibilitychange", saveOnPageHide);
|
|
||||||
});
|
|
||||||
|
|
||||||
let apiPartial: Partial<NoteEditorAPI> = {};
|
|
||||||
export { apiPartial as api };
|
|
||||||
|
|
||||||
const hoveredField: NoteEditorAPI["hoveredField"] = writable(null);
|
|
||||||
const focusedField: NoteEditorAPI["focusedField"] = writable(null);
|
|
||||||
const focusedInput: NoteEditorAPI["focusedInput"] = writable(null);
|
|
||||||
|
|
||||||
const api: NoteEditorAPI = {
|
|
||||||
...apiPartial,
|
|
||||||
hoveredField,
|
|
||||||
focusedField,
|
|
||||||
focusedInput,
|
|
||||||
toolbar: toolbar as EditorToolbarAPI,
|
|
||||||
fields,
|
|
||||||
};
|
|
||||||
|
|
||||||
setContextProperty(api);
|
|
||||||
setupLifecycleHooks(api);
|
|
||||||
|
|
||||||
$: tagAmount = $tags.length;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
@component
|
|
||||||
Serves as a pre-slotted convenience component which combines all the common
|
|
||||||
components and functionality for general note editing.
|
|
||||||
|
|
||||||
Functionality exclusive to specific note-editing views (e.g. in the browser or
|
|
||||||
the AddCards dialog) should be implemented in the user of this component.
|
|
||||||
-->
|
|
||||||
<div class="note-editor">
|
|
||||||
<EditorToolbar {size} {wrap} api={toolbar}>
|
|
||||||
<slot slot="notetypeButtons" name="notetypeButtons" />
|
|
||||||
</EditorToolbar>
|
|
||||||
|
|
||||||
{#if hint}
|
|
||||||
<Absolute bottom right --margin="10px">
|
|
||||||
<Notification>
|
|
||||||
<Badge --badge-color="tomato" --icon-align="top">
|
|
||||||
<Icon icon={alertIcon} />
|
|
||||||
</Badge>
|
|
||||||
<span>{@html hint}</span>
|
|
||||||
</Notification>
|
|
||||||
</Absolute>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if imageOcclusionMode && ($ioMaskEditorVisible || imageOcclusionMode?.kind === "add")}
|
|
||||||
<div style="display: {$ioMaskEditorVisible ? 'block' : 'none'};">
|
|
||||||
<ImageOcclusionPage
|
|
||||||
mode={imageOcclusionMode}
|
|
||||||
on:save={saveOcclusions}
|
|
||||||
on:image-loaded={onImageLoaded}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if $ioMaskEditorVisible && isImageOcclusion && !isIOImageLoaded}
|
|
||||||
<ImageOcclusionPicker
|
|
||||||
onPickImage={pickIOImage}
|
|
||||||
onPickImageFromClipboard={pickIOImageFromClipboard}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if !$ioMaskEditorVisible}
|
|
||||||
<Fields>
|
|
||||||
{#each fieldsData as field, index}
|
|
||||||
{@const content = fieldStores[index]}
|
|
||||||
|
|
||||||
<EditorField
|
|
||||||
{field}
|
|
||||||
{content}
|
|
||||||
{index}
|
|
||||||
flipInputs={plainTextDefaults[index]}
|
|
||||||
api={fields[index]}
|
|
||||||
on:focusin={() => {
|
|
||||||
$focusedField = fields[index];
|
|
||||||
setAddonButtonsDisabled(false);
|
|
||||||
bridgeCommand(`focus:${index}`);
|
|
||||||
}}
|
|
||||||
on:focusout={() => {
|
|
||||||
$focusedField = null;
|
|
||||||
setAddonButtonsDisabled(true);
|
|
||||||
bridgeCommand(
|
|
||||||
`blur:${index}:${getNoteId()}:${transformContentBeforeSave(
|
|
||||||
get(content),
|
|
||||||
)}`,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
on:mouseenter={() => {
|
|
||||||
$hoveredField = fields[index];
|
|
||||||
}}
|
|
||||||
on:mouseleave={() => {
|
|
||||||
$hoveredField = null;
|
|
||||||
}}
|
|
||||||
collapsed={fieldsCollapsed[index]}
|
|
||||||
dupe={cols[index] === "dupe"}
|
|
||||||
--description-font-size="{field.fontSize}px"
|
|
||||||
--description-content={`"${field.description}"`}
|
|
||||||
>
|
|
||||||
<svelte:fragment slot="field-label">
|
|
||||||
<LabelContainer
|
|
||||||
collapsed={fieldsCollapsed[index]}
|
|
||||||
on:toggle={() => toggleField(index)}
|
|
||||||
--icon-align="bottom"
|
|
||||||
>
|
|
||||||
<svelte:fragment slot="field-name">
|
|
||||||
<LabelName>
|
|
||||||
{field.name}
|
|
||||||
</LabelName>
|
|
||||||
</svelte:fragment>
|
|
||||||
<FieldState>
|
|
||||||
{#if cols[index] === "dupe"}
|
|
||||||
<DuplicateLink />
|
|
||||||
{/if}
|
|
||||||
<slot
|
|
||||||
name="field-state"
|
|
||||||
{field}
|
|
||||||
{index}
|
|
||||||
show={fields[index] === $hoveredField ||
|
|
||||||
fields[index] === $focusedField}
|
|
||||||
/>
|
|
||||||
{#if plainTextDefaults[index]}
|
|
||||||
<RichTextBadge
|
|
||||||
show={!fieldsCollapsed[index] &&
|
|
||||||
(fields[index] === $hoveredField ||
|
|
||||||
fields[index] === $focusedField)}
|
|
||||||
bind:off={richTextsHidden[index]}
|
|
||||||
on:toggle={() => toggleRichTextInput(index)}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<PlainTextBadge
|
|
||||||
show={!fieldsCollapsed[index] &&
|
|
||||||
(fields[index] === $hoveredField ||
|
|
||||||
fields[index] === $focusedField)}
|
|
||||||
bind:off={plainTextsHidden[index]}
|
|
||||||
on:toggle={() => togglePlainTextInput(index)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</FieldState>
|
|
||||||
</LabelContainer>
|
|
||||||
</svelte:fragment>
|
|
||||||
<svelte:fragment slot="rich-text-input">
|
|
||||||
<Collapsible
|
|
||||||
collapse={richTextsHidden[index]}
|
|
||||||
let:collapsed={hidden}
|
|
||||||
toggleDisplay
|
|
||||||
>
|
|
||||||
<RichTextInput
|
|
||||||
{hidden}
|
|
||||||
on:focusout={() => {
|
|
||||||
saveFieldNow();
|
|
||||||
$focusedInput = null;
|
|
||||||
}}
|
|
||||||
bind:this={richTextInputs[index]}
|
|
||||||
isClozeField={field.isClozeField}
|
|
||||||
/>
|
|
||||||
</Collapsible>
|
|
||||||
</svelte:fragment>
|
|
||||||
<svelte:fragment slot="plain-text-input">
|
|
||||||
<Collapsible
|
|
||||||
collapse={plainTextsHidden[index]}
|
|
||||||
let:collapsed={hidden}
|
|
||||||
toggleDisplay
|
|
||||||
>
|
|
||||||
<PlainTextInput
|
|
||||||
{hidden}
|
|
||||||
fieldCollapsed={fieldsCollapsed[index]}
|
|
||||||
on:focusout={() => {
|
|
||||||
saveFieldNow();
|
|
||||||
$focusedInput = null;
|
|
||||||
}}
|
|
||||||
bind:this={plainTextInputs[index]}
|
|
||||||
/>
|
|
||||||
</Collapsible>
|
|
||||||
</svelte:fragment>
|
|
||||||
</EditorField>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<MathjaxOverlay />
|
|
||||||
<ImageOverlay maxWidth={250} maxHeight={125} />
|
|
||||||
</Fields>
|
|
||||||
|
|
||||||
<Shortcut
|
|
||||||
keyCombination="Control+Shift+T"
|
|
||||||
on:action={() => {
|
|
||||||
updateTagsCollapsed(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<CollapseLabel
|
|
||||||
collapsed={$tagsCollapsed}
|
|
||||||
tooltip={$tagsCollapsed ? tr.editingExpand() : tr.editingCollapse()}
|
|
||||||
on:toggle={() => updateTagsCollapsed(!$tagsCollapsed)}
|
|
||||||
>
|
|
||||||
{@html `${tagAmount > 0 ? tagAmount : ""} ${tr.editingTags()}`}
|
|
||||||
</CollapseLabel>
|
|
||||||
<Collapsible toggleDisplay collapse={$tagsCollapsed}>
|
|
||||||
<TagEditor {tags} on:tagsupdate={saveTags} />
|
|
||||||
</Collapsible>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.note-editor {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.image-occlusion) {
|
|
||||||
position: fixed;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.image-occlusion .tab-buttons) {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.image-occlusion .top-tool-bar-container) {
|
|
||||||
margin-left: 28px !important;
|
|
||||||
}
|
|
||||||
:global(.top-tool-bar-container .icon-button) {
|
|
||||||
height: 36px !important;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
:global(.image-occlusion .tool-bar-container) {
|
|
||||||
top: unset !important;
|
|
||||||
margin-top: 2px !important;
|
|
||||||
}
|
|
||||||
:global(.image-occlusion .sticky-footer) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
<!--
|
|
||||||
Copyright: Ankitects Pty Ltd and contributors
|
|
||||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import type { NoteEditorAPI } from "./NoteEditor.svelte";
|
|
||||||
import NoteEditor from "./NoteEditor.svelte";
|
|
||||||
|
|
||||||
const api: Partial<NoteEditorAPI> = {};
|
|
||||||
let noteEditor: NoteEditor;
|
|
||||||
|
|
||||||
export let uiResolve: (api: NoteEditorAPI) => void;
|
|
||||||
|
|
||||||
$: if (noteEditor) {
|
|
||||||
uiResolve(api as NoteEditorAPI);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<NoteEditor bind:this={noteEditor} {api} />
|
|
||||||
63
ts/lib/components/DeckChooser.svelte
Normal file
63
ts/lib/components/DeckChooser.svelte
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { mdiBookOutline } from "./icons";
|
||||||
|
import { getDeck, getDeckNames } from "@generated/backend";
|
||||||
|
import ItemChooser from "./ItemChooser.svelte";
|
||||||
|
import type { DeckNameId } from "@generated/anki/decks_pb";
|
||||||
|
import * as tr from "@generated/ftl";
|
||||||
|
import { registerOperationHandler } from "@tslib/operations";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onChange?: (deck: DeckNameId) => void;
|
||||||
|
}
|
||||||
|
let { onChange }: Props = $props();
|
||||||
|
let selectedDeck: DeckNameId | null = $state(null);
|
||||||
|
let decks: DeckNameId[] = $state([]);
|
||||||
|
let itemChooser: ItemChooser<DeckNameId> | null = $state(null);
|
||||||
|
|
||||||
|
async function fetchDecks(skipEmptyDefault: boolean = true) {
|
||||||
|
decks = (await getDeckNames({ skipEmptyDefault, includeFiltered: false }))
|
||||||
|
.entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function select(notetypeId: bigint) {
|
||||||
|
itemChooser?.select(notetypeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSelected(): Promise<DeckNameId> {
|
||||||
|
await fetchDecks(false);
|
||||||
|
try {
|
||||||
|
await getDeck({ did: selectedDeck!.id }, { alertOnError: false });
|
||||||
|
} catch (error) {
|
||||||
|
select(1n);
|
||||||
|
}
|
||||||
|
return selectedDeck!;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
registerOperationHandler((changes) => {
|
||||||
|
if (changes.deck) {
|
||||||
|
getSelected();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
fetchDecks();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ItemChooser
|
||||||
|
bind:this={itemChooser}
|
||||||
|
title={tr.qtMiscChooseDeck()}
|
||||||
|
bind:selectedItem={selectedDeck}
|
||||||
|
{onChange}
|
||||||
|
items={decks}
|
||||||
|
icon={mdiBookOutline}
|
||||||
|
keyCombination="Control+D"
|
||||||
|
tooltip={tr.qtMiscTargetDeckCtrlandd()}
|
||||||
|
/>
|
||||||
|
|
@ -6,16 +6,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
import * as tr from "@generated/ftl";
|
import * as tr from "@generated/ftl";
|
||||||
import { renderMarkdown } from "@tslib/helpers";
|
import { renderMarkdown } from "@tslib/helpers";
|
||||||
import Carousel from "bootstrap/js/dist/carousel";
|
import Carousel from "bootstrap/js/dist/carousel";
|
||||||
import Modal from "bootstrap/js/dist/modal";
|
import { createEventDispatcher, onMount } from "svelte";
|
||||||
import { createEventDispatcher, getContext, onDestroy, onMount } from "svelte";
|
import Modal from "./Modal.svelte";
|
||||||
|
|
||||||
import { infoCircle } from "$lib/components/icons";
|
import { infoCircle } from "$lib/components/icons";
|
||||||
import { registerModalClosingHandler } from "$lib/sveltelib/modal-closing";
|
|
||||||
import { pageTheme } from "$lib/sveltelib/theme";
|
import { pageTheme } from "$lib/sveltelib/theme";
|
||||||
|
|
||||||
import Badge from "./Badge.svelte";
|
import Badge from "./Badge.svelte";
|
||||||
import Col from "./Col.svelte";
|
import Col from "./Col.svelte";
|
||||||
import { modalsKey } from "./context-keys";
|
|
||||||
import HelpSection from "./HelpSection.svelte";
|
import HelpSection from "./HelpSection.svelte";
|
||||||
import Icon from "./Icon.svelte";
|
import Icon from "./Icon.svelte";
|
||||||
import Row from "./Row.svelte";
|
import Row from "./Row.svelte";
|
||||||
|
|
@ -28,50 +26,20 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
export let helpSections: HelpItem[];
|
export let helpSections: HelpItem[];
|
||||||
export let fsrs = false;
|
export let fsrs = false;
|
||||||
|
|
||||||
export const modalKey: string = Math.random().toString(36).substring(2);
|
|
||||||
|
|
||||||
const modals = getContext<Map<string, Modal>>(modalsKey);
|
|
||||||
|
|
||||||
let modal: Modal;
|
|
||||||
let carousel: Carousel;
|
let carousel: Carousel;
|
||||||
|
|
||||||
let modalRef: HTMLDivElement;
|
let modal: Modal;
|
||||||
let carouselRef: HTMLDivElement;
|
let carouselRef: HTMLDivElement;
|
||||||
|
|
||||||
function onOkClicked(): void {
|
|
||||||
modal.hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
const { set: setModalOpen, remove: removeModalClosingHandler } =
|
|
||||||
registerModalClosingHandler(onOkClicked);
|
|
||||||
|
|
||||||
function onShown() {
|
|
||||||
setModalOpen(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onHidden() {
|
|
||||||
setModalOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
modalRef.addEventListener("shown.bs.modal", onShown);
|
|
||||||
modalRef.addEventListener("hidden.bs.modal", onHidden);
|
|
||||||
modal = new Modal(modalRef, { keyboard: false });
|
|
||||||
carousel = new Carousel(carouselRef, { interval: false, ride: false });
|
carousel = new Carousel(carouselRef, { interval: false, ride: false });
|
||||||
/* Bootstrap's Carousel.Event interface doesn't seem to work as a type here */
|
/* Bootstrap's Carousel.Event interface doesn't seem to work as a type here */
|
||||||
carouselRef.addEventListener("slide.bs.carousel", (e: any) => {
|
carouselRef.addEventListener("slide.bs.carousel", (e: any) => {
|
||||||
activeIndex = e.to;
|
activeIndex = e.to;
|
||||||
});
|
});
|
||||||
dispatch("mount", { modal: modal, carousel: carousel });
|
dispatch("mount", { modal: modal, carousel: carousel });
|
||||||
modals.set(modalKey, modal);
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
removeModalClosingHandler();
|
|
||||||
modalRef.removeEventListener("shown.bs.modal", onShown);
|
|
||||||
modalRef.removeEventListener("hidden.bs.modal", onHidden);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let activeIndex = startIndex;
|
let activeIndex = startIndex;
|
||||||
|
|
@ -81,16 +49,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
<Icon icon={infoCircle} />
|
<Icon icon={infoCircle} />
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
<div
|
<Modal bind:this={modal} dialogClass="modal-lg">
|
||||||
bind:this={modalRef}
|
<div slot="header" class="modal-header">
|
||||||
class="modal fade"
|
|
||||||
tabindex="-1"
|
|
||||||
aria-labelledby="modalLabel"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<div class="modal-dialog modal-lg">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<div style="display: flex;">
|
<div style="display: flex;">
|
||||||
<h1 class="modal-title" id="modalLabel">
|
<h1 class="modal-title" id="modalLabel">
|
||||||
{title}
|
{title}
|
||||||
|
|
@ -113,7 +73,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div slot="body" class="modal-body">
|
||||||
<Row --cols={4}>
|
<Row --cols={4}>
|
||||||
<Col --col-size={1}>
|
<Col --col-size={1}>
|
||||||
<nav>
|
<nav>
|
||||||
|
|
@ -128,10 +88,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
}}
|
}}
|
||||||
class:active={i == activeIndex}
|
class:active={i == activeIndex}
|
||||||
class:d-none={fsrs
|
class:d-none={fsrs
|
||||||
? item.sched ===
|
? item.sched === HelpItemScheduler.SM2
|
||||||
HelpItemScheduler.SM2
|
: item.sched == HelpItemScheduler.FSRS}
|
||||||
: item.sched ==
|
|
||||||
HelpItemScheduler.FSRS}
|
|
||||||
>
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -164,32 +122,23 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div slot="footer" class="modal-footer">
|
||||||
<button type="button" class="btn btn-primary" on:click={onOkClicked}>
|
<button type="button" class="btn btn-primary" on:click={modal.onOkClicked}>
|
||||||
{tr.helpOk()}
|
{tr.helpOk()}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
#nav {
|
#nav {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
|
||||||
z-index: 1066;
|
|
||||||
background-color: rgba($color: black, $alpha: 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-title {
|
.modal-title {
|
||||||
margin-inline-end: 0.75rem;
|
margin-inline-end: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content {
|
:global(.modal-content) {
|
||||||
background-color: var(--canvas);
|
|
||||||
color: var(--fg);
|
|
||||||
border-radius: var(--border-radius-medium, 10px);
|
border-radius: var(--border-radius-medium, 10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
279
ts/lib/components/ItemChooser.svelte
Normal file
279
ts/lib/components/ItemChooser.svelte
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
-->
|
||||||
|
<script lang="ts" generics="Item extends { id: bigint, name: string }">
|
||||||
|
import { magnifyIcon, mdiClose } from "./icons";
|
||||||
|
import Icon from "./Icon.svelte";
|
||||||
|
import IconConstrain from "./IconConstrain.svelte";
|
||||||
|
import LabelButton from "./LabelButton.svelte";
|
||||||
|
import Modal from "./Modal.svelte";
|
||||||
|
import type { IconData } from "./types";
|
||||||
|
import * as tr from "@generated/ftl";
|
||||||
|
import Shortcut from "./Shortcut.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
selectedItem?: Item | null;
|
||||||
|
items: Item[];
|
||||||
|
icon: IconData;
|
||||||
|
keyCombination: string;
|
||||||
|
tooltip: string;
|
||||||
|
onChange?: (item: Item) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
onChange,
|
||||||
|
icon,
|
||||||
|
items,
|
||||||
|
selectedItem = $bindable(null),
|
||||||
|
keyCombination,
|
||||||
|
tooltip,
|
||||||
|
}: Props = $props();
|
||||||
|
let modal: Modal | null = $state(null);
|
||||||
|
let searchQuery = $state("");
|
||||||
|
let searchInput: HTMLInputElement | null = $state(null);
|
||||||
|
|
||||||
|
const filteredItems = $derived(
|
||||||
|
searchQuery.trim() === ""
|
||||||
|
? items
|
||||||
|
: items.filter((item) =>
|
||||||
|
item.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const filteredElements: HTMLButtonElement[] = $state([]);
|
||||||
|
|
||||||
|
function onSelect(item: Item) {
|
||||||
|
selectedItem = item;
|
||||||
|
onChange?.(item);
|
||||||
|
modal?.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModal() {
|
||||||
|
searchQuery = "";
|
||||||
|
modal?.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleModal() {
|
||||||
|
modal?.toggle();
|
||||||
|
searchQuery = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function select(itemId: bigint) {
|
||||||
|
if (selectedItem?.id === itemId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const item = items.find((item) => item.id === itemId);
|
||||||
|
if (item) {
|
||||||
|
selectedItem = item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onShown() {
|
||||||
|
searchInput?.focus();
|
||||||
|
for (const element of filteredElements) {
|
||||||
|
if (element.classList.contains("selected")) {
|
||||||
|
element.scrollIntoView({ block: "start" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!selectedItem && items.length > 0) {
|
||||||
|
selectedItem = items[0];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<LabelButton {tooltip} on:click={openModal} class="chooser-button">
|
||||||
|
{selectedItem?.name ?? "…"}
|
||||||
|
</LabelButton>
|
||||||
|
|
||||||
|
<Shortcut {keyCombination} on:action={toggleModal} />
|
||||||
|
<Modal bind:this={modal} {onShown} dialogClass="modal-lg">
|
||||||
|
<div slot="header" class="modal-header">
|
||||||
|
<IconConstrain iconSize={90}>
|
||||||
|
<Icon {icon} />
|
||||||
|
</IconConstrain>
|
||||||
|
<h5 class="modal-title">{title}</h5>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-close"
|
||||||
|
data-bs-dismiss="modal"
|
||||||
|
aria-label="Close"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div slot="body" class="modal-body">
|
||||||
|
<div class="search-container">
|
||||||
|
<div class="search-input-wrapper">
|
||||||
|
<div class="search-icon">
|
||||||
|
<IconConstrain iconSize={70}>
|
||||||
|
<Icon icon={magnifyIcon} />
|
||||||
|
</IconConstrain>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="search-input"
|
||||||
|
placeholder={tr.actionsSearch()}
|
||||||
|
bind:value={searchQuery}
|
||||||
|
bind:this={searchInput}
|
||||||
|
/>
|
||||||
|
{#if searchQuery}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="clear-search"
|
||||||
|
onclick={() => (searchQuery = "")}
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<IconConstrain iconSize={60}>
|
||||||
|
<Icon icon={mdiClose} />
|
||||||
|
</IconConstrain>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="item-grid">
|
||||||
|
{#each filteredItems as item, i (item.id)}
|
||||||
|
<button
|
||||||
|
bind:this={filteredElements[i]}
|
||||||
|
class="item-card"
|
||||||
|
class:selected={selectedItem?.id === item.id}
|
||||||
|
onclick={() => onSelect(item)}
|
||||||
|
aria-label="Select {item.name}"
|
||||||
|
>
|
||||||
|
<h6 class="item-title">{item.name}</h6>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use "../sass/button-mixins" as button;
|
||||||
|
|
||||||
|
:global(.label-button.chooser-button) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container {
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--canvas);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
color: var(--fg-subtle);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5rem 0.75rem 0.5rem 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--fg);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--fg-subtle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-search {
|
||||||
|
padding: 0.25rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--fg-subtle);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--canvas-inset);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
padding-inline-end: 0.5em;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.item-card) {
|
||||||
|
@include button.base(
|
||||||
|
$border: true,
|
||||||
|
$with-hover: true,
|
||||||
|
$with-active: true,
|
||||||
|
$with-disabled: false
|
||||||
|
);
|
||||||
|
@include button.border-radius;
|
||||||
|
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: start;
|
||||||
|
background: var(--canvas-elevated);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--canvas-inset);
|
||||||
|
border-color: var(--border);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
border-color: var(--border-focus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-title {
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: inherit;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 80vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
background: var(--canvas-elevated);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
102
ts/lib/components/Modal.svelte
Normal file
102
ts/lib/components/Modal.svelte
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import Modal from "bootstrap/js/dist/modal";
|
||||||
|
import { getContext, onDestroy, onMount } from "svelte";
|
||||||
|
|
||||||
|
import { registerModalClosingHandler } from "$lib/sveltelib/modal-closing";
|
||||||
|
|
||||||
|
import { modalsKey } from "./context-keys";
|
||||||
|
|
||||||
|
export let modalKey: string = Math.random().toString(36).substring(2);
|
||||||
|
export let dialogClass: string = "";
|
||||||
|
export let onOkClicked: (() => void) | undefined = undefined;
|
||||||
|
export let onCancelClicked: (() => void) | undefined = undefined;
|
||||||
|
export let onShown: (() => void) | undefined = undefined;
|
||||||
|
export let onHidden: (() => void) | undefined = undefined;
|
||||||
|
|
||||||
|
const modals = getContext<Map<string, Modal>>(modalsKey);
|
||||||
|
|
||||||
|
let modal: Modal;
|
||||||
|
let modalRef: HTMLDivElement;
|
||||||
|
|
||||||
|
function onOkClicked_(): void {
|
||||||
|
modal.hide();
|
||||||
|
onOkClicked?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCancelClicked_(): void {
|
||||||
|
modal.hide();
|
||||||
|
onCancelClicked?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function show(): void {
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hide(): void {
|
||||||
|
modal.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggle(): void {
|
||||||
|
modal.toggle();
|
||||||
|
}
|
||||||
|
|
||||||
|
export { onOkClicked_ as acceptHandler, onCancelClicked_ as cancelHandler };
|
||||||
|
|
||||||
|
const { set: setModalOpen, remove: removeModalClosingHandler } =
|
||||||
|
registerModalClosingHandler(onOkClicked_);
|
||||||
|
|
||||||
|
function onShown_() {
|
||||||
|
setModalOpen(true);
|
||||||
|
onShown?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHidden_() {
|
||||||
|
setModalOpen(false);
|
||||||
|
onHidden?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
modalRef.addEventListener("shown.bs.modal", onShown_);
|
||||||
|
modalRef.addEventListener("hidden.bs.modal", onHidden_);
|
||||||
|
modal = new Modal(modalRef, { keyboard: false });
|
||||||
|
modals.set(modalKey, modal);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
removeModalClosingHandler();
|
||||||
|
modalRef.removeEventListener("shown.bs.modal", onShown_);
|
||||||
|
modalRef.removeEventListener("hidden.bs.modal", onHidden_);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={modalRef}
|
||||||
|
class="modal fade"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-labelledby="modalLabel"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div class="modal-dialog {dialogClass}">
|
||||||
|
<div class="modal-content">
|
||||||
|
<slot name="header" />
|
||||||
|
<slot name="body" />
|
||||||
|
<slot name="footer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.modal {
|
||||||
|
z-index: 1066;
|
||||||
|
background-color: rgba($color: black, $alpha: 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: var(--canvas);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
63
ts/lib/components/NotetypeChooser.svelte
Normal file
63
ts/lib/components/NotetypeChooser.svelte
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import type { NotetypeNameId } from "@generated/anki/notetypes_pb";
|
||||||
|
|
||||||
|
import { mdiNewspaper } from "./icons";
|
||||||
|
import { getNotetype, getNotetypeNames } from "@generated/backend";
|
||||||
|
import ItemChooser from "./ItemChooser.svelte";
|
||||||
|
import * as tr from "@generated/ftl";
|
||||||
|
import { registerOperationHandler } from "@tslib/operations";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onChange?: (notetype: NotetypeNameId) => void;
|
||||||
|
}
|
||||||
|
let { onChange }: Props = $props();
|
||||||
|
let selectedNotetype: NotetypeNameId | null = $state(null);
|
||||||
|
let notetypes: NotetypeNameId[] = $state([]);
|
||||||
|
let itemChooser: ItemChooser<NotetypeNameId> | null = $state(null);
|
||||||
|
|
||||||
|
async function fetchNotetypes() {
|
||||||
|
notetypes = (await getNotetypeNames({})).entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function select(notetypeId: bigint) {
|
||||||
|
itemChooser?.select(notetypeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSelected(): Promise<NotetypeNameId> {
|
||||||
|
await fetchNotetypes();
|
||||||
|
try {
|
||||||
|
await getNotetype({ ntid: selectedNotetype!.id }, { alertOnError: false });
|
||||||
|
} catch (error) {
|
||||||
|
select(notetypes[0].id);
|
||||||
|
}
|
||||||
|
return selectedNotetype!;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
registerOperationHandler((changes) => {
|
||||||
|
if (changes.notetype) {
|
||||||
|
getSelected();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
fetchNotetypes();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ItemChooser
|
||||||
|
bind:this={itemChooser}
|
||||||
|
title={tr.qtMiscChooseNoteType()}
|
||||||
|
bind:selectedItem={selectedNotetype}
|
||||||
|
{onChange}
|
||||||
|
items={notetypes}
|
||||||
|
icon={mdiNewspaper}
|
||||||
|
keyCombination="Control+N"
|
||||||
|
tooltip={tr.qtMiscChangeNoteTypeCtrlandn()}
|
||||||
|
/>
|
||||||
|
|
@ -15,6 +15,8 @@ import AlignVerticalCenter_ from "@mdi/svg/svg/align-vertical-center.svg?compone
|
||||||
import alignVerticalCenter_ from "@mdi/svg/svg/align-vertical-center.svg?url";
|
import alignVerticalCenter_ from "@mdi/svg/svg/align-vertical-center.svg?url";
|
||||||
import AlignVerticalTop_ from "@mdi/svg/svg/align-vertical-top.svg?component";
|
import AlignVerticalTop_ from "@mdi/svg/svg/align-vertical-top.svg?component";
|
||||||
import alignVerticalTop_ from "@mdi/svg/svg/align-vertical-top.svg?url";
|
import alignVerticalTop_ from "@mdi/svg/svg/align-vertical-top.svg?url";
|
||||||
|
import BookOutline_ from "@mdi/svg/svg/book-outline.svg?component";
|
||||||
|
import bookOutline_ from "@mdi/svg/svg/book-outline.svg?url";
|
||||||
import CheckCircle_ from "@mdi/svg/svg/check-circle.svg?component";
|
import CheckCircle_ from "@mdi/svg/svg/check-circle.svg?component";
|
||||||
import checkCircle_ from "@mdi/svg/svg/check-circle.svg?url";
|
import checkCircle_ from "@mdi/svg/svg/check-circle.svg?url";
|
||||||
import ChevronDown_ from "@mdi/svg/svg/chevron-down.svg?component";
|
import ChevronDown_ from "@mdi/svg/svg/chevron-down.svg?component";
|
||||||
|
|
@ -111,6 +113,8 @@ import Math_ from "@mdi/svg/svg/math-integral-box.svg?component";
|
||||||
import math_ from "@mdi/svg/svg/math-integral-box.svg?url";
|
import math_ from "@mdi/svg/svg/math-integral-box.svg?url";
|
||||||
import NewBox_ from "@mdi/svg/svg/new-box.svg?component";
|
import NewBox_ from "@mdi/svg/svg/new-box.svg?component";
|
||||||
import newBox_ from "@mdi/svg/svg/new-box.svg?url";
|
import newBox_ from "@mdi/svg/svg/new-box.svg?url";
|
||||||
|
import Newspaper_ from "@mdi/svg/svg/newspaper.svg?component";
|
||||||
|
import newspaper_ from "@mdi/svg/svg/newspaper.svg?url";
|
||||||
import Paperclip_ from "@mdi/svg/svg/paperclip.svg?component";
|
import Paperclip_ from "@mdi/svg/svg/paperclip.svg?component";
|
||||||
import paperclip_ from "@mdi/svg/svg/paperclip.svg?url";
|
import paperclip_ from "@mdi/svg/svg/paperclip.svg?url";
|
||||||
import RectangleOutline_ from "@mdi/svg/svg/rectangle-outline.svg?component";
|
import RectangleOutline_ from "@mdi/svg/svg/rectangle-outline.svg?component";
|
||||||
|
|
@ -149,6 +153,8 @@ import ArrowLeft_ from "bootstrap-icons/icons/arrow-left.svg?component";
|
||||||
import arrowLeft_ from "bootstrap-icons/icons/arrow-left.svg?url";
|
import arrowLeft_ from "bootstrap-icons/icons/arrow-left.svg?url";
|
||||||
import ArrowRight_ from "bootstrap-icons/icons/arrow-right.svg?component";
|
import ArrowRight_ from "bootstrap-icons/icons/arrow-right.svg?component";
|
||||||
import arrowRight_ from "bootstrap-icons/icons/arrow-right.svg?url";
|
import arrowRight_ from "bootstrap-icons/icons/arrow-right.svg?url";
|
||||||
|
import CaretDownFill_ from "bootstrap-icons/icons/caret-down-fill.svg?component";
|
||||||
|
import caretDownFill_ from "bootstrap-icons/icons/caret-down-fill.svg?url";
|
||||||
import Minus_ from "bootstrap-icons/icons/dash-lg.svg?component";
|
import Minus_ from "bootstrap-icons/icons/dash-lg.svg?component";
|
||||||
import minus_ from "bootstrap-icons/icons/dash-lg.svg?url";
|
import minus_ from "bootstrap-icons/icons/dash-lg.svg?url";
|
||||||
import Eraser_ from "bootstrap-icons/icons/eraser.svg?component";
|
import Eraser_ from "bootstrap-icons/icons/eraser.svg?component";
|
||||||
|
|
@ -285,3 +291,6 @@ export const mdiUngroup = { url: ungroup_, component: Ungroup_ };
|
||||||
export const mdiVectorPolygonVariant = { url: vectorPolygonVariant_, component: VectorPolygonVariant_ };
|
export const mdiVectorPolygonVariant = { url: vectorPolygonVariant_, component: VectorPolygonVariant_ };
|
||||||
export const incrementClozeIcon = { url: incrementCloze_, component: IncrementCloze_ };
|
export const incrementClozeIcon = { url: incrementCloze_, component: IncrementCloze_ };
|
||||||
export const mdiEarth = { url: earth_, component: Earth_ };
|
export const mdiEarth = { url: earth_, component: Earth_ };
|
||||||
|
export const caretDownFill = { url: caretDownFill_, component: CaretDownFill_ };
|
||||||
|
export const mdiNewspaper = { url: newspaper_, component: Newspaper_ };
|
||||||
|
export const mdiBookOutline = { url: bookOutline_, component: BookOutline_ };
|
||||||
|
|
|
||||||
82
ts/lib/context-menu/ContextMenu.svelte
Normal file
82
ts/lib/context-menu/ContextMenu.svelte
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
<!-- Copyright: Ankitects Pty Ltd and contributors -->
|
||||||
|
<!-- License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -->
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { tick } from "svelte";
|
||||||
|
import type { ContextMenuMouseEvent } from "./types";
|
||||||
|
|
||||||
|
let visible = $state(false);
|
||||||
|
let x = $state(0);
|
||||||
|
let y = $state(0);
|
||||||
|
let contextMenuElement = $state<HTMLDivElement>();
|
||||||
|
|
||||||
|
const { children } = $props();
|
||||||
|
|
||||||
|
export async function show(event: ContextMenuMouseEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
x = event.clientX;
|
||||||
|
y = event.clientY;
|
||||||
|
visible = true;
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
const rect = contextMenuElement!.getBoundingClientRect();
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
|
if (x + rect.width > viewportWidth) {
|
||||||
|
x = viewportWidth - rect.width;
|
||||||
|
}
|
||||||
|
if (y + rect.height > viewportHeight) {
|
||||||
|
y = viewportHeight - rect.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
x = Math.max(0, x);
|
||||||
|
y = Math.max(0, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (
|
||||||
|
visible &&
|
||||||
|
contextMenuElement &&
|
||||||
|
!contextMenuElement.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:document on:click={handleClickOutside} />
|
||||||
|
|
||||||
|
{#if visible}
|
||||||
|
<div
|
||||||
|
bind:this={contextMenuElement}
|
||||||
|
class="context-menu"
|
||||||
|
style="left: {x}px; top: {y}px;"
|
||||||
|
role="menu"
|
||||||
|
tabindex="0"
|
||||||
|
onclick={hide}
|
||||||
|
onkeydown={hide}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.context-menu {
|
||||||
|
position: fixed;
|
||||||
|
background: var(--canvas);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
padding: 4px 0;
|
||||||
|
min-width: 120px;
|
||||||
|
z-index: 1000;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
33
ts/lib/context-menu/Item.svelte
Normal file
33
ts/lib/context-menu/Item.svelte
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<!-- Copyright: Ankitects Pty Ltd and contributors -->
|
||||||
|
<!-- License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -->
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
const { click, children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="context-menu-item"
|
||||||
|
onclick={click}
|
||||||
|
onkeydown={click}
|
||||||
|
role="menuitem"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.context-menu-item {
|
||||||
|
padding: 8px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--highlight-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background-color: var(--highlight-bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
6
ts/lib/context-menu/index.ts
Normal file
6
ts/lib/context-menu/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
export { default as ContextMenu } from "./ContextMenu.svelte";
|
||||||
|
export { default as Item } from "./Item.svelte";
|
||||||
|
export type { ContextMenuAPI, ContextMenuMouseEvent } from "./types";
|
||||||
12
ts/lib/context-menu/types.ts
Normal file
12
ts/lib/context-menu/types.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
export interface ContextMenuMouseEvent {
|
||||||
|
clientX: number;
|
||||||
|
clientY: number;
|
||||||
|
preventDefault(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContextMenuAPI {
|
||||||
|
show(event: ContextMenuMouseEvent): void;
|
||||||
|
}
|
||||||
|
|
@ -57,21 +57,5 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
></anki-editable>
|
></anki-editable>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
anki-editable {
|
@import "./content-editable.scss";
|
||||||
display: block;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
overflow: auto;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
/* fallback for iOS */
|
|
||||||
word-break: break-word;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
min-height: 1.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* editable-base.scss contains styling targeting user HTML */
|
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -47,7 +47,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
const debouncer = new CooldownTimer(500);
|
const debouncer = new CooldownTimer(500);
|
||||||
|
|
||||||
$: debouncer.schedule(() => {
|
$: debouncer.schedule(async () => {
|
||||||
const cache = getCache($pageTheme.isDark, fontSize);
|
const cache = getCache($pageTheme.isDark, fontSize);
|
||||||
const entry = cache.get(mathjax);
|
const entry = cache.get(mathjax);
|
||||||
if (entry) {
|
if (entry) {
|
||||||
|
|
@ -124,24 +124,5 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
:global(anki-mathjax) {
|
@import "./mathjax.scss";
|
||||||
white-space: pre;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
vertical-align: var(--vertical-center);
|
|
||||||
}
|
|
||||||
|
|
||||||
.block {
|
|
||||||
display: block;
|
|
||||||
margin: 1rem auto;
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
vertical-align: text-bottom;
|
|
||||||
|
|
||||||
width: var(--font-size);
|
|
||||||
height: var(--font-size);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -3,13 +3,13 @@
|
||||||
|
|
||||||
export class ChangeTimer {
|
export class ChangeTimer {
|
||||||
private value: number | null = null;
|
private value: number | null = null;
|
||||||
private action: (() => void) | null = null;
|
private action: (() => Promise<void>) | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.fireImmediately = this.fireImmediately.bind(this);
|
this.fireImmediately = this.fireImmediately.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
schedule(action: () => void, delay: number): void {
|
schedule(action: () => Promise<void>, delay: number): void {
|
||||||
this.clear();
|
this.clear();
|
||||||
this.action = action;
|
this.action = action;
|
||||||
this.value = setTimeout(this.fireImmediately, delay) as any;
|
this.value = setTimeout(this.fireImmediately, delay) as any;
|
||||||
|
|
@ -22,9 +22,9 @@ export class ChangeTimer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fireImmediately(): void {
|
async fireImmediately(): Promise<void> {
|
||||||
if (this.action) {
|
if (this.action) {
|
||||||
this.action();
|
await this.action();
|
||||||
this.action = null;
|
this.action = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
15
ts/lib/editable/content-editable.scss
Normal file
15
ts/lib/editable/content-editable.scss
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
anki-editable {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
overflow: auto;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
/* fallback for iOS */
|
||||||
|
word-break: break-word;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
min-height: 1.5em;
|
||||||
|
}
|
||||||
|
|
@ -3,20 +3,19 @@
|
||||||
|
|
||||||
export class CooldownTimer {
|
export class CooldownTimer {
|
||||||
private executing = false;
|
private executing = false;
|
||||||
private queuedAction: (() => void) | null = null;
|
private queuedAction: (() => Promise<void>) | null = null;
|
||||||
private delay: number;
|
private delay: number;
|
||||||
|
|
||||||
constructor(delayMs: number) {
|
constructor(delayMs: number) {
|
||||||
this.delay = delayMs;
|
this.delay = delayMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
schedule(action: () => void): void {
|
schedule(action: () => Promise<void>): void {
|
||||||
if (this.executing) {
|
if (this.executing) {
|
||||||
this.queuedAction = action;
|
this.queuedAction = action;
|
||||||
} else {
|
} else {
|
||||||
this.executing = true;
|
this.executing = true;
|
||||||
action();
|
action().then(() => setTimeout(this.#pop.bind(this), this.delay));
|
||||||
setTimeout(this.#pop.bind(this), this.delay);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
@use "../lib/sass/scrollbar";
|
@use "../sass/scrollbar";
|
||||||
|
|
||||||
* {
|
* {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|
@ -2,6 +2,5 @@
|
||||||
// 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 "./editable-base.scss";
|
import "./editable-base.scss";
|
||||||
/* only imported for the CSS */
|
import "./content-editable.scss";
|
||||||
import "./ContentEditable.svelte";
|
import "./mathjax.scss";
|
||||||
import "./Mathjax.svelte";
|
|
||||||
|
|
@ -22,9 +22,9 @@ function trimBreaks(text: string): string {
|
||||||
.replace(/\n*$/, "");
|
.replace(/\n*$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mathjaxConfig = {
|
export const mathjaxConfig = $state({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
};
|
});
|
||||||
|
|
||||||
interface MathjaxProps {
|
interface MathjaxProps {
|
||||||
mathjax: string;
|
mathjax: string;
|
||||||
15
ts/lib/editable/mathjax.scss
Normal file
15
ts/lib/editable/mathjax.scss
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
img {
|
||||||
|
vertical-align: var(--vertical-center);
|
||||||
|
}
|
||||||
|
|
||||||
|
.block {
|
||||||
|
display: block;
|
||||||
|
margin: 1rem auto;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
width: var(--font-size);
|
||||||
|
height: var(--font-size);
|
||||||
|
}
|
||||||
|
|
@ -11,11 +11,12 @@ export async function postProto<T>(
|
||||||
input: { toBinary(): Uint8Array; getType(): { typeName: string } },
|
input: { toBinary(): Uint8Array; getType(): { typeName: string } },
|
||||||
outputType: { fromBinary(arr: Uint8Array): T },
|
outputType: { fromBinary(arr: Uint8Array): T },
|
||||||
options: PostProtoOptions = {},
|
options: PostProtoOptions = {},
|
||||||
|
opChangesType = 0,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
try {
|
try {
|
||||||
const inputBytes = input.toBinary();
|
const inputBytes = input.toBinary();
|
||||||
const path = `/_anki/${method}`;
|
const path = `/_anki/${method}`;
|
||||||
const outputBytes = await postProtoInner(path, inputBytes);
|
const outputBytes = await postProtoInner(path, inputBytes, opChangesType);
|
||||||
return outputType.fromBinary(outputBytes);
|
return outputType.fromBinary(outputBytes);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const { alertOnError = true } = options;
|
const { alertOnError = true } = options;
|
||||||
|
|
@ -26,12 +27,10 @@ export async function postProto<T>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postProtoInner(url: string, body: Uint8Array): Promise<Uint8Array> {
|
async function postProtoInner(url: string, body: Uint8Array, opChangesType: number): Promise<Uint8Array> {
|
||||||
const result = await fetch(url, {
|
const result = await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/binary", "Anki-Op-Changes": opChangesType.toString() },
|
||||||
"Content-Type": "application/binary",
|
|
||||||
},
|
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,12 @@ const allow = (attrs: string[]): FilterMethod => (element: Element): void =>
|
||||||
element,
|
element,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function convertToDiv(element: Element): void {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.innerHTML = element.innerHTML;
|
||||||
|
element.replaceWith(div);
|
||||||
|
}
|
||||||
|
|
||||||
function unwrapElement(element: Element): void {
|
function unwrapElement(element: Element): void {
|
||||||
element.replaceWith(...element.childNodes);
|
element.replaceWith(...element.childNodes);
|
||||||
}
|
}
|
||||||
|
|
@ -50,7 +56,7 @@ const tagsAllowedBasic: TagsAllowed = {
|
||||||
BR: allowNone,
|
BR: allowNone,
|
||||||
IMG: allow(["SRC", "ALT"]),
|
IMG: allow(["SRC", "ALT"]),
|
||||||
DIV: allowNone,
|
DIV: allowNone,
|
||||||
P: allowNone,
|
P: convertToDiv,
|
||||||
SUB: allowNone,
|
SUB: allowNone,
|
||||||
SUP: allowNone,
|
SUP: allowNone,
|
||||||
TITLE: removeElement,
|
TITLE: removeElement,
|
||||||
|
|
@ -33,6 +33,11 @@ const outputHTMLProcessors: Record<FilterMode, (outputHTML: string) => string> =
|
||||||
};
|
};
|
||||||
|
|
||||||
export function filterHTML(html: string, internal: boolean, extended: boolean): string {
|
export function filterHTML(html: string, internal: boolean, extended: boolean): string {
|
||||||
|
// https://anki.tenderapp.com/discussions/ankidesktop/39543-anki-is-replacing-the-character-by-when-i-exit-the-html-edit-mode-ctrlshiftx
|
||||||
|
if (html.indexOf(">") < 0) {
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
const template = document.createElement("template");
|
const template = document.createElement("template");
|
||||||
template.innerHTML = html;
|
template.innerHTML = html;
|
||||||
|
|
||||||
|
|
@ -97,7 +97,7 @@
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
color: var(--fg-disabled);
|
color: var(--fg-disabled);
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
background-color: var(--button-gradient-end);
|
background: var(--button-gradient-end);
|
||||||
border-bottom-color: var(--border-subtle);
|
border-bottom-color: var(--border-subtle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -240,6 +240,7 @@ export interface DefaultSlotInterface extends Record<string, unknown> {
|
||||||
show(position: Identifier): Promise<boolean>;
|
show(position: Identifier): Promise<boolean>;
|
||||||
hide(position: Identifier): Promise<boolean>;
|
hide(position: Identifier): Promise<boolean>;
|
||||||
toggle(position: Identifier): Promise<boolean>;
|
toggle(position: Identifier): Promise<boolean>;
|
||||||
|
setShown(position: Identifier, shown: boolean): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defaultInterface<T extends SlotHostProps, U extends Element>({
|
export function defaultInterface<T extends SlotHostProps, U extends Element>({
|
||||||
|
|
@ -287,12 +288,20 @@ export function defaultInterface<T extends SlotHostProps, U extends Element>({
|
||||||
}, id);
|
}, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setShown(id: Identifier, shown: boolean): Promise<boolean> {
|
||||||
|
return updateProps((props: T): T => {
|
||||||
|
props.detach.set(!shown);
|
||||||
|
return props;
|
||||||
|
}, id);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
insert,
|
insert,
|
||||||
append,
|
append,
|
||||||
show,
|
show,
|
||||||
hide,
|
hide,
|
||||||
toggle,
|
toggle,
|
||||||
|
setShown,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,5 +7,6 @@ export function globalExport(globals: Record<string, unknown>): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
// but also export as window.anki
|
// but also export as window.anki
|
||||||
window["anki"] = globals;
|
window["anki"] = window["anki"] || {};
|
||||||
|
window["anki"] = { ...window["anki"], ...globals };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,4 +47,7 @@ export const HelpPage = {
|
||||||
updating: "https://docs.ankiweb.net/importing/text-files.html#duplicates-and-updating",
|
updating: "https://docs.ankiweb.net/importing/text-files.html#duplicates-and-updating",
|
||||||
html: "https://docs.ankiweb.net/importing/text-files.html#html",
|
html: "https://docs.ankiweb.net/importing/text-files.html#html",
|
||||||
},
|
},
|
||||||
|
Editing: {
|
||||||
|
adding: "https://docs.ankiweb.net/editing.html#adding-cards-and-notes",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
20
ts/lib/tslib/operations.ts
Normal file
20
ts/lib/tslib/operations.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
import type { OpChanges } from "@generated/anki/collection_pb";
|
||||||
|
|
||||||
|
type OperationHandler = (changes: Partial<OpChanges>) => void;
|
||||||
|
const handlers: OperationHandler[] = [];
|
||||||
|
|
||||||
|
export function registerOperationHandler(handler: (changes: Partial<OpChanges>) => void): void {
|
||||||
|
handlers.push(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOperationDidExecute(changes: Partial<OpChanges>): void {
|
||||||
|
for (const handler of handlers) {
|
||||||
|
handler(changes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.anki = globalThis.anki || {};
|
||||||
|
globalThis.anki.onOperationDidExecute = onOperationDidExecute;
|
||||||
55
ts/lib/tslib/profile.ts
Normal file
55
ts/lib/tslib/profile.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
import {
|
||||||
|
getConfigJson,
|
||||||
|
getMetaJson,
|
||||||
|
getProfileConfigJson,
|
||||||
|
setConfigJson,
|
||||||
|
setMetaJson,
|
||||||
|
setProfileConfigJson,
|
||||||
|
} from "@generated/backend";
|
||||||
|
|
||||||
|
async function getSettingJson(key: string, backendGetter: (key: string) => Promise<any>): Promise<any> {
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
const json = decoder.decode((await backendGetter(key)).json);
|
||||||
|
return JSON.parse(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setSettingJson(
|
||||||
|
key: string,
|
||||||
|
value: any,
|
||||||
|
backendSetter: (key: string, value: any) => Promise<any>,
|
||||||
|
): Promise<void> {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const json = JSON.stringify(value);
|
||||||
|
await backendSetter(key, encoder.encode(json));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProfileConfig(key: string): Promise<any> {
|
||||||
|
return getSettingJson(key, async (k) => await getProfileConfigJson({ val: k }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setProfileConfig(key: string, value: any): Promise<void> {
|
||||||
|
return await setSettingJson(key, value, async (k, v) => await setProfileConfigJson({ key: k, valueJson: v }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMeta(key: string): Promise<any> {
|
||||||
|
return getSettingJson(key, async (k) => await getMetaJson({ val: k }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setMeta(key: string, value: any): Promise<void> {
|
||||||
|
return await setSettingJson(key, value, async (k, v) => await setMetaJson({ key: k, valueJson: v }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getColConfig(key: string): Promise<any> {
|
||||||
|
return await getSettingJson(key, async (k) => await getConfigJson({ val: k }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setColConfig(key: string, value: any): Promise<void> {
|
||||||
|
return await setSettingJson(
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
async (k, v) => await setConfigJson({ key: k, valueJson: v, undoable: true }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,11 +3,7 @@ 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
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Modal from "bootstrap/js/dist/modal";
|
import Modal from "$lib/components/Modal.svelte";
|
||||||
import { getContext, onDestroy, onMount } from "svelte";
|
|
||||||
|
|
||||||
import { modalsKey } from "$lib/components/context-keys";
|
|
||||||
import { registerModalClosingHandler } from "$lib/sveltelib/modal-closing";
|
|
||||||
import { pageTheme } from "$lib/sveltelib/theme";
|
import { pageTheme } from "$lib/sveltelib/theme";
|
||||||
|
|
||||||
export let title: string;
|
export let title: string;
|
||||||
|
|
@ -16,73 +12,36 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
export let onOk: (text: string) => void;
|
export let onOk: (text: string) => void;
|
||||||
$: value = initialValue;
|
$: value = initialValue;
|
||||||
|
|
||||||
export const modalKey: string = Math.random().toString(36).substring(2);
|
|
||||||
|
|
||||||
const modals = getContext<Map<string, Modal>>(modalsKey);
|
|
||||||
|
|
||||||
let modalRef: HTMLDivElement;
|
|
||||||
let modal: Modal;
|
|
||||||
|
|
||||||
let inputRef: HTMLInputElement;
|
let inputRef: HTMLInputElement;
|
||||||
|
let modal: Modal;
|
||||||
|
export let modalKey: string;
|
||||||
|
|
||||||
function onOkClicked(): void {
|
function onOkClicked(): void {
|
||||||
onOk(inputRef.value);
|
onOk(inputRef.value);
|
||||||
modal.hide();
|
|
||||||
value = initialValue;
|
value = initialValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCancelClicked(): void {
|
function onCancelClicked(): void {
|
||||||
modal.hide();
|
|
||||||
value = initialValue;
|
value = initialValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onShown(): void {
|
function onShown(): void {
|
||||||
inputRef.focus();
|
inputRef.focus();
|
||||||
setModalOpen(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onHidden() {
|
|
||||||
setModalOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { set: setModalOpen, remove: removeModalClosingHandler } =
|
|
||||||
registerModalClosingHandler(onCancelClicked);
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
modalRef.addEventListener("shown.bs.modal", onShown);
|
|
||||||
modalRef.addEventListener("hidden.bs.modal", onHidden);
|
|
||||||
modal = new Modal(modalRef, { keyboard: false });
|
|
||||||
modals.set(modalKey, modal);
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
removeModalClosingHandler();
|
|
||||||
modalRef.removeEventListener("shown.bs.modal", onShown);
|
|
||||||
modalRef.removeEventListener("hidden.bs.modal", onHidden);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<Modal bind:this={modal} bind:modalKey {onOkClicked} {onShown} {onCancelClicked}>
|
||||||
bind:this={modalRef}
|
<div slot="header" class="modal-header">
|
||||||
class="modal fade"
|
|
||||||
tabindex="-1"
|
|
||||||
aria-labelledby="modalLabel"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content" class:default-colors={$pageTheme.isDark}>
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title" id="modalLabel">{title}</h5>
|
<h5 class="modal-title" id="modalLabel">{title}</h5>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-close"
|
class="btn-close"
|
||||||
class:invert={$pageTheme.isDark}
|
|
||||||
data-bs-dismiss="modal"
|
data-bs-dismiss="modal"
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
></button>
|
></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div slot="body" class="modal-body">
|
||||||
<form on:submit|preventDefault={onOkClicked}>
|
<form on:submit|preventDefault={modal.acceptHandler}>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="prompt-input" class="col-form-label">
|
<label for="prompt-input" class="col-form-label">
|
||||||
{prompt}:
|
{prompt}:
|
||||||
|
|
@ -98,35 +57,20 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div slot="footer" class="modal-footer">
|
||||||
<button
|
<button type="button" class="btn btn-secondary" on:click={modal.cancelHandler}>
|
||||||
type="button"
|
|
||||||
class="btn btn-secondary"
|
|
||||||
on:click={onCancelClicked}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-primary" on:click={onOkClicked}>
|
<button type="button" class="btn btn-primary" on:click={modal.acceptHandler}>
|
||||||
OK
|
OK
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@use "$lib/sass/night-mode" as nightmode;
|
@use "../../lib/sass/night-mode" as nightmode;
|
||||||
|
|
||||||
.nightMode {
|
.nightMode {
|
||||||
@include nightmode.input;
|
@include nightmode.input;
|
||||||
}
|
}
|
||||||
|
|
||||||
.default-colors {
|
|
||||||
background-color: var(--canvas);
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.invert {
|
|
||||||
filter: invert(1) grayscale(100%) brightness(200%);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
23
ts/routes/editor/+page.svelte
Normal file
23
ts/routes/editor/+page.svelte
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { setupEditor } from "./base";
|
||||||
|
import * as base from "./base";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import type { EditorMode } from "./types";
|
||||||
|
import { globalExport } from "@tslib/globals";
|
||||||
|
import { bridgeCommand } from "@tslib/bridgecommand";
|
||||||
|
|
||||||
|
const mode = (page.url.searchParams.get("mode") ?? "add") as EditorMode;
|
||||||
|
|
||||||
|
globalExport(base);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
setupEditor(mode).then(() => {
|
||||||
|
bridgeCommand("editorReady");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
33
ts/routes/editor/ActionButton.svelte
Normal file
33
ts/routes/editor/ActionButton.svelte
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import LabelButton from "$lib/components/LabelButton.svelte";
|
||||||
|
|
||||||
|
const { children, onClick, tooltip, disabled = false } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="action-button">
|
||||||
|
<LabelButton
|
||||||
|
primary
|
||||||
|
on:click={onClick}
|
||||||
|
{tooltip}
|
||||||
|
--border-left-radius="5px"
|
||||||
|
--border-right-radius="5px"
|
||||||
|
{disabled}
|
||||||
|
>
|
||||||
|
<div class="action-text">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</LabelButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.action-button {
|
||||||
|
margin-inline-end: 0.5rem;
|
||||||
|
}
|
||||||
|
.action-text {
|
||||||
|
margin: 0 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
36
ts/routes/editor/ActionButtons.svelte
Normal file
36
ts/routes/editor/ActionButtons.svelte
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import AddButton from "./AddButton.svelte";
|
||||||
|
import CloseButton from "./CloseButton.svelte";
|
||||||
|
import HelpButton from "./HelpButton.svelte";
|
||||||
|
import HistoryButton from "./HistoryButton.svelte";
|
||||||
|
import type { EditorMode, HistoryEntry } from "./types";
|
||||||
|
|
||||||
|
export let mode: EditorMode;
|
||||||
|
export let onClose: () => void;
|
||||||
|
export let onAdd: () => void;
|
||||||
|
export let onHistory: () => void;
|
||||||
|
export let history: HistoryEntry[] = [];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="action-buttons d-flex flex-row-reverse">
|
||||||
|
{#if mode === "add"}
|
||||||
|
<HelpButton />
|
||||||
|
{/if}
|
||||||
|
{#if mode === "add" || mode === "current"}
|
||||||
|
<CloseButton {onClose} enableShortcut={mode === "current"} />
|
||||||
|
{/if}
|
||||||
|
{#if mode === "add"}
|
||||||
|
<HistoryButton {onHistory} {history} />
|
||||||
|
<AddButton {onAdd} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.action-buttons {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
18
ts/routes/editor/AddButton.svelte
Normal file
18
ts/routes/editor/AddButton.svelte
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import * as tr from "@generated/ftl";
|
||||||
|
import { getPlatformString } from "@tslib/shortcuts";
|
||||||
|
import Shortcut from "$lib/components/Shortcut.svelte";
|
||||||
|
import ActionButton from "./ActionButton.svelte";
|
||||||
|
|
||||||
|
export let onAdd: () => void;
|
||||||
|
const addKeyCombination = "Control+Enter";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ActionButton onClick={onAdd} tooltip={getPlatformString(addKeyCombination)}>
|
||||||
|
{tr.actionsAdd()}
|
||||||
|
<Shortcut keyCombination={addKeyCombination} on:action={onAdd} />
|
||||||
|
</ActionButton>
|
||||||
22
ts/routes/editor/CloseButton.svelte
Normal file
22
ts/routes/editor/CloseButton.svelte
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import * as tr from "@generated/ftl";
|
||||||
|
import { getPlatformString } from "@tslib/shortcuts";
|
||||||
|
import Shortcut from "$lib/components/Shortcut.svelte";
|
||||||
|
import ActionButton from "./ActionButton.svelte";
|
||||||
|
|
||||||
|
export let onClose: () => void;
|
||||||
|
export let enableShortcut: boolean;
|
||||||
|
|
||||||
|
const closeKeyCombination = enableShortcut ? "Control+Enter" : "";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ActionButton onClick={onClose} tooltip={getPlatformString(closeKeyCombination)}>
|
||||||
|
{tr.actionsClose()}
|
||||||
|
{#if enableShortcut}
|
||||||
|
<Shortcut keyCombination={closeKeyCombination} on:action={onClose} />
|
||||||
|
{/if}
|
||||||
|
</ActionButton>
|
||||||
47
ts/routes/editor/DuplicateLink.svelte
Normal file
47
ts/routes/editor/DuplicateLink.svelte
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { searchInBrowser } from "@generated/backend";
|
||||||
|
import * as tr from "@generated/ftl";
|
||||||
|
import type { Note } from "@generated/anki/notes_pb";
|
||||||
|
import { bridgeCommand } from "@tslib/bridgecommand";
|
||||||
|
|
||||||
|
export let note: Note | null = null;
|
||||||
|
export let isLegacy: boolean;
|
||||||
|
|
||||||
|
function showDupes(event: MouseEvent) {
|
||||||
|
if (isLegacy) {
|
||||||
|
bridgeCommand("dupes");
|
||||||
|
} else if (note) {
|
||||||
|
searchInBrowser({
|
||||||
|
filter: {
|
||||||
|
case: "dupe",
|
||||||
|
value: {
|
||||||
|
notetypeId: note.notetypeId,
|
||||||
|
firstField: note.fields[0],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="duplicate-link-container">
|
||||||
|
<a class="duplicate-link" href="/#" on:click={showDupes}>
|
||||||
|
{tr.editingShowDuplicates()}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.duplicate-link-container {
|
||||||
|
text-align: center;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duplicate-link {
|
||||||
|
color: var(--highlight-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -6,6 +6,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
import type { Readable } from "svelte/store";
|
import type { Readable } from "svelte/store";
|
||||||
|
|
||||||
import type { EditingAreaAPI } from "./EditingArea.svelte";
|
import type { EditingAreaAPI } from "./EditingArea.svelte";
|
||||||
|
import { mathjaxConfig } from "$lib/editable/mathjax-element.svelte";
|
||||||
|
|
||||||
export interface FieldData {
|
export interface FieldData {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -117,6 +118,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
fontSize={field.fontSize}
|
fontSize={field.fontSize}
|
||||||
api={editingArea}
|
api={editingArea}
|
||||||
>
|
>
|
||||||
|
{#key mathjaxConfig.enabled}
|
||||||
{#if flipInputs}
|
{#if flipInputs}
|
||||||
<slot name="plain-text-input" />
|
<slot name="plain-text-input" />
|
||||||
<slot name="rich-text-input" />
|
<slot name="rich-text-input" />
|
||||||
|
|
@ -124,13 +126,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
<slot name="rich-text-input" />
|
<slot name="rich-text-input" />
|
||||||
<slot name="plain-text-input" />
|
<slot name="plain-text-input" />
|
||||||
{/if}
|
{/if}
|
||||||
|
{/key}
|
||||||
</EditingArea>
|
</EditingArea>
|
||||||
</div>
|
</div>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@use "../lib/sass/elevation" as *;
|
@use "../../lib/sass/elevation" as *;
|
||||||
|
|
||||||
/* Make sure labels are readable on custom Qt backgrounds */
|
/* Make sure labels are readable on custom Qt backgrounds */
|
||||||
.field-container {
|
.field-container {
|
||||||
23
ts/routes/editor/HelpButton.svelte
Normal file
23
ts/routes/editor/HelpButton.svelte
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import * as tr from "@generated/ftl";
|
||||||
|
import { HelpPage } from "@tslib/help-page";
|
||||||
|
import ActionButton from "./ActionButton.svelte";
|
||||||
|
import { getPlatformString } from "@tslib/shortcuts";
|
||||||
|
import Shortcut from "$lib/components/Shortcut.svelte";
|
||||||
|
import { openLink } from "@generated/backend";
|
||||||
|
|
||||||
|
const helpKeyCombination = "F1";
|
||||||
|
|
||||||
|
function onClick() {
|
||||||
|
openLink({ val: HelpPage.Editing.adding });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ActionButton tooltip={getPlatformString(helpKeyCombination)} {onClick}>
|
||||||
|
{tr.actionsHelp()}
|
||||||
|
<Shortcut keyCombination={helpKeyCombination} on:action={onClick} />
|
||||||
|
</ActionButton>
|
||||||
28
ts/routes/editor/HistoryButton.svelte
Normal file
28
ts/routes/editor/HistoryButton.svelte
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import * as tr from "@generated/ftl";
|
||||||
|
import { getPlatformString } from "@tslib/shortcuts";
|
||||||
|
import Shortcut from "$lib/components/Shortcut.svelte";
|
||||||
|
import ActionButton from "./ActionButton.svelte";
|
||||||
|
import type { HistoryEntry } from "./types";
|
||||||
|
import Icon from "$lib/components/Icon.svelte";
|
||||||
|
import { caretDownFill } from "$lib/components/icons";
|
||||||
|
import { isApplePlatform } from "@tslib/platform";
|
||||||
|
|
||||||
|
export let onHistory: () => void;
|
||||||
|
export let history: HistoryEntry[] = [];
|
||||||
|
const historyKeyCombination = isApplePlatform() ? "Control+Shift+H" : "Control+H";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ActionButton
|
||||||
|
onClick={onHistory}
|
||||||
|
tooltip={getPlatformString(historyKeyCombination)}
|
||||||
|
disabled={history.length === 0}
|
||||||
|
>
|
||||||
|
{tr.addingHistory()}
|
||||||
|
<Icon icon={caretDownFill} />
|
||||||
|
<Shortcut keyCombination={historyKeyCombination} on:action={onHistory} />
|
||||||
|
</ActionButton>
|
||||||
137
ts/routes/editor/HistoryModal.svelte
Normal file
137
ts/routes/editor/HistoryModal.svelte
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import * as tr from "@generated/ftl";
|
||||||
|
import Modal from "$lib/components/Modal.svelte";
|
||||||
|
import { pageTheme } from "$lib/sveltelib/theme";
|
||||||
|
import type { HistoryEntry } from "./types";
|
||||||
|
import { searchInBrowser } from "@generated/backend";
|
||||||
|
|
||||||
|
export let history: HistoryEntry[] = [];
|
||||||
|
export let modal: Modal;
|
||||||
|
|
||||||
|
function onEntryClick(entry: HistoryEntry): void {
|
||||||
|
searchInBrowser({
|
||||||
|
filter: {
|
||||||
|
case: "nids",
|
||||||
|
value: { ids: [entry.noteId] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
modal.hide();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal bind:this={modal}>
|
||||||
|
<div slot="header" class="modal-header">
|
||||||
|
<h5 class="modal-title" id="modalLabel">{tr.addingHistory()}</h5>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-close"
|
||||||
|
data-bs-dismiss="modal"
|
||||||
|
aria-label="Close"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
<div slot="body" class="modal-body" class:nightMode={$pageTheme.isDark}>
|
||||||
|
<ul class="history-list">
|
||||||
|
{#each history as entry}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="history-entry"
|
||||||
|
on:click={() => onEntryClick(entry)}
|
||||||
|
>
|
||||||
|
{entry.text}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div slot="footer" class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" on:click={modal.cancelHandler}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.history-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list li {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list li:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-entry {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background-color: var(--canvas-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-break: break-word;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--fg);
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-entry::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
left: 16px;
|
||||||
|
right: 16px;
|
||||||
|
height: 1px;
|
||||||
|
background-color: var(--link-color);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-entry:hover {
|
||||||
|
background-color: var(--canvas-elevated-hover);
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
color: var(--link-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-entry:hover::after {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-entry:active {
|
||||||
|
transform: translateY(0px);
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-entry:focus {
|
||||||
|
outline: 2px solid var(--link-color);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nightMode .history-entry {
|
||||||
|
background-color: var(--canvas-elevated);
|
||||||
|
border-color: var(--border, rgba(255, 255, 255, 0.15));
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nightMode .history-entry:hover {
|
||||||
|
border-color: var(--border-strong, rgba(255, 255, 255, 0.25));
|
||||||
|
color: var(--link-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1658
ts/routes/editor/NoteEditor.svelte
Normal file
1658
ts/routes/editor/NoteEditor.svelte
Normal file
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue