This commit is contained in:
Abdo 2025-09-15 00:34:54 -07:00 committed by GitHub
commit a7044b7db0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
164 changed files with 6952 additions and 3241 deletions

View file

@ -41,11 +41,17 @@ module.exports = {
parser: "svelte-eslint-parser", parser: "svelte-eslint-parser",
parserOptions: { parserOptions: {
parser: "@typescript-eslint/parser", parser: "@typescript-eslint/parser",
svelteFeatures: {
experimentalGenerics: true,
},
}, },
rules: { rules: {
"svelte/no-at-html-tags": "off", "svelte/no-at-html-tags": "off",
"svelte/valid-compile": ["error", { "ignoreWarnings": true }], "svelte/valid-compile": ["error", { "ignoreWarnings": true }],
"@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-explicit-any": "off",
"prefer-const": "off",
// TODO: enable this when we update to eslint-plugin-svelte 3
// "svelte/prefer-const": "warn",
}, },
}, },
], ],

View file

@ -191,7 +191,12 @@ fn build_js(build: &mut Build) -> Result<()> {
}, },
)?; )?;
let files_from_ts = build.inputs_with_suffix( let files_from_ts = build.inputs_with_suffix(
inputs![":ts:editor", ":ts:reviewer:reviewer.js", ":ts:mathjax"], inputs![
":ts:editor",
":ts:editable",
":ts:reviewer:reviewer.js",
":ts:mathjax"
],
".js", ".js",
); );
build.add_action( build.add_action(

View file

@ -170,7 +170,7 @@ fn declare_and_check_other_libraries(build: &mut Build) -> Result<()> {
"components", "components",
inputs![":ts:lib", ":ts:sveltelib", glob!("ts/components/**")], inputs![":ts:lib", ":ts:sveltelib", glob!("ts/components/**")],
), ),
("html-filter", inputs![glob!("ts/html-filter/**")]), ("html-filter", inputs![glob!("ts/lib/html-filter/**")]),
] { ] {
let library_with_ts = format!("ts:{library}"); let library_with_ts = format!("ts:{library}");
build.add_dependency(&library_with_ts, inputs.clone()); build.add_dependency(&library_with_ts, inputs.clone());
@ -187,7 +187,7 @@ fn build_and_check_pages(build: &mut Build) -> Result<()> {
let entrypoint = if html { let entrypoint = if html {
format!("ts/routes/{name}/index.ts") format!("ts/routes/{name}/index.ts")
} else { } else {
format!("ts/{name}/index.ts") format!("ts/lib/{name}/index.ts")
}; };
build.add_action( build.add_action(
&group, &group,
@ -203,12 +203,11 @@ fn build_and_check_pages(build: &mut Build) -> Result<()> {
Ok(()) Ok(())
}; };
// we use the generated .css file separately // we use the generated .css file separately in the legacy editor
build_page( build_page(
"editable", "editable",
false, false,
inputs![ inputs![
//
":ts:lib", ":ts:lib",
":ts:components", ":ts:components",
":ts:domlib", ":ts:domlib",
@ -220,21 +219,15 @@ fn build_and_check_pages(build: &mut Build) -> Result<()> {
build_page( build_page(
"congrats", "congrats",
true, true,
inputs![ inputs![":ts:lib", ":ts:components", ":sass", ":sveltekit"],
//
":ts:lib",
":ts:components",
":sass",
":sveltekit"
],
)?; )?;
Ok(()) Ok(())
} }
/// Only used for the legacy editor page.
fn build_and_check_editor(build: &mut Build) -> Result<()> { fn build_and_check_editor(build: &mut Build) -> Result<()> {
let editor_deps = inputs![ let editor_deps = inputs![
//
":ts:lib", ":ts:lib",
":ts:components", ":ts:components",
":ts:domlib", ":ts:domlib",
@ -242,14 +235,14 @@ fn build_and_check_editor(build: &mut Build) -> Result<()> {
":ts:html-filter", ":ts:html-filter",
":sass", ":sass",
":sveltekit", ":sveltekit",
glob!("ts/{editable,editor,routes/image-occlusion}/**") glob!("ts/lib/editable,routes/{editor,image-occlusion}/**")
]; ];
build.add_action( build.add_action(
"ts:editor", "ts:editor",
EsbuildScript { EsbuildScript {
script: "ts/bundle_svelte.mjs".into(), script: "ts/bundle_svelte.mjs".into(),
entrypoint: "ts/editor/index.ts".into(), entrypoint: "ts/routes/editor/index.ts".into(),
output_stem: "ts/editor/editor", output_stem: "ts/editor/editor",
deps: editor_deps.clone(), deps: editor_deps.clone(),
extra_exts: &["css"], extra_exts: &["css"],

View file

@ -10,6 +10,9 @@ package anki.frontend;
import "anki/scheduler.proto"; import "anki/scheduler.proto";
import "anki/generic.proto"; import "anki/generic.proto";
import "anki/search.proto"; import "anki/search.proto";
import "anki/notes.proto";
import "anki/notetypes.proto";
import "anki/links.proto";
service FrontendService { service FrontendService {
// Returns values from the reviewer // Returns values from the reviewer
@ -27,6 +30,34 @@ service FrontendService {
rpc deckOptionsRequireClose(generic.Empty) returns (generic.Empty); rpc deckOptionsRequireClose(generic.Empty) returns (generic.Empty);
// Warns python that the deck option web view is ready to receive requests. // Warns python that the deck option web view is ready to receive requests.
rpc deckOptionsReady(generic.Empty) returns (generic.Empty); rpc deckOptionsReady(generic.Empty) returns (generic.Empty);
// Editor
rpc 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 {} service BackendFrontendService {}
@ -40,3 +71,64 @@ message SetSchedulingStatesRequest {
string key = 1; string key = 1;
scheduler.SchedulingStates states = 2; scheduler.SchedulingStates states = 2;
} }
message ConvertPastedImageRequest {
bytes data = 1;
string ext = 2;
}
message ConvertPastedImageResponse {
bytes data = 1;
}
message SetSettingJsonRequest {
string key = 1;
bytes value_json = 2;
}
message openFilePickerRequest {
string title = 1;
string key = 2;
string filter_description = 3;
repeated string extensions = 4;
}
message ReadClipboardRequest {
repeated string types = 1;
}
message ReadClipboardResponse {
map<string, bytes> data = 1;
}
message WriteClipboardRequest {
map<string, bytes> data = 1;
}
message Help {
oneof value {
links.HelpPageLinkRequest.HelpPage help_page = 1;
string help_link = 2;
}
}
message AskUserRequest {
string text = 1;
optional Help help = 2;
optional string title = 4;
optional bool default_no = 5;
}
enum MessageBoxType {
INFO = 0;
WARNING = 1;
CRITICAL = 2;
}
message ShowMessageBoxRequest {
string text = 1;
MessageBoxType type = 2;
optional Help help = 3;
optional string title = 4;
optional string text_format = 5;
}

View file

@ -13,16 +13,21 @@ import "anki/notetypes.proto";
service MediaService { service MediaService {
rpc CheckMedia(generic.Empty) returns (CheckMediaResponse); rpc CheckMedia(generic.Empty) returns (CheckMediaResponse);
rpc AddMediaFile(AddMediaFileRequest) returns (generic.String); rpc AddMediaFile(AddMediaFileRequest) returns (generic.String);
rpc AddMediaFromPath(AddMediaFromPathRequest) returns (generic.String);
rpc TrashMediaFiles(TrashMediaFilesRequest) returns (generic.Empty); rpc TrashMediaFiles(TrashMediaFilesRequest) returns (generic.Empty);
rpc EmptyTrash(generic.Empty) returns (generic.Empty); rpc EmptyTrash(generic.Empty) returns (generic.Empty);
rpc RestoreTrash(generic.Empty) returns (generic.Empty); rpc RestoreTrash(generic.Empty) returns (generic.Empty);
rpc ExtractStaticMediaFiles(notetypes.NotetypeId) rpc ExtractStaticMediaFiles(notetypes.NotetypeId)
returns (generic.StringList); returns (generic.StringList);
rpc ExtractMediaFiles(generic.String) returns (generic.StringList);
rpc GetAbsoluteMediaPath(generic.String) returns (generic.String);
} }
// Implicitly includes any of the above methods that are not listed in the // Implicitly includes any of the above methods that are not listed in the
// backend service. // backend service.
service BackendMediaService {} service BackendMediaService {
rpc AddMediaFromUrl(AddMediaFromUrlRequest) returns (AddMediaFromUrlResponse);
}
message CheckMediaResponse { message CheckMediaResponse {
repeated string unused = 1; repeated string unused = 1;
@ -40,3 +45,16 @@ message AddMediaFileRequest {
string desired_name = 1; string desired_name = 1;
bytes data = 2; bytes data = 2;
} }
message AddMediaFromPathRequest {
string path = 1;
}
message AddMediaFromUrlRequest {
string url = 1;
}
message AddMediaFromUrlResponse {
optional string filename = 1;
optional string error = 2;
}

View file

@ -125,9 +125,11 @@ from aqt import stats, about, preferences, mediasync # isort:skip
class DialogManager: class DialogManager:
_dialogs: dict[str, list] = { _dialogs: dict[str, list] = {
"AddCards": [addcards.AddCards, None], "AddCards": [addcards.AddCards, None],
"NewAddCards": [addcards.NewAddCards, None],
"AddonsDialog": [addons.AddonsDialog, None], "AddonsDialog": [addons.AddonsDialog, None],
"Browser": [browser.Browser, None], "Browser": [browser.Browser, None],
"EditCurrent": [editcurrent.EditCurrent, None], "EditCurrent": [editcurrent.EditCurrent, None],
"NewEditCurrent": [editcurrent.NewEditCurrent, None],
"FilteredDeckConfigDialog": [filtered_deck.FilteredDeckConfigDialog, None], "FilteredDeckConfigDialog": [filtered_deck.FilteredDeckConfigDialog, None],
"DeckStats": [stats.DeckStats, None], "DeckStats": [stats.DeckStats, None],
"NewDeckStats": [stats.NewDeckStats, None], "NewDeckStats": [stats.NewDeckStats, None],

View file

@ -5,40 +5,33 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
import aqt.editor
import aqt.forms
from anki._legacy import deprecated
from anki.collection import OpChanges, OpChangesWithCount, SearchNode
from anki.decks import DeckId from anki.decks import DeckId
from anki.models import NotetypeId from anki.notes import Note
from anki.notes import Note, NoteFieldsCheckResult, NoteId from anki.utils import is_mac
from anki.utils import html_to_text_line, is_mac
from aqt import AnkiQt, gui_hooks from aqt import AnkiQt, gui_hooks
from aqt.deckchooser import DeckChooser from aqt.addcards_legacy import *
from aqt.notetypechooser import NotetypeChooser
from aqt.operations.note import add_note
from aqt.qt import * from aqt.qt import *
from aqt.sound import av_player
from aqt.utils import ( from aqt.utils import (
HelpPage, HelpPage,
add_close_shortcut, add_close_shortcut,
ask_user_dialog, ask_user_dialog,
askUser,
downArrow,
openHelp, openHelp,
restoreGeom, restoreGeom,
saveGeom, saveGeom,
shortcut,
showWarning,
tooltip,
tr, tr,
) )
class AddCards(QMainWindow): class NewAddCards(QMainWindow):
def __init__(self, mw: AnkiQt) -> None: def __init__(
self,
mw: AnkiQt,
deck_id: DeckId | None = None,
notetype_id: NotetypeId | None = None,
) -> None:
super().__init__(None, Qt.WindowType.Window) super().__init__(None, Qt.WindowType.Window)
self._close_event_has_cleaned_up = False self._close_event_has_cleaned_up = False
self._close_callback: Callable[[], None] = self._close
self.mw = mw self.mw = mw
self.col = mw.col self.col = mw.col
form = aqt.forms.addcards.Ui_Dialog() form = aqt.forms.addcards.Ui_Dialog()
@ -47,297 +40,52 @@ class AddCards(QMainWindow):
self.setWindowTitle(tr.actions_add()) self.setWindowTitle(tr.actions_add())
self.setMinimumHeight(300) self.setMinimumHeight(300)
self.setMinimumWidth(400) self.setMinimumWidth(400)
self.setup_choosers()
self.setupEditor() self.setupEditor()
add_close_shortcut(self) add_close_shortcut(self)
self._load_new_note() self._load_new_note(deck_id, notetype_id)
self.setupButtons()
self.history: list[NoteId] = []
self._last_added_note: Note | None = None
gui_hooks.operation_did_execute.append(self.on_operation_did_execute)
restoreGeom(self, "add") restoreGeom(self, "add")
gui_hooks.add_cards_did_init(self) gui_hooks.add_cards_did_init(self)
if not is_mac: if not is_mac:
self.setMenuBar(None) self.setMenuBar(None)
self.show() self.show()
def set_deck(self, deck_id: DeckId) -> None:
self.deck_chooser.selected_deck_id = deck_id
def set_note_type(self, note_type_id: NotetypeId) -> None:
self.notetype_chooser.selected_notetype_id = note_type_id
def set_note(self, note: Note, deck_id: DeckId | None = None) -> None: def set_note(self, note: Note, deck_id: DeckId | None = None) -> None:
"""Set tags, field contents and notetype according to `note`. Deck is set """Set tags, field contents and notetype according to `note`. Deck is set
to `deck_id` or the deck last used with the notetype. to `deck_id` or the deck last used with the notetype.
""" """
self.notetype_chooser.selected_notetype_id = note.mid self.editor.load_note(
if deck_id or (deck_id := self.col.default_deck_for_notetype(note.mid)): mid=note.mid,
self.deck_chooser.selected_deck_id = deck_id original_note_id=note.id,
focus_to=0,
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: def setupEditor(self) -> None:
self.editor = aqt.editor.Editor( self.editor = aqt.editor.NewEditor(
self.mw, self.mw,
self.form.fieldsArea, self.form.fieldsArea,
self, self,
editor_mode=aqt.editor.EditorMode.ADD_CARDS, editor_mode=aqt.editor.EditorMode.ADD_CARDS,
) )
def setup_choosers(self) -> None: def reopen(
defaults = self.col.defaults_for_adding( self,
current_review_card=self.mw.reviewer.card mw: AnkiQt,
) deck_id: DeckId | None = None,
notetype_id: NotetypeId | None = None,
self.notetype_chooser = NotetypeChooser( ) -> None:
mw=self.mw, self.editor.reload_note_if_empty(deck_id, notetype_id)
widget=self.form.modelArea,
starting_notetype_id=NotetypeId(defaults.notetype_id),
on_button_activated=self.show_notetype_selector,
on_notetype_changed=self.on_notetype_change,
)
self.deck_chooser = DeckChooser(
self.mw,
self.form.deckArea,
starting_deck_id=DeckId(defaults.deck_id),
on_deck_changed=self.on_deck_changed,
)
def reopen(self, mw: AnkiQt) -> None:
if not self.editor.fieldsAreBlank():
return
defaults = self.col.defaults_for_adding(
current_review_card=self.mw.reviewer.card
)
self.set_note_type(NotetypeId(defaults.notetype_id))
self.set_deck(DeckId(defaults.deck_id))
def helpRequested(self) -> None: def helpRequested(self) -> None:
openHelp(HelpPage.ADDING_CARD_AND_NOTE) openHelp(HelpPage.ADDING_CARD_AND_NOTE)
def setupButtons(self) -> None: def _load_new_note(
bb = self.form.buttonBox self, deck_id: DeckId | None = None, notetype_id: NotetypeId | None = None
ar = QDialogButtonBox.ButtonRole.ActionRole
# add
self.addButton = bb.addButton(tr.actions_add(), ar)
qconnect(self.addButton.clicked, self.add_current_note)
self.addButton.setShortcut(QKeySequence("Ctrl+Return"))
# qt5.14+ doesn't handle numpad enter on Windows
self.compat_add_shorcut = QShortcut(QKeySequence("Ctrl+Enter"), self)
qconnect(self.compat_add_shorcut.activated, self.addButton.click)
self.addButton.setToolTip(shortcut(tr.adding_add_shortcut_ctrlandenter()))
# close
self.closeButton = QPushButton(tr.actions_close())
self.closeButton.setAutoDefault(False)
bb.addButton(self.closeButton, QDialogButtonBox.ButtonRole.RejectRole)
qconnect(self.closeButton.clicked, self.close)
# help
self.helpButton = QPushButton(tr.actions_help(), clicked=self.helpRequested) # type: ignore
self.helpButton.setAutoDefault(False)
bb.addButton(self.helpButton, QDialogButtonBox.ButtonRole.HelpRole)
# history
b = bb.addButton(f"{tr.adding_history()} {downArrow()}", ar)
if is_mac:
sc = "Ctrl+Shift+H"
else:
sc = "Ctrl+H"
b.setShortcut(QKeySequence(sc))
b.setToolTip(tr.adding_shortcut(val=shortcut(sc)))
qconnect(b.clicked, self.onHistory)
b.setEnabled(False)
self.historyButton = b
def setAndFocusNote(self, note: Note) -> None:
self.editor.set_note(note, focusTo=0)
def show_notetype_selector(self) -> None:
self.editor.call_after_note_saved(self.notetype_chooser.choose_notetype)
def on_deck_changed(self, deck_id: int) -> None:
gui_hooks.add_cards_did_change_deck(deck_id)
def on_notetype_change(
self, notetype_id: NotetypeId, update_deck: bool = True
) -> None: ) -> None:
# need to adjust current deck? self.editor.load_note(
if update_deck: mid=notetype_id,
if deck_id := self.col.default_deck_for_notetype(notetype_id): deck_id=deck_id,
self.deck_chooser.selected_deck_id = deck_id focus_to=0,
# only used for detecting changed sticky fields on close
self._last_added_note = None
# copy fields into new note with the new notetype
old_note = self.editor.note
new_note = self._new_note()
if old_note:
old_field_names = list(old_note.keys())
new_field_names = list(new_note.keys())
copied_field_names = set()
for f in new_note.note_type()["flds"]:
field_name = f["name"]
# copy identical non-empty fields
if field_name in old_field_names and old_note[field_name]:
new_note[field_name] = old_note[field_name]
copied_field_names.add(field_name)
new_idx = 0
for old_idx, old_field_value in enumerate(old_field_names):
# skip previously copied identical fields in new note
while (
new_idx < len(new_field_names)
and new_field_names[new_idx] in copied_field_names
):
new_idx += 1
if new_idx >= len(new_field_names):
break
# copy non-empty old fields
if (
old_field_value not in copied_field_names
and old_note.fields[old_idx]
):
new_note.fields[new_idx] = old_note.fields[old_idx]
new_idx += 1
new_note.tags = old_note.tags
# and update editor state
self.editor.note = new_note
self.editor.loadNote(
focusTo=min(self.editor.last_field_index or 0, len(new_note.fields) - 1)
) )
gui_hooks.addcards_did_change_note_type(
self, old_note.note_type(), new_note.note_type()
)
def _load_new_note(self, sticky_fields_from: Note | None = None) -> None:
note = self._new_note()
if old_note := sticky_fields_from:
flds = note.note_type()["flds"]
# copy fields from old note
if old_note:
for n in range(min(len(note.fields), len(old_note.fields))):
if flds[n]["sticky"]:
note.fields[n] = old_note.fields[n]
# and tags
note.tags = old_note.tags
self.setAndFocusNote(note)
def on_operation_did_execute(
self, changes: OpChanges, handler: object | None
) -> None:
if (changes.notetype or changes.deck) and handler is not self.editor:
self.on_notetype_change(
NotetypeId(
self.col.defaults_for_adding(
current_review_card=self.mw.reviewer.card
).notetype_id
),
update_deck=False,
)
def _new_note(self) -> Note:
return self.col.new_note(
self.col.models.get(self.notetype_chooser.selected_notetype_id)
)
def addHistory(self, note: Note) -> None:
self.history.insert(0, note.id)
self.history = self.history[:15]
self.historyButton.setEnabled(True)
def onHistory(self) -> None:
m = QMenu(self)
for nid in self.history:
if self.col.find_notes(self.col.build_search_string(SearchNode(nid=nid))):
note = self.col.get_note(nid)
fields = note.fields
txt = html_to_text_line(", ".join(fields))
if len(txt) > 30:
txt = f"{txt[:30]}..."
line = tr.adding_edit(val=txt)
line = gui_hooks.addcards_will_add_history_entry(line, note)
line = line.replace("&", "&&")
# In qt action "&i" means "underline i, trigger this line when i is pressed".
# except for "&&" which is replaced by a single "&"
a = m.addAction(line)
qconnect(a.triggered, lambda b, nid=nid: self.editHistory(nid))
else:
a = m.addAction(tr.adding_note_deleted())
a.setEnabled(False)
gui_hooks.add_cards_will_show_history_menu(self, m)
m.exec(self.historyButton.mapToGlobal(QPoint(0, 0)))
def editHistory(self, nid: NoteId) -> None:
aqt.dialogs.open("Browser", self.mw, search=(SearchNode(nid=nid),))
def add_current_note(self) -> None:
if self.editor.current_notetype_is_image_occlusion():
self.editor.update_occlusions_field()
self.editor.call_after_note_saved(self._add_current_note)
self.editor.reset_image_occlusion()
else:
self.editor.call_after_note_saved(self._add_current_note)
def _add_current_note(self) -> None:
note = self.editor.note
if not self._note_can_be_added(note):
return
target_deck_id = self.deck_chooser.selected_deck_id
def on_success(changes: OpChangesWithCount) -> None:
# only used for detecting changed sticky fields on close
self._last_added_note = note
self.addHistory(note)
tooltip(tr.importing_cards_added(count=changes.count), period=500)
av_player.stop_and_clear_queue()
self._load_new_note(sticky_fields_from=note)
gui_hooks.add_cards_did_add_note(note)
add_note(parent=self, note=note, target_deck_id=target_deck_id).success(
on_success
).run_in_background()
def _note_can_be_added(self, note: Note) -> bool:
result = note.fields_check()
# no problem, duplicate, and confirmed cloze cases
problem = None
if result == NoteFieldsCheckResult.EMPTY:
if self.editor.current_notetype_is_image_occlusion():
problem = tr.notetypes_no_occlusion_created2()
else:
problem = tr.adding_the_first_field_is_empty()
elif result == NoteFieldsCheckResult.MISSING_CLOZE:
if not askUser(tr.adding_you_have_a_cloze_deletion_note()):
return False
elif result == NoteFieldsCheckResult.NOTETYPE_NOT_CLOZE:
problem = tr.adding_cloze_outside_cloze_notetype()
elif result == NoteFieldsCheckResult.FIELD_NOT_CLOZE:
problem = tr.adding_cloze_outside_cloze_field()
# filter problem through add-ons
problem = gui_hooks.add_cards_will_add_note(problem, note)
if problem is not None:
showWarning(problem, help=HelpPage.ADDING_CARD_AND_NOTE)
return False
optional_problems: list[str] = []
gui_hooks.add_cards_might_add_note(optional_problems, note)
if not all(askUser(op) for op in optional_problems):
return False
return True
def keyPressEvent(self, evt: QKeyEvent) -> None: def keyPressEvent(self, evt: QKeyEvent) -> None:
if evt.key() == Qt.Key.Key_Escape: if evt.key() == Qt.Key.Key_Escape:
@ -354,35 +102,34 @@ class AddCards(QMainWindow):
def _close(self) -> None: def _close(self) -> None:
self.editor.cleanup() self.editor.cleanup()
self.notetype_chooser.cleanup()
self.deck_chooser.cleanup()
gui_hooks.operation_did_execute.remove(self.on_operation_did_execute)
self.mw.maybeReset() self.mw.maybeReset()
saveGeom(self, "add") saveGeom(self, "add")
aqt.dialogs.markClosed("AddCards") aqt.dialogs.markClosed("NewAddCards")
self._close_event_has_cleaned_up = True self._close_event_has_cleaned_up = True
self.mw.deferred_delete_and_garbage_collect(self) self.mw.deferred_delete_and_garbage_collect(self)
self.close() self.close()
def ifCanClose(self, onOk: Callable) -> None: def ifCanClose(self, onOk: Callable) -> None:
self._close_callback = onOk
self.editor.web.eval("closeAddCards()")
def _close_if_user_wants_to_discard_changes(self, prompt: bool) -> None:
if not prompt:
self._close_callback()
return
def callback(choice: int) -> None: def callback(choice: int) -> None:
if choice == 0: if choice == 0:
onOk() self._close_callback()
def afterSave() -> None: ask_user_dialog(
if self.editor.fieldsAreBlank(self._last_added_note): tr.adding_discard_current_input(),
return onOk() callback=callback,
buttons=[
ask_user_dialog( QMessageBox.StandardButton.Discard,
tr.adding_discard_current_input(), (tr.adding_keep_editing(), QMessageBox.ButtonRole.RejectRole),
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 closeWithCallback(self, cb: Callable[[], None]) -> None:
def doClose() -> None: def doClose() -> None:
@ -390,25 +137,3 @@ class AddCards(QMainWindow):
cb() cb()
self.ifCanClose(doClose) self.ifCanClose(doClose)
# legacy aliases
@property
def deckChooser(self) -> DeckChooser:
if getattr(self, "form", None):
# show this warning only after Qt form has been initialized,
# or PyQt's introspection triggers it
print("deckChooser is deprecated; use deck_chooser instead")
return self.deck_chooser
addCards = add_current_note
_addCards = _add_current_note
onModelChange = on_notetype_change
@deprecated(info="obsolete")
def addNote(self, note: Note) -> None:
pass
@deprecated(info="does nothing; will go away")
def removeTempNote(self, note: Note) -> None:
pass

414
qt/aqt/addcards_legacy.py Normal file
View 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

View file

@ -8,7 +8,7 @@ import json
import math import math
import re import re
from collections.abc import Callable, Sequence from collections.abc import Callable, Sequence
from typing import Any, cast from typing import Any
from markdown import markdown from markdown import markdown
@ -22,7 +22,7 @@ from anki.cards import Card, CardId
from anki.collection import Collection, Config, OpChanges, SearchNode from anki.collection import Collection, Config, OpChanges, SearchNode
from anki.consts import * from anki.consts import *
from anki.decks import DeckId from anki.decks import DeckId
from anki.errors import NotFoundError, SearchError from anki.errors import SearchError
from anki.lang import without_unicode_isolation from anki.lang import without_unicode_isolation
from anki.models import NotetypeId from anki.models import NotetypeId
from anki.notes import NoteId from anki.notes import NoteId
@ -30,7 +30,6 @@ from anki.scheduler.base import ScheduleCardsAsNew
from anki.tags import MARKED_TAG from anki.tags import MARKED_TAG
from anki.utils import is_mac from anki.utils import is_mac
from aqt import AnkiQt, gui_hooks from aqt import AnkiQt, gui_hooks
from aqt.editor import Editor, EditorWebView
from aqt.errors import show_exception from aqt.errors import show_exception
from aqt.exporting import ExportDialog as LegacyExportDialog from aqt.exporting import ExportDialog as LegacyExportDialog
from aqt.import_export.exporting import ExportDialog from aqt.import_export.exporting import ExportDialog
@ -80,7 +79,6 @@ from aqt.utils import (
tr, tr,
) )
from ..addcards import AddCards
from ..changenotetype import change_notetype_dialog from ..changenotetype import change_notetype_dialog
from .card_info import BrowserCardInfo from .card_info import BrowserCardInfo
from .find_and_replace import FindAndReplaceDialog from .find_and_replace import FindAndReplaceDialog
@ -114,7 +112,7 @@ class MockModel:
class Browser(QMainWindow): class Browser(QMainWindow):
mw: AnkiQt mw: AnkiQt
col: Collection col: Collection
editor: Editor | None editor: aqt.editor.NewEditor | None
table: Table table: Table
def __init__( def __init__(
@ -192,15 +190,7 @@ class Browser(QMainWindow):
# fixme: this will leave the splitter shown, but with no current # fixme: this will leave the splitter shown, but with no current
# note being edited # note being edited
assert self.editor is not None assert self.editor is not None
self.editor.reload_note()
note = self.editor.note
if note:
try:
note.load()
except NotFoundError:
self.editor.set_note(None)
return
self.editor.set_note(note)
if changes.browser_table and changes.card: if changes.browser_table and changes.card:
self.card = self.table.get_single_selected_card() self.card = self.table.get_single_selected_card()
@ -278,11 +268,10 @@ class Browser(QMainWindow):
return None return None
def add_card(self, deck_id: DeckId): def add_card(self, deck_id: DeckId):
add_cards = cast(AddCards, aqt.dialogs.open("AddCards", self.mw)) args = [self.mw, deck_id]
add_cards.set_deck(deck_id)
if note_type_id := self.get_active_note_type_id(): if note_type_id := self.get_active_note_type_id():
add_cards.set_note_type(note_type_id) args.append(note_type_id)
aqt.dialogs.open("NewAddCards", *args)
# If in the Browser we open Preview and press Ctrl+W there, # If in the Browser we open Preview and press Ctrl+W there,
# both Preview and Browser windows get closed by Qt out of the box. # both Preview and Browser windows get closed by Qt out of the box.
@ -403,7 +392,7 @@ class Browser(QMainWindow):
add_ellipsis_to_action_label(f.action_forget) add_ellipsis_to_action_label(f.action_forget)
add_ellipsis_to_action_label(f.action_grade_now) add_ellipsis_to_action_label(f.action_grade_now)
def _editor_web_view(self) -> EditorWebView: def _editor_web_view(self) -> aqt.editor.NewEditorWebView:
assert self.editor is not None assert self.editor is not None
editor_web_view = self.editor.web editor_web_view = self.editor.web
assert editor_web_view is not None assert editor_web_view is not None
@ -605,17 +594,19 @@ class Browser(QMainWindow):
def setupEditor(self) -> None: def setupEditor(self) -> None:
QShortcut(QKeySequence("Ctrl+Shift+P"), self, self.onTogglePreview) QShortcut(QKeySequence("Ctrl+Shift+P"), self, self.onTogglePreview)
def add_preview_button(editor: Editor) -> None: def add_preview_button(
editor: aqt.editor.Editor | aqt.editor.NewEditor,
) -> None:
editor._links["preview"] = lambda _editor: self.onTogglePreview() editor._links["preview"] = lambda _editor: self.onTogglePreview()
gui_hooks.editor_did_init.remove(add_preview_button)
gui_hooks.editor_did_init.append(add_preview_button) gui_hooks.editor_did_init.append(add_preview_button)
self.editor = aqt.editor.Editor( self.editor = aqt.editor.NewEditor(
self.mw, self.mw,
self.form.fieldsArea, self.form.fieldsArea,
self, self,
editor_mode=aqt.editor.EditorMode.BROWSER, editor_mode=aqt.editor.EditorMode.BROWSER,
) )
gui_hooks.editor_did_init.remove(add_preview_button)
@ensure_editor_saved @ensure_editor_saved
def on_all_or_selected_rows_changed(self) -> None: def on_all_or_selected_rows_changed(self) -> None:
@ -819,7 +810,7 @@ class Browser(QMainWindow):
assert current_card is not None assert current_card is not None
deck_id = current_card.current_deck_id() deck_id = current_card.current_deck_id()
aqt.dialogs.open("AddCards", self.mw).set_note(note, deck_id) aqt.dialogs.open("NewAddCards", self.mw).set_note(note, deck_id)
@no_arg_trigger @no_arg_trigger
@skip_if_selection_is_empty @skip_if_selection_is_empty
@ -843,7 +834,7 @@ class Browser(QMainWindow):
if self._previewer: if self._previewer:
self._previewer.close() self._previewer.close()
elif self.editor.note: else:
self._previewer = PreviewDialog(self, self.mw, self._on_preview_closed) self._previewer = PreviewDialog(self, self.mw, self._on_preview_closed)
self._previewer.open() self._previewer.open()
self.toggle_preview_button_state(True) self.toggle_preview_button_state(True)
@ -1265,7 +1256,7 @@ class Browser(QMainWindow):
def cb(): def cb():
assert self.editor is not None and self.editor.web is not None assert self.editor is not None and self.editor.web is not None
self.editor.web.setFocus() self.editor.web.setFocus()
self.editor.loadNote(focusTo=0) self.editor.reload_note()
assert self.editor is not None assert self.editor is not None
self.editor.call_after_note_saved(cb) self.editor.call_after_note_saved(cb)

View file

@ -6,13 +6,13 @@ from collections.abc import Callable
import aqt.editor import aqt.editor
from anki.collection import OpChanges from anki.collection import OpChanges
from anki.errors import NotFoundError
from aqt import gui_hooks from aqt import gui_hooks
from aqt.editcurrent_legacy import *
from aqt.qt import * from aqt.qt import *
from aqt.utils import add_close_shortcut, restoreGeom, saveGeom, tr from aqt.utils import add_close_shortcut, restoreGeom, saveGeom, tr
class EditCurrent(QMainWindow): class NewEditCurrent(QMainWindow):
def __init__(self, mw: aqt.AnkiQt) -> None: def __init__(self, mw: aqt.AnkiQt) -> None:
super().__init__(None, Qt.WindowType.Window) super().__init__(None, Qt.WindowType.Window)
self.mw = mw self.mw = mw
@ -23,7 +23,7 @@ class EditCurrent(QMainWindow):
self.setMinimumWidth(250) self.setMinimumWidth(250)
if not is_mac: if not is_mac:
self.setMenuBar(None) self.setMenuBar(None)
self.editor = aqt.editor.Editor( self.editor = aqt.editor.NewEditor(
self.mw, self.mw,
self.form.fieldsArea, self.form.fieldsArea,
self, self,
@ -33,13 +33,7 @@ class EditCurrent(QMainWindow):
self.editor.card = self.mw.reviewer.card self.editor.card = self.mw.reviewer.card
self.editor.set_note(self.mw.reviewer.card.note(), focusTo=0) self.editor.set_note(self.mw.reviewer.card.note(), focusTo=0)
restoreGeom(self, "editcurrent") restoreGeom(self, "editcurrent")
close_button = self.form.buttonBox.button(QDialogButtonBox.StandardButton.Close)
assert close_button is not None
close_button.setShortcut(QKeySequence("Ctrl+Return"))
add_close_shortcut(self) add_close_shortcut(self)
# qt5.14+ doesn't handle numpad enter on Windows
self.compat_add_shorcut = QShortcut(QKeySequence("Ctrl+Enter"), self)
qconnect(self.compat_add_shorcut.activated, close_button.click)
gui_hooks.operation_did_execute.append(self.on_operation_did_execute) gui_hooks.operation_did_execute.append(self.on_operation_did_execute)
self.show() self.show()
@ -47,24 +41,13 @@ class EditCurrent(QMainWindow):
self, changes: OpChanges, handler: object | None self, changes: OpChanges, handler: object | None
) -> None: ) -> None:
if changes.note_text and handler is not self.editor: if changes.note_text and handler is not self.editor:
# reload note self.editor.reload_note()
note = self.editor.note
try:
assert note is not None
note.load()
except NotFoundError:
# note's been deleted
self.cleanup()
self.close()
return
self.editor.set_note(note)
def cleanup(self) -> None: def cleanup(self) -> None:
gui_hooks.operation_did_execute.remove(self.on_operation_did_execute) gui_hooks.operation_did_execute.remove(self.on_operation_did_execute)
self.editor.cleanup() self.editor.cleanup()
saveGeom(self, "editcurrent") saveGeom(self, "editcurrent")
aqt.dialogs.markClosed("EditCurrent") aqt.dialogs.markClosed("NewEditCurrent")
def reopen(self, mw: aqt.AnkiQt) -> None: def reopen(self, mw: aqt.AnkiQt) -> None:
if card := self.mw.reviewer.card: if card := self.mw.reviewer.card:

View 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

File diff suppressed because it is too large Load diff

1789
qt/aqt/editor_legacy.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -28,16 +28,6 @@
<item> <item>
<widget class="QWidget" name="fieldsArea" native="true"/> <widget class="QWidget" name="fieldsArea" native="true"/>
</item> </item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Close</set>
</property>
</widget>
</item>
</layout> </layout>
</widget> </widget>
<widget class="QMenuBar" name="menubar"> <widget class="QMenuBar" name="menubar">
@ -60,22 +50,4 @@
<resources> <resources>
<include location="icons.qrc"/> <include location="icons.qrc"/>
</resources> </resources>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>Dialog</receiver>
<slot>close()</slot>
<hints>
<hint type="sourcelabel">
<x>316</x>
<y>260</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>274</y>
</hint>
</hints>
</connection>
</connections>
</ui> </ui>

View file

@ -1280,14 +1280,20 @@ title="{}" {}>{}</button>""".format(
# Other menu operations # 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: def onAddCard(self) -> None:
aqt.dialogs.open("AddCards", self) self._open_new_or_legacy_dialog("AddCards")
def onBrowse(self) -> None: def onBrowse(self) -> None:
aqt.dialogs.open("Browser", self, card=self.reviewer.card) aqt.dialogs.open("Browser", self, card=self.reviewer.card)
def onEditCurrent(self) -> None: def onEditCurrent(self) -> None:
aqt.dialogs.open("EditCurrent", self) self._open_new_or_legacy_dialog("EditCurrent")
def onOverview(self) -> None: def onOverview(self) -> None:
self.moveToState("overview") self.moveToState("overview")
@ -1296,11 +1302,7 @@ title="{}" {}>{}</button>""".format(
deck = self._selectedDeck() deck = self._selectedDeck()
if not deck: if not deck:
return return
want_old = KeyboardModifiersPressed().shift self._open_new_or_legacy_dialog("DeckStats", self)
if want_old:
aqt.dialogs.open("DeckStats", self)
else:
aqt.dialogs.open("NewDeckStats", self)
def onPrefs(self) -> None: def onPrefs(self) -> None:
aqt.dialogs.open("Preferences", self) aqt.dialogs.open("Preferences", self)

View file

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import enum import enum
import logging import logging
import mimetypes import mimetypes
@ -16,6 +17,7 @@ from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from errno import EPROTOTYPE from errno import EPROTOTYPE
from http import HTTPStatus from http import HTTPStatus
from typing import Any, Generic, cast
import flask import flask
import flask_cors import flask_cors
@ -27,18 +29,25 @@ from waitress.server import create_server
import aqt import aqt
import aqt.main import aqt.main
import aqt.operations import aqt.operations
from anki import hooks from anki import frontend_pb2, generic_pb2, hooks
from anki.collection import OpChanges, OpChangesOnly, Progress, SearchNode from anki.collection import OpChanges, OpChangesOnly, Progress, SearchNode
from anki.decks import UpdateDeckConfigs from anki.decks import UpdateDeckConfigs
from anki.scheduler.v3 import SchedulingStatesWithContext, SetSchedulingStatesRequest from anki.scheduler.v3 import SchedulingStatesWithContext, SetSchedulingStatesRequest
from anki.utils import dev_mode from anki.utils import dev_mode, from_json_bytes, to_json_bytes
from aqt.changenotetype import ChangeNotetypeDialog from aqt.changenotetype import ChangeNotetypeDialog
from aqt.deckoptions import DeckOptionsDialog from aqt.deckoptions import DeckOptionsDialog
from aqt.operations import on_op_finished from aqt.operations import on_op_finished
from aqt.operations.deck import update_deck_configs as update_deck_configs_op from aqt.operations.deck import update_deck_configs as update_deck_configs_op
from aqt.progress import ProgressUpdate from aqt.progress import ProgressUpdate
from aqt.qt import * from aqt.qt import *
from aqt.utils import aqt_data_path, show_warning, tr from aqt.utils import (
aqt_data_path,
askUser,
openLink,
show_info,
show_warning,
tr,
)
# https://forums.ankiweb.net/t/anki-crash-when-using-a-specific-deck/22266 # https://forums.ankiweb.net/t/anki-crash-when-using-a-specific-deck/22266
waitress.wasyncore._DISCONNECTED = waitress.wasyncore._DISCONNECTED.union({EPROTOTYPE}) # type: ignore waitress.wasyncore._DISCONNECTED = waitress.wasyncore._DISCONNECTED.union({EPROTOTYPE}) # type: ignore
@ -334,6 +343,7 @@ def is_sveltekit_page(path: str) -> bool:
"import-csv", "import-csv",
"import-page", "import-page",
"image-occlusion", "image-occlusion",
"editor",
] ]
@ -599,6 +609,304 @@ def deck_options_ready() -> bytes:
return b"" 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 = [ post_handler_list = [
congrats_info, congrats_info,
get_deck_configs_for_update, get_deck_configs_for_update,
@ -614,6 +922,26 @@ post_handler_list = [
search_in_browser, search_in_browser,
deck_options_require_close, deck_options_require_close,
deck_options_ready, 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 # NotesService
"get_field_names", "get_field_names",
"get_note", "get_note",
"new_note",
"note_fields_check",
"defaults_for_adding",
"default_deck_for_notetype",
# NotetypesService # NotetypesService
"get_notetype",
"get_notetype_names", "get_notetype_names",
"get_change_notetype_info", "get_change_notetype_info",
"get_cloze_field_ords",
# StatsService # StatsService
"card_stats", "card_stats",
"get_review_logs", "get_review_logs",
@ -658,6 +992,21 @@ exposed_backend_list = [
# DeckConfigService # DeckConfigService
"get_ignored_before_count", "get_ignored_before_count",
"get_retention_workload", "get_retention_workload",
# CardRenderingService
"encode_iri_paths",
"decode_iri_paths",
"html_to_text_line",
# ConfigService
"set_config_json",
"get_config_bool",
# MediaService
"add_media_file",
"add_media_from_path",
"add_media_from_url",
"get_absolute_media_path",
"extract_media_files",
# CardsService
"get_card",
] ]
@ -686,7 +1035,25 @@ def _extract_collection_post_request(path: str) -> DynamicRequest | NotFound:
# convert bytes/None into response # convert bytes/None into response
def wrapped() -> Response: def wrapped() -> Response:
try: try:
if data := handler(): import inspect
if inspect.iscoroutinefunction(handler):
try:
loop = asyncio.get_event_loop()
if loop.is_running():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(asyncio.run, handler())
data = future.result()
else:
data = loop.run_until_complete(handler())
except RuntimeError:
data = asyncio.run(handler())
else:
result = handler()
data = result
if data:
response = flask.make_response(data) response = flask.make_response(data)
response.headers["Content-Type"] = "application/binary" response.headers["Content-Type"] = "application/binary"
else: else:

View file

@ -1167,7 +1167,7 @@ timerStopped = false;
def on_create_copy(self) -> None: def on_create_copy(self) -> None:
if self.card: if self.card:
aqt.dialogs.open("AddCards", self.mw).set_note( aqt.dialogs.open("NewAddCards", self.mw).set_note(
self.card.note(), self.card.current_deck_id() self.card.note(), self.card.current_deck_id()
) )

View file

@ -5,7 +5,7 @@ requires-python = ">=3.9"
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
dependencies = [ dependencies = [
"beautifulsoup4", "beautifulsoup4",
"flask", "flask[async]",
"flask_cors", "flask_cors",
"jsonschema", "jsonschema",
"requests", "requests",

View file

@ -1008,12 +1008,15 @@ hooks = [
################### ###################
Hook( Hook(
name="add_cards_will_show_history_menu", name="add_cards_will_show_history_menu",
args=["addcards: aqt.addcards.AddCards", "menu: QMenu"], args=[
"addcards: aqt.addcards.AddCards | aqt.addcards.NewAddCards",
"menu: QMenu",
],
legacy_hook="AddCards.onHistory", legacy_hook="AddCards.onHistory",
), ),
Hook( Hook(
name="add_cards_did_init", name="add_cards_did_init",
args=["addcards: aqt.addcards.AddCards"], args=["addcards: aqt.addcards.AddCards | aqt.addcards.NewAddCards"],
), ),
Hook( Hook(
name="add_cards_did_add_note", name="add_cards_did_add_note",
@ -1068,7 +1071,7 @@ hooks = [
Hook( Hook(
name="addcards_did_change_note_type", name="addcards_did_change_note_type",
args=[ args=[
"addcards: aqt.addcards.AddCards", "addcards: aqt.addcards.AddCards | aqt.addcards.NewAddCards",
"old: anki.models.NoteType", "old: anki.models.NoteType",
"new: anki.models.NoteType", "new: anki.models.NoteType",
], ],
@ -1087,20 +1090,26 @@ hooks = [
################### ###################
Hook( Hook(
name="editor_did_init_left_buttons", name="editor_did_init_left_buttons",
args=["buttons: list[str]", "editor: aqt.editor.Editor"], args=["buttons: list[str]", "editor: aqt.editor.Editor | aqt.editor.NewEditor"],
), ),
Hook( Hook(
name="editor_did_init_buttons", name="editor_did_init_buttons",
args=["buttons: list[str]", "editor: aqt.editor.Editor"], args=["buttons: list[str]", "editor: aqt.editor.Editor | aqt.editor.NewEditor"],
), ),
Hook( Hook(
name="editor_did_init_shortcuts", name="editor_did_init_shortcuts",
args=["shortcuts: list[tuple]", "editor: aqt.editor.Editor"], args=[
"shortcuts: list[tuple]",
"editor: aqt.editor.Editor | aqt.editor.NewEditor",
],
legacy_hook="setupEditorShortcuts", legacy_hook="setupEditorShortcuts",
), ),
Hook( Hook(
name="editor_will_show_context_menu", name="editor_will_show_context_menu",
args=["editor_webview: aqt.editor.EditorWebView", "menu: QMenu"], args=[
"editor_webview: aqt.editor.EditorWebView | aqt.editor.NewEditorWebView",
"menu: QMenu",
],
legacy_hook="EditorWebView.contextMenuEvent", legacy_hook="EditorWebView.contextMenuEvent",
), ),
Hook( Hook(
@ -1121,7 +1130,7 @@ hooks = [
), ),
Hook( Hook(
name="editor_did_load_note", name="editor_did_load_note",
args=["editor: aqt.editor.Editor"], args=["editor: aqt.editor.Editor | aqt.editor.NewEditor"],
legacy_hook="loadNote", legacy_hook="loadNote",
), ),
Hook( Hook(
@ -1131,7 +1140,7 @@ hooks = [
), ),
Hook( Hook(
name="editor_will_munge_html", name="editor_will_munge_html",
args=["txt: str", "editor: aqt.editor.Editor"], args=["txt: str", "editor: aqt.editor.Editor | aqt.editor.NewEditor"],
return_type="str", return_type="str",
doc="""Allows manipulating the text that will be saved by the editor""", doc="""Allows manipulating the text that will be saved by the editor""",
), ),
@ -1143,15 +1152,21 @@ hooks = [
), ),
Hook( Hook(
name="editor_web_view_did_init", name="editor_web_view_did_init",
args=["editor_web_view: aqt.editor.EditorWebView"], args=[
"editor_web_view: aqt.editor.EditorWebView | aqt.editor.NewEditorWebView"
],
), ),
Hook( Hook(
name="editor_did_init", name="editor_did_init",
args=["editor: aqt.editor.Editor"], args=["editor: aqt.editor.Editor | aqt.editor.NewEditor"],
), ),
Hook( Hook(
name="editor_will_load_note", name="editor_will_load_note",
args=["js: str", "note: anki.notes.Note", "editor: aqt.editor.Editor"], args=[
"js: str",
"note: anki.notes.Note",
"editor: aqt.editor.Editor | aqt.editor.NewEditor",
],
return_type="str", return_type="str",
doc="""Allows changing the javascript commands to load note before doc="""Allows changing the javascript commands to load note before
executing it and do change in the QT editor.""", executing it and do change in the QT editor.""",
@ -1159,7 +1174,7 @@ hooks = [
Hook( Hook(
name="editor_did_paste", name="editor_did_paste",
args=[ args=[
"editor: aqt.editor.Editor", "editor: aqt.editor.Editor | aqt.editor.NewEditor",
"html: str", "html: str",
"internal: bool", "internal: bool",
"extended: bool", "extended: bool",
@ -1170,7 +1185,7 @@ hooks = [
name="editor_will_process_mime", name="editor_will_process_mime",
args=[ args=[
"mime: QMimeData", "mime: QMimeData",
"editor_web_view: aqt.editor.EditorWebView", "editor_web_view: aqt.editor.EditorWebView | aqt.editor.NewEditorWebView",
"internal: bool", "internal: bool",
"extended: bool", "extended: bool",
"drop_event: bool", "drop_event: bool",
@ -1194,7 +1209,7 @@ hooks = [
Hook( Hook(
name="editor_state_did_change", name="editor_state_did_change",
args=[ args=[
"editor: aqt.editor.Editor", "editor: aqt.editor.Editor | aqt.editor.NewEditor",
"new_state: aqt.editor.EditorState", "new_state: aqt.editor.EditorState",
"old_state: aqt.editor.EditorState", "old_state: aqt.editor.EditorState",
], ],
@ -1203,7 +1218,10 @@ hooks = [
), ),
Hook( Hook(
name="editor_mask_editor_did_load_image", name="editor_mask_editor_did_load_image",
args=["editor: aqt.editor.Editor", "path_or_nid: str | anki.notes.NoteId"], args=[
"editor: aqt.editor.Editor | aqt.editor.NewEditor",
"path_or_nid: str | anki.notes.NoteId",
],
doc="""Called when the image occlusion mask editor has completed doc="""Called when the image occlusion mask editor has completed
loading an image. loading an image.

View 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)
}
}

View file

@ -12,6 +12,7 @@ pub(crate) mod dbproxy;
mod error; mod error;
mod i18n; mod i18n;
mod import_export; mod import_export;
mod media;
mod ops; mod ops;
mod sync; mod sync;

117
rslib/src/editor.rs Normal file
View 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
}

View file

@ -16,6 +16,7 @@ pub mod config;
pub mod dbcheck; pub mod dbcheck;
pub mod deckconfig; pub mod deckconfig;
pub mod decks; pub mod decks;
pub mod editor;
pub mod error; pub mod error;
pub mod findreplace; pub mod findreplace;
pub mod i18n; pub mod i18n;

View file

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

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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} />

View 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()}
/>

View file

@ -6,16 +6,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import * as tr from "@generated/ftl"; import * as tr from "@generated/ftl";
import { renderMarkdown } from "@tslib/helpers"; import { renderMarkdown } from "@tslib/helpers";
import Carousel from "bootstrap/js/dist/carousel"; import Carousel from "bootstrap/js/dist/carousel";
import Modal from "bootstrap/js/dist/modal"; import { createEventDispatcher, onMount } from "svelte";
import { createEventDispatcher, getContext, onDestroy, onMount } from "svelte"; import Modal from "./Modal.svelte";
import { infoCircle } from "$lib/components/icons"; import { infoCircle } from "$lib/components/icons";
import { registerModalClosingHandler } from "$lib/sveltelib/modal-closing";
import { pageTheme } from "$lib/sveltelib/theme"; import { pageTheme } from "$lib/sveltelib/theme";
import Badge from "./Badge.svelte"; import Badge from "./Badge.svelte";
import Col from "./Col.svelte"; import Col from "./Col.svelte";
import { modalsKey } from "./context-keys";
import HelpSection from "./HelpSection.svelte"; import HelpSection from "./HelpSection.svelte";
import Icon from "./Icon.svelte"; import Icon from "./Icon.svelte";
import Row from "./Row.svelte"; import Row from "./Row.svelte";
@ -27,50 +25,20 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let helpSections: HelpItem[]; export let helpSections: HelpItem[];
export let fsrs = false; export let fsrs = false;
export const modalKey: string = Math.random().toString(36).substring(2);
const modals = getContext<Map<string, Modal>>(modalsKey);
let modal: Modal;
let carousel: Carousel; let carousel: Carousel;
let modalRef: HTMLDivElement; let modal: Modal;
let carouselRef: HTMLDivElement; let carouselRef: HTMLDivElement;
function onOkClicked(): void {
modal.hide();
}
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
const { set: setModalOpen, remove: removeModalClosingHandler } =
registerModalClosingHandler(onOkClicked);
function onShown() {
setModalOpen(true);
}
function onHidden() {
setModalOpen(false);
}
onMount(() => { onMount(() => {
modalRef.addEventListener("shown.bs.modal", onShown);
modalRef.addEventListener("hidden.bs.modal", onHidden);
modal = new Modal(modalRef, { keyboard: false });
carousel = new Carousel(carouselRef, { interval: false, ride: false }); carousel = new Carousel(carouselRef, { interval: false, ride: false });
/* Bootstrap's Carousel.Event interface doesn't seem to work as a type here */ /* Bootstrap's Carousel.Event interface doesn't seem to work as a type here */
carouselRef.addEventListener("slide.bs.carousel", (e: any) => { carouselRef.addEventListener("slide.bs.carousel", (e: any) => {
activeIndex = e.to; activeIndex = e.to;
}); });
dispatch("mount", { modal: modal, carousel: carousel }); dispatch("mount", { modal: modal, carousel: carousel });
modals.set(modalKey, modal);
});
onDestroy(() => {
removeModalClosingHandler();
modalRef.removeEventListener("shown.bs.modal", onShown);
modalRef.removeEventListener("hidden.bs.modal", onHidden);
}); });
let activeIndex = startIndex; let activeIndex = startIndex;
@ -80,119 +48,98 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<Icon icon={infoCircle} /> <Icon icon={infoCircle} />
</Badge> </Badge>
<div <Modal bind:this={modal} dialogClass="modal-lg">
bind:this={modalRef} <div slot="header" class="modal-header">
class="modal fade" <div style="display: flex;">
tabindex="-1" <h1 class="modal-title" id="modalLabel">
aria-labelledby="modalLabel" {title}
aria-hidden="true" </h1>
> <button
<div class="modal-dialog modal-lg"> type="button"
<div class="modal-content"> class="btn-close"
<div class="modal-header"> class:invert={$pageTheme.isDark}
<div style="display: flex;"> data-bs-dismiss="modal"
<h1 class="modal-title" id="modalLabel"> aria-label="Close"
{title} ></button>
</h1> </div>
<button {#if url}
type="button" <div class="chapter-redirect">
class="btn-close" {@html renderMarkdown(
class:invert={$pageTheme.isDark} tr.helpForMoreInfo({
data-bs-dismiss="modal" link: `<a href="${url}" title="${tr.helpOpenManualChapter({
aria-label="Close" name: title,
></button> })}">${title}</a>`,
</div> }),
{#if url} )}
<div class="chapter-redirect">
{@html renderMarkdown(
tr.helpForMoreInfo({
link: `<a href="${url}" title="${tr.helpOpenManualChapter(
{
name: title,
},
)}">${title}</a>`,
}),
)}
</div>
{/if}
</div> </div>
<div class="modal-body"> {/if}
<Row --cols={4}> </div>
<Col --col-size={1}> <div slot="body" class="modal-body">
<nav> <Row --cols={4}>
<div id="nav"> <Col --col-size={1}>
<ul> <nav>
{#each helpSections as item, i} <div id="nav">
<li> <ul>
<button {#each helpSections as item, i}
on:click={() => { <li>
activeIndex = i; <button
carousel.to(activeIndex); on:click={() => {
}} activeIndex = i;
class:active={i == activeIndex} carousel.to(activeIndex);
class:d-none={fsrs }}
? item.sched === class:active={i == activeIndex}
HelpItemScheduler.SM2
: item.sched ==
HelpItemScheduler.FSRS}
>
{item.title}
</button>
</li>
{/each}
</ul>
</div>
</nav>
</Col>
<Col --col-size={3}>
<div
id="helpSectionIndicators"
class="carousel slide"
bind:this={carouselRef}
>
<div class="carousel-inner">
{#each helpSections as item, i}
<div
class="carousel-item"
class:active={i == startIndex}
class:d-none={fsrs class:d-none={fsrs
? item.sched === HelpItemScheduler.SM2 ? item.sched === HelpItemScheduler.SM2
: item.sched == HelpItemScheduler.FSRS} : item.sched == HelpItemScheduler.FSRS}
> >
<HelpSection {item} /> {item.title}
</div> </button>
{/each} </li>
{/each}
</ul>
</div>
</nav>
</Col>
<Col --col-size={3}>
<div
id="helpSectionIndicators"
class="carousel slide"
bind:this={carouselRef}
>
<div class="carousel-inner">
{#each helpSections as item, i}
<div
class="carousel-item"
class:active={i == startIndex}
class:d-none={fsrs
? item.sched === HelpItemScheduler.SM2
: item.sched == HelpItemScheduler.FSRS}
>
<HelpSection {item} />
</div> </div>
</div> {/each}
</Col> </div>
</Row> </div>
</div> </Col>
<div class="modal-footer"> </Row>
<button type="button" class="btn btn-primary" on:click={onOkClicked}>
{tr.helpOk()}
</button>
</div>
</div>
</div> </div>
</div> <div slot="footer" class="modal-footer">
<button type="button" class="btn btn-primary" on:click={modal.onOkClicked}>
{tr.helpOk()}
</button>
</div>
</Modal>
<style lang="scss"> <style lang="scss">
#nav { #nav {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.modal {
z-index: 1066;
background-color: rgba($color: black, $alpha: 0.5);
}
.modal-title { .modal-title {
margin-inline-end: 0.75rem; margin-inline-end: 0.75rem;
} }
.modal-content { :global(.modal-content) {
background-color: var(--canvas);
color: var(--fg);
border-radius: var(--border-radius-medium, 10px); border-radius: var(--border-radius-medium, 10px);
} }

View 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>

View 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>

View 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()}
/>

View file

@ -15,6 +15,8 @@ import AlignVerticalCenter_ from "@mdi/svg/svg/align-vertical-center.svg?compone
import alignVerticalCenter_ from "@mdi/svg/svg/align-vertical-center.svg?url"; import alignVerticalCenter_ from "@mdi/svg/svg/align-vertical-center.svg?url";
import AlignVerticalTop_ from "@mdi/svg/svg/align-vertical-top.svg?component"; import AlignVerticalTop_ from "@mdi/svg/svg/align-vertical-top.svg?component";
import alignVerticalTop_ from "@mdi/svg/svg/align-vertical-top.svg?url"; import alignVerticalTop_ from "@mdi/svg/svg/align-vertical-top.svg?url";
import BookOutline_ from "@mdi/svg/svg/book-outline.svg?component";
import bookOutline_ from "@mdi/svg/svg/book-outline.svg?url";
import CheckCircle_ from "@mdi/svg/svg/check-circle.svg?component"; import CheckCircle_ from "@mdi/svg/svg/check-circle.svg?component";
import checkCircle_ from "@mdi/svg/svg/check-circle.svg?url"; import checkCircle_ from "@mdi/svg/svg/check-circle.svg?url";
import ChevronDown_ from "@mdi/svg/svg/chevron-down.svg?component"; import ChevronDown_ from "@mdi/svg/svg/chevron-down.svg?component";
@ -111,6 +113,8 @@ import Math_ from "@mdi/svg/svg/math-integral-box.svg?component";
import math_ from "@mdi/svg/svg/math-integral-box.svg?url"; import math_ from "@mdi/svg/svg/math-integral-box.svg?url";
import NewBox_ from "@mdi/svg/svg/new-box.svg?component"; import NewBox_ from "@mdi/svg/svg/new-box.svg?component";
import newBox_ from "@mdi/svg/svg/new-box.svg?url"; import newBox_ from "@mdi/svg/svg/new-box.svg?url";
import Newspaper_ from "@mdi/svg/svg/newspaper.svg?component";
import newspaper_ from "@mdi/svg/svg/newspaper.svg?url";
import Paperclip_ from "@mdi/svg/svg/paperclip.svg?component"; import Paperclip_ from "@mdi/svg/svg/paperclip.svg?component";
import paperclip_ from "@mdi/svg/svg/paperclip.svg?url"; import paperclip_ from "@mdi/svg/svg/paperclip.svg?url";
import RectangleOutline_ from "@mdi/svg/svg/rectangle-outline.svg?component"; import RectangleOutline_ from "@mdi/svg/svg/rectangle-outline.svg?component";
@ -149,6 +153,8 @@ import ArrowLeft_ from "bootstrap-icons/icons/arrow-left.svg?component";
import arrowLeft_ from "bootstrap-icons/icons/arrow-left.svg?url"; import arrowLeft_ from "bootstrap-icons/icons/arrow-left.svg?url";
import ArrowRight_ from "bootstrap-icons/icons/arrow-right.svg?component"; import ArrowRight_ from "bootstrap-icons/icons/arrow-right.svg?component";
import arrowRight_ from "bootstrap-icons/icons/arrow-right.svg?url"; import arrowRight_ from "bootstrap-icons/icons/arrow-right.svg?url";
import CaretDownFill_ from "bootstrap-icons/icons/caret-down-fill.svg?component";
import caretDownFill_ from "bootstrap-icons/icons/caret-down-fill.svg?url";
import Minus_ from "bootstrap-icons/icons/dash-lg.svg?component"; import Minus_ from "bootstrap-icons/icons/dash-lg.svg?component";
import minus_ from "bootstrap-icons/icons/dash-lg.svg?url"; import minus_ from "bootstrap-icons/icons/dash-lg.svg?url";
import Eraser_ from "bootstrap-icons/icons/eraser.svg?component"; import Eraser_ from "bootstrap-icons/icons/eraser.svg?component";
@ -285,3 +291,6 @@ export const mdiUngroup = { url: ungroup_, component: Ungroup_ };
export const mdiVectorPolygonVariant = { url: vectorPolygonVariant_, component: VectorPolygonVariant_ }; export const mdiVectorPolygonVariant = { url: vectorPolygonVariant_, component: VectorPolygonVariant_ };
export const incrementClozeIcon = { url: incrementCloze_, component: IncrementCloze_ }; export const incrementClozeIcon = { url: incrementCloze_, component: IncrementCloze_ };
export const mdiEarth = { url: earth_, component: Earth_ }; export const mdiEarth = { url: earth_, component: Earth_ };
export const caretDownFill = { url: caretDownFill_, component: CaretDownFill_ };
export const mdiNewspaper = { url: newspaper_, component: Newspaper_ };
export const mdiBookOutline = { url: bookOutline_, component: BookOutline_ };

View 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>

View 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>

View 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";

View 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;
}

View file

@ -57,21 +57,5 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
></anki-editable> ></anki-editable>
<style lang="scss"> <style lang="scss">
anki-editable { @import "./content-editable.scss";
display: block;
position: relative;
overflow: auto;
overflow-wrap: anywhere;
/* fallback for iOS */
word-break: break-word;
&:focus {
outline: none;
}
min-height: 1.5em;
}
/* editable-base.scss contains styling targeting user HTML */
</style> </style>

View file

@ -125,24 +125,5 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/> />
<style lang="scss"> <style lang="scss">
:global(anki-mathjax) { @import "./mathjax.scss";
white-space: pre;
}
img {
vertical-align: var(--vertical-center);
}
.block {
display: block;
margin: 1rem auto;
transform: scale(1.1);
}
.empty {
vertical-align: text-bottom;
width: var(--font-size);
height: var(--font-size);
}
</style> </style>

View 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;
}

View file

@ -1,4 +1,4 @@
@use "../lib/sass/scrollbar"; @use "../sass/scrollbar";
* { * {
max-width: 100%; max-width: 100%;

View file

@ -2,6 +2,5 @@
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import "./editable-base.scss"; import "./editable-base.scss";
/* only imported for the CSS */ import "./content-editable.scss";
import "./ContentEditable.svelte"; import "./mathjax.scss";
import "./Mathjax.svelte";

View 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);
}

View file

@ -34,6 +34,12 @@ const allow = (attrs: string[]): FilterMethod => (element: Element): void =>
element, element,
); );
function convertToDiv(element: Element): void {
const div = document.createElement("div");
div.innerHTML = element.innerHTML;
element.replaceWith(div);
}
function unwrapElement(element: Element): void { function unwrapElement(element: Element): void {
element.replaceWith(...element.childNodes); element.replaceWith(...element.childNodes);
} }
@ -50,7 +56,7 @@ const tagsAllowedBasic: TagsAllowed = {
BR: allowNone, BR: allowNone,
IMG: allow(["SRC", "ALT"]), IMG: allow(["SRC", "ALT"]),
DIV: allowNone, DIV: allowNone,
P: allowNone, P: convertToDiv,
SUB: allowNone, SUB: allowNone,
SUP: allowNone, SUP: allowNone,
TITLE: removeElement, TITLE: removeElement,

View file

@ -33,6 +33,11 @@ const outputHTMLProcessors: Record<FilterMode, (outputHTML: string) => string> =
}; };
export function filterHTML(html: string, internal: boolean, extended: boolean): string { export function filterHTML(html: string, internal: boolean, extended: boolean): string {
// https://anki.tenderapp.com/discussions/ankidesktop/39543-anki-is-replacing-the-character-by-when-i-exit-the-html-edit-mode-ctrlshiftx
if (html.indexOf(">") < 0) {
return html;
}
const template = document.createElement("template"); const template = document.createElement("template");
template.innerHTML = html; template.innerHTML = html;

View file

@ -240,6 +240,7 @@ export interface DefaultSlotInterface extends Record<string, unknown> {
show(position: Identifier): Promise<boolean>; show(position: Identifier): Promise<boolean>;
hide(position: Identifier): Promise<boolean>; hide(position: Identifier): Promise<boolean>;
toggle(position: Identifier): Promise<boolean>; toggle(position: Identifier): Promise<boolean>;
setShown(position: Identifier, shown: boolean): Promise<boolean>;
} }
export function defaultInterface<T extends SlotHostProps, U extends Element>({ export function defaultInterface<T extends SlotHostProps, U extends Element>({
@ -287,12 +288,20 @@ export function defaultInterface<T extends SlotHostProps, U extends Element>({
}, id); }, id);
} }
function setShown(id: Identifier, shown: boolean): Promise<boolean> {
return updateProps((props: T): T => {
props.detach.set(!shown);
return props;
}, id);
}
return { return {
insert, insert,
append, append,
show, show,
hide, hide,
toggle, toggle,
setShown,
}; };
} }

View file

@ -46,4 +46,7 @@ export const HelpPage = {
updating: "https://docs.ankiweb.net/importing/text-files.html#duplicates-and-updating", updating: "https://docs.ankiweb.net/importing/text-files.html#duplicates-and-updating",
html: "https://docs.ankiweb.net/importing/text-files.html#html", html: "https://docs.ankiweb.net/importing/text-files.html#html",
}, },
Editing: {
adding: "https://docs.ankiweb.net/editing.html#adding-cards-and-notes",
},
}; };

55
ts/lib/tslib/profile.ts Normal file
View 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 }),
);
}

View file

@ -3,11 +3,7 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts"> <script lang="ts">
import Modal from "bootstrap/js/dist/modal"; import Modal from "$lib/components/Modal.svelte";
import { getContext, onDestroy, onMount } from "svelte";
import { modalsKey } from "$lib/components/context-keys";
import { registerModalClosingHandler } from "$lib/sveltelib/modal-closing";
import { pageTheme } from "$lib/sveltelib/theme"; import { pageTheme } from "$lib/sveltelib/theme";
export let title: string; export let title: string;
@ -16,117 +12,65 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let onOk: (text: string) => void; export let onOk: (text: string) => void;
$: value = initialValue; $: value = initialValue;
export const modalKey: string = Math.random().toString(36).substring(2);
const modals = getContext<Map<string, Modal>>(modalsKey);
let modalRef: HTMLDivElement;
let modal: Modal;
let inputRef: HTMLInputElement; let inputRef: HTMLInputElement;
let modal: Modal;
export let modalKey: string;
function onOkClicked(): void { function onOkClicked(): void {
onOk(inputRef.value); onOk(inputRef.value);
modal.hide();
value = initialValue; value = initialValue;
} }
function onCancelClicked(): void { function onCancelClicked(): void {
modal.hide();
value = initialValue; value = initialValue;
} }
function onShown(): void { function onShown(): void {
inputRef.focus(); inputRef.focus();
setModalOpen(true);
} }
function onHidden() {
setModalOpen(false);
}
const { set: setModalOpen, remove: removeModalClosingHandler } =
registerModalClosingHandler(onCancelClicked);
onMount(() => {
modalRef.addEventListener("shown.bs.modal", onShown);
modalRef.addEventListener("hidden.bs.modal", onHidden);
modal = new Modal(modalRef, { keyboard: false });
modals.set(modalKey, modal);
});
onDestroy(() => {
removeModalClosingHandler();
modalRef.removeEventListener("shown.bs.modal", onShown);
modalRef.removeEventListener("hidden.bs.modal", onHidden);
});
</script> </script>
<div <Modal bind:this={modal} bind:modalKey {onOkClicked} {onShown} {onCancelClicked}>
bind:this={modalRef} <div slot="header" class="modal-header">
class="modal fade" <h5 class="modal-title" id="modalLabel">{title}</h5>
tabindex="-1" <button
aria-labelledby="modalLabel" type="button"
aria-hidden="true" class="btn-close"
> data-bs-dismiss="modal"
<div class="modal-dialog"> aria-label="Close"
<div class="modal-content" class:default-colors={$pageTheme.isDark}> ></button>
<div 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 class="mb-3">
<label for="prompt-input" class="col-form-label">
{prompt}:
</label>
<input
id="prompt-input"
bind:this={inputRef}
type="text"
class:nightMode={$pageTheme.isDark}
class="form-control"
bind:value
/>
</div>
</form>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-secondary"
on:click={onCancelClicked}
>
Cancel
</button>
<button type="button" class="btn btn-primary" on:click={onOkClicked}>
OK
</button>
</div>
</div>
</div> </div>
</div> <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}:
</label>
<input
id="prompt-input"
bind:this={inputRef}
type="text"
class:nightMode={$pageTheme.isDark}
class="form-control"
bind:value
/>
</div>
</form>
</div>
<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={modal.acceptHandler}>
OK
</button>
</div>
</Modal>
<style lang="scss"> <style lang="scss">
@use "$lib/sass/night-mode" as nightmode; @use "../../lib/sass/night-mode" as nightmode;
.nightMode { .nightMode {
@include nightmode.input; @include nightmode.input;
} }
.default-colors {
background-color: var(--canvas);
color: var(--fg);
}
.invert {
filter: invert(1) grayscale(100%) brightness(200%);
}
</style> </style>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View file

@ -130,7 +130,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</div> </div>
<style lang="scss"> <style lang="scss">
@use "../lib/sass/elevation" as *; @use "../../lib/sass/elevation" as *;
/* Make sure labels are readable on custom Qt backgrounds */ /* Make sure labels are readable on custom Qt backgrounds */
.field-container { .field-container {

View 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>

View 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>

View 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>

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts"> <script lang="ts">
import * as tr from "@generated/ftl"; import * as tr from "@generated/ftl";
import { bridgeCommand } from "@tslib/bridgecommand";
import { getPlatformString, registerShortcut } from "@tslib/shortcuts"; import { getPlatformString, registerShortcut } from "@tslib/shortcuts";
import { onEnterOrSpace } from "@tslib/keys"; import { onEnterOrSpace } from "@tslib/keys";
import { onMount } from "svelte"; 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 { stickyIconSolid } from "$lib/components/icons";
import { context as editorFieldContext } from "./EditorField.svelte"; 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"); const animated = !document.body.classList.contains("reduce-motion");
export let active: boolean; export let active: boolean;
export let show: boolean; export let show: boolean;
export let isLegacy: boolean;
const editorField = editorFieldContext.get(); const editorField = editorFieldContext.get();
const keyCombination = "F9"; const keyCombination = "F9";
export let index: number; export let index: number;
export let note: Note;
function toggle() { async function toggle() {
bridgeCommand(`toggleSticky:${index}`, (value: boolean) => { if (isLegacy) {
active = value; 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 { function shortcut(target: HTMLElement): () => void {
@ -52,12 +63,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<Badge <Badge
tooltip="{tr.editingToggleSticky()} ({getPlatformString(keyCombination)})" tooltip="{tr.editingToggleSticky()} ({getPlatformString(keyCombination)})"
widthMultiplier={0.7} widthMultiplier={0.7}
></Badge> >
{#if active} {#if active}
<Icon icon={stickyIconSolid} /> <Icon icon={stickyIconSolid} />
{:else} {:else}
<Icon icon={stickyIconHollow} /> <Icon icon={stickyIconHollow} />
{/if} {/if}
</Badge>
</span> </span>
<style lang="scss"> <style lang="scss">

View file

@ -19,10 +19,7 @@ import LabelButton from "$lib/components/LabelButton.svelte";
import WithContext from "$lib/components/WithContext.svelte"; import WithContext from "$lib/components/WithContext.svelte";
import WithState from "$lib/components/WithState.svelte"; import WithState from "$lib/components/WithState.svelte";
import BrowserEditor from "./BrowserEditor.svelte"; import NoteEditor, * as editorContextKeys from "./NoteEditor.svelte";
import NoteCreator from "./NoteCreator.svelte";
import * as editorContextKeys from "./NoteEditor.svelte";
import ReviewerEditor from "./ReviewerEditor.svelte";
declare global { declare global {
interface Selection { interface Selection {
@ -32,8 +29,10 @@ declare global {
} }
} }
import { modalsKey } from "$lib/components/context-keys";
import { ModuleName } from "@tslib/i18n"; import { ModuleName } from "@tslib/i18n";
import { mount } from "svelte"; import { mount } from "svelte";
import type { EditorMode } from "./types";
export const editorModules = [ export const editorModules = [
ModuleName.EDITING, ModuleName.EDITING,
@ -43,6 +42,9 @@ export const editorModules = [
ModuleName.NOTETYPES, ModuleName.NOTETYPES,
ModuleName.IMPORTING, ModuleName.IMPORTING,
ModuleName.UNDO, ModuleName.UNDO,
ModuleName.ADDING,
ModuleName.QT_MISC,
ModuleName.DECKS,
]; ];
export const components = { export const components = {
@ -55,33 +57,13 @@ export const components = {
export { editorToolbar } from "./editor-toolbar"; export { editorToolbar } from "./editor-toolbar";
async function setupBrowserEditor(): Promise<void> { export async function setupEditor(mode: EditorMode, isLegacy = false) {
await setupI18n({ modules: editorModules }); if (!["add", "browser", "current"].includes(mode)) {
mount(BrowserEditor, { target: document.body, props: { uiResolve } }); alert("unexpected editor type");
} return;
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:
alert("unexpected editor type");
} }
const context = new Map();
context.set(modalsKey, new Map());
await setupI18n({ modules: editorModules });
mount(NoteEditor, { target: document.body, props: { uiResolve, mode, isLegacy }, context });
} }

View 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];
}

View file

@ -3,10 +3,10 @@
import { BLOCK_ELEMENTS } from "@tslib/dom"; import { BLOCK_ELEMENTS } from "@tslib/dom";
import { CustomElementArray } from "../editable/decorated"; import { CustomElementArray } from "$lib/editable/decorated";
import { FrameElement } from "../editable/frame-element"; import { FrameElement } from "$lib/editable/frame-element";
import { FrameEnd, FrameStart } from "../editable/frame-handle"; import { FrameEnd, FrameStart } from "$lib/editable/frame-handle";
import { Mathjax } from "../editable/mathjax-element.svelte"; import { Mathjax } from "$lib/editable/mathjax-element.svelte";
import { parsingInstructions } from "./plain-text-input"; import { parsingInstructions } from "./plain-text-input";
const decoratedElements = new CustomElementArray(); const decoratedElements = new CustomElementArray();

View file

@ -1,12 +1,12 @@
/* Copyright: Ankitects Pty Ltd and contributors /* Copyright: Ankitects Pty Ltd and contributors
* License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */
@import "../lib/sass/base"; @import "../../lib/sass/base";
$btn-disabled-opacity: 0.4; $btn-disabled-opacity: 0.4;
@import "bootstrap/scss/buttons"; @import "bootstrap/scss/buttons";
@import "bootstrap/scss/button-group"; @import "bootstrap/scss/button-group";
@import "../lib/sass/bootstrap-tooltip"; @import "../../lib/sass/bootstrap-tooltip";
html, html,
body { body {

Some files were not shown because too many files have changed in this diff Show more