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