263 lines
7.6 KiB
TypeScript
263 lines
7.6 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import {
|
|
ensureGoogleFontsForDescriptors,
|
|
ensureTemplateFontsForDescriptors,
|
|
waitForFontDescriptorsLoaded,
|
|
type TemplateFontOption,
|
|
} from "@/components/slide-editor/text/google-fonts";
|
|
import {
|
|
fontFromRecord,
|
|
rawFont,
|
|
} from "@/components/slide-editor/text/template-v2-text";
|
|
import {
|
|
asRecord,
|
|
childArrayInfo,
|
|
readArray,
|
|
readString,
|
|
type RawElement,
|
|
type RawUi,
|
|
} from "@/components/slide-editor/model/model";
|
|
|
|
const EMPTY_TEMPLATE_FONTS: TemplateFontOption[] = [];
|
|
|
|
export function useFontLoadState(
|
|
ui: RawUi,
|
|
templateFonts: TemplateFontOption[] = EMPTY_TEMPLATE_FONTS,
|
|
) {
|
|
const fontSignature = useMemo(() => fontLoadSignatureForUi(ui), [ui]);
|
|
const [state, setState] = useState(() => ({
|
|
revision: 0,
|
|
ready: !fontSignature,
|
|
}));
|
|
|
|
useEffect(() => {
|
|
if (
|
|
typeof document === "undefined" ||
|
|
!document.fonts ||
|
|
!fontSignature
|
|
) {
|
|
setState((current) =>
|
|
current.ready ? current : { ...current, ready: true },
|
|
);
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
let animationFrame: number | null = null;
|
|
let readyFallbackTimeout: number | null = null;
|
|
let headMutationObserver: MutationObserver | null = null;
|
|
const markReady = () => {
|
|
if (cancelled) return;
|
|
setState((current) => ({
|
|
revision: current.revision + 1,
|
|
ready: true,
|
|
}));
|
|
};
|
|
const scheduleReadyProbe = () => {
|
|
if (cancelled) return;
|
|
if (animationFrame != null) {
|
|
window.cancelAnimationFrame(animationFrame);
|
|
}
|
|
animationFrame = window.requestAnimationFrame(() => {
|
|
animationFrame = null;
|
|
if (areFontDescriptorsLoaded(fontSignature)) {
|
|
markReady();
|
|
}
|
|
});
|
|
};
|
|
|
|
const fonts = document.fonts;
|
|
const descriptors = fontSignature.split("\n").filter(Boolean);
|
|
const templateFontFamilies = templateFonts.map(({ family }) => family);
|
|
const stylesheetLoads = [
|
|
...ensureTemplateFontsForDescriptors(descriptors, templateFonts),
|
|
...ensureGoogleFontsForDescriptors(descriptors, templateFontFamilies),
|
|
];
|
|
const fontsAlreadyReady =
|
|
stylesheetLoads.length === 0 && areFontDescriptorsLoaded(fontSignature);
|
|
setState((current) =>
|
|
current.ready && fontsAlreadyReady
|
|
? current
|
|
: { ...current, ready: false },
|
|
);
|
|
|
|
// Keep canvas hidden while we expect fonts, but never indefinitely.
|
|
readyFallbackTimeout = window.setTimeout(markReady, 4000);
|
|
void Promise.all(stylesheetLoads)
|
|
.then(() => waitForFontDescriptorsLoaded(descriptors))
|
|
.then(scheduleReadyProbe)
|
|
.catch(scheduleReadyProbe);
|
|
fonts.addEventListener?.("loadingdone", scheduleReadyProbe);
|
|
fonts.addEventListener?.("loadingerror", scheduleReadyProbe);
|
|
|
|
// Some font injections happen outside this hook; observe head changes and re-probe.
|
|
if (typeof MutationObserver !== "undefined") {
|
|
headMutationObserver = new MutationObserver((mutations) => {
|
|
const changedFontNode = mutations.some((mutation) =>
|
|
Array.from(mutation.addedNodes).some((node) => {
|
|
if (!(node instanceof HTMLElement)) return false;
|
|
if (node.tagName === "STYLE" || node.tagName === "LINK") return true;
|
|
return false;
|
|
}),
|
|
);
|
|
if (!changedFontNode) return;
|
|
void fonts.ready.then(scheduleReadyProbe);
|
|
});
|
|
headMutationObserver.observe(document.head, {
|
|
childList: true,
|
|
subtree: true,
|
|
});
|
|
}
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
if (animationFrame != null) {
|
|
window.cancelAnimationFrame(animationFrame);
|
|
}
|
|
if (readyFallbackTimeout != null) {
|
|
window.clearTimeout(readyFallbackTimeout);
|
|
}
|
|
headMutationObserver?.disconnect();
|
|
fonts.removeEventListener?.("loadingdone", scheduleReadyProbe);
|
|
fonts.removeEventListener?.("loadingerror", scheduleReadyProbe);
|
|
};
|
|
}, [fontSignature, templateFonts]);
|
|
|
|
return state;
|
|
}
|
|
|
|
function areFontDescriptorsLoaded(signature: string) {
|
|
if (!signature || typeof document === "undefined" || !document.fonts) {
|
|
return true;
|
|
}
|
|
return signature
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.every((descriptor) => {
|
|
try {
|
|
return document.fonts.check(descriptor);
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
}
|
|
|
|
function fontLoadSignatureForUi(ui: RawUi) {
|
|
const descriptors = new Set<string>();
|
|
const visitElement = (value: unknown) => {
|
|
const element = asRecord(value);
|
|
if (!element) return;
|
|
collectElementFontDescriptors(element, descriptors);
|
|
childArrayInfo(element)?.items.forEach(visitElement);
|
|
};
|
|
|
|
readArray(ui.elements).forEach(visitElement);
|
|
readArray(ui.components).forEach((component) => {
|
|
readArray(asRecord(component)?.elements).forEach(visitElement);
|
|
});
|
|
|
|
return Array.from(descriptors).sort().join("\n");
|
|
}
|
|
|
|
function collectElementFontDescriptors(
|
|
element: RawElement,
|
|
descriptors: Set<string>,
|
|
) {
|
|
const type = readString(element.type);
|
|
if (type !== "text" && type !== "text-list" && type !== "table") return;
|
|
|
|
const baseFont = rawFont(element);
|
|
addFontLoadDescriptor(baseFont, descriptors);
|
|
collectRunFontDescriptors(element.runs, baseFont, descriptors);
|
|
collectTextListFontDescriptors(element.items, baseFont, descriptors);
|
|
collectTableFontDescriptors(element.columns, baseFont, descriptors);
|
|
if (Array.isArray(element.rows)) {
|
|
element.rows.forEach((row) =>
|
|
collectTableFontDescriptors(row, baseFont, descriptors),
|
|
);
|
|
}
|
|
}
|
|
|
|
function collectRunFontDescriptors(
|
|
value: unknown,
|
|
fallback: ReturnType<typeof rawFont>,
|
|
descriptors: Set<string>,
|
|
) {
|
|
if (!Array.isArray(value)) return;
|
|
value.forEach((run) => {
|
|
const record = asRecord(run);
|
|
if (record?.font) {
|
|
addFontLoadDescriptor(
|
|
fontFromRecord(asRecord(record.font), fallback),
|
|
descriptors,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
function collectTextListFontDescriptors(
|
|
value: unknown,
|
|
fallback: ReturnType<typeof rawFont>,
|
|
descriptors: Set<string>,
|
|
) {
|
|
if (!Array.isArray(value)) return;
|
|
value.forEach((item) => {
|
|
if (Array.isArray(item)) {
|
|
collectRunFontDescriptors(item, fallback, descriptors);
|
|
return;
|
|
}
|
|
const record = asRecord(item);
|
|
if (!record) return;
|
|
if (record.font) {
|
|
addFontLoadDescriptor(
|
|
fontFromRecord(asRecord(record.font), fallback),
|
|
descriptors,
|
|
);
|
|
}
|
|
collectRunFontDescriptors(record.runs, fallback, descriptors);
|
|
});
|
|
}
|
|
|
|
function collectTableFontDescriptors(
|
|
value: unknown,
|
|
fallback: ReturnType<typeof rawFont>,
|
|
descriptors: Set<string>,
|
|
) {
|
|
if (!Array.isArray(value)) return;
|
|
value.forEach((cell) => {
|
|
const record = asRecord(cell);
|
|
if (!record) return;
|
|
if (record.font) {
|
|
addFontLoadDescriptor(
|
|
fontFromRecord(asRecord(record.font), fallback),
|
|
descriptors,
|
|
);
|
|
}
|
|
collectRunFontDescriptors(record.runs, fallback, descriptors);
|
|
|
|
const textRecord = asRecord(record.text);
|
|
if (!textRecord) return;
|
|
if (textRecord.font) {
|
|
addFontLoadDescriptor(
|
|
fontFromRecord(asRecord(textRecord.font), fallback),
|
|
descriptors,
|
|
);
|
|
}
|
|
collectRunFontDescriptors(textRecord.runs, fallback, descriptors);
|
|
});
|
|
}
|
|
|
|
function addFontLoadDescriptor(
|
|
font: ReturnType<typeof rawFont>,
|
|
descriptors: Set<string>,
|
|
) {
|
|
const family = font.family.trim();
|
|
if (!family) return;
|
|
const escapedFamily = family.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
const style = font.italic ? "italic " : "";
|
|
const weight = font.bold ? "700 " : "400 ";
|
|
descriptors.add(`${style}${weight}16px "${escapedFamily}"`);
|
|
}
|