@elabs-ai/components-viewer 4.0.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 (140) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +143 -0
  3. package/dist/chunk-2NQ4RSJ3.js +18 -0
  4. package/dist/chunk-2NQ4RSJ3.js.map +1 -0
  5. package/dist/chunk-3U3MESBU.js +21 -0
  6. package/dist/chunk-3U3MESBU.js.map +1 -0
  7. package/dist/chunk-4OFQYALG.js +147 -0
  8. package/dist/chunk-4OFQYALG.js.map +1 -0
  9. package/dist/chunk-5OMONA2G.js +87 -0
  10. package/dist/chunk-5OMONA2G.js.map +1 -0
  11. package/dist/chunk-AORNMH77.js +58 -0
  12. package/dist/chunk-AORNMH77.js.map +1 -0
  13. package/dist/chunk-BCF3RWRL.js +18 -0
  14. package/dist/chunk-BCF3RWRL.js.map +1 -0
  15. package/dist/chunk-FELIAGKI.js +22 -0
  16. package/dist/chunk-FELIAGKI.js.map +1 -0
  17. package/dist/chunk-GGR7BNW4.js +18 -0
  18. package/dist/chunk-GGR7BNW4.js.map +1 -0
  19. package/dist/chunk-H3OAON3D.js +22 -0
  20. package/dist/chunk-H3OAON3D.js.map +1 -0
  21. package/dist/chunk-HMG2ERXH.js +17 -0
  22. package/dist/chunk-HMG2ERXH.js.map +1 -0
  23. package/dist/chunk-KOMDRWOU.js +21 -0
  24. package/dist/chunk-KOMDRWOU.js.map +1 -0
  25. package/dist/chunk-NMA57QZ7.js +117 -0
  26. package/dist/chunk-NMA57QZ7.js.map +1 -0
  27. package/dist/chunk-RGWDIQJR.js +26 -0
  28. package/dist/chunk-RGWDIQJR.js.map +1 -0
  29. package/dist/chunk-SLXRUYT3.js +91 -0
  30. package/dist/chunk-SLXRUYT3.js.map +1 -0
  31. package/dist/chunk-UJIJECEF.js +21 -0
  32. package/dist/chunk-UJIJECEF.js.map +1 -0
  33. package/dist/chunk-UL43NGUG.js +68 -0
  34. package/dist/chunk-UL43NGUG.js.map +1 -0
  35. package/dist/chunk-VO2273Z2.js +55 -0
  36. package/dist/chunk-VO2273Z2.js.map +1 -0
  37. package/dist/code-adapter-ADZ4UOGN.js +226 -0
  38. package/dist/code-adapter-ADZ4UOGN.js.map +1 -0
  39. package/dist/csv-adapter-6VU3FFVU.js +100 -0
  40. package/dist/csv-adapter-6VU3FFVU.js.map +1 -0
  41. package/dist/docx-adapter-5CQDHWTD.js +383 -0
  42. package/dist/docx-adapter-5CQDHWTD.js.map +1 -0
  43. package/dist/image-adapter-WOHZR24J.js +132 -0
  44. package/dist/image-adapter-WOHZR24J.js.map +1 -0
  45. package/dist/index.d.ts +1353 -0
  46. package/dist/index.js +1364 -0
  47. package/dist/index.js.map +1 -0
  48. package/dist/json-adapter-ZUW5GQHE.js +90 -0
  49. package/dist/json-adapter-ZUW5GQHE.js.map +1 -0
  50. package/dist/markdown-adapter-YC6WTBS4.js +214 -0
  51. package/dist/markdown-adapter-YC6WTBS4.js.map +1 -0
  52. package/dist/media-adapter-MCTB4GBH.js +78 -0
  53. package/dist/media-adapter-MCTB4GBH.js.map +1 -0
  54. package/dist/pdf-adapter-5PMKEXUD.js +424 -0
  55. package/dist/pdf-adapter-5PMKEXUD.js.map +1 -0
  56. package/dist/pptx-adapter-6GEQLS2Z.js +351 -0
  57. package/dist/pptx-adapter-6GEQLS2Z.js.map +1 -0
  58. package/dist/text-adapter-NFNWB5W3.js +81 -0
  59. package/dist/text-adapter-NFNWB5W3.js.map +1 -0
  60. package/dist/xlsx-adapter-CM2Y6AKQ.js +167 -0
  61. package/dist/xlsx-adapter-CM2Y6AKQ.js.map +1 -0
  62. package/package.json +103 -0
  63. package/src/adapters/code/code-adapter.test.tsx +221 -0
  64. package/src/adapters/code/code-adapter.tsx +272 -0
  65. package/src/adapters/code/code-language.test.ts +45 -0
  66. package/src/adapters/code/code-language.ts +100 -0
  67. package/src/adapters/code/code-manifest.ts +19 -0
  68. package/src/adapters/code/code-theme.test.ts +46 -0
  69. package/src/adapters/code/code-theme.ts +99 -0
  70. package/src/adapters/csv/csv-adapter.test.tsx +169 -0
  71. package/src/adapters/csv/csv-adapter.tsx +146 -0
  72. package/src/adapters/csv/csv-manifest.ts +20 -0
  73. package/src/adapters/docx/docx-adapter.test.tsx +192 -0
  74. package/src/adapters/docx/docx-adapter.tsx +328 -0
  75. package/src/adapters/docx/docx-manifest.ts +18 -0
  76. package/src/adapters/docx/docx-model.test.ts +181 -0
  77. package/src/adapters/docx/docx-model.ts +294 -0
  78. package/src/adapters/image/image-adapter.test.tsx +141 -0
  79. package/src/adapters/image/image-adapter.tsx +191 -0
  80. package/src/adapters/image/image-manifest.ts +15 -0
  81. package/src/adapters/index.ts +64 -0
  82. package/src/adapters/json/json-adapter.test.tsx +77 -0
  83. package/src/adapters/json/json-adapter.tsx +141 -0
  84. package/src/adapters/json/json-manifest.ts +11 -0
  85. package/src/adapters/markdown/markdown-adapter.test.tsx +228 -0
  86. package/src/adapters/markdown/markdown-adapter.tsx +353 -0
  87. package/src/adapters/markdown/markdown-manifest.ts +21 -0
  88. package/src/adapters/markdown/markdown-marks.test.ts +85 -0
  89. package/src/adapters/markdown/markdown-marks.ts +119 -0
  90. package/src/adapters/media/media-adapter.test.tsx +79 -0
  91. package/src/adapters/media/media-adapter.tsx +123 -0
  92. package/src/adapters/media/media-manifest.ts +17 -0
  93. package/src/adapters/office-fixture.ts +239 -0
  94. package/src/adapters/pdf/pdf-adapter.test.tsx +489 -0
  95. package/src/adapters/pdf/pdf-adapter.tsx +599 -0
  96. package/src/adapters/pdf/pdf-engine.ts +171 -0
  97. package/src/adapters/pdf/pdf-fixture.ts +27 -0
  98. package/src/adapters/pdf/pdf-manifest.ts +30 -0
  99. package/src/adapters/pdf/pdf-text.test.ts +171 -0
  100. package/src/adapters/pdf/pdf-text.ts +202 -0
  101. package/src/adapters/pptx/pptx-adapter.test.tsx +241 -0
  102. package/src/adapters/pptx/pptx-adapter.tsx +341 -0
  103. package/src/adapters/pptx/pptx-manifest.ts +19 -0
  104. package/src/adapters/pptx/pptx-model.test.ts +219 -0
  105. package/src/adapters/pptx/pptx-model.ts +248 -0
  106. package/src/adapters/text/text-adapter.test.tsx +125 -0
  107. package/src/adapters/text/text-adapter.tsx +117 -0
  108. package/src/adapters/text/text-manifest.ts +19 -0
  109. package/src/adapters/xlsx/xlsx-adapter.test.tsx +159 -0
  110. package/src/adapters/xlsx/xlsx-adapter.tsx +250 -0
  111. package/src/adapters/xlsx/xlsx-manifest.ts +26 -0
  112. package/src/components/grid-text.test.ts +68 -0
  113. package/src/components/grid-text.ts +77 -0
  114. package/src/components/marked-text.tsx +36 -0
  115. package/src/components/sheet-table.tsx +142 -0
  116. package/src/core/errors.ts +120 -0
  117. package/src/core/highlight-marks.test.ts +95 -0
  118. package/src/core/highlight-marks.ts +95 -0
  119. package/src/core/highlight-resolve.test.ts +208 -0
  120. package/src/core/highlight-resolve.ts +191 -0
  121. package/src/core/highlight.ts +125 -0
  122. package/src/core/registry.test.ts +172 -0
  123. package/src/core/registry.ts +188 -0
  124. package/src/core/scroll-host.ts +42 -0
  125. package/src/core/text-index.test.ts +95 -0
  126. package/src/core/text-index.ts +159 -0
  127. package/src/core/types.ts +285 -0
  128. package/src/core/use-highlight-scroll.ts +59 -0
  129. package/src/core/use-page-control.ts +47 -0
  130. package/src/core/use-paged-scroll.ts +194 -0
  131. package/src/core/use-viewport-size.ts +54 -0
  132. package/src/core/zoom.ts +56 -0
  133. package/src/file-viewer/file-viewer-context.tsx +239 -0
  134. package/src/file-viewer/file-viewer-find.tsx +204 -0
  135. package/src/file-viewer/file-viewer-pager.tsx +120 -0
  136. package/src/file-viewer/file-viewer-zoom.tsx +166 -0
  137. package/src/file-viewer/file-viewer.stories.tsx +947 -0
  138. package/src/file-viewer/file-viewer.test.tsx +947 -0
  139. package/src/file-viewer/file-viewer.tsx +1143 -0
  140. package/src/index.ts +157 -0
@@ -0,0 +1,599 @@
1
+ "use client";
2
+
3
+ /**
4
+ * PDF adapter — the format nothing in this repo could open before.
5
+ *
6
+ * ## Why this one renders a canvas, and why that is not a rule break
7
+ *
8
+ * "Adapters emit data, never HTML" is about CHROME: an adapter may not smuggle
9
+ * colours, fonts or borders past the token layer. A PDF page's pixels ARE the
10
+ * document — a PDF is a description of marks on a page, and reproducing it is
11
+ * the whole job. So the page is rasterized to a `<canvas>` in the file's own
12
+ * colours, while everything AROUND it (the pager, the zoom control, the page
13
+ * frame, the loading and error states) is brand-ui components on tokens.
14
+ *
15
+ * ## The text layer
16
+ *
17
+ * A canvas has no text, so a canvas-only PDF viewer is unusable: nothing to
18
+ * select, copy, search or read aloud. pdf.js exposes the text with per-item
19
+ * transforms, so we position transparent spans over the page. That is the same
20
+ * approach pdf.js's own viewer takes, and it is what makes the page selectable
21
+ * and reachable by a screen reader.
22
+ *
23
+ * ## Highlights are boxes, not marks
24
+ *
25
+ * Every other text adapter paints a `<mark>` around the cited characters. There
26
+ * is no text to wrap here — the words are pixels — so a citation is a rectangle
27
+ * over the raster, positioned from the page's own geometry (`pdf-text.ts`). The
28
+ * same layer draws a `rect` address, which is why this is the one adapter that
29
+ * can honour geometry a text pipeline never produced.
30
+ *
31
+ * ## The chrome is not here
32
+ *
33
+ * The pager and the zoom control used to live in this file, over `useState` no
34
+ * one outside the canvas could reach. They are now shell parts over provider
35
+ * state (ADR 0026): this renderer draws the page it is TOLD to, at the scale it
36
+ * is told to, and reports back what a fit mode resolved to. It keeps its own
37
+ * page only when mounted outside a provider.
38
+ *
39
+ * ## Pages scroll; they are not flipped
40
+ *
41
+ * The document is one continuous column of pages, virtualized over the shell's
42
+ * viewport (`usePagedScroll`). A pager that replaced the canvas made a PDF the
43
+ * only thing in the app you could not simply scroll, and it broke the two
44
+ * gestures readers actually use — skimming, and dragging the scrollbar to
45
+ * "about two thirds in". The page number is now a consequence of where the
46
+ * reader is, and typing one is a scroll.
47
+ */
48
+
49
+ import { cn, StatePanel, useLocale, type ResolvedFileSource } from "@elabs-ai/components-ui";
50
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
51
+
52
+ import { toViewerError } from "../../core/errors";
53
+ import type { ResolvedHighlight } from "../../core/highlight";
54
+ import { toMarkRanges, type MarkRanges } from "../../core/highlight-marks";
55
+ import { createTextIndexBuilder, type TextIndex } from "../../core/text-index";
56
+ import { useScrollActiveHighlightIntoView } from "../../core/use-highlight-scroll";
57
+ import { usePageControl } from "../../core/use-page-control";
58
+ import { usePagedScroll } from "../../core/use-paged-scroll";
59
+ import { useViewportSize } from "../../core/use-viewport-size";
60
+ import type {
61
+ AdapterDocument,
62
+ AdapterLoadContext,
63
+ AdapterModule,
64
+ AdapterRendererProps,
65
+ FileAdapter,
66
+ PageSize,
67
+ } from "../../core/types";
68
+ import { DEFAULT_ZOOM } from "../../core/zoom";
69
+ import {
70
+ openPdfDocument,
71
+ type PdfDocument,
72
+ type PdfPage,
73
+ type PdfSession,
74
+ type PdfTextItem,
75
+ } from "./pdf-engine";
76
+ import { pdfManifest } from "./pdf-manifest";
77
+ import {
78
+ PDF_PAGE_SEPARATOR,
79
+ pageOfHighlight,
80
+ pageTextChunk,
81
+ rangeBoxes,
82
+ rectBoxes,
83
+ type PdfHighlightBox,
84
+ type PdfTextSpan,
85
+ } from "./pdf-text";
86
+
87
+ /**
88
+ * Breathing room kept around a fitted page, in CSS pixels.
89
+ *
90
+ * Without it a fit-to-width page is exactly as wide as the viewport, and the
91
+ * page frame's own 1px border tips it over into a horizontal scrollbar — which
92
+ * narrows the viewport, which re-fits smaller, which removes the scrollbar.
93
+ */
94
+ const FIT_GUTTER = 16;
95
+
96
+ /** Sanity bounds on a fitted scale, so a degenerate measurement cannot blank the page. */
97
+ const MIN_SCALE = 0.1;
98
+ const MAX_SCALE = 10;
99
+
100
+ /**
101
+ * Pages whose text is extracted for the plain-text projection.
102
+ *
103
+ * Extraction costs one worker round-trip per page, so doing it for a 900-page
104
+ * document at load time would stall the first paint for something most readers
105
+ * never use. The pages a reader actually looks at get their text layer rendered
106
+ * on demand regardless — this cap only bounds the eager `document.text`.
107
+ */
108
+ export const PDF_TEXT_PAGE_LIMIT = 50;
109
+
110
+ export interface PdfViewerDocument extends AdapterDocument {
111
+ kind: "pdf";
112
+ /** The live pdf.js handle. Owned by the adapter — the renderer only reads it. */
113
+ handle: PdfDocument;
114
+ pageCount: number;
115
+ /** Page number per stretch of the projection, so a citation can turn the page. */
116
+ textIndex?: TextIndex<number>;
117
+ }
118
+
119
+ /** Vertical space between two pages in the scrolling column, in CSS pixels. */
120
+ const PAGE_GAP = 16;
121
+
122
+ /** The page frame's own 1px border, top and bottom — part of the reserved height. */
123
+ const PAGE_BORDER = 2;
124
+
125
+ /**
126
+ * Height assumed for a page whose size was never measured, in CSS pixels.
127
+ *
128
+ * Only reached by a document with no extractable page at all; every real one
129
+ * contributes at least page 1 to `pageSizes`. US Letter at 96dpi.
130
+ */
131
+ const FALLBACK_PAGE_HEIGHT = 1056;
132
+
133
+ class PdfAdapter implements FileAdapter {
134
+ #session?: PdfSession;
135
+
136
+ async load(source: ResolvedFileSource, context: AdapterLoadContext): Promise<PdfViewerDocument> {
137
+ let buffer: ArrayBuffer;
138
+ try {
139
+ buffer = await source.bytes(context.signal);
140
+ } catch (error) {
141
+ throw toViewerError(error, "read-failed", { fileName: source.name });
142
+ }
143
+
144
+ try {
145
+ // pdf.js takes ownership of the buffer it is handed (it transfers it to
146
+ // the worker), so the view is built here rather than reusing the source's
147
+ // memoized copy in place.
148
+ const session = await openPdfDocument(new Uint8Array(buffer), context.signal);
149
+ this.#session = session;
150
+ const handle = session.document;
151
+ const { textIndex, pageSizes } = await extractText(handle, context.signal);
152
+ return {
153
+ kind: "pdf",
154
+ handle,
155
+ pageCount: handle.numPages,
156
+ pageSizes,
157
+ text: textIndex?.text,
158
+ textIndex,
159
+ // So a passage the shell cannot find in a 900-page report reads as "past
160
+ // the pages we extracted" rather than "not in this document".
161
+ textTruncated: handle.numPages > PDF_TEXT_PAGE_LIMIT || undefined,
162
+ };
163
+ } catch (error) {
164
+ // A password-protected or corrupt file lands here; the engine failing to
165
+ // resolve is caught upstream and becomes `parser-missing`.
166
+ throw toViewerError(error, "parse-failed", { fileName: source.name });
167
+ }
168
+ }
169
+
170
+ dispose(): void {
171
+ // Tears down the worker and its transferred buffers. Skipping this leaks a
172
+ // worker per document opened, which a file browser hits within minutes.
173
+ // The teardown lives on the SESSION, not on the document — see `PdfSession`.
174
+ void this.#session?.destroy();
175
+ this.#session = undefined;
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Best-effort plain text, plus the page each stretch of it came from — and each
181
+ * page's size, which the same loop already has in hand.
182
+ *
183
+ * Never throws: a document whose text will not extract still renders — the
184
+ * reader loses search and citations, not the file. Whatever was extracted before
185
+ * the failure is kept, because half a projection still answers half the queries.
186
+ *
187
+ * The index is built with the SAME join rule the renderer re-applies per page
188
+ * (`pageTextChunk`), so an offset in the projection and a box on the page can
189
+ * never disagree about which item they mean.
190
+ *
191
+ * The sizes are collected here rather than in their own pass because this loop
192
+ * has already paid for `getPage` — a second pass over a 900-page document would
193
+ * be 900 worker round-trips for numbers we are holding.
194
+ */
195
+ async function extractText(
196
+ handle: PdfDocument,
197
+ signal?: AbortSignal,
198
+ ): Promise<{ textIndex?: TextIndex<number>; pageSizes?: PageSize[] }> {
199
+ const pages = Math.min(handle.numPages, PDF_TEXT_PAGE_LIMIT);
200
+ const builder = createTextIndexBuilder<number>({ separator: PDF_PAGE_SEPARATOR });
201
+ const pageSizes: PageSize[] = [];
202
+ try {
203
+ for (let pageNumber = 1; pageNumber <= pages; pageNumber += 1) {
204
+ if (signal?.aborted) break;
205
+ const page = await handle.getPage(pageNumber);
206
+ const { width, height } = page.getViewport({ scale: 1 });
207
+ pageSizes.push({ width, height });
208
+ const content = await page.getTextContent();
209
+ builder.push(pageTextChunk(content.items).text, pageNumber);
210
+ }
211
+ } catch {
212
+ // Fall through: return what we have.
213
+ }
214
+ const index = builder.build();
215
+ return {
216
+ textIndex: index.text.length > 0 ? index : undefined,
217
+ pageSizes: pageSizes.length > 0 ? pageSizes : undefined,
218
+ };
219
+ }
220
+
221
+ /* -------------------------------------------------------------------------- */
222
+ /* Renderer */
223
+ /* -------------------------------------------------------------------------- */
224
+
225
+ function PdfRenderer({
226
+ document: doc,
227
+ source,
228
+ className,
229
+ highlights,
230
+ activeHighlightId,
231
+ pageNumber: pageNumberProp,
232
+ onPageChange,
233
+ zoom = DEFAULT_ZOOM,
234
+ onZoomResolved,
235
+ }: AdapterRendererProps) {
236
+ const pdf = doc as PdfViewerDocument;
237
+
238
+ const [pageNumber, goToPage] = usePageControl(pageNumberProp, onPageChange, pdf.pageCount);
239
+
240
+ const listRef = useRef<HTMLDivElement>(null);
241
+ const viewport = useViewportSize(listRef);
242
+
243
+ /**
244
+ * A page's size at scale 1.
245
+ *
246
+ * Falls back to the first page for anything past what the adapter measured
247
+ * (`AdapterDocument.pageSizes` may be shorter than the page count). Uniform
248
+ * page size is overwhelmingly the common case, and a wrong guess costs a
249
+ * corrected scrollbar, not a wrong render.
250
+ */
251
+ const naturalAt = useCallback(
252
+ (index: number): PageSize | undefined => pdf.pageSizes?.[index] ?? pdf.pageSizes?.[0],
253
+ [pdf.pageSizes],
254
+ );
255
+
256
+ // A fit mode is a REQUEST the renderer resolves — it is the only party that
257
+ // knows how wide its viewport is (`AdapterRendererProps.zoom`). Fitted to the
258
+ // page the reader is ON, so a landscape page in a portrait document does not
259
+ // re-fit the whole column.
260
+ const natural = naturalAt(pageNumber - 1);
261
+ const scale = useMemo(() => {
262
+ if (typeof zoom === "number") return zoom;
263
+ // Until the page and the pane have both been measured, 100% is a better
264
+ // first frame than a guess that jumps.
265
+ if (!natural || !viewport) return DEFAULT_ZOOM;
266
+ const byWidth = (viewport.width - FIT_GUTTER) / natural.width;
267
+ const fitted =
268
+ zoom === "fit-width"
269
+ ? byWidth
270
+ : Math.min(byWidth, (viewport.height - FIT_GUTTER) / natural.height);
271
+ return Math.min(MAX_SCALE, Math.max(MIN_SCALE, fitted));
272
+ }, [zoom, natural, viewport]);
273
+
274
+ // Report what the fit became, so the chrome can show a percentage and the
275
+ // reader's next "zoom in" steps from what is on screen.
276
+ useEffect(() => {
277
+ onZoomResolved?.(scale);
278
+ }, [scale, onZoomResolved]);
279
+
280
+ const estimateSize = useCallback(
281
+ (index: number) =>
282
+ Math.round((naturalAt(index)?.height ?? FALLBACK_PAGE_HEIGHT) * scale) +
283
+ PAGE_BORDER +
284
+ PAGE_GAP,
285
+ [naturalAt, scale],
286
+ );
287
+
288
+ const virtualizer = usePagedScroll({
289
+ count: pdf.pageCount,
290
+ listRef,
291
+ pageNumber,
292
+ goToPage,
293
+ estimateSize,
294
+ sizeKey: scale,
295
+ });
296
+
297
+ // LOCATE happened in the shell; the per-page views below do MAP and PAINT.
298
+ // Computed once here because it is a function of the whole projection, not of
299
+ // any one page.
300
+ const marks = useMemo(() => toMarkRanges(highlights, pdf.text?.length ?? 0), [highlights, pdf]);
301
+
302
+ // Scrolling to the cited page is the first half of "take me there"; the mark's
303
+ // own `scrollIntoView`, inside the page view, is the second. Keyed on the page
304
+ // NUMBER, so a reader who scrolls away while the same citation stays active is
305
+ // not dragged back.
306
+ const activePage = pageOfHighlight(
307
+ highlights?.find((highlight) => highlight.id === activeHighlightId),
308
+ pdf.textIndex,
309
+ );
310
+ useEffect(() => {
311
+ if (activePage === undefined || activePage < 1 || activePage > pdf.pageCount) return;
312
+ goToPage(activePage);
313
+ }, [activePage, pdf.pageCount, goToPage]);
314
+
315
+ return (
316
+ // No viewport of its own: `FileViewerContent` is the one scroll boundary
317
+ // (`viewer-components.md`), and this column is a child of it. The height is
318
+ // the whole document's, so the scrollbar is honest from the first frame
319
+ // rather than growing as pages arrive.
320
+ <div
321
+ ref={listRef}
322
+ data-slot="pdf-pages"
323
+ className={cn("relative w-full", className)}
324
+ style={{ height: virtualizer.getTotalSize() }}
325
+ >
326
+ {virtualizer.getVirtualItems().map((item) => (
327
+ <div
328
+ key={item.key}
329
+ data-index={item.index}
330
+ ref={virtualizer.measureElement}
331
+ className="absolute inset-x-0 top-0"
332
+ style={{
333
+ paddingBottom: PAGE_GAP,
334
+ transform: `translateY(${item.start - virtualizer.options.scrollMargin}px)`,
335
+ }}
336
+ >
337
+ <PdfPageView
338
+ handle={pdf.handle}
339
+ pageNumber={item.index + 1}
340
+ natural={naturalAt(item.index)}
341
+ scale={scale}
342
+ alt={source.alt}
343
+ highlights={highlights}
344
+ activeHighlightId={activeHighlightId}
345
+ marks={marks}
346
+ pageStart={pdf.textIndex?.spans.find((span) => span.ref === item.index + 1)?.start}
347
+ />
348
+ </div>
349
+ ))}
350
+ </div>
351
+ );
352
+ }
353
+
354
+ interface PdfPageViewProps {
355
+ handle: PdfDocument;
356
+ /** 1-based, as everything on the wire is. */
357
+ pageNumber: number;
358
+ /** This page at scale 1, when the adapter measured it. Reserves the box. */
359
+ natural?: PageSize;
360
+ scale: number;
361
+ alt?: string;
362
+ highlights?: readonly ResolvedHighlight[];
363
+ activeHighlightId?: string | null;
364
+ marks: MarkRanges;
365
+ /** Where this page's text begins in the projection, if it was extracted. */
366
+ pageStart?: number;
367
+ }
368
+
369
+ /**
370
+ * One page: the raster, the transparent text over it, and any citation boxes.
371
+ *
372
+ * A component per page rather than one effect over the visible range, so that
373
+ * mounting and unmounting IS the lifecycle — a page scrolled out of the window
374
+ * cancels its own render task and releases its own pdf.js page, with no
375
+ * bookkeeping to get wrong.
376
+ */
377
+ function PdfPageView({
378
+ handle,
379
+ pageNumber,
380
+ natural,
381
+ scale,
382
+ alt,
383
+ highlights,
384
+ activeHighlightId,
385
+ marks,
386
+ pageStart,
387
+ }: PdfPageViewProps) {
388
+ const { t, formatNumber } = useLocale();
389
+
390
+ const [size, setSize] = useState<{ width: number; height: number } | undefined>(
391
+ natural && { width: natural.width * scale, height: natural.height * scale },
392
+ );
393
+ const [spans, setSpans] = useState<PdfTextSpan[]>([]);
394
+ const [items, setItems] = useState<PdfTextItem[]>([]);
395
+ const [failed, setFailed] = useState<string>();
396
+
397
+ const pageRef = useRef<HTMLDivElement>(null);
398
+ const canvasRef = useRef<HTMLCanvasElement>(null);
399
+
400
+ useEffect(() => {
401
+ let cancelled = false;
402
+ let task: ReturnType<PdfPage["render"]> | undefined;
403
+ let page: PdfPage | undefined;
404
+
405
+ void (async () => {
406
+ try {
407
+ page = await handle.getPage(pageNumber);
408
+ if (cancelled) return;
409
+
410
+ // Rasterize at the device pixel ratio, then present at CSS size — a 1:1
411
+ // canvas is visibly soft on every retina display.
412
+ const ratio = typeof window === "undefined" ? 1 : (window.devicePixelRatio ?? 1);
413
+ const viewport = page.getViewport({ scale: scale * ratio });
414
+ const cssViewport = page.getViewport({ scale });
415
+ const canvas = canvasRef.current;
416
+ const context = canvas?.getContext("2d");
417
+ if (!canvas || !context) return;
418
+
419
+ canvas.width = Math.floor(viewport.width);
420
+ canvas.height = Math.floor(viewport.height);
421
+ setSize({ width: cssViewport.width, height: cssViewport.height });
422
+
423
+ task = page.render({ canvasContext: context, viewport });
424
+ await task.promise;
425
+ if (cancelled) return;
426
+
427
+ const content = await page.getTextContent();
428
+ if (cancelled) return;
429
+ setItems(content.items);
430
+ setSpans(toTextSpans(content.items, cssViewport.height, scale));
431
+ setFailed(undefined);
432
+ } catch (error) {
433
+ // A cancelled render is the normal outcome of scrolling quickly; only a
434
+ // settled failure is news (loading-states.md).
435
+ if (cancelled) return;
436
+ const message = error instanceof Error ? error.message : String(error);
437
+ if (/cancel/i.test(message)) return;
438
+ setFailed(message);
439
+ }
440
+ })();
441
+
442
+ return () => {
443
+ cancelled = true;
444
+ task?.cancel();
445
+ page?.cleanup();
446
+ };
447
+ }, [handle, pageNumber, scale]);
448
+
449
+ // MAP + PAINT. Turning the shell's offsets into rectangles needs this page's
450
+ // own items, which the effect above already fetched for the text layer.
451
+ const boxes = useMemo<PdfHighlightBox[]>(() => {
452
+ if (!size) return [];
453
+ // A `rect` address is already geometry — it only has to be on this page.
454
+ // `page` defaults to 1 so a single-page citation needs no page number.
455
+ const geometry = (highlights ?? []).filter(
456
+ (highlight) =>
457
+ highlight.status === "resolved" &&
458
+ highlight.rects !== undefined &&
459
+ (highlight.page ?? 1) === pageNumber,
460
+ );
461
+ return [
462
+ ...(pageStart === undefined ? [] : rangeBoxes(spans, pageTextChunk(items), pageStart, marks)),
463
+ ...rectBoxes(geometry, size),
464
+ ];
465
+ }, [size, highlights, pageNumber, pageStart, spans, items, marks]);
466
+
467
+ // The boxes arrive a tick after the page does (the text content is fetched
468
+ // asynchronously), so the scroll waits for them rather than for the id.
469
+ useScrollActiveHighlightIntoView(pageRef, activeHighlightId, boxes);
470
+
471
+ if (failed) {
472
+ return (
473
+ <div
474
+ className="mx-auto flex w-fit items-center justify-center"
475
+ style={size ? { width: size.width, height: size.height } : undefined}
476
+ >
477
+ <StatePanel
478
+ kind="error"
479
+ title={t("viewer.error.parseFailed")}
480
+ description={t("viewer.pdf.pageFailed", {
481
+ page: formatNumber(pageNumber),
482
+ })}
483
+ />
484
+ </div>
485
+ );
486
+ }
487
+
488
+ return (
489
+ <div
490
+ ref={pageRef}
491
+ data-slot="pdf-page"
492
+ data-page={pageNumber}
493
+ className="border-border relative mx-auto w-fit border shadow-sm"
494
+ style={size ? { width: size.width, height: size.height } : undefined}
495
+ >
496
+ <canvas
497
+ ref={canvasRef}
498
+ // The page's own description, so a screen reader gets what the document
499
+ // is rather than "canvas". The text layer below carries the words
500
+ // themselves. Every page names its own number, which is what makes a
501
+ // continuous column navigable by heading-less landmark browsing.
502
+ role="img"
503
+ aria-label={alt ?? t("viewer.pdf.page", { page: formatNumber(pageNumber) })}
504
+ className="block"
505
+ style={size ? { width: size.width, height: size.height } : undefined}
506
+ />
507
+ {/* Transparent, selectable text over the raster. `select-text` and the
508
+ transforms come from pdf.js's own item geometry. */}
509
+ <div aria-hidden={spans.length === 0} className="absolute inset-0 select-text">
510
+ {spans.map((span, index) => (
511
+ <span
512
+ key={index}
513
+ className="absolute origin-top-left whitespace-pre text-transparent"
514
+ style={{
515
+ left: span.left,
516
+ top: span.top,
517
+ fontSize: span.fontSize,
518
+ lineHeight: 1,
519
+ }}
520
+ >
521
+ {span.text}
522
+ </span>
523
+ ))}
524
+ </div>
525
+ {/* Decorative: the words themselves are in the text layer above, and the
526
+ shell's status line is what names the citation. A plate here would
527
+ otherwise be read out as an unlabelled region.
528
+
529
+ Translucent rather than a solid plate, because the "ink" it sits on is
530
+ the raster below it — an opaque mark would delete the very sentence it
531
+ is pointing at. The current box is not distinguished by colour alone
532
+ (WCAG 1.4.1): it is also drawn twice as thick. */}
533
+ <div aria-hidden="true" className="pointer-events-none absolute inset-0">
534
+ {boxes.map((box, index) => (
535
+ <div
536
+ key={index}
537
+ data-slot="highlight-rect"
538
+ // Presence form, matching every other painter's `data-active`.
539
+ data-active={box.active ? "" : undefined}
540
+ className={cn(
541
+ "absolute rounded-xs",
542
+ box.active
543
+ ? "bg-highlight-active/30 border-highlight-active border-2"
544
+ : "bg-highlight/35 border-highlight border",
545
+ )}
546
+ style={{
547
+ left: box.left,
548
+ top: box.top,
549
+ width: box.width,
550
+ height: box.height,
551
+ }}
552
+ />
553
+ ))}
554
+ </div>
555
+ </div>
556
+ );
557
+ }
558
+
559
+ /**
560
+ * pdf.js text geometry → CSS boxes.
561
+ *
562
+ * Item transforms are `[a, b, c, d, e, f]` in PDF user space, whose origin is
563
+ * the BOTTOM-left of the page; CSS measures from the top. `d` carries the glyph
564
+ * height, which is the font size once scaled.
565
+ *
566
+ * Whitespace-only items are dropped — they would be invisible clutter in the
567
+ * text layer — so each span carries the index of the item it came from. That is
568
+ * what lets a highlight box find its geometry after the filtering, without
569
+ * either side counting positions.
570
+ */
571
+ export function toTextSpans(
572
+ items: { str: string; transform: number[]; width?: number }[],
573
+ pageHeight: number,
574
+ scale: number,
575
+ ): PdfTextSpan[] {
576
+ const spans: PdfTextSpan[] = [];
577
+ items.forEach((item, index) => {
578
+ if (!item.str.trim()) return;
579
+ const [, , , d = 0, e = 0, f = 0] = item.transform;
580
+ const fontSize = Math.abs(d) * scale;
581
+ spans.push({
582
+ text: item.str,
583
+ left: e * scale,
584
+ top: pageHeight - f * scale - fontSize,
585
+ width: (item.width ?? 0) * scale,
586
+ fontSize,
587
+ item: index,
588
+ });
589
+ });
590
+ return spans;
591
+ }
592
+
593
+ const adapterModule: AdapterModule = {
594
+ manifest: pdfManifest,
595
+ create: () => new PdfAdapter(),
596
+ Renderer: PdfRenderer,
597
+ };
598
+
599
+ export default adapterModule;