Open History to see the last commit
1import DOMPurify from "dompurify";
2import { MinusIcon, PlusIcon, RotateCcwIcon } from "lucide-react";
3import { FileSurfaceAction } from "./fileSurfaceChrome";
4import { useEffect, useRef, useState } from "react";
5import { useTheme } from "~/hooks/useTheme";
6import { observeResize } from "~/lib/observeResize";
7
8export function sanitizeDocumentHtml(source: string): string {
9 const fragment = DOMPurify.sanitize(source, {
10 RETURN_DOM_FRAGMENT: true,
11 ALLOWED_TAGS: [
12 "p",
13 "h1",
14 "h2",
15 "h3",
16 "h4",
17 "h5",
18 "h6",
19 "div",
20 "span",
21 "strong",
22 "b",
23 "em",
24 "i",
25 "u",
26 "s",
27 "ul",
28 "ol",
29 "li",
30 "blockquote",
31 "pre",
32 "code",
33 "table",
34 "thead",
35 "tbody",
36 "tr",
37 "th",
38 "td",
39 "caption",
40 "figure",
41 "figcaption",
42 "br",
43 "hr",
44 "sub",
45 "sup",
46 "dl",
47 "dt",
48 "dd",
49 "img",
50 ],
51 ALLOWED_ATTR: ["src", "alt", "colspan", "rowspan", "data-odf-whitespace"],
52 });
53 for (const image of fragment.querySelectorAll("img")) {
54 if (
55 !/^data:image\/(png|jpeg|gif|webp);base64,[a-z\d+/]*={0,2}$/i.test(
56 image.getAttribute("src") ?? "",
57 )
58 )
59 image.remove();
60 }
61 const container = document.createElement("div");
62 container.append(fragment);
63 return container.innerHTML;
64}
65
66function documentColors() {
67 const style = getComputedStyle(document.documentElement);
68 return ["background", "foreground", "card", "border"].map((token) =>
69 style.getPropertyValue(`--${token}`),
70 );
71}
72
73export default function DocumentPages(props: {
74 readonly html: string;
75 readonly name: string;
76 readonly output?: boolean;
77 readonly details?: string;
78}) {
79 const { theme, resolvedTheme, themeHalves } = useTheme();
80 const [colors, setColors] = useState(documentColors);
81 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Read the host DOM's applied theme tokens so the sandboxed document receives the current palette.
82 useEffect(() => {
83 // The iframe cannot inherit the host palette across its sandbox boundary.
84 // oxlint-disable-next-line react/set-state-in-effect -- Read tokens after useTheme applies the selected palette.
85 setColors(documentColors());
86 // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Theme values invalidate the palette read from the host DOM.
87 }, [theme, resolvedTheme, themeHalves]);
88 const [zoom, setZoom] = useState(1);
89 const [outputHeight, setOutputHeight] = useState(0);
90 const stopObserving = useRef<(() => void) | null>(null);
91 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Disconnect the iframe body's resize observer when the document unmounts.
92 useEffect(() => () => stopObserving.current?.(), []);
93 const html = sanitizeDocumentHtml(props.html);
94 const srcDoc = `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'"><style>:root{color-scheme:${resolvedTheme}}html{background:${colors[0]};color:${colors[1]};font:${15 * zoom}px/1.65 system-ui,sans-serif}body{box-sizing:border-box;margin:${props.output ? "0" : "20px auto"};padding:${props.output ? "16px" : "32px"};width:${props.output ? "100%" : "calc(100% - 40px)"};max-width:${860 * zoom}px;min-height:${props.output ? "0" : "calc(100vh - 40px)"};display:flow-root;background:${colors[2]};overflow-wrap:anywhere}h1,h2,h3{line-height:1.25}img{max-width:100%;height:auto}table{border-collapse:collapse;min-width:100%;max-width:100%}td,th{border:1px solid ${colors[3]};padding:6px 12px;text-align:left}th{background:${colors[0]}}tr:nth-child(even){background:${colors[0]}}pre,span[data-odf-whitespace]{white-space:pre-wrap}*{scrollbar-width:thin}${props.output ? "" : "@media(max-width:420px){body{padding:20px;width:calc(100% - 24px);margin:12px auto}}"}</style></head><body>${html}</body></html>`;
95 return (
96 <>
97 {!props.output ? (
98 <div className="flex min-h-10 shrink-0 flex-wrap items-center gap-1 border-b border-border/60 px-3 py-1 text-xs text-muted-foreground">
99 <span className="min-w-0 flex-1 text-muted-foreground tabular-nums" role="status">
100 {props.details}
101 </span>
102 <FileSurfaceAction
103 label="Decrease reading size"
104 disabled={zoom <= 0.5}
105 onPress={() => setZoom((value) => Math.max(0.5, value - 0.25))}
106 >
107 <MinusIcon className="size-3.5" />
108 </FileSurfaceAction>
109 <span className="min-w-10 text-center tabular-nums">{Math.round(zoom * 100)}%</span>
110 <FileSurfaceAction
111 label="Increase reading size"
112 disabled={zoom >= 2}
113 onPress={() => setZoom((value) => Math.min(2, value + 0.25))}
114 >
115 <PlusIcon className="size-3.5" />
116 </FileSurfaceAction>
117 <FileSurfaceAction
118 label="Reset reading size"
119 disabled={zoom === 1}
120 onPress={() => setZoom(1)}
121 >
122 <RotateCcwIcon className="size-3.5" />
123 </FileSurfaceAction>
124 </div>
125 ) : null}
126 <iframe
127 title={props.name}
128 srcDoc={srcDoc}
129 // Same-origin access lets the parent measure saved output; scripts remain blocked.
130 sandbox={props.output ? "allow-same-origin" : ""}
131 onLoad={(event) => {
132 if (!props.output) return;
133 stopObserving.current?.();
134 const body = event.currentTarget.contentDocument?.body;
135 if (!body) return;
136 const measure = () => setOutputHeight(Math.ceil(body.getBoundingClientRect().height));
137 stopObserving.current = observeResize(body, measure);
138 measure();
139 }}
140 style={props.output ? { height: outputHeight } : undefined}
141 className={props.output ? "block w-full border-0" : "min-h-0 flex-1 border-0"}
142 />
143 </>
144 );
145}