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

Presentation.tsx

158 lines5.8 KB

1/* oxlint-disable react/no-unstable-nested-components -- These callbacks return overlays, and are invoked as render functions. */
2import { useEffect, useRef, useState } from "react";
3import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
4import {
5 FileSurfaceAction,
6 FileSurfaceLoading,
7 FILE_SURFACE_SUBHEADER_CLASS,
8} from "../fileSurfaceChrome";
9import { OfficePages, type PaintPage } from "./OfficePages";
10import { createWorkerClient } from "./worker-rpc";
11import type { SlideMethods, SlideSummary } from "./slide-protocol";
12import { rasterize } from "./vector-pictures";
13​
14type Opened = { slides: readonly SlideSummary[]; paint: PaintPage };
15export default function Presentation(props: {
16 bytes: ArrayBuffer;
17 onDetails: (text: string) => void;
18 onError: (cause: unknown) => void;
19}) {
20 const { onError, bytes: source, onDetails } = props;
21 const [opened, setOpened] = useState<Opened>();
22 const [page, setPage] = useState(0);
23 const [failed, setFailed] = useState<Record<number, boolean>>({});
24 const scroll = useRef<((index: number, y?: number) => void) | null>(null);
25 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Open and paint slides through a worker whose requests and lifetime belong to this presentation.
26 useEffect(() => {
27 const controller = new AbortController();
28 const worker = new Worker(new URL("./presentation.worker.ts", import.meta.url), {
29 type: "module",
30 });
31 const engine = createWorkerClient<SlideMethods>(worker);
32 const fail = (cause: unknown) => {
33 if (!controller.signal.aborted) onError(cause);
34 };
35 worker.addEventListener("error", fail);
36 worker.addEventListener("messageerror", fail);
37 const paint = async (
38 index: number,
39 canvas: HTMLCanvasElement,
40 scale: number,
41 signal: AbortSignal,
42 again = false,
43 ): Promise<void> => {
44 const result = await engine.call(
45 "paint",
46 { index, density: (window.devicePixelRatio || 1) * scale },
47 { key: `slide:${index}`, signal },
48 );
49 if (signal.aborted || result.kind === "cancelled") {
50 if (result.kind === "painted") result.bitmap.close();
51 return;
52 }
53 setFailed((previous) => ({ ...previous, [index]: result.kind === "failed" }));
54 if (result.kind === "failed") return;
55 const context = canvas.getContext("bitmaprenderer");
56 if (!context) {
57 result.bitmap.close();
58 throw new Error("This browser cannot draw slides.");
59 }
60 canvas.width = result.bitmap.width;
61 canvas.height = result.bitmap.height;
62 context.transferFromImageBitmap(result.bitmap);
63 if (again || !result.vectors.length) return;
64 const bitmaps = await Promise.all(result.vectors.map(rasterize));
65 if (signal.aborted) {
66 bitmaps.forEach((value) => value.bitmap?.close());
67 return;
68 }
69 await engine.call("vectors", bitmaps, {
70 transfer: bitmaps.flatMap((value) => (value.bitmap ? [value.bitmap] : [])),
71 signal,
72 });
73 if (!signal.aborted) await paint(index, canvas, scale, signal, true);
74 };
75 const bytes = source.slice(0);
76 void engine
77 .call("open", { bytes }, { transfer: [bytes], signal: controller.signal })
78 .then((slides) => {
79 if (slides.length === 0 || slides.length > 500)
80 throw new Error("Preview limited to presentations with 1 to 500 slides.");
81 setOpened({ slides, paint });
82 onDetails(`${slides.length} slide${slides.length === 1 ? "" : "s"}`);
83 })
84 .catch(fail);
85 return () => {
86 controller.abort();
87 engine.close();
88 };
89 }, [source, onDetails, onError]);
90 if (!opened) return <FileSurfaceLoading />;
91 const select = (index: number) => {
92 setPage(index);
93 scroll.current?.(index);
94 };
95 return (
96 <div className="flex min-h-0 flex-1 flex-col">
97 <div className={FILE_SURFACE_SUBHEADER_CLASS}>
98 <FileSurfaceAction
99 label="Previous slide"
100 disabled={page === 0}
101 onPress={() => select(page - 1)}
102 >
103 <ChevronLeftIcon className="size-3.5" />
104 </FileSurfaceAction>
105 <span className="flex-1 text-center text-xs tabular-nums" aria-live="polite">
106 Slide {page + 1} of {opened.slides.length}
107 </span>
108 <FileSurfaceAction
109 label="Next slide"
110 disabled={page + 1 === opened.slides.length}
111 onPress={() => select(page + 1)}
112 >
113 <ChevronRightIcon className="size-3.5" />
114 </FileSurfaceAction>
115 </div>
116 <OfficePages
117 pages={opened.slides}
118 paint={opened.paint}
119 label="Slide"
120 scrollRef={scroll}
121 onPageChange={setPage}
122 onError={onError}
123 overlay={(index) => (
124 <>
125 {opened.slides[index]?.hidden ? (
126 <div className="pointer-events-none absolute inset-0 bg-white/50" />
127 ) : null}
128 {failed[index] ? (
129 <p
130 role="status"
131 className="absolute inset-0 flex items-center justify-center bg-background/80 p-4 text-xs text-foreground"
132 >
133 This slide could not be drawn.
134 </p>
135 ) : null}
136 </>
137 )}
138 footer={(index) => {
139 const slide = opened.slides[index];
140 return slide?.notes || slide?.hidden ? (
141 <div className="space-y-1 text-xs text-muted-foreground">
142 {slide.hidden ? <p>Hidden slide</p> : null}
143 {slide.notes ? (
144 <p
145 role="note"
146 aria-label="Speaker notes"
147 className="select-text whitespace-pre-wrap break-words"
148 >
149 {slide.notes}
150 </p>
151 ) : null}
152 </div>
153 ) : null;
154 }}
155 />
156 </div>
157 );