1import { useEffect, useState } from "react";
2
3import { useTheme } from "~/hooks/useTheme";
4
5import { FileSurfaceLoading } from "./fileSurfaceChrome";
6
7const MAX_FONT_BYTES = 10 * 1024 * 1024;
8
9async function readFont(src: string, signal: AbortSignal) {
10 const response = await fetch(src, { signal });
11 if (!response.ok) throw new Error("The font could not be loaded.");
12 if (Number(response.headers.get("content-length")) > MAX_FONT_BYTES)
13 throw new Error("Font previews are limited to 10 MB.");
14 const reader = response.body?.getReader();
15 if (!reader) throw new Error("The font is unavailable.");
16 const chunks: Uint8Array<ArrayBuffer>[] = [];
17 let length = 0;
18 try {
19 while (true) {
20 const { done, value } = await reader.read();
21 if (done) break;
22 length += value.length;
23 if (length > MAX_FONT_BYTES) throw new Error("Font previews are limited to 10 MB.");
24 chunks.push(value);
25 }
26 } finally {
27 await reader.cancel();
28 }
29 const blob = new Blob(chunks);
30 await new FontFace("file-preview", await blob.arrayBuffer()).load();
31 return await new Promise<string>((resolve, reject) => {
32 const reader = new FileReader();
33 reader.addEventListener("load", () => resolve(String(reader.result)), { once: true });
34 reader.addEventListener("error", () => reject(new Error("The font could not be decoded.")), {
35 once: true,
36 });
37 reader.readAsDataURL(blob);
38 });
39}
40
41export default function FontViewer(props: { readonly src: string; readonly name: string }) {
42 const { resolvedTheme } = useTheme();
43 const [data, setData] = useState<string | null>(null);
44 const [error, setError] = useState<string | null>(null);
45 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Load the font from its URL and abort the request and timeout when the source changes or unmounts.
46 useEffect(() => {
47 const controller = new AbortController();
48 const timeout = setTimeout(() => {
49 setError("The font took too long to load.");
50 controller.abort();
51 }, 30_000);
52 void readFont(props.src, controller.signal)
53 .then((value) => {
54 if (!controller.signal.aborted) setData(value);
55 })
56 .catch((cause: unknown) => {
57 if (!controller.signal.aborted)
58 setError(cause instanceof Error ? cause.message : "The font is damaged or unsupported.");
59 })
60 .finally(() => clearTimeout(timeout));
61 return () => {
62 controller.abort();
63 clearTimeout(timeout);
64 };
65 }, [props.src]);
66 if (error)
67 return (
68 <div
69 role="alert"
70 className="flex min-h-0 flex-1 flex-col items-center justify-center gap-1 px-6 text-center"
71 >
72 <p className="text-sm font-medium">No preview for this file</p>
73 <p className="max-w-sm text-xs leading-relaxed text-muted-foreground">
74 {error} Save it to open in another app.
75 </p>
76 </div>
77 );
78 if (!data) return <FileSurfaceLoading />;
79 const tokens = getComputedStyle(document.documentElement);
80 const background = tokens.getPropertyValue("--background");
81 const foreground = tokens.getPropertyValue("--foreground");
82 const srcDoc = `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; font-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'"><style>@font-face{font-family:preview;src:url('${data}')}html{color-scheme:${resolvedTheme};background:${background};color:${foreground}}body{margin:0;padding:32px;font-family:preview;overflow-wrap:anywhere}h1{font-size:40px;font-weight:normal}p{line-height:1.4}small{font:12px monospace;display:block;opacity:.6;margin-bottom:8px}</style></head><body><h1>Font preview</h1><p style="font-size:24px">ABCDEFGHIJKLMNOPQRSTUVWXYZ<br>abcdefghijklmnopqrstuvwxyz<br>0123456789 !?&@#%(){}[]</p>${[12, 16, 24, 40, 64].map((size) => `<p style="font-size:${size}px"><small>${size} px</small>The quick brown fox jumps over the lazy dog.</p>`).join("")}</body></html>`;
83 return (
84 <iframe title={props.name} srcDoc={srcDoc} sandbox="" className="min-h-0 flex-1 border-0" />
85 );
86}