mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 14:02:21 -04:00
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:
parent
f356f177a2
commit
779ca57660
5 changed files with 134 additions and 93 deletions
|
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
|
@ -1,13 +1,7 @@
|
||||||
// Copyright: Ankitects Pty Ltd and contributors
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
function injectPreloadLink(href: string, as: string): void {
|
const template = document.createElement("template");
|
||||||
const link = document.createElement("link");
|
|
||||||
link.rel = "preload";
|
|
||||||
link.href = href;
|
|
||||||
link.as = as;
|
|
||||||
document.head.appendChild(link);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function allImagesLoaded(): Promise<void[]> {
|
export function allImagesLoaded(): Promise<void[]> {
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
|
@ -24,42 +18,25 @@ function imageLoaded(img: HTMLImageElement): Promise<void> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearPreloadLinks(): void {
|
function extractImageSrcs(fragment: DocumentFragment): string[] {
|
||||||
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;
|
|
||||||
const srcs = [...fragment.querySelectorAll("img[src]")].map(
|
const srcs = [...fragment.querySelectorAll("img[src]")].map(
|
||||||
(img) => (img as HTMLImageElement).src,
|
(img) => (img as HTMLImageElement).src,
|
||||||
);
|
);
|
||||||
return srcs;
|
return srcs;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function preloadAnswerImages(qHtml: string, aHtml: string): void {
|
function createImage(src: string): HTMLImageElement {
|
||||||
clearPreloadLinks();
|
const img = new Image();
|
||||||
const aSrcs = extractImageSrcs(aHtml);
|
img.src = src;
|
||||||
if (aSrcs.length) {
|
return img;
|
||||||
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> {
|
export function preloadAnswerImages(html: string): void {
|
||||||
const srcs = extractImageSrcs(html);
|
template.innerHTML = html;
|
||||||
await Promise.race([
|
extractImageSrcs(template.content).forEach(createImage);
|
||||||
Promise.all(
|
}
|
||||||
srcs.map((src) => {
|
|
||||||
const img = new Image();
|
/** Prevent flickering & layout shift on image load */
|
||||||
img.src = src;
|
export function preloadImages(fragment: DocumentFragment): Promise<void>[] {
|
||||||
return imageLoaded(img);
|
return extractImageSrcs(fragment).map(createImage).map(imageLoaded);
|
||||||
}),
|
|
||||||
),
|
|
||||||
new Promise((r) => setTimeout(r, 100)),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,8 +18,8 @@ globalThis.anki.setupImageCloze = setupImageCloze;
|
||||||
|
|
||||||
import { bridgeCommand } from "@tslib/bridgecommand";
|
import { bridgeCommand } from "@tslib/bridgecommand";
|
||||||
|
|
||||||
import { maybePreloadExternalCss } from "./css";
|
import { allImagesLoaded, preloadAnswerImages } from "./images";
|
||||||
import { allImagesLoaded, maybePreloadImages, preloadAnswerImages } from "./images";
|
import { preloadResources } from "./preload";
|
||||||
|
|
||||||
declare const MathJax: any;
|
declare const MathJax: any;
|
||||||
|
|
||||||
|
@ -131,11 +131,7 @@ export async function _updateQA(
|
||||||
|
|
||||||
const qa = document.getElementById("qa")!;
|
const qa = document.getElementById("qa")!;
|
||||||
|
|
||||||
// prevent flash of unstyled content when external css used
|
await preloadResources(html);
|
||||||
await maybePreloadExternalCss(html);
|
|
||||||
|
|
||||||
// prevent flickering & layout shift on image load
|
|
||||||
await maybePreloadImages(html);
|
|
||||||
|
|
||||||
qa.style.opacity = "0";
|
qa.style.opacity = "0";
|
||||||
|
|
||||||
|
@ -183,7 +179,7 @@ export function _showQuestion(q: string, a: string, bodyclass: string): void {
|
||||||
typeans.focus();
|
typeans.focus();
|
||||||
}
|
}
|
||||||
// preload images
|
// preload images
|
||||||
allImagesLoaded().then(() => preloadAnswerImages(q, a));
|
allImagesLoaded().then(() => preloadAnswerImages(a));
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
114
ts/reviewer/preload.ts
Normal file
114
ts/reviewer/preload.ts
Normal 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)),
|
||||||
|
]);
|
||||||
|
}
|
|
@ -26,9 +26,10 @@
|
||||||
"es2017",
|
"es2017",
|
||||||
"es2018.intl",
|
"es2018.intl",
|
||||||
"es2018.promise",
|
"es2018.promise",
|
||||||
|
"es2018.regexp",
|
||||||
"es2019.array",
|
"es2019.array",
|
||||||
"es2019.object",
|
"es2019.object",
|
||||||
"es2019.string",
|
"es2020.string",
|
||||||
"es2020.promise",
|
"es2020.promise",
|
||||||
"dom",
|
"dom",
|
||||||
"dom.iterable"
|
"dom.iterable"
|
||||||
|
|
Loading…
Reference in a new issue