mirror of
https://github.com/ankitects/anki.git
synced 2025-09-23 16:26:40 -04:00
Merge 6f93bfa13a
into 4506ad0c97
This commit is contained in:
commit
a7044b7db0
164 changed files with 6952 additions and 3241 deletions
|
@ -41,11 +41,17 @@ module.exports = {
|
|||
parser: "svelte-eslint-parser",
|
||||
parserOptions: {
|
||||
parser: "@typescript-eslint/parser",
|
||||
svelteFeatures: {
|
||||
experimentalGenerics: true,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"svelte/no-at-html-tags": "off",
|
||||
"svelte/valid-compile": ["error", { "ignoreWarnings": true }],
|
||||
"@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(
|
||||
inputs![":ts:editor", ":ts:reviewer:reviewer.js", ":ts:mathjax"],
|
||||
inputs![
|
||||
":ts:editor",
|
||||
":ts:editable",
|
||||
":ts:reviewer:reviewer.js",
|
||||
":ts:mathjax"
|
||||
],
|
||||
".js",
|
||||
);
|
||||
build.add_action(
|
||||
|
|
|
@ -170,7 +170,7 @@ fn declare_and_check_other_libraries(build: &mut Build) -> Result<()> {
|
|||
"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}");
|
||||
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 {
|
||||
format!("ts/routes/{name}/index.ts")
|
||||
} else {
|
||||
format!("ts/{name}/index.ts")
|
||||
format!("ts/lib/{name}/index.ts")
|
||||
};
|
||||
build.add_action(
|
||||
&group,
|
||||
|
@ -203,12 +203,11 @@ fn build_and_check_pages(build: &mut Build) -> Result<()> {
|
|||
|
||||
Ok(())
|
||||
};
|
||||
// we use the generated .css file separately
|
||||
// we use the generated .css file separately in the legacy editor
|
||||
build_page(
|
||||
"editable",
|
||||
false,
|
||||
inputs![
|
||||
//
|
||||
":ts:lib",
|
||||
":ts:components",
|
||||
":ts:domlib",
|
||||
|
@ -220,21 +219,15 @@ fn build_and_check_pages(build: &mut Build) -> Result<()> {
|
|||
build_page(
|
||||
"congrats",
|
||||
true,
|
||||
inputs![
|
||||
//
|
||||
":ts:lib",
|
||||
":ts:components",
|
||||
":sass",
|
||||
":sveltekit"
|
||||
],
|
||||
inputs![":ts:lib", ":ts:components", ":sass", ":sveltekit"],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Only used for the legacy editor page.
|
||||
fn build_and_check_editor(build: &mut Build) -> Result<()> {
|
||||
let editor_deps = inputs![
|
||||
//
|
||||
":ts:lib",
|
||||
":ts:components",
|
||||
":ts:domlib",
|
||||
|
@ -242,14 +235,14 @@ fn build_and_check_editor(build: &mut Build) -> Result<()> {
|
|||
":ts:html-filter",
|
||||
":sass",
|
||||
":sveltekit",
|
||||
glob!("ts/{editable,editor,routes/image-occlusion}/**")
|
||||
glob!("ts/lib/editable,routes/{editor,image-occlusion}/**")
|
||||
];
|
||||
|
||||
build.add_action(
|
||||
"ts:editor",
|
||||
EsbuildScript {
|
||||
script: "ts/bundle_svelte.mjs".into(),
|
||||
entrypoint: "ts/editor/index.ts".into(),
|
||||
entrypoint: "ts/routes/editor/index.ts".into(),
|
||||
output_stem: "ts/editor/editor",
|
||||
deps: editor_deps.clone(),
|
||||
extra_exts: &["css"],
|
||||
|
|
|
@ -10,6 +10,9 @@ package anki.frontend;
|
|||
import "anki/scheduler.proto";
|
||||
import "anki/generic.proto";
|
||||
import "anki/search.proto";
|
||||
import "anki/notes.proto";
|
||||
import "anki/notetypes.proto";
|
||||
import "anki/links.proto";
|
||||
|
||||
service FrontendService {
|
||||
// Returns values from the reviewer
|
||||
|
@ -27,6 +30,34 @@ service FrontendService {
|
|||
rpc deckOptionsRequireClose(generic.Empty) returns (generic.Empty);
|
||||
// Warns python that the deck option web view is ready to receive requests.
|
||||
rpc deckOptionsReady(generic.Empty) returns (generic.Empty);
|
||||
|
||||
// Editor
|
||||
rpc UpdateEditorNote(notes.UpdateNotesRequest) returns (generic.Empty);
|
||||
rpc UpdateEditorNotetype(notetypes.Notetype) returns (generic.Empty);
|
||||
rpc AddEditorNote(notes.AddNoteRequest) returns (notes.AddNoteResponse);
|
||||
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);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
service BackendFrontendService {}
|
||||
|
@ -40,3 +71,64 @@ message SetSchedulingStatesRequest {
|
|||
string key = 1;
|
||||
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 {
|
||||
rpc CheckMedia(generic.Empty) returns (CheckMediaResponse);
|
||||
rpc AddMediaFile(AddMediaFileRequest) returns (generic.String);
|
||||
rpc AddMediaFromPath(AddMediaFromPathRequest) returns (generic.String);
|
||||
rpc TrashMediaFiles(TrashMediaFilesRequest) returns (generic.Empty);
|
||||
rpc EmptyTrash(generic.Empty) returns (generic.Empty);
|
||||
rpc RestoreTrash(generic.Empty) returns (generic.Empty);
|
||||
rpc ExtractStaticMediaFiles(notetypes.NotetypeId)
|
||||
returns (generic.StringList);
|
||||
rpc ExtractMediaFiles(generic.String) returns (generic.StringList);
|
||||
rpc GetAbsoluteMediaPath(generic.String) returns (generic.String);
|
||||
}
|
||||
|
||||
// Implicitly includes any of the above methods that are not listed in the
|
||||
// backend service.
|
||||
service BackendMediaService {}
|
||||
service BackendMediaService {
|
||||
rpc AddMediaFromUrl(AddMediaFromUrlRequest) returns (AddMediaFromUrlResponse);
|
||||
}
|
||||
|
||||
message CheckMediaResponse {
|
||||
repeated string unused = 1;
|
||||
|
@ -40,3 +45,16 @@ message AddMediaFileRequest {
|
|||
string desired_name = 1;
|
||||
bytes data = 2;
|
||||
}
|
||||
|
||||
message AddMediaFromPathRequest {
|
||||
string path = 1;
|
||||
}
|
||||
|
||||
message AddMediaFromUrlRequest {
|
||||
string url = 1;
|
||||
}
|
||||
|
||||
message AddMediaFromUrlResponse {
|
||||
optional string filename = 1;
|
||||
optional string error = 2;
|
||||
}
|
||||
|
|
|
@ -125,9 +125,11 @@ from aqt import stats, about, preferences, mediasync # isort:skip
|
|||
class DialogManager:
|
||||
_dialogs: dict[str, list] = {
|
||||
"AddCards": [addcards.AddCards, None],
|
||||
"NewAddCards": [addcards.NewAddCards, None],
|
||||
"AddonsDialog": [addons.AddonsDialog, None],
|
||||
"Browser": [browser.Browser, None],
|
||||
"EditCurrent": [editcurrent.EditCurrent, None],
|
||||
"NewEditCurrent": [editcurrent.NewEditCurrent, None],
|
||||
"FilteredDeckConfigDialog": [filtered_deck.FilteredDeckConfigDialog, None],
|
||||
"DeckStats": [stats.DeckStats, None],
|
||||
"NewDeckStats": [stats.NewDeckStats, None],
|
||||
|
|
|
@ -5,40 +5,33 @@ 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 anki.notes import Note
|
||||
from anki.utils import 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.addcards_legacy import *
|
||||
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:
|
||||
class NewAddCards(QMainWindow):
|
||||
def __init__(
|
||||
self,
|
||||
mw: AnkiQt,
|
||||
deck_id: DeckId | None = None,
|
||||
notetype_id: NotetypeId | None = None,
|
||||
) -> None:
|
||||
super().__init__(None, Qt.WindowType.Window)
|
||||
self._close_event_has_cleaned_up = False
|
||||
self._close_callback: Callable[[], None] = self._close
|
||||
self.mw = mw
|
||||
self.col = mw.col
|
||||
form = aqt.forms.addcards.Ui_Dialog()
|
||||
|
@ -47,297 +40,52 @@ class AddCards(QMainWindow):
|
|||
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)
|
||||
self._load_new_note(deck_id, notetype_id)
|
||||
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)
|
||||
self.editor.load_note(
|
||||
mid=note.mid,
|
||||
original_note_id=note.id,
|
||||
focus_to=0,
|
||||
)
|
||||
|
||||
def setupEditor(self) -> None:
|
||||
self.editor = aqt.editor.Editor(
|
||||
self.editor = aqt.editor.NewEditor(
|
||||
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 reopen(
|
||||
self,
|
||||
mw: AnkiQt,
|
||||
deck_id: DeckId | None = None,
|
||||
notetype_id: NotetypeId | None = None,
|
||||
) -> None:
|
||||
self.editor.reload_note_if_empty(deck_id, notetype_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
|
||||
def _load_new_note(
|
||||
self, deck_id: DeckId | None = None, notetype_id: NotetypeId | None = None
|
||||
) -> 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)
|
||||
self.editor.load_note(
|
||||
mid=notetype_id,
|
||||
deck_id=deck_id,
|
||||
focus_to=0,
|
||||
)
|
||||
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
|
||||
|
||||
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:
|
||||
|
@ -354,24 +102,25 @@ class AddCards(QMainWindow):
|
|||
|
||||
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")
|
||||
aqt.dialogs.markClosed("NewAddCards")
|
||||
self._close_event_has_cleaned_up = True
|
||||
self.mw.deferred_delete_and_garbage_collect(self)
|
||||
self.close()
|
||||
|
||||
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:
|
||||
if choice == 0:
|
||||
onOk()
|
||||
|
||||
def afterSave() -> None:
|
||||
if self.editor.fieldsAreBlank(self._last_added_note):
|
||||
return onOk()
|
||||
self._close_callback()
|
||||
|
||||
ask_user_dialog(
|
||||
tr.adding_discard_current_input(),
|
||||
|
@ -382,33 +131,9 @@ class AddCards(QMainWindow):
|
|||
],
|
||||
)
|
||||
|
||||
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
|
||||
|
|
414
qt/aqt/addcards_legacy.py
Normal file
414
qt/aqt/addcards_legacy.py
Normal file
|
@ -0,0 +1,414 @@
|
|||
# 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
|
||||
|
||||
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 re
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import Any, cast
|
||||
from typing import Any
|
||||
|
||||
from markdown import markdown
|
||||
|
||||
|
@ -22,7 +22,7 @@ from anki.cards import Card, CardId
|
|||
from anki.collection import Collection, Config, OpChanges, SearchNode
|
||||
from anki.consts import *
|
||||
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.models import NotetypeId
|
||||
from anki.notes import NoteId
|
||||
|
@ -30,7 +30,6 @@ from anki.scheduler.base import ScheduleCardsAsNew
|
|||
from anki.tags import MARKED_TAG
|
||||
from anki.utils import is_mac
|
||||
from aqt import AnkiQt, gui_hooks
|
||||
from aqt.editor import Editor, EditorWebView
|
||||
from aqt.errors import show_exception
|
||||
from aqt.exporting import ExportDialog as LegacyExportDialog
|
||||
from aqt.import_export.exporting import ExportDialog
|
||||
|
@ -80,7 +79,6 @@ from aqt.utils import (
|
|||
tr,
|
||||
)
|
||||
|
||||
from ..addcards import AddCards
|
||||
from ..changenotetype import change_notetype_dialog
|
||||
from .card_info import BrowserCardInfo
|
||||
from .find_and_replace import FindAndReplaceDialog
|
||||
|
@ -114,7 +112,7 @@ class MockModel:
|
|||
class Browser(QMainWindow):
|
||||
mw: AnkiQt
|
||||
col: Collection
|
||||
editor: Editor | None
|
||||
editor: aqt.editor.NewEditor | None
|
||||
table: Table
|
||||
|
||||
def __init__(
|
||||
|
@ -192,15 +190,7 @@ class Browser(QMainWindow):
|
|||
# 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)
|
||||
self.editor.reload_note()
|
||||
|
||||
if changes.browser_table and changes.card:
|
||||
self.card = self.table.get_single_selected_card()
|
||||
|
@ -278,11 +268,10 @@ class Browser(QMainWindow):
|
|||
return None
|
||||
|
||||
def add_card(self, deck_id: DeckId):
|
||||
add_cards = cast(AddCards, aqt.dialogs.open("AddCards", self.mw))
|
||||
add_cards.set_deck(deck_id)
|
||||
|
||||
args = [self.mw, deck_id]
|
||||
if note_type_id := self.get_active_note_type_id():
|
||||
add_cards.set_note_type(note_type_id)
|
||||
args.append(note_type_id)
|
||||
aqt.dialogs.open("NewAddCards", *args)
|
||||
|
||||
# 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.
|
||||
|
@ -403,7 +392,7 @@ class Browser(QMainWindow):
|
|||
add_ellipsis_to_action_label(f.action_forget)
|
||||
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
|
||||
editor_web_view = self.editor.web
|
||||
assert editor_web_view is not None
|
||||
|
@ -605,17 +594,19 @@ class Browser(QMainWindow):
|
|||
def setupEditor(self) -> None:
|
||||
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()
|
||||
gui_hooks.editor_did_init.remove(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.form.fieldsArea,
|
||||
self,
|
||||
editor_mode=aqt.editor.EditorMode.BROWSER,
|
||||
)
|
||||
gui_hooks.editor_did_init.remove(add_preview_button)
|
||||
|
||||
@ensure_editor_saved
|
||||
def on_all_or_selected_rows_changed(self) -> None:
|
||||
|
@ -819,7 +810,7 @@ class Browser(QMainWindow):
|
|||
assert current_card is not None
|
||||
|
||||
deck_id = current_card.current_deck_id()
|
||||
aqt.dialogs.open("AddCards", self.mw).set_note(note, deck_id)
|
||||
aqt.dialogs.open("NewAddCards", self.mw).set_note(note, deck_id)
|
||||
|
||||
@no_arg_trigger
|
||||
@skip_if_selection_is_empty
|
||||
|
@ -843,7 +834,7 @@ class Browser(QMainWindow):
|
|||
|
||||
if self._previewer:
|
||||
self._previewer.close()
|
||||
elif self.editor.note:
|
||||
else:
|
||||
self._previewer = PreviewDialog(self, self.mw, self._on_preview_closed)
|
||||
self._previewer.open()
|
||||
self.toggle_preview_button_state(True)
|
||||
|
@ -1265,7 +1256,7 @@ class Browser(QMainWindow):
|
|||
def cb():
|
||||
assert self.editor is not None and self.editor.web is not None
|
||||
self.editor.web.setFocus()
|
||||
self.editor.loadNote(focusTo=0)
|
||||
self.editor.reload_note()
|
||||
|
||||
assert self.editor is not None
|
||||
self.editor.call_after_note_saved(cb)
|
||||
|
|
|
@ -6,13 +6,13 @@ 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.editcurrent_legacy import *
|
||||
from aqt.qt import *
|
||||
from aqt.utils import add_close_shortcut, restoreGeom, saveGeom, tr
|
||||
|
||||
|
||||
class EditCurrent(QMainWindow):
|
||||
class NewEditCurrent(QMainWindow):
|
||||
def __init__(self, mw: aqt.AnkiQt) -> None:
|
||||
super().__init__(None, Qt.WindowType.Window)
|
||||
self.mw = mw
|
||||
|
@ -23,7 +23,7 @@ class EditCurrent(QMainWindow):
|
|||
self.setMinimumWidth(250)
|
||||
if not is_mac:
|
||||
self.setMenuBar(None)
|
||||
self.editor = aqt.editor.Editor(
|
||||
self.editor = aqt.editor.NewEditor(
|
||||
self.mw,
|
||||
self.form.fieldsArea,
|
||||
self,
|
||||
|
@ -33,13 +33,7 @@ class EditCurrent(QMainWindow):
|
|||
self.editor.card = self.mw.reviewer.card
|
||||
self.editor.set_note(self.mw.reviewer.card.note(), focusTo=0)
|
||||
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)
|
||||
# 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()
|
||||
|
||||
|
@ -47,24 +41,13 @@ class EditCurrent(QMainWindow):
|
|||
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)
|
||||
self.editor.reload_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")
|
||||
aqt.dialogs.markClosed("NewEditCurrent")
|
||||
|
||||
def reopen(self, mw: aqt.AnkiQt) -> None:
|
||||
if card := self.mw.reviewer.card:
|
||||
|
|
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
|
1522
qt/aqt/editor.py
1522
qt/aqt/editor.py
File diff suppressed because it is too large
Load diff
1789
qt/aqt/editor_legacy.py
Normal file
1789
qt/aqt/editor_legacy.py
Normal file
File diff suppressed because it is too large
Load diff
|
@ -28,16 +28,6 @@
|
|||
<item>
|
||||
<widget class="QWidget" name="fieldsArea" native="true"/>
|
||||
</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>
|
||||
</widget>
|
||||
<widget class="QMenuBar" name="menubar">
|
||||
|
@ -60,22 +50,4 @@
|
|||
<resources>
|
||||
<include location="icons.qrc"/>
|
||||
</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>
|
||||
|
|
|
@ -1280,14 +1280,20 @@ title="{}" {}>{}</button>""".format(
|
|||
# Other menu operations
|
||||
##########################################################################
|
||||
|
||||
def _open_new_or_legacy_dialog(self, name: str, *args: Any, **kwargs: Any) -> None:
|
||||
want_old = KeyboardModifiersPressed().shift
|
||||
if not want_old:
|
||||
name = f"New{name}"
|
||||
aqt.dialogs.open(name, self, *args, **kwargs)
|
||||
|
||||
def onAddCard(self) -> None:
|
||||
aqt.dialogs.open("AddCards", self)
|
||||
self._open_new_or_legacy_dialog("AddCards")
|
||||
|
||||
def onBrowse(self) -> None:
|
||||
aqt.dialogs.open("Browser", self, card=self.reviewer.card)
|
||||
|
||||
def onEditCurrent(self) -> None:
|
||||
aqt.dialogs.open("EditCurrent", self)
|
||||
self._open_new_or_legacy_dialog("EditCurrent")
|
||||
|
||||
def onOverview(self) -> None:
|
||||
self.moveToState("overview")
|
||||
|
@ -1296,11 +1302,7 @@ title="{}" {}>{}</button>""".format(
|
|||
deck = self._selectedDeck()
|
||||
if not deck:
|
||||
return
|
||||
want_old = KeyboardModifiersPressed().shift
|
||||
if want_old:
|
||||
aqt.dialogs.open("DeckStats", self)
|
||||
else:
|
||||
aqt.dialogs.open("NewDeckStats", self)
|
||||
self._open_new_or_legacy_dialog("DeckStats", self)
|
||||
|
||||
def onPrefs(self) -> None:
|
||||
aqt.dialogs.open("Preferences", self)
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import enum
|
||||
import logging
|
||||
import mimetypes
|
||||
|
@ -16,6 +17,7 @@ from collections.abc import Callable
|
|||
from dataclasses import dataclass
|
||||
from errno import EPROTOTYPE
|
||||
from http import HTTPStatus
|
||||
from typing import Any, Generic, cast
|
||||
|
||||
import flask
|
||||
import flask_cors
|
||||
|
@ -27,18 +29,25 @@ from waitress.server import create_server
|
|||
import aqt
|
||||
import aqt.main
|
||||
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.decks import UpdateDeckConfigs
|
||||
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.deckoptions import DeckOptionsDialog
|
||||
from aqt.operations import on_op_finished
|
||||
from aqt.operations.deck import update_deck_configs as update_deck_configs_op
|
||||
from aqt.progress import ProgressUpdate
|
||||
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
|
||||
waitress.wasyncore._DISCONNECTED = waitress.wasyncore._DISCONNECTED.union({EPROTOTYPE}) # type: ignore
|
||||
|
@ -334,6 +343,7 @@ def is_sveltekit_page(path: str) -> bool:
|
|||
"import-csv",
|
||||
"import-page",
|
||||
"image-occlusion",
|
||||
"editor",
|
||||
]
|
||||
|
||||
|
||||
|
@ -599,6 +609,304 @@ def deck_options_ready() -> bytes:
|
|||
return b""
|
||||
|
||||
|
||||
def editor_op_changes_request(endpoint: str) -> bytes:
|
||||
output = raw_backend_request(endpoint)()
|
||||
response = OpChanges()
|
||||
response.ParseFromString(output)
|
||||
|
||||
def handle_on_main() -> None:
|
||||
from aqt.editor import NewEditor
|
||||
|
||||
handler = aqt.mw.app.activeWindow()
|
||||
if handler and isinstance(getattr(handler, "editor", None), NewEditor):
|
||||
handler = handler.editor # type: ignore
|
||||
on_op_finished(aqt.mw, response, handler)
|
||||
|
||||
aqt.mw.taskman.run_on_main(handle_on_main)
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def update_editor_note() -> bytes:
|
||||
return editor_op_changes_request("update_notes")
|
||||
|
||||
|
||||
def update_editor_notetype() -> bytes:
|
||||
return editor_op_changes_request("update_notetype")
|
||||
|
||||
|
||||
def add_editor_note() -> bytes:
|
||||
return editor_op_changes_request("add_note")
|
||||
|
||||
|
||||
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_event_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()
|
||||
|
||||
|
||||
post_handler_list = [
|
||||
congrats_info,
|
||||
get_deck_configs_for_update,
|
||||
|
@ -614,6 +922,26 @@ post_handler_list = [
|
|||
search_in_browser,
|
||||
deck_options_require_close,
|
||||
deck_options_ready,
|
||||
update_editor_note,
|
||||
update_editor_notetype,
|
||||
add_editor_note,
|
||||
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,
|
||||
]
|
||||
|
||||
|
||||
|
@ -630,9 +958,15 @@ exposed_backend_list = [
|
|||
# NotesService
|
||||
"get_field_names",
|
||||
"get_note",
|
||||
"new_note",
|
||||
"note_fields_check",
|
||||
"defaults_for_adding",
|
||||
"default_deck_for_notetype",
|
||||
# NotetypesService
|
||||
"get_notetype",
|
||||
"get_notetype_names",
|
||||
"get_change_notetype_info",
|
||||
"get_cloze_field_ords",
|
||||
# StatsService
|
||||
"card_stats",
|
||||
"get_review_logs",
|
||||
|
@ -658,6 +992,21 @@ exposed_backend_list = [
|
|||
# DeckConfigService
|
||||
"get_ignored_before_count",
|
||||
"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",
|
||||
]
|
||||
|
||||
|
||||
|
@ -686,7 +1035,25 @@ def _extract_collection_post_request(path: str) -> DynamicRequest | NotFound:
|
|||
# convert bytes/None into response
|
||||
def wrapped() -> Response:
|
||||
try:
|
||||
if data := handler():
|
||||
import inspect
|
||||
|
||||
if inspect.iscoroutinefunction(handler):
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
import concurrent.futures
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(asyncio.run, handler())
|
||||
data = future.result()
|
||||
else:
|
||||
data = loop.run_until_complete(handler())
|
||||
except RuntimeError:
|
||||
data = asyncio.run(handler())
|
||||
else:
|
||||
result = handler()
|
||||
data = result
|
||||
if data:
|
||||
response = flask.make_response(data)
|
||||
response.headers["Content-Type"] = "application/binary"
|
||||
else:
|
||||
|
|
|
@ -1167,7 +1167,7 @@ timerStopped = false;
|
|||
|
||||
def on_create_copy(self) -> None:
|
||||
if self.card:
|
||||
aqt.dialogs.open("AddCards", self.mw).set_note(
|
||||
aqt.dialogs.open("NewAddCards", self.mw).set_note(
|
||||
self.card.note(), self.card.current_deck_id()
|
||||
)
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@ requires-python = ">=3.9"
|
|||
license = "AGPL-3.0-or-later"
|
||||
dependencies = [
|
||||
"beautifulsoup4",
|
||||
"flask",
|
||||
"flask[async]",
|
||||
"flask_cors",
|
||||
"jsonschema",
|
||||
"requests",
|
||||
|
|
|
@ -1008,12 +1008,15 @@ hooks = [
|
|||
###################
|
||||
Hook(
|
||||
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",
|
||||
),
|
||||
Hook(
|
||||
name="add_cards_did_init",
|
||||
args=["addcards: aqt.addcards.AddCards"],
|
||||
args=["addcards: aqt.addcards.AddCards | aqt.addcards.NewAddCards"],
|
||||
),
|
||||
Hook(
|
||||
name="add_cards_did_add_note",
|
||||
|
@ -1068,7 +1071,7 @@ hooks = [
|
|||
Hook(
|
||||
name="addcards_did_change_note_type",
|
||||
args=[
|
||||
"addcards: aqt.addcards.AddCards",
|
||||
"addcards: aqt.addcards.AddCards | aqt.addcards.NewAddCards",
|
||||
"old: anki.models.NoteType",
|
||||
"new: anki.models.NoteType",
|
||||
],
|
||||
|
@ -1087,20 +1090,26 @@ hooks = [
|
|||
###################
|
||||
Hook(
|
||||
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(
|
||||
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(
|
||||
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",
|
||||
),
|
||||
Hook(
|
||||
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",
|
||||
),
|
||||
Hook(
|
||||
|
@ -1121,7 +1130,7 @@ hooks = [
|
|||
),
|
||||
Hook(
|
||||
name="editor_did_load_note",
|
||||
args=["editor: aqt.editor.Editor"],
|
||||
args=["editor: aqt.editor.Editor | aqt.editor.NewEditor"],
|
||||
legacy_hook="loadNote",
|
||||
),
|
||||
Hook(
|
||||
|
@ -1131,7 +1140,7 @@ hooks = [
|
|||
),
|
||||
Hook(
|
||||
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",
|
||||
doc="""Allows manipulating the text that will be saved by the editor""",
|
||||
),
|
||||
|
@ -1143,15 +1152,21 @@ hooks = [
|
|||
),
|
||||
Hook(
|
||||
name="editor_web_view_did_init",
|
||||
args=["editor_web_view: aqt.editor.EditorWebView"],
|
||||
args=[
|
||||
"editor_web_view: aqt.editor.EditorWebView | aqt.editor.NewEditorWebView"
|
||||
],
|
||||
),
|
||||
Hook(
|
||||
name="editor_did_init",
|
||||
args=["editor: aqt.editor.Editor"],
|
||||
args=["editor: aqt.editor.Editor | aqt.editor.NewEditor"],
|
||||
),
|
||||
Hook(
|
||||
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",
|
||||
doc="""Allows changing the javascript commands to load note before
|
||||
executing it and do change in the QT editor.""",
|
||||
|
@ -1159,7 +1174,7 @@ hooks = [
|
|||
Hook(
|
||||
name="editor_did_paste",
|
||||
args=[
|
||||
"editor: aqt.editor.Editor",
|
||||
"editor: aqt.editor.Editor | aqt.editor.NewEditor",
|
||||
"html: str",
|
||||
"internal: bool",
|
||||
"extended: bool",
|
||||
|
@ -1170,7 +1185,7 @@ hooks = [
|
|||
name="editor_will_process_mime",
|
||||
args=[
|
||||
"mime: QMimeData",
|
||||
"editor_web_view: aqt.editor.EditorWebView",
|
||||
"editor_web_view: aqt.editor.EditorWebView | aqt.editor.NewEditorWebView",
|
||||
"internal: bool",
|
||||
"extended: bool",
|
||||
"drop_event: bool",
|
||||
|
@ -1194,7 +1209,7 @@ hooks = [
|
|||
Hook(
|
||||
name="editor_state_did_change",
|
||||
args=[
|
||||
"editor: aqt.editor.Editor",
|
||||
"editor: aqt.editor.Editor | aqt.editor.NewEditor",
|
||||
"new_state: aqt.editor.EditorState",
|
||||
"old_state: aqt.editor.EditorState",
|
||||
],
|
||||
|
@ -1203,7 +1218,10 @@ hooks = [
|
|||
),
|
||||
Hook(
|
||||
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
|
||||
loading an image.
|
||||
|
||||
|
|
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 i18n;
|
||||
mod import_export;
|
||||
mod media;
|
||||
mod ops;
|
||||
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 deckconfig;
|
||||
pub mod decks;
|
||||
pub mod editor;
|
||||
pub mod error;
|
||||
pub mod findreplace;
|
||||
pub mod i18n;
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
use anki_proto::generic;
|
||||
use anki_proto::media::AddMediaFileRequest;
|
||||
use anki_proto::media::AddMediaFromPathRequest;
|
||||
use anki_proto::media::CheckMediaResponse;
|
||||
use anki_proto::media::TrashMediaFilesRequest;
|
||||
|
||||
|
@ -12,6 +14,7 @@ use crate::error;
|
|||
use crate::error::OrNotFound;
|
||||
use crate::notes::service::to_i64s;
|
||||
use crate::notetype::NotetypeId;
|
||||
use crate::text::extract_media_refs;
|
||||
|
||||
impl crate::services::MediaService for Collection {
|
||||
fn check_media(&mut self) -> error::Result<CheckMediaResponse> {
|
||||
|
@ -40,6 +43,19 @@ impl crate::services::MediaService for Collection {
|
|||
.into())
|
||||
}
|
||||
|
||||
fn add_media_from_path(
|
||||
&mut self,
|
||||
input: AddMediaFromPathRequest,
|
||||
) -> error::Result<generic::String> {
|
||||
let base_name = Path::new(&input.path)
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_str()
|
||||
.unwrap_or_default();
|
||||
let data = std::fs::read(&input.path)?;
|
||||
Ok(self.media()?.add_file(base_name, &data)?.to_string().into())
|
||||
}
|
||||
|
||||
fn trash_media_files(&mut self, input: TrashMediaFilesRequest) -> error::Result<()> {
|
||||
self.media()?.remove_files(&input.fnames)
|
||||
}
|
||||
|
@ -66,4 +82,28 @@ impl crate::services::MediaService for Collection {
|
|||
|
||||
Ok(files.into_iter().collect::<Vec<_>>().into())
|
||||
}
|
||||
|
||||
fn extract_media_files(
|
||||
&mut self,
|
||||
html: anki_proto::generic::String,
|
||||
) -> error::Result<generic::StringList> {
|
||||
let files = extract_media_refs(&html.val)
|
||||
.iter()
|
||||
.map(|r| r.fname_decoded.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
Ok(files.into())
|
||||
}
|
||||
|
||||
fn get_absolute_media_path(
|
||||
&mut self,
|
||||
path: anki_proto::generic::String,
|
||||
) -> error::Result<generic::String> {
|
||||
Ok(self
|
||||
.media()?
|
||||
.media_folder
|
||||
.join(path.val)
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
.into())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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} />
|
42
ts/lib/components/DeckChooser.svelte
Normal file
42
ts/lib/components/DeckChooser.svelte
Normal file
|
@ -0,0 +1,42 @@
|
|||
<!--
|
||||
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 { getDeckNames } from "@generated/backend";
|
||||
import ItemChooser from "./ItemChooser.svelte";
|
||||
import type { DeckNameId } from "@generated/anki/decks_pb";
|
||||
import * as tr from "@generated/ftl";
|
||||
|
||||
interface Props {
|
||||
selectedDeck: DeckNameId | null;
|
||||
onChange?: (deck: DeckNameId) => void;
|
||||
}
|
||||
let { selectedDeck = $bindable(null), onChange }: Props = $props();
|
||||
let decks: DeckNameId[] = $state([]);
|
||||
let itemChooser: ItemChooser<DeckNameId> | null = $state(null);
|
||||
|
||||
export function select(notetypeId: bigint) {
|
||||
itemChooser?.select(notetypeId);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
getDeckNames({ skipEmptyDefault: true, includeFiltered: false }).then(
|
||||
(response) => {
|
||||
decks = response.entries;
|
||||
},
|
||||
);
|
||||
});
|
||||
</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 { renderMarkdown } from "@tslib/helpers";
|
||||
import Carousel from "bootstrap/js/dist/carousel";
|
||||
import Modal from "bootstrap/js/dist/modal";
|
||||
import { createEventDispatcher, getContext, onDestroy, onMount } from "svelte";
|
||||
import { createEventDispatcher, onMount } from "svelte";
|
||||
import Modal from "./Modal.svelte";
|
||||
|
||||
import { infoCircle } from "$lib/components/icons";
|
||||
import { registerModalClosingHandler } from "$lib/sveltelib/modal-closing";
|
||||
import { pageTheme } from "$lib/sveltelib/theme";
|
||||
|
||||
import Badge from "./Badge.svelte";
|
||||
import Col from "./Col.svelte";
|
||||
import { modalsKey } from "./context-keys";
|
||||
import HelpSection from "./HelpSection.svelte";
|
||||
import Icon from "./Icon.svelte";
|
||||
import Row from "./Row.svelte";
|
||||
|
@ -27,50 +25,20 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
export let helpSections: HelpItem[];
|
||||
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 modalRef: HTMLDivElement;
|
||||
let modal: Modal;
|
||||
let carouselRef: HTMLDivElement;
|
||||
|
||||
function onOkClicked(): void {
|
||||
modal.hide();
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const { set: setModalOpen, remove: removeModalClosingHandler } =
|
||||
registerModalClosingHandler(onOkClicked);
|
||||
|
||||
function onShown() {
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function onHidden() {
|
||||
setModalOpen(false);
|
||||
}
|
||||
|
||||
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 });
|
||||
/* Bootstrap's Carousel.Event interface doesn't seem to work as a type here */
|
||||
carouselRef.addEventListener("slide.bs.carousel", (e: any) => {
|
||||
activeIndex = e.to;
|
||||
});
|
||||
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;
|
||||
|
@ -80,16 +48,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
<Icon icon={infoCircle} />
|
||||
</Badge>
|
||||
|
||||
<div
|
||||
bind:this={modalRef}
|
||||
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">
|
||||
<Modal bind:this={modal} dialogClass="modal-lg">
|
||||
<div slot="header" class="modal-header">
|
||||
<div style="display: flex;">
|
||||
<h1 class="modal-title" id="modalLabel">
|
||||
{title}
|
||||
|
@ -106,17 +66,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
<div class="chapter-redirect">
|
||||
{@html renderMarkdown(
|
||||
tr.helpForMoreInfo({
|
||||
link: `<a href="${url}" title="${tr.helpOpenManualChapter(
|
||||
{
|
||||
link: `<a href="${url}" title="${tr.helpOpenManualChapter({
|
||||
name: title,
|
||||
},
|
||||
)}">${title}</a>`,
|
||||
})}">${title}</a>`,
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div slot="body" class="modal-body">
|
||||
<Row --cols={4}>
|
||||
<Col --col-size={1}>
|
||||
<nav>
|
||||
|
@ -131,10 +89,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
}}
|
||||
class:active={i == activeIndex}
|
||||
class:d-none={fsrs
|
||||
? item.sched ===
|
||||
HelpItemScheduler.SM2
|
||||
: item.sched ==
|
||||
HelpItemScheduler.FSRS}
|
||||
? item.sched === HelpItemScheduler.SM2
|
||||
: item.sched == HelpItemScheduler.FSRS}
|
||||
>
|
||||
{item.title}
|
||||
</button>
|
||||
|
@ -167,32 +123,23 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" on:click={onOkClicked}>
|
||||
<div slot="footer" class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" on:click={modal.onOkClicked}>
|
||||
{tr.helpOk()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<style lang="scss">
|
||||
#nav {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.modal {
|
||||
z-index: 1066;
|
||||
background-color: rgba($color: black, $alpha: 0.5);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
margin-inline-end: 0.75rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: var(--canvas);
|
||||
color: var(--fg);
|
||||
:global(.modal-content) {
|
||||
border-radius: var(--border-radius-medium, 10px);
|
||||
}
|
||||
|
||||
|
|
258
ts/lib/components/ItemChooser.svelte
Normal file
258
ts/lib/components/ItemChooser.svelte
Normal file
|
@ -0,0 +1,258 @@
|
|||
<!--
|
||||
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()),
|
||||
),
|
||||
);
|
||||
|
||||
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) {
|
||||
const item = items.find((item) => item.id === itemId);
|
||||
selectedItem = item ? item : null;
|
||||
}
|
||||
|
||||
$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={() => searchInput?.focus()} 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 (item.id)}
|
||||
<button
|
||||
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-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;
|
||||
}
|
||||
|
||||
: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 {
|
||||
padding: 1.5rem;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 1.5rem;
|
||||
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>
|
41
ts/lib/components/NotetypeChooser.svelte
Normal file
41
ts/lib/components/NotetypeChooser.svelte
Normal file
|
@ -0,0 +1,41 @@
|
|||
<!--
|
||||
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 { getNotetypeNames } from "@generated/backend";
|
||||
import ItemChooser from "./ItemChooser.svelte";
|
||||
import * as tr from "@generated/ftl";
|
||||
|
||||
interface Props {
|
||||
selectedNotetype: NotetypeNameId | null;
|
||||
onChange?: (notetype: NotetypeNameId) => void;
|
||||
}
|
||||
let { selectedNotetype = $bindable(null), onChange }: Props = $props();
|
||||
let notetypes: NotetypeNameId[] = $state([]);
|
||||
let itemChooser: ItemChooser<NotetypeNameId> | null = $state(null);
|
||||
|
||||
export function select(notetypeId: bigint) {
|
||||
itemChooser?.select(notetypeId);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
getNotetypeNames({}).then((response) => {
|
||||
notetypes = response.entries;
|
||||
});
|
||||
});
|
||||
</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 AlignVerticalTop_ from "@mdi/svg/svg/align-vertical-top.svg?component";
|
||||
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?url";
|
||||
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 NewBox_ from "@mdi/svg/svg/new-box.svg?component";
|
||||
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?url";
|
||||
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 ArrowRight_ from "bootstrap-icons/icons/arrow-right.svg?component";
|
||||
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?url";
|
||||
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 incrementClozeIcon = { url: incrementCloze_, component: IncrementCloze_ };
|
||||
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>
|
||||
|
||||
<style lang="scss">
|
||||
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;
|
||||
}
|
||||
|
||||
/* editable-base.scss contains styling targeting user HTML */
|
||||
@import "./content-editable.scss";
|
||||
</style>
|
|
@ -125,24 +125,5 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
/>
|
||||
|
||||
<style lang="scss">
|
||||
:global(anki-mathjax) {
|
||||
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);
|
||||
}
|
||||
@import "./mathjax.scss";
|
||||
</style>
|
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;
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
@use "../lib/sass/scrollbar";
|
||||
@use "../sass/scrollbar";
|
||||
|
||||
* {
|
||||
max-width: 100%;
|
|
@ -2,6 +2,5 @@
|
|||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import "./editable-base.scss";
|
||||
/* only imported for the CSS */
|
||||
import "./ContentEditable.svelte";
|
||||
import "./Mathjax.svelte";
|
||||
import "./content-editable.scss";
|
||||
import "./mathjax.scss";
|
13
ts/lib/editable/mathjax.scss
Normal file
13
ts/lib/editable/mathjax.scss
Normal file
|
@ -0,0 +1,13 @@
|
|||
:global(anki-mathjax) {
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
img {
|
||||
vertical-align: var(--vertical-center);
|
||||
}
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
margin: 1rem auto;
|
||||
transform: scale(1.1);
|
||||
}
|
|
@ -34,6 +34,12 @@ const allow = (attrs: string[]): FilterMethod => (element: Element): void =>
|
|||
element,
|
||||
);
|
||||
|
||||
function convertToDiv(element: Element): void {
|
||||
const div = document.createElement("div");
|
||||
div.innerHTML = element.innerHTML;
|
||||
element.replaceWith(div);
|
||||
}
|
||||
|
||||
function unwrapElement(element: Element): void {
|
||||
element.replaceWith(...element.childNodes);
|
||||
}
|
||||
|
@ -50,7 +56,7 @@ const tagsAllowedBasic: TagsAllowed = {
|
|||
BR: allowNone,
|
||||
IMG: allow(["SRC", "ALT"]),
|
||||
DIV: allowNone,
|
||||
P: allowNone,
|
||||
P: convertToDiv,
|
||||
SUB: allowNone,
|
||||
SUP: allowNone,
|
||||
TITLE: removeElement,
|
|
@ -33,6 +33,11 @@ const outputHTMLProcessors: Record<FilterMode, (outputHTML: string) => 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");
|
||||
template.innerHTML = html;
|
||||
|
|
@ -240,6 +240,7 @@ export interface DefaultSlotInterface extends Record<string, unknown> {
|
|||
show(position: Identifier): Promise<boolean>;
|
||||
hide(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>({
|
||||
|
@ -287,12 +288,20 @@ export function defaultInterface<T extends SlotHostProps, U extends Element>({
|
|||
}, id);
|
||||
}
|
||||
|
||||
function setShown(id: Identifier, shown: boolean): Promise<boolean> {
|
||||
return updateProps((props: T): T => {
|
||||
props.detach.set(!shown);
|
||||
return props;
|
||||
}, id);
|
||||
}
|
||||
|
||||
return {
|
||||
insert,
|
||||
append,
|
||||
show,
|
||||
hide,
|
||||
toggle,
|
||||
setShown,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -46,4 +46,7 @@ export const HelpPage = {
|
|||
updating: "https://docs.ankiweb.net/importing/text-files.html#duplicates-and-updating",
|
||||
html: "https://docs.ankiweb.net/importing/text-files.html#html",
|
||||
},
|
||||
Editing: {
|
||||
adding: "https://docs.ankiweb.net/editing.html#adding-cards-and-notes",
|
||||
},
|
||||
};
|
||||
|
|
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
|
||||
-->
|
||||
<script lang="ts">
|
||||
import Modal from "bootstrap/js/dist/modal";
|
||||
import { getContext, onDestroy, onMount } from "svelte";
|
||||
|
||||
import { modalsKey } from "$lib/components/context-keys";
|
||||
import { registerModalClosingHandler } from "$lib/sveltelib/modal-closing";
|
||||
import Modal from "$lib/components/Modal.svelte";
|
||||
import { pageTheme } from "$lib/sveltelib/theme";
|
||||
|
||||
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;
|
||||
$: 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 modal: Modal;
|
||||
export let modalKey: string;
|
||||
|
||||
function onOkClicked(): void {
|
||||
onOk(inputRef.value);
|
||||
modal.hide();
|
||||
value = initialValue;
|
||||
}
|
||||
|
||||
function onCancelClicked(): void {
|
||||
modal.hide();
|
||||
value = initialValue;
|
||||
}
|
||||
|
||||
function onShown(): void {
|
||||
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>
|
||||
|
||||
<div
|
||||
bind:this={modalRef}
|
||||
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">
|
||||
<Modal bind:this={modal} bind:modalKey {onOkClicked} {onShown} {onCancelClicked}>
|
||||
<div slot="header" class="modal-header">
|
||||
<h5 class="modal-title" id="modalLabel">{title}</h5>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
class:invert={$pageTheme.isDark}
|
||||
data-bs-dismiss="modal"
|
||||
aria-label="Close"
|
||||
></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form on:submit|preventDefault={onOkClicked}>
|
||||
<div slot="body" class="modal-body">
|
||||
<form on:submit|preventDefault={modal.acceptHandler}>
|
||||
<div class="mb-3">
|
||||
<label for="prompt-input" class="col-form-label">
|
||||
{prompt}:
|
||||
|
@ -98,35 +57,20 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
on:click={onCancelClicked}
|
||||
>
|
||||
<div slot="footer" class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" on:click={modal.cancelHandler}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" on:click={onOkClicked}>
|
||||
<button type="button" class="btn btn-primary" on:click={modal.acceptHandler}>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<style lang="scss">
|
||||
@use "$lib/sass/night-mode" as nightmode;
|
||||
@use "../../lib/sass/night-mode" as nightmode;
|
||||
|
||||
.nightMode {
|
||||
@include nightmode.input;
|
||||
}
|
||||
|
||||
.default-colors {
|
||||
background-color: var(--canvas);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.invert {
|
||||
filter: invert(1) grayscale(100%) brightness(200%);
|
||||
}
|
||||
</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>
|
|
@ -130,7 +130,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use "../lib/sass/elevation" as *;
|
||||
@use "../../lib/sass/elevation" as *;
|
||||
|
||||
/* Make sure labels are readable on custom Qt backgrounds */
|
||||
.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>
|
1616
ts/routes/editor/NoteEditor.svelte
Normal file
1616
ts/routes/editor/NoteEditor.svelte
Normal file
File diff suppressed because it is too large
Load diff
|
@ -4,7 +4,6 @@ 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";
|
||||
import { getPlatformString, registerShortcut } from "@tslib/shortcuts";
|
||||
import { onEnterOrSpace } from "@tslib/keys";
|
||||
import { onMount } from "svelte";
|
||||
|
@ -15,21 +14,33 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
import { stickyIconSolid } from "$lib/components/icons";
|
||||
|
||||
import { context as editorFieldContext } from "./EditorField.svelte";
|
||||
import type { Note } from "@generated/anki/notes_pb";
|
||||
import { getNotetype, updateEditorNotetype } from "@generated/backend";
|
||||
import { bridgeCommand } from "@tslib/bridgecommand";
|
||||
|
||||
const animated = !document.body.classList.contains("reduce-motion");
|
||||
|
||||
export let active: boolean;
|
||||
export let show: boolean;
|
||||
export let isLegacy: boolean;
|
||||
|
||||
const editorField = editorFieldContext.get();
|
||||
const keyCombination = "F9";
|
||||
|
||||
export let index: number;
|
||||
export let note: Note;
|
||||
|
||||
function toggle() {
|
||||
async function toggle() {
|
||||
if (isLegacy) {
|
||||
bridgeCommand(`toggleSticky:${index}`, (value: boolean) => {
|
||||
active = value;
|
||||
});
|
||||
} else {
|
||||
active = !active;
|
||||
const notetype = await getNotetype({ ntid: note.notetypeId });
|
||||
notetype.fields[index].config!.sticky = active;
|
||||
await updateEditorNotetype(notetype);
|
||||
}
|
||||
}
|
||||
|
||||
function shortcut(target: HTMLElement): () => void {
|
||||
|
@ -52,12 +63,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
<Badge
|
||||
tooltip="{tr.editingToggleSticky()} ({getPlatformString(keyCombination)})"
|
||||
widthMultiplier={0.7}
|
||||
></Badge>
|
||||
>
|
||||
{#if active}
|
||||
<Icon icon={stickyIconSolid} />
|
||||
{:else}
|
||||
<Icon icon={stickyIconHollow} />
|
||||
{/if}
|
||||
</Badge>
|
||||
</span>
|
||||
|
||||
<style lang="scss">
|
|
@ -19,10 +19,7 @@ import LabelButton from "$lib/components/LabelButton.svelte";
|
|||
import WithContext from "$lib/components/WithContext.svelte";
|
||||
import WithState from "$lib/components/WithState.svelte";
|
||||
|
||||
import BrowserEditor from "./BrowserEditor.svelte";
|
||||
import NoteCreator from "./NoteCreator.svelte";
|
||||
import * as editorContextKeys from "./NoteEditor.svelte";
|
||||
import ReviewerEditor from "./ReviewerEditor.svelte";
|
||||
import NoteEditor, * as editorContextKeys from "./NoteEditor.svelte";
|
||||
|
||||
declare global {
|
||||
interface Selection {
|
||||
|
@ -32,8 +29,10 @@ declare global {
|
|||
}
|
||||
}
|
||||
|
||||
import { modalsKey } from "$lib/components/context-keys";
|
||||
import { ModuleName } from "@tslib/i18n";
|
||||
import { mount } from "svelte";
|
||||
import type { EditorMode } from "./types";
|
||||
|
||||
export const editorModules = [
|
||||
ModuleName.EDITING,
|
||||
|
@ -43,6 +42,9 @@ export const editorModules = [
|
|||
ModuleName.NOTETYPES,
|
||||
ModuleName.IMPORTING,
|
||||
ModuleName.UNDO,
|
||||
ModuleName.ADDING,
|
||||
ModuleName.QT_MISC,
|
||||
ModuleName.DECKS,
|
||||
];
|
||||
|
||||
export const components = {
|
||||
|
@ -55,33 +57,13 @@ export const components = {
|
|||
|
||||
export { editorToolbar } from "./editor-toolbar";
|
||||
|
||||
async function setupBrowserEditor(): Promise<void> {
|
||||
await setupI18n({ modules: editorModules });
|
||||
mount(BrowserEditor, { target: document.body, props: { uiResolve } });
|
||||
}
|
||||
|
||||
async function setupNoteCreator(): Promise<void> {
|
||||
await setupI18n({ modules: editorModules });
|
||||
mount(NoteCreator, { target: document.body, props: { uiResolve } });
|
||||
}
|
||||
|
||||
async function setupReviewerEditor(): Promise<void> {
|
||||
await setupI18n({ modules: editorModules });
|
||||
mount(ReviewerEditor, { target: document.body, props: { uiResolve } });
|
||||
}
|
||||
|
||||
export function setupEditor(mode: "add" | "browse" | "review") {
|
||||
switch (mode) {
|
||||
case "add":
|
||||
setupNoteCreator();
|
||||
break;
|
||||
case "browse":
|
||||
setupBrowserEditor();
|
||||
break;
|
||||
case "review":
|
||||
setupReviewerEditor();
|
||||
break;
|
||||
default:
|
||||
export async function setupEditor(mode: EditorMode, isLegacy = false) {
|
||||
if (!["add", "browser", "current"].includes(mode)) {
|
||||
alert("unexpected editor type");
|
||||
return;
|
||||
}
|
||||
const context = new Map();
|
||||
context.set(modalsKey, new Map());
|
||||
await setupI18n({ modules: editorModules });
|
||||
mount(NoteEditor, { target: document.body, props: { uiResolve, mode, isLegacy }, context });
|
||||
}
|
120
ts/routes/editor/context-menu.svelte.ts
Normal file
120
ts/routes/editor/context-menu.svelte.ts
Normal file
|
@ -0,0 +1,120 @@
|
|||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import type { ContextMenu, ContextMenuMouseEvent } from "$lib/context-menu";
|
||||
|
||||
import { openMedia, showInMediaFolder } from "@generated/backend";
|
||||
import * as tr from "@generated/ftl";
|
||||
import { bridgeCommand } from "@tslib/bridgecommand";
|
||||
import { getSelection } from "@tslib/cross-browser";
|
||||
import { get } from "svelte/store";
|
||||
import type { EditingInputAPI } from "./EditingArea.svelte";
|
||||
import type { NoteEditorAPI } from "./NoteEditor.svelte";
|
||||
import { editingInputIsPlainText } from "./plain-text-input";
|
||||
import { editingInputIsRichText } from "./rich-text-input";
|
||||
import { writeBlobToClipboard } from "./rich-text-input/data-transfer";
|
||||
import { EditorState } from "./types";
|
||||
|
||||
async function getFieldSelection(focusedInput: EditingInputAPI): Promise<string | null> {
|
||||
if (editingInputIsRichText(focusedInput)) {
|
||||
const selection = getSelection(await focusedInput.element);
|
||||
if (selection && selection.toString()) {
|
||||
return selection.toString();
|
||||
}
|
||||
} else if (editingInputIsPlainText(focusedInput)) {
|
||||
const selection = (await focusedInput.codeMirror.editor).getSelection();
|
||||
if (selection) {
|
||||
return selection;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getImageFromMouseEvent(event: ContextMenuMouseEvent, element: HTMLElement): string | null {
|
||||
const elements = element.getRootNode().elementsFromPoint(event.clientX, event.clientY);
|
||||
for (const element of elements) {
|
||||
if (element instanceof HTMLImageElement && (new URL(element.src)).hostname === window.location.hostname) {
|
||||
return decodeURI(element.getAttribute("src")!);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface ContextMenuItem {
|
||||
label: string;
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
export function setupContextMenu(): [
|
||||
(
|
||||
event: ContextMenuMouseEvent,
|
||||
noteEditor: NoteEditorAPI,
|
||||
focusedInput: EditingInputAPI | null,
|
||||
contextMenu: ContextMenu,
|
||||
) => Promise<void>,
|
||||
ContextMenuItem[],
|
||||
] {
|
||||
const contextMenuItems: ContextMenuItem[] = $state([]);
|
||||
async function onContextMenu(
|
||||
event: ContextMenuMouseEvent,
|
||||
noteEditor: NoteEditorAPI,
|
||||
focusedInput: EditingInputAPI | null,
|
||||
contextMenu: ContextMenu,
|
||||
) {
|
||||
contextMenuItems.length = 0;
|
||||
contextMenuItems.push({
|
||||
label: tr.editingPaste(),
|
||||
action: () => {
|
||||
bridgeCommand("paste");
|
||||
},
|
||||
});
|
||||
const selection = focusedInput ? await getFieldSelection(focusedInput) : null;
|
||||
if (selection) {
|
||||
contextMenuItems.push({
|
||||
label: tr.editingCut(),
|
||||
action: () => {
|
||||
bridgeCommand("cut");
|
||||
},
|
||||
}, {
|
||||
label: tr.actionsCopy(),
|
||||
action: () => {
|
||||
bridgeCommand("copy");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let imagePath: string | null = null;
|
||||
if (get(noteEditor.state) === EditorState.ImageOcclusionMasks) {
|
||||
imagePath = get(noteEditor.lastIOImagePath);
|
||||
} else if (focusedInput && editingInputIsRichText(focusedInput)) {
|
||||
imagePath = getImageFromMouseEvent(event, await focusedInput.element);
|
||||
}
|
||||
if (imagePath) {
|
||||
contextMenuItems.push({
|
||||
label: tr.editingCopyImage(),
|
||||
action: async () => {
|
||||
const image = await fetch(imagePath);
|
||||
const blob = await image.blob();
|
||||
await writeBlobToClipboard(blob);
|
||||
},
|
||||
}, {
|
||||
label: tr.editingOpenImage(),
|
||||
action: () => {
|
||||
openMedia({ val: imagePath });
|
||||
},
|
||||
}, {
|
||||
label: tr.editingShowInFolder(),
|
||||
action: () => {
|
||||
showInMediaFolder({ val: imagePath });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (contextMenuItems.length > 0) {
|
||||
contextMenu?.show(event);
|
||||
}
|
||||
}
|
||||
|
||||
return [onContextMenu, contextMenuItems];
|
||||
}
|
|
@ -3,10 +3,10 @@
|
|||
|
||||
import { BLOCK_ELEMENTS } from "@tslib/dom";
|
||||
|
||||
import { CustomElementArray } from "../editable/decorated";
|
||||
import { FrameElement } from "../editable/frame-element";
|
||||
import { FrameEnd, FrameStart } from "../editable/frame-handle";
|
||||
import { Mathjax } from "../editable/mathjax-element.svelte";
|
||||
import { CustomElementArray } from "$lib/editable/decorated";
|
||||
import { FrameElement } from "$lib/editable/frame-element";
|
||||
import { FrameEnd, FrameStart } from "$lib/editable/frame-handle";
|
||||
import { Mathjax } from "$lib/editable/mathjax-element.svelte";
|
||||
import { parsingInstructions } from "./plain-text-input";
|
||||
|
||||
const decoratedElements = new CustomElementArray();
|
|
@ -1,12 +1,12 @@
|
|||
/* Copyright: Ankitects Pty Ltd and contributors
|
||||
* License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */
|
||||
|
||||
@import "../lib/sass/base";
|
||||
@import "../../lib/sass/base";
|
||||
|
||||
$btn-disabled-opacity: 0.4;
|
||||
@import "bootstrap/scss/buttons";
|
||||
@import "bootstrap/scss/button-group";
|
||||
@import "../lib/sass/bootstrap-tooltip";
|
||||
@import "../../lib/sass/bootstrap-tooltip";
|
||||
|
||||
html,
|
||||
body {
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue