@tangle-network/ui 11.24.0 → 11.26.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +22 -0
  3. package/dist/chat/agent-timeline.d.ts +13 -1
  4. package/dist/chat/agent-timeline.js +25 -4
  5. package/dist/chat/chat-message.d.ts +13 -1
  6. package/dist/chat/chat-message.js +9 -5
  7. package/dist/chat/message-author.d.ts +24 -0
  8. package/dist/chat/message-author.js +53 -0
  9. package/dist/chat/user-message.d.ts +12 -2
  10. package/dist/chat/user-message.js +52 -16
  11. package/dist/chat.d.ts +3 -1
  12. package/dist/chat.js +2 -1
  13. package/dist/editor/editor-peers.js +1 -21
  14. package/dist/files/file-card.d.ts +30 -0
  15. package/dist/files/file-card.js +162 -0
  16. package/dist/files/file-list.d.ts +25 -0
  17. package/dist/files/file-list.js +88 -0
  18. package/dist/files/file-preview.js +16 -62
  19. package/dist/files/pdf-loader.d.ts +22 -0
  20. package/dist/files/pdf-loader.js +82 -0
  21. package/dist/files/pdf-viewer.d.ts +32 -0
  22. package/dist/files/pdf-viewer.js +567 -0
  23. package/dist/files.d.ts +5 -1
  24. package/dist/files.js +5 -1
  25. package/dist/index.d.ts +9 -4
  26. package/dist/index.js +6 -1
  27. package/dist/lib/optional-peer.js +29 -0
  28. package/dist/primitives.d.ts +2 -2
  29. package/dist/types/message.d.ts +19 -0
  30. package/dist/types.d.ts +2 -2
  31. package/package.json +7 -2
  32. package/src/chat/agent-timeline.tsx +33 -3
  33. package/src/chat/chat-message.tsx +37 -10
  34. package/src/chat/index.ts +2 -0
  35. package/src/chat/message-author.stories.tsx +170 -0
  36. package/src/chat/message-author.test.tsx +189 -0
  37. package/src/chat/message-author.tsx +52 -0
  38. package/src/chat/user-message.tsx +63 -8
  39. package/src/editor/editor-peers.ts +3 -25
  40. package/src/files/file-artifact-pane.test.tsx +12 -2
  41. package/src/files/file-card.test.tsx +62 -0
  42. package/src/files/file-card.tsx +210 -0
  43. package/src/files/file-list.stories.tsx +121 -0
  44. package/src/files/file-list.test.tsx +81 -0
  45. package/src/files/file-list.tsx +167 -0
  46. package/src/files/file-preview.tsx +25 -94
  47. package/src/files/index.ts +9 -0
  48. package/src/files/pdf-loader.test.ts +130 -0
  49. package/src/files/pdf-loader.ts +169 -0
  50. package/src/files/pdf-viewer.stories.tsx +81 -0
  51. package/src/files/pdf-viewer.test.tsx +245 -0
  52. package/src/files/pdf-viewer.tsx +677 -0
  53. package/src/files/pdfjs-worker.d.ts +3 -0
  54. package/src/lib/optional-peer.ts +30 -0
  55. package/src/markdown/code-block.test.tsx +2 -1
  56. package/src/types/message.ts +20 -0
@@ -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
+ });
@@ -0,0 +1,3 @@
1
+ // pdfjs-dist ships no declarations for its worker module. pdf-loader.ts
2
+ // imports it only for its side effect of defining `globalThis.pdfjsWorker`.
3
+ declare module "pdfjs-dist/legacy/build/pdf.worker.min.mjs" {}