Skip to content
Source
Sign inSign up
Open History to see the last commit

BrowserDocumentFrame.tsx

344 lines12.6 KB

1import { useAtomValue } from "@effect/atom-react";
2import {
3 type KeybindingCommand,
4 type KeybindingShortcut,
5 MODEL_PICKER_JUMP_KEYBINDING_COMMANDS,
6 type ScopedThreadRef,
7 THREAD_JUMP_KEYBINDING_COMMANDS,
8} from "@t3tools/contracts";
9import {
10 type HtmlRenderShortcut,
11 htmlRenderShortcutsMessage,
12 htmlRenderThemeFragment,
13 htmlRenderThemeMessage,
14 htmlRenderResult,
15 readHtmlRenderContentHeight,
16 readHtmlRenderImageRequest,
17 readHtmlRenderKeyDown,
18 readHtmlRenderLinkRequest,
19} from "@t3tools/shared/htmlRender";
20import {
21 isMacPlatform,
22 matchesKeybindingShortcut,
23 normalizeEventKey,
24} from "@t3tools/shared/keybindings";
25import {
26 lazy,
27 Suspense,
28 useEffect,
29 useEffectEvent,
30 useLayoutEffect,
31 useRef,
32 useState,
33} from "react";
34import { isElectron } from "~/env";
35import { FileSurfaceLoading } from "./fileSurfaceChrome";
36import type { PdfViewState } from "./PdfViewer";
37​
38import { useOpenLink } from "~/browser/useOpenLink";
39import { ExpandedImageDialog } from "~/components/chat/ExpandedImageDialog";
40import type { ExpandedImagePreview } from "~/components/chat/ExpandedImagePreview";
41import { useHtmlRenderTheme } from "~/hooks/useHtmlRenderTheme";
42import { shortcutConflictKey } from "~/keybindings";
43import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink";
44import { readLocalApi } from "~/localApi";
45import { cn } from "~/lib/utils";
46import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server";
47​
48/**
49 * The page reports the keys, and a page script can report any key it likes,
50 * so only commands that change what the reader sees are here, never one that
51 * changes a thread.
52 */
53const HTML_RENDER_SHORTCUT_COMMANDS: ReadonlySet<KeybindingCommand> = new Set([
54 "commandPalette.toggle",
55 "filePicker.toggle",
56 "projectSearch.toggle",
57 "chat.find",
58 "sidebar.toggle",
59 "rightPanel.toggle",
60 "rightPanel.toggleMaximized",
61 "threadPanel.toggle",
62 "terminal.toggle",
63 "preview.toggle",
64 "navigation.back",
65 "navigation.forward",
66 "thread.previous",
67 "thread.next",
68 ...THREAD_JUMP_KEYBINDING_COMMANDS,
69 ...MODEL_PICKER_JUMP_KEYBINDING_COMMANDS,
70]);
71​
72export const isPdfPreviewFile = (path: string): boolean =>
73 /\.pdf$/i.test(path.split(/[?#]/, 1)[0] ?? "");
74​
75const PdfViewer = lazy(() => import("./PdfViewer"));
76​
77// The document stays mounted while each revision gets a fresh PDF.js document.
78function PdfDocument(props: { src: string; title: string; documentKey: string }) {
79 const [view, setView] = useState<PdfViewState>({ page: 0, offset: 0, zoom: null, left: 0 });
80 return (
81 <PdfDocumentRevision
82 key={props.documentKey}
83 src={props.src}
84 title={props.title}
85 positionKey={props.documentKey}
86 initialView={view}
87 onViewChange={setView}
88 />
89 );
91​
92// Keep signed URL renewals from reloading the same revision.
93function PdfDocumentRevision(props: {
94 src: string;
95 title: string;
96 positionKey: string;
97 initialView: PdfViewState;
98 onViewChange: (view: PdfViewState) => void;
99}) {
100 const [src] = useState(props.src);
101 // The Electron shell does not enable PDF plugins. WebKit embeds on iOS
102 // expose only the first page, even when pdfViewerEnabled reports support.
103 const native =
104 !isElectron &&
105 navigator.pdfViewerEnabled &&
106 !/iPad|iPhone|iPod/.test(navigator.userAgent) &&
107 !(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
108 return native ? (
109 // oxlint-disable-next-line react/iframe-missing-sandbox -- Chromium's PDF plugin requires an unsandboxed frame; src is an exact-file asset grant.
110 <iframe
111 src={`${src.split("#", 1)[0]}#navpanes=0&view=FitH`}
112 title={props.title}
113 className="block h-full min-h-0 w-full flex-1 border-0 bg-white"
114 />
115 ) : (
116 <Suspense fallback={<FileSurfaceLoading />}>
117 <PdfViewer
118 src={src}
119 title={props.title}
120 positionKey={props.positionKey}
121 initialView={props.initialView}
122 onViewChange={props.onViewChange}
123 />
124 </Suspense>
125 );
127​
128/**
129 * HTML runs in a sandboxed frame with an opaque origin, so a page cannot reach
130 * the app's session or storage. PDFs use a native viewer or the lazy PDF.js fallback.
131 */
132export function BrowserDocumentFrame(props: {
133 readonly src: string;
134 readonly title: string;
135 readonly pdf: boolean;
136 readonly htmlRender?: boolean;
137 readonly threadRef?: ScopedThreadRef | undefined;
138 /** Stable resource identity and revision; defaults to src for callers that reload on URL changes. */
139 readonly documentKey?: string | undefined;
140 /** Resource identity without its revision, so PDF reloads can retain the reading position. */
141 readonly documentId?: string | undefined;
142}) {
143 const key = props.documentKey ?? props.src;
144 return props.pdf ? (
145 <PdfDocument
146 key={props.documentId ?? key}
147 documentKey={key}
148 src={props.src}
149 title={props.title}
150 />
151 ) : props.htmlRender ? (
152 <HtmlRenderDocument
153 key={key}
154 src={props.src}
155 title={props.title}
156 threadRef={props.threadRef}
157 className="min-h-0 flex-1"
158 />
159 ) : (
160 <FileDocument key={key} src={props.src} title={props.title} />
161 );
163​
164// URL renewals must not discard form input or scroll; a new key mounts a fresh document.
165function FileDocument(props: { readonly src: string; readonly title: string }) {
166 const [src] = useState(props.src);
167 return (
168 <iframe
169 src={src}
170 title={props.title}
171 className="min-h-0 flex-1 border-0 bg-white"
172 sandbox="allow-scripts allow-forms allow-popups allow-downloads"
173 />
174 );
176​
177/** Keep the initial URL while mounted; re-signing must not reload the page. */
178export function HtmlRenderDocument(props: {
179 readonly src: string;
180 readonly title: string;
181 readonly className?: string;
182 readonly threadRef?: ScopedThreadRef | undefined;
183 readonly onContentHeight?: (height: number) => void;
184 /** Reports fullscreen changes so virtualized rows can stay mounted. */
185 readonly onFullscreenChange?: ((fullscreen: boolean) => void) | undefined;
186}) {
187 const theme = useHtmlRenderTheme();
188 const frameRef = useRef<HTMLIFrameElement>(null);
189 const [src] = useState(() => `${props.src.split("#", 1)[0]}${htmlRenderThemeFragment(theme)}`);
190 const [loaded, setLoaded] = useState(false);
191 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
192 const postTheme = () => {
193 frameRef.current?.contentWindow?.postMessage(htmlRenderThemeMessage(theme), "*");
194 };
195 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- posts the current theme to the sandboxed iframe's window
196 useEffect(postTheme, [theme]);
197 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
198 const environmentKeybindings = useAtomValue(
199 serverEnvironment.configValueAtom(props.threadRef?.environmentId ?? null),
200 )?.keybindings;
201 // Replay only Cmd/Ctrl chords exclusive to allowed commands regardless of when; live resolution could
202 // otherwise type in the composer.
203 const mac = isMacPlatform(navigator.platform);
204 const resolve = (shortcut: KeybindingShortcut): HtmlRenderShortcut => ({
205 key: shortcut.key,
206 metaKey: shortcut.metaKey || (shortcut.modKey && mac),
207 ctrlKey: shortcut.ctrlKey || (shortcut.modKey && !mac),
208 shiftKey: shortcut.shiftKey,
209 altKey: shortcut.altKey,
210 });
211 const blocked = [...keybindings, ...(environmentKeybindings ?? [])]
212 .filter((binding) => !HTML_RENDER_SHORTCUT_COMMANDS.has(binding.command))
213 .map((binding) => resolve(binding.shortcut));
214 const shortcutsByKey = new Map<string, HtmlRenderShortcut>();
215 for (const { command, shortcut } of keybindings) {
216 if (
217 HTML_RENDER_SHORTCUT_COMMANDS.has(command) &&
218 (shortcut.metaKey || shortcut.ctrlKey || shortcut.modKey)
219 ) {
220 shortcutsByKey.set(shortcutConflictKey(shortcut), resolve(shortcut));
221 }
222 }
223 const shortcuts = [...shortcutsByKey.values()];
224 const postShortcuts = () => {
225 frameRef.current?.contentWindow?.postMessage(
226 htmlRenderShortcutsMessage(shortcuts, blocked),
227 "*",
228 );
229 };
230 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- posts allowed and blocked shortcuts to the sandboxed iframe's window
231 useEffect(postShortcuts, [blocked, shortcuts]);
232 // Pages can forge key/code pairs; reject pairs matching other bindings and require named keys to match the
233 // shortcut.
234 const isShortcut = useEffectEvent(
235 (key: NonNullable<ReturnType<typeof readHtmlRenderKeyDown>>) => {
236 const matches = (shortcut: HtmlRenderShortcut) =>
237 matchesKeybindingShortcut(key, { ...shortcut, modKey: false }, navigator.platform);
238 const allowed = shortcuts.filter(matches);
239 return (
240 allowed.length > 0 &&
241 !blocked.some(matches) &&
242 (key.key.length === 1 ||
243 key.key === "Dead" ||
244 allowed.some((shortcut) => shortcut.key === normalizeEventKey(key.key)))
245 );
246 },
247 );
248 const openChangeRequestLink = useOpenChangeRequestLink(props.threadRef);
249 const openLink = useOpenLink(props.threadRef);
250 const open = useEffectEvent((url: string, modifiers: { metaKey: boolean; ctrlKey: boolean }) => {
251 if (modifiers.metaKey || modifiers.ctrlKey) {
252 void readLocalApi()
253 ?.shell.openExternal(url)
254 .catch((cause: unknown) => console.error(cause));
255 return;
256 }
257 if (
258 props.threadRef !== undefined &&
259 openChangeRequestLink(
260 { ...modifiers, preventDefault: () => undefined, stopPropagation: () => undefined },
261 url,
262 undefined,
263 props.threadRef.environmentId,
264 )
265 ) {
266 return;
267 }
268 void openLink(url, { event: modifiers }).catch((cause: unknown) => console.error(cause));
269 });
270 const { onFullscreenChange } = props;
271 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reports fullscreen to the parent while the image viewer is open, and clears it on unmount
272 useEffect(() => {
273 if (expandedImage === null) return;
274 onFullscreenChange?.(true);
275 return () => onFullscreenChange?.(false);
276 }, [expandedImage, onFullscreenChange]);
277 // Links, keys and images require frame focus and recent reader activity: a page can take
278 // focus by script, so this blocks opens on load, though not a page that waits for the
279 // reader's next input.
280 const onFrameMessage = useEffectEvent((frame: HTMLIFrameElement, event: MessageEvent) => {
281 const height = readHtmlRenderContentHeight(event.data);
282 if (height !== undefined) {
283 props.onContentHeight?.(height);
284 return;
285 }
286 if (document.activeElement !== frame || navigator.userActivation?.isActive === false) return;
287 const linkRequest = readHtmlRenderLinkRequest(event.data);
288 if (linkRequest !== undefined) {
289 open(linkRequest.url, linkRequest);
290 frame.contentWindow?.postMessage(htmlRenderResult(linkRequest.id), "*");
291 return;
292 }
293 const key = readHtmlRenderKeyDown(event.data);
294 if (key !== undefined) {
295 if (isShortcut(key)) {
296 frame.dispatchEvent(
297 new KeyboardEvent("keydown", { ...key, bubbles: true, cancelable: true }),
298 );
299 }
300 return;
301 }
302 const imageRequest = readHtmlRenderImageRequest(event.data);
303 if (imageRequest !== undefined) {
304 setExpandedImage({ images: [...imageRequest.images], index: imageRequest.index });
305 }
306 });
307 // Attach at commit because a fast page may post its initial height before passive effects run.
308 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- attaches a window message listener for the iframe at commit, before its first height post
309 useLayoutEffect(() => {
310 const frame = frameRef.current;
311 if (frame === null) return;
312 const onMessage = (event: MessageEvent) => {
313 if (event.source === frame.contentWindow) onFrameMessage(frame, event);
314 };
315 window.addEventListener("message", onMessage);
316 return () => window.removeEventListener("message", onMessage);
317 }, []);
318 return (
319 <>
320 <iframe
321 ref={frameRef}
322 src={src}
323 title={props.title}
324 // Never allow-same-origin: the opaque origin keeps the page out of the app's session.
325 sandbox="allow-scripts allow-forms"
326 // Lets a page's video or image viewer take the screen.
327 allow="fullscreen"
328 loading="lazy"
329 onLoad={() => {
330 setLoaded(true);
331 // Covers a theme change that landed while the page was loading.
332 postTheme();
333 postShortcuts();
334 }}
335 // Match the blank document's scheme to avoid dark-mode white flashes, then follow the app after load.
336 className={cn("border-0 scheme-light", props.className)}
337 style={loaded ? { colorScheme: theme.appearance } : undefined}
338 />
339 {expandedImage !== null ? (
340 <ExpandedImageDialog preview={expandedImage} onClose={() => setExpandedImage(null)} />
341 ) : null}
342 </>
343 );