Open History to see the last commit
1/* oxlint-disable react/no-array-index-key -- Notebook cells and outputs retain their saved source order. */
2import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
3import { useEffect, useRef, useState } from "react";
4import type { Root, RootContent } from "mdast";
5import ChatMarkdown from "~/components/ChatMarkdown";
6import { Button } from "~/components/ui/button";
7import ReadOnlySourcePreview from "./ReadOnlySourcePreview";
8import DocumentPages from "./DocumentPages";
9import { FileSurfaceNotice } from "./fileSurfaceChrome";
10import type { OfficeWorkerResult } from "./officePreviewProtocol";
11
12// Saved notebooks must not load URLs or turn authored links into app actions.
13function inertNotebookMarkdown() {
14 function clean(nodes: RootContent[]): RootContent[] {
15 return nodes.flatMap((node): RootContent[] => {
16 if (node.type === "html" || node.type === "definition") return [];
17 if (node.type === "image" || node.type === "imageReference")
18 return [{ type: "text", value: node.alt ?? "" }];
19 if (node.type === "link" || node.type === "linkReference") return clean(node.children);
20 if (node.type === "inlineCode") return [{ type: "text", value: node.value }];
21 if (node.type === "code" && node.lang === "mermaid") return [{ ...node, lang: "text" }];
22 if ("children" in node) node.children = clean(node.children) as typeof node.children;
23 return [node];
24 });
25 }
26 return (tree: Root) => {
27 tree.children = clean(tree.children) as Root["children"];
28 };
29}
30const NOTEBOOK_PLUGINS = [inertNotebookMarkdown];
31
32function NotebookSource(props: { readonly name: string; readonly text: string }) {
33 const [height, setHeight] = useState(Math.min(360, props.text.split("\n").length * 20 + 16));
34 const measure = (node: HTMLElement) => {
35 setHeight(Math.min(360, Math.ceil(node.getBoundingClientRect().height)));
36 };
37 return (
38 <div className="flex min-h-0 flex-col" style={{ height }}>
39 <ReadOnlySourcePreview {...props} onPostRender={measure} />
40 </div>
41 );
42}
43
44export default function NotebookPreview(props: {
45 readonly notebook: Extract<OfficeWorkerResult, { type: "notebook" }>;
46 readonly sizeBytes?: number | undefined;
47}) {
48 const [visible, setVisible] = useState(20);
49 const scroll = useRef<HTMLDivElement>(null);
50 const [outputView, setOutputView] = useState(() => ({
51 mounted: new Set<string>(),
52 heights: new Map<string, number>(),
53 }));
54 const outputCount = props.notebook.cells
55 .slice(0, visible)
56 .reduce(
57 (count, cell) => count + cell.outputs.filter((output) => output.kind === "html").length,
58 0,
59 );
60 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Observe output visibility and measure departing outputs to bound iframe lifetimes while preserving scroll height.
61 useEffect(() => {
62 const root = scroll.current;
63 if (!root || !outputCount) return;
64 const candidates = new Map<Element, string>();
65 let active: Element[] = [];
66 const observer = new IntersectionObserver(
67 (entries) => {
68 for (const entry of entries) {
69 if (entry.isIntersecting)
70 candidates.set(entry.target, (entry.target as HTMLElement).dataset.notebookOutput!);
71 else candidates.delete(entry.target);
72 }
73 // Bound browsing contexts even when a cell contains hundreds of tiny HTML outputs.
74 const next = [...candidates.keys()].slice(0, 16);
75 const measured = active
76 .filter((node) => !next.includes(node))
77 .map(
78 (node) =>
79 [
80 (node as HTMLElement).dataset.notebookOutput!,
81 node.getBoundingClientRect().height,
82 ] as const,
83 );
84 active = next;
85 setOutputView((old) => ({
86 mounted: new Set(next.map((node) => candidates.get(node)!)),
87 heights: new Map([...old.heights, ...measured]),
88 }));
89 },
90 { root, rootMargin: "160px" },
91 );
92 for (const node of root.querySelectorAll("[data-notebook-output]")) observer.observe(node);
93 return () => observer.disconnect();
94 }, [outputCount]);
95 return (
96 <>
97 <div
98 className="border-b border-border/60 px-3 py-2 text-xs text-muted-foreground tabular-nums"
99 role="status"
100 >
101 {props.notebook.cells.length.toLocaleString()} cells · {props.notebook.language}
102 {props.sizeBytes !== undefined
103 ? ` · ${props.sizeBytes === 0 ? "0 bytes" : formatAttachmentSize(props.sizeBytes)}`
104 : ""}
105 </div>
106 <FileSurfaceNotice>Saved outputs only. Code is never executed.</FileSurfaceNotice>
107 <div ref={scroll} className="scrollbar-gutter-both min-h-0 flex-1 overflow-auto">
108 <div className="mx-auto max-w-4xl space-y-5 px-5 py-6">
109 {props.notebook.cells.slice(0, visible).map((cell, index) => (
110 <section
111 key={index}
112 aria-label={`Cell ${index + 1}`}
113 className="min-w-0 overflow-hidden rounded-md border border-border/60"
114 >
115 <div className="border-b border-border/60 bg-muted/30 px-3 py-1.5 text-xs text-muted-foreground">
116 {cell.kind === "code" ? props.notebook.language : cell.kind} · {index + 1}
117 </div>
118 {cell.kind === "markdown" ? (
119 <ChatMarkdown
120 text={cell.source}
121 cwd={undefined}
122 parseRawHtml={false}
123 extraRemarkPlugins={NOTEBOOK_PLUGINS}
124 className="px-4 py-3"
125 />
126 ) : cell.source.trim() ? (
127 <NotebookSource
128 name={`cell.${cell.kind === "code" ? (({ python: "py", javascript: "js", typescript: "ts", julia: "jl" } as Record<string, string>)[props.notebook.language] ?? props.notebook.language) : "txt"}`}
129 text={cell.source}
130 />
131 ) : null}
132 {cell.outputs.map((output, outputIndex) => (
133 <div
134 key={outputIndex}
135 data-notebook-output={
136 output.kind === "html" ? `${index}-${outputIndex}` : undefined
137 }
138 className="border-t border-border/60"
139 style={{
140 minHeight:
141 output.kind !== "html" || outputView.mounted.has(`${index}-${outputIndex}`)
142 ? undefined
143 : (outputView.heights.get(`${index}-${outputIndex}`) ?? 80),
144 }}
145 >
146 {output.kind === "html" ? (
147 !outputView.mounted.has(`${index}-${outputIndex}`) ? null : (
148 <DocumentPages
149 html={output.text}
150 name={`Cell ${index + 1} HTML output`}
151 output
152 />
153 )
154 ) : output.kind === "image" ? (
155 <img
156 src={output.text}
157 alt={`Cell ${index + 1} output`}
158 loading="lazy"
159 className="max-h-96 max-w-full p-4 object-contain"
160 />
161 ) : (
162 <pre className="overflow-x-auto p-4 text-xs whitespace-pre-wrap break-words">
163 {output.text}
164 </pre>
165 )}
166 </div>
167 ))}
168 </section>
169 ))}
170 {props.notebook.cells.length === 0 ? (
171 <p className="text-sm text-muted-foreground">This notebook is empty.</p>
172 ) : null}
173 {visible < props.notebook.cells.length ? (
174 <Button variant="outline" size="sm" onClick={() => setVisible((count) => count + 20)}>
175 Show more cells ({props.notebook.cells.length - visible} remaining)
176 </Button>
177 ) : null}
178 </div>
179 </div>
180 </>
181 );
182}