Refactor CSS/image preloading; implement custom font preloading (#2356)

* Refactor CSS preloading

- Rename css.ts to preload.ts
- Rename type/function names
- Automatically remove style/link element on load/error event

* Refactor image preloading

- Reuse template element
- Change timeout value from 100ms to 200ms, as it often takes more than
  100ms to load even a single small image on a low-spec machine
- Refactor preloadAnswerImages():
  - Use 'new Image()' instead of <link rel=preload>
  - Stop calculating images that only appear on the answer side as
    cached images are resolved immediately

* Update tsconfig.json

es2020.string -> String.matchAll()
es2018.regexp -> RegExprMatchArray.groups

* Implement custom font preloading

Font files for some languages such as Chinese and Japanese can be as
large as 20MB, so we set the timeout value to 800ms for font preloading.
This commit is contained in:
Hikaru Y 2023-05-10 12:26:02 +09:00 committed by GitHub
parent f356f177a2
commit 779ca57660
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 134 additions and 93 deletions

View file

@ -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<void> {
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 <CssElementType[]> (
[...fragment.querySelectorAll("style, link")].filter(
(css) =>
(css instanceof HTMLStyleElement
&& css.innerHTML.includes("@import"))
|| (css instanceof HTMLLinkElement && css.rel === "stylesheet"),
)
);
}
function injectAndLoadCss(css: CssElementType): Promise<void> {
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);
});
}

View file

@ -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<void[]> {
return Promise.all(
@ -24,42 +18,25 @@ function imageLoaded(img: HTMLImageElement): Promise<void> {
});
}
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"));
}
}
export async function maybePreloadImages(html: string): Promise<void> {
const srcs = extractImageSrcs(html);
await Promise.race([
Promise.all(
srcs.map((src) => {
function createImage(src: string): HTMLImageElement {
const img = new Image();
img.src = src;
return imageLoaded(img);
}),
),
new Promise((r) => setTimeout(r, 100)),
]);
return img;
}
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<void>[] {
return extractImageSrcs(fragment).map(createImage).map(imageLoaded);
}

View file

@ -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));
},
)
);

114
ts/reviewer/preload.ts Normal file
View file

@ -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*(?<quote>["']?)(?<url>\S.*?)\k<quote>\s*\)/g;
const cachedFonts = new Set<string>();
type CSSElement = HTMLStyleElement | HTMLLinkElement;
function loadResource(element: HTMLElement): Promise<void> {
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<void>[] {
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<void>[] {
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<void> {
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)),
]);
}

View file

@ -26,9 +26,10 @@
"es2017",
"es2018.intl",
"es2018.promise",
"es2018.regexp",
"es2019.array",
"es2019.object",
"es2019.string",
"es2020.string",
"es2020.promise",
"dom",
"dom.iterable"