@tangle-network/ui 11.25.0 → 11.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +22 -0
- package/dist/editor/editor-peers.js +1 -21
- package/dist/files/file-card.d.ts +30 -0
- package/dist/files/file-card.js +162 -0
- package/dist/files/file-list.d.ts +25 -0
- package/dist/files/file-list.js +88 -0
- package/dist/files/file-preview.js +16 -62
- package/dist/files/pdf-loader.d.ts +22 -0
- package/dist/files/pdf-loader.js +82 -0
- package/dist/files/pdf-viewer.d.ts +32 -0
- package/dist/files/pdf-viewer.js +567 -0
- package/dist/files.d.ts +5 -1
- package/dist/files.js +5 -1
- package/dist/index.d.ts +7 -3
- package/dist/index.js +5 -1
- package/dist/lib/optional-peer.js +29 -0
- package/dist/markdown/markdown.d.ts +3 -2
- package/dist/markdown/markdown.js +70 -4
- package/dist/primitives.d.ts +2 -2
- package/package.json +7 -2
- package/src/editor/editor-peers.ts +3 -25
- package/src/files/file-artifact-pane.test.tsx +12 -2
- package/src/files/file-card.test.tsx +62 -0
- package/src/files/file-card.tsx +210 -0
- package/src/files/file-list.stories.tsx +121 -0
- package/src/files/file-list.test.tsx +81 -0
- package/src/files/file-list.tsx +167 -0
- package/src/files/file-preview.tsx +25 -94
- package/src/files/index.ts +9 -0
- package/src/files/pdf-loader.test.ts +130 -0
- package/src/files/pdf-loader.ts +169 -0
- package/src/files/pdf-viewer.stories.tsx +81 -0
- package/src/files/pdf-viewer.test.tsx +245 -0
- package/src/files/pdf-viewer.tsx +677 -0
- package/src/files/pdfjs-worker.d.ts +3 -0
- package/src/lib/optional-peer.ts +30 -0
- package/src/markdown/markdown.test.tsx +32 -3
- package/src/markdown/markdown.tsx +85 -4
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PdfViewer — renders a PDF's pages to canvas with pdf.js, so a document reads
|
|
3
|
+
* the same in every browser, phones included. A continuous column of pages
|
|
4
|
+
* with page navigation, zoom and fit-to-width, and Download.
|
|
5
|
+
*
|
|
6
|
+
* Only pages within a screen of the visible area hold a canvas; the rest keep
|
|
7
|
+
* their size and release theirs, so a long document stays inside the canvas
|
|
8
|
+
* memory a phone allows.
|
|
9
|
+
*
|
|
10
|
+
* When pdf.js is not installed or cannot open the file, the viewer says so
|
|
11
|
+
* and falls back to the browser's own viewer through `<object>`, and from
|
|
12
|
+
* there to a FileCard where the browser has none (Android Chrome).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
memo,
|
|
17
|
+
useCallback,
|
|
18
|
+
useEffect,
|
|
19
|
+
useLayoutEffect,
|
|
20
|
+
useMemo,
|
|
21
|
+
useRef,
|
|
22
|
+
useState,
|
|
23
|
+
} from "react";
|
|
24
|
+
import {
|
|
25
|
+
ChevronLeft,
|
|
26
|
+
ChevronRight,
|
|
27
|
+
Download,
|
|
28
|
+
Info,
|
|
29
|
+
LoaderCircle,
|
|
30
|
+
ZoomIn,
|
|
31
|
+
ZoomOut,
|
|
32
|
+
} from "lucide-react";
|
|
33
|
+
import type { PDFDocumentProxy, PDFPageProxy } from "pdfjs-dist";
|
|
34
|
+
import { fieldPresentation } from "../lib/control-presentation";
|
|
35
|
+
import { focusRingInset } from "../lib/focus";
|
|
36
|
+
import { cn } from "../lib/utils";
|
|
37
|
+
import { Alert, AlertDescription } from "../primitives/alert";
|
|
38
|
+
import { Button } from "../primitives/button";
|
|
39
|
+
import { FileCard } from "./file-card";
|
|
40
|
+
import { isMissingPdfjsError, loadPdfjs, pdfAssetParams } from "./pdf-loader";
|
|
41
|
+
|
|
42
|
+
export interface PdfViewerProps {
|
|
43
|
+
/**
|
|
44
|
+
* The PDF: an object URL, a data URL, or an http(s) URL. A URL on another
|
|
45
|
+
* origin must allow this one through CORS; when it does not, the viewer
|
|
46
|
+
* falls back to the browser's viewer.
|
|
47
|
+
*/
|
|
48
|
+
src: string;
|
|
49
|
+
/** Names the document for assistive technology and the fallback card. */
|
|
50
|
+
filename?: string;
|
|
51
|
+
/** Size in bytes, shown on the fallback card. */
|
|
52
|
+
size?: number;
|
|
53
|
+
/** Adds a Download button to the toolbar and the fallback card. */
|
|
54
|
+
onDownload?: () => void;
|
|
55
|
+
/** Give the viewer a height; it scrolls its pages inside it. */
|
|
56
|
+
className?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** pdf.js measures in points; CSS pixels are 96 to the inch. */
|
|
60
|
+
const PDF_TO_CSS = 96 / 72;
|
|
61
|
+
const ZOOM_STEPS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4];
|
|
62
|
+
const MIN_ZOOM = ZOOM_STEPS[0];
|
|
63
|
+
const MAX_ZOOM = ZOOM_STEPS[ZOOM_STEPS.length - 1];
|
|
64
|
+
/** Matches the column's `p-3`. */
|
|
65
|
+
const COLUMN_PADDING = 12;
|
|
66
|
+
/**
|
|
67
|
+
* iOS Safari refuses to draw a canvas above 4096 × 4096 pixels. Past that, a
|
|
68
|
+
* page renders below the device pixel ratio, softer but whole.
|
|
69
|
+
*/
|
|
70
|
+
const MAX_CANVAS_PIXELS = 4096 * 4096;
|
|
71
|
+
|
|
72
|
+
type ZoomSetting = { mode: "fit" } | { mode: "manual"; value: number };
|
|
73
|
+
|
|
74
|
+
type LoadState =
|
|
75
|
+
| { status: "loading"; percent?: number }
|
|
76
|
+
| { status: "ready"; doc: PDFDocumentProxy }
|
|
77
|
+
| { status: "fallback"; notice: string; retry: boolean };
|
|
78
|
+
|
|
79
|
+
function clamp(value: number, min: number, max: number): number {
|
|
80
|
+
return Math.min(max, Math.max(min, value));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* pdf.js streams an http(s) URL itself, with range requests, so the first
|
|
85
|
+
* page shows before a long file finishes downloading. It reads other URLs
|
|
86
|
+
* through XMLHttpRequest, which not every browser allows for a data URL, so
|
|
87
|
+
* those are read here and handed over as bytes.
|
|
88
|
+
*/
|
|
89
|
+
async function documentSource(
|
|
90
|
+
src: string,
|
|
91
|
+
signal: AbortSignal,
|
|
92
|
+
): Promise<{ url: string } | { data: Uint8Array }> {
|
|
93
|
+
if (/^https?:/i.test(new URL(src, document.baseURI).protocol)) return { url: src };
|
|
94
|
+
const response = await fetch(src, { signal });
|
|
95
|
+
if (!response.ok) throw new Error(`Reading the PDF failed with status ${response.status}.`);
|
|
96
|
+
return { data: new Uint8Array(await response.arrayBuffer()) };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** What the reader is told when the viewer falls back, and whether retrying can help. */
|
|
100
|
+
function fallbackFor(error: unknown): { notice: string; retry: boolean } {
|
|
101
|
+
if (isMissingPdfjsError(error)) {
|
|
102
|
+
return {
|
|
103
|
+
notice: "This is the browser's PDF viewer, because this app does not include the page viewer.",
|
|
104
|
+
retry: false,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const name = error instanceof Error ? error.name : "";
|
|
108
|
+
if (name === "PasswordException") {
|
|
109
|
+
return {
|
|
110
|
+
notice: "This PDF is password-protected, so it opens in the browser's PDF viewer.",
|
|
111
|
+
retry: false,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
if (name === "InvalidPDFException") {
|
|
115
|
+
return { notice: "This file could not be read as a PDF.", retry: false };
|
|
116
|
+
}
|
|
117
|
+
const status = (error as { status?: unknown } | null)?.status;
|
|
118
|
+
if (name === "ResponseException" && typeof status === "number" && status > 0) {
|
|
119
|
+
return { notice: `The PDF could not be downloaded (HTTP ${status}).`, retry: true };
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
notice: "The page viewer could not open this PDF, so this is the browser's PDF viewer.",
|
|
123
|
+
retry: true,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function PdfViewer({
|
|
128
|
+
src,
|
|
129
|
+
filename = "Document",
|
|
130
|
+
size,
|
|
131
|
+
onDownload,
|
|
132
|
+
className,
|
|
133
|
+
}: PdfViewerProps) {
|
|
134
|
+
const [attempt, setAttempt] = useState(0);
|
|
135
|
+
const [state, setState] = useState<LoadState>({ status: "loading" });
|
|
136
|
+
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
let active = true;
|
|
139
|
+
let destroy: (() => Promise<void>) | undefined;
|
|
140
|
+
const abort = new AbortController();
|
|
141
|
+
setState({ status: "loading" });
|
|
142
|
+
|
|
143
|
+
const fail = (error: unknown) => {
|
|
144
|
+
if (!active) return;
|
|
145
|
+
// The notice tells the reader; this tells the developer why.
|
|
146
|
+
console.error(`PdfViewer: falling back to the browser's PDF viewer for ${filename}.`, error);
|
|
147
|
+
setState({ status: "fallback", ...fallbackFor(error) });
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
(async () => {
|
|
151
|
+
const pdfjs = await loadPdfjs();
|
|
152
|
+
const source = await documentSource(src, abort.signal);
|
|
153
|
+
if (!active) return;
|
|
154
|
+
const task = pdfjs.getDocument({ ...source, ...pdfAssetParams() });
|
|
155
|
+
destroy = () => task.destroy();
|
|
156
|
+
task.onProgress = ({ loaded, total }: { loaded: number; total: number }) => {
|
|
157
|
+
if (active && total > 0) {
|
|
158
|
+
setState({ status: "loading", percent: Math.min(100, Math.round((loaded / total) * 100)) });
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
const doc = await task.promise;
|
|
162
|
+
if (active) setState({ status: "ready", doc });
|
|
163
|
+
})().catch(fail);
|
|
164
|
+
|
|
165
|
+
return () => {
|
|
166
|
+
active = false;
|
|
167
|
+
abort.abort();
|
|
168
|
+
// Destroying the task also destroys a document it already opened.
|
|
169
|
+
void destroy?.();
|
|
170
|
+
};
|
|
171
|
+
// `filename` only labels the log line; a rename must not reload the file.
|
|
172
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
173
|
+
}, [src, attempt]);
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<div
|
|
177
|
+
data-state={state.status}
|
|
178
|
+
className={cn(
|
|
179
|
+
"flex max-h-[100dvh] min-h-0 flex-col overflow-hidden rounded-[var(--radius-md)] border border-border bg-card",
|
|
180
|
+
className,
|
|
181
|
+
)}
|
|
182
|
+
>
|
|
183
|
+
{state.status === "ready" ? (
|
|
184
|
+
<PdfDocument doc={state.doc} filename={filename} onDownload={onDownload} />
|
|
185
|
+
) : state.status === "loading" ? (
|
|
186
|
+
<div
|
|
187
|
+
role="status"
|
|
188
|
+
className="flex min-h-[12rem] flex-1 flex-col items-center justify-center gap-3 p-6 text-sm text-muted-foreground"
|
|
189
|
+
>
|
|
190
|
+
<LoaderCircle aria-hidden="true" className="size-5 animate-spin motion-reduce:animate-none" />
|
|
191
|
+
<span>
|
|
192
|
+
Loading {filename}
|
|
193
|
+
{state.percent === undefined ? "…" : ` · ${state.percent}%`}
|
|
194
|
+
</span>
|
|
195
|
+
</div>
|
|
196
|
+
) : (
|
|
197
|
+
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-auto p-3">
|
|
198
|
+
<Alert tone="info" icon={<Info />}>
|
|
199
|
+
<AlertDescription>{state.notice}</AlertDescription>
|
|
200
|
+
{state.retry && (
|
|
201
|
+
<div>
|
|
202
|
+
<Button type="button" variant="outline" size="sm" onClick={() => setAttempt((n) => n + 1)}>
|
|
203
|
+
Try again
|
|
204
|
+
</Button>
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
207
|
+
</Alert>
|
|
208
|
+
{/* A browser with a PDF viewer shows the file here; one without
|
|
209
|
+
(Android Chrome) shows the card. */}
|
|
210
|
+
<object
|
|
211
|
+
data={src}
|
|
212
|
+
type="application/pdf"
|
|
213
|
+
title={filename}
|
|
214
|
+
className="min-h-[20rem] w-full flex-1 rounded-[var(--radius-md)]"
|
|
215
|
+
>
|
|
216
|
+
<FileCard
|
|
217
|
+
filename={filename}
|
|
218
|
+
mimeType="application/pdf"
|
|
219
|
+
size={size}
|
|
220
|
+
description="This browser does not show PDFs inline."
|
|
221
|
+
onDownload={onDownload}
|
|
222
|
+
className="h-full"
|
|
223
|
+
/>
|
|
224
|
+
</object>
|
|
225
|
+
</div>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function PdfDocument({
|
|
232
|
+
doc,
|
|
233
|
+
filename,
|
|
234
|
+
onDownload,
|
|
235
|
+
}: {
|
|
236
|
+
doc: PDFDocumentProxy;
|
|
237
|
+
filename: string;
|
|
238
|
+
onDownload?: () => void;
|
|
239
|
+
}) {
|
|
240
|
+
const pageCount = doc.numPages;
|
|
241
|
+
const [scroller, setScroller] = useState<HTMLDivElement | null>(null);
|
|
242
|
+
const pageElements = useRef<(HTMLDivElement | null)[]>([]);
|
|
243
|
+
// Page 1 at 100%, in CSS pixels. Every page takes this size until its own
|
|
244
|
+
// loads, and fit-to-width fits it.
|
|
245
|
+
const [firstPage, setFirstPage] = useState<{ width: number; height: number } | null>(null);
|
|
246
|
+
const [availableWidth, setAvailableWidth] = useState(0);
|
|
247
|
+
const [zoomSetting, setZoomSetting] = useState<ZoomSetting>({ mode: "fit" });
|
|
248
|
+
const [currentPage, setCurrentPage] = useState(1);
|
|
249
|
+
// The reading position as a page and a fraction of its height, so a zoom
|
|
250
|
+
// or a resize keeps the same text in view. Null until the reader scrolls.
|
|
251
|
+
const anchor = useRef<{ index: number; fraction: number } | null>(null);
|
|
252
|
+
const registerPage = useCallback((pageNumber: number, element: HTMLDivElement | null) => {
|
|
253
|
+
pageElements.current[pageNumber - 1] = element;
|
|
254
|
+
}, []);
|
|
255
|
+
|
|
256
|
+
useEffect(() => {
|
|
257
|
+
let active = true;
|
|
258
|
+
doc.getPage(1).then(
|
|
259
|
+
(page) => {
|
|
260
|
+
if (!active) return;
|
|
261
|
+
const { width, height } = page.getViewport({ scale: PDF_TO_CSS });
|
|
262
|
+
setFirstPage({ width, height });
|
|
263
|
+
},
|
|
264
|
+
() => {
|
|
265
|
+
// A first page that will not load still leaves the others readable.
|
|
266
|
+
if (active) setFirstPage({ width: 612 * PDF_TO_CSS, height: 792 * PDF_TO_CSS });
|
|
267
|
+
},
|
|
268
|
+
);
|
|
269
|
+
return () => {
|
|
270
|
+
active = false;
|
|
271
|
+
};
|
|
272
|
+
}, [doc]);
|
|
273
|
+
|
|
274
|
+
useLayoutEffect(() => {
|
|
275
|
+
if (!scroller) return;
|
|
276
|
+
setAvailableWidth(scroller.clientWidth);
|
|
277
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
278
|
+
const observer = new ResizeObserver(() => setAvailableWidth(scroller.clientWidth));
|
|
279
|
+
observer.observe(scroller);
|
|
280
|
+
return () => observer.disconnect();
|
|
281
|
+
}, [scroller]);
|
|
282
|
+
|
|
283
|
+
const fitZoom =
|
|
284
|
+
firstPage && availableWidth > COLUMN_PADDING * 2
|
|
285
|
+
? clamp((availableWidth - COLUMN_PADDING * 2) / firstPage.width, MIN_ZOOM, MAX_ZOOM)
|
|
286
|
+
: 1;
|
|
287
|
+
const zoom = zoomSetting.mode === "fit" ? fitZoom : zoomSetting.value;
|
|
288
|
+
|
|
289
|
+
const readPosition = useCallback(() => {
|
|
290
|
+
if (!scroller) return;
|
|
291
|
+
const top = scroller.scrollTop;
|
|
292
|
+
const bottom = top + scroller.clientHeight;
|
|
293
|
+
// The page with the most height on screen is the current one, except at
|
|
294
|
+
// either end, where the first or last page cannot scroll to the top.
|
|
295
|
+
let best = 0;
|
|
296
|
+
let bestVisible = -1;
|
|
297
|
+
pageElements.current.forEach((element, index) => {
|
|
298
|
+
if (!element) return;
|
|
299
|
+
const visible =
|
|
300
|
+
Math.min(bottom, element.offsetTop + element.offsetHeight) - Math.max(top, element.offsetTop);
|
|
301
|
+
if (visible > bestVisible) {
|
|
302
|
+
best = index;
|
|
303
|
+
bestVisible = visible;
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
if (top + scroller.clientHeight >= scroller.scrollHeight - 1 && top > 0) best = pageCount - 1;
|
|
307
|
+
const element = pageElements.current[best];
|
|
308
|
+
anchor.current = {
|
|
309
|
+
index: best,
|
|
310
|
+
fraction: element?.offsetHeight ? (top - element.offsetTop) / element.offsetHeight : 0,
|
|
311
|
+
};
|
|
312
|
+
setCurrentPage(best + 1);
|
|
313
|
+
}, [scroller, pageCount]);
|
|
314
|
+
|
|
315
|
+
// Restore the reading position after the page sizes change.
|
|
316
|
+
useLayoutEffect(() => {
|
|
317
|
+
const element = anchor.current && pageElements.current[anchor.current.index];
|
|
318
|
+
if (!scroller || !anchor.current || !element) return;
|
|
319
|
+
scroller.scrollTop = element.offsetTop + anchor.current.fraction * element.offsetHeight;
|
|
320
|
+
scroller.scrollLeft = (scroller.scrollWidth - scroller.clientWidth) / 2;
|
|
321
|
+
}, [scroller, zoom]);
|
|
322
|
+
|
|
323
|
+
const frame = useRef(0);
|
|
324
|
+
useEffect(() => () => cancelAnimationFrame(frame.current), []);
|
|
325
|
+
const onScroll = () => {
|
|
326
|
+
if (frame.current) return;
|
|
327
|
+
frame.current = requestAnimationFrame(() => {
|
|
328
|
+
frame.current = 0;
|
|
329
|
+
readPosition();
|
|
330
|
+
});
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const goToPage = (pageNumber: number) => {
|
|
334
|
+
const target = clamp(pageNumber, 1, pageCount);
|
|
335
|
+
const element = pageElements.current[target - 1];
|
|
336
|
+
if (scroller && element) {
|
|
337
|
+
scroller.scrollTop = element.offsetTop - COLUMN_PADDING;
|
|
338
|
+
anchor.current = {
|
|
339
|
+
index: target - 1,
|
|
340
|
+
fraction: element.offsetHeight ? -COLUMN_PADDING / element.offsetHeight : 0,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
setCurrentPage(target);
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const zoomBy = (direction: 1 | -1) => {
|
|
347
|
+
readPosition();
|
|
348
|
+
const next =
|
|
349
|
+
direction > 0
|
|
350
|
+
? ZOOM_STEPS.find((step) => step > zoom + 0.001) ?? MAX_ZOOM
|
|
351
|
+
: [...ZOOM_STEPS].reverse().find((step) => step < zoom - 0.001) ?? MIN_ZOOM;
|
|
352
|
+
setZoomSetting({ mode: "manual", value: next });
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const percent = Math.round(zoom * 100);
|
|
356
|
+
const fitting = zoomSetting.mode === "fit";
|
|
357
|
+
|
|
358
|
+
return (
|
|
359
|
+
<>
|
|
360
|
+
<div
|
|
361
|
+
role="group"
|
|
362
|
+
aria-label={`${filename} controls`}
|
|
363
|
+
// Fits one row at 360px with a three-digit page count. Narrower than
|
|
364
|
+
// that it scrolls, because a squeezed button is a missed tap.
|
|
365
|
+
className="flex shrink-0 items-center gap-0.5 overflow-x-auto border-b border-border px-1 py-1 [&>*]:shrink-0"
|
|
366
|
+
>
|
|
367
|
+
<Button
|
|
368
|
+
type="button"
|
|
369
|
+
variant="ghost"
|
|
370
|
+
size="icon"
|
|
371
|
+
aria-label="Previous page"
|
|
372
|
+
title="Previous page"
|
|
373
|
+
disabled={currentPage <= 1}
|
|
374
|
+
onClick={() => goToPage(currentPage - 1)}
|
|
375
|
+
>
|
|
376
|
+
<ChevronLeft />
|
|
377
|
+
</Button>
|
|
378
|
+
<PageField current={currentPage} total={pageCount} onCommit={goToPage} />
|
|
379
|
+
<Button
|
|
380
|
+
type="button"
|
|
381
|
+
variant="ghost"
|
|
382
|
+
size="icon"
|
|
383
|
+
aria-label="Next page"
|
|
384
|
+
title="Next page"
|
|
385
|
+
disabled={currentPage >= pageCount}
|
|
386
|
+
onClick={() => goToPage(currentPage + 1)}
|
|
387
|
+
>
|
|
388
|
+
<ChevronRight />
|
|
389
|
+
</Button>
|
|
390
|
+
<div className="ml-auto flex items-center gap-0.5">
|
|
391
|
+
<Button
|
|
392
|
+
type="button"
|
|
393
|
+
variant="ghost"
|
|
394
|
+
size="icon"
|
|
395
|
+
aria-label="Zoom out"
|
|
396
|
+
title="Zoom out"
|
|
397
|
+
disabled={zoom <= MIN_ZOOM + 0.001}
|
|
398
|
+
onClick={() => zoomBy(-1)}
|
|
399
|
+
>
|
|
400
|
+
<ZoomOut />
|
|
401
|
+
</Button>
|
|
402
|
+
{/* The zoom level doubles as the fit-to-width control, which keeps
|
|
403
|
+
the toolbar to one row at 360px. Pressed while fitting. */}
|
|
404
|
+
<Button
|
|
405
|
+
type="button"
|
|
406
|
+
variant="ghost"
|
|
407
|
+
size="sm"
|
|
408
|
+
aria-label={`${percent}%, fit to width`}
|
|
409
|
+
title="Fit to width"
|
|
410
|
+
aria-pressed={fitting}
|
|
411
|
+
className={cn(
|
|
412
|
+
"min-w-12 px-1.5 tabular-nums",
|
|
413
|
+
fitting && "bg-primary/10 text-[var(--accent-text)] hover:text-[var(--accent-text)]",
|
|
414
|
+
)}
|
|
415
|
+
onClick={() => {
|
|
416
|
+
readPosition();
|
|
417
|
+
setZoomSetting({ mode: "fit" });
|
|
418
|
+
}}
|
|
419
|
+
>
|
|
420
|
+
{percent}%
|
|
421
|
+
</Button>
|
|
422
|
+
<span className="sr-only" aria-live="polite">
|
|
423
|
+
Zoom {percent}%
|
|
424
|
+
</span>
|
|
425
|
+
<Button
|
|
426
|
+
type="button"
|
|
427
|
+
variant="ghost"
|
|
428
|
+
size="icon"
|
|
429
|
+
aria-label="Zoom in"
|
|
430
|
+
title="Zoom in"
|
|
431
|
+
disabled={zoom >= MAX_ZOOM - 0.001}
|
|
432
|
+
onClick={() => zoomBy(1)}
|
|
433
|
+
>
|
|
434
|
+
<ZoomIn />
|
|
435
|
+
</Button>
|
|
436
|
+
{onDownload && (
|
|
437
|
+
<Button
|
|
438
|
+
type="button"
|
|
439
|
+
variant="ghost"
|
|
440
|
+
size="icon"
|
|
441
|
+
aria-label={`Download ${filename}`}
|
|
442
|
+
title="Download"
|
|
443
|
+
onClick={onDownload}
|
|
444
|
+
>
|
|
445
|
+
<Download />
|
|
446
|
+
</Button>
|
|
447
|
+
)}
|
|
448
|
+
</div>
|
|
449
|
+
</div>
|
|
450
|
+
<div
|
|
451
|
+
ref={setScroller}
|
|
452
|
+
tabIndex={0}
|
|
453
|
+
role="region"
|
|
454
|
+
aria-label={filename}
|
|
455
|
+
onScroll={onScroll}
|
|
456
|
+
// A stable gutter keeps fit-to-width from oscillating as the
|
|
457
|
+
// scrollbar appears and narrows the column it was fitted to.
|
|
458
|
+
className={cn(
|
|
459
|
+
"relative min-h-0 flex-1 overflow-auto overscroll-contain bg-muted [scrollbar-gutter:stable]",
|
|
460
|
+
focusRingInset,
|
|
461
|
+
)}
|
|
462
|
+
>
|
|
463
|
+
{firstPage ? (
|
|
464
|
+
<div className="flex w-max min-w-full flex-col items-center gap-3 p-3">
|
|
465
|
+
{Array.from({ length: pageCount }, (_, index) => (
|
|
466
|
+
<PdfPage
|
|
467
|
+
key={index}
|
|
468
|
+
doc={doc}
|
|
469
|
+
pageNumber={index + 1}
|
|
470
|
+
pageCount={pageCount}
|
|
471
|
+
zoom={zoom}
|
|
472
|
+
placeholder={firstPage}
|
|
473
|
+
root={scroller}
|
|
474
|
+
register={registerPage}
|
|
475
|
+
/>
|
|
476
|
+
))}
|
|
477
|
+
</div>
|
|
478
|
+
) : (
|
|
479
|
+
<div role="status" className="flex h-full min-h-[12rem] items-center justify-center text-sm text-muted-foreground">
|
|
480
|
+
<LoaderCircle aria-hidden="true" className="size-5 animate-spin motion-reduce:animate-none" />
|
|
481
|
+
<span className="sr-only">Loading {filename}…</span>
|
|
482
|
+
</div>
|
|
483
|
+
)}
|
|
484
|
+
</div>
|
|
485
|
+
</>
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* The current page number, editable. A typed number applies on Enter or when
|
|
491
|
+
* the field loses focus; Escape restores the current page.
|
|
492
|
+
*/
|
|
493
|
+
function PageField({
|
|
494
|
+
current,
|
|
495
|
+
total,
|
|
496
|
+
onCommit,
|
|
497
|
+
}: {
|
|
498
|
+
current: number;
|
|
499
|
+
total: number;
|
|
500
|
+
onCommit: (page: number) => void;
|
|
501
|
+
}) {
|
|
502
|
+
const [draft, setDraft] = useState<string | null>(null);
|
|
503
|
+
const commit = () => {
|
|
504
|
+
if (draft === null) return;
|
|
505
|
+
const page = Number.parseInt(draft, 10);
|
|
506
|
+
setDraft(null);
|
|
507
|
+
if (Number.isFinite(page)) onCommit(page);
|
|
508
|
+
};
|
|
509
|
+
return (
|
|
510
|
+
<span className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
|
511
|
+
<input
|
|
512
|
+
type="text"
|
|
513
|
+
inputMode="numeric"
|
|
514
|
+
aria-label={`Page, 1 to ${total}`}
|
|
515
|
+
value={draft ?? String(current)}
|
|
516
|
+
onFocus={(event) => event.currentTarget.select()}
|
|
517
|
+
onChange={(event) => setDraft(event.currentTarget.value.replace(/\D/g, ""))}
|
|
518
|
+
onBlur={commit}
|
|
519
|
+
onKeyDown={(event) => {
|
|
520
|
+
if (event.key === "Enter") commit();
|
|
521
|
+
if (event.key === "Escape") setDraft(null);
|
|
522
|
+
}}
|
|
523
|
+
// 16px on phones: iOS zooms the whole page into a smaller field.
|
|
524
|
+
className={cn(
|
|
525
|
+
fieldPresentation,
|
|
526
|
+
"h-8 w-11 rounded-md border px-1 text-center text-base tabular-nums sm:text-sm",
|
|
527
|
+
)}
|
|
528
|
+
/>
|
|
529
|
+
<span aria-hidden="true" className="whitespace-nowrap tabular-nums">
|
|
530
|
+
/ {total}
|
|
531
|
+
</span>
|
|
532
|
+
</span>
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function releaseCanvases(host: HTMLElement) {
|
|
537
|
+
// Zeroing the size frees the bitmap now; iOS otherwise holds it until
|
|
538
|
+
// garbage collection and stops drawing canvases once its budget is spent.
|
|
539
|
+
for (const canvas of host.querySelectorAll("canvas")) {
|
|
540
|
+
canvas.width = 0;
|
|
541
|
+
canvas.height = 0;
|
|
542
|
+
}
|
|
543
|
+
host.replaceChildren();
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const PdfPage = memo(function PdfPage({
|
|
547
|
+
doc,
|
|
548
|
+
pageNumber,
|
|
549
|
+
pageCount,
|
|
550
|
+
zoom,
|
|
551
|
+
placeholder,
|
|
552
|
+
root,
|
|
553
|
+
register,
|
|
554
|
+
}: {
|
|
555
|
+
doc: PDFDocumentProxy;
|
|
556
|
+
pageNumber: number;
|
|
557
|
+
pageCount: number;
|
|
558
|
+
zoom: number;
|
|
559
|
+
placeholder: { width: number; height: number };
|
|
560
|
+
root: HTMLElement | null;
|
|
561
|
+
register: (pageNumber: number, element: HTMLDivElement | null) => void;
|
|
562
|
+
}) {
|
|
563
|
+
const [page, setPage] = useState<PDFPageProxy | null>(null);
|
|
564
|
+
const [near, setNear] = useState(false);
|
|
565
|
+
const [failed, setFailed] = useState(false);
|
|
566
|
+
const boxRef = useRef<HTMLDivElement | null>(null);
|
|
567
|
+
const hostRef = useRef<HTMLDivElement>(null);
|
|
568
|
+
const scale = zoom * PDF_TO_CSS;
|
|
569
|
+
|
|
570
|
+
useEffect(() => {
|
|
571
|
+
let active = true;
|
|
572
|
+
doc.getPage(pageNumber).then(
|
|
573
|
+
(loaded) => active && setPage(loaded),
|
|
574
|
+
() => active && setFailed(true),
|
|
575
|
+
);
|
|
576
|
+
return () => {
|
|
577
|
+
active = false;
|
|
578
|
+
};
|
|
579
|
+
}, [doc, pageNumber]);
|
|
580
|
+
|
|
581
|
+
useEffect(() => {
|
|
582
|
+
const box = boxRef.current;
|
|
583
|
+
if (!box) return;
|
|
584
|
+
if (typeof IntersectionObserver === "undefined") {
|
|
585
|
+
setNear(true);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
// One screen above and below the visible area renders ahead of a scroll.
|
|
589
|
+
const observer = new IntersectionObserver(
|
|
590
|
+
([entry]) => setNear(entry?.isIntersecting ?? false),
|
|
591
|
+
{ root, rootMargin: "100% 0px" },
|
|
592
|
+
);
|
|
593
|
+
observer.observe(box);
|
|
594
|
+
return () => observer.disconnect();
|
|
595
|
+
}, [root]);
|
|
596
|
+
|
|
597
|
+
useEffect(() => {
|
|
598
|
+
const host = hostRef.current;
|
|
599
|
+
if (!host || !page) return;
|
|
600
|
+
if (!near) {
|
|
601
|
+
releaseCanvases(host);
|
|
602
|
+
page.cleanup();
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
const viewport = page.getViewport({ scale });
|
|
606
|
+
const ratio = Math.min(
|
|
607
|
+
window.devicePixelRatio || 1,
|
|
608
|
+
Math.sqrt(MAX_CANVAS_PIXELS / (viewport.width * viewport.height)),
|
|
609
|
+
);
|
|
610
|
+
// Draw into a new canvas and swap it in when complete, so a zoom shows the
|
|
611
|
+
// previous rendering stretched rather than a blank page.
|
|
612
|
+
const canvas = document.createElement("canvas");
|
|
613
|
+
canvas.width = Math.max(1, Math.floor(viewport.width * ratio));
|
|
614
|
+
canvas.height = Math.max(1, Math.floor(viewport.height * ratio));
|
|
615
|
+
canvas.style.cssText = "display:block;width:100%;height:100%";
|
|
616
|
+
canvas.setAttribute("aria-hidden", "true");
|
|
617
|
+
const task = page.render({
|
|
618
|
+
canvas,
|
|
619
|
+
viewport,
|
|
620
|
+
transform: ratio === 1 ? undefined : [ratio, 0, 0, ratio, 0, 0],
|
|
621
|
+
});
|
|
622
|
+
let finished = false;
|
|
623
|
+
task.promise.then(
|
|
624
|
+
() => {
|
|
625
|
+
finished = true;
|
|
626
|
+
releaseCanvases(host);
|
|
627
|
+
host.append(canvas);
|
|
628
|
+
setFailed(false);
|
|
629
|
+
},
|
|
630
|
+
(error: unknown) => {
|
|
631
|
+
finished = true;
|
|
632
|
+
canvas.width = 0;
|
|
633
|
+
canvas.height = 0;
|
|
634
|
+
if ((error as Error | null)?.name !== "RenderingCancelledException") setFailed(true);
|
|
635
|
+
},
|
|
636
|
+
);
|
|
637
|
+
return () => {
|
|
638
|
+
if (!finished) task.cancel();
|
|
639
|
+
};
|
|
640
|
+
}, [page, near, scale]);
|
|
641
|
+
|
|
642
|
+
useEffect(() => {
|
|
643
|
+
const host = hostRef.current;
|
|
644
|
+
return () => {
|
|
645
|
+
if (host) releaseCanvases(host);
|
|
646
|
+
};
|
|
647
|
+
}, []);
|
|
648
|
+
|
|
649
|
+
const box = useMemo(() => {
|
|
650
|
+
if (page) {
|
|
651
|
+
const { width, height } = page.getViewport({ scale });
|
|
652
|
+
return { width, height };
|
|
653
|
+
}
|
|
654
|
+
return { width: placeholder.width * zoom, height: placeholder.height * zoom };
|
|
655
|
+
}, [page, scale, zoom, placeholder]);
|
|
656
|
+
|
|
657
|
+
return (
|
|
658
|
+
<div
|
|
659
|
+
ref={(element) => {
|
|
660
|
+
boxRef.current = element;
|
|
661
|
+
register(pageNumber, element);
|
|
662
|
+
}}
|
|
663
|
+
role="img"
|
|
664
|
+
aria-label={`Page ${pageNumber} of ${pageCount}`}
|
|
665
|
+
data-page-number={pageNumber}
|
|
666
|
+
style={{ width: Math.round(box.width), height: Math.round(box.height) }}
|
|
667
|
+
className="relative shrink-0 overflow-hidden bg-card shadow-sm ring-1 ring-border"
|
|
668
|
+
>
|
|
669
|
+
<div ref={hostRef} className="absolute inset-0" />
|
|
670
|
+
{failed && (
|
|
671
|
+
<p className="absolute inset-0 flex items-center justify-center p-4 text-center text-sm text-muted-foreground">
|
|
672
|
+
Page {pageNumber} could not be displayed.
|
|
673
|
+
</p>
|
|
674
|
+
)}
|
|
675
|
+
</div>
|
|
676
|
+
);
|
|
677
|
+
});
|