@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
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The adapter protocol — what a format plugin declares, what it produces, and
|
|
3
|
+
* what renders it.
|
|
4
|
+
*
|
|
5
|
+
* ## The two halves, and why they are split
|
|
6
|
+
*
|
|
7
|
+
* A registry entry is an eager {@link AdapterManifest} plus a lazy loader. The
|
|
8
|
+
* manifest is plain data available the moment the registry is created, so the
|
|
9
|
+
* chrome can decide which controls to show and whether a file is openable at all
|
|
10
|
+
* **without downloading a parser**. The loader is a `() => import(…)` that pulls
|
|
11
|
+
* the parser AND its renderer together, only once a file of that kind is opened.
|
|
12
|
+
*
|
|
13
|
+
* ## Adapters emit DATA, not HTML (ADR 0024 §3)
|
|
14
|
+
*
|
|
15
|
+
* An adapter's `load()` returns an {@link AdapterDocument} — a model — and ships
|
|
16
|
+
* a `Renderer` that draws it with brand-ui components. No adapter returns an
|
|
17
|
+
* HTML string, so no adapter can smuggle a colour, a font or a border past the
|
|
18
|
+
* token layer. This is the single decision that keeps the viewer themeable.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type {
|
|
22
|
+
DocumentAddressKind,
|
|
23
|
+
FileCategory,
|
|
24
|
+
ProseHeadingLevel,
|
|
25
|
+
ResolvedFileSource,
|
|
26
|
+
} from "@elabs-ai/components-ui";
|
|
27
|
+
import type { ComponentType } from "react";
|
|
28
|
+
|
|
29
|
+
import type { ResolvedHighlight } from "./highlight";
|
|
30
|
+
import type { TextIndex } from "./text-index";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The adapter protocol version. Bumped only when the shapes below change in a
|
|
34
|
+
* way an existing adapter would not survive; an adapter declaring a different
|
|
35
|
+
* version is rejected with a named error rather than crashing at render.
|
|
36
|
+
*/
|
|
37
|
+
export const PROTOCOL_VERSION = 1;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A quarter-turn clockwise rotation applied to the whole document.
|
|
41
|
+
*
|
|
42
|
+
* Document-level, not per-page: turning one page of a scan and leaving the rest
|
|
43
|
+
* is an AUTHORING act (it changes the file), and this package reads files.
|
|
44
|
+
*/
|
|
45
|
+
export type DocumentRotation = 0 | 90 | 180 | 270;
|
|
46
|
+
|
|
47
|
+
/** Scale the content to a dimension of the viewport instead of to a fixed number. */
|
|
48
|
+
export type ZoomFit = "fit-width" | "fit-page";
|
|
49
|
+
|
|
50
|
+
/** One page's size at scale 1, in CSS pixels. */
|
|
51
|
+
export interface PageSize {
|
|
52
|
+
width: number;
|
|
53
|
+
height: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* What the chrome asks a renderer to draw at.
|
|
58
|
+
*
|
|
59
|
+
* A fit mode is a REQUEST, not a scale: only the renderer knows how wide its own
|
|
60
|
+
* viewport is and how big the page is, so it resolves the request and reports the
|
|
61
|
+
* number back through {@link AdapterRendererProps.onZoomResolved}. Resolving it
|
|
62
|
+
* in the provider would mean the shell measuring a box it does not own.
|
|
63
|
+
*/
|
|
64
|
+
export type ZoomLevel = number | ZoomFit;
|
|
65
|
+
|
|
66
|
+
/** What the chrome may offer for a format, known before any parser is fetched. */
|
|
67
|
+
export interface AdapterCapabilities {
|
|
68
|
+
/** The document is paginated — show the pager. */
|
|
69
|
+
pages?: boolean;
|
|
70
|
+
/** The rendered content can be scaled — show the zoom control. */
|
|
71
|
+
zoom?: boolean;
|
|
72
|
+
/** The rendered content can be rotated. */
|
|
73
|
+
rotate?: boolean;
|
|
74
|
+
/**
|
|
75
|
+
* The document exposes text that can be searched.
|
|
76
|
+
*
|
|
77
|
+
* An OVERRIDE, not the source of truth: the shell offers find-in-document
|
|
78
|
+
* whenever a format has both a text projection and a text-based
|
|
79
|
+
* {@link AdapterCapabilities.highlight} kind, and reads this only when an
|
|
80
|
+
* adapter says otherwise. Left as the source of truth it would deny the find
|
|
81
|
+
* box to PDF — the format readers most expect it on — purely because that
|
|
82
|
+
* manifest predates the feature and never declared the flag.
|
|
83
|
+
*
|
|
84
|
+
* It overrides in the OFF direction only, so a declared `true` is inert: it
|
|
85
|
+
* cannot grant find to a format with no text or no range-painting renderer,
|
|
86
|
+
* because the box would then have nothing to show for a match.
|
|
87
|
+
*/
|
|
88
|
+
search?: boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Which {@link DocumentAddress} kinds this adapter's `Renderer` can actually
|
|
91
|
+
* paint. Absent means none.
|
|
92
|
+
*
|
|
93
|
+
* On the EAGER manifest on purpose: an app can decide whether to show a
|
|
94
|
+
* "jump to the cited passage" affordance before it downloads pdf.js.
|
|
95
|
+
*
|
|
96
|
+
* It is a promise the renderer has to keep — `pnpm viewer-highlight:check`
|
|
97
|
+
* fails a manifest that declares a kind its adapter never exercises.
|
|
98
|
+
*/
|
|
99
|
+
highlight?: readonly DocumentAddressKind[];
|
|
100
|
+
/** The document has an outline/table of contents for the sidebar. */
|
|
101
|
+
outline?: boolean;
|
|
102
|
+
/** The document can produce page thumbnails for the sidebar. */
|
|
103
|
+
thumbnails?: boolean;
|
|
104
|
+
/** A plain-text projection exists — enables the raw/source toggle and copy. */
|
|
105
|
+
text?: boolean;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* What an adapter can open. At least one of `extensions` / `mediaTypes` /
|
|
110
|
+
* `categories` must be present, or nothing will ever route to it.
|
|
111
|
+
*/
|
|
112
|
+
export interface AdapterManifest {
|
|
113
|
+
/**
|
|
114
|
+
* Stable identity and the OVERRIDE key: registering a different loader under
|
|
115
|
+
* an existing id replaces it, which is how a consumer swaps a built-in.
|
|
116
|
+
*/
|
|
117
|
+
id: string;
|
|
118
|
+
/** Must equal {@link PROTOCOL_VERSION}. */
|
|
119
|
+
protocol: number;
|
|
120
|
+
/**
|
|
121
|
+
* Tie-break when several adapters claim the same file. Built-ins are `0`;
|
|
122
|
+
* register above it to win, below it to act only as a fallback.
|
|
123
|
+
*/
|
|
124
|
+
priority?: number;
|
|
125
|
+
/** Extensions claimed, lowercase and without the dot. The most specific match. */
|
|
126
|
+
extensions?: string[];
|
|
127
|
+
/** MIME types claimed. A trailing slash makes it a prefix (`"image/"`). */
|
|
128
|
+
mediaTypes?: string[];
|
|
129
|
+
/** Coarse categories claimed. The broadest match — a catch-all fallback. */
|
|
130
|
+
categories?: FileCategory[];
|
|
131
|
+
/** Which chrome controls apply. Absent keys are `false`. */
|
|
132
|
+
capabilities?: AdapterCapabilities;
|
|
133
|
+
/**
|
|
134
|
+
* Optional peer packages this adapter dynamically imports. Purely
|
|
135
|
+
* informational to the registry — it is what the "install this to open that"
|
|
136
|
+
* message names when the import fails to resolve.
|
|
137
|
+
*/
|
|
138
|
+
requires?: string[];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* An adapter's parsed output. Adapters EXTEND this with their own payload; the
|
|
143
|
+
* fields here are the parts the shell itself understands, so it can drive the
|
|
144
|
+
* pager, the search box and the raw toggle without knowing the format.
|
|
145
|
+
*/
|
|
146
|
+
export interface AdapterDocument {
|
|
147
|
+
/** Adapter-defined discriminant, matched by that adapter's own `Renderer`. */
|
|
148
|
+
readonly kind: string;
|
|
149
|
+
/** Page count for a paginated document. Omit for single-surface formats. */
|
|
150
|
+
readonly pageCount?: number;
|
|
151
|
+
/**
|
|
152
|
+
* Each page's size at scale 1, in document order.
|
|
153
|
+
*
|
|
154
|
+
* What lets a continuously-scrolling renderer reserve the right height for a
|
|
155
|
+
* page it has not drawn yet, so the scrollbar means something from the first
|
|
156
|
+
* frame instead of growing as pages arrive. May be SHORTER than
|
|
157
|
+
* {@link AdapterDocument.pageCount} — measuring 900 pages up front costs 900
|
|
158
|
+
* round-trips to a worker, so an adapter is free to describe the pages it
|
|
159
|
+
* already touched and let the renderer assume the rest match.
|
|
160
|
+
*/
|
|
161
|
+
readonly pageSizes?: readonly PageSize[];
|
|
162
|
+
/** Plain-text projection, when the format has one. Powers search, copy and the raw view. */
|
|
163
|
+
readonly text?: string;
|
|
164
|
+
/**
|
|
165
|
+
* Whether {@link AdapterDocument.text} is CAPPED rather than complete.
|
|
166
|
+
*
|
|
167
|
+
* Changes what "we couldn't find that passage" means: absent from the whole
|
|
168
|
+
* document, or simply past the part we previewed. Those deserve different
|
|
169
|
+
* sentences, and only the adapter knows which one is true.
|
|
170
|
+
*/
|
|
171
|
+
readonly textTruncated?: boolean;
|
|
172
|
+
/**
|
|
173
|
+
* The map from {@link AdapterDocument.text} back into this adapter's own
|
|
174
|
+
* model, so a character range can become a block, a page or a cell.
|
|
175
|
+
*
|
|
176
|
+
* The `ref` type is the adapter's business — its own `Renderer` is the only
|
|
177
|
+
* thing that reads it, which is why it is `unknown` here.
|
|
178
|
+
*/
|
|
179
|
+
readonly textIndex?: TextIndex<unknown>;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Everything an adapter is given while loading a document. */
|
|
183
|
+
export interface AdapterLoadContext {
|
|
184
|
+
/** Cancels the load — always honour it; a stale parse must not win a race. */
|
|
185
|
+
signal?: AbortSignal;
|
|
186
|
+
/** Report determinate progress in `0..1` where the format allows it. */
|
|
187
|
+
onProgress?: (fraction: number) => void;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** A parser instance. One per document — adapters may hold per-document state. */
|
|
191
|
+
export interface FileAdapter {
|
|
192
|
+
load(source: ResolvedFileSource, context: AdapterLoadContext): Promise<AdapterDocument>;
|
|
193
|
+
/** Release anything the document holds (workers, object URLs, decoded buffers). */
|
|
194
|
+
dispose?(): void;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Props every adapter `Renderer` receives. */
|
|
198
|
+
export interface AdapterRendererProps {
|
|
199
|
+
/** The value this adapter's own `load()` returned — narrow it by `kind`. */
|
|
200
|
+
document: AdapterDocument;
|
|
201
|
+
/** The source, for identity, `alt` text and download actions. */
|
|
202
|
+
source: ResolvedFileSource;
|
|
203
|
+
className?: string;
|
|
204
|
+
/**
|
|
205
|
+
* The heading rung a document's own top-level heading renders at.
|
|
206
|
+
*
|
|
207
|
+
* A viewed file carries its OWN heading tree, and that tree has to be
|
|
208
|
+
* relative to the page hosting it: a README's `#` inside an app that already
|
|
209
|
+
* has an `<h1>` would otherwise put two `h1`s in a screen reader's flat
|
|
210
|
+
* heading list. `FileViewerContent` passes the provider's value; an adapter
|
|
211
|
+
* that renders headings offsets by it (`clampHeadingLevel(own + base - 1)`),
|
|
212
|
+
* and one that renders none ignores it. Same seam as
|
|
213
|
+
* `@elabs-ai/components-ai`'s `MarkdownView baseHeadingLevel`.
|
|
214
|
+
*
|
|
215
|
+
* Optional, so adding it is not a `PROTOCOL_VERSION` change.
|
|
216
|
+
*/
|
|
217
|
+
baseHeadingLevel?: ProseHeadingLevel;
|
|
218
|
+
/**
|
|
219
|
+
* The parts of the document to mark, already located — citations from the
|
|
220
|
+
* app and the viewer's own find-in-document matches, in document order.
|
|
221
|
+
*
|
|
222
|
+
* The shell has done LOCATE (a quote is now a character range); the renderer
|
|
223
|
+
* does MAP and PAINT, because only it knows that offsets 812–847 are the
|
|
224
|
+
* second half of block 14. An adapter renders only the entries whose `status`
|
|
225
|
+
* is `"resolved"` and whose `address.kind` it declared, and ignores the rest
|
|
226
|
+
* — the shell is what tells the reader about a miss.
|
|
227
|
+
*
|
|
228
|
+
* Optional, so adding it is not a `PROTOCOL_VERSION` change.
|
|
229
|
+
*/
|
|
230
|
+
highlights?: readonly ResolvedHighlight[];
|
|
231
|
+
/**
|
|
232
|
+
* Which highlight the viewer is pointed at. The renderer scrolls it into
|
|
233
|
+
* view, pages to it if the format paginates, and draws it distinctly.
|
|
234
|
+
*
|
|
235
|
+
* Also present as `active` on the entry itself; this is the shortcut for a
|
|
236
|
+
* renderer that only needs to know whether anything changed.
|
|
237
|
+
*/
|
|
238
|
+
activeHighlightId?: string | null;
|
|
239
|
+
/**
|
|
240
|
+
* Which page the reader is on, 1-based — the provider's, not the renderer's.
|
|
241
|
+
*
|
|
242
|
+
* Page, zoom and rotation are PROVIDER state (ADR 0026) so a control can live
|
|
243
|
+
* anywhere: the shell's own toolbar, an app's page header, a deep link. While
|
|
244
|
+
* these were `useState` inside each renderer nothing outside the canvas could
|
|
245
|
+
* read or drive them.
|
|
246
|
+
*
|
|
247
|
+
* Uncontrolled when absent — a renderer used outside a `FileViewerProvider`
|
|
248
|
+
* keeps its own page, so it still works standalone. Optional, so adding it is
|
|
249
|
+
* not a `PROTOCOL_VERSION` change.
|
|
250
|
+
*/
|
|
251
|
+
pageNumber?: number;
|
|
252
|
+
/**
|
|
253
|
+
* Report a page the RENDERER navigated to on its own — turning to a cited
|
|
254
|
+
* page, or (once pages scroll continuously) the page scrolled into view. The
|
|
255
|
+
* provider is what keeps the pager's number honest.
|
|
256
|
+
*/
|
|
257
|
+
onPageChange?: (page: number) => void;
|
|
258
|
+
/** The scale, or a fit mode to resolve. Absent means the renderer's own default. */
|
|
259
|
+
zoom?: ZoomLevel;
|
|
260
|
+
/**
|
|
261
|
+
* Report the scale a {@link ZoomFit} actually resolved to.
|
|
262
|
+
*
|
|
263
|
+
* Only a renderer knows this — it is the one measuring its viewport. Without
|
|
264
|
+
* it the chrome cannot show a percentage for a fitted page, and the reader's
|
|
265
|
+
* next "zoom in" would step from the last fixed stop rather than from what is
|
|
266
|
+
* on screen. A renderer that never resolves a fit mode never calls it.
|
|
267
|
+
*/
|
|
268
|
+
onZoomResolved?: (scale: number) => void;
|
|
269
|
+
/** Quarter-turns clockwise. Ignored by renderers whose manifest omits `rotate`. */
|
|
270
|
+
rotation?: DocumentRotation;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* What an adapter module exports. `manifest` is re-declared here so the registry
|
|
275
|
+
* can verify the loaded module is the one the eager manifest promised.
|
|
276
|
+
*/
|
|
277
|
+
export interface AdapterModule {
|
|
278
|
+
manifest: AdapterManifest;
|
|
279
|
+
/** Fresh instance per document — never a shared singleton. */
|
|
280
|
+
create(): FileAdapter;
|
|
281
|
+
Renderer: ComponentType<AdapterRendererProps>;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** A lazy adapter loader. Always `() => import("…")` so nothing heavy is eager. */
|
|
285
|
+
export type AdapterLoader = () => Promise<AdapterModule | { default: AdapterModule }>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Bring the current highlight into view.
|
|
5
|
+
*
|
|
6
|
+
* Pointing at a passage is only half the job — a mark 4,000 lines down that
|
|
7
|
+
* nobody scrolls to is the same as no mark at all. Every text-ish renderer needs
|
|
8
|
+
* this, so it is written once here rather than per adapter.
|
|
9
|
+
*
|
|
10
|
+
* The current highlight is found by the stable `data-slot` selector its painter
|
|
11
|
+
* emits, not by a ref: both painters produce their elements from a list — marks
|
|
12
|
+
* inside `MatchHighlight`, boxes inside a page overlay — so the renderer has no
|
|
13
|
+
* handle on the individual element and should not grow one just for scrolling.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useEffect, type RefObject } from "react";
|
|
17
|
+
|
|
18
|
+
import { useReducedMotion } from "@elabs-ai/components-tokens";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* How the current highlight identifies itself in the DOM, whichever way it was
|
|
22
|
+
* painted: a `<mark>` in flowing text, a box over a page raster, or a whole
|
|
23
|
+
* block plated because the format can only be addressed that coarsely.
|
|
24
|
+
*/
|
|
25
|
+
export const ACTIVE_HIGHLIGHT_SELECTOR =
|
|
26
|
+
'[data-slot="match-highlight-mark"][data-active],[data-slot="highlight-rect"][data-active],' +
|
|
27
|
+
'[data-slot="highlight-block"][data-active]';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Scroll the current highlight inside `containerRef` into view whenever the
|
|
31
|
+
* viewer is pointed somewhere new.
|
|
32
|
+
*
|
|
33
|
+
* `block: "center"` rather than `"nearest"`: a cited passage should land where a
|
|
34
|
+
* reader looks, with its surrounding context visible, not flush against the top
|
|
35
|
+
* edge of the pane. Smooth scrolling is skipped under a reduced-motion
|
|
36
|
+
* preference — an imperative scroll is invisible to the CSS media query, so the
|
|
37
|
+
* check has to be explicit.
|
|
38
|
+
*
|
|
39
|
+
* `paintKey` is anything whose change means the highlights were repainted — a
|
|
40
|
+
* page's box list, a re-tokenized document. A renderer that paints
|
|
41
|
+
* asynchronously (a PDF fetches the page's text after the raster) has no element
|
|
42
|
+
* to scroll to at the moment the active id changes, so it passes the thing it
|
|
43
|
+
* was waiting for and the scroll runs once that arrives.
|
|
44
|
+
*/
|
|
45
|
+
export function useScrollActiveHighlightIntoView(
|
|
46
|
+
containerRef: RefObject<HTMLElement | null>,
|
|
47
|
+
activeHighlightId: string | null | undefined,
|
|
48
|
+
paintKey?: unknown,
|
|
49
|
+
): void {
|
|
50
|
+
const reducedMotion = useReducedMotion();
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!activeHighlightId) return;
|
|
54
|
+
const target = containerRef.current?.querySelector(ACTIVE_HIGHLIGHT_SELECTOR);
|
|
55
|
+
// jsdom ships no `scrollIntoView`; a viewer must not throw in a test run.
|
|
56
|
+
if (!(target instanceof HTMLElement) || typeof target.scrollIntoView !== "function") return;
|
|
57
|
+
target.scrollIntoView({ block: "center", behavior: reducedMotion ? "auto" : "smooth" });
|
|
58
|
+
}, [containerRef, activeHighlightId, paintKey, reducedMotion]);
|
|
59
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The page half of the renderer contract, as one hook.
|
|
5
|
+
*
|
|
6
|
+
* `AdapterRendererProps.pageNumber` is optional on purpose: inside a
|
|
7
|
+
* `FileViewerProvider` the shell owns the page so a control can live anywhere,
|
|
8
|
+
* but a `Renderer` mounted on its own — a test, an embed that wants nothing but
|
|
9
|
+
* the canvas — must still be able to turn a page. That is the ordinary
|
|
10
|
+
* controlled/uncontrolled trio (`component-api.md`), and both paginated adapters
|
|
11
|
+
* need exactly the same six lines of it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useCallback, useState } from "react";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param pageNumber The controlled page, or `undefined` for uncontrolled.
|
|
18
|
+
* @param onPageChange Reported on every navigation, controlled or not.
|
|
19
|
+
* @param pageCount Upper bound. `0` while the count is not known yet.
|
|
20
|
+
* @returns The page to render, and the function that navigates to another.
|
|
21
|
+
*/
|
|
22
|
+
export function usePageControl(
|
|
23
|
+
pageNumber: number | undefined,
|
|
24
|
+
onPageChange: ((page: number) => void) | undefined,
|
|
25
|
+
pageCount: number,
|
|
26
|
+
): readonly [number, (page: number) => void] {
|
|
27
|
+
const controlled = pageNumber !== undefined;
|
|
28
|
+
const [own, setOwn] = useState(1);
|
|
29
|
+
|
|
30
|
+
// Clamped on READ, not on write: a controlled owner that has not yet heard
|
|
31
|
+
// about a shorter document would otherwise render page 40 of 12 and blank the
|
|
32
|
+
// canvas, and clamping its prop back at it would fight the owner.
|
|
33
|
+
const raw = controlled ? pageNumber : own;
|
|
34
|
+
const current = pageCount > 0 ? Math.min(Math.max(1, raw), pageCount) : Math.max(1, raw);
|
|
35
|
+
|
|
36
|
+
const goToPage = useCallback(
|
|
37
|
+
(page: number) => {
|
|
38
|
+
const next = pageCount > 0 ? Math.min(Math.max(1, page), pageCount) : Math.max(1, page);
|
|
39
|
+
// Mirroring the platform: a controlled value is never written locally.
|
|
40
|
+
if (!controlled) setOwn(next);
|
|
41
|
+
onPageChange?.(next);
|
|
42
|
+
},
|
|
43
|
+
[controlled, onPageChange, pageCount],
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
return [current, goToPage] as const;
|
|
47
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Continuous scrolling for a paginated document, over the shell's own viewport.
|
|
5
|
+
*
|
|
6
|
+
* ## Why the pages are virtualized
|
|
7
|
+
*
|
|
8
|
+
* A 900-page PDF is 900 canvases, 900 text layers and 900 rasterization jobs. A
|
|
9
|
+
* plain stack would allocate all of them before the first page appeared. Only the
|
|
10
|
+
* pages near the viewport are mounted, so the cost is a function of the WINDOW,
|
|
11
|
+
* not of the document.
|
|
12
|
+
*
|
|
13
|
+
* ## Why the virtualizer scrolls someone else's element
|
|
14
|
+
*
|
|
15
|
+
* `FileViewerContent` is the one scroll boundary (`viewer-components.md`), so the
|
|
16
|
+
* list is a CHILD of the scrolling box rather than being one. That is what
|
|
17
|
+
* `scrollMargin` is for: every measurement is offset by where the list starts
|
|
18
|
+
* inside the pane, which is the pane's own padding.
|
|
19
|
+
*
|
|
20
|
+
* ## The page number is a two-way binding, and that is the whole difficulty
|
|
21
|
+
*
|
|
22
|
+
* The shell owns `pageNumber` (ADR 0026), so it flows two ways: the reader
|
|
23
|
+
* scrolls and the pager must follow; the reader types "7" and the document must
|
|
24
|
+
* scroll. Naively wiring both makes them fight — a scroll reports page 3, the
|
|
25
|
+
* prop becomes 3, and the "the prop changed, scroll to it" effect snaps the
|
|
26
|
+
* reader back to the top of page 3 mid-gesture. Each direction therefore
|
|
27
|
+
* remembers what it last said, and ignores an echo of its own words.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { useVirtualizer, type Virtualizer } from "@tanstack/react-virtual";
|
|
31
|
+
import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react";
|
|
32
|
+
|
|
33
|
+
import { findScrollHost } from "./scroll-host";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Slack under the viewport's top edge, in CSS pixels.
|
|
37
|
+
*
|
|
38
|
+
* Without it the page whose last pixel row is still visible counts as the page
|
|
39
|
+
* being read, so the pager flickers back a page at every boundary.
|
|
40
|
+
*/
|
|
41
|
+
const EDGE_SLACK = 8;
|
|
42
|
+
|
|
43
|
+
export interface PagedScrollOptions {
|
|
44
|
+
/** Pages in the document. `0` disables the virtualizer entirely. */
|
|
45
|
+
count: number;
|
|
46
|
+
/**
|
|
47
|
+
* The element the pages are laid out in. Must be a descendant of the scroll
|
|
48
|
+
* host — its `offsetTop` is what `scrollMargin` is derived from.
|
|
49
|
+
*/
|
|
50
|
+
listRef: RefObject<HTMLElement | null>;
|
|
51
|
+
/** The page the shell wants shown, 1-based. */
|
|
52
|
+
pageNumber: number;
|
|
53
|
+
/** Report the page the reader scrolled to, 1-based. */
|
|
54
|
+
goToPage: (page: number) => void;
|
|
55
|
+
/** Height of page `index` at the current scale, in CSS pixels. */
|
|
56
|
+
estimateSize: (index: number) => number;
|
|
57
|
+
/**
|
|
58
|
+
* A value that changes whenever EVERY page's size changes — the scale, in
|
|
59
|
+
* practice. Every cached measurement is thrown away and the reader is put back
|
|
60
|
+
* where they were reading, at the new size.
|
|
61
|
+
*/
|
|
62
|
+
sizeKey?: unknown;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @returns The virtualizer. Render `getVirtualItems()` inside `listRef`'s
|
|
67
|
+
* element, sized to `getTotalSize()`, translated by `start - scrollMargin`.
|
|
68
|
+
*/
|
|
69
|
+
export function usePagedScroll({
|
|
70
|
+
count,
|
|
71
|
+
listRef,
|
|
72
|
+
pageNumber,
|
|
73
|
+
goToPage,
|
|
74
|
+
estimateSize,
|
|
75
|
+
sizeKey,
|
|
76
|
+
}: PagedScrollOptions): Virtualizer<HTMLElement, Element> {
|
|
77
|
+
// Resolved after mount rather than passed in: a renderer may be composed into
|
|
78
|
+
// the shell, into a consumer's own frame, or mounted bare in a test, and only
|
|
79
|
+
// the DOM knows which. State (not a ref) so the virtualizer re-reads it.
|
|
80
|
+
const [host, setHost] = useState<HTMLElement | null>(null);
|
|
81
|
+
const [scrollMargin, setScrollMargin] = useState(0);
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
const list = listRef.current;
|
|
84
|
+
const found = findScrollHost(list) ?? list?.parentElement ?? null;
|
|
85
|
+
setHost(found);
|
|
86
|
+
if (!list || !found) return;
|
|
87
|
+
// Deliberately geometric rather than `offsetTop`: `offsetTop` is measured
|
|
88
|
+
// from the nearest POSITIONED ancestor, and the scrolling pane is not one —
|
|
89
|
+
// so it would report the distance to some outer frame and put every page at
|
|
90
|
+
// the wrong offset. This is the list's true start inside the scrolled
|
|
91
|
+
// content, which is the pane's own padding.
|
|
92
|
+
setScrollMargin(
|
|
93
|
+
list.getBoundingClientRect().top - found.getBoundingClientRect().top + found.scrollTop,
|
|
94
|
+
);
|
|
95
|
+
}, [listRef]);
|
|
96
|
+
|
|
97
|
+
const virtualizer = useVirtualizer({
|
|
98
|
+
count,
|
|
99
|
+
getScrollElement: () => host,
|
|
100
|
+
estimateSize,
|
|
101
|
+
overscan: 1,
|
|
102
|
+
scrollMargin,
|
|
103
|
+
measureElement: (element, entry, instance) => {
|
|
104
|
+
const measured =
|
|
105
|
+
entry?.borderBoxSize?.[0]?.blockSize ?? element.getBoundingClientRect().height;
|
|
106
|
+
// A hidden pane measures zero, and so does a layout-less environment.
|
|
107
|
+
// Recording that would collapse the page to nothing and — because the
|
|
108
|
+
// measurement is cached — leave it collapsed after the pane is shown
|
|
109
|
+
// again. Keeping the estimate is the same guard `useViewportSize` carries,
|
|
110
|
+
// for the same reason.
|
|
111
|
+
if (measured > 0) return measured;
|
|
112
|
+
const index = Number(element.getAttribute("data-index"));
|
|
113
|
+
return Number.isNaN(index) ? 0 : instance.options.estimateSize(index);
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const items = virtualizer.getVirtualItems();
|
|
118
|
+
const scrollOffset = virtualizer.scrollOffset ?? 0;
|
|
119
|
+
|
|
120
|
+
// The page under the viewport's top edge — the one a reader would name if you
|
|
121
|
+
// asked what page they are on.
|
|
122
|
+
const top = items.find((item) => item.start + item.size > scrollOffset + EDGE_SLACK);
|
|
123
|
+
const active = top ? top.index + 1 : 1;
|
|
124
|
+
|
|
125
|
+
// Where the reader is WITHIN that page, so a scale change can put them back.
|
|
126
|
+
// Recorded on every scroll rather than captured at the moment of the change:
|
|
127
|
+
// by then the measurements have already been reset.
|
|
128
|
+
const anchor = useRef({ index: 0, fraction: 0 });
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (!top || top.size <= 0) return;
|
|
131
|
+
// Deliberately NOT clamped at the low end. At the very top of a document the
|
|
132
|
+
// reader is ABOVE page 1's start — the pane's own padding sits between them —
|
|
133
|
+
// and flooring that to 0 would scroll the padding away the first time the
|
|
134
|
+
// scale resolves, so simply opening a file nudges the view.
|
|
135
|
+
const fraction = (scrollOffset - top.start) / top.size;
|
|
136
|
+
anchor.current = { index: top.index, fraction: Math.min(fraction, 1) };
|
|
137
|
+
}, [top, scrollOffset]);
|
|
138
|
+
|
|
139
|
+
// ── The page number, in both directions ──────────────────────────────────
|
|
140
|
+
// Both start at 0 — a page number that cannot occur — so the FIRST value of
|
|
141
|
+
// `pageNumber` still counts as something the shell asked for. A shell that
|
|
142
|
+
// mounts already pointing at page 140 (a deep link, a citation restored from a
|
|
143
|
+
// URL) has made a request that nothing has honoured yet.
|
|
144
|
+
const reported = useRef(0);
|
|
145
|
+
const requested = useRef(0);
|
|
146
|
+
|
|
147
|
+
const lastActive = useRef(active);
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
// Only a CHANGE in the page under the top edge is news. Reporting the
|
|
150
|
+
// initial value would answer the deep link above with "you are on page 1"
|
|
151
|
+
// before the scroll that honours it has even been attempted.
|
|
152
|
+
if (count === 0 || active === lastActive.current) return;
|
|
153
|
+
lastActive.current = active;
|
|
154
|
+
if (active === pageNumber) return;
|
|
155
|
+
reported.current = active;
|
|
156
|
+
goToPage(active);
|
|
157
|
+
}, [active, pageNumber, count, goToPage]);
|
|
158
|
+
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
// `host` is resolved in an effect, so the first pass through here has
|
|
161
|
+
// nothing to scroll — and virtual-core silently drops a scroll with no
|
|
162
|
+
// element. Waiting for it is what makes the mount-time request land.
|
|
163
|
+
if (!host || count === 0 || requested.current === pageNumber) return;
|
|
164
|
+
requested.current = pageNumber;
|
|
165
|
+
// Our own report coming back around. Scrolling here would fight the gesture
|
|
166
|
+
// that produced it.
|
|
167
|
+
if (pageNumber === reported.current || pageNumber === active) return;
|
|
168
|
+
virtualizer.scrollToIndex(pageNumber - 1, { align: "start" });
|
|
169
|
+
}, [host, pageNumber, active, count, virtualizer]);
|
|
170
|
+
|
|
171
|
+
// ── Re-anchor after a scale change ───────────────────────────────────────
|
|
172
|
+
const lastSizeKey = useRef(sizeKey);
|
|
173
|
+
useLayoutEffect(() => {
|
|
174
|
+
if (lastSizeKey.current === sizeKey) return;
|
|
175
|
+
lastSizeKey.current = sizeKey;
|
|
176
|
+
if (count === 0) return;
|
|
177
|
+
|
|
178
|
+
// Measured heights describe the OLD scale, so keeping them would place every
|
|
179
|
+
// page at the wrong offset until it happened to be re-measured.
|
|
180
|
+
virtualizer.measure();
|
|
181
|
+
|
|
182
|
+
const { index, fraction } = anchor.current;
|
|
183
|
+
// At the very top there is no reading position to preserve, and scrolling to
|
|
184
|
+
// page 1's own start would scroll the pane's top padding away — so simply
|
|
185
|
+
// opening a file and letting its fit resolve would nudge the view.
|
|
186
|
+
if (index === 0 && fraction <= 0) return;
|
|
187
|
+
const start = virtualizer.getOffsetForIndex(index, "start")?.[0];
|
|
188
|
+
if (start === undefined) return;
|
|
189
|
+
const size = virtualizer.measurementsCache[index]?.size ?? 0;
|
|
190
|
+
virtualizer.scrollToOffset(start + fraction * size, { align: "start" });
|
|
191
|
+
}, [sizeKey, count, virtualizer]);
|
|
192
|
+
|
|
193
|
+
return virtualizer;
|
|
194
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The size of the box a renderer scrolls inside, kept current.
|
|
5
|
+
*
|
|
6
|
+
* Needed because a fit-to-width scale is a function of the VIEWPORT, not of the
|
|
7
|
+
* document: only the renderer can compute it, and only by measuring. Measures
|
|
8
|
+
* the container rather than the window (`window.innerWidth` would be wrong the
|
|
9
|
+
* moment a viewer sits in a resizable pane, a dialog or a split workspace).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useEffect, useState, type RefObject } from "react";
|
|
13
|
+
|
|
14
|
+
import { findScrollHost } from "./scroll-host";
|
|
15
|
+
|
|
16
|
+
export interface ViewportSize {
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param ref An element inside the scrolling box — typically the renderer's root.
|
|
23
|
+
* @returns The scroll host's content size, or `undefined` until it is measured.
|
|
24
|
+
*/
|
|
25
|
+
export function useViewportSize(ref: RefObject<HTMLElement | null>): ViewportSize | undefined {
|
|
26
|
+
const [size, setSize] = useState<ViewportSize>();
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const host = findScrollHost(ref.current) ?? ref.current?.parentElement ?? ref.current;
|
|
30
|
+
if (!host || typeof ResizeObserver === "undefined") return;
|
|
31
|
+
|
|
32
|
+
const measure = () => {
|
|
33
|
+
const { clientWidth: width, clientHeight: height } = host;
|
|
34
|
+
// A hidden pane measures zero. Re-fitting to a zero-width viewport would
|
|
35
|
+
// rasterize a zero-width page and clear the canvas — and nothing would
|
|
36
|
+
// measure again when the pane is shown, because its size did not change
|
|
37
|
+
// from the observer's point of view. Ignoring the reading keeps the last
|
|
38
|
+
// good one. (The guard extend-hq/ui's `useElementWidth` carries too.)
|
|
39
|
+
if (width === 0 || height === 0) return;
|
|
40
|
+
setSize((current) =>
|
|
41
|
+
current && Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1
|
|
42
|
+
? current
|
|
43
|
+
: { width, height },
|
|
44
|
+
);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
measure();
|
|
48
|
+
const observer = new ResizeObserver(measure);
|
|
49
|
+
observer.observe(host);
|
|
50
|
+
return () => observer.disconnect();
|
|
51
|
+
}, [ref]);
|
|
52
|
+
|
|
53
|
+
return size;
|
|
54
|
+
}
|