Anki/ts/routes/reviewer/reviewer.ts
2025-10-29 15:48:21 +00:00

149 lines
4.6 KiB
TypeScript

// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { CardAnswer, type NextCardDataResponse_NextCardData } from "@generated/anki/scheduler_pb";
import { nextCardData, playAudio } from "@generated/backend";
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();
}
}
export class ReviewerState {
answerHtml = "";
_cardData: NextCardDataResponse_NextCardData | undefined = undefined;
beginAnsweringMs = Date.now();
readonly cardClass = writable("");
readonly answerShown = writable(false);
readonly cardData = writable<NextCardDataResponse_NextCardData | undefined>(undefined);
readonly answerButtons = derived(this.cardData, ($cardData) => $cardData?.answerButtons ?? []);
iframe: HTMLIFrameElement | undefined = undefined;
onReady() {
this.showQuestion(null);
addEventListener("message", this.onMessage.bind(this));
}
onMessage(e: MessageEvent<ReviewerRequest>) {
switch (e.data.type) {
case "audio": {
playAudio({ answerSide: e.data.answerSide, index: e.data.index, cid: this.currentCard!.card!.id });
break;
}
}
}
public registerIFrame(iframe: HTMLIFrameElement) {
this.iframe = iframe;
iframe.addEventListener("load", this.onReady.bind(this));
}
onKeyDown(e: KeyboardEvent) {
switch (e.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 " ": {
if (!get(this.answerShown)) {
this.showAnswer();
} else {
this.easeButtonPressed(2);
}
break;
}
}
}
public registerShortcuts() {
document.addEventListener("keydown", this.onKeyDown.bind(this));
}
sendInnerRequest(message: InnerReviewerRequest) {
this.iframe?.contentWindow?.postMessage(message, "*");
}
updateHtml(htmlString: string, css?: string, bodyclass?: string) {
this.sendInnerRequest({ type: "html", value: htmlString, css, bodyclass });
}
async showQuestion(answer: CardAnswer | null) {
const resp = await nextCardData({
answer: answer || undefined,
});
// TODO: "Congratulation screen" logic
this._cardData = resp.nextCard;
this.cardData.set(this._cardData);
this.answerShown.set(false);
const question = resp.nextCard?.front || "";
this.updateHtml(question, resp?.nextCard?.css, resp?.nextCard?.bodyClass);
this.beginAnsweringMs = Date.now();
}
get currentCard() {
return this._cardData?.queue?.cards[0];
}
public showAnswer() {
this.answerShown.set(true);
this.updateHtml(this._cardData?.back || "");
}
public easeButtonPressed(rating: number) {
if (!get(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,
}),
);
}
}