@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,1143 @@
1
+ "use client";
2
+
3
+ /**
4
+ * `FileViewer` — the compound shell.
5
+ *
6
+ * Every part is composed from `@elabs-ai/components-ui` primitives;
7
+ * this package contributes file LOGIC (detection, parsing, the page model) and
8
+ * never a parallel widget set. There is no `showToolbar` boolean: compose the
9
+ * parts you want, or render `<FileViewer>` for the batteries-included default.
10
+ *
11
+ * Surface separation (`styling-and-tokens.md`): the frame is one raised `card`
12
+ * with a border, the toolbar is divided from the content by a single `border-b`
13
+ * — the sole structural cue between two same-fill regions, so it takes the
14
+ * strong rung.
15
+ */
16
+
17
+ import {
18
+ Button,
19
+ cn,
20
+ downloadBlob,
21
+ fileIconFor,
22
+ IconButton,
23
+ normalizeFileSource,
24
+ normalizeQuoteTextWithOffsets,
25
+ queryToRanges,
26
+ resolveFileKind,
27
+ Separator,
28
+ Skeleton,
29
+ StatePanel,
30
+ Text,
31
+ useLocale,
32
+ type FileSource,
33
+ type ProseHeadingLevel,
34
+ } from "@elabs-ai/components-ui";
35
+ import { DownloadIcon, EyeOffIcon, SearchXIcon } from "lucide-react";
36
+ import {
37
+ forwardRef,
38
+ useCallback,
39
+ useDeferredValue,
40
+ useEffect,
41
+ useMemo,
42
+ useRef,
43
+ useState,
44
+ type HTMLAttributes,
45
+ type KeyboardEvent as ReactKeyboardEvent,
46
+ type ReactNode,
47
+ } from "react";
48
+
49
+ import { createDefaultRegistry } from "../adapters";
50
+ import {
51
+ ViewerError,
52
+ isAbort,
53
+ isModuleNotFound,
54
+ parserMissingError,
55
+ toViewerError,
56
+ type ViewerErrorCode,
57
+ } from "../core/errors";
58
+ import {
59
+ FIND_MATCH_LIMIT,
60
+ findMatchId,
61
+ type DocumentHighlight,
62
+ type HighlightSupport,
63
+ type ResolvedHighlight,
64
+ } from "../core/highlight";
65
+ import { resolveHighlights } from "../core/highlight-resolve";
66
+ import type { ViewerRegistry } from "../core/registry";
67
+ import type { DocumentRotation, ZoomLevel } from "../core/types";
68
+ import { DEFAULT_ZOOM, stepZoom } from "../core/zoom";
69
+ import { FileViewerFind, isFindShortcut } from "./file-viewer-find";
70
+ import { FileViewerPager } from "./file-viewer-pager";
71
+ import { FileViewerRotate, FileViewerZoom } from "./file-viewer-zoom";
72
+ import {
73
+ FileViewerContext,
74
+ useFileViewer,
75
+ type FileViewerContextValue,
76
+ type FileViewerFindState,
77
+ type FileViewerLoadState,
78
+ } from "./file-viewer-context";
79
+
80
+ /** Stable empties, so a provider with no citations does not re-render on identity. */
81
+ const NO_HIGHLIGHTS: readonly DocumentHighlight[] = [];
82
+ const NO_RESOLVED: readonly ResolvedHighlight[] = [];
83
+ const NO_SUPPORT: HighlightSupport = [];
84
+
85
+ /* -------------------------------------------------------------------------- */
86
+ /* Provider */
87
+ /* -------------------------------------------------------------------------- */
88
+
89
+ export interface FileViewerProviderProps {
90
+ /** The file to show. `undefined` is the empty state, not an error. */
91
+ source?: FileSource;
92
+ /**
93
+ * Adapters available to this viewer. Defaults to the built-ins.
94
+ * Pass your own to add a format, drop one, or override a built-in.
95
+ */
96
+ registry?: ViewerRegistry;
97
+ /**
98
+ * Force the not-ready state while a parent fetches the source itself.
99
+ * ORed with the viewer's own loading — a parent can add loading, never remove it.
100
+ */
101
+ loading?: boolean;
102
+ /**
103
+ * The rung a viewed document's own top-level heading renders at. Default `2`.
104
+ *
105
+ * A file carries its OWN heading tree, and that tree is only correct relative
106
+ * to the page hosting it: a README's `#` rendered as an `<h1>` inside an app
107
+ * that already has one puts two `h1`s in a screen reader's flat heading list,
108
+ * and the frame's `<section aria-label>` does not fix that — most screen
109
+ * readers list headings flat, not per landmark. The default assumes the
110
+ * common case, a viewer embedded BELOW the page's own heading; pass `1` when
111
+ * the viewer genuinely is the page.
112
+ *
113
+ * Adapters that render no headings ignore it. Same seam as
114
+ * `@elabs-ai/components-ai`'s `MarkdownView baseHeadingLevel`.
115
+ */
116
+ baseHeadingLevel?: ProseHeadingLevel;
117
+ /**
118
+ * The parts of the document to point at — an answer's citations, a search
119
+ * result's context, anything the app already knows about the file.
120
+ *
121
+ * A PROP rather than provider-only state because citations originate outside
122
+ * the viewer entirely: the chat pane that produced them usually lives in
123
+ * another route, and it owns which one the reader clicked. Pass
124
+ * `defaultHighlights` instead to let the viewer own them.
125
+ */
126
+ highlights?: readonly DocumentHighlight[];
127
+ /** Uncontrolled initial citations. Ignored when `highlights` is supplied. */
128
+ defaultHighlights?: readonly DocumentHighlight[];
129
+ onHighlightsChange?: (highlights: readonly DocumentHighlight[]) => void;
130
+ /**
131
+ * Which citation the viewer is pointed at. `null` is "none" explicitly;
132
+ * `undefined` is what selects uncontrolled mode, so the two are not
133
+ * interchangeable here.
134
+ */
135
+ activeHighlightId?: string | null;
136
+ defaultActiveHighlightId?: string | null;
137
+ onActiveHighlightChange?: (id: string | null) => void;
138
+ /**
139
+ * Which page the viewer is on, 1-based — controlled.
140
+ *
141
+ * A trio (`component-api.md`) rather than provider-only state because the page
142
+ * is routinely something the APP owns: a deep link to page 7, a URL the reader
143
+ * can share, a position restored from a "continue reading" record. Clamped to
144
+ * the document on read, so an out-of-range value degrades to the nearest real
145
+ * page instead of blanking the canvas.
146
+ */
147
+ pageNumber?: number;
148
+ /** Uncontrolled initial page. Ignored when `pageNumber` is supplied. */
149
+ defaultPageNumber?: number;
150
+ onPageNumberChange?: (page: number) => void;
151
+ /**
152
+ * The scale to draw at, or a fit mode — controlled. Persisting a reader's
153
+ * preferred zoom across files and sessions is the reason this is a prop.
154
+ */
155
+ zoom?: ZoomLevel;
156
+ /**
157
+ * Uncontrolled initial zoom. Ignored when `zoom` is supplied.
158
+ *
159
+ * Defaults to `"fit-width"`, not `1`: a viewer's job on open is to show the
160
+ * document, and a 4000px scan or an A4 page at 100% in a 600px pane shows its
161
+ * top-left corner. Every reader-facing PDF viewer opens fitted for the same
162
+ * reason. Pass `1` for true 100%.
163
+ */
164
+ defaultZoom?: ZoomLevel;
165
+ onZoomChange?: (zoom: ZoomLevel) => void;
166
+ /** Quarter-turns clockwise — controlled. */
167
+ rotation?: DocumentRotation;
168
+ /** Uncontrolled initial rotation. Ignored when `rotation` is supplied. Default `0`. */
169
+ defaultRotation?: DocumentRotation;
170
+ onRotationChange?: (rotation: DocumentRotation) => void;
171
+ children: ReactNode;
172
+ }
173
+
174
+ /** Matches for the current query, plus whether the cap swallowed any. */
175
+ interface FindMatches {
176
+ highlights: readonly DocumentHighlight[];
177
+ total: number;
178
+ truncated: boolean;
179
+ }
180
+
181
+ const NO_MATCHES: FindMatches = { highlights: NO_HIGHLIGHTS, total: 0, truncated: false };
182
+
183
+ export function FileViewerProvider({
184
+ source,
185
+ registry: registryProp,
186
+ loading = false,
187
+ baseHeadingLevel = 2,
188
+ highlights: highlightsProp,
189
+ defaultHighlights,
190
+ onHighlightsChange,
191
+ activeHighlightId: activeHighlightIdProp,
192
+ defaultActiveHighlightId = null,
193
+ onActiveHighlightChange,
194
+ pageNumber: pageNumberProp,
195
+ defaultPageNumber = 1,
196
+ onPageNumberChange,
197
+ zoom: zoomProp,
198
+ defaultZoom = "fit-width",
199
+ onZoomChange,
200
+ rotation: rotationProp,
201
+ defaultRotation = 0,
202
+ onRotationChange,
203
+ children,
204
+ }: FileViewerProviderProps) {
205
+ // A default registry per provider, not per module: one screen's `register()`
206
+ // override must not leak into another's.
207
+ const fallbackRegistry = useMemo(() => createDefaultRegistry(), []);
208
+ const registry = registryProp ?? fallbackRegistry;
209
+
210
+ const [attempt, setAttempt] = useState(0);
211
+ const [state, setState] = useState<FileViewerLoadState>({ status: "empty", capabilities: {} });
212
+
213
+ const resolved = useMemo(() => (source ? normalizeFileSource(source) : undefined), [source]);
214
+
215
+ useEffect(() => {
216
+ if (!resolved) {
217
+ setState({ status: "empty", capabilities: {} });
218
+ return;
219
+ }
220
+
221
+ const controller = new AbortController();
222
+ let instance: { dispose?: () => void } | undefined;
223
+ let cancelled = false;
224
+
225
+ const kind = resolveFileKind(resolved.name, resolved.mediaType);
226
+ const manifest = registry.detect(kind);
227
+
228
+ // Capabilities come from the eager manifest, so the chrome is correct from
229
+ // the first frame — before a single byte of parser is fetched.
230
+ setState({
231
+ status: "loading",
232
+ source: resolved,
233
+ capabilities: manifest?.capabilities ?? {},
234
+ });
235
+
236
+ void (async () => {
237
+ try {
238
+ if (!manifest) {
239
+ throw new ViewerError("unsupported-format", `No adapter can open "${resolved.name}".`, {
240
+ fileName: resolved.name,
241
+ });
242
+ }
243
+ const adapter = await registry.load(manifest.id);
244
+ // A fresh instance per document — adapters may hold per-document state.
245
+ const parser = adapter.create();
246
+ instance = parser;
247
+ const document = await parser.load(resolved, { signal: controller.signal });
248
+ if (cancelled) return;
249
+ setState({
250
+ status: "ready",
251
+ source: resolved,
252
+ document,
253
+ adapter,
254
+ capabilities: manifest.capabilities ?? {},
255
+ });
256
+ } catch (error) {
257
+ // A cancelled load is a superseded one — reporting it would flash an
258
+ // error every time the user picks a different file.
259
+ if (cancelled || isAbort(error)) return;
260
+ // Every parser engine is reached by a dynamic `import()` INSIDE the
261
+ // adapter's own `load()`, so "the optional peer is not installed" lands
262
+ // here, not on `registry.load()` above. Falling through to `parse-failed`
263
+ // would tell the reader their file is damaged and offer a retry that can
264
+ // never succeed.
265
+ const missingPeer =
266
+ isModuleNotFound(error) && manifest
267
+ ? parserMissingError(manifest.id, manifest.requires ?? [], {
268
+ fileName: resolved.name,
269
+ cause: error,
270
+ })
271
+ : undefined;
272
+ setState({
273
+ status: "error",
274
+ source: resolved,
275
+ capabilities: manifest?.capabilities ?? {},
276
+ error: missingPeer ?? toViewerError(error, "parse-failed", { fileName: resolved.name }),
277
+ });
278
+ }
279
+ })();
280
+
281
+ return () => {
282
+ cancelled = true;
283
+ controller.abort();
284
+ instance?.dispose?.();
285
+ // Releases any object URL the source minted for this load.
286
+ resolved.revoke();
287
+ };
288
+ }, [resolved, registry, attempt]);
289
+
290
+ /* ---- Citations: a prop the app may control, or the viewer's own --------- */
291
+
292
+ const highlightsControlled = highlightsProp !== undefined;
293
+ const [ownHighlights, setOwnHighlights] = useState(defaultHighlights ?? NO_HIGHLIGHTS);
294
+ const highlights = highlightsControlled ? highlightsProp : ownHighlights;
295
+ const setHighlights = useCallback(
296
+ (next: readonly DocumentHighlight[]) => {
297
+ // Mirroring the platform: a controlled value is never written locally, so
298
+ // the component can't drift from the prop that owns it.
299
+ if (!highlightsControlled) setOwnHighlights(next);
300
+ onHighlightsChange?.(next);
301
+ },
302
+ [highlightsControlled, onHighlightsChange],
303
+ );
304
+
305
+ const activeControlled = activeHighlightIdProp !== undefined;
306
+ const [ownActiveId, setOwnActiveId] = useState<string | null>(defaultActiveHighlightId);
307
+ const activeHighlightId = activeControlled ? activeHighlightIdProp : ownActiveId;
308
+ const setActiveHighlight = useCallback(
309
+ (id: string | null) => {
310
+ if (!activeControlled) setOwnActiveId(id);
311
+ onActiveHighlightChange?.(id);
312
+ },
313
+ [activeControlled, onActiveHighlightChange],
314
+ );
315
+
316
+ /* ---- View: which page, at what scale, turned which way ------------------ */
317
+
318
+ const pageCount = state.document?.pageCount ?? 0;
319
+
320
+ const pageControlled = pageNumberProp !== undefined;
321
+ const [ownPage, setOwnPage] = useState(defaultPageNumber);
322
+ const rawPage = pageControlled ? pageNumberProp : ownPage;
323
+ // Clamped on READ. Writing a clamped value back at a controlled owner would
324
+ // fight it, and a document whose page count shrank (a different file, same
325
+ // provider) must not blank the canvas while the owner catches up.
326
+ const pageNumber =
327
+ pageCount > 0 ? Math.min(Math.max(1, rawPage), pageCount) : Math.max(1, rawPage);
328
+ const goToPage = useCallback(
329
+ (page: number) => {
330
+ const next = Math.max(1, Math.round(page));
331
+ if (!pageControlled) setOwnPage(next);
332
+ onPageNumberChange?.(next);
333
+ },
334
+ [pageControlled, onPageNumberChange],
335
+ );
336
+
337
+ const zoomControlled = zoomProp !== undefined;
338
+ const [ownZoom, setOwnZoom] = useState<ZoomLevel>(defaultZoom);
339
+ const zoom = zoomControlled ? zoomProp : ownZoom;
340
+ const setZoom = useCallback(
341
+ (next: ZoomLevel) => {
342
+ if (!zoomControlled) setOwnZoom(next);
343
+ onZoomChange?.(next);
344
+ },
345
+ [zoomControlled, onZoomChange],
346
+ );
347
+
348
+ // What a fit mode actually became. Only the renderer can know it — it is the
349
+ // one measuring its viewport — so this is a report, not a derivation.
350
+ const [reportedZoom, setReportedZoom] = useState(DEFAULT_ZOOM);
351
+ const effectiveZoom = typeof zoom === "number" ? zoom : reportedZoom;
352
+
353
+ const rotationControlled = rotationProp !== undefined;
354
+ const [ownRotation, setOwnRotation] = useState<DocumentRotation>(defaultRotation);
355
+ const rotation = rotationControlled ? rotationProp : ownRotation;
356
+ const setRotation = useCallback(
357
+ (next: DocumentRotation) => {
358
+ if (!rotationControlled) setOwnRotation(next);
359
+ onRotationChange?.(next);
360
+ },
361
+ [rotationControlled, onRotationChange],
362
+ );
363
+
364
+ // Opening a DIFFERENT file starts at its first page, the right way up. Zoom
365
+ // deliberately survives: a reader who zoomed in is reading at that size, and
366
+ // snapping back to 100% on every file fights them.
367
+ //
368
+ // Compared against the previous source rather than run on mount, so a
369
+ // `defaultPageNumber` (deep link, restored position) is not clobbered by its
370
+ // own first render.
371
+ const previousSource = useRef(resolved);
372
+ useEffect(() => {
373
+ if (previousSource.current === resolved) return;
374
+ previousSource.current = resolved;
375
+ setOwnPage(1);
376
+ setOwnRotation(0);
377
+ }, [resolved]);
378
+
379
+ /* ---- Find-in-document: entirely the viewer's own ------------------------ */
380
+
381
+ const [find, setFind] = useState({
382
+ open: false,
383
+ query: "",
384
+ caseSensitive: false,
385
+ activeIndex: 0,
386
+ });
387
+
388
+ // Whether a `FileViewerFind` part is actually composed into this viewer.
389
+ //
390
+ // The frame swallows Ctrl/Cmd+F, and swallowing it without a box to show is
391
+ // strictly worse than not intercepting at all: the reader loses the browser's
392
+ // own find and gets nothing in return. The parts are composable by design, so
393
+ // "the adapter could paint a match" is not the same question as "there is
394
+ // somewhere to type" — the find part answers the second by registering itself.
395
+ const [findParts, setFindParts] = useState(0);
396
+ const registerFind = useCallback(() => {
397
+ setFindParts((count) => count + 1);
398
+ return () => setFindParts((count) => count - 1);
399
+ }, []);
400
+
401
+ const text = state.document?.text;
402
+ const capabilities = state.capabilities;
403
+ const support = capabilities.highlight ?? NO_SUPPORT;
404
+
405
+ // The declared `search` flag is an override in the OFF direction only. Read
406
+ // as the source of truth it would deny the find box to PDF — the format
407
+ // readers expect it on most — purely because that manifest predates the
408
+ // feature. Read as "can this adapter paint what find produces" it is right by
409
+ // construction: find emits `range` addresses, so `range` support is the bar.
410
+ const canFind = (capabilities.search ?? true) && text !== undefined && support.includes("range");
411
+
412
+ // Deferred so a keystroke paints immediately and the (potentially large)
413
+ // match scan lands a frame later, instead of blocking the caret.
414
+ const query = useDeferredValue(find.query);
415
+ const findMatches = useMemo<FindMatches>(() => {
416
+ if (!find.open || !canFind || text === undefined || query.length === 0) return NO_MATCHES;
417
+ // Deliberately NOT run through `normalizeRanges`: it merges ADJACENT
418
+ // ranges, which is right for a fuzzy matcher painting one contiguous mark
419
+ // and wrong here — searching "l" in "hello" finds two matches, and merging
420
+ // them into one would make the counter disagree with what a reader counts.
421
+ // A single needle's matches never overlap, so there is nothing to merge.
422
+ const ranges = queryToRanges(text, query, find.caseSensitive);
423
+ return {
424
+ highlights: ranges.slice(0, FIND_MATCH_LIMIT).map(([start, end], index) => ({
425
+ id: findMatchId(index),
426
+ address: { kind: "range" as const, start, end },
427
+ source: "search" as const,
428
+ })),
429
+ total: ranges.length,
430
+ truncated: ranges.length > FIND_MATCH_LIMIT,
431
+ };
432
+ }, [find.open, find.caseSensitive, canFind, text, query]);
433
+
434
+ /* ---- Locate: the one step that runs outside the adapter ----------------- */
435
+
436
+ // Folded once per document, not once per keystroke: find re-resolves on every
437
+ // character, and folding a 2 MB projection each time is what turns a search
438
+ // box into a stutter.
439
+ const normalized = useMemo(
440
+ () => (text === undefined ? undefined : normalizeQuoteTextWithOffsets(text)),
441
+ [text],
442
+ );
443
+
444
+ // Two things can claim to be "the current one": a citation the app pointed at,
445
+ // and the match the reader is stepping through. The precedence is stated once,
446
+ // here — while the find box is open and matching, find wins, because it is what
447
+ // the reader's own keystrokes are moving. Leaving both active would paint the
448
+ // reader's match like every other match while a citation kept the active plate.
449
+ const activeFindId =
450
+ find.open && findMatches.highlights.length > 0 ? findMatchId(find.activeIndex) : undefined;
451
+
452
+ /**
453
+ * The id the RENDERER is pointed at — the effective one, not the citation knob.
454
+ *
455
+ * A renderer that owns a pager or a tab strip navigates on this, and the shared
456
+ * scroll hook fires on it. Handing it `state.activeHighlightId` instead would
457
+ * leave find-in-document unable to scroll to, page to, or switch sheet to its
458
+ * own match whenever no citation happened to be active.
459
+ */
460
+ const currentHighlightId = activeFindId ?? activeHighlightId;
461
+
462
+ const resolvedHighlights = useMemo<readonly ResolvedHighlight[]>(() => {
463
+ if (highlights.length === 0 && findMatches.highlights.length === 0) return NO_RESOLVED;
464
+ return resolveHighlights([...highlights, ...findMatches.highlights], {
465
+ normalized,
466
+ textLength: text?.length,
467
+ truncated: state.document?.textTruncated,
468
+ supported: support,
469
+ activeId: currentHighlightId,
470
+ });
471
+ }, [
472
+ highlights,
473
+ findMatches.highlights,
474
+ normalized,
475
+ text?.length,
476
+ state.document?.textTruncated,
477
+ support,
478
+ currentHighlightId,
479
+ ]);
480
+
481
+ /* ---- Stepping ---------------------------------------------------------- */
482
+
483
+ const stepCitation = useCallback(
484
+ (delta: 1 | -1) => {
485
+ const list = resolvedHighlights.filter(
486
+ (highlight) => highlight.source === "citation" && highlight.status === "resolved",
487
+ );
488
+ if (list.length === 0) return;
489
+ const at = list.findIndex((highlight) => highlight.id === activeHighlightId);
490
+ // Nothing active yet: "next" starts at the top of the document and
491
+ // "previous" at the bottom, rather than both landing on the first.
492
+ const to =
493
+ at === -1 ? (delta === 1 ? 0 : list.length - 1) : (at + delta + list.length) % list.length;
494
+ setActiveHighlight((list[to] as ResolvedHighlight).id);
495
+ },
496
+ [resolvedHighlights, activeHighlightId, setActiveHighlight],
497
+ );
498
+
499
+ const stepFind = useCallback(
500
+ (delta: 1 | -1) => {
501
+ const count = findMatches.highlights.length;
502
+ if (count === 0) return;
503
+ setFind((current) => ({
504
+ ...current,
505
+ activeIndex: (current.activeIndex + delta + count) % count,
506
+ }));
507
+ },
508
+ [findMatches.highlights.length],
509
+ );
510
+
511
+ const findState = useMemo<FileViewerFindState>(
512
+ () => ({
513
+ open: find.open,
514
+ query: find.query,
515
+ caseSensitive: find.caseSensitive,
516
+ matches: findMatches.total,
517
+ truncated: findMatches.truncated,
518
+ activeIndex: find.activeIndex,
519
+ }),
520
+ [find, findMatches.total, findMatches.truncated],
521
+ );
522
+
523
+ const actions = useMemo(
524
+ () => ({
525
+ reload: () => setAttempt((n) => n + 1),
526
+ setHighlights,
527
+ setActiveHighlight,
528
+ nextHighlight: () => stepCitation(1),
529
+ previousHighlight: () => stepCitation(-1),
530
+ openFind: () => setFind((current) => ({ ...current, open: true })),
531
+ // The query survives a close, so re-opening resumes where the reader was;
532
+ // only the box goes away.
533
+ closeFind: () => setFind((current) => ({ ...current, open: false })),
534
+ setFindQuery: (next: string) =>
535
+ setFind((current) => ({ ...current, query: next, activeIndex: 0 })),
536
+ setFindCaseSensitive: (next: boolean) =>
537
+ setFind((current) => ({ ...current, caseSensitive: next, activeIndex: 0 })),
538
+ nextFindMatch: () => stepFind(1),
539
+ previousFindMatch: () => stepFind(-1),
540
+ goToPage,
541
+ // Stop at the ends rather than wrap: a document is not a carousel, and a
542
+ // reader who holds "next" past the last page expects to stay there.
543
+ nextPage: () => goToPage(Math.min(pageNumber + 1, pageCount || pageNumber + 1)),
544
+ previousPage: () => goToPage(pageNumber - 1),
545
+ setZoom,
546
+ // Stepping from `effectiveZoom`, not from `zoom`: after a fit the ladder
547
+ // has to continue from what is actually on screen.
548
+ zoomIn: () => setZoom(stepZoom(effectiveZoom, 1)),
549
+ zoomOut: () => setZoom(stepZoom(effectiveZoom, -1)),
550
+ setRotation,
551
+ rotate: (quarterTurns: 1 | -1) =>
552
+ setRotation(((((rotation + quarterTurns * 90) % 360) + 360) % 360) as DocumentRotation),
553
+ reportZoom: setReportedZoom,
554
+ registerFind,
555
+ }),
556
+ [
557
+ setHighlights,
558
+ setActiveHighlight,
559
+ stepCitation,
560
+ stepFind,
561
+ registerFind,
562
+ goToPage,
563
+ pageNumber,
564
+ pageCount,
565
+ setZoom,
566
+ effectiveZoom,
567
+ setRotation,
568
+ rotation,
569
+ ],
570
+ );
571
+
572
+ const value = useMemo<FileViewerContextValue>(
573
+ () => ({
574
+ state: {
575
+ // A parent's `loading` can add the not-ready state but never clear a
576
+ // real error — losing a failure to a stale prop is worse than a late
577
+ // spinner.
578
+ ...(loading && state.status !== "error" ? { ...state, status: "loading" as const } : state),
579
+ highlights,
580
+ activeHighlightId,
581
+ find: findState,
582
+ pageNumber,
583
+ pageCount,
584
+ zoom,
585
+ effectiveZoom,
586
+ rotation,
587
+ },
588
+ actions,
589
+ registry,
590
+ meta: {
591
+ baseHeadingLevel,
592
+ resolvedHighlights,
593
+ currentHighlightId,
594
+ highlightSupport: support,
595
+ canFind,
596
+ hasFind: findParts > 0,
597
+ },
598
+ }),
599
+ [
600
+ state,
601
+ loading,
602
+ registry,
603
+ baseHeadingLevel,
604
+ highlights,
605
+ activeHighlightId,
606
+ findState,
607
+ actions,
608
+ resolvedHighlights,
609
+ currentHighlightId,
610
+ support,
611
+ canFind,
612
+ findParts,
613
+ pageNumber,
614
+ pageCount,
615
+ zoom,
616
+ effectiveZoom,
617
+ rotation,
618
+ ],
619
+ );
620
+
621
+ return <FileViewerContext value={value}>{children}</FileViewerContext>;
622
+ }
623
+
624
+ /* -------------------------------------------------------------------------- */
625
+ /* Frame */
626
+ /* -------------------------------------------------------------------------- */
627
+
628
+ export type FileViewerFrameProps = HTMLAttributes<HTMLDivElement>;
629
+
630
+ /**
631
+ * The bordered surface the toolbar and content sit in, and the scope of the
632
+ * viewer's find shortcut.
633
+ *
634
+ * Ctrl/Cmd+F is handled HERE rather than on `document`: a page may hold several
635
+ * viewers, or a viewer beside an editor that has its own find, and a
636
+ * document-level listener would let whichever mounted last win. Bound to the
637
+ * frame, the shortcut belongs to whichever viewer the reader is actually inside,
638
+ * and the browser's own find is untouched everywhere else on the page.
639
+ */
640
+ export const FileViewerFrame = forwardRef<HTMLDivElement, FileViewerFrameProps>(
641
+ function FileViewerFrame({ className, children, onKeyDown, ...props }, ref) {
642
+ const { state, actions, meta } = useFileViewer();
643
+ const { t } = useLocale();
644
+ const frame = useRef<HTMLElement | null>(null);
645
+ const wasOpen = useRef(false);
646
+
647
+ // Closing the box must hand the caret back, or a keyboard reader is left
648
+ // with focus on nothing at the top of the document.
649
+ const findOpen = state.find.open && meta.canFind;
650
+ useEffect(() => {
651
+ if (wasOpen.current && !findOpen) {
652
+ frame.current
653
+ ?.querySelector<HTMLElement>('[data-slot="file-viewer-content"]')
654
+ ?.focus({ preventScroll: true });
655
+ }
656
+ wasOpen.current = findOpen;
657
+ }, [findOpen]);
658
+
659
+ return (
660
+ <section
661
+ ref={(node) => {
662
+ frame.current = node;
663
+ if (typeof ref === "function") ref(node as HTMLDivElement | null);
664
+ else if (ref) ref.current = node as HTMLDivElement | null;
665
+ }}
666
+ data-slot="file-viewer"
667
+ aria-label={t("viewer.label")}
668
+ className={cn(
669
+ "bg-card text-card-foreground border-border flex h-full min-h-0 flex-col overflow-hidden rounded-lg border shadow-sm",
670
+ className,
671
+ )}
672
+ onKeyDown={(event) => {
673
+ // The frame renders a `<section>` while its public props type says
674
+ // `HTMLDivElement` — a pre-existing signature. One cast here beats
675
+ // widening the exported ref type and breaking every caller's
676
+ // `useRef<HTMLDivElement>`.
677
+ onKeyDown?.(event as ReactKeyboardEvent<HTMLDivElement>);
678
+ // Only intercept the browser's shortcut when this viewer can actually
679
+ // honour it — otherwise the reader loses their browser find and gets
680
+ // nothing back. Both halves are needed: an adapter that can paint a
681
+ // match (`canFind`) AND a find part composed in to type into
682
+ // (`hasFind`). A hand-composed frame that omits the part keeps the
683
+ // browser's own find, which is the right answer for it.
684
+ if (!event.defaultPrevented && meta.canFind && meta.hasFind && isFindShortcut(event)) {
685
+ event.preventDefault();
686
+ actions.openFind();
687
+ }
688
+ }}
689
+ {...props}
690
+ >
691
+ {children}
692
+ </section>
693
+ );
694
+ },
695
+ );
696
+
697
+ /* -------------------------------------------------------------------------- */
698
+ /* Toolbar */
699
+ /* -------------------------------------------------------------------------- */
700
+
701
+ export interface FileViewerToolbarProps extends HTMLAttributes<HTMLDivElement> {
702
+ /** Extra controls, placed after the built-in actions. */
703
+ actions?: ReactNode;
704
+ }
705
+
706
+ /**
707
+ * The identity row: glyph, name, actions.
708
+ *
709
+ * No `role="toolbar"` — that role promises roving-tabindex arrow-key navigation,
710
+ * which this row does not implement. The same decision `ViewToolbar` made, and
711
+ * the reason the P1 `Toolbar` primitive exists (ADR 0024 §5a).
712
+ *
713
+ * With no file it renders NOTHING: a row holding a generic glyph and a blank
714
+ * name reads as a broken render, not as chrome. A screen that needs a permanent
715
+ * header composes its own row around `FileViewerFrame` — that is what the parts
716
+ * are for.
717
+ */
718
+ export const FileViewerToolbar = forwardRef<HTMLDivElement, FileViewerToolbarProps>(
719
+ function FileViewerToolbar({ className, actions, children, ...props }, ref) {
720
+ const { state } = useFileViewer();
721
+ const { t } = useLocale();
722
+ const source = state.source;
723
+ const Glyph = fileIconFor(source?.name ?? "", source?.mediaType);
724
+
725
+ if (!source) return null;
726
+
727
+ const download = () => {
728
+ void source.bytes().then((bytes) => {
729
+ downloadBlob(new Blob([bytes], { type: source.mediaType }), source.name);
730
+ });
731
+ };
732
+
733
+ return (
734
+ <div
735
+ ref={ref}
736
+ data-slot="file-viewer-toolbar"
737
+ className={cn(
738
+ // The divider is the ONLY cue between toolbar and content (same fill,
739
+ // no elevation change) — WCAG 1.4.11, so the strong rung.
740
+ "border-border-strong flex shrink-0 items-center gap-2 border-b px-3 py-2",
741
+ className,
742
+ )}
743
+ {...props}
744
+ >
745
+ <Glyph aria-hidden="true" className="text-muted-foreground size-4 shrink-0" />
746
+ {/* min-w-0 is what actually lets the name truncate inside a flex row. */}
747
+ <Text className="min-w-0 flex-1 truncate" title={source.name}>
748
+ {source.name}
749
+ </Text>
750
+ {children}
751
+ {actions}
752
+ <Separator orientation="vertical" className="h-4" />
753
+ {/* `label` is IconButton's single source of truth — it becomes both the
754
+ accessible name and the tooltip, so the two cannot drift. */}
755
+ <IconButton
756
+ variant="ghost"
757
+ label={t("viewer.download", { name: source.name })}
758
+ icon={<DownloadIcon aria-hidden="true" />}
759
+ onClick={download}
760
+ />
761
+ </div>
762
+ );
763
+ },
764
+ );
765
+
766
+ /* -------------------------------------------------------------------------- */
767
+ /* Highlight status */
768
+ /* -------------------------------------------------------------------------- */
769
+
770
+ export type FileViewerHighlightStatusProps = HTMLAttributes<HTMLDivElement>;
771
+
772
+ /**
773
+ * "We couldn't find that passage."
774
+ *
775
+ * A citation that fails to locate must not fail SILENTLY: the reader clicked a
776
+ * source link and got a document that looks untouched, with no way to tell
777
+ * whether the viewer is broken, the passage moved, or they mis-clicked. The
778
+ * request survives resolution precisely so this line has something to say.
779
+ *
780
+ * Three different pieces of news, three sentences:
781
+ * - `not-found` / `absent` — searched the whole projection; it is not in it.
782
+ * - `not-found` / `truncated` — the projection is capped, so it may lie past it.
783
+ * - `unsupported` — a CAPABILITY GAP: this build cannot point at part of that
784
+ * format. Not the reader's fault and not retryable, same call the error panel
785
+ * already makes for `unsupported-format`.
786
+ *
787
+ * Search misses are excluded: the find bar already counts its own matches, and
788
+ * "No matches" there says it better than a second line here would.
789
+ */
790
+ export const FileViewerHighlightStatus = forwardRef<HTMLDivElement, FileViewerHighlightStatusProps>(
791
+ function FileViewerHighlightStatus({ className, ...props }, ref) {
792
+ const { state, meta } = useFileViewer();
793
+ const { t } = useLocale();
794
+
795
+ const missed = meta.resolvedHighlights.filter(
796
+ (highlight) =>
797
+ highlight.source === "citation" &&
798
+ (highlight.status === "not-found" || highlight.status === "unsupported"),
799
+ );
800
+ if (missed.length === 0) return null;
801
+
802
+ const first = missed[0] as ResolvedHighlight;
803
+ const message =
804
+ first.status === "unsupported"
805
+ ? t("viewer.highlight.unsupported", {
806
+ format: state.source?.extension.toUpperCase() || "",
807
+ })
808
+ : first.reason === "truncated"
809
+ ? t("viewer.highlight.notFoundTruncated")
810
+ : t("viewer.highlight.notFound");
811
+
812
+ return (
813
+ <div
814
+ ref={ref}
815
+ data-slot="file-viewer-highlight-status"
816
+ role="status"
817
+ aria-live="polite"
818
+ className={cn(
819
+ // Information, not a failure — a neutral muted row, never the
820
+ // destructive tone. The divider is the sole cue between it and the
821
+ // content below (WCAG 1.4.11, strong rung).
822
+ "border-border-strong text-muted-foreground flex shrink-0 items-center gap-2 border-b px-3 py-2",
823
+ className,
824
+ )}
825
+ {...props}
826
+ >
827
+ <SearchXIcon aria-hidden="true" className="size-4 shrink-0" />
828
+ <span className="text-meta min-w-0 flex-1 truncate">{message}</span>
829
+ </div>
830
+ );
831
+ },
832
+ );
833
+
834
+ /* -------------------------------------------------------------------------- */
835
+ /* States */
836
+ /* -------------------------------------------------------------------------- */
837
+
838
+ /**
839
+ * A layout-shaped skeleton, not a spinner: it occupies the box the real content
840
+ * will, so nothing shifts when the file arrives (`loading-states.md`).
841
+ * `aria-hidden` because `Skeleton` is decorative — the single live region on the
842
+ * wrapper is what AT hears.
843
+ */
844
+ export const FileViewerSkeleton = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
845
+ function FileViewerSkeleton({ className, ...props }, ref) {
846
+ const { state } = useFileViewer();
847
+ const { t } = useLocale();
848
+ return (
849
+ <div
850
+ ref={ref}
851
+ data-slot="file-viewer-skeleton"
852
+ role="status"
853
+ aria-live="polite"
854
+ className={cn("flex h-full flex-col gap-3 p-4", className)}
855
+ {...props}
856
+ >
857
+ <span className="sr-only">{t("viewer.loading", { name: state.source?.name ?? "" })}</span>
858
+ <Skeleton aria-hidden="true" className="h-4 w-2/5" />
859
+ <Skeleton aria-hidden="true" className="h-4 w-4/5" />
860
+ <Skeleton aria-hidden="true" className="h-4 w-3/5" />
861
+ <Skeleton aria-hidden="true" className="min-h-24 flex-1" />
862
+ </div>
863
+ );
864
+ },
865
+ );
866
+
867
+ /** Message keys per failure code — the code is the contract, the prose is not. */
868
+ const ERROR_MESSAGES: Record<ViewerErrorCode, { title: string; body: string }> = {
869
+ "unsupported-format": {
870
+ title: "viewer.error.unsupportedFormat",
871
+ body: "viewer.error.unsupportedFormatBody",
872
+ },
873
+ "parser-missing": {
874
+ title: "viewer.error.parserMissing",
875
+ body: "viewer.error.parserMissingBody",
876
+ },
877
+ "read-failed": { title: "viewer.error.readFailed", body: "viewer.error.readFailedBody" },
878
+ "parse-failed": { title: "viewer.error.parseFailed", body: "viewer.error.parseFailedBody" },
879
+ // Neither should ever reach the UI: a protocol mismatch throws at registration
880
+ // and an abort is swallowed as a superseded load. Mapped anyway so the panel
881
+ // can never render an empty title.
882
+ "protocol-mismatch": { title: "viewer.error.parseFailed", body: "viewer.error.parseFailedBody" },
883
+ aborted: { title: "viewer.error.readFailed", body: "viewer.error.readFailedBody" },
884
+ };
885
+
886
+ /**
887
+ * A gap in what this build can SHOW — the file is fine, we just cannot draw it.
888
+ * Neither is retryable, and neither is the user's fault, so both are presented
889
+ * as information rather than as a failure (see `FileViewerError`).
890
+ */
891
+ const CAPABILITY_GAPS = new Set<ViewerErrorCode>(["unsupported-format", "parser-missing"]);
892
+
893
+ export const FileViewerError = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
894
+ function FileViewerError({ className, ...props }, ref) {
895
+ const { state } = useFileViewer();
896
+ const { t } = useLocale();
897
+ const error = state.error;
898
+ if (!error) return null;
899
+
900
+ const message = ERROR_MESSAGES[error.code];
901
+ const vars = {
902
+ name: state.source?.name ?? "",
903
+ format: state.source?.extension.toUpperCase() || t("viewer.error.parseFailed"),
904
+ packages: error.packages?.join(", ") ?? "",
905
+ };
906
+
907
+ // "This build can't draw PDFs" and "the network dropped" are different news.
908
+ // A red alarm on the first one blames the reader for a capability we never
909
+ // shipped, so the gap gets a neutral panel and `status`; a real failure keeps
910
+ // the destructive panel and the `alert` StatePanel already sets.
911
+ const isGap = CAPABILITY_GAPS.has(error.code);
912
+
913
+ return (
914
+ <div
915
+ ref={ref}
916
+ data-slot="file-viewer-error"
917
+ // min-h-full (not h-full) so a tall panel grows and scrolls instead of
918
+ // being clipped at the top by `justify-center`.
919
+ className={cn("flex min-h-full flex-col justify-center p-4", className)}
920
+ {...(isGap ? { role: "status" as const, "aria-live": "polite" as const } : {})}
921
+ {...props}
922
+ >
923
+ <StatePanel
924
+ kind={isGap ? "empty" : "error"}
925
+ // A dashed edge invites a drop; this panel accepts nothing. Solid.
926
+ className={isGap ? "border-solid" : undefined}
927
+ icon={isGap ? <EyeOffIcon aria-hidden="true" /> : undefined}
928
+ title={t(message.title)}
929
+ description={t(message.body, vars)}
930
+ // `unsupported-format` and `parser-missing` are not retryable — the
931
+ // file has not changed and neither has what is installed. Offering
932
+ // "Try again" there teaches users the button does nothing.
933
+ actions={
934
+ error.code === "read-failed" || error.code === "parse-failed" ? (
935
+ <RetryButton />
936
+ ) : undefined
937
+ }
938
+ />
939
+ </div>
940
+ );
941
+ },
942
+ );
943
+
944
+ /**
945
+ * The real `Button`, not a styled `<button>`.
946
+ *
947
+ * A hand-rolled `text-primary` link measured 4.14:1 against `StatePanel`'s error
948
+ * wash and failed axe — the brand hue is tuned against `--background`/`--card`,
949
+ * not against a tinted panel. `outline` puts the label on ordinary ink over its
950
+ * own surface, which is contrast-safe on any panel and gives the control a
951
+ * visible hit target besides.
952
+ */
953
+ function RetryButton() {
954
+ const { actions } = useFileViewer();
955
+ const { t } = useLocale();
956
+ return (
957
+ <Button variant="outline" size="sm" onClick={actions.reload}>
958
+ {t("viewer.retry")}
959
+ </Button>
960
+ );
961
+ }
962
+
963
+ export const FileViewerEmpty = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
964
+ function FileViewerEmpty({ className, ...props }, ref) {
965
+ const { t } = useLocale();
966
+ return (
967
+ <div
968
+ ref={ref}
969
+ data-slot="file-viewer-empty"
970
+ className={cn("flex min-h-full flex-col justify-center p-4", className)}
971
+ {...props}
972
+ >
973
+ <StatePanel kind="empty" title={t("viewer.empty")} description={t("viewer.emptyBody")} />
974
+ </div>
975
+ );
976
+ },
977
+ );
978
+
979
+ /* -------------------------------------------------------------------------- */
980
+ /* Content */
981
+ /* -------------------------------------------------------------------------- */
982
+
983
+ export type FileViewerContentProps = HTMLAttributes<HTMLDivElement>;
984
+
985
+ /**
986
+ * The state switch: empty · loading · error · the adapter's own renderer.
987
+ *
988
+ * The adapter supplies its `Renderer` alongside its parser, so this component
989
+ * never grows a per-format `switch` — that is what keeps formats additive.
990
+ *
991
+ * **This is THE scroll boundary** for any adapter that does not manage its own
992
+ * viewport. Two nested `overflow-auto` boxes do not compose: the inner one clips
993
+ * while the outer one's padding stays put, so a long document ends flush against
994
+ * a band of whitespace and reads as a failed render rather than as "scroll for
995
+ * more". Adapters whose content simply flows (text, code, markdown, Word) let
996
+ * this scroll; the ones with a fixed sub-control and a scrolling body of their
997
+ * own — PDF pages, PowerPoint slides, a sheet under its tab bar — keep theirs
998
+ * and label it the same way.
999
+ *
1000
+ * It is also a **focusable, named region**: a pane that scrolls but contains
1001
+ * nothing focusable cannot be reached from a keyboard at all (WCAG 2.1.1), and
1002
+ * a plain-text file contains nothing focusable by definition.
1003
+ */
1004
+ export const FileViewerContent = forwardRef<HTMLDivElement, FileViewerContentProps>(
1005
+ function FileViewerContent({ className, ...props }, ref) {
1006
+ const { state, actions, meta } = useFileViewer();
1007
+ const { t } = useLocale();
1008
+
1009
+ return (
1010
+ <div
1011
+ ref={ref}
1012
+ data-slot="file-viewer-content"
1013
+ role="region"
1014
+ aria-label={t("viewer.content")}
1015
+ tabIndex={0}
1016
+ className={cn(
1017
+ "focus-visible:ring-ring min-h-0 flex-1 overflow-auto p-4 focus-visible:outline-none focus-visible:ring-2",
1018
+ className,
1019
+ )}
1020
+ {...props}
1021
+ >
1022
+ {state.status === "empty" && <FileViewerEmpty />}
1023
+ {state.status === "loading" && <FileViewerSkeleton />}
1024
+ {state.status === "error" && <FileViewerError />}
1025
+ {state.status === "ready" && state.adapter && state.document && state.source && (
1026
+ <state.adapter.Renderer
1027
+ document={state.document}
1028
+ source={state.source}
1029
+ baseHeadingLevel={meta.baseHeadingLevel}
1030
+ highlights={meta.resolvedHighlights}
1031
+ // `meta.currentHighlightId`, NOT `state.activeHighlightId`: the
1032
+ // renderer must follow the find match too, or find can never scroll,
1033
+ // page or switch sheet.
1034
+ activeHighlightId={meta.currentHighlightId}
1035
+ // The view half (ADR 0026). A renderer that ignores these keeps
1036
+ // working — they are optional, and a format with no pages or no
1037
+ // scaling never reads them.
1038
+ pageNumber={state.pageNumber}
1039
+ onPageChange={actions.goToPage}
1040
+ zoom={state.zoom}
1041
+ onZoomResolved={actions.reportZoom}
1042
+ rotation={state.rotation}
1043
+ />
1044
+ )}
1045
+ </div>
1046
+ );
1047
+ },
1048
+ );
1049
+
1050
+ /* -------------------------------------------------------------------------- */
1051
+ /* Batteries-included */
1052
+ /* -------------------------------------------------------------------------- */
1053
+
1054
+ export interface FileViewerProps
1055
+ extends
1056
+ Omit<FileViewerProviderProps, "children">,
1057
+ Omit<HTMLAttributes<HTMLDivElement>, "children"> {
1058
+ /** Replace the default composition. Rendered inside the provider AND the frame. */
1059
+ children?: ReactNode;
1060
+ }
1061
+
1062
+ /**
1063
+ * The default composition — provider + frame + toolbar + content.
1064
+ *
1065
+ * Reach for the parts when you need a different arrangement; this covers the
1066
+ * common case in one element.
1067
+ */
1068
+ export const FileViewer = forwardRef<HTMLDivElement, FileViewerProps>(function FileViewer(
1069
+ {
1070
+ // Every provider prop is destructured BY NAME, and the rest goes to the
1071
+ // frame. Peeling off only some of them and spreading the remainder was a
1072
+ // silent bug: `baseHeadingLevel` type-checked, never reached the provider,
1073
+ // and landed on the `<section>` as an unknown DOM attribute. Adding a
1074
+ // provider prop above without adding it here re-creates that exactly.
1075
+ source,
1076
+ registry,
1077
+ loading,
1078
+ baseHeadingLevel,
1079
+ highlights,
1080
+ defaultHighlights,
1081
+ onHighlightsChange,
1082
+ activeHighlightId,
1083
+ defaultActiveHighlightId,
1084
+ onActiveHighlightChange,
1085
+ pageNumber,
1086
+ defaultPageNumber,
1087
+ onPageNumberChange,
1088
+ zoom,
1089
+ defaultZoom,
1090
+ onZoomChange,
1091
+ rotation,
1092
+ defaultRotation,
1093
+ onRotationChange,
1094
+ children,
1095
+ ...props
1096
+ },
1097
+ ref,
1098
+ ) {
1099
+ return (
1100
+ <FileViewerProvider
1101
+ source={source}
1102
+ registry={registry}
1103
+ loading={loading}
1104
+ baseHeadingLevel={baseHeadingLevel}
1105
+ highlights={highlights}
1106
+ defaultHighlights={defaultHighlights}
1107
+ onHighlightsChange={onHighlightsChange}
1108
+ activeHighlightId={activeHighlightId}
1109
+ defaultActiveHighlightId={defaultActiveHighlightId}
1110
+ onActiveHighlightChange={onActiveHighlightChange}
1111
+ pageNumber={pageNumber}
1112
+ defaultPageNumber={defaultPageNumber}
1113
+ onPageNumberChange={onPageNumberChange}
1114
+ zoom={zoom}
1115
+ defaultZoom={defaultZoom}
1116
+ onZoomChange={onZoomChange}
1117
+ rotation={rotation}
1118
+ defaultRotation={defaultRotation}
1119
+ onRotationChange={onRotationChange}
1120
+ >
1121
+ <FileViewerFrame ref={ref} {...props}>
1122
+ {children ?? (
1123
+ <>
1124
+ {/* The page and scale controls live in the identity row, not inside
1125
+ the canvas: one row of chrome per viewer, and an app that wants
1126
+ them somewhere else composes the parts itself. Each renders
1127
+ nothing for a format whose manifest does not claim it. */}
1128
+ <FileViewerToolbar>
1129
+ <FileViewerPager />
1130
+ <FileViewerZoom />
1131
+ <FileViewerRotate />
1132
+ </FileViewerToolbar>
1133
+ {/* Renders nothing until Ctrl/Cmd+F, and nothing at all for a
1134
+ format whose adapter cannot paint a range. */}
1135
+ <FileViewerFind />
1136
+ <FileViewerHighlightStatus />
1137
+ <FileViewerContent />
1138
+ </>
1139
+ )}
1140
+ </FileViewerFrame>
1141
+ </FileViewerProvider>
1142
+ );
1143
+ });