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
|
||||
// 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"));
|
||||
}
|
||||
function createImage(src: string): HTMLImageElement {
|
||||
const img = new Image();
|
||||
img.src = src;
|
||||
return img;
|
||||
}
|
||||
|
||||
export async function maybePreloadImages(html: string): Promise<void> {
|
||||
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<void>[] {
|
||||
return extractImageSrcs(fragment).map(createImage).map(imageLoaded);
|
||||
}
|
||||
|
|
|
@ -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
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",
|
||||
"es2018.intl",
|
||||
"es2018.promise",
|
||||
"es2018.regexp",
|
||||
"es2019.array",
|
||||
"es2019.object",
|
||||
"es2019.string",
|
||||
"es2020.string",
|
||||
"es2020.promise",
|
||||
"dom",
|
||||
"dom.iterable"
|
||||
|
|
Loading…
Reference in a new issue