diff --git a/qt/aqt/browser/previewer.py b/qt/aqt/browser/previewer.py index e7e2b7532..51824250d 100644 --- a/qt/aqt/browser/previewer.py +++ b/qt/aqt/browser/previewer.py @@ -124,17 +124,14 @@ class Previewer(QDialog): self._close_callback() def _setup_web_view(self) -> None: - jsinc = [ - "js/vendor/jquery.min.js", - "js/vendor/css_browser_selector.min.js", - "js/mathjax.js", - "js/vendor/mathjax/tex-chtml.js", - "js/reviewer.js", - ] self._web.stdHtml( self.mw.reviewer.revHtml(), css=["css/reviewer.css"], - js=jsinc, + js=[ + "js/mathjax.js", + "js/vendor/mathjax/tex-chtml.js", + "js/reviewer.js", + ], context=self, ) self._web.set_bridge_command(self._on_bridge_cmd, self) diff --git a/qt/aqt/clayout.py b/qt/aqt/clayout.py index 4c69299e1..20319cc3c 100644 --- a/qt/aqt/clayout.py +++ b/qt/aqt/clayout.py @@ -330,17 +330,14 @@ class CardLayout(QDialog): ) qconnect(pform.preview_settings.clicked, self.on_preview_settings) - jsinc = [ - "js/vendor/jquery.min.js", - "js/vendor/css_browser_selector.min.js", - "js/mathjax.js", - "js/vendor/mathjax/tex-chtml.js", - "js/reviewer.js", - ] self.preview_web.stdHtml( self.mw.reviewer.revHtml(), css=["css/reviewer.css"], - js=jsinc, + js=[ + "js/mathjax.js", + "js/vendor/mathjax/tex-chtml.js", + "js/reviewer.js", + ], context=self, ) self.preview_web.set_bridge_command(self._on_bridge_cmd, self) diff --git a/qt/aqt/data/web/css/BUILD.bazel b/qt/aqt/data/web/css/BUILD.bazel index e29acf7ed..6ac2deb4c 100644 --- a/qt/aqt/data/web/css/BUILD.bazel +++ b/qt/aqt/data/web/css/BUILD.bazel @@ -26,11 +26,20 @@ copy_files_into_group( package = "//ts/editor", ) +copy_files_into_group( + name = "reviewer", + srcs = [ + "reviewer.css", + ], + package = "//ts/reviewer", +) + filegroup( name = "css", srcs = [ "css_local", "editor", + "reviewer", ], visibility = ["//qt:__subpackages__"], ) diff --git a/qt/aqt/data/web/js/BUILD.bazel b/qt/aqt/data/web/js/BUILD.bazel index 20db65237..c46de6f84 100644 --- a/qt/aqt/data/web/js/BUILD.bazel +++ b/qt/aqt/data/web/js/BUILD.bazel @@ -38,9 +38,9 @@ copy_files_into_group( ) copy_files_into_group( - name = "reviewer_extras", + name = "reviewer", srcs = [ - "reviewer_extras.js", + "reviewer.js", ], package = "//ts/reviewer", ) @@ -50,8 +50,8 @@ filegroup( srcs = [ "aqt_es5", "editor", + "reviewer", "mathjax.js", - "reviewer_extras", "//qt/aqt/data/web/js/vendor", ], visibility = ["//qt:__subpackages__"], diff --git a/qt/aqt/data/web/js/reviewer.ts b/qt/aqt/data/web/js/reviewer.ts deleted file mode 100644 index 92fb7ee2a..000000000 --- a/qt/aqt/data/web/js/reviewer.ts +++ /dev/null @@ -1,240 +0,0 @@ -/* Copyright: Ankitects Pty Ltd and contributors - * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ - -declare var MathJax: any; - -type Callback = () => void | Promise; - -var ankiPlatform = "desktop"; -var typeans: HTMLElement | undefined; -var _updatingQueue: Promise = Promise.resolve(); - -var onUpdateHook: Array; -var onShownHook: Array; - -function _runHook(arr: Array): Promise { - const promises = []; - - for (let i = 0; i < arr.length; i++) { - promises.push(arr[i]()); - } - - return Promise.all(promises); -} - -function _queueAction(action: Callback): void { - _updatingQueue = _updatingQueue.then(action); -} - -function setInnerHTML(element: Element, html: string): 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")) { - const newScript = document.createElement("script"); - - for (const attribute of oldScript.attributes) { - newScript.setAttribute(attribute.name, attribute.value); - } - - newScript.appendChild(document.createTextNode(oldScript.innerHTML)); - oldScript.parentNode.replaceChild(newScript, oldScript); - } -} - -async function _updateQA( - html: string, - _unusused: unknown, - onupdate: Callback, - onshown: Callback -): Promise { - onUpdateHook = [onupdate]; - onShownHook = [onshown]; - - const qa = document.getElementById("qa")!; - const renderError = - (kind: string) => - (error: Error): void => { - const errorMessage = String(error).substring(0, 2000); - const errorStack = String(error.stack).substring(0, 2000); - qa.innerHTML = - `Invalid ${kind} on card: ${errorMessage}\n${errorStack}`.replace( - /\n/g, - "
" - ); - }; - - // hide current card - qa.style.opacity = "0"; - - // update card - try { - setInnerHTML(qa, html); - } catch (error) { - renderError("HTML")(error); - } - - await _runHook(onUpdateHook); - - // 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")); - - // and reveal card when processing is done - qa.style.opacity = "1"; - await _runHook(onShownHook); -} - -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"); - if (typeans) { - typeans.focus(); - } - // preload images - allImagesLoaded().then(() => preloadAnswerImages(q, a)); - } - ) - ); -} - -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 () {} - ) - ); -} - -const _flagColours = { - 1: "#e25252", - 2: "#ffb347", - 3: "#54c414", - 4: "#578cff", - 5: "#ff82ee", - 6: "#00d1b5", - 7: "#9649dd", -}; - -function _drawFlag(flag: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7): void { - const elem = document.getElementById("_flag"); - if (flag === 0) { - elem.setAttribute("hidden", ""); - return; - } - elem.removeAttribute("hidden"); - elem.style.color = _flagColours[flag]; -} - -function _drawMark(mark: boolean): void { - const elem = document.getElementById("_mark"); - if (!mark) { - elem.setAttribute("hidden", ""); - } else { - elem.removeAttribute("hidden"); - } -} - -function _typeAnsPress(): void { - const code = (window.event as KeyboardEvent).code; - if (["Enter", "NumpadEnter"].includes(code)) { - pycmd("ans"); - } -} - -function _emulateMobile(enabled: boolean): void { - const list = document.documentElement.classList; - if (enabled) { - list.add("mobile"); - } else { - list.remove("mobile"); - } -} - -function allImagesLoaded(): Promise { - return Promise.all( - Array.from(document.getElementsByTagName("img")).map(imageLoaded) - ); -} - -function imageLoaded(img: HTMLImageElement): Promise { - return img.complete - ? Promise.resolve() - : new Promise((resolve) => { - img.addEventListener("load", () => resolve()); - img.addEventListener("error", () => resolve()); - }); -} - -function scrollToAnswer(): void { - document.getElementById("answer")?.scrollIntoView(); -} - -function injectPreloadLink(href: string, as: string): void { - const link = document.createElement("link"); - link.rel = "preload"; - link.href = href; - link.as = as; - document.head.appendChild(link); -} - -function clearPreloadLinks(): void { - document.head - .querySelectorAll("link[rel='preload']") - .forEach((link) => link.remove()); -} - -function extractImageSrcs(html: string): string[] { - const fragment = document.createRange().createContextualFragment(html); - const srcs = [...fragment.querySelectorAll("img[src]")].map( - (img) => (img as HTMLImageElement).src - ); - return srcs; -} - -function preloadAnswerImages(qHtml: string, aHtml: string): void { - clearPreloadLinks(); - const aSrcs = extractImageSrcs(aHtml); - if (aSrcs.length) { - const qSrcs = extractImageSrcs(qHtml); - const diff = aSrcs.filter((src) => !qSrcs.includes(src)); - diff.forEach((src) => injectPreloadLink(src, "image")); - } -} diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index 9e7715535..f5e1d2793 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -288,12 +288,9 @@ class Reviewer: self.revHtml(), css=["css/reviewer.css"], js=[ - "js/vendor/jquery.min.js", - "js/vendor/css_browser_selector.min.js", "js/mathjax.js", "js/vendor/mathjax/tex-chtml.js", "js/reviewer.js", - "js/reviewer_extras.js", ], context=self, ) @@ -699,7 +696,7 @@ class Reviewer: return s def _getTypedAnswer(self) -> None: - self.web.evalWithCallback("typeans ? typeans.value : null", self._onTypedAnswer) + self.web.evalWithCallback("getTypedAnswer();", self._onTypedAnswer) def _onTypedAnswer(self, val: None) -> None: self.typedAnswer = val or "" diff --git a/ts/editor/BUILD.bazel b/ts/editor/BUILD.bazel index 15946689b..18221bd9e 100644 --- a/ts/editor/BUILD.bazel +++ b/ts/editor/BUILD.bazel @@ -33,6 +33,7 @@ compile_sass( visibility = ["//visibility:public"], deps = [ "//ts/sass:scrollbar_lib", + "//ts/sass/codemirror", ], ) @@ -132,10 +133,10 @@ esbuild( output_css = "editor.css", visibility = ["//visibility:public"], deps = [ - "base_css", - "bootstrap-icons", "editor_ts", + "base_css", "local_css", + "bootstrap-icons", "mdi-icons", "svelte_components", "//ts/components", diff --git a/ts/reviewer/BUILD.bazel b/ts/reviewer/BUILD.bazel index 76bec83ff..ebc3b79f0 100644 --- a/ts/reviewer/BUILD.bazel +++ b/ts/reviewer/BUILD.bazel @@ -5,23 +5,39 @@ load("//ts/svelte:svelte.bzl", "svelte", "svelte_check") load("//ts:esbuild.bzl", "esbuild") load("//ts:compile_sass.bzl", "compile_sass") +compile_sass( + srcs = [ + "reviewer.scss", + ], + group = "reviewer_scss", + visibility = ["//visibility:public"], + deps = [], +) + + ts_library( - name = "lib", + name = "reviewer_ts", srcs = glob(["*.ts"]), - deps = ["//ts/lib"], + deps = [ + "//ts/lib", + "//ts/lib:backend_proto", + "@npm//css-browser-selector", + "@npm//jquery", + ], ) esbuild( - name = "reviewer_extras", + name = "reviewer", args = [ "--resolve-extensions=.mjs,.js", "--log-level=warning", ], - entry_point = "index.ts", + entry_point = "index_wrapper.ts", visibility = ["//visibility:public"], deps = [ - ":lib", + ":reviewer_ts", "//ts/lib", + "//ts/lib:backend_proto", "@npm//protobufjs", ], ) diff --git a/ts/reviewer/images.ts b/ts/reviewer/images.ts new file mode 100644 index 000000000..d98635cf1 --- /dev/null +++ b/ts/reviewer/images.ts @@ -0,0 +1,49 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +function injectPreloadLink(href: string, as: string): void { + const link = document.createElement("link"); + link.rel = "preload"; + link.href = href; + link.as = as; + document.head.appendChild(link); +} + +export function allImagesLoaded(): Promise { + return Promise.all( + Array.from(document.getElementsByTagName("img")).map(imageLoaded) + ); +} + +function imageLoaded(img: HTMLImageElement): Promise { + return img.complete + ? Promise.resolve() + : new Promise((resolve) => { + img.addEventListener("load", () => resolve()); + img.addEventListener("error", () => resolve()); + }); +} + +function clearPreloadLinks(): void { + document.head + .querySelectorAll("link[rel='preload']") + .forEach((link) => link.remove()); +} + +function extractImageSrcs(html: string): string[] { + const fragment = document.createRange().createContextualFragment(html); + const srcs = [...fragment.querySelectorAll("img[src]")].map( + (img) => (img as HTMLImageElement).src + ); + return srcs; +} + +export function preloadAnswerImages(qHtml: string, aHtml: string): void { + clearPreloadLinks(); + const aSrcs = extractImageSrcs(aHtml); + if (aSrcs.length) { + const qSrcs = extractImageSrcs(qHtml); + const diff = aSrcs.filter((src) => !qSrcs.includes(src)); + diff.forEach((src) => injectPreloadLink(src, "image")); + } +} diff --git a/ts/reviewer/index.ts b/ts/reviewer/index.ts index ebe6a96fb..334f9f988 100644 --- a/ts/reviewer/index.ts +++ b/ts/reviewer/index.ts @@ -1,9 +1,189 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -// This is a temporary extra file we load separately from reviewer.ts. Once -// reviewer.ts has been migrated into ts/, the code here can be merged into -// it. +/* eslint +@typescript-eslint/no-non-null-assertion: "off", +@typescript-eslint/no-explicit-any: "off", + */ -import { mutateNextCardStates } from "./answering"; -globalThis.anki = { ...globalThis.anki, mutateNextCardStates }; +import "css-browser-selector/css_browser_selector"; +import "jquery/dist/jquery"; + +export { mutateNextCardStates } from "./answering"; + +import { bridgeCommand } from "lib/bridgecommand"; +import { allImagesLoaded, preloadAnswerImages } from "./images"; +declare const MathJax: any; + +type Callback = () => void | Promise; + +export const onUpdateHook: Array = []; +export const onShownHook: Array = []; + +export const ankiPlatform = "desktop"; +let typeans: HTMLInputElement | undefined; + +export function getTypedAnswer(): string | null { + return typeans?.value ?? null; +} + +function _runHook(arr: Array): Promise { + const promises: (Promise | void)[] = []; + + for (let i = 0; i < arr.length; i++) { + promises.push(arr[i]()); + } + + return Promise.all(promises); +} + +let _updatingQueue: Promise = Promise.resolve(); + +function _queueAction(action: Callback): void { + _updatingQueue = _updatingQueue.then(action); +} + +function setInnerHTML(element: Element, html: string): 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")) { + const newScript = document.createElement("script"); + + for (const attribute of oldScript.attributes) { + newScript.setAttribute(attribute.name, attribute.value); + } + + newScript.appendChild(document.createTextNode(oldScript.innerHTML)); + oldScript.parentNode!.replaceChild(newScript, oldScript); + } +} + +const renderError = + (type: string) => + (error: Error): string => { + const errorMessage = String(error).substring(0, 2000); + const errorStack = String(error.stack).substring(0, 2000); + return `
Invalid ${type} on card: ${errorMessage}\n${errorStack}
`.replace( + /\n/g, + "
" + ); + }; + +async function _updateQA( + html: string, + _unusused: unknown, + onupdate: Callback, + onshown: Callback +): Promise { + onUpdateHook.length = 0; + onUpdateHook.push(onupdate); + + onShownHook.length = 0; + onShownHook.push(onshown); + + const qa = document.getElementById("qa")!; + + qa.style.opacity = "0"; + + try { + setInnerHTML(qa, html); + } catch (error) { + setInnerHTML(qa, renderError("html")(error)); + } + + await _runHook(onUpdateHook); + + // 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(q, 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}-fg)`; +} + +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); +} diff --git a/ts/reviewer/index_wrapper.ts b/ts/reviewer/index_wrapper.ts new file mode 100644 index 000000000..13b2ec606 --- /dev/null +++ b/ts/reviewer/index_wrapper.ts @@ -0,0 +1,8 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +// extend the global namespace with our exports - not sure if there's a better way with esbuild +import * as globals from "./index"; +for (const key in globals) { + window[key] = globals[key]; +} diff --git a/qt/aqt/data/web/css/reviewer.scss b/ts/reviewer/reviewer.scss similarity index 100% rename from qt/aqt/data/web/css/reviewer.scss rename to ts/reviewer/reviewer.scss