anki/ts/reviewer/index.ts
Aristotelis c828a2eb6f
Add APIs for IO card rendering (#2739)
* Refactor: Add index to shapes package

* Add shape draw callback API to setupImageCloze

* Expose IO drawing API, switch away from image cloze naming

We currently use "image occlusion" in most places, but some references to "image cloze" still remain. For consistency's sake and to make it easier to quickly find IO-related code, this commit replaces all remaining references to "image cloze", only maintaining those required for backwards compatibility with existing note types.

* Add cloze ordinal to shapes

* Do not mutate original shapes during (de)normalization

Mutating shapes would be a recipe for trouble when combined with IO API use by external consumers.

(makeNormal(makeAbsolute(makeNormal())) is not idempotent,
and keeping track of the original state would introduce
additional complexity with no discernible performance benefit
or otherwise.)

* Tweak IO API, allowing modifications to ShapeProperties

* Tweak drawShape parameters

* Switch method order

For consistency with previous implementation

* Run Rust formatters

* Simplify position (de)normalization

---------

Co-authored-by: Glutanimate <glutanimate@users.noreply.github.com>
2023-10-20 09:36:46 +10:00

276 lines
7.9 KiB
TypeScript

// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/* eslint
@typescript-eslint/no-explicit-any: "off",
*/
import "css-browser-selector/css_browser_selector.min";
export { default as $, default as jQuery } from "jquery/dist/jquery";
import { imageOcclusionAPI } from "../image-occlusion/review";
import { mutateNextCardStates } from "./answering";
globalThis.anki = globalThis.anki || {};
globalThis.anki.mutateNextCardStates = mutateNextCardStates;
globalThis.anki.imageOcclusion = imageOcclusionAPI;
globalThis.anki.setupImageCloze = imageOcclusionAPI.setup; // deprecated
import { bridgeCommand } from "@tslib/bridgecommand";
import { registerPackage } from "@tslib/runtime-require";
import { allImagesLoaded, preloadAnswerImages } from "./images";
import { preloadResources } from "./preload";
declare const MathJax: any;
type Callback = () => void | Promise<void>;
export const onUpdateHook: Array<Callback> = [];
export const onShownHook: Array<Callback> = [];
export const ankiPlatform = "desktop";
let typeans: HTMLInputElement | undefined;
export function getTypedAnswer(): string | null {
return typeans?.value ?? null;
}
function _runHook(
hooks: Array<Callback>,
): Promise<PromiseSettledResult<void | Promise<void>>[]> {
const promises: (Promise<void> | void)[] = [];
for (const hook of hooks) {
try {
const result = hook();
promises.push(result);
} catch (error) {
console.log("Hook failed: ", error);
}
}
return Promise.allSettled(promises);
}
let _updatingQueue: Promise<void> = Promise.resolve();
export function _queueAction(action: Callback): void {
_updatingQueue = _updatingQueue.then(action);
}
// Setting innerHTML does not evaluate the contents of script tags, so we need
// to add them again in order to trigger the download/evaluation. Promise resolves
// when download/evaluation has completed.
function replaceScript(oldScript: HTMLScriptElement): Promise<void> {
return new Promise((resolve) => {
const newScript = document.createElement("script");
let mustWaitForNetwork = true;
if (oldScript.src) {
newScript.addEventListener("load", () => resolve());
newScript.addEventListener("error", () => resolve());
} else {
mustWaitForNetwork = false;
}
for (const attribute of oldScript.attributes) {
newScript.setAttribute(attribute.name, attribute.value);
}
newScript.appendChild(document.createTextNode(oldScript.innerHTML));
oldScript.replaceWith(newScript);
if (!mustWaitForNetwork) {
resolve();
}
});
}
async function setInnerHTML(element: Element, html: string): Promise<void> {
for (const oldVideo of element.getElementsByTagName("video")) {
oldVideo.pause();
while (oldVideo.firstChild) {
oldVideo.removeChild(oldVideo.firstChild);
}
oldVideo.load();
}
element.innerHTML = html;
for (const oldScript of element.getElementsByTagName("script")) {
await replaceScript(oldScript);
}
}
const renderError = (type: string) => (error: unknown): string => {
const errorMessage = String(error).substring(0, 2000);
let errorStack: string;
if (error instanceof Error) {
errorStack = String(error.stack).substring(0, 2000);
} else {
errorStack = "";
}
return `<div>Invalid ${type} on card: ${errorMessage}\n${errorStack}</div>`.replace(
/\n/g,
"<br>",
);
};
export async function _updateQA(
html: string,
_unusused: unknown,
onupdate: Callback,
onshown: Callback,
): Promise<void> {
onUpdateHook.length = 0;
onUpdateHook.push(onupdate);
onShownHook.length = 0;
onShownHook.push(onshown);
const qa = document.getElementById("qa")!;
await preloadResources(html);
qa.style.opacity = "0";
try {
await setInnerHTML(qa, html);
} catch (error) {
await setInnerHTML(qa, renderError("html")(error));
}
await _runHook(onUpdateHook);
// dynamic toolbar background
bridgeCommand("updateToolbar");
// wait for mathjax to ready
await MathJax.startup.promise
.then(() => {
// clear MathJax buffers from previous typesets
MathJax.typesetClear();
return MathJax.typesetPromise([qa]);
})
.catch(renderError("MathJax"));
qa.style.opacity = "1";
await _runHook(onShownHook);
}
export function _showQuestion(q: string, a: string, bodyclass: string): void {
_queueAction(() =>
_updateQA(
q,
null,
function() {
// return to top of window
window.scrollTo(0, 0);
document.body.className = bodyclass;
},
function() {
// focus typing area if visible
typeans = document.getElementById("typeans") as HTMLInputElement;
if (typeans) {
typeans.focus();
}
// preload images
allImagesLoaded().then(() => preloadAnswerImages(a));
},
)
);
}
function scrollToAnswer(): void {
document.getElementById("answer")?.scrollIntoView();
}
export function _showAnswer(a: string, bodyclass: string): void {
_queueAction(() =>
_updateQA(
a,
null,
function() {
if (bodyclass) {
// when previewing
document.body.className = bodyclass;
}
// avoid scrolling to the answer until images load
allImagesLoaded().then(scrollToAnswer);
},
function() {
/* noop */
},
)
);
}
export function _drawFlag(flag: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7): void {
const elem = document.getElementById("_flag")!;
elem.toggleAttribute("hidden", flag === 0);
elem.style.color = `var(--flag-${flag})`;
}
export function _drawMark(mark: boolean): void {
document.getElementById("_mark")!.toggleAttribute("hidden", !mark);
}
export function _typeAnsPress(): void {
const code = (window.event as KeyboardEvent).code;
if (["Enter", "NumpadEnter"].includes(code)) {
bridgeCommand("ans");
}
}
export function _emulateMobile(enabled: boolean): void {
document.documentElement.classList.toggle("mobile", enabled);
}
// Block Qt's default drag & drop behavior by default
export function _blockDefaultDragDropBehavior(): void {
function handler(evt: DragEvent) {
evt.preventDefault();
}
document.ondragenter = handler;
document.ondragover = handler;
document.ondrop = handler;
}
// work around WebEngine/IME bug in Qt6
// https://github.com/ankitects/anki/issues/1952
const dummyButton = document.createElement("button");
dummyButton.style.position = "absolute";
dummyButton.style.opacity = "0";
document.addEventListener("focusout", (event) => {
// Prevent type box from losing focus when switching IMEs
if (!document.hasFocus()) {
return;
}
const target = event.target;
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
dummyButton.style.left = `${window.scrollX}px`;
dummyButton.style.top = `${window.scrollY}px`;
document.body.appendChild(dummyButton);
dummyButton.focus();
document.body.removeChild(dummyButton);
}
});
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,
});