summaryrefslogtreecommitdiff
path: root/apps/web/src/fonts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/fonts
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/fonts')
-rw-r--r--apps/web/src/fonts/google-fonts.ts88
-rw-r--r--apps/web/src/fonts/system-fonts.ts11
-rw-r--r--apps/web/src/fonts/types.ts24
-rw-r--r--apps/web/src/fonts/use-font-atlas.ts53
4 files changed, 176 insertions, 0 deletions
diff --git a/apps/web/src/fonts/google-fonts.ts b/apps/web/src/fonts/google-fonts.ts
new file mode 100644
index 00000000..36fca068
--- /dev/null
+++ b/apps/web/src/fonts/google-fonts.ts
@@ -0,0 +1,88 @@
+import type { FontAtlas } from "@/fonts/types";
+import { SYSTEM_FONTS } from "@/fonts/system-fonts";
+
+const GOOGLE_FONTS_CSS = "https://fonts.googleapis.com/css2";
+const FONT_ATLAS_PATH = "/fonts/font-atlas.json";
+const FONT_CHUNK_PATH_PREFIX = "/fonts/font-chunk-";
+
+const fullLoaded = new Set<string>();
+
+let cachedAtlas: FontAtlas | null = null;
+let atlasFetchPromise: Promise<FontAtlas | null> | null = null;
+
+function encodeGoogleFontsFamily(family: string): string {
+ return family.replace(/ /g, "+");
+}
+
+export function getCachedFontAtlas(): FontAtlas | null {
+ return cachedAtlas;
+}
+
+export function clearFontAtlasCache(): void {
+ cachedAtlas = null;
+ atlasFetchPromise = null;
+ fullLoaded.clear();
+}
+
+export function loadFontAtlas(): Promise<FontAtlas | null> {
+ if (cachedAtlas) return Promise.resolve(cachedAtlas);
+ if (atlasFetchPromise) return atlasFetchPromise;
+
+ atlasFetchPromise = fetch(FONT_ATLAS_PATH)
+ .then(async (response) => {
+ if (!response.ok) return null;
+ const data: FontAtlas = await response.json();
+ cachedAtlas = data;
+ preloadChunkImages({ atlas: data });
+ return data;
+ })
+ .catch(() => null);
+
+ return atlasFetchPromise;
+}
+
+function preloadChunkImages({ atlas }: { atlas: FontAtlas }): void {
+ const maxChunk = Math.max(
+ ...Object.values(atlas.fonts).map((entry) => entry.ch),
+ );
+ for (let i = 0; i <= maxChunk; i++) {
+ // hint browser to preload chunk images without blocking
+ const img = new Image();
+ img.src = `${FONT_CHUNK_PATH_PREFIX}${i}.avif`;
+ }
+}
+
+export async function loadFullFont({
+ family,
+ weights = [400, 700],
+}: {
+ family: string;
+ weights?: number[];
+}): Promise<void> {
+ if (fullLoaded.has(family)) return;
+
+ const url = `${GOOGLE_FONTS_CSS}?family=${encodeGoogleFontsFamily(family)}:wght@${weights.join(";")}&display=swap`;
+ const link = document.createElement("link");
+ link.rel = "stylesheet";
+ link.href = url;
+ document.head.appendChild(link);
+ await new Promise<void>((resolve) => {
+ link.addEventListener("load", () => resolve(), { once: true });
+ link.addEventListener("error", () => resolve(), { once: true });
+ });
+ await Promise.all(
+ weights.map((weight) =>
+ document.fonts.load(`${weight} 16px "${family.replace(/"/g, '\\"')}"`),
+ ),
+ );
+ fullLoaded.add(family);
+}
+
+export async function loadFonts({
+ families,
+}: {
+ families: string[];
+}): Promise<void> {
+ const googleFonts = families.filter((family) => !SYSTEM_FONTS.has(family));
+ await Promise.all(googleFonts.map((family) => loadFullFont({ family })));
+}
diff --git a/apps/web/src/fonts/system-fonts.ts b/apps/web/src/fonts/system-fonts.ts
new file mode 100644
index 00000000..9cc363a3
--- /dev/null
+++ b/apps/web/src/fonts/system-fonts.ts
@@ -0,0 +1,11 @@
+export const SYSTEM_FONTS = new Set([
+ "Arial",
+ "Helvetica",
+ "Times New Roman",
+ "Courier New",
+ "Verdana",
+ "Georgia",
+ "monospace",
+ "sans-serif",
+ "serif",
+]);
diff --git a/apps/web/src/fonts/types.ts b/apps/web/src/fonts/types.ts
new file mode 100644
index 00000000..89ebce08
--- /dev/null
+++ b/apps/web/src/fonts/types.ts
@@ -0,0 +1,24 @@
+export interface FontOption {
+ value: string;
+ label: string;
+ category: "system" | "google" | "custom";
+ weights?: number[];
+ hasClassName?: boolean;
+}
+
+export interface GoogleFontMeta {
+ family: string;
+ category: string;
+}
+
+export interface FontAtlasEntry {
+ x: number;
+ y: number;
+ w: number;
+ ch: number;
+ s: string[];
+}
+
+export interface FontAtlas {
+ fonts: Record<string, FontAtlasEntry>;
+}
diff --git a/apps/web/src/fonts/use-font-atlas.ts b/apps/web/src/fonts/use-font-atlas.ts
new file mode 100644
index 00000000..153eebd4
--- /dev/null
+++ b/apps/web/src/fonts/use-font-atlas.ts
@@ -0,0 +1,53 @@
+import { useState, useMemo, useCallback, useEffect } from "react";
+import {
+ getCachedFontAtlas,
+ loadFontAtlas,
+ clearFontAtlasCache,
+} from "@/fonts/google-fonts";
+import type { FontAtlas } from "@/fonts/types";
+import { SYSTEM_FONTS } from "@/fonts/system-fonts";
+
+type Status = "idle" | "loading" | "error";
+
+export function useFontAtlas({ open }: { open: boolean }) {
+ const [atlas, setAtlas] = useState<FontAtlas | null>(() =>
+ getCachedFontAtlas(),
+ );
+ const [status, setStatus] = useState<Status>(() =>
+ getCachedFontAtlas() ? "idle" : "loading",
+ );
+
+ useEffect(() => {
+ if (!open || atlas) return;
+
+ setStatus("loading");
+ loadFontAtlas().then((data) => {
+ if (data) {
+ setAtlas(data);
+ setStatus("idle");
+ } else {
+ setStatus("error");
+ }
+ });
+ }, [open, atlas]);
+
+ const retry = useCallback(() => {
+ clearFontAtlasCache();
+ setStatus("loading");
+ loadFontAtlas().then((data) => {
+ if (data) {
+ setAtlas(data);
+ setStatus("idle");
+ } else {
+ setStatus("error");
+ }
+ });
+ }, []);
+
+ const fontNames = useMemo(() => {
+ if (!atlas) return [];
+ return [...Object.keys(atlas.fonts), ...SYSTEM_FONTS].sort();
+ }, [atlas]);
+
+ return { atlas, status, fontNames, retry };
+}