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

OfficePages.tsx

206 lines6.9 KB

1/* oxlint-disable react/no-array-index-key -- A page keeps its ordinal throughout incremental layout. */
2import { observeResize } from "~/lib/observeResize";
3import {
4 useEffect,
5 useImperativeHandle,
6 useRef,
7 useState,
8 type ReactNode,
9 type RefObject,
10} from "react";
11​
12export type PageSize = {
13 readonly width: number;
14 readonly height: number;
15 readonly background?: string | undefined;
16};
17export type PaintPage = (
18 index: number,
19 canvas: HTMLCanvasElement,
20 scale: number,
21 signal: AbortSignal,
22) => Promise<void>;
23​
24export function OfficePages(props: {
25 pages: readonly PageSize[];
26 paint: PaintPage;
27 label: string;
28 overlay?: (index: number) => ReactNode;
29 footer?: (index: number) => ReactNode;
30 scrollRef?: RefObject<((index: number, y?: number) => void) | null>;
31 onPageChange?: (index: number) => void;
32 onError: (error: unknown) => void;
33}) {
34 const scroller = useRef<HTMLDivElement>(null);
35 const rows = useRef(new Map<number, HTMLDivElement>());
36 const [width, setWidth] = useState(0);
37 const [settledWidth, setSettledWidth] = useState(0);
38 const [mounted, setMounted] = useState(6);
39 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Observe the scroll container width and debounce raster resizing, releasing the observer and timer on unmount.
40 useEffect(() => {
41 const root = scroller.current;
42 if (!root) return;
43 let timer: ReturnType<typeof setTimeout>;
44 const unobserve = observeResize(root, ([entry]) => {
45 if (!entry) return;
46 const next = entry.contentRect.width;
47 setWidth(next);
48 clearTimeout(timer);
49 timer = setTimeout(() => setSettledWidth(next), 150);
50 });
51 return () => {
52 clearTimeout(timer);
53 unobserve();
54 };
55 }, []);
56 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Yield between page mounting batches with a timer so large documents do not block the browser.
57 useEffect(() => {
58 if (mounted >= props.pages.length) return;
59 const timer = setTimeout(() => setMounted((n) => Math.min(props.pages.length, n + 50)), 0);
60 return () => clearTimeout(timer);
61 }, [mounted, props.pages.length]);
62 useImperativeHandle(
63 props.scrollRef,
64 () =>
65 (index: number, y = 0) => {
66 const row = rows.current.get(index);
67 const page = props.pages[index];
68 if (row && page)
69 scroller.current?.scrollTo({
70 top: row.offsetTop + y * Math.min(1, Math.max(0, width - 48) / page.width) - 24,
71 });
72 },
73 [props.pages, width],
74 );
75 return (
76 <div
77 ref={scroller}
78 className="relative min-h-0 flex-1 overflow-auto bg-muted/60"
79 data-office-pages
80 onScroll={() => {
81 const root = scroller.current;
82 if (!root || !props.onPageChange) return;
83 if (
84 root.scrollHeight > root.clientHeight &&
85 root.scrollTop + root.clientHeight >= root.scrollHeight - 1
86 ) {
87 props.onPageChange(Math.min(mounted, props.pages.length) - 1);
88 return;
89 }
90 for (const [index, row] of [...rows.current].sort(([a], [b]) => a - b)) {
91 if (row.offsetTop + row.offsetHeight > root.scrollTop + 24) {
92 props.onPageChange(index);
93 break;
94 }
95 }
96 }}
97 >
98 <div className="flex flex-col items-center gap-4 py-6">
99 {props.pages.slice(0, mounted).map((page, index) => (
100 <OfficePage
101 key={index}
102 page={page}
103 index={index}
104 root={scroller}
105 register={(el) => {
106 if (el) rows.current.set(index, el);
107 else rows.current.delete(index);
108 }}
109 scale={Math.min(1, Math.max(0, width - 48) / page.width)}
110 rasterScale={Math.min(1, Math.max(0, (settledWidth || width) - 48) / page.width)}
111 {...props}
112 />
113 ))}
114 </div>
115 </div>
116 );
118​
119function OfficePage(props: {
120 page: PageSize;
121 index: number;
122 root: RefObject<HTMLDivElement | null>;
123 register: (element: HTMLDivElement | null) => void;
124 scale: number;
125 rasterScale: number;
126 paint: PaintPage;
127 label: string;
128 overlay?: (index: number) => ReactNode;
129 footer?: (index: number) => ReactNode;
130 onError: (error: unknown) => void;
131}) {
132 const {
133 root,
134 rasterScale,
135 index,
136 onError,
137 page,
138 paint,
139 scale,
140 register,
141 label,
142 overlay,
143 footer,
144 } = props;
145 const row = useRef<HTMLDivElement>(null);
146 const canvas = useRef<HTMLCanvasElement>(null);
147 const [near, setNear] = useState(false);
148 const [ratio, setRatio] = useState(window.devicePixelRatio || 1);
149 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Observe the page's viewport proximity to limit painting and text layers to nearby pages.
150 useEffect(() => {
151 if (!row.current) return;
152 const observer = new IntersectionObserver(
153 ([entry]) => setNear(entry?.isIntersecting ?? false),
154 { root: root.current, rootMargin: "100% 0px" },
155 );
156 observer.observe(row.current);
157 return () => observer.disconnect();
158 }, [root]);
159 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Subscribe to display density changes so canvas rasters match the current screen.
160 useEffect(() => {
161 const query = matchMedia(`(resolution: ${ratio}dppx)`);
162 const change = () => setRatio(window.devicePixelRatio || 1);
163 query.addEventListener("change", change);
164 return () => query.removeEventListener("change", change);
165 }, [ratio]);
166 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Paint the visible page canvas at the current raster scale and cancel obsolete paint requests.
167 useEffect(() => {
168 const target = canvas.current;
169 if (!target || !page || !near || ratio <= 0 || rasterScale <= 0) return;
170 const controller = new AbortController();
171 void paint(index, target, rasterScale, controller.signal).catch((cause: unknown) => {
172 if (!controller.signal.aborted) onError(cause);
173 });
174 return () => controller.abort();
175 }, [near, index, page, paint, rasterScale, onError, ratio]);
176 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Release the canvas backing store when its page moves away from the viewport.
177 useEffect(() => {
178 const target = canvas.current;
179 if (!near && target) {
180 target.width = 0;
181 target.height = 0;
182 }
183 }, [near]);
184 return (
185 <div className="flex shrink-0 flex-col gap-3" style={{ width: page.width * scale }}>
186 <div
187 ref={(el) => {
188 row.current = el;
189 register(el);
190 }}
191 role="group"
192 aria-label={`${label} ${index + 1}`}
193 className="relative shrink-0 shadow-md"
194 style={{
195 width: page.width * scale,
196 height: page.height * scale,
197 background: page.background ?? "#fff",
198 }}
199 >
200 <canvas ref={canvas} aria-hidden="true" className="absolute inset-0 block size-full" />
201 {near ? overlay?.(index) : null}
202 </div>
203 {footer?.(index)}
204 </div>
205 );