Skip to content
Source
Sign inSign up

OpenXmlOfficeViewer.tsx

137 lines5.3 KB

1import { useStableCallback } from "~/hooks/useStableCallback";
2import type { OfficePreviewKind } from "@t3tools/shared/filePreview";
3import { lazy, Suspense, useEffect, useState } from "react";
4import {
5 FileSurfaceLoading,
6 FileSurfaceNotice,
7 FILE_SURFACE_SUBHEADER_CLASS,
8} from "./fileSurfaceChrome";
9import { OFFICE_PREVIEW_MAX_BYTES, type OfficeWorkerResult } from "./officePreviewProtocol";
10const WordDocument = lazy(() => import("./office/WordDocument"));
11const Spreadsheet = lazy(() => import("./office/Spreadsheet"));
12const Presentation = lazy(() => import("./office/Presentation"));
13async function readOfficeFile(src: string, signal: AbortSignal) {
14 const response = await fetch(src, { signal });
15 if (!response.ok) throw new Error("The file could not be loaded. Close it and try again.");
16 if (Number(response.headers.get("content-length")) > OFFICE_PREVIEW_MAX_BYTES)
17 throw new Error("Preview limited to files under 50 MB.");
18 const reader = response.body?.getReader();
19 if (!reader) throw new Error("The file is unavailable.");
20 const chunks: Uint8Array<ArrayBuffer>[] = [];
21 let size = 0;
22 try {
23 while (true) {
24 const { value, done } = await reader.read();
25 if (done) break;
26 size += value.length;
27 if (size > OFFICE_PREVIEW_MAX_BYTES) throw new Error("Preview limited to files under 50 MB.");
28 chunks.push(value);
29 }
30 } finally {
31 await reader.cancel();
32 }
33 return new Blob(chunks).arrayBuffer();
35​
36export default function OfficeViewer(props: {
37 readonly src: string;
38 readonly name: string;
39 readonly kind: Extract<OfficePreviewKind, "docx" | "xlsx" | "pptx">;
40 readonly sizeBytes?: number;
41}) {
42 const [error, setError] = useState<string | null>(null);
43 const [prepared, setPrepared] = useState<Extract<OfficeWorkerResult, { bytes: ArrayBuffer }>>();
44 const [details, setDetails] = useState("");
45 const fail = useStableCallback((cause: unknown) =>
46 setError(cause instanceof Error ? cause.message : "Could not render this Office document."),
47 );
48 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Own the Open XML fetch, preparation worker, and timeout for this source and release them on cleanup.
49 useEffect(() => {
50 if ((props.sizeBytes ?? 0) > OFFICE_PREVIEW_MAX_BYTES) {
51 // Metadata bounds are checked before fetching or starting an engine.
52 // oxlint-disable-next-line react/set-state-in-effect
53 setError("Preview limited to files under 50 MB.");
54 return;
55 }
56 const controller = new AbortController();
57 const worker = new Worker(new URL("./officePreview.worker.ts", import.meta.url), {
58 type: "module",
59 });
60 const failure = (cause: unknown) => {
61 if (!controller.signal.aborted) fail(cause);
62 controller.abort();
63 worker.terminate();
64 };
65 const timer = setTimeout(
66 () => failure(new Error("This document took too long to preview. Open it in another app.")),
67 30000,
68 );
69 worker.addEventListener("error", () => failure(new Error("Could not load the Office viewer.")));
70 worker.addEventListener("messageerror", () =>
71 failure(new Error("Could not read the Office viewer response.")),
72 );
73 worker.addEventListener("message", (event: MessageEvent<OfficeWorkerResult>) => {
74 if (controller.signal.aborted) return;
75 clearTimeout(timer);
76 if (event.data.type === "error") failure(new Error(event.data.message));
77 else if ("bytes" in event.data) {
78 setPrepared(event.data);
79 worker.terminate();
80 }
81 });
82 void readOfficeFile(props.src, controller.signal)
83 .then((bytes) => {
84 if (!controller.signal.aborted)
85 worker.postMessage({ type: "open", kind: props.kind, bytes }, [bytes]);
86 })
87 .catch(failure);
88 return () => {
89 controller.abort();
90 worker.terminate();
91 clearTimeout(timer);
92 };
93 }, [props.src, props.kind, props.sizeBytes, fail]);
94 if (error)
95 return (
96 <div
97 role="alert"
98 className="flex min-h-0 flex-1 flex-col items-center justify-center gap-1 px-6 text-center"
99 >
100 <p className="text-sm font-medium">No preview for this file</p>
101 <p className="max-w-sm text-xs leading-relaxed text-muted-foreground">
102 {error} Save it or open it in another app.
103 </p>
104 </div>
105 );
106 if (!prepared) return <FileSurfaceLoading />;
107 const Viewer = props.kind === "docx" ? WordDocument : Presentation;
108 return (
109 <div className="flex min-h-0 flex-1 flex-col">
110 {details ? (
111 <div className={FILE_SURFACE_SUBHEADER_CLASS}>
112 <span className="text-xs text-muted-foreground" aria-live="polite">
113 {details}
114 {props.kind !== "xlsx" ? " · Fit to width" : ""}
115 </span>
116 </div>
117 ) : null}
118 {prepared.truncated ? (
119 <FileSurfaceNotice>
120 Preview limited. Save it or open it in another app to read the full document.
121 </FileSurfaceNotice>
122 ) : null}
123 <Suspense fallback={<FileSurfaceLoading />}>
124 {prepared.view ? (
125 <Spreadsheet
126 bytes={prepared.bytes}
127 view={prepared.view}
128 onDetails={setDetails}
129 onError={fail}
130 />
131 ) : (
132 <Viewer bytes={prepared.bytes} onDetails={setDetails} onError={fail} />
133 )}
134 </Suspense>
135 </div>
136 );