@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,56 @@
1
+ /**
2
+ * The zoom vocabulary — the stops, and how the chrome moves between them.
3
+ *
4
+ * Lives in `core/` rather than in the PDF adapter (where the stops started)
5
+ * because zoom is now provider state: the shell's zoom control, the provider's
6
+ * `zoomIn`/`zoomOut` and every adapter that scales its content all have to agree
7
+ * on the same ladder, or "125%" in the toolbar and the scale the page rendered
8
+ * at drift apart.
9
+ */
10
+
11
+ import type { ZoomFit, ZoomLevel } from "./types";
12
+
13
+ /**
14
+ * Zoom stops, in document scale.
15
+ *
16
+ * Discrete rather than continuous: a slider is imprecise with a pointer and
17
+ * impossible with a keyboard at this size, and "somewhere near 137%" is not a
18
+ * state a reader ever wants to be in. The ladder is the one from the original
19
+ * PDF pager, unchanged, so existing screens land on the same numbers.
20
+ */
21
+ export const VIEWER_ZOOM_STEPS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3] as const;
22
+
23
+ /** 100% — the scale a document opens at. */
24
+ export const DEFAULT_ZOOM = 1;
25
+
26
+ /** Float slack, so `1.0000000000000002 > 1` does not skip a stop. */
27
+ const EPSILON = 1e-6;
28
+
29
+ /** Whether a zoom level is a fit mode rather than a fixed scale. */
30
+ export function isZoomFit(zoom: ZoomLevel): zoom is ZoomFit {
31
+ return typeof zoom === "string";
32
+ }
33
+
34
+ /**
35
+ * The next stop above or below `from`.
36
+ *
37
+ * Takes an arbitrary scale, not an index, because a fit mode resolves to
38
+ * whatever the viewport made it (1.37, say) and the reader's next "zoom in" has
39
+ * to land on a real stop above that — an index-based pager would jump back to
40
+ * wherever the ladder was last parked.
41
+ */
42
+ export function stepZoom(from: number, delta: 1 | -1): number {
43
+ const steps = VIEWER_ZOOM_STEPS;
44
+ const first = steps[0];
45
+ const last = steps[steps.length - 1] as number;
46
+ if (delta === 1) return steps.find((step) => step > from + EPSILON) ?? last;
47
+ return [...steps].reverse().find((step) => step < from - EPSILON) ?? (first as number);
48
+ }
49
+
50
+ /** Whether there is a stop left in that direction — what disables the button. */
51
+ export function canStepZoom(from: number, delta: 1 | -1): boolean {
52
+ const steps = VIEWER_ZOOM_STEPS;
53
+ return delta === 1
54
+ ? from < (steps[steps.length - 1] as number) - EPSILON
55
+ : from > (steps[0] as number) + EPSILON;
56
+ }
@@ -0,0 +1,239 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The context contract behind every `FileViewer` part.
5
+ *
6
+ * Per `component-api.md` ("Lift state into the Provider; expose a
7
+ * `state` / `actions` interface"), `FileViewerProvider` is the only thing that
8
+ * knows HOW a file is loaded. Parts read the interface below, so a sibling
9
+ * control placed outside the visual frame but inside the provider — a download
10
+ * button in a page header, a format badge in a breadcrumb — reads and drives the
11
+ * same state with no prop-drilling.
12
+ *
13
+ * This module is the CONTRACT only; the provider itself lives beside the parts
14
+ * it feeds, in `file-viewer.tsx`.
15
+ */
16
+
17
+ import type { ProseHeadingLevel, ResolvedFileSource } from "@elabs-ai/components-ui";
18
+ import { createContext, use } from "react";
19
+
20
+ import type { ViewerError } from "../core/errors";
21
+ import type { DocumentHighlight, HighlightSupport, ResolvedHighlight } from "../core/highlight";
22
+ import type { ViewerRegistry } from "../core/registry";
23
+ import type {
24
+ AdapterCapabilities,
25
+ AdapterDocument,
26
+ AdapterModule,
27
+ DocumentRotation,
28
+ ZoomLevel,
29
+ } from "../core/types";
30
+
31
+ /**
32
+ * Where a file is in its journey to the screen.
33
+ *
34
+ * `loading` means "no renderable content yet" — the canonical signal from
35
+ * `.claude/rules/loading-states.md`, rendered as a layout-shaped skeleton.
36
+ * There is no separate `isStreaming`: a file arrives settled or not at all.
37
+ */
38
+ export type FileViewerStatus = "empty" | "loading" | "ready" | "error";
39
+
40
+ /**
41
+ * The LOAD half of the state — everything the fetch-and-parse effect owns.
42
+ *
43
+ * Split from the highlight half because the two have different lifetimes: this
44
+ * one is replaced wholesale each time a file is opened, while the citations
45
+ * pointing into it are a prop the app controls and outlive any single parse.
46
+ * Folding them together would mean every `setState` in the load effect had to
47
+ * remember to carry the highlights forward.
48
+ */
49
+ export interface FileViewerLoadState {
50
+ status: FileViewerStatus;
51
+ /** The resolved source, available as soon as there IS one — before any read. */
52
+ source?: ResolvedFileSource;
53
+ /** The adapter's parsed output. Only in `ready`. */
54
+ document?: AdapterDocument;
55
+ /** The adapter module that produced it, for its `Renderer`. Only in `ready`. */
56
+ adapter?: AdapterModule;
57
+ /** What the chrome may offer. Known from the manifest BEFORE the parser loads. */
58
+ capabilities: AdapterCapabilities;
59
+ /** Only in `error`. Always a `ViewerError`, so `code` can drive the message. */
60
+ error?: ViewerError;
61
+ }
62
+
63
+ /**
64
+ * How the open document is being LOOKED at — which page, at what scale, turned
65
+ * which way.
66
+ *
67
+ * Separate from the load state because it survives nothing and owns nothing: it
68
+ * is pure view, reset (page, rotation) or carried (zoom) when a new file opens.
69
+ * It lives in the provider rather than inside each adapter's `Renderer` so a
70
+ * control can sit anywhere — the shell toolbar, an app's own page header, a
71
+ * deep link — instead of only inside the canvas (ADR 0026).
72
+ */
73
+ export interface FileViewerViewState {
74
+ /** 1-based. `1` for a format that does not paginate. */
75
+ pageNumber: number;
76
+ /** `0` until a paginated document is ready, and for formats with no pages. */
77
+ pageCount: number;
78
+ /** What was ASKED for: a fixed scale, or a fit mode the renderer resolves. */
79
+ zoom: ZoomLevel;
80
+ /**
81
+ * What that resolved to, as a number — what the zoom control shows and what
82
+ * `zoomIn`/`zoomOut` step from. Equal to `zoom` whenever `zoom` is a number.
83
+ */
84
+ effectiveZoom: number;
85
+ /** Quarter-turns clockwise. Reset to `0` when a different file is opened. */
86
+ rotation: DocumentRotation;
87
+ }
88
+
89
+ export interface FileViewerState extends FileViewerLoadState, FileViewerViewState {
90
+ /**
91
+ * The parts of the document to point at, as REQUESTED. What was actually
92
+ * located is `meta.resolvedHighlights` — the request survives a miss so the
93
+ * chrome can say "we couldn't find that passage" instead of showing nothing.
94
+ */
95
+ highlights: readonly DocumentHighlight[];
96
+ /** Which one the viewer is pointed at. `null` is "none", explicitly. */
97
+ activeHighlightId: string | null;
98
+ find: FileViewerFindState;
99
+ }
100
+
101
+ /** What find-in-document is doing right now. */
102
+ export interface FileViewerFindState {
103
+ /** Whether the search box is showing. */
104
+ open: boolean;
105
+ query: string;
106
+ caseSensitive: boolean;
107
+ /** How many matches the current query has. */
108
+ matches: number;
109
+ /** Whether that count hit `FIND_MATCH_LIMIT` and is therefore a floor. */
110
+ truncated: boolean;
111
+ /**
112
+ * Which match is current, 0-based — the "3" in "3 of 12", minus one.
113
+ *
114
+ * Deliberately NOT the same knob as `activeHighlightId`. Find is the viewer's
115
+ * own, and an app that controls `activeHighlightId` to drive citations would
116
+ * otherwise have to also honour every keystroke of a search it never asked
117
+ * for, or silently break next/previous.
118
+ */
119
+ activeIndex: number;
120
+ }
121
+
122
+ export interface FileViewerActions {
123
+ /** Re-run the load. The retry action on the error state. */
124
+ reload: () => void;
125
+ /**
126
+ * Replace the citations. While `highlights` is controlled this writes no local
127
+ * state — but it still calls `onHighlightsChange`, so the owner can accept the
128
+ * request. Mirroring the platform: a controlled input reports, it does not
129
+ * self-update.
130
+ */
131
+ setHighlights: (highlights: readonly DocumentHighlight[]) => void;
132
+ /** Point the viewer at one highlight, or at none. */
133
+ setActiveHighlight: (id: string | null) => void;
134
+ /** Move to the next/previous CITATION in document order, wrapping around. */
135
+ nextHighlight: () => void;
136
+ previousHighlight: () => void;
137
+ openFind: () => void;
138
+ closeFind: () => void;
139
+ setFindQuery: (query: string) => void;
140
+ setFindCaseSensitive: (caseSensitive: boolean) => void;
141
+ /** Move to the next/previous SEARCH match, wrapping around. */
142
+ nextFindMatch: () => void;
143
+ previousFindMatch: () => void;
144
+ /**
145
+ * Turn to a page, 1-based. Clamped to the document — an out-of-range page is
146
+ * a caller's arithmetic slip, not a reason to blank the canvas.
147
+ */
148
+ goToPage: (page: number) => void;
149
+ /** Turn one page. Both stop at the ends rather than wrapping: a document is not a carousel. */
150
+ nextPage: () => void;
151
+ previousPage: () => void;
152
+ /** Draw at a fixed scale, or hand the renderer a fit mode to resolve. */
153
+ setZoom: (zoom: ZoomLevel) => void;
154
+ /**
155
+ * Step to the next stop above/below what is currently ON SCREEN — so zooming
156
+ * in from a fitted page continues from the fitted scale, not from wherever the
157
+ * fixed ladder was last parked.
158
+ */
159
+ zoomIn: () => void;
160
+ zoomOut: () => void;
161
+ setRotation: (rotation: DocumentRotation) => void;
162
+ /** Turn the document a quarter-turn: `1` clockwise, `-1` counter-clockwise. */
163
+ rotate: (quarterTurns: 1 | -1) => void;
164
+ /**
165
+ * The renderer's report channel for {@link FileViewerViewState.effectiveZoom}
166
+ * — not for app code. `FileViewerContent` wires it to the adapter's
167
+ * `onZoomResolved`, the same way `registerFind` is wired to a part rather than
168
+ * called by a consumer.
169
+ */
170
+ reportZoom: (scale: number) => void;
171
+ /**
172
+ * Tell the viewer a find part is mounted; call the returned function on
173
+ * unmount. `FileViewerFind` does this for you — it exists so the frame knows
174
+ * whether taking Ctrl/Cmd+F off the browser leads anywhere.
175
+ */
176
+ registerFind: () => () => void;
177
+ }
178
+
179
+ export interface FileViewerContextValue {
180
+ state: FileViewerState;
181
+ actions: FileViewerActions;
182
+ registry: ViewerRegistry;
183
+ meta: {
184
+ /**
185
+ * The rung a viewed document's own top-level heading renders at. Passed to
186
+ * every adapter `Renderer`; see `AdapterRendererProps.baseHeadingLevel`.
187
+ */
188
+ baseHeadingLevel: ProseHeadingLevel;
189
+ /**
190
+ * Citations and find matches, LOCATED, in document order, numbered — what
191
+ * an adapter `Renderer` is handed and what the chrome counts.
192
+ */
193
+ resolvedHighlights: readonly ResolvedHighlight[];
194
+ /**
195
+ * Which highlight the viewer is EFFECTIVELY pointed at, and what an adapter
196
+ * `Renderer` receives as `activeHighlightId`.
197
+ *
198
+ * Not the same knob as `state.activeHighlightId`: that one is the citation
199
+ * the app controls, while the reader stepping through find matches is also
200
+ * "current". Find outranks the citation while its box is open and matching,
201
+ * so navigation and scrolling follow whichever the reader is actually moving.
202
+ */
203
+ currentHighlightId: string | null;
204
+ /** Which address kinds this document's adapter declared it can paint. */
205
+ highlightSupport: HighlightSupport;
206
+ /**
207
+ * Whether find-in-document applies to the open document. Derived, not read
208
+ * straight off the manifest — see `AdapterCapabilities.search`.
209
+ */
210
+ canFind: boolean;
211
+ /**
212
+ * Whether a `FileViewerFind` part is composed into this viewer.
213
+ *
214
+ * Separate from {@link canFind}, which only says the ADAPTER could paint a
215
+ * match. The frame needs both before it takes Ctrl/Cmd+F away from the
216
+ * browser: intercepting the shortcut with nowhere to type leaves a keyboard
217
+ * reader with no find at all.
218
+ */
219
+ hasFind: boolean;
220
+ };
221
+ }
222
+
223
+ /**
224
+ * Exported for `FileViewerProvider` (which lives with the parts it feeds, in
225
+ * `file-viewer.tsx`) — NOT part of the package's public surface.
226
+ */
227
+ export const FileViewerContext = createContext<FileViewerContextValue | null>(null);
228
+
229
+ /**
230
+ * Read the viewer state. Throws outside a provider — a part that silently
231
+ * rendered nothing would be far harder to diagnose than a named error.
232
+ */
233
+ export function useFileViewer(): FileViewerContextValue {
234
+ const value = use(FileViewerContext);
235
+ if (!value) {
236
+ throw new Error("useFileViewer must be used inside a <FileViewerProvider> (or <FileViewer>).");
237
+ }
238
+ return value;
239
+ }
@@ -0,0 +1,204 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Find-in-document — the viewer's own Ctrl/Cmd+F.
5
+ *
6
+ * It paints through the SAME highlight layer citations use (`source: "search"`),
7
+ * so a document never grows two mark systems that disagree about what a mark
8
+ * looks like. Only which one is CURRENT differs, and that lives on its own knob
9
+ * (`find.activeIndex`) so an app controlling citations does not have to honour
10
+ * every keystroke of a search it never asked for.
11
+ *
12
+ * **Not a `role="toolbar"`.** A toolbar is one tab stop with roving arrow keys,
13
+ * and an `<input>` inside one has its Left/Right stolen from the caret. This is
14
+ * `role="search"` — the same call `FileViewerToolbar` already made.
15
+ */
16
+
17
+ import {
18
+ cn,
19
+ IconButton,
20
+ InputGroup,
21
+ InputGroupAddon,
22
+ InputGroupButton,
23
+ InputGroupInput,
24
+ Separator,
25
+ useLocale,
26
+ } from "@elabs-ai/components-ui";
27
+ import { CaseSensitiveIcon, ChevronDownIcon, ChevronUpIcon, SearchIcon, XIcon } from "lucide-react";
28
+ import {
29
+ forwardRef,
30
+ useEffect,
31
+ useRef,
32
+ type HTMLAttributes,
33
+ type KeyboardEvent as ReactKeyboardEvent,
34
+ } from "react";
35
+
36
+ import { FIND_MATCH_LIMIT } from "../core/highlight";
37
+ import { useFileViewer } from "./file-viewer-context";
38
+
39
+ /** Whether a keyboard event is the platform's find shortcut. */
40
+ export function isFindShortcut(event: {
41
+ key: string;
42
+ metaKey: boolean;
43
+ ctrlKey: boolean;
44
+ }): boolean {
45
+ // Either modifier, not `navigator.platform`: a Mac driven by an external PC
46
+ // keyboard sends Ctrl, and sniffing the platform gets that reader wrong.
47
+ return event.key.toLowerCase() === "f" && (event.metaKey || event.ctrlKey);
48
+ }
49
+
50
+ export type FileViewerFindProps = HTMLAttributes<HTMLDivElement>;
51
+
52
+ /**
53
+ * The search row. Renders nothing until `actions.openFind()` (or Ctrl/Cmd+F on
54
+ * the frame) opens it, and nothing at all for a document whose adapter cannot
55
+ * paint a range — an affordance that could never highlight anything is worse
56
+ * than no affordance at all.
57
+ */
58
+ export const FileViewerFind = forwardRef<HTMLDivElement, FileViewerFindProps>(
59
+ function FileViewerFind({ className, ...props }, ref) {
60
+ const { state, actions, meta } = useFileViewer();
61
+ const { t, formatNumber } = useLocale();
62
+ const input = useRef<HTMLInputElement>(null);
63
+ const find = state.find;
64
+ const open = find.open && meta.canFind;
65
+ const { registerFind } = actions;
66
+
67
+ // Announce that there IS somewhere to type. The frame only takes Ctrl/Cmd+F
68
+ // away from the browser once this has run — a shortcut swallowed with no box
69
+ // to show leaves a keyboard reader with no find at all. Registered on mount
70
+ // rather than when open, because the box is closed at exactly the moment the
71
+ // shortcut has to be decided.
72
+ useEffect(() => registerFind(), [registerFind]);
73
+
74
+ // A find box that does not take the caret sends the next keystroke to the
75
+ // document behind it — the one thing every reader expects not to happen.
76
+ useEffect(() => {
77
+ if (open) input.current?.focus();
78
+ }, [open]);
79
+
80
+ if (!open) return null;
81
+
82
+ const hasQuery = find.query.length > 0;
83
+ const hasMatches = find.matches > 0;
84
+
85
+ const onKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
86
+ if (event.key === "Escape") {
87
+ event.preventDefault();
88
+ // Stopped here: an Escape meant for the find box must not also close a
89
+ // Dialog the viewer happens to be sitting in.
90
+ event.stopPropagation();
91
+ actions.closeFind();
92
+ return;
93
+ }
94
+ if (event.key === "Enter") {
95
+ event.preventDefault();
96
+ if (event.shiftKey) actions.previousFindMatch();
97
+ else actions.nextFindMatch();
98
+ }
99
+ };
100
+
101
+ return (
102
+ <div
103
+ ref={ref}
104
+ data-slot="file-viewer-find"
105
+ role="search"
106
+ aria-label={t("viewer.find.label")}
107
+ className={cn(
108
+ // The divider is the only cue between this row and the content below
109
+ // it — same fill, no elevation change (WCAG 1.4.11, strong rung).
110
+ "border-border-strong flex shrink-0 items-center gap-2 border-b px-3 py-2",
111
+ className,
112
+ )}
113
+ {...props}
114
+ >
115
+ <InputGroup className="h-8 max-w-72">
116
+ <InputGroupAddon>
117
+ <SearchIcon aria-hidden="true" />
118
+ </InputGroupAddon>
119
+ <InputGroupInput
120
+ ref={input}
121
+ // Not `type="search"`: the browser's own clear affordance is
122
+ // unlabelled and unthemeable, and this row already carries a close
123
+ // control that does something more useful.
124
+ type="text"
125
+ value={find.query}
126
+ aria-label={t("viewer.find.label")}
127
+ placeholder={t("viewer.find.placeholder")}
128
+ autoComplete="off"
129
+ spellCheck={false}
130
+ onChange={(event) => actions.setFindQuery(event.target.value)}
131
+ onKeyDown={onKeyDown}
132
+ />
133
+ <InputGroupAddon align="inline-end">
134
+ {/* A real toggle button, so the state is announced rather than
135
+ inferred from a colour change. */}
136
+ <InputGroupButton
137
+ size="icon-xs"
138
+ aria-pressed={find.caseSensitive}
139
+ aria-label={t("viewer.find.caseSensitive")}
140
+ title={t("viewer.find.caseSensitive")}
141
+ onClick={() => actions.setFindCaseSensitive(!find.caseSensitive)}
142
+ >
143
+ <CaseSensitiveIcon aria-hidden="true" />
144
+ </InputGroupButton>
145
+ </InputGroupAddon>
146
+ </InputGroup>
147
+
148
+ {/* ONE live region for the whole result state: "3 of 12" and "No
149
+ matches" replace each other rather than sitting side by side, so a
150
+ fruitless search never reads as "0 of 0". */}
151
+ <span
152
+ role="status"
153
+ aria-live="polite"
154
+ className="text-meta text-muted-foreground min-w-0 flex-1 truncate tabular-nums"
155
+ >
156
+ {!hasQuery
157
+ ? null
158
+ : hasMatches
159
+ ? t("viewer.find.count", {
160
+ index: formatNumber(find.activeIndex + 1),
161
+ total: formatNumber(Math.min(find.matches, FIND_MATCH_LIMIT)),
162
+ })
163
+ : t("viewer.find.none")}
164
+ {find.truncated
165
+ ? ` ${t("viewer.find.capped", {
166
+ limit: formatNumber(FIND_MATCH_LIMIT),
167
+ total: formatNumber(find.matches),
168
+ })}`
169
+ : null}
170
+ </span>
171
+
172
+ {/* `aria-disabled`, never the native attribute: a focused control that
173
+ becomes `disabled` is dropped from the focus order, so a reader who
174
+ tabbed to Next and then cleared the query would have focus silently
175
+ fall to <body>. The actions are the real guard — both no-op with no
176
+ matches (see interaction-guidelines.md). */}
177
+ <IconButton
178
+ variant="ghost"
179
+ size="icon-sm"
180
+ aria-disabled={!hasMatches}
181
+ label={t("viewer.find.previous")}
182
+ icon={<ChevronUpIcon aria-hidden="true" />}
183
+ onClick={actions.previousFindMatch}
184
+ />
185
+ <IconButton
186
+ variant="ghost"
187
+ size="icon-sm"
188
+ aria-disabled={!hasMatches}
189
+ label={t("viewer.find.next")}
190
+ icon={<ChevronDownIcon aria-hidden="true" />}
191
+ onClick={actions.nextFindMatch}
192
+ />
193
+ <Separator orientation="vertical" className="h-4" />
194
+ <IconButton
195
+ variant="ghost"
196
+ size="icon-sm"
197
+ label={t("viewer.find.close")}
198
+ icon={<XIcon aria-hidden="true" />}
199
+ onClick={actions.closeFind}
200
+ />
201
+ </div>
202
+ );
203
+ },
204
+ );
@@ -0,0 +1,120 @@
1
+ "use client";
2
+
3
+ /**
4
+ * `FileViewerPager` — previous / a page you can type into / next.
5
+ *
6
+ * A PART, not adapter chrome. The page lives in the provider (ADR 0026), so this
7
+ * row can sit in the viewer's own toolbar, in an app's page header, or beside a
8
+ * thumbnail rail, and every copy of it stays in step. While the page was
9
+ * `useState` inside `PdfRenderer` there could only ever be one pager, inside the
10
+ * canvas.
11
+ *
12
+ * ## Why this is not a `role="toolbar"`
13
+ *
14
+ * The role promises roving-tabindex arrow-key navigation, and the page field is
15
+ * a TEXT INPUT — ArrowLeft/ArrowRight there move the caret. A toolbar that
16
+ * swallowed them would make the number unusable to a keyboard reader, so this is
17
+ * a plain named group of ordinary tab stops: the same call `FileViewerToolbar`
18
+ * and `ViewToolbar` make, for the same reason.
19
+ */
20
+
21
+ import { cn, IconButton, Input, useLocale } from "@elabs-ai/components-ui";
22
+ import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
23
+ import { forwardRef, useState, type HTMLAttributes } from "react";
24
+
25
+ import { useFileViewer } from "./file-viewer-context";
26
+
27
+ export type FileViewerPagerProps = HTMLAttributes<HTMLDivElement>;
28
+
29
+ export const FileViewerPager = forwardRef<HTMLDivElement, FileViewerPagerProps>(
30
+ function FileViewerPager({ className, ...props }, ref) {
31
+ const { state, actions } = useFileViewer();
32
+ const { t, formatNumber } = useLocale();
33
+
34
+ // The half-typed value, while the reader is typing it. `null` means "not
35
+ // editing", so the field follows the document the rest of the time — a
36
+ // citation that turns the page updates the number under the caret too.
37
+ const [draft, setDraft] = useState<string | null>(null);
38
+
39
+ const { pageNumber, pageCount } = state;
40
+
41
+ // No pages, or none known yet: render nothing rather than an inert "1 of 0".
42
+ // Chrome with nothing in it reads as a broken render (viewer-components.md).
43
+ if (!state.capabilities.pages || pageCount === 0) return null;
44
+
45
+ const commit = () => {
46
+ if (draft === null) return;
47
+ const parsed = Number.parseInt(draft, 10);
48
+ setDraft(null);
49
+ // A blank or nonsense entry is a reader changing their mind, not a
50
+ // navigation — snap back to where they are instead of jumping to page 1.
51
+ if (Number.isNaN(parsed)) return;
52
+ actions.goToPage(parsed);
53
+ };
54
+
55
+ return (
56
+ <div
57
+ ref={ref}
58
+ data-slot="file-viewer-pager"
59
+ role="group"
60
+ aria-label={t("viewer.pager.controls")}
61
+ className={cn("flex shrink-0 items-center gap-1", className)}
62
+ {...props}
63
+ >
64
+ <IconButton
65
+ variant="ghost"
66
+ size="icon-sm"
67
+ label={t("viewer.pager.previous")}
68
+ icon={<ChevronLeftIcon aria-hidden="true" />}
69
+ disabled={pageNumber <= 1}
70
+ onClick={actions.previousPage}
71
+ />
72
+ <Input
73
+ // Explicit: Radix's toolbar slot and several wrappers default an
74
+ // unset `type` to `"button"`, which would silently turn the field
75
+ // into a button the day this moves inside one.
76
+ type="text"
77
+ inputMode="numeric"
78
+ autoComplete="off"
79
+ spellCheck={false}
80
+ aria-label={t("viewer.pager.pageNumber")}
81
+ value={draft ?? String(pageNumber)}
82
+ onChange={(event) => setDraft(event.target.value)}
83
+ onBlur={commit}
84
+ onKeyDown={(event) => {
85
+ if (event.key === "Enter") {
86
+ event.preventDefault();
87
+ commit();
88
+ } else if (event.key === "Escape") {
89
+ // Abandon the edit without navigating; focus stays put, so the
90
+ // reader can try again without re-reaching the field.
91
+ setDraft(null);
92
+ }
93
+ }}
94
+ className="h-7 w-12 px-1 text-center tabular-nums"
95
+ />
96
+ <span className="text-meta text-muted-foreground whitespace-nowrap tabular-nums">
97
+ {t("viewer.pager.of", { total: formatNumber(pageCount) })}
98
+ </span>
99
+ <IconButton
100
+ variant="ghost"
101
+ size="icon-sm"
102
+ label={t("viewer.pager.next")}
103
+ icon={<ChevronRightIcon aria-hidden="true" />}
104
+ disabled={pageNumber >= pageCount}
105
+ onClick={actions.nextPage}
106
+ />
107
+ {/* Paging repaints a canvas, which announces nothing on its own, and the
108
+ field's own value change is not announced either. ONE live region for
109
+ the group carries the whole sentence — `loading-states.md`'s rule that
110
+ a region announces once, not per element. */}
111
+ <span role="status" aria-live="polite" className="sr-only">
112
+ {t("viewer.pager.status", {
113
+ page: formatNumber(pageNumber),
114
+ total: formatNumber(pageCount),
115
+ })}
116
+ </span>
117
+ </div>
118
+ );
119
+ },
120
+ );