Skip to content
Source
Sign inSign up

OfficeViewer.tsx

309 lines11.8 KB

1/* oxlint-disable react/no-array-index-key -- Spreadsheet rows and columns retain their source positions. */
2import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
3import type { OfficePreviewKind } from "@t3tools/shared/filePreview";
4import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
5import { lazy, Suspense, useEffect, useRef, useState } from "react";
6​
7import VirtualTablePreview from "./VirtualTablePreview";
8​
9import {
10 Select,
11 SelectItem,
12 SelectPopup,
13 SelectTrigger,
14 SelectValue,
15} from "~/components/ui/select";
16​
17import { FileSurfaceAction, FileSurfaceLoading, FileSurfaceNotice } from "./fileSurfaceChrome";
18import { OFFICE_PREVIEW_MAX_BYTES, type OfficeWorkerResult } from "./officePreviewProtocol";
19​
20const DocumentPages = lazy(() => import("./DocumentPages"));
21const NotebookPreview = lazy(() => import("./NotebookPreview"));
22​
23type Workbook = Extract<OfficeWorkerResult, { type: "ods" }>;
24​
25const OpenXmlOfficeViewer = lazy(() => import("./OpenXmlOfficeViewer"));
26​
27async function readOfficeFile(src: string, signal: AbortSignal) {
28 const response = await fetch(src, { signal });
29 if (!response.ok) throw new Error("The file could not be loaded. Close it and try again.");
30 if (Number(response.headers.get("content-length")) > OFFICE_PREVIEW_MAX_BYTES)
31 throw new Error("Preview limited to files under 50 MB.");
32 const reader = response.body?.getReader();
33 if (!reader) throw new Error("The file is unavailable.");
34 const chunks: Uint8Array<ArrayBuffer>[] = [];
35 let size = 0;
36 try {
37 while (true) {
38 const { value, done } = await reader.read();
39 if (done) break;
40 size += value.length;
41 if (size > OFFICE_PREVIEW_MAX_BYTES) throw new Error("Preview limited to files under 50 MB.");
42 chunks.push(value);
43 }
44 } finally {
45 await reader.cancel();
46 }
47 return new Blob(chunks).arrayBuffer();
49​
50export default function OfficeViewer(props: {
51 readonly src: string;
52 readonly name: string;
53 readonly kind: OfficePreviewKind;
54 readonly sizeBytes?: number;
55}) {
56 if (props.kind === "docx" || props.kind === "xlsx" || props.kind === "pptx")
57 return (
58 <Suspense fallback={<FileSurfaceLoading />}>
59 <OpenXmlOfficeViewer {...props} kind={props.kind} />
60 </Suspense>
61 );
62 return <ExtendedOfficeViewer {...props} />;
64​
65function ExtendedOfficeViewer(props: {
66 readonly src: string;
67 readonly name: string;
68 readonly kind: OfficePreviewKind;
69 readonly sizeBytes?: number;
70}) {
71 const [error, setError] = useState<string | null>(null);
72 const [sheetPages, setSheetPages] = useState(new Map<string, string[][]>());
73 const pendingPages = useRef(new Set<string>());
74 const [previewDocument, setDocument] = useState<Extract<
75 OfficeWorkerResult,
76 { type: "document" }
77 > | null>(null);
78 const [notebook, setNotebook] = useState<Extract<
79 OfficeWorkerResult,
80 { type: "notebook" }
81 > | null>(null);
82 const [workbook, setWorkbook] = useState<Workbook | null>(null);
83 const [count, setCount] = useState(0);
84 const [fileSize, setFileSize] = useState(props.sizeBytes);
85 const [page, setPage] = useState(0);
86 const workerRef = useRef<Worker | null>(null);
87 const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
88 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Own the Office fetch, preparation worker, and timeout for this source and release them on cleanup.
89 useEffect(() => {
90 const controller = new AbortController();
91 if ((props.sizeBytes ?? 0) > OFFICE_PREVIEW_MAX_BYTES) {
92 // oxlint-disable-next-line react/set-state-in-effect -- The file metadata can reject the preview before loading bytes or engines.
93 setError("Preview limited to files under 50 MB.");
94 return;
95 }
96 const worker = new Worker(new URL("./officePreview.worker.ts", import.meta.url), {
97 type: "module",
98 });
99 workerRef.current = worker;
100 const fail = (message: string) => {
101 if (!controller.signal.aborted) setError(message);
102 controller.abort();
103 worker.terminate();
104 if (timerRef.current) clearTimeout(timerRef.current);
105 };
106 timerRef.current = setTimeout(
107 () => fail("This document took too long to preview. Open it in another app."),
108 30_000,
109 );
110 worker.addEventListener("error", () =>
111 fail("Could not load the Office viewer. Close the file and try again."),
112 );
113 worker.addEventListener("message", (event: MessageEvent<OfficeWorkerResult>) => {
114 if (controller.signal.aborted) return;
115 const result = event.data;
116 if (result.type === "error") fail(result.message);
117 else if (result.type === "document") {
118 if (timerRef.current) clearTimeout(timerRef.current);
119 setDocument(result);
120 setCount(result.pages.length > 1 ? result.pages.length : 0);
121 } else if (result.type === "notebook") {
122 if (timerRef.current) clearTimeout(timerRef.current);
123 setNotebook(result);
124 } else if (result.type === "ods") {
125 if (timerRef.current) clearTimeout(timerRef.current);
126 setWorkbook(result);
127 setCount(result.sheets.length);
128 } else if (result.type === "sheet-page") {
129 const key = `${result.sheet}:${result.index}`;
130 pendingPages.current.delete(key);
131 setSheetPages((old) => {
132 const next = new Map(old);
133 next.set(key, result.rows);
134 if (next.size > 12) next.delete(next.keys().next().value!);
135 return next;
136 });
137 }
138 });
139 void readOfficeFile(props.src, controller.signal)
140 .then((bytes) => {
141 if (!controller.signal.aborted) {
142 setFileSize(bytes.byteLength);
143 worker.postMessage({ type: "open", kind: props.kind, bytes }, [bytes]);
144 }
145 })
146 .catch((cause: unknown) =>
147 fail(cause instanceof Error ? cause.message : "Could not read this file."),
148 );
149 return () => {
150 controller.abort();
151 worker.terminate();
152 workerRef.current = null;
153 if (timerRef.current) clearTimeout(timerRef.current);
154 };
155 }, [props.src, props.kind, props.sizeBytes]);
156 const selectPage = setPage;
157 if (error)
158 return (
159 <div
160 role="alert"
161 className="flex min-h-0 flex-1 flex-col items-center justify-center gap-1 px-6 text-center"
162 >
163 <p className="text-sm font-medium">No preview for this file</p>
164 <p className="max-w-sm text-xs leading-relaxed text-muted-foreground">
165 {error} Save it or open it in another app.
166 </p>
167 </div>
168 );
169 if (notebook)
170 return (
171 <Suspense fallback={<FileSurfaceLoading />}>
172 <NotebookPreview notebook={notebook} sizeBytes={fileSize} />
173 </Suspense>
174 );
175 const sheet = workbook?.sheets[page];
176 const documentPage = previewDocument?.pages[page];
177 const navigation = workbook ? "sheet" : (previewDocument?.navigation ?? "slide");
178 const documentCount = previewDocument?.pages.length ?? 0;
179 const documentDetails =
180 navigation === "chapter"
181 ? `Chapter ${page + 1} of ${documentCount}`
182 : `${documentCount} ${navigation}${documentCount === 1 ? "" : "s"}`;
183 return (
184 <div className="flex min-h-0 flex-1 flex-col">
185 {count > 0 ? (
186 <div className="flex h-10 shrink-0 items-center gap-2 border-b border-border/60 px-3 text-xs">
187 <FileSurfaceAction
188 label={`Previous ${navigation}`}
189 disabled={page === 0}
190 onPress={() => selectPage(page - 1)}
191 >
192 <ChevronLeftIcon className="size-3.5" />
193 </FileSurfaceAction>
194 {workbook ? (
195 <div className="min-w-0 flex-1">
196 <Select
197 value={page}
198 onValueChange={(value) => {
199 if (value !== null) selectPage(value);
200 }}
201 >
202 <SelectTrigger aria-label="Sheet" size="xs">
203 <SelectValue>{sheet?.name}</SelectValue>
204 </SelectTrigger>
205 <SelectPopup>
206 {workbook.sheets.map((item, index) => (
207 <SelectItem key={index} value={index}>
208 {item.name}
209 </SelectItem>
210 ))}
211 </SelectPopup>
212 </Select>
213 </div>
214 ) : previewDocument?.navigation === "chapter" ? (
215 <div className="min-w-0 flex-1">
216 <Select
217 value={page}
218 onValueChange={(value) => {
219 if (value !== null) selectPage(value);
220 }}
221 >
222 <SelectTrigger aria-label="Chapter" size="xs">
223 <SelectValue>{documentPage?.title}</SelectValue>
224 </SelectTrigger>
225 <SelectPopup>
226 {previewDocument.pages.map((item, index) => (
227 <SelectItem key={index} value={index}>
228 {item.title}
229 </SelectItem>
230 ))}
231 </SelectPopup>
232 </Select>
233 </div>
234 ) : (
235 <span className="flex-1 text-center tabular-nums" aria-live="polite">
236 {navigation === "slide" ? "Slide" : "Page"} {page + 1} of {count}
237 </span>
238 )}
239 <FileSurfaceAction
240 label={`Next ${navigation}`}
241 disabled={page + 1 === count}
242 onPress={() => selectPage(page + 1)}
243 >
244 <ChevronRightIcon className="size-3.5" />
245 </FileSurfaceAction>
246 </div>
247 ) : null}
248 {props.kind === "ods" && sheet ? (
249 <div
250 className="flex min-h-10 shrink-0 flex-wrap items-center gap-x-1 border-b border-border/60 px-3 py-2 text-xs text-muted-foreground tabular-nums"
251 role="status"
252 >
253 Sheet {page + 1} of {count} · {sheet.rowCount.toLocaleString()} row
254 {sheet.rowCount === 1 ? "" : "s"} · {sheet.columns.toLocaleString()} column
255 {sheet.columns === 1 ? "" : "s"}
256 {fileSize !== undefined
257 ? ` · ${fileSize === 0 ? "0 bytes" : formatAttachmentSize(fileSize)}`
258 : ""}
259 </div>
260 ) : null}
261 {documentPage ? (
262 <>
263 {props.kind !== "epub" ? (
264 <FileSurfaceNotice>
265 Text and images. Layout and embedded objects may differ from the original.
266 </FileSurfaceNotice>
267 ) : null}
268 <Suspense fallback={<FileSurfaceLoading />}>
269 <DocumentPages
270 html={documentPage.html}
271 name={`${props.name}: ${documentPage.title}`}
272 details={`${documentDetails}${fileSize !== undefined ? ` · ${fileSize === 0 ? "0 bytes" : formatAttachmentSize(fileSize)}` : ""}`}
273 />
274 </Suspense>
275 </>
276 ) : sheet ? (
277 <>
278 <FileSurfaceNotice>
279 Cell values only. Charts, images, and formatting are not shown.
280 {sheet.truncated
281 ? props.kind === "ods"
282 ? " Limited to 500 rows and 50 columns."
283 : " Preview limited to 250,000 rows, 1,024 columns, and bounded cell text."
284 : ""}
285 </FileSurfaceNotice>
286 {sheet.rowCount === 0 ? (
287 <p className="p-6 text-xs text-muted-foreground">This sheet is empty.</p>
288 ) : (
289 <VirtualTablePreview
290 key={page}
291 name={`${props.name}: ${sheet.name}`}
292 count={sheet.rowCount}
293 columns={sheet.columns}
294 getRows={(index) => (index === 0 ? sheet.rows : sheetPages.get(`${page}:${index}`))}
295 loadPage={(index) => {
296 const key = `${page}:${index}`;
297 if (pendingPages.current.has(key)) return;
298 pendingPages.current.add(key);
299 workerRef.current?.postMessage({ type: "sheet-page", sheet: page, index });
300 }}
301 />
302 )}
303 </>
304 ) : (
305 <FileSurfaceLoading />
306 )}
307 </div>
308 );