@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.
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/dist/chunk-2NQ4RSJ3.js +18 -0
- package/dist/chunk-2NQ4RSJ3.js.map +1 -0
- package/dist/chunk-3U3MESBU.js +21 -0
- package/dist/chunk-3U3MESBU.js.map +1 -0
- package/dist/chunk-4OFQYALG.js +147 -0
- package/dist/chunk-4OFQYALG.js.map +1 -0
- package/dist/chunk-5OMONA2G.js +87 -0
- package/dist/chunk-5OMONA2G.js.map +1 -0
- package/dist/chunk-AORNMH77.js +58 -0
- package/dist/chunk-AORNMH77.js.map +1 -0
- package/dist/chunk-BCF3RWRL.js +18 -0
- package/dist/chunk-BCF3RWRL.js.map +1 -0
- package/dist/chunk-FELIAGKI.js +22 -0
- package/dist/chunk-FELIAGKI.js.map +1 -0
- package/dist/chunk-GGR7BNW4.js +18 -0
- package/dist/chunk-GGR7BNW4.js.map +1 -0
- package/dist/chunk-H3OAON3D.js +22 -0
- package/dist/chunk-H3OAON3D.js.map +1 -0
- package/dist/chunk-HMG2ERXH.js +17 -0
- package/dist/chunk-HMG2ERXH.js.map +1 -0
- package/dist/chunk-KOMDRWOU.js +21 -0
- package/dist/chunk-KOMDRWOU.js.map +1 -0
- package/dist/chunk-NMA57QZ7.js +117 -0
- package/dist/chunk-NMA57QZ7.js.map +1 -0
- package/dist/chunk-RGWDIQJR.js +26 -0
- package/dist/chunk-RGWDIQJR.js.map +1 -0
- package/dist/chunk-SLXRUYT3.js +91 -0
- package/dist/chunk-SLXRUYT3.js.map +1 -0
- package/dist/chunk-UJIJECEF.js +21 -0
- package/dist/chunk-UJIJECEF.js.map +1 -0
- package/dist/chunk-UL43NGUG.js +68 -0
- package/dist/chunk-UL43NGUG.js.map +1 -0
- package/dist/chunk-VO2273Z2.js +55 -0
- package/dist/chunk-VO2273Z2.js.map +1 -0
- package/dist/code-adapter-ADZ4UOGN.js +226 -0
- package/dist/code-adapter-ADZ4UOGN.js.map +1 -0
- package/dist/csv-adapter-6VU3FFVU.js +100 -0
- package/dist/csv-adapter-6VU3FFVU.js.map +1 -0
- package/dist/docx-adapter-5CQDHWTD.js +383 -0
- package/dist/docx-adapter-5CQDHWTD.js.map +1 -0
- package/dist/image-adapter-WOHZR24J.js +132 -0
- package/dist/image-adapter-WOHZR24J.js.map +1 -0
- package/dist/index.d.ts +1353 -0
- package/dist/index.js +1364 -0
- package/dist/index.js.map +1 -0
- package/dist/json-adapter-ZUW5GQHE.js +90 -0
- package/dist/json-adapter-ZUW5GQHE.js.map +1 -0
- package/dist/markdown-adapter-YC6WTBS4.js +214 -0
- package/dist/markdown-adapter-YC6WTBS4.js.map +1 -0
- package/dist/media-adapter-MCTB4GBH.js +78 -0
- package/dist/media-adapter-MCTB4GBH.js.map +1 -0
- package/dist/pdf-adapter-5PMKEXUD.js +424 -0
- package/dist/pdf-adapter-5PMKEXUD.js.map +1 -0
- package/dist/pptx-adapter-6GEQLS2Z.js +351 -0
- package/dist/pptx-adapter-6GEQLS2Z.js.map +1 -0
- package/dist/text-adapter-NFNWB5W3.js +81 -0
- package/dist/text-adapter-NFNWB5W3.js.map +1 -0
- package/dist/xlsx-adapter-CM2Y6AKQ.js +167 -0
- package/dist/xlsx-adapter-CM2Y6AKQ.js.map +1 -0
- package/package.json +103 -0
- package/src/adapters/code/code-adapter.test.tsx +221 -0
- package/src/adapters/code/code-adapter.tsx +272 -0
- package/src/adapters/code/code-language.test.ts +45 -0
- package/src/adapters/code/code-language.ts +100 -0
- package/src/adapters/code/code-manifest.ts +19 -0
- package/src/adapters/code/code-theme.test.ts +46 -0
- package/src/adapters/code/code-theme.ts +99 -0
- package/src/adapters/csv/csv-adapter.test.tsx +169 -0
- package/src/adapters/csv/csv-adapter.tsx +146 -0
- package/src/adapters/csv/csv-manifest.ts +20 -0
- package/src/adapters/docx/docx-adapter.test.tsx +192 -0
- package/src/adapters/docx/docx-adapter.tsx +328 -0
- package/src/adapters/docx/docx-manifest.ts +18 -0
- package/src/adapters/docx/docx-model.test.ts +181 -0
- package/src/adapters/docx/docx-model.ts +294 -0
- package/src/adapters/image/image-adapter.test.tsx +141 -0
- package/src/adapters/image/image-adapter.tsx +191 -0
- package/src/adapters/image/image-manifest.ts +15 -0
- package/src/adapters/index.ts +64 -0
- package/src/adapters/json/json-adapter.test.tsx +77 -0
- package/src/adapters/json/json-adapter.tsx +141 -0
- package/src/adapters/json/json-manifest.ts +11 -0
- package/src/adapters/markdown/markdown-adapter.test.tsx +228 -0
- package/src/adapters/markdown/markdown-adapter.tsx +353 -0
- package/src/adapters/markdown/markdown-manifest.ts +21 -0
- package/src/adapters/markdown/markdown-marks.test.ts +85 -0
- package/src/adapters/markdown/markdown-marks.ts +119 -0
- package/src/adapters/media/media-adapter.test.tsx +79 -0
- package/src/adapters/media/media-adapter.tsx +123 -0
- package/src/adapters/media/media-manifest.ts +17 -0
- package/src/adapters/office-fixture.ts +239 -0
- package/src/adapters/pdf/pdf-adapter.test.tsx +489 -0
- package/src/adapters/pdf/pdf-adapter.tsx +599 -0
- package/src/adapters/pdf/pdf-engine.ts +171 -0
- package/src/adapters/pdf/pdf-fixture.ts +27 -0
- package/src/adapters/pdf/pdf-manifest.ts +30 -0
- package/src/adapters/pdf/pdf-text.test.ts +171 -0
- package/src/adapters/pdf/pdf-text.ts +202 -0
- package/src/adapters/pptx/pptx-adapter.test.tsx +241 -0
- package/src/adapters/pptx/pptx-adapter.tsx +341 -0
- package/src/adapters/pptx/pptx-manifest.ts +19 -0
- package/src/adapters/pptx/pptx-model.test.ts +219 -0
- package/src/adapters/pptx/pptx-model.ts +248 -0
- package/src/adapters/text/text-adapter.test.tsx +125 -0
- package/src/adapters/text/text-adapter.tsx +117 -0
- package/src/adapters/text/text-manifest.ts +19 -0
- package/src/adapters/xlsx/xlsx-adapter.test.tsx +159 -0
- package/src/adapters/xlsx/xlsx-adapter.tsx +250 -0
- package/src/adapters/xlsx/xlsx-manifest.ts +26 -0
- package/src/components/grid-text.test.ts +68 -0
- package/src/components/grid-text.ts +77 -0
- package/src/components/marked-text.tsx +36 -0
- package/src/components/sheet-table.tsx +142 -0
- package/src/core/errors.ts +120 -0
- package/src/core/highlight-marks.test.ts +95 -0
- package/src/core/highlight-marks.ts +95 -0
- package/src/core/highlight-resolve.test.ts +208 -0
- package/src/core/highlight-resolve.ts +191 -0
- package/src/core/highlight.ts +125 -0
- package/src/core/registry.test.ts +172 -0
- package/src/core/registry.ts +188 -0
- package/src/core/scroll-host.ts +42 -0
- package/src/core/text-index.test.ts +95 -0
- package/src/core/text-index.ts +159 -0
- package/src/core/types.ts +285 -0
- package/src/core/use-highlight-scroll.ts +59 -0
- package/src/core/use-page-control.ts +47 -0
- package/src/core/use-paged-scroll.ts +194 -0
- package/src/core/use-viewport-size.ts +54 -0
- package/src/core/zoom.ts +56 -0
- package/src/file-viewer/file-viewer-context.tsx +239 -0
- package/src/file-viewer/file-viewer-find.tsx +204 -0
- package/src/file-viewer/file-viewer-pager.tsx +120 -0
- package/src/file-viewer/file-viewer-zoom.tsx +166 -0
- package/src/file-viewer/file-viewer.stories.tsx +947 -0
- package/src/file-viewer/file-viewer.test.tsx +947 -0
- package/src/file-viewer/file-viewer.tsx +1143 -0
- package/src/index.ts +157 -0
package/src/core/zoom.ts
ADDED
|
@@ -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
|
+
);
|