Skip to content
Source
Sign inSign up

WordDocument.tsx

204 lines7.2 KB

1/* oxlint-disable react/no-array-index-key -- Text spans retain their order within an immutable page. */
2/* oxlint-disable react/no-unstable-nested-components -- These callbacks return overlays, and are invoked as render functions. */
3import { Tooltip, TooltipTrigger, TooltipPopup } from "~/components/ui/tooltip";
4import { Fragment, useEffect, useRef, useState } from "react";
5import { FileSurfaceLoading } from "../fileSurfaceChrome";
6import { OfficePages, type PaintPage } from "./OfficePages";
7import { rasterize } from "./vector-pictures";
8import { createWorkerClient } from "./worker-rpc";
9import {
10 layerFont,
11 lineHeight,
12 type WordMethods,
13 type WordPageSize,
14 type WordSpan,
15 type WordLink,
16} from "./word-protocol";
17​
18type Opened = {
19 pages: readonly WordPageSize[];
20 paint: PaintPage;
21 engine: ReturnType<typeof createWorkerClient<WordMethods>>;
22};
23​
24export default function WordDocument(props: {
25 bytes: ArrayBuffer;
26 onDetails: (text: string) => void;
27 onError: (cause: unknown) => void;
28}) {
29 const { onError, bytes, onDetails } = props;
30 const [opened, setOpened] = useState<Opened>();
31 const scroll = useRef<((index: number, y?: number) => void) | null>(null);
32 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Own the document worker and cancel its layout and painting requests when the document changes or unmounts.
33 useEffect(() => {
34 const controller = new AbortController();
35 const worker = new Worker(new URL("./document.worker.ts", import.meta.url), { type: "module" });
36 const engine = createWorkerClient<WordMethods>(worker);
37 const fail = (error: unknown) => {
38 if (!controller.signal.aborted) onError(error);
39 };
40 worker.addEventListener("error", fail);
41 worker.addEventListener("messageerror", fail);
42 const buffer = bytes.slice(0);
43 const paint = async (
44 index: number,
45 canvas: HTMLCanvasElement,
46 scale: number,
47 signal: AbortSignal,
48 again = false,
49 ): Promise<void> => {
50 const painted = await engine.call(
51 "paint",
52 { index, scale, ratio: window.devicePixelRatio || 1 },
53 { key: `paint:${index}`, signal },
54 );
55 void engine
56 .call("build", { index: index + 1 }, { key: "build", signal })
57 .catch(() => undefined);
58 if (!painted) return;
59 const context = canvas.getContext("bitmaprenderer");
60 if (!context || signal.aborted) {
61 painted.bitmap.close();
62 return;
63 }
64 canvas.width = painted.bitmap.width;
65 canvas.height = painted.bitmap.height;
66 context.transferFromImageBitmap(painted.bitmap);
67 if (again || !painted.vectors.length) return;
68 const bitmaps = await Promise.all(painted.vectors.map(rasterize));
69 if (signal.aborted) {
70 bitmaps.forEach((value) => value.bitmap?.close());
71 return;
72 }
73 await engine.call("vectors", bitmaps, {
74 transfer: bitmaps.flatMap((value) => (value.bitmap ? [value.bitmap] : [])),
75 signal,
76 });
77 if (!signal.aborted) await paint(index, canvas, scale, signal, true);
78 };
79 void engine
80 .call("open", { bytes: buffer }, { transfer: [buffer], signal: controller.signal })
81 .then(async (first) => {
82 if (controller.signal.aborted) return;
83 setOpened({ pages: first.pages, paint, engine });
84 onDetails(
85 `${first.pages.length}${first.loading ? "+" : ""} page${first.pages.length === 1 && !first.loading ? "" : "s"}`,
86 );
87 if (!first.loading) return;
88 await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
89 while (!controller.signal.aborted) {
90 const step = await engine.call("layout", undefined, { signal: controller.signal });
91 if (!step.done) continue;
92 if (step.pages.length > 1000) throw new Error("Preview limited to 1,000 pages.");
93 setOpened({ pages: step.pages, paint, engine });
94 onDetails(`${step.pages.length} page${step.pages.length === 1 ? "" : "s"}`);
95 break;
96 }
97 })
98 .catch(fail);
99 return () => {
100 controller.abort();
101 engine.close();
102 };
103 }, [bytes, onDetails, onError]);
104 if (!opened) return <FileSurfaceLoading />;
105 const follow = (link: WordLink) => {
106 if (link.kind === "external") {
107 window.open(link.url, "_blank", "noopener,noreferrer");
108 return;
109 }
110 void opened.engine
111 .call("bookmark", { name: link.bookmark })
112 .then((target) => {
113 if (target) scroll.current?.(target.index, target.y);
114 })
115 .catch(onError);
116 };
117 return (
118 <OfficePages
119 pages={opened.pages}
120 paint={opened.paint}
121 label="Page"
122 scrollRef={scroll}
123 onError={onError}
124 overlay={(index) => (
125 <WordTextLayer
126 engine={opened.engine}
127 index={index}
128 page={opened.pages[index]}
129 follow={follow}
130 />
131 )}
132 />
133 );
135​
136function WordTextLayer(props: {
137 engine: Opened["engine"];
138 index: number;
139 page: WordPageSize | undefined;
140 follow: (link: WordLink) => void;
141}) {
142 const { engine, index, page, follow } = props;
143 const [spans, setSpans] = useState<readonly WordSpan[]>([]);
144 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Request selectable text from the document worker and cancel the request when its page leaves the viewport.
145 useEffect(() => {
146 if (!page) return;
147 const controller = new AbortController();
148 void engine
149 .call("text", { index: index }, { signal: controller.signal })
150 .then(setSpans)
151 .catch(() => undefined);
152 return () => controller.abort();
153 }, [engine, index, page]);
154 return (
155 <div
156 className="absolute inset-0 select-text overflow-hidden whitespace-pre text-transparent selection:bg-primary/30 selection:text-transparent"
157 style={{ containerType: "inline-size", fontFamily: layerFont, fontSize: 0, lineHeight }}
158 >
159 {spans.map((span, index) => {
160 const text = (
161 <span
162 dir={span.dir}
163 role={span.link ? "link" : undefined}
164 tabIndex={span.link ? 0 : undefined}
165 aria-label={span.title}
166 className="absolute origin-top-left"
167 style={{
168 left: `${span.left * 100}%`,
169 top: `${span.top * 100}%`,
170 fontSize: `${span.size * 100}cqw`,
171 transform: span.transform,
172 cursor: span.link ? "pointer" : "text",
173 }}
174 onClick={(event) => {
175 if (span.link && event.button === 0 && (getSelection()?.isCollapsed ?? true))
176 follow(span.link);
177 }}
178 onKeyDown={(event) => {
179 if (span.link && event.key === "Enter") {
180 event.preventDefault();
181 follow(span.link);
182 }
183 }}
184 >
185 {span.text}
186 </span>
187 );
188 return (
189 <Fragment key={index}>
190 {span.title ? (
191 <Tooltip>
192 <TooltipTrigger render={text} />
193 <TooltipPopup>{span.title}</TooltipPopup>
194 </Tooltip>
195 ) : (
196 text
197 )}
198 {span.after === "br" ? <br /> : span.after === "tab" ? "\t" : null}
199 </Fragment>
200 );
201 })}
202 </div>
203 );