diff --git a/ts/reviewer/css.ts b/ts/reviewer/css.ts deleted file mode 100644 index b9bb5d030..000000000 --- a/ts/reviewer/css.ts +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright: Ankitects Pty Ltd and contributors -// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html - -type CssElementType = HTMLStyleElement | HTMLLinkElement; - -const preloadCssClassName = "preload-css"; -const template = document.createElement("template"); - -export async function maybePreloadExternalCss(html: string): Promise { - clearPreloadedCss(); - template.innerHTML = html; - const externalCssElements = extractExternalCssElements(template.content); - if (externalCssElements.length) { - await Promise.race([ - Promise.all(externalCssElements.map(injectAndLoadCss)), - new Promise((r) => setTimeout(r, 500)), - ]); - } -} - -function clearPreloadedCss(): void { - [...document.head.getElementsByClassName(preloadCssClassName)].forEach((css) => css.remove()); -} - -function extractExternalCssElements(fragment: DocumentFragment): CssElementType[] { - return ( - [...fragment.querySelectorAll("style, link")].filter( - (css) => - (css instanceof HTMLStyleElement - && css.innerHTML.includes("@import")) - || (css instanceof HTMLLinkElement && css.rel === "stylesheet"), - ) - ); -} - -function injectAndLoadCss(css: CssElementType): Promise { - return new Promise((resolve) => { - css.classList.add(preloadCssClassName); - - // this prevents the css from affecting the page rendering - css.media = "print"; - - css.addEventListener("load", () => resolve()); - css.addEventListener("error", () => resolve()); - document.head.appendChild(css); - }); -} diff --git a/ts/reviewer/images.ts b/ts/reviewer/images.ts index a30c354fe..19c218409 100644 --- a/ts/reviewer/images.ts +++ b/ts/reviewer/images.ts @@ -1,13 +1,7 @@ // 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); -} +const template = document.createElement("template"); export function allImagesLoaded(): Promise { return Promise.all( @@ -24,42 +18,25 @@ function imageLoaded(img: HTMLImageElement): Promise { }); } -function clearPreloadLinks(): void { - document.head - .querySelectorAll("link[rel='preload']") - .forEach((link) => link.remove()); -} - -function extractImageSrcs(html: string): string[] { - const tmpl = document.createElement("template"); - tmpl.innerHTML = html; - const fragment = tmpl.content; +function extractImageSrcs(fragment: DocumentFragment): string[] { 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")); - } +function createImage(src: string): HTMLImageElement { + const img = new Image(); + img.src = src; + return img; } -export async function maybePreloadImages(html: string): Promise { - const srcs = extractImageSrcs(html); - await Promise.race([ - Promise.all( - srcs.map((src) => { - const img = new Image(); - img.src = src; - return imageLoaded(img); - }), - ), - new Promise((r) => setTimeout(r, 100)), - ]); +export function preloadAnswerImages(html: string): void { + template.innerHTML = html; + extractImageSrcs(template.content).forEach(createImage); +} + +/** Prevent flickering & layout shift on image load */ +export function preloadImages(fragment: DocumentFragment): Promise[] { + return extractImageSrcs(fragment).map(createImage).map(imageLoaded); } diff --git a/ts/reviewer/index.ts b/ts/reviewer/index.ts index 44fef54c4..fddc940f2 100644 --- a/ts/reviewer/index.ts +++ b/ts/reviewer/index.ts @@ -18,8 +18,8 @@ globalThis.anki.setupImageCloze = setupImageCloze; import { bridgeCommand } from "@tslib/bridgecommand"; -import { maybePreloadExternalCss } from "./css"; -import { allImagesLoaded, maybePreloadImages, preloadAnswerImages } from "./images"; +import { allImagesLoaded, preloadAnswerImages } from "./images"; +import { preloadResources } from "./preload"; declare const MathJax: any; @@ -131,11 +131,7 @@ export async function _updateQA( const qa = document.getElementById("qa")!; - // prevent flash of unstyled content when external css used - await maybePreloadExternalCss(html); - - // prevent flickering & layout shift on image load - await maybePreloadImages(html); + await preloadResources(html); qa.style.opacity = "0"; @@ -183,7 +179,7 @@ export function _showQuestion(q: string, a: string, bodyclass: string): void { typeans.focus(); } // preload images - allImagesLoaded().then(() => preloadAnswerImages(q, a)); + allImagesLoaded().then(() => preloadAnswerImages(a)); }, ) ); diff --git a/ts/reviewer/preload.ts b/ts/reviewer/preload.ts new file mode 100644 index 000000000..55942667d --- /dev/null +++ b/ts/reviewer/preload.ts @@ -0,0 +1,114 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import { preloadImages } from "./images"; + +const template = document.createElement("template"); +const htmlDoc = document.implementation.createHTMLDocument(); +const fontURLPattern = /url\s*\(\s*(?["']?)(?\S.*?)\k\s*\)/g; +const cachedFonts = new Set(); + +type CSSElement = HTMLStyleElement | HTMLLinkElement; + +function loadResource(element: HTMLElement): Promise { + return new Promise((resolve) => { + function resolveAndRemove(): void { + resolve(); + document.head.removeChild(element); + } + element.addEventListener("load", resolveAndRemove); + element.addEventListener("error", resolveAndRemove); + document.head.appendChild(element); + }); +} + +function createPreloadLink(href: string, as: string): HTMLLinkElement { + const link = document.createElement("link"); + link.rel = "preload"; + link.href = href; + link.as = as; + if (as === "font") { + link.crossOrigin = ""; + } + return link; +} + +function extractExternalStyleSheets(fragment: DocumentFragment): CSSElement[] { + return ([...fragment.querySelectorAll("style, link")] as CSSElement[]) + .filter((css) => + (css instanceof HTMLStyleElement && css.innerHTML.includes("@import")) + || (css instanceof HTMLLinkElement && css.rel === "stylesheet") + ); +} + +/** Prevent FOUC */ +function preloadStyleSheets(fragment: DocumentFragment): Promise[] { + const promises = extractExternalStyleSheets(fragment).map((css) => { + // prevent the CSS from affecting the page rendering + css.media = "print"; + + return loadResource(css); + }); + return promises; +} + +function extractFontFaceRules(style: HTMLStyleElement): CSSFontFaceRule[] { + htmlDoc.head.innerHTML = ""; + // must be attached to an HTMLDocument to access 'sheet' property + htmlDoc.head.appendChild(style); + + const fontFaceRules: CSSFontFaceRule[] = []; + if (style.sheet) { + for (const rule of style.sheet.cssRules) { + if (rule instanceof CSSFontFaceRule) { + fontFaceRules.push(rule); + } + } + } + return fontFaceRules; +} + +function extractFontURLs(rule: CSSFontFaceRule): string[] { + const src = rule.style.getPropertyValue("src"); + const matches = src.matchAll(fontURLPattern); + return [...matches].map((m) => (m.groups?.url ? m.groups.url : "")).filter(Boolean); +} + +function preloadFonts(fragment: DocumentFragment): Promise[] { + const styles = fragment.querySelectorAll("style"); + const fonts: string[] = []; + for (const style of styles) { + for (const rule of extractFontFaceRules(style)) { + fonts.push(...extractFontURLs(rule)); + } + } + const newFonts = fonts.filter((font) => !cachedFonts.has(font)); + newFonts.forEach((font) => cachedFonts.add(font)); + const promises = newFonts.map((font) => { + const link = createPreloadLink(font, "font"); + return loadResource(link); + }); + return promises; +} + +export async function preloadResources(html: string): Promise { + template.innerHTML = html; + const fragment = template.content; + const styleSheets = preloadStyleSheets(fragment); + const images = preloadImages(fragment); + const fonts = preloadFonts(fragment); + + let timeout: number; + if (fonts.length) { + timeout = 800; + } else if (styleSheets.length) { + timeout = 500; + } else if (images.length) { + timeout = 200; + } else return; + + await Promise.race([ + Promise.all([...styleSheets, ...images, ...fonts]), + new Promise((r) => setTimeout(r, timeout)), + ]); +} diff --git a/ts/tsconfig.json b/ts/tsconfig.json index 4148b5cd8..942f5239c 100644 --- a/ts/tsconfig.json +++ b/ts/tsconfig.json @@ -26,9 +26,10 @@ "es2017", "es2018.intl", "es2018.promise", + "es2018.regexp", "es2019.array", "es2019.object", - "es2019.string", + "es2020.string", "es2020.promise", "dom", "dom.iterable"