mirror of
https://github.com/ankitects/anki.git
synced 2026-01-05 18:13:56 -05:00
Merge 286daac650 into 8f2144534b
This commit is contained in:
commit
cdc8ce98b2
40 changed files with 2095 additions and 65 deletions
|
|
@ -228,6 +228,17 @@ fn build_and_check_pages(build: &mut Build) -> Result<()> {
|
|||
":sveltekit"
|
||||
],
|
||||
)?;
|
||||
build_page(
|
||||
"reviewer-inner",
|
||||
true,
|
||||
inputs![
|
||||
//
|
||||
":ts:lib",
|
||||
":ts:components",
|
||||
":sass",
|
||||
":sveltekit"
|
||||
],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ preferences-on-next-sync-force-changes-in = On next sync, force changes in one d
|
|||
preferences-paste-clipboard-images-as-png = Paste clipboard images as PNG
|
||||
preferences-paste-without-shift-key-strips-formatting = Paste without shift key strips formatting
|
||||
preferences-generate-latex-images-automatically = Generate LaTeX images (security risk)
|
||||
preferences-use-new-reviewer = Use new reviewer
|
||||
preferences-latex-generation-disabled = LaTeX image generation is disabled in the preferences.
|
||||
preferences-periodically-sync-media = Periodically sync media
|
||||
preferences-please-restart-anki-to-complete-language = Please restart Anki to complete language change.
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ message ConfigKey {
|
|||
LOAD_BALANCER_ENABLED = 26;
|
||||
FSRS_SHORT_TERM_WITH_STEPS_ENABLED = 27;
|
||||
FSRS_LEGACY_EVALUATE = 28;
|
||||
NEW_REVIEWER = 29;
|
||||
}
|
||||
enum String {
|
||||
SET_DUE_BROWSER = 0;
|
||||
|
|
@ -120,6 +121,7 @@ message Preferences {
|
|||
uint32 time_limit_secs = 5;
|
||||
bool load_balancer_enabled = 6;
|
||||
bool fsrs_short_term_with_steps_enabled = 7;
|
||||
bool new_reviewer = 8;
|
||||
}
|
||||
message Editing {
|
||||
bool adding_defaults_to_current_deck = 1;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ package anki.frontend;
|
|||
import "anki/scheduler.proto";
|
||||
import "anki/generic.proto";
|
||||
import "anki/search.proto";
|
||||
import "anki/card_rendering.proto";
|
||||
|
||||
service FrontendService {
|
||||
// Returns values from the reviewer
|
||||
|
|
@ -30,6 +31,10 @@ service FrontendService {
|
|||
|
||||
// Save colour picker's custom colour palette
|
||||
rpc SaveCustomColours(generic.Empty) returns (generic.Empty);
|
||||
|
||||
// Plays the listed AV tags
|
||||
rpc PlayAVTags(PlayAVTagsRequest) returns (generic.Empty);
|
||||
rpc ReviewerAction(ReviewerActionRequest) returns (generic.Empty);
|
||||
}
|
||||
|
||||
service BackendFrontendService {}
|
||||
|
|
@ -43,3 +48,35 @@ message SetSchedulingStatesRequest {
|
|||
string key = 1;
|
||||
scheduler.SchedulingStates states = 2;
|
||||
}
|
||||
|
||||
message PlayAVTagsRequest {
|
||||
repeated card_rendering.AVTag tags = 1;
|
||||
}
|
||||
|
||||
message ReviewerActionRequest {
|
||||
enum ReviewerAction {
|
||||
// Menus
|
||||
EditCurrent = 0;
|
||||
SetDueDate = 1;
|
||||
CardInfo = 2;
|
||||
PreviousCardInfo = 3;
|
||||
CreateCopy = 4;
|
||||
// Reset
|
||||
Forget = 5;
|
||||
// Preset Options
|
||||
Options = 6;
|
||||
// "Congratulations"
|
||||
Overview = 7;
|
||||
|
||||
// Audio
|
||||
PauseAudio = 9;
|
||||
SeekBackward = 10;
|
||||
SeekForward = 11;
|
||||
RecordVoice = 12;
|
||||
ReplayRecorded = 13;
|
||||
};
|
||||
|
||||
ReviewerAction menu = 1;
|
||||
// In case the card isn't set in a next_card_data intercept function
|
||||
optional int64 current_card_id = 2;
|
||||
}
|
||||
|
|
@ -13,10 +13,12 @@ import "anki/decks.proto";
|
|||
import "anki/collection.proto";
|
||||
import "anki/config.proto";
|
||||
import "anki/deck_config.proto";
|
||||
import "anki/card_rendering.proto";
|
||||
|
||||
service SchedulerService {
|
||||
rpc GetQueuedCards(GetQueuedCardsRequest) returns (QueuedCards);
|
||||
rpc AnswerCard(CardAnswer) returns (collection.OpChanges);
|
||||
rpc NextCardData(NextCardDataRequest) returns (NextCardDataResponse);
|
||||
rpc SchedTimingToday(generic.Empty) returns (SchedTimingTodayResponse);
|
||||
rpc StudiedToday(generic.Empty) returns (generic.String);
|
||||
rpc StudiedTodayMessage(StudiedTodayMessageRequest) returns (generic.String);
|
||||
|
|
@ -285,6 +287,66 @@ message CardAnswer {
|
|||
uint32 milliseconds_taken = 6;
|
||||
}
|
||||
|
||||
message NextCardDataRequest {
|
||||
optional CardAnswer answer = 1;
|
||||
}
|
||||
|
||||
message NextCardDataResponse {
|
||||
message AnswerButton {
|
||||
CardAnswer.Rating rating = 1;
|
||||
string due = 2;
|
||||
}
|
||||
|
||||
message TypedAnswer {
|
||||
string text = 1;
|
||||
string args = 2;
|
||||
}
|
||||
|
||||
message TimerPreferences {
|
||||
uint32 max_time_ms = 1;
|
||||
bool stop_on_answer = 2;
|
||||
}
|
||||
|
||||
message PartialTemplate {
|
||||
repeated card_rendering.RenderedTemplateNode front = 1;
|
||||
repeated card_rendering.RenderedTemplateNode back = 2;
|
||||
}
|
||||
|
||||
message NextCardData {
|
||||
QueuedCards queue = 1;
|
||||
repeated AnswerButton answer_buttons = 2;
|
||||
|
||||
string front = 3;
|
||||
string back = 4;
|
||||
string css = 5;
|
||||
string body_class = 6;
|
||||
bool autoplay = 7;
|
||||
bool marked = 13;
|
||||
optional TypedAnswer typed_answer = 12;
|
||||
optional TimerPreferences timer = 14;
|
||||
// TODO: Is it worth setting up some sort of "ReviewerPreferences" endpoint
|
||||
// akin to GetGraphPreferences Also should this reviewer setting be moved to
|
||||
// a config bool rather than config.meta
|
||||
bool accept_enter = 15;
|
||||
|
||||
repeated card_rendering.AVTag question_av_tags = 8;
|
||||
repeated card_rendering.AVTag answer_av_tags = 9;
|
||||
|
||||
float autoAdvanceQuestionSeconds = 16;
|
||||
float autoAdvanceAnswerSeconds = 17;
|
||||
bool autoAdvanceWaitForAudio = 20;
|
||||
|
||||
deck_config.DeckConfig.Config.QuestionAction autoAdvanceQuestionAction = 18;
|
||||
deck_config.DeckConfig.Config.AnswerAction autoAdvanceAnswerAction = 19;
|
||||
|
||||
optional PartialTemplate partialTemplate = 11;
|
||||
}
|
||||
|
||||
optional NextCardData next_card = 1;
|
||||
// For media pre-loading. The fields of the note after next_card.
|
||||
string preload = 2;
|
||||
}
|
||||
|
||||
message CustomStudyRequest {
|
||||
message Cram {
|
||||
enum CramKind {
|
||||
|
|
|
|||
|
|
@ -81,4 +81,4 @@ button {
|
|||
#outer {
|
||||
border-top-color: color(border-subtle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -451,6 +451,19 @@
|
|||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QCheckBox" name="new_reviewer">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>preferences_use_new_reviewer</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="url_schemes">
|
||||
<property name="text">
|
||||
|
|
|
|||
|
|
@ -255,13 +255,11 @@ class AnkiQt(QMainWindow):
|
|||
# screens
|
||||
self.setupDeckBrowser()
|
||||
self.setupOverview()
|
||||
self.setupReviewer()
|
||||
# self.setupReviewer()
|
||||
|
||||
def finish_ui_setup(self) -> None:
|
||||
"Actions that are deferred until after add-on loading."
|
||||
self.toolbar.draw()
|
||||
# add-ons are only available here after setupAddons
|
||||
gui_hooks.reviewer_did_init(self.reviewer)
|
||||
|
||||
def setupProfileAfterWebviewsLoaded(self) -> None:
|
||||
for w in (self.web, self.bottomWeb):
|
||||
|
|
@ -679,6 +677,8 @@ class AnkiQt(QMainWindow):
|
|||
# dump error to stderr so it gets picked up by errors.py
|
||||
traceback.print_exc()
|
||||
|
||||
self.setupReviewer(self.backend.get_config_bool(Config.Bool.NEW_REVIEWER))
|
||||
|
||||
return True
|
||||
|
||||
def _loadCollection(self) -> None:
|
||||
|
|
@ -1074,10 +1074,13 @@ title="{}" {}>{}</button>""".format(
|
|||
|
||||
self.overview = Overview(self)
|
||||
|
||||
def setupReviewer(self) -> None:
|
||||
from aqt.reviewer import Reviewer
|
||||
def setupReviewer(self, new: bool) -> None:
|
||||
from aqt.reviewer import Reviewer, SvelteReviewer
|
||||
|
||||
self.reviewer = Reviewer(self)
|
||||
self.reviewer = SvelteReviewer(self) if new else Reviewer(self)
|
||||
|
||||
# add-ons are only available here after setupAddons
|
||||
gui_hooks.reviewer_did_init(self.reviewer)
|
||||
|
||||
# Syncing
|
||||
##########################################################################
|
||||
|
|
|
|||
|
|
@ -28,16 +28,33 @@ import aqt
|
|||
import aqt.main
|
||||
import aqt.operations
|
||||
from anki import hooks
|
||||
from anki.collection import OpChanges, OpChangesOnly, Progress, SearchNode
|
||||
from anki.cards import Card, CardId
|
||||
from anki.collection import (
|
||||
OpChanges,
|
||||
OpChangesOnly,
|
||||
Progress,
|
||||
SearchNode,
|
||||
)
|
||||
from anki.decks import UpdateDeckConfigs
|
||||
from anki.frontend_pb2 import PlayAVTagsRequest, ReviewerActionRequest
|
||||
from anki.scheduler.v3 import SchedulingStatesWithContext, SetSchedulingStatesRequest
|
||||
from anki.scheduler_pb2 import NextCardDataRequest, NextCardDataResponse
|
||||
from anki.template import (
|
||||
PartiallyRenderedCard,
|
||||
TemplateRenderContext,
|
||||
apply_custom_filters,
|
||||
av_tags_to_native,
|
||||
)
|
||||
from anki.utils import dev_mode
|
||||
from aqt import gui_hooks
|
||||
from aqt.changenotetype import ChangeNotetypeDialog
|
||||
from aqt.deckoptions import DeckOptionsDialog
|
||||
from aqt.operations import on_op_finished
|
||||
from aqt.operations.deck import update_deck_configs as update_deck_configs_op
|
||||
from aqt.progress import ProgressUpdate
|
||||
from aqt.qt import *
|
||||
from aqt.sound import av_player
|
||||
from aqt.theme import ThemeManager
|
||||
from aqt.utils import aqt_data_path, show_warning, tr
|
||||
|
||||
# https://forums.ankiweb.net/t/anki-crash-when-using-a-specific-deck/22266
|
||||
|
|
@ -45,7 +62,16 @@ waitress.wasyncore._DISCONNECTED = waitress.wasyncore._DISCONNECTED.union({EPROT
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
app = flask.Flask(__name__, root_path="/fake")
|
||||
flask_cors.CORS(app, resources={r"/*": {"origins": "127.0.0.1"}})
|
||||
flask_cors.CORS(
|
||||
app,
|
||||
resources={
|
||||
r"/_anki/js/vendor/mathjax/output/chtml/fonts/woff-v2/.*.woff": {
|
||||
"origins": "*"
|
||||
},
|
||||
r"/media/.*": {"origins": "*"},
|
||||
r"/*": {"origins": "127.0.0.1"},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -363,6 +389,7 @@ def is_sveltekit_page(path: str) -> bool:
|
|||
"import-csv",
|
||||
"import-page",
|
||||
"image-occlusion",
|
||||
"reviewer",
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -461,6 +488,7 @@ def _extract_request(
|
|||
if not aqt.mw.col:
|
||||
return NotFound(message=f"collection not open, ignore request for {path}")
|
||||
|
||||
path = path.removeprefix("media/")
|
||||
path = hooks.media_file_filter(path)
|
||||
return LocalFileRequest(root=aqt.mw.col.media.dir(), path=path)
|
||||
|
||||
|
|
@ -637,6 +665,136 @@ def save_custom_colours() -> bytes:
|
|||
return b""
|
||||
|
||||
|
||||
theme_manager = ThemeManager()
|
||||
|
||||
|
||||
def next_card_data() -> bytes:
|
||||
raw = aqt.mw.col._backend.next_card_data_raw(request.data)
|
||||
data = NextCardDataResponse.FromString(raw)
|
||||
|
||||
av_player.stop_and_clear_queue()
|
||||
aqt.mw.update_undo_actions()
|
||||
|
||||
if len(data.next_card.queue.cards) == 0:
|
||||
card = None
|
||||
else:
|
||||
backend_card = data.next_card.queue.cards[0].card
|
||||
card = Card(aqt.mw.col, backend_card=backend_card)
|
||||
|
||||
# TODO: Is dealing with gui_hooks in mediasrv like this a good idea?
|
||||
if gui_hooks.reviewer_did_answer_card.count() > 0:
|
||||
req = NextCardDataRequest.FromString(request.data)
|
||||
if req.HasField("answer"):
|
||||
aqt.mw.taskman.run_on_main(
|
||||
lambda: gui_hooks.reviewer_did_answer_card(
|
||||
aqt.mw.reviewer,
|
||||
aqt.mw.col.get_card(CardId(req.answer.card_id)),
|
||||
req.answer.rating + 1, # type: ignore
|
||||
)
|
||||
)
|
||||
|
||||
reviewer = aqt.mw.reviewer
|
||||
# This if statement prevents refreshes from causing the previous card to update.
|
||||
if reviewer.card is None or card is None or card.id != reviewer.card.id:
|
||||
reviewer.previous_card = reviewer.card
|
||||
reviewer.card = card
|
||||
|
||||
def update_card_info():
|
||||
reviewer._previous_card_info.set_card(reviewer.previous_card)
|
||||
reviewer._card_info.set_card(card)
|
||||
|
||||
aqt.mw.taskman.run_on_main(update_card_info)
|
||||
|
||||
if card is None:
|
||||
return data.SerializeToString()
|
||||
|
||||
ctx = TemplateRenderContext.from_existing_card(card, False)
|
||||
|
||||
qside = apply_custom_filters(
|
||||
PartiallyRenderedCard.nodes_from_proto(data.next_card.partialTemplate.front),
|
||||
ctx,
|
||||
None,
|
||||
)
|
||||
aside = apply_custom_filters(
|
||||
PartiallyRenderedCard.nodes_from_proto(data.next_card.partialTemplate.back),
|
||||
ctx,
|
||||
qside,
|
||||
)
|
||||
|
||||
# Dont send the partialy rendered template to the frontend to save bandwidth
|
||||
data.next_card.ClearField("partialTemplate")
|
||||
|
||||
q_avtags = ctx.col()._backend.extract_av_tags(text=qside, question_side=True)
|
||||
a_avtags = ctx.col()._backend.extract_av_tags(text=aside, question_side=False)
|
||||
|
||||
# Assumes the av tags are empty in the original response
|
||||
data.next_card.question_av_tags.extend(q_avtags.av_tags)
|
||||
data.next_card.answer_av_tags.extend(a_avtags.av_tags)
|
||||
|
||||
qside = q_avtags.text
|
||||
aside = a_avtags.text
|
||||
|
||||
qside = aqt.mw.prepare_card_text_for_display(qside)
|
||||
aside = aqt.mw.prepare_card_text_for_display(aside)
|
||||
|
||||
data.next_card.front = qside
|
||||
data.next_card.back = aside
|
||||
# Night mode is handled by the frontend so that it works with the browsers theme if used outside of anki.
|
||||
# Perhaps the OS class should be handled this way too?
|
||||
data.next_card.body_class = theme_manager.body_classes_for_card_ord(card.ord, False)
|
||||
data.next_card.accept_enter = aqt.mw.pm.spacebar_rates_card()
|
||||
|
||||
return data.SerializeToString()
|
||||
|
||||
|
||||
def play_avtags():
|
||||
req = PlayAVTagsRequest.FromString(request.data)
|
||||
av_player.play_tags(av_tags_to_native(req.tags))
|
||||
return b""
|
||||
|
||||
|
||||
def reviewer_action():
|
||||
reviewer = aqt.mw.reviewer
|
||||
ACTION_ENUM = ReviewerActionRequest.ReviewerAction
|
||||
|
||||
def overview():
|
||||
aqt.mw.moveToState("overview")
|
||||
|
||||
REVIEWER_ACTIONS = {
|
||||
ACTION_ENUM.EditCurrent: aqt.mw.onEditCurrent,
|
||||
ACTION_ENUM.SetDueDate: reviewer.on_set_due,
|
||||
ACTION_ENUM.CardInfo: reviewer.on_card_info,
|
||||
ACTION_ENUM.PreviousCardInfo: reviewer.on_previous_card_info,
|
||||
ACTION_ENUM.CreateCopy: reviewer.on_create_copy,
|
||||
ACTION_ENUM.Forget: reviewer.forget_current_card,
|
||||
ACTION_ENUM.Options: reviewer.onOptions,
|
||||
ACTION_ENUM.Overview: overview,
|
||||
ACTION_ENUM.PauseAudio: reviewer.on_pause_audio,
|
||||
ACTION_ENUM.SeekBackward: reviewer.on_seek_backward,
|
||||
ACTION_ENUM.SeekForward: reviewer.on_seek_forward,
|
||||
ACTION_ENUM.RecordVoice: reviewer.onRecordVoice,
|
||||
ACTION_ENUM.ReplayRecorded: reviewer.onReplayRecorded,
|
||||
}
|
||||
|
||||
req = ReviewerActionRequest.FromString(request.data)
|
||||
aqt.mw.taskman.run_on_main(REVIEWER_ACTIONS[req.menu])
|
||||
return b""
|
||||
|
||||
|
||||
def undo_redo(action: str):
|
||||
resp = raw_backend_request(action)()
|
||||
aqt.mw.update_undo_actions()
|
||||
return resp
|
||||
|
||||
|
||||
def undo():
|
||||
return undo_redo("undo")
|
||||
|
||||
|
||||
def redo():
|
||||
return undo_redo("redo")
|
||||
|
||||
|
||||
post_handler_list = [
|
||||
congrats_info,
|
||||
get_deck_configs_for_update,
|
||||
|
|
@ -653,6 +811,11 @@ post_handler_list = [
|
|||
deck_options_require_close,
|
||||
deck_options_ready,
|
||||
save_custom_colours,
|
||||
next_card_data,
|
||||
play_avtags,
|
||||
reviewer_action,
|
||||
undo,
|
||||
redo,
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -660,6 +823,9 @@ exposed_backend_list = [
|
|||
# CollectionService
|
||||
"latest_progress",
|
||||
"get_custom_colours",
|
||||
"set_config_json",
|
||||
"get_config_json",
|
||||
"get_undo_status",
|
||||
# DeckService
|
||||
"get_deck_names",
|
||||
# I18nService
|
||||
|
|
@ -670,6 +836,9 @@ exposed_backend_list = [
|
|||
# NotesService
|
||||
"get_field_names",
|
||||
"get_note",
|
||||
"remove_notes",
|
||||
"add_note_tags",
|
||||
"remove_note_tags",
|
||||
# NotetypesService
|
||||
"get_notetype_names",
|
||||
"get_change_notetype_info",
|
||||
|
|
@ -695,9 +864,13 @@ exposed_backend_list = [
|
|||
"get_optimal_retention_parameters",
|
||||
"simulate_fsrs_review",
|
||||
"simulate_fsrs_workload",
|
||||
"bury_or_suspend_cards",
|
||||
# DeckConfigService
|
||||
"get_ignored_before_count",
|
||||
"get_retention_workload",
|
||||
# CardsService
|
||||
"set_flag",
|
||||
"compare_answer",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -138,6 +138,7 @@ class Preferences(QDialog):
|
|||
form.showProgress.setChecked(reviewing.show_remaining_due_counts)
|
||||
form.showPlayButtons.setChecked(not reviewing.hide_audio_play_buttons)
|
||||
form.interrupt_audio.setChecked(reviewing.interrupt_audio_when_answering)
|
||||
form.new_reviewer.setChecked(reviewing.new_reviewer)
|
||||
|
||||
editing = self.prefs.editing
|
||||
form.useCurrent.setCurrentIndex(
|
||||
|
|
@ -173,6 +174,7 @@ class Preferences(QDialog):
|
|||
reviewing.time_limit_secs = form.timeLimit.value() * 60
|
||||
reviewing.hide_audio_play_buttons = not self.form.showPlayButtons.isChecked()
|
||||
reviewing.interrupt_audio_when_answering = self.form.interrupt_audio.isChecked()
|
||||
reviewing.new_reviewer = form.new_reviewer.isChecked()
|
||||
|
||||
editing = self.prefs.editing
|
||||
editing.adding_defaults_to_current_deck = not form.useCurrent.currentIndex()
|
||||
|
|
|
|||
|
|
@ -172,9 +172,7 @@ class Reviewer:
|
|||
gui_hooks.av_player_did_end_playing.append(self._on_av_player_did_end_playing)
|
||||
|
||||
def show(self) -> None:
|
||||
if self.mw.col.sched_ver() == 1 or not self.mw.col.v3_scheduler():
|
||||
self.mw.moveToState("deckBrowser")
|
||||
show_warning(tr.scheduling_update_required().replace("V2", "v3"))
|
||||
if not self._scheduler_version_check():
|
||||
return
|
||||
self.mw.setStateShortcuts(self._shortcutKeys()) # type: ignore
|
||||
self.web.set_bridge_command(self._linkHandler, self)
|
||||
|
|
@ -184,6 +182,13 @@ class Reviewer:
|
|||
self._refresh_needed = RefreshNeeded.QUEUES
|
||||
self.refresh_if_needed()
|
||||
|
||||
def _scheduler_version_check(self):
|
||||
if self.mw.col.sched_ver() == 1 or not self.mw.col.v3_scheduler():
|
||||
self.mw.moveToState("deckBrowser")
|
||||
show_warning(tr.scheduling_update_required().replace("V2", "v3"))
|
||||
return False
|
||||
return True
|
||||
|
||||
# this is only used by add-ons
|
||||
def lastCard(self) -> Card | None:
|
||||
if self._answeredIds:
|
||||
|
|
@ -444,7 +449,6 @@ class Reviewer:
|
|||
tooltip(tr.studying_question_time_elapsed())
|
||||
|
||||
def autoplay(self, card: Card) -> bool:
|
||||
print("use card.autoplay() instead of reviewer.autoplay(card)")
|
||||
return card.autoplay()
|
||||
|
||||
def _update_flag_icon(self) -> None:
|
||||
|
|
@ -1233,6 +1237,36 @@ timerStopped = false;
|
|||
setFlag = set_flag_on_current_card
|
||||
|
||||
|
||||
class SvelteReviewer(Reviewer):
|
||||
def refresh_if_needed(self):
|
||||
if self._refresh_needed:
|
||||
self.mw.fade_in_webview()
|
||||
self.web.eval("if (anki) {anki.changeReceived()}")
|
||||
self._refresh_needed = None
|
||||
|
||||
def show(self) -> None:
|
||||
if not self._scheduler_version_check():
|
||||
return
|
||||
self._initWeb()
|
||||
# Prevents the shortcuts selecting the toolbar buttons for the next time enter is pressed
|
||||
self.mw.setStateShortcuts(self._shortcutKeys()) # type: ignore
|
||||
|
||||
def _linkHandler(self, url: str) -> None:
|
||||
pass
|
||||
|
||||
def _initWeb(self) -> None:
|
||||
self._reps = 0
|
||||
# hide the bottom bar
|
||||
self.bottom.web.setHtml("<style>body {margin:0;} html {height:0;}</style>")
|
||||
# main window
|
||||
self.web.load_sveltekit_page("reviewer")
|
||||
# block default drag & drop behavior while allowing drop events to be received by JS handlers
|
||||
self.web.allow_drops = True
|
||||
|
||||
def _shortcutKeys(self) -> Sequence[tuple[str, Callable] | tuple[Qt.Key, Callable]]:
|
||||
return []
|
||||
|
||||
|
||||
# if the last element is a comment, then the RUN_STATE_MUTATION code
|
||||
# breaks due to the comment wrongly commenting out python code.
|
||||
# To prevent this we put the js code on a separate line
|
||||
|
|
|
|||
|
|
@ -211,7 +211,11 @@ class BottomWebView(ToolbarWebView):
|
|||
def animate_height(self, height: int) -> None:
|
||||
self.web_height = height
|
||||
|
||||
if self.mw.pm.reduce_motion() or height == self.height():
|
||||
if (
|
||||
self.mw.pm.reduce_motion()
|
||||
or self.mw.col.conf.get("newReviewer")
|
||||
or height == self.height()
|
||||
):
|
||||
self.setFixedHeight(height)
|
||||
else:
|
||||
# Collapse/Expand animation
|
||||
|
|
@ -426,6 +430,7 @@ class Toolbar:
|
|||
######################################################################
|
||||
|
||||
def _linkHandler(self, link: str) -> bool:
|
||||
self.mw.web.setFocus()
|
||||
if link in self.link_handlers:
|
||||
self.link_handlers[link]()
|
||||
return False
|
||||
|
|
@ -457,7 +462,7 @@ class Toolbar:
|
|||
######################################################################
|
||||
|
||||
_body = """
|
||||
<div class="header">
|
||||
<div class="header" onclick="pycmd('focus')">
|
||||
<div class="left-tray">{left_tray_content}</div>
|
||||
<div class="toolbar">{toolbar_content}</div>
|
||||
<div class="right-tray">{right_tray_content}</div>
|
||||
|
|
|
|||
|
|
@ -142,6 +142,7 @@ class AnkiWebPage(QWebEnginePage):
|
|||
AnkiWebViewKind.IMPORT_ANKI_PACKAGE,
|
||||
AnkiWebViewKind.IMPORT_CSV,
|
||||
AnkiWebViewKind.IMPORT_LOG,
|
||||
AnkiWebViewKind.MAIN,
|
||||
)
|
||||
|
||||
global _profile_with_api_access, _profile_without_api_access
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ impl From<BoolKeyProto> for BoolKey {
|
|||
BoolKeyProto::LoadBalancerEnabled => BoolKey::LoadBalancerEnabled,
|
||||
BoolKeyProto::FsrsShortTermWithStepsEnabled => BoolKey::FsrsShortTermWithStepsEnabled,
|
||||
BoolKeyProto::FsrsLegacyEvaluate => BoolKey::FsrsLegacyEvaluate,
|
||||
BoolKeyProto::NewReviewer => BoolKey::NewReviewer,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -57,8 +58,14 @@ impl From<StringKeyProto> for StringKey {
|
|||
|
||||
impl crate::services::ConfigService for Collection {
|
||||
fn get_config_json(&mut self, input: generic::String) -> Result<generic::Json> {
|
||||
let val: Option<Value> = self.get_config_optional(input.val.as_str());
|
||||
val.or_not_found(input.val)
|
||||
let key = input.val.as_str();
|
||||
let val: Option<Value> = self.get_config_optional(key);
|
||||
let default = match key {
|
||||
"reviewerStorage" => Some(serde_json::from_str("{}").unwrap()),
|
||||
_ => None,
|
||||
};
|
||||
val.or(default)
|
||||
.or_not_found(key)
|
||||
.and_then(|v| serde_json::to_vec(&v).map_err(Into::into))
|
||||
.map(Into::into)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ impl crate::services::CardRenderingService for Collection {
|
|||
}
|
||||
}
|
||||
|
||||
fn rendered_nodes_to_proto(
|
||||
pub(crate) fn rendered_nodes_to_proto(
|
||||
nodes: Vec<RenderedNode>,
|
||||
) -> Vec<anki_proto::card_rendering::RenderedTemplateNode> {
|
||||
nodes
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ pub enum BoolKey {
|
|||
FsrsLegacyEvaluate,
|
||||
LoadBalancerEnabled,
|
||||
FsrsShortTermWithStepsEnabled,
|
||||
NewReviewer,
|
||||
#[strum(to_string = "normalize_note_text")]
|
||||
NormalizeNoteText,
|
||||
#[strum(to_string = "dayLearnFirst")]
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ impl Collection {
|
|||
load_balancer_enabled: self.get_config_bool(BoolKey::LoadBalancerEnabled),
|
||||
fsrs_short_term_with_steps_enabled: self
|
||||
.get_config_bool(BoolKey::FsrsShortTermWithStepsEnabled),
|
||||
new_reviewer: self.get_config_bool(BoolKey::NewReviewer),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -125,6 +126,7 @@ impl Collection {
|
|||
BoolKey::FsrsShortTermWithStepsEnabled,
|
||||
s.fsrs_short_term_with_steps_enabled,
|
||||
)?;
|
||||
self.set_config_bool_inner(BoolKey::NewReviewer, settings.new_reviewer)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,16 @@
|
|||
mod answering;
|
||||
mod states;
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use anki_proto::cards;
|
||||
use anki_proto::generic;
|
||||
use anki_proto::scheduler;
|
||||
use anki_proto::scheduler::next_card_data_response::AnswerButton;
|
||||
use anki_proto::scheduler::next_card_data_response::NextCardData;
|
||||
use anki_proto::scheduler::next_card_data_response::PartialTemplate;
|
||||
use anki_proto::scheduler::next_card_data_response::TimerPreferences;
|
||||
use anki_proto::scheduler::next_card_data_response::TypedAnswer;
|
||||
use anki_proto::scheduler::ComputeFsrsParamsResponse;
|
||||
use anki_proto::scheduler::ComputeMemoryStateResponse;
|
||||
use anki_proto::scheduler::ComputeOptimalRetentionResponse;
|
||||
|
|
@ -14,6 +21,8 @@ use anki_proto::scheduler::FsrsBenchmarkResponse;
|
|||
use anki_proto::scheduler::FuzzDeltaRequest;
|
||||
use anki_proto::scheduler::FuzzDeltaResponse;
|
||||
use anki_proto::scheduler::GetOptimalRetentionParametersResponse;
|
||||
use anki_proto::scheduler::NextCardDataRequest;
|
||||
use anki_proto::scheduler::NextCardDataResponse;
|
||||
use anki_proto::scheduler::SimulateFsrsReviewRequest;
|
||||
use anki_proto::scheduler::SimulateFsrsReviewResponse;
|
||||
use anki_proto::scheduler::SimulateFsrsWorkloadResponse;
|
||||
|
|
@ -21,15 +30,20 @@ use fsrs::ComputeParametersInput;
|
|||
use fsrs::FSRSItem;
|
||||
use fsrs::FSRSReview;
|
||||
use fsrs::FSRS;
|
||||
use regex::Regex;
|
||||
|
||||
use crate::backend::Backend;
|
||||
use crate::card_rendering::service::rendered_nodes_to_proto;
|
||||
use crate::cloze::extract_cloze_for_typing;
|
||||
use crate::prelude::*;
|
||||
use crate::scheduler::fsrs::params::ComputeParamsRequest;
|
||||
use crate::scheduler::new::NewCardDueOrder;
|
||||
use crate::scheduler::states::CardState;
|
||||
use crate::scheduler::states::SchedulingStates;
|
||||
use crate::search::SortMode;
|
||||
use crate::services::NotesService;
|
||||
use crate::stats::studied_today;
|
||||
use crate::template::RenderedNode;
|
||||
|
||||
impl crate::services::SchedulerService for Collection {
|
||||
/// This behaves like _updateCutoff() in older code - it also unburies at
|
||||
|
|
@ -382,6 +396,145 @@ impl crate::services::SchedulerService for Collection {
|
|||
delta_days: self.get_fuzz_delta(input.card_id.into(), input.interval)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn next_card_data(&mut self, req: NextCardDataRequest) -> Result<NextCardDataResponse> {
|
||||
if let Some(answer) = req.answer {
|
||||
self.answer_card(&mut answer.into())?;
|
||||
}
|
||||
let mut queue = self.get_queued_cards(2, false)?;
|
||||
let next_card = queue.cards.first();
|
||||
if let Some(next_card) = next_card {
|
||||
let cid = next_card.card.id;
|
||||
let deck_config = self.deck_config_for_card(&next_card.card)?.inner;
|
||||
let note = self.get_note(next_card.card.note_id.into())?;
|
||||
|
||||
let render = self.render_existing_card(cid, false, true)?;
|
||||
let show_due = self.get_config_bool(BoolKey::ShowIntervalsAboveAnswerButtons);
|
||||
let show_remaning = self.get_config_bool(BoolKey::ShowRemainingDueCountsInStudy);
|
||||
|
||||
let answer_buttons = self
|
||||
.describe_next_states(&next_card.states)?
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, due)| AnswerButton {
|
||||
rating: i as i32,
|
||||
due: if show_due {
|
||||
due
|
||||
} else {
|
||||
"\u{00A0}".to_string() /*   */
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Typed answer replacements
|
||||
static ANSWER_REGEX: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"\[\[type:(.+?:)?(.+?)\]\]").unwrap());
|
||||
|
||||
const ANSWER_HTML: &str = "<center>
|
||||
<input type=text id=typeans onkeypress=\"_typeAnsPress();\"
|
||||
style=\"font-family: '{self.typeFont}'; font-size: {self.typeSize}px;\">
|
||||
</center>";
|
||||
|
||||
let mut q_nodes = render.qnodes;
|
||||
let typed_answer_parent_node = q_nodes.iter_mut().find_map(|node| {
|
||||
if let RenderedNode::Text { text } = node {
|
||||
let mut out = None;
|
||||
*text = ANSWER_REGEX
|
||||
.replace(text, |cap: ®ex::Captures<'_>| {
|
||||
out = Some((
|
||||
cap.get(1)
|
||||
.map(|g| g.as_str().to_string())
|
||||
.unwrap_or("".to_string()),
|
||||
cap[2].to_string(),
|
||||
));
|
||||
ANSWER_HTML
|
||||
})
|
||||
.to_string();
|
||||
out
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let typed_answer = typed_answer_parent_node
|
||||
.map(|field| -> Result<(String, String)> {
|
||||
let notetype = self
|
||||
.get_notetype(note.notetype_id.into())?
|
||||
.or_not_found(note.notetype_id)?;
|
||||
let field_ord = notetype.get_field_ord(&field.1).or_not_found(field.1)?;
|
||||
let mut correct = note.fields[field_ord].clone();
|
||||
if field.0.contains("cloze") {
|
||||
let card_ord = queue.cards[0].card.template_idx;
|
||||
correct = extract_cloze_for_typing(&correct, card_ord + 1).to_string()
|
||||
}
|
||||
Ok((field.0, correct))
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
let marked = note.tags.contains(&"marked".to_string());
|
||||
|
||||
if !show_remaning {
|
||||
queue.learning_count = 0;
|
||||
queue.review_count = 0;
|
||||
queue.new_count = 0;
|
||||
}
|
||||
|
||||
let timer = deck_config.show_timer.then_some(TimerPreferences {
|
||||
max_time_ms: deck_config.cap_answer_time_to_secs * 1000,
|
||||
stop_on_answer: deck_config.stop_timer_on_answer,
|
||||
});
|
||||
|
||||
let preload = queue
|
||||
.cards
|
||||
.get(1)
|
||||
.map(|after_card| -> Result<Vec<String>> {
|
||||
let after_note = self.get_note(after_card.card.note_id.into())?;
|
||||
Ok(after_note.fields)
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or(vec![])
|
||||
.join("");
|
||||
|
||||
Ok(NextCardDataResponse {
|
||||
next_card: Some(NextCardData {
|
||||
queue: Some(queue.into()),
|
||||
|
||||
css: render.css.clone(),
|
||||
partial_template: Some(PartialTemplate {
|
||||
front: rendered_nodes_to_proto(q_nodes),
|
||||
back: rendered_nodes_to_proto(render.anodes),
|
||||
}),
|
||||
|
||||
answer_buttons,
|
||||
autoplay: !deck_config.disable_autoplay,
|
||||
typed_answer: typed_answer.map(|answer| TypedAnswer {
|
||||
text: answer.1,
|
||||
args: answer.0,
|
||||
}),
|
||||
marked,
|
||||
timer,
|
||||
|
||||
auto_advance_answer_seconds: deck_config.seconds_to_show_answer,
|
||||
auto_advance_question_seconds: deck_config.seconds_to_show_question,
|
||||
auto_advance_wait_for_audio: deck_config.wait_for_audio,
|
||||
|
||||
auto_advance_answer_action: deck_config.answer_action,
|
||||
auto_advance_question_action: deck_config.question_action,
|
||||
|
||||
// Filled by python
|
||||
accept_enter: true,
|
||||
front: "".to_string(),
|
||||
back: "".to_string(),
|
||||
body_class: "".to_string(),
|
||||
question_av_tags: vec![],
|
||||
answer_av_tags: vec![],
|
||||
}),
|
||||
preload,
|
||||
})
|
||||
} else {
|
||||
Ok(NextCardDataResponse::default())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::services::BackendSchedulerService for Backend {
|
||||
|
|
|
|||
|
|
@ -6,36 +6,6 @@ import type { Modifier } from "./keys";
|
|||
import { checkIfModifierKey, checkModifiers, keyToPlatformString, modifiersToPlatformString } from "./keys";
|
||||
import { registerPackage } from "./runtime-require";
|
||||
|
||||
const keyCodeLookup = {
|
||||
Backspace: 8,
|
||||
Delete: 46,
|
||||
Tab: 9,
|
||||
Enter: 13,
|
||||
F1: 112,
|
||||
F2: 113,
|
||||
F3: 114,
|
||||
F4: 115,
|
||||
F5: 116,
|
||||
F6: 117,
|
||||
F7: 118,
|
||||
F8: 119,
|
||||
F9: 120,
|
||||
F10: 121,
|
||||
F11: 122,
|
||||
F12: 123,
|
||||
"=": 187,
|
||||
"-": 189,
|
||||
"[": 219,
|
||||
"]": 221,
|
||||
"\\": 220,
|
||||
";": 186,
|
||||
"'": 222,
|
||||
",": 188,
|
||||
".": 190,
|
||||
"/": 191,
|
||||
"`": 192,
|
||||
};
|
||||
|
||||
function isRequiredModifier(modifier: string): boolean {
|
||||
return !modifier.endsWith("?");
|
||||
}
|
||||
|
|
@ -58,10 +28,8 @@ export function getPlatformString(keyCombinationString: string): string {
|
|||
.join(", ");
|
||||
}
|
||||
|
||||
function checkKey(event: KeyboardEvent, key: number): boolean {
|
||||
// avoid deprecation warning
|
||||
const which = event["which" + ""];
|
||||
return which === key;
|
||||
function checkKey(event: KeyboardEvent, key: string): boolean {
|
||||
return event.key.toLowerCase() === key.toLowerCase();
|
||||
}
|
||||
|
||||
function partition<T>(predicate: (t: T) => boolean, items: T[]): [T[], T[]] {
|
||||
|
|
@ -93,25 +61,23 @@ function separateRequiredOptionalModifiers(
|
|||
}
|
||||
|
||||
const check =
|
||||
(keyCode: number, requiredModifiers: Modifier[], optionalModifiers: Modifier[]) =>
|
||||
(event: KeyboardEvent): boolean => {
|
||||
(key: string, requiredModifiers: Modifier[], optionalModifiers: Modifier[]) => (event: KeyboardEvent): boolean => {
|
||||
return (
|
||||
checkKey(event, keyCode)
|
||||
checkKey(event, key)
|
||||
&& checkModifiers(requiredModifiers, optionalModifiers)(event)
|
||||
);
|
||||
};
|
||||
|
||||
function keyToCode(key: string): number {
|
||||
return keyCodeLookup[key] || key.toUpperCase().charCodeAt(0);
|
||||
}
|
||||
|
||||
function keyCombinationToCheck(
|
||||
keyCombination: string[],
|
||||
): (event: KeyboardEvent) => boolean {
|
||||
const keyCode = keyToCode(keyCombination[keyCombination.length - 1]);
|
||||
const keyCode = keyCombination[keyCombination.length - 1];
|
||||
const [required, optional] = separateRequiredOptionalModifiers(
|
||||
keyCombination.slice(0, -1),
|
||||
);
|
||||
if ("@*!=".includes(keyCode)) {
|
||||
optional.push("Shift");
|
||||
}
|
||||
|
||||
return check(keyCode, required, optional);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export function getTypedAnswer(): string | null {
|
|||
return typeans?.value ?? null;
|
||||
}
|
||||
|
||||
function _runHook(
|
||||
export function _runHook(
|
||||
hooks: Array<Callback>,
|
||||
): Promise<PromiseSettledResult<void | Promise<void>>[]> {
|
||||
const promises: (Promise<void> | void)[] = [];
|
||||
|
|
@ -104,7 +104,7 @@ async function setInnerHTML(element: Element, html: string): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
const renderError = (type: string) => (error: unknown): string => {
|
||||
export const renderError = (type: string) => (error: unknown): string => {
|
||||
const errorMessage = String(error).substring(0, 2000);
|
||||
let errorStack: string;
|
||||
if (error instanceof Error) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
@import "$lib/sass/base";
|
||||
@import "../lib/sass/base";
|
||||
|
||||
// override Bootstrap transition duration
|
||||
$carousel-transition: var(--transition);
|
||||
|
|
@ -11,8 +11,8 @@ $carousel-transition: var(--transition);
|
|||
@import "bootstrap/scss/close";
|
||||
@import "bootstrap/scss/alert";
|
||||
@import "bootstrap/scss/badge";
|
||||
@import "$lib/sass/bootstrap-forms";
|
||||
@import "$lib/sass/bootstrap-tooltip";
|
||||
@import "../lib/sass/bootstrap-forms";
|
||||
@import "../lib/sass/bootstrap-tooltip";
|
||||
|
||||
input[type="text"],
|
||||
input[type="date"],
|
||||
|
|
|
|||
236
ts/routes/reviewer-inner/index.ts
Normal file
236
ts/routes/reviewer-inner/index.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
"use-strict";
|
||||
import "../base.scss";
|
||||
import "../../reviewer/reviewer.scss";
|
||||
|
||||
import "../../mathjax";
|
||||
import "mathjax/es5/tex-chtml-full.js";
|
||||
import { registerPackage } from "@tslib/runtime-require";
|
||||
import { _runHook, renderError } from "../../reviewer";
|
||||
import { addBrowserClasses } from "../../reviewer/browser_selector";
|
||||
import { preloadResources } from "../../reviewer/preload";
|
||||
import { imageOcclusionAPI } from "../image-occlusion/review";
|
||||
import { enableNightMode } from "../reviewer/reviewer";
|
||||
import type { ReviewerRequest } from "../reviewer/reviewerRequest";
|
||||
import type { InnerReviewerRequest } from "./innerReviewerRequest";
|
||||
|
||||
addBrowserClasses();
|
||||
|
||||
export const imageOcclusion = imageOcclusionAPI;
|
||||
export const setupImageCloze = imageOcclusionAPI.setup; // deprecated
|
||||
|
||||
function postParentMessage(message: ReviewerRequest) {
|
||||
window.parent.postMessage(
|
||||
message,
|
||||
"*",
|
||||
);
|
||||
}
|
||||
|
||||
type Callback = () => void | Promise<void>;
|
||||
|
||||
export const onUpdateHook: Array<Callback> = [];
|
||||
export const onShownHook: Array<Callback> = [];
|
||||
|
||||
globalThis.onUpdateHook = onUpdateHook;
|
||||
globalThis.onShownHook = onShownHook;
|
||||
|
||||
declare const MathJax: any;
|
||||
const urlParams = new URLSearchParams(location.search);
|
||||
const decoder = new TextDecoder();
|
||||
const style = document.createElement("style");
|
||||
document.head.appendChild(style);
|
||||
const qaDiv = document.createElement("div");
|
||||
document.body.appendChild(qaDiv);
|
||||
qaDiv.id = "qa";
|
||||
qaDiv.style.opacity = "1";
|
||||
|
||||
addEventListener("message", async (e: MessageEvent<InnerReviewerRequest>) => {
|
||||
switch (e.data.type) {
|
||||
case "setstorage": {
|
||||
const json = JSON.parse(decoder.decode(e.data.json_buffer));
|
||||
Object.assign(storageObj, json);
|
||||
break;
|
||||
}
|
||||
case "html": {
|
||||
qaDiv.innerHTML = e.data.value;
|
||||
if (e.data.css) {
|
||||
style.innerHTML = e.data.css;
|
||||
}
|
||||
if (e.data.bodyclass) {
|
||||
document.body.className = e.data.bodyclass;
|
||||
const theme = urlParams.get("nightMode");
|
||||
if (theme !== null) {
|
||||
enableNightMode();
|
||||
document.body.classList.add("night_mode");
|
||||
document.body.classList.add("nightMode");
|
||||
}
|
||||
}
|
||||
|
||||
onUpdateHook.length = 0;
|
||||
onShownHook.length = 0;
|
||||
|
||||
// "".innerHTML =" does not run scripts
|
||||
for (const script of qaDiv.querySelectorAll("script")) {
|
||||
// strict mode prevents the use of "eval" here
|
||||
const parent = script.parentElement!;
|
||||
const _script = script.parentElement!.removeChild(script);
|
||||
const new_script = document.createElement("script");
|
||||
const new_script_text = document.createTextNode(_script.innerHTML);
|
||||
new_script.appendChild(new_script_text);
|
||||
parent.appendChild(new_script);
|
||||
}
|
||||
|
||||
_runHook(onUpdateHook);
|
||||
// wait for mathjax to ready
|
||||
await MathJax.startup.promise
|
||||
.then(() => {
|
||||
// clear MathJax buffers from previous typesets
|
||||
MathJax.typesetClear();
|
||||
|
||||
return MathJax.typesetPromise([document.body]);
|
||||
})
|
||||
.catch(renderError("MathJax"));
|
||||
|
||||
const scrollTarget = document.getElementById("answer");
|
||||
if (scrollTarget) {
|
||||
scrollTarget.scrollIntoView();
|
||||
} else {
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
_runHook(onShownHook);
|
||||
|
||||
if (e.data.preload) {
|
||||
preloadResources(e.data.preload);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// console.warn(`Unknown message type: ${e.data.type}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
addEventListener("keydown", (e) => {
|
||||
const keyInfo: ReviewerRequest = {
|
||||
type: "keypress",
|
||||
eventInit: {
|
||||
key: e.key,
|
||||
code: e.code,
|
||||
keyCode: e.keyCode,
|
||||
which: e.which,
|
||||
altKey: e.altKey,
|
||||
ctrlKey: e.ctrlKey,
|
||||
shiftKey: e.shiftKey,
|
||||
metaKey: e.metaKey,
|
||||
repeat: e.repeat,
|
||||
bubbles: true,
|
||||
},
|
||||
};
|
||||
if (
|
||||
!document.activeElement?.matches("input[type=text], input[type=number], textarea") || e.key === "Enter"
|
||||
) {
|
||||
postParentMessage(keyInfo);
|
||||
}
|
||||
});
|
||||
|
||||
addEventListener("click", () => {
|
||||
postParentMessage({ type: "closemenu" });
|
||||
});
|
||||
|
||||
const base = document.createElement("base");
|
||||
base.href = "/media/";
|
||||
document.head.appendChild(base);
|
||||
|
||||
function pycmd(cmd: string) {
|
||||
const match = cmd.match(/play:(q|a):(\d+)/);
|
||||
if (match) {
|
||||
const [_, context, index] = match;
|
||||
postParentMessage({
|
||||
type: "audio",
|
||||
answerSide: context === "a",
|
||||
index: parseInt(index),
|
||||
});
|
||||
}
|
||||
}
|
||||
globalThis.pycmd = pycmd;
|
||||
|
||||
function _typeAnsPress() {
|
||||
const elem = document.getElementById("typeans")! as HTMLInputElement;
|
||||
let key = (window.event as KeyboardEvent).key;
|
||||
key = key.length == 1 ? key : "";
|
||||
postParentMessage(
|
||||
{ type: "typed", value: elem.value + key },
|
||||
);
|
||||
}
|
||||
globalThis._typeAnsPress = _typeAnsPress;
|
||||
|
||||
const storageObj = {};
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function updateParentStorage() {
|
||||
postParentMessage({ type: "setstorage", json_buffer: encoder.encode(JSON.stringify(storageObj)) });
|
||||
}
|
||||
|
||||
function createStorageProxy() {
|
||||
return new Proxy({}, {
|
||||
get(_target, prop) {
|
||||
switch (prop) {
|
||||
case "getItem":
|
||||
return (key) => key in storageObj ? storageObj[key] : null;
|
||||
case "setItem":
|
||||
return (key, value) => {
|
||||
storageObj[key] = String(value);
|
||||
updateParentStorage();
|
||||
};
|
||||
case "removeItem":
|
||||
return (key) => {
|
||||
delete storageObj[key];
|
||||
updateParentStorage();
|
||||
};
|
||||
case "clear":
|
||||
return () => {
|
||||
Object.keys(storageObj).forEach(key => delete storageObj[key]);
|
||||
updateParentStorage();
|
||||
};
|
||||
case "key":
|
||||
return (index) => Object.keys(storageObj)[index] ?? null;
|
||||
case "length":
|
||||
return Object.keys(storageObj).length;
|
||||
default:
|
||||
return storageObj[prop];
|
||||
}
|
||||
},
|
||||
set(_target, prop, value) {
|
||||
storageObj[prop] = String(value);
|
||||
return true;
|
||||
},
|
||||
ownKeys() {
|
||||
return Object.keys(storageObj);
|
||||
},
|
||||
getOwnPropertyDescriptor(_target, _prop) {
|
||||
return { enumerable: true, configurable: true };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const ankiStorage = createStorageProxy();
|
||||
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: ankiStorage,
|
||||
writable: false,
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
});
|
||||
|
||||
registerPackage("anki/reviewer", {
|
||||
// If you append a function to this each time the question or answer
|
||||
// is shown, it will be called before MathJax has been rendered.
|
||||
onUpdateHook,
|
||||
// If you append a function to this each time the question or answer
|
||||
// is shown, it will be called after images have been preloaded and
|
||||
// MathJax has been rendered.
|
||||
onShownHook,
|
||||
});
|
||||
16
ts/routes/reviewer-inner/innerReviewerRequest.ts
Normal file
16
ts/routes/reviewer-inner/innerReviewerRequest.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
interface HtmlMessage {
|
||||
type: "html";
|
||||
value: string;
|
||||
css?: string;
|
||||
bodyclass?: string;
|
||||
preload?: string;
|
||||
}
|
||||
|
||||
interface StorageUpdateMessage {
|
||||
type: "setstorage";
|
||||
json_buffer: Uint8Array;
|
||||
}
|
||||
|
||||
export type InnerReviewerRequest = HtmlMessage | StorageUpdateMessage;
|
||||
38
ts/routes/reviewer/+page.svelte
Normal file
38
ts/routes/reviewer/+page.svelte
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<!--
|
||||
Copyright: Ankitects Pty Ltd and contributors
|
||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
import "./index.scss";
|
||||
|
||||
import { onMount } from "svelte";
|
||||
import { ReviewerState, updateNightMode } from "./reviewer";
|
||||
import ReviewerBottom from "./reviewer-bottom/ReviewerBottom.svelte";
|
||||
import Reviewer from "./Reviewer.svelte";
|
||||
import { _blockDefaultDragDropBehavior } from "../../reviewer";
|
||||
import type { PageData } from "./$types";
|
||||
|
||||
export let data: PageData;
|
||||
const state = new ReviewerState();
|
||||
|
||||
onMount(() => {
|
||||
updateNightMode();
|
||||
globalThis.anki ??= {};
|
||||
globalThis.anki.changeReceived = () => state.showQuestion(null);
|
||||
_blockDefaultDragDropBehavior();
|
||||
state.undoStatus = data.initialUndoStatus;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<Reviewer {state}></Reviewer>
|
||||
<ReviewerBottom {state}></ReviewerBottom>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
div {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
9
ts/routes/reviewer/+page.ts
Normal file
9
ts/routes/reviewer/+page.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
import { getUndoStatus } from "@generated/backend";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load = (async () => {
|
||||
const initialUndoStatus = await getUndoStatus({});
|
||||
return { initialUndoStatus };
|
||||
}) satisfies PageLoad;
|
||||
66
ts/routes/reviewer/Reviewer.svelte
Normal file
66
ts/routes/reviewer/Reviewer.svelte
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<!--
|
||||
Copyright: Ankitects Pty Ltd and contributors
|
||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { isNightMode, type ReviewerState } from "./reviewer";
|
||||
|
||||
let iframe: HTMLIFrameElement;
|
||||
export let state: ReviewerState;
|
||||
|
||||
$: if (iframe) {
|
||||
state.registerIFrame(iframe);
|
||||
state.registerShortcuts();
|
||||
}
|
||||
$: tooltipMessage = state.tooltipMessage;
|
||||
$: tooltipShown = state.tooltipShown;
|
||||
$: flag = state.flag;
|
||||
$: marked = state.marked;
|
||||
</script>
|
||||
|
||||
<div class="iframe-container">
|
||||
<iframe
|
||||
src={"/_anki/pages/reviewer-inner.html" + (isNightMode() ? "?nightMode" : "")}
|
||||
bind:this={iframe}
|
||||
title="card"
|
||||
frameborder="0"
|
||||
sandbox="allow-scripts"
|
||||
></iframe>
|
||||
|
||||
<div class="tooltip" style:opacity={$tooltipShown ? 1 : 0}>
|
||||
{$tooltipMessage}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if $flag}
|
||||
<div id="_flag" style:color={`var(--flag-${$flag})`}>⚑</div>
|
||||
{/if}
|
||||
|
||||
{#if $marked}
|
||||
<div id="_mark">★</div>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
div.iframe-container {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
div.tooltip {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
padding: 0.8em;
|
||||
background-color: var(--bs-tooltip-color);
|
||||
z-index: var(--bs-tooltip-z-index);
|
||||
border: 2px solid var(--highlight-fg);
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
visibility: hidden;
|
||||
}
|
||||
</style>
|
||||
1
ts/routes/reviewer/index.scss
Normal file
1
ts/routes/reviewer/index.scss
Normal file
|
|
@ -0,0 +1 @@
|
|||
@use "../../reviewer/reviewer.scss";
|
||||
0
ts/routes/reviewer/index.ts
Normal file
0
ts/routes/reviewer/index.ts
Normal file
37
ts/routes/reviewer/reviewer-bottom/AnswerButton.svelte
Normal file
37
ts/routes/reviewer/reviewer-bottom/AnswerButton.svelte
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<!--
|
||||
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 { NextCardDataResponse_AnswerButton } from "@generated/anki/scheduler_pb";
|
||||
import * as tr from "@generated/ftl";
|
||||
import type { ReviewerState } from "../reviewer";
|
||||
|
||||
export let info: NextCardDataResponse_AnswerButton;
|
||||
export let state: ReviewerState;
|
||||
|
||||
const labels = [
|
||||
tr.studyingAgain(),
|
||||
tr.studyingHard(),
|
||||
tr.studyingGood(),
|
||||
tr.studyingEasy(),
|
||||
];
|
||||
$: label = labels[info.rating];
|
||||
</script>
|
||||
|
||||
<span>
|
||||
{#if info.due}
|
||||
{info.due}
|
||||
{:else}
|
||||
|
||||
{/if}
|
||||
</span>
|
||||
<button on:click={() => state.easeButtonPressed(info.rating)}>
|
||||
{label}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
span {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
269
ts/routes/reviewer/reviewer-bottom/More.svelte
Normal file
269
ts/routes/reviewer/reviewer-bottom/More.svelte
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
<!--
|
||||
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 MoreSubmenu from "./MoreSubmenu.svelte";
|
||||
import MoreItem from "./MoreItem.svelte";
|
||||
import type { ReviewerState } from "../reviewer";
|
||||
import type { MoreMenuItemInfo } from "./types";
|
||||
import Shortcut from "$lib/components/Shortcut.svelte";
|
||||
|
||||
let showFloating = false;
|
||||
let showFlags = false;
|
||||
export let state: ReviewerState;
|
||||
|
||||
const flags = [
|
||||
{ colour: tr.actionsFlagRed(), shortcut: "Ctrl+1" },
|
||||
{ colour: tr.actionsFlagOrange(), shortcut: "Ctrl+2" },
|
||||
{ colour: tr.actionsFlagGreen(), shortcut: "Ctrl+3" },
|
||||
{ colour: tr.actionsFlagBlue(), shortcut: "Ctrl+4" },
|
||||
{ colour: tr.actionsFlagPink(), shortcut: "Ctrl+5" },
|
||||
{ colour: tr.actionsFlagTurquoise(), shortcut: "Ctrl+6" },
|
||||
{ colour: tr.actionsFlagPurple(), shortcut: "Ctrl+7" },
|
||||
];
|
||||
|
||||
const shortcuts: MoreMenuItemInfo[] = [
|
||||
{
|
||||
name: tr.studyingBuryCard(),
|
||||
shortcut: "-",
|
||||
onClick: state.buryOrSuspendCurrentCard.bind(state, false),
|
||||
},
|
||||
{
|
||||
name: tr.actionsForgetCard(),
|
||||
shortcut: "Ctrl+Alt+N",
|
||||
onClick: state.displayForgetMenu.bind(state),
|
||||
},
|
||||
{
|
||||
name: tr.actionsSetDueDate(),
|
||||
shortcut: "Ctrl+Shift+D",
|
||||
onClick: state.displaySetDueDateMenu.bind(state),
|
||||
},
|
||||
{
|
||||
name: tr.actionsSuspendCard(),
|
||||
shortcut: "@",
|
||||
onClick: state.buryOrSuspendCurrentCard.bind(state, true),
|
||||
},
|
||||
{
|
||||
name: tr.actionsOptions(),
|
||||
shortcut: "O",
|
||||
onClick: state.displayOptionsMenu.bind(state),
|
||||
},
|
||||
{
|
||||
name: tr.actionsCardInfo(),
|
||||
shortcut: "I",
|
||||
onClick: state.displayCardInfoMenu.bind(state),
|
||||
},
|
||||
{
|
||||
name: tr.actionsPreviousCardInfo(),
|
||||
shortcut: "Ctrl+Alt+I",
|
||||
onClick: state.displayPreviousCardInfoMenu.bind(state),
|
||||
},
|
||||
|
||||
"hr",
|
||||
// Notes
|
||||
{
|
||||
name: tr.studyingMarkNote(),
|
||||
shortcut: "*",
|
||||
onClick: state.toggleMarked.bind(state),
|
||||
},
|
||||
{
|
||||
name: tr.studyingBuryNote(),
|
||||
shortcut: "=",
|
||||
onClick: state.buryOrSuspendCurrentNote.bind(state, false),
|
||||
},
|
||||
{
|
||||
name: tr.studyingSuspendNote(),
|
||||
shortcut: "!",
|
||||
onClick: state.buryOrSuspendCurrentNote.bind(state, true),
|
||||
},
|
||||
{
|
||||
name: tr.actionsCreateCopy(),
|
||||
shortcut: "Ctrl+Alt+E",
|
||||
onClick: state.displayCreateCopyMenu.bind(state),
|
||||
},
|
||||
{
|
||||
name: tr.studyingDeleteNote(),
|
||||
shortcut:
|
||||
/* FIXME: (I have no apple devices to test it) isMac ? "Ctrl+Backspace" :*/ "Ctrl+Delete",
|
||||
onClick: state.deleteCurrentNote.bind(state),
|
||||
},
|
||||
|
||||
"hr",
|
||||
// Audio
|
||||
{
|
||||
name: tr.actionsReplayAudio(),
|
||||
shortcut: "R",
|
||||
onClick: state.replayAudio.bind(state),
|
||||
},
|
||||
{
|
||||
name: tr.studyingPauseAudio(),
|
||||
shortcut: "5",
|
||||
onClick: state.pauseAudio.bind(state),
|
||||
},
|
||||
{
|
||||
name: tr.studyingAudio5s(),
|
||||
shortcut: "6",
|
||||
onClick: state.AudioSeekBackward.bind(state),
|
||||
},
|
||||
{
|
||||
name: tr.studyingAudioAnd5s(),
|
||||
shortcut: "7",
|
||||
onClick: state.AudioSeekForward.bind(state),
|
||||
},
|
||||
{
|
||||
name: tr.studyingRecordOwnVoice(),
|
||||
shortcut: "Shift+V",
|
||||
onClick: state.RecordVoice.bind(state),
|
||||
},
|
||||
{
|
||||
name: tr.studyingReplayOwnVoice(),
|
||||
shortcut: "V",
|
||||
onClick: state.ReplayRecorded.bind(state),
|
||||
},
|
||||
{
|
||||
name: tr.actionsAutoAdvance(),
|
||||
shortcut: "Shift+A",
|
||||
onClick: () => state.toggleAutoAdvance(),
|
||||
},
|
||||
];
|
||||
|
||||
$: currentFlag = state.flag;
|
||||
$: autoAdvance = state.autoAdvance;
|
||||
|
||||
function prepKeycodeForShortcut(keycode: string) {
|
||||
return keycode.replace("Ctrl", "Control");
|
||||
}
|
||||
|
||||
$: if (!showFloating) {
|
||||
showFlags = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Shortcut
|
||||
keyCombination="m"
|
||||
event="keyup"
|
||||
on:action={() => (showFloating = !showFloating)}
|
||||
/>
|
||||
|
||||
{#each shortcuts as shortcut}
|
||||
{#if shortcut !== "hr"}
|
||||
<Shortcut
|
||||
keyCombination={prepKeycodeForShortcut(shortcut.shortcut)}
|
||||
event="keydown"
|
||||
on:action={shortcut.onClick}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#each flags as flag, i}
|
||||
<Shortcut
|
||||
keyCombination={prepKeycodeForShortcut(flag.shortcut)}
|
||||
event="keydown"
|
||||
on:action={() => {
|
||||
state.changeFlag(i + 1);
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<MoreSubmenu bind:showFloating>
|
||||
<button
|
||||
slot="button"
|
||||
on:click={() => {
|
||||
showFloating = !showFloating;
|
||||
}}
|
||||
title={tr.actionsShortcutKey({ val: "M" })}
|
||||
class="more"
|
||||
>
|
||||
{tr.studyingMore()}
|
||||
</button>
|
||||
|
||||
<div slot="items" class="dropdown">
|
||||
<div class="row">
|
||||
<MoreSubmenu bind:showFloating={showFlags}>
|
||||
<MoreItem
|
||||
slot="button"
|
||||
submenu
|
||||
on:click={() => {
|
||||
showFlags = !showFlags;
|
||||
}}
|
||||
>
|
||||
{tr.studyingFlagCard()}
|
||||
</MoreItem>
|
||||
<div slot="items" class="dropdown">
|
||||
{#each flags as flag, i}
|
||||
{@const flag_id = i + 1}
|
||||
<div
|
||||
style:background-color={$currentFlag == flag_id
|
||||
? `color-mix(in srgb, var(--flag-${flag_id}) 50%, transparent)`
|
||||
: ""}
|
||||
>
|
||||
<MoreItem
|
||||
shortcut={flag.shortcut}
|
||||
on:click={() => {
|
||||
state.changeFlag(flag_id);
|
||||
}}
|
||||
>
|
||||
{flag.colour}
|
||||
</MoreItem>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</MoreSubmenu>
|
||||
</div>
|
||||
{#each shortcuts as shortcut}
|
||||
{#if shortcut == "hr"}
|
||||
<hr />
|
||||
{:else}
|
||||
{@const highlighted = shortcut.shortcut == "Shift+A" && $autoAdvance}
|
||||
<div style:background-color={highlighted ? "RGBA(0,255,0,0.25)" : ""}>
|
||||
<MoreItem
|
||||
shortcut={shortcut.shortcut}
|
||||
on:click={shortcut.onClick}
|
||||
on:keydown={shortcut.onClick}
|
||||
on:action={console.log}
|
||||
>
|
||||
{shortcut.name}
|
||||
</MoreItem>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</MoreSubmenu>
|
||||
|
||||
<style lang="scss">
|
||||
div.dropdown {
|
||||
:global(button) {
|
||||
border-radius: 0;
|
||||
padding: 0.5em;
|
||||
margin: 0;
|
||||
|
||||
&:hover {
|
||||
background: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.more::after {
|
||||
content: " ▾ ";
|
||||
|
||||
:global(.isWin) & {
|
||||
content: " ▼ ";
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
line-height: 18px;
|
||||
}
|
||||
</style>
|
||||
46
ts/routes/reviewer/reviewer-bottom/MoreItem.svelte
Normal file
46
ts/routes/reviewer/reviewer-bottom/MoreItem.svelte
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<!--
|
||||
Copyright: Ankitects Pty Ltd and contributors
|
||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
export let shortcut: string = "";
|
||||
export let submenu: boolean = false;
|
||||
</script>
|
||||
|
||||
<div class="row" on:click on:keydown role="button" tabindex="0">
|
||||
<slot />
|
||||
<span>{shortcut}</span>
|
||||
{#if submenu}
|
||||
<span class="chevron">{"▸"}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div.row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
padding: 0.5em;
|
||||
cursor: pointer;
|
||||
line-height: normal;
|
||||
gap: 0.5em;
|
||||
|
||||
&:hover {
|
||||
background: var(--highlight-bg);
|
||||
color: var(--highlight-fg);
|
||||
}
|
||||
|
||||
span {
|
||||
min-width: 7em;
|
||||
}
|
||||
}
|
||||
|
||||
.chevron {
|
||||
text-align: right;
|
||||
padding-right: 0.5em;
|
||||
opacity: 50%;
|
||||
:global(.isLin) & {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
42
ts/routes/reviewer/reviewer-bottom/MoreSubmenu.svelte
Normal file
42
ts/routes/reviewer/reviewer-bottom/MoreSubmenu.svelte
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<!--
|
||||
Copyright: Ankitects Pty Ltd and contributors
|
||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
import Popover from "$lib/components/Popover.svelte";
|
||||
import WithFloating from "$lib/components/WithFloating.svelte";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
export let showFloating = false;
|
||||
|
||||
function close() {
|
||||
showFloating = false;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("closemenu", close);
|
||||
window.addEventListener("blur", close);
|
||||
return () => {
|
||||
document.removeEventListener("closemenu", close);
|
||||
window.removeEventListener("blur", close);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<WithFloating show={showFloating} inline on:close={close}>
|
||||
<slot slot="reference" name="button"></slot>
|
||||
|
||||
<Popover slot="floating">
|
||||
<slot name="items" />
|
||||
</Popover>
|
||||
</WithFloating>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
div :global(.popover) {
|
||||
padding: 0;
|
||||
|
||||
max-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
40
ts/routes/reviewer/reviewer-bottom/Remaining.svelte
Normal file
40
ts/routes/reviewer/reviewer-bottom/Remaining.svelte
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<!--
|
||||
Copyright: Ankitects Pty Ltd and contributors
|
||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { QueuedCards_Queue } from "@generated/anki/scheduler_pb";
|
||||
import type { ReviewerState } from "../reviewer";
|
||||
import RemainingNumber from "./RemainingNumber.svelte";
|
||||
|
||||
export let state: ReviewerState;
|
||||
const cardData = state.cardData;
|
||||
$: queue = $cardData?.queue;
|
||||
$: underlined = queue?.cards[0].queue;
|
||||
</script>
|
||||
|
||||
<span>
|
||||
<RemainingNumber
|
||||
cls="new-count"
|
||||
underlined={underlined === QueuedCards_Queue.NEW}
|
||||
count={queue?.newCount}
|
||||
></RemainingNumber>
|
||||
{"+"}
|
||||
<RemainingNumber
|
||||
cls="learn-count"
|
||||
underlined={underlined === QueuedCards_Queue.LEARNING}
|
||||
count={queue?.learningCount}
|
||||
></RemainingNumber>
|
||||
{"+"}
|
||||
<RemainingNumber
|
||||
cls="review-count"
|
||||
underlined={underlined === QueuedCards_Queue.REVIEW}
|
||||
count={queue?.reviewCount}
|
||||
></RemainingNumber>
|
||||
</span>
|
||||
|
||||
<style>
|
||||
span {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
19
ts/routes/reviewer/reviewer-bottom/RemainingNumber.svelte
Normal file
19
ts/routes/reviewer/reviewer-bottom/RemainingNumber.svelte
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<!--
|
||||
Copyright: Ankitects Pty Ltd and contributors
|
||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
export let underlined: boolean;
|
||||
export let cls: string;
|
||||
export let count: number | undefined;
|
||||
|
||||
$: displayCount = count?.toFixed(0) ?? "?";
|
||||
</script>
|
||||
|
||||
<span class={cls}>
|
||||
{#if underlined}
|
||||
<u>{displayCount}</u>
|
||||
{:else}
|
||||
{displayCount}
|
||||
{/if}
|
||||
</span>
|
||||
101
ts/routes/reviewer/reviewer-bottom/ReviewerBottom.svelte
Normal file
101
ts/routes/reviewer/reviewer-bottom/ReviewerBottom.svelte
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<!--
|
||||
Copyright: Ankitects Pty Ltd and contributors
|
||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
import "./index.scss";
|
||||
|
||||
import AnswerButton from "./AnswerButton.svelte";
|
||||
import * as tr from "@generated/ftl";
|
||||
import type { ReviewerState } from "../reviewer";
|
||||
import Remaining from "./Remaining.svelte";
|
||||
import More from "./More.svelte";
|
||||
import Timer from "./Timer.svelte";
|
||||
|
||||
export let state: ReviewerState;
|
||||
|
||||
const answerButtons = state.answerButtons;
|
||||
const answerShown = state.answerShown;
|
||||
|
||||
$: buttonCount = $answerShown ? $answerButtons.length : 1;
|
||||
$: cardData = state.cardData;
|
||||
$: remainingShown =
|
||||
($cardData?.queue?.learningCount ?? 0) +
|
||||
($cardData?.queue?.reviewCount ?? 0) +
|
||||
($cardData?.queue?.newCount ?? 0) >
|
||||
0;
|
||||
</script>
|
||||
|
||||
<div id="outer" class="fancy">
|
||||
<div id="tableinner" style="--answer-button-count: {buttonCount}">
|
||||
<span class="disappearing"></span>
|
||||
<div class="disappearing edit">
|
||||
<button
|
||||
title={tr.actionsShortcutKey({ val: "E" })}
|
||||
on:click={() => state.displayEditMenu()}
|
||||
>
|
||||
{tr.studyingEdit()}
|
||||
</button>
|
||||
</div>
|
||||
{#if $answerShown}
|
||||
{#each $answerButtons as answerButton}
|
||||
<AnswerButton {state} info={answerButton}></AnswerButton>
|
||||
{/each}
|
||||
{:else}
|
||||
{#if remainingShown}
|
||||
<Remaining {state}></Remaining>
|
||||
{:else}
|
||||
<span> </span>
|
||||
{/if}
|
||||
<button on:click={() => state.showAnswer()}>
|
||||
{tr.studyingShowAnswer()}
|
||||
</button>
|
||||
{/if}
|
||||
<div class="disappearing more">
|
||||
{#if $cardData?.timer}
|
||||
<Timer {state}></Timer>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="disappearing more">
|
||||
<More {state}></More>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
#tableinner {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr repeat(var(--answer-button-count, 1), auto) 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
justify-content: space-between;
|
||||
justify-items: center;
|
||||
align-items: center;
|
||||
grid-auto-flow: column;
|
||||
}
|
||||
|
||||
#outer {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.more,
|
||||
.edit {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.more {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 583px) {
|
||||
.disappearing {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#tableinner {
|
||||
grid-template-columns: repeat(var(--answer-button-count, 1), auto);
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
67
ts/routes/reviewer/reviewer-bottom/Timer.svelte
Normal file
67
ts/routes/reviewer/reviewer-bottom/Timer.svelte
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<!--
|
||||
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 { ReviewerState } from "../reviewer";
|
||||
import { onDestroy } from "svelte";
|
||||
|
||||
export let state: ReviewerState;
|
||||
|
||||
let text = "";
|
||||
let cls = "";
|
||||
|
||||
function step() {
|
||||
const timerPreferences = state._cardData?.timer;
|
||||
let time = Date.now();
|
||||
if (timerPreferences?.stopOnAnswer && state.answerMs !== undefined) {
|
||||
time = state.answerMs;
|
||||
}
|
||||
time -= state.beginAnsweringMs;
|
||||
const maxTime = state._cardData?.timer?.maxTimeMs ?? 0;
|
||||
if (time >= maxTime) {
|
||||
time = maxTime;
|
||||
cls = "overtime";
|
||||
} else {
|
||||
cls = "";
|
||||
}
|
||||
|
||||
text = formatTime(time);
|
||||
}
|
||||
|
||||
let interval: ReturnType<typeof setInterval> | undefined = undefined;
|
||||
function startTimer() {
|
||||
clearInterval(interval);
|
||||
interval = setInterval(step, 1000);
|
||||
text = formatTime(0);
|
||||
cls = "";
|
||||
}
|
||||
|
||||
state.cardData.subscribe(startTimer);
|
||||
|
||||
onDestroy(() => {
|
||||
clearInterval(interval);
|
||||
});
|
||||
|
||||
function formatTime(time: number) {
|
||||
const seconds = time / 1000;
|
||||
return `${Math.floor(seconds / 60)
|
||||
.toFixed(0)
|
||||
.padStart(2, "0")}:${(seconds % 60).toFixed(0).padStart(2, "0")}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={cls}>
|
||||
{text}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
div {
|
||||
width: 88px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.overtime {
|
||||
color: red;
|
||||
}
|
||||
</style>
|
||||
15
ts/routes/reviewer/reviewer-bottom/index.scss
Normal file
15
ts/routes/reviewer/reviewer-bottom/index.scss
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/* Copyright: Ankitects Pty Ltd and contributors
|
||||
* License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */
|
||||
|
||||
@use "../../../../qt/aqt/data/web/css/reviewer-bottom.scss";
|
||||
@use "../../../../qt/aqt/data/web/css/toolbar.scss";
|
||||
@use "../../../lib/sass/buttons";
|
||||
|
||||
html body {
|
||||
font-size: 12px;
|
||||
height: 72px;
|
||||
|
||||
button {
|
||||
min-width: 80px;
|
||||
}
|
||||
}
|
||||
15
ts/routes/reviewer/reviewer-bottom/types.ts
Normal file
15
ts/routes/reviewer/reviewer-bottom/types.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
export interface AnswerButtonInfo {
|
||||
"extra": string;
|
||||
"key": string;
|
||||
"i": number;
|
||||
"label": string;
|
||||
"due": string;
|
||||
}
|
||||
|
||||
export type MoreMenuItemInfo = {
|
||||
name: string;
|
||||
onClick: () => any;
|
||||
shortcut: string;
|
||||
} | "hr";
|
||||
507
ts/routes/reviewer/reviewer.ts
Normal file
507
ts/routes/reviewer/reviewer.ts
Normal file
|
|
@ -0,0 +1,507 @@
|
|||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
import type { AVTag } from "@generated/anki/card_rendering_pb";
|
||||
import type { UndoStatus } from "@generated/anki/collection_pb";
|
||||
import { DeckConfig_Config_AnswerAction, DeckConfig_Config_QuestionAction } from "@generated/anki/deck_config_pb";
|
||||
import { ReviewerActionRequest_ReviewerAction } from "@generated/anki/frontend_pb";
|
||||
import {
|
||||
BuryOrSuspendCardsRequest_Mode,
|
||||
CardAnswer,
|
||||
type NextCardDataResponse_NextCardData,
|
||||
} from "@generated/anki/scheduler_pb";
|
||||
import {
|
||||
addNoteTags,
|
||||
buryOrSuspendCards,
|
||||
compareAnswer,
|
||||
getConfigJson,
|
||||
nextCardData,
|
||||
playAvtags,
|
||||
redo,
|
||||
removeNotes,
|
||||
removeNoteTags,
|
||||
reviewerAction,
|
||||
setConfigJson,
|
||||
setFlag,
|
||||
undo,
|
||||
} from "@generated/backend";
|
||||
import * as tr from "@generated/ftl";
|
||||
import { derived, get, writable } from "svelte/store";
|
||||
import type { InnerReviewerRequest } from "../reviewer-inner/innerReviewerRequest";
|
||||
import type { ReviewerRequest } from "./reviewerRequest";
|
||||
|
||||
export function isNightMode() {
|
||||
// https://stackoverflow.com/a/57795518
|
||||
// This will be true in browsers if darkmode but also false in the reviewer if darkmode
|
||||
// If in the reviewer then this will need to be set by the python instead
|
||||
return (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches)
|
||||
|| document.documentElement.classList.contains("night-mode");
|
||||
}
|
||||
|
||||
export function enableNightMode() {
|
||||
document.documentElement.classList.add("night-mode");
|
||||
document.documentElement.setAttribute("data-bs-theme", "dark");
|
||||
}
|
||||
|
||||
export function updateNightMode() {
|
||||
if (isNightMode()) {
|
||||
enableNightMode();
|
||||
}
|
||||
}
|
||||
|
||||
const typedAnswerRegex = /\[\[type:(.+?:)?(.+?)\]\]/m;
|
||||
const TOOLTIP_TIMEOUT_MS = 2000;
|
||||
|
||||
export class ReviewerState {
|
||||
answerHtml = "";
|
||||
currentTypedAnswer = "";
|
||||
_cardData: NextCardDataResponse_NextCardData | undefined = undefined;
|
||||
beginAnsweringMs = Date.now();
|
||||
answerMs: number | undefined = undefined;
|
||||
readonly cardClass = writable("");
|
||||
readonly answerShown = writable(false);
|
||||
readonly cardData = writable<NextCardDataResponse_NextCardData | undefined>(undefined);
|
||||
readonly answerButtons = derived(this.cardData, ($cardData) => $cardData?.answerButtons ?? []);
|
||||
tooltipMessageTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
readonly tooltipMessage = writable("");
|
||||
readonly tooltipShown = writable(false);
|
||||
readonly flag = writable(0);
|
||||
readonly marked = writable(false);
|
||||
readonly autoAdvance = writable(false);
|
||||
undoStatus: UndoStatus | undefined = undefined;
|
||||
autoAdvanceQuestionTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
autoAdvanceAnswerTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
_answerShown = false;
|
||||
|
||||
iframe: HTMLIFrameElement | undefined = undefined;
|
||||
|
||||
constructor() {
|
||||
this.autoAdvance.subscribe($autoAdvance => {
|
||||
if (this._answerShown) {
|
||||
this.updateAutoAdvanceAnswer();
|
||||
} else {
|
||||
this.updateAutoAdvanceQuestion();
|
||||
}
|
||||
if (!$autoAdvance) {
|
||||
clearInterval(this.autoAdvanceQuestionTimeout);
|
||||
clearInterval(this.autoAdvanceAnswerTimeout);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toggleAutoAdvance() {
|
||||
this.autoAdvance.update(($autoAdvance) => {
|
||||
// Reversed because the $autoAdvance will be flipped by the return.
|
||||
this.showTooltip($autoAdvance ? tr.actionsAutoAdvanceDeactivated() : tr.actionsAutoAdvanceActivated());
|
||||
return !$autoAdvance;
|
||||
});
|
||||
}
|
||||
|
||||
async onReady() {
|
||||
const { json } = await getConfigJson({ val: "reviewerStorage" });
|
||||
this.sendInnerRequest({ type: "setstorage", json_buffer: json });
|
||||
this.showQuestion(null);
|
||||
addEventListener("message", this.onMessage.bind(this));
|
||||
}
|
||||
|
||||
async onMessage(e: MessageEvent<ReviewerRequest>) {
|
||||
switch (e.data.type) {
|
||||
case "audio": {
|
||||
const tags = get(this.answerShown) ? this._cardData!.answerAvTags : this._cardData!.questionAvTags;
|
||||
this.playAudio([tags[e.data.index]]);
|
||||
break;
|
||||
}
|
||||
case "typed": {
|
||||
this.currentTypedAnswer = e.data.value;
|
||||
break;
|
||||
}
|
||||
case "keypress": {
|
||||
// This is a hacky fix because otherwise while focused on the reviewer-bottom, pressing m only keeps the menu open for the duration of the button press (using "keyup" in the shortcut in More.svelte fixed this)
|
||||
const forceKeyUp = e.data.eventInit.key?.toLowerCase() == "m";
|
||||
document.dispatchEvent(new KeyboardEvent(forceKeyUp ? "keyup" : "keydown", e.data.eventInit));
|
||||
break;
|
||||
}
|
||||
case "closemenu": {
|
||||
document.dispatchEvent(new CustomEvent("closemenu"));
|
||||
break;
|
||||
}
|
||||
case "setstorage": {
|
||||
setConfigJson({
|
||||
key: "reviewerStorage",
|
||||
valueJson: e.data.json_buffer,
|
||||
undoable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public registerIFrame(iframe: HTMLIFrameElement) {
|
||||
this.iframe = iframe;
|
||||
iframe.addEventListener("load", this.onReady.bind(this));
|
||||
}
|
||||
|
||||
public refresh() {
|
||||
this.showQuestion(null);
|
||||
}
|
||||
|
||||
reviewerAction(menu: ReviewerActionRequest_ReviewerAction) {
|
||||
reviewerAction({ menu, currentCardId: this.currentCard?.card?.id });
|
||||
}
|
||||
|
||||
public displayEditMenu() {
|
||||
this.reviewerAction(ReviewerActionRequest_ReviewerAction.EditCurrent);
|
||||
}
|
||||
|
||||
public displaySetDueDateMenu() {
|
||||
this.reviewerAction(ReviewerActionRequest_ReviewerAction.SetDueDate);
|
||||
}
|
||||
|
||||
public displayCardInfoMenu() {
|
||||
this.reviewerAction(ReviewerActionRequest_ReviewerAction.CardInfo);
|
||||
}
|
||||
|
||||
public displayPreviousCardInfoMenu() {
|
||||
this.reviewerAction(ReviewerActionRequest_ReviewerAction.PreviousCardInfo);
|
||||
}
|
||||
|
||||
public displayCreateCopyMenu() {
|
||||
this.reviewerAction(ReviewerActionRequest_ReviewerAction.CreateCopy);
|
||||
}
|
||||
|
||||
public displayForgetMenu() {
|
||||
this.reviewerAction(ReviewerActionRequest_ReviewerAction.Forget);
|
||||
}
|
||||
|
||||
public displayOptionsMenu() {
|
||||
this.reviewerAction(ReviewerActionRequest_ReviewerAction.Options);
|
||||
}
|
||||
|
||||
public displayOverview() {
|
||||
this.reviewerAction(ReviewerActionRequest_ReviewerAction.Overview);
|
||||
}
|
||||
|
||||
public playAudio(tags: AVTag[]) {
|
||||
if (tags.length) {
|
||||
playAvtags({ tags });
|
||||
}
|
||||
}
|
||||
|
||||
maybeAutoPlayAudio(tags: AVTag[]) {
|
||||
if (this._cardData?.autoplay) {
|
||||
this.playAudio(tags);
|
||||
}
|
||||
}
|
||||
|
||||
public replayAudio() {
|
||||
if (this._answerShown) {
|
||||
this.playAudio(this._cardData!.answerAvTags);
|
||||
} else {
|
||||
this.playAudio(this._cardData!.questionAvTags);
|
||||
}
|
||||
}
|
||||
|
||||
public pauseAudio() {
|
||||
this.reviewerAction(ReviewerActionRequest_ReviewerAction.PauseAudio);
|
||||
}
|
||||
|
||||
public AudioSeekBackward() {
|
||||
this.reviewerAction(ReviewerActionRequest_ReviewerAction.SeekBackward);
|
||||
}
|
||||
|
||||
public AudioSeekForward() {
|
||||
this.reviewerAction(ReviewerActionRequest_ReviewerAction.SeekForward);
|
||||
}
|
||||
|
||||
public RecordVoice() {
|
||||
this.reviewerAction(ReviewerActionRequest_ReviewerAction.RecordVoice);
|
||||
}
|
||||
|
||||
public ReplayRecorded() {
|
||||
this.reviewerAction(ReviewerActionRequest_ReviewerAction.ReplayRecorded);
|
||||
}
|
||||
|
||||
public async toggleMarked() {
|
||||
if (this._cardData && this.currentCard?.card?.noteId) {
|
||||
const noteIds = [this.currentCard.card.noteId];
|
||||
if (this._cardData.marked) {
|
||||
await removeNoteTags({ noteIds, tags: "marked" });
|
||||
this.setUndo(tr.actionsRemoveTag());
|
||||
} else {
|
||||
await addNoteTags({ noteIds, tags: "marked" });
|
||||
this.setUndo(tr.actionsUpdateTag());
|
||||
}
|
||||
this.marked.update($marked => !$marked);
|
||||
this._cardData.marked = !this._cardData.marked;
|
||||
}
|
||||
}
|
||||
|
||||
public changeFlag(index: number) {
|
||||
this.flag.update($flag => {
|
||||
if ($flag === index) {
|
||||
index = 0;
|
||||
}
|
||||
setFlag({ cardIds: [this.currentCard!.card!.id], flag: index });
|
||||
return index;
|
||||
});
|
||||
}
|
||||
|
||||
public showTooltip(message: string) {
|
||||
clearTimeout(this.tooltipMessageTimeout);
|
||||
this.tooltipMessage.set(message);
|
||||
this.tooltipShown.set(true);
|
||||
this.tooltipMessageTimeout = setTimeout(() => {
|
||||
this.tooltipShown.set(false);
|
||||
}, TOOLTIP_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
public setUndo(status: string) {
|
||||
// For a list of statuses, see
|
||||
// https://github.com/ankitects/anki/blob/acdf486b290bd47d13e2e880fbb1c14773899091/rslib/src/ops.rs#L57
|
||||
if (this.undoStatus) { // Skip if "undoStatus" is disabled / not set
|
||||
this.undoStatus.undo = status;
|
||||
}
|
||||
}
|
||||
|
||||
public setBuryOrSuspendUndo(suspend: boolean) {
|
||||
this.setUndo(suspend ? tr.studyingSuspend() : tr.studyingBury());
|
||||
}
|
||||
|
||||
public async buryOrSuspendCurrentCard(suspend: boolean) {
|
||||
const mode = suspend ? BuryOrSuspendCardsRequest_Mode.SUSPEND : BuryOrSuspendCardsRequest_Mode.BURY_USER;
|
||||
if (this.currentCard?.card?.id) {
|
||||
await buryOrSuspendCards({
|
||||
cardIds: [this.currentCard.card.id],
|
||||
noteIds: [],
|
||||
mode,
|
||||
});
|
||||
this.showTooltip(suspend ? tr.studyingCardSuspended() : tr.studyingCardsBuried({ count: 1 }));
|
||||
this.setBuryOrSuspendUndo(suspend);
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
public async buryOrSuspendCurrentNote(suspend: boolean) {
|
||||
const mode = suspend ? BuryOrSuspendCardsRequest_Mode.SUSPEND : BuryOrSuspendCardsRequest_Mode.BURY_USER;
|
||||
if (this.currentCard?.card?.noteId) {
|
||||
const op = await buryOrSuspendCards({
|
||||
cardIds: [],
|
||||
noteIds: [this.currentCard.card.noteId],
|
||||
mode,
|
||||
});
|
||||
this.showTooltip(suspend ? tr.studyingNoteSuspended() : tr.studyingCardsBuried({ count: op.count }));
|
||||
this.setBuryOrSuspendUndo(suspend);
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteCurrentNote() {
|
||||
if (this.currentCard?.card?.noteId) {
|
||||
const op = await removeNotes({ noteIds: [this.currentCard.card.noteId], cardIds: [] });
|
||||
this.showTooltip(tr.browsingCardsDeleted({ count: op.count }));
|
||||
this.setUndo(tr.studyingDeleteNote());
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
async handleKeyPress(key: string, ctrl: boolean, shift: boolean) {
|
||||
key = key.toLowerCase();
|
||||
switch (key) {
|
||||
case "1": {
|
||||
this.easeButtonPressed(0);
|
||||
break;
|
||||
}
|
||||
case "2": {
|
||||
this.easeButtonPressed(1);
|
||||
break;
|
||||
}
|
||||
case "3": {
|
||||
this.easeButtonPressed(2);
|
||||
break;
|
||||
}
|
||||
case "4": {
|
||||
this.easeButtonPressed(3);
|
||||
break;
|
||||
}
|
||||
case " ":
|
||||
case "enter": {
|
||||
if (!this._answerShown) {
|
||||
this.showAnswer();
|
||||
} else if (this._cardData?.acceptEnter ?? true) {
|
||||
this.easeButtonPressed(2);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "z": {
|
||||
if (ctrl) {
|
||||
if (shift && this.undoStatus?.redo) {
|
||||
const op = await redo({});
|
||||
this.showTooltip(tr.undoActionRedone({ action: op.operation }));
|
||||
this.undoStatus = op.newStatus;
|
||||
} else if (this.undoStatus?.undo) {
|
||||
const op = await undo({});
|
||||
this.showTooltip(tr.undoActionUndone({ action: op.operation }));
|
||||
this.undoStatus = op.newStatus;
|
||||
} else {
|
||||
this.showTooltip(shift ? tr.actionsNothingToRedo() : tr.actionsNothingToUndo());
|
||||
}
|
||||
this.refresh();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "e": {
|
||||
if (!ctrl) {
|
||||
this.displayEditMenu();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onKeyDown(e: KeyboardEvent) {
|
||||
if (e.repeat) {
|
||||
return;
|
||||
}
|
||||
if (e.key == "Enter") {
|
||||
e.preventDefault();
|
||||
}
|
||||
this.handleKeyPress(e.key, e.ctrlKey, e.shiftKey);
|
||||
}
|
||||
|
||||
public registerShortcuts() {
|
||||
window.addEventListener("keydown", this.onKeyDown.bind(this));
|
||||
}
|
||||
|
||||
sendInnerRequest(message: InnerReviewerRequest) {
|
||||
this.iframe?.contentWindow?.postMessage(message, "*");
|
||||
}
|
||||
|
||||
updateHtml(htmlString: string, css?: string, bodyclass?: string, preload?: string) {
|
||||
this.sendInnerRequest({ type: "html", value: htmlString, css, bodyclass, preload });
|
||||
}
|
||||
|
||||
updateAutoAdvanceQuestion() {
|
||||
clearTimeout(this.autoAdvanceAnswerTimeout);
|
||||
if (get(this.autoAdvance) && this._cardData!.autoAdvanceQuestionSeconds) {
|
||||
const action = ({
|
||||
[DeckConfig_Config_QuestionAction.SHOW_ANSWER]: () => {
|
||||
this.showAnswer();
|
||||
},
|
||||
[DeckConfig_Config_QuestionAction.SHOW_REMINDER]: () => {
|
||||
this.showTooltip(tr.studyingQuestionTimeElapsed());
|
||||
},
|
||||
})[this._cardData!.autoAdvanceQuestionAction];
|
||||
|
||||
this.autoAdvanceQuestionTimeout = setTimeout(action, this._cardData!.autoAdvanceQuestionSeconds * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
updateAutoAdvanceAnswer() {
|
||||
clearTimeout(this.autoAdvanceQuestionTimeout);
|
||||
if (get(this.autoAdvance) && this._cardData?.autoAdvanceAnswerSeconds) {
|
||||
const action = ({
|
||||
[DeckConfig_Config_AnswerAction.ANSWER_AGAIN]: () => {
|
||||
this.easeButtonPressed(0);
|
||||
},
|
||||
[DeckConfig_Config_AnswerAction.ANSWER_HARD]: () => {
|
||||
this.easeButtonPressed(1);
|
||||
},
|
||||
[DeckConfig_Config_AnswerAction.ANSWER_GOOD]: () => {
|
||||
this.easeButtonPressed(2);
|
||||
},
|
||||
[DeckConfig_Config_AnswerAction.BURY_CARD]: () => {
|
||||
this.buryOrSuspendCurrentCard(false);
|
||||
},
|
||||
[DeckConfig_Config_AnswerAction.SHOW_REMINDER]: () => {
|
||||
this.showTooltip(tr.studyingAnswerTimeElapsed());
|
||||
},
|
||||
})[this._cardData.autoAdvanceAnswerAction];
|
||||
|
||||
this.autoAdvanceAnswerTimeout = setTimeout(action, this._cardData.autoAdvanceAnswerSeconds * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
async showQuestion(answer: CardAnswer | null) {
|
||||
if (answer !== null) {
|
||||
this.setUndo(tr.actionsAnswerCard());
|
||||
}
|
||||
|
||||
this._answerShown = false;
|
||||
const resp = await nextCardData({
|
||||
answer: answer || undefined,
|
||||
});
|
||||
|
||||
if (!resp.nextCard) {
|
||||
this.displayOverview();
|
||||
return;
|
||||
}
|
||||
|
||||
this._cardData = resp.nextCard;
|
||||
this.cardData.set(this._cardData);
|
||||
this.flag.set(this.currentCard?.card?.flags ?? 0);
|
||||
this.marked.set(this._cardData.marked);
|
||||
this.answerShown.set(false);
|
||||
|
||||
const question = resp.nextCard?.front || "";
|
||||
this.updateHtml(question, resp?.nextCard?.css, resp?.nextCard?.bodyClass, resp?.preload);
|
||||
this.iframe!.style.visibility = "visible";
|
||||
this.maybeAutoPlayAudio(this._cardData.questionAvTags);
|
||||
this.beginAnsweringMs = Date.now();
|
||||
this.answerMs = undefined;
|
||||
this.updateAutoAdvanceQuestion();
|
||||
}
|
||||
|
||||
get currentCard() {
|
||||
return this._cardData?.queue?.cards[0];
|
||||
}
|
||||
|
||||
async showTypedAnswer(html: string) {
|
||||
if (this._cardData?.typedAnswer === undefined) {
|
||||
return html;
|
||||
}
|
||||
const args = this._cardData.typedAnswer.args;
|
||||
const compareAnswerResp = await compareAnswer({
|
||||
expected: this._cardData.typedAnswer.text,
|
||||
provided: this.currentTypedAnswer,
|
||||
combining: !args.includes("nc"),
|
||||
});
|
||||
|
||||
const prefix = args.includes("cloze") ? "<br/>" : "";
|
||||
const display = prefix + compareAnswerResp.val;
|
||||
|
||||
return html.replace(typedAnswerRegex, display);
|
||||
}
|
||||
|
||||
public async showAnswer() {
|
||||
this.answerShown.set(true);
|
||||
this._answerShown = true;
|
||||
this.maybeAutoPlayAudio(this._cardData!.answerAvTags);
|
||||
this.answerMs = Date.now();
|
||||
this.updateHtml(await this.showTypedAnswer(this._cardData?.back || ""));
|
||||
this.updateAutoAdvanceAnswer();
|
||||
}
|
||||
|
||||
public easeButtonPressed(rating: number) {
|
||||
if (!this._answerShown) {
|
||||
return;
|
||||
}
|
||||
|
||||
const states = this.currentCard!.states!;
|
||||
|
||||
const newState = [
|
||||
states.again!,
|
||||
states.hard!,
|
||||
states.good!,
|
||||
states.easy!,
|
||||
][rating]!;
|
||||
|
||||
this.showQuestion(
|
||||
new CardAnswer({
|
||||
rating: rating,
|
||||
currentState: states!.current!,
|
||||
newState,
|
||||
cardId: this.currentCard?.card?.id,
|
||||
answeredAtMillis: BigInt(Date.now()),
|
||||
millisecondsTaken: Date.now() - this.beginAnsweringMs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
33
ts/routes/reviewer/reviewerRequest.ts
Normal file
33
ts/routes/reviewer/reviewerRequest.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
interface AudioMessage {
|
||||
type: "audio";
|
||||
answerSide: boolean;
|
||||
index: number;
|
||||
}
|
||||
|
||||
interface UpdateTypedAnswerMessage {
|
||||
type: "typed";
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface KeyPressMessage {
|
||||
type: "keypress";
|
||||
eventInit: KeyboardEventInit;
|
||||
}
|
||||
|
||||
interface CloseMenuMessage {
|
||||
type: "closemenu";
|
||||
}
|
||||
|
||||
interface SetStorageMessage {
|
||||
type: "setstorage";
|
||||
json_buffer: Uint8Array;
|
||||
}
|
||||
|
||||
export type ReviewerRequest =
|
||||
| AudioMessage
|
||||
| UpdateTypedAnswerMessage
|
||||
| KeyPressMessage
|
||||
| SetStorageMessage
|
||||
| CloseMenuMessage;
|
||||
Loading…
Reference in a new issue