@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,947 @@
|
|
|
1
|
+
import { createEvent, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
import type { DocumentAddressKind } from "@elabs-ai/components-ui";
|
|
7
|
+
|
|
8
|
+
import type { DocumentHighlight } from "../core/highlight";
|
|
9
|
+
import { createRegistry } from "../core/registry";
|
|
10
|
+
import {
|
|
11
|
+
PROTOCOL_VERSION,
|
|
12
|
+
type AdapterManifest,
|
|
13
|
+
type AdapterModule,
|
|
14
|
+
type AdapterRendererProps,
|
|
15
|
+
} from "../core/types";
|
|
16
|
+
import { FileViewer, FileViewerContent, FileViewerFrame, FileViewerProvider } from "./file-viewer";
|
|
17
|
+
import { useFileViewer } from "./file-viewer-context";
|
|
18
|
+
|
|
19
|
+
const textSource = { kind: "text", text: "hello world", name: "notes.txt" } as const;
|
|
20
|
+
|
|
21
|
+
/** A registry with only the text adapter — enough to exercise the state grid. */
|
|
22
|
+
function textOnlyRegistry() {
|
|
23
|
+
const registry = createRegistry();
|
|
24
|
+
registry.register(
|
|
25
|
+
{
|
|
26
|
+
id: "text",
|
|
27
|
+
protocol: PROTOCOL_VERSION,
|
|
28
|
+
categories: ["text"],
|
|
29
|
+
mediaTypes: ["text/"],
|
|
30
|
+
capabilities: { text: true },
|
|
31
|
+
},
|
|
32
|
+
() => import("../adapters/text/text-adapter"),
|
|
33
|
+
);
|
|
34
|
+
return registry;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("FileViewer — the state grid", () => {
|
|
38
|
+
it("shows the empty state with no source, and does not call it an error", () => {
|
|
39
|
+
render(<FileViewer registry={textOnlyRegistry()} />);
|
|
40
|
+
expect(screen.getByText("No file selected")).toBeInTheDocument();
|
|
41
|
+
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("announces loading once, then renders the file", async () => {
|
|
45
|
+
render(<FileViewer source={textSource} registry={textOnlyRegistry()} />);
|
|
46
|
+
|
|
47
|
+
// The skeleton is decorative; the single live region is what AT hears.
|
|
48
|
+
expect(screen.getByRole("status")).toHaveTextContent("Loading notes.txt…");
|
|
49
|
+
await waitFor(() => {
|
|
50
|
+
expect(screen.getByText("hello world")).toBeInTheDocument();
|
|
51
|
+
});
|
|
52
|
+
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("reports a file no adapter claims as unsupported, with no retry offered", async () => {
|
|
56
|
+
render(
|
|
57
|
+
<FileViewer
|
|
58
|
+
source={{ kind: "text", text: "x", name: "thing.pdf", mediaType: "application/pdf" }}
|
|
59
|
+
registry={textOnlyRegistry()}
|
|
60
|
+
/>,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
await waitFor(() => {
|
|
64
|
+
expect(screen.getByText("Can't preview this file type")).toBeInTheDocument();
|
|
65
|
+
});
|
|
66
|
+
// Retrying cannot change the outcome — offering it would teach the user the
|
|
67
|
+
// button does nothing.
|
|
68
|
+
expect(screen.queryByRole("button", { name: "Try again" })).not.toBeInTheDocument();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("names the missing package when an optional peer is not installed", async () => {
|
|
72
|
+
const registry = createRegistry();
|
|
73
|
+
const manifest: AdapterManifest = {
|
|
74
|
+
id: "csv",
|
|
75
|
+
protocol: PROTOCOL_VERSION,
|
|
76
|
+
extensions: ["csv"],
|
|
77
|
+
requires: ["papaparse"],
|
|
78
|
+
};
|
|
79
|
+
registry.register(manifest, () => Promise.reject(new Error("Cannot find module 'papaparse'")));
|
|
80
|
+
|
|
81
|
+
render(
|
|
82
|
+
<FileViewer source={{ kind: "text", text: "a,b", name: "r.csv" }} registry={registry} />,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
await waitFor(() => {
|
|
86
|
+
expect(screen.getByText(/needs papaparse to be installed/)).toBeInTheDocument();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("names the missing package when the peer is reached from INSIDE load()", async () => {
|
|
91
|
+
// Where every parser engine actually lives: `await import("mammoth")` runs
|
|
92
|
+
// in the adapter's own `load()`, so the module resolves fine and the
|
|
93
|
+
// rejection lands on the provider, not on `registry.load()`. Before this was
|
|
94
|
+
// handled, the reader was told their file was damaged ("isn't a valid file")
|
|
95
|
+
// and offered a retry that could never succeed.
|
|
96
|
+
const registry = createRegistry();
|
|
97
|
+
const manifest: AdapterManifest = {
|
|
98
|
+
id: "docx",
|
|
99
|
+
protocol: PROTOCOL_VERSION,
|
|
100
|
+
extensions: ["docx"],
|
|
101
|
+
requires: ["mammoth"],
|
|
102
|
+
};
|
|
103
|
+
registry.register(manifest, () =>
|
|
104
|
+
Promise.resolve({
|
|
105
|
+
manifest,
|
|
106
|
+
create: () => ({
|
|
107
|
+
load: () => Promise.reject(new Error("Failed to resolve module specifier 'mammoth'")),
|
|
108
|
+
}),
|
|
109
|
+
Renderer: () => null,
|
|
110
|
+
} satisfies AdapterModule),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
render(
|
|
114
|
+
<FileViewer source={{ kind: "text", text: "x", name: "report.docx" }} registry={registry} />,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
await waitFor(() => {
|
|
118
|
+
expect(screen.getByText(/needs mammoth to be installed/)).toBeInTheDocument();
|
|
119
|
+
});
|
|
120
|
+
// A capability gap, so: no alarm, and no retry that cannot work.
|
|
121
|
+
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
|
122
|
+
expect(screen.queryByRole("button", { name: "Try again" })).not.toBeInTheDocument();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("presents a capability gap as news, not as an alarm", async () => {
|
|
126
|
+
render(
|
|
127
|
+
<FileViewer
|
|
128
|
+
source={{ kind: "text", text: "x", name: "thing.pdf", mediaType: "application/pdf" }}
|
|
129
|
+
registry={textOnlyRegistry()}
|
|
130
|
+
/>,
|
|
131
|
+
);
|
|
132
|
+
await screen.findByText("Can't preview this file type");
|
|
133
|
+
|
|
134
|
+
// "This build can't draw PDFs" is not the reader's failure, and `alert`
|
|
135
|
+
// interrupts. It is still announced — politely.
|
|
136
|
+
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
|
137
|
+
expect(screen.getByRole("status")).toHaveTextContent("Can't preview this file type");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("keeps a real failure an alert", async () => {
|
|
141
|
+
const registry = createRegistry();
|
|
142
|
+
registry.register({ id: "bad", protocol: PROTOCOL_VERSION, categories: ["text"] }, () =>
|
|
143
|
+
Promise.resolve({
|
|
144
|
+
manifest: { id: "bad", protocol: PROTOCOL_VERSION, categories: ["text"] },
|
|
145
|
+
create: () => ({ load: () => Promise.reject(new Error("bad bytes")) }),
|
|
146
|
+
Renderer: () => null,
|
|
147
|
+
} satisfies AdapterModule),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
render(<FileViewer source={textSource} registry={registry} />);
|
|
151
|
+
await screen.findByText("Couldn't read this file");
|
|
152
|
+
expect(screen.getByRole("alert")).toBeInTheDocument();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("offers a retry for a read failure, and re-runs the load", async () => {
|
|
156
|
+
const load = vi
|
|
157
|
+
.fn()
|
|
158
|
+
.mockRejectedValueOnce(new Error("network died"))
|
|
159
|
+
.mockResolvedValueOnce({ kind: "text", text: "second try" });
|
|
160
|
+
const adapterModule: AdapterModule = {
|
|
161
|
+
manifest: { id: "flaky", protocol: PROTOCOL_VERSION, categories: ["text"] },
|
|
162
|
+
create: () => ({ load }),
|
|
163
|
+
Renderer: ({ document: doc }) => <p>{(doc as { text: string }).text}</p>,
|
|
164
|
+
};
|
|
165
|
+
const registry = createRegistry();
|
|
166
|
+
registry.register(adapterModule.manifest, () => Promise.resolve(adapterModule));
|
|
167
|
+
|
|
168
|
+
render(<FileViewer source={textSource} registry={registry} />);
|
|
169
|
+
|
|
170
|
+
const retry = await screen.findByRole("button", { name: "Try again" });
|
|
171
|
+
await userEvent.click(retry);
|
|
172
|
+
|
|
173
|
+
await waitFor(() => {
|
|
174
|
+
expect(screen.getByText("second try")).toBeInTheDocument();
|
|
175
|
+
});
|
|
176
|
+
expect(load).toHaveBeenCalledTimes(2);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("keeps a parent's loading prop additive — it cannot clear a real error", async () => {
|
|
180
|
+
const registry = createRegistry();
|
|
181
|
+
registry.register({ id: "bad", protocol: PROTOCOL_VERSION, categories: ["text"] }, () =>
|
|
182
|
+
Promise.resolve({
|
|
183
|
+
manifest: { id: "bad", protocol: PROTOCOL_VERSION, categories: ["text"] },
|
|
184
|
+
create: () => ({ load: () => Promise.reject(new Error("bad bytes")) }),
|
|
185
|
+
Renderer: () => null,
|
|
186
|
+
} satisfies AdapterModule),
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const { rerender } = render(<FileViewer source={textSource} registry={registry} />);
|
|
190
|
+
await screen.findByText("Couldn't read this file");
|
|
191
|
+
|
|
192
|
+
rerender(<FileViewer source={textSource} registry={registry} loading />);
|
|
193
|
+
expect(screen.getByText("Couldn't read this file")).toBeInTheDocument();
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("FileViewer — chrome", () => {
|
|
198
|
+
it("names the region and the file, and offers a download", async () => {
|
|
199
|
+
render(<FileViewer source={textSource} registry={textOnlyRegistry()} />);
|
|
200
|
+
|
|
201
|
+
expect(screen.getByRole("region", { name: "File viewer" })).toBeInTheDocument();
|
|
202
|
+
expect(screen.getByText("notes.txt")).toBeInTheDocument();
|
|
203
|
+
expect(await screen.findByRole("button", { name: "Download notes.txt" })).toBeInTheDocument();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("renders no identity row when there is no file", () => {
|
|
207
|
+
render(<FileViewer registry={textOnlyRegistry()} />);
|
|
208
|
+
// A row holding a generic glyph and a blank name reads as a broken render.
|
|
209
|
+
expect(document.querySelector('[data-slot="file-viewer-toolbar"]')).toBeNull();
|
|
210
|
+
expect(screen.queryByRole("button", { name: /Download/ })).not.toBeInTheDocument();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("makes the scrolling content pane a real keyboard stop", async () => {
|
|
214
|
+
render(<FileViewer source={textSource} registry={textOnlyRegistry()} />);
|
|
215
|
+
await screen.findByText("hello world");
|
|
216
|
+
|
|
217
|
+
// A plain-text file contains nothing focusable, so without this the pane
|
|
218
|
+
// scrolls for a mouse and is unreachable from a keyboard (WCAG 2.1.1).
|
|
219
|
+
const content = screen.getByRole("region", { name: "File content" });
|
|
220
|
+
expect(content).toHaveAttribute("tabindex", "0");
|
|
221
|
+
|
|
222
|
+
await userEvent.tab();
|
|
223
|
+
await userEvent.tab();
|
|
224
|
+
expect(content).toHaveFocus();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("keeps ONE scroll boundary — a flowing adapter does not nest a second", async () => {
|
|
228
|
+
// Two nested `overflow-auto` boxes do not compose: the inner one clips
|
|
229
|
+
// while the outer one's padding stays put, so a long file ends flush
|
|
230
|
+
// against a band of whitespace and reads as a failed render.
|
|
231
|
+
render(<FileViewer source={textSource} registry={textOnlyRegistry()} />);
|
|
232
|
+
const content = await screen.findByRole("region", { name: "File content" });
|
|
233
|
+
expect(content.querySelector(".overflow-auto")).toBeNull();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("does not claim role=toolbar — that promises arrow-key navigation it lacks", () => {
|
|
237
|
+
render(<FileViewer source={textSource} registry={textOnlyRegistry()} />);
|
|
238
|
+
expect(screen.queryByRole("toolbar")).not.toBeInTheDocument();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("replaces the default composition when children are given", () => {
|
|
242
|
+
render(
|
|
243
|
+
<FileViewer source={textSource} registry={textOnlyRegistry()}>
|
|
244
|
+
<p>custom</p>
|
|
245
|
+
</FileViewer>,
|
|
246
|
+
);
|
|
247
|
+
expect(screen.getByText("custom")).toBeInTheDocument();
|
|
248
|
+
expect(screen.queryByRole("button", { name: /Download/ })).not.toBeInTheDocument();
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
/* -------------------------------------------------------------------------- */
|
|
253
|
+
/* Page, zoom and rotation (ADR 0026) */
|
|
254
|
+
/* -------------------------------------------------------------------------- */
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* A three-page format that claims every view capability and echoes back what the
|
|
258
|
+
* shell handed it.
|
|
259
|
+
*
|
|
260
|
+
* The echo is the point: page, zoom and rotation used to be `useState` inside
|
|
261
|
+
* each renderer, so the only way to observe them was through that renderer's own
|
|
262
|
+
* chrome. Now the shell owns them and the adapter is downstream — which is what
|
|
263
|
+
* these assertions check.
|
|
264
|
+
*/
|
|
265
|
+
function pagedRegistry(
|
|
266
|
+
capabilities: AdapterManifest["capabilities"] = { pages: true, zoom: true, rotate: true },
|
|
267
|
+
reportZoom?: number,
|
|
268
|
+
) {
|
|
269
|
+
const manifest: AdapterManifest = {
|
|
270
|
+
id: "paged",
|
|
271
|
+
protocol: PROTOCOL_VERSION,
|
|
272
|
+
categories: ["text"],
|
|
273
|
+
capabilities,
|
|
274
|
+
};
|
|
275
|
+
const registry = createRegistry();
|
|
276
|
+
registry.register(manifest, () =>
|
|
277
|
+
Promise.resolve({
|
|
278
|
+
manifest,
|
|
279
|
+
create: () => ({ load: () => Promise.resolve({ kind: "paged", pageCount: 3 }) }),
|
|
280
|
+
Renderer: ({ pageNumber, zoom, rotation, onZoomResolved }: AdapterRendererProps) => {
|
|
281
|
+
// Standing in for the real thing: only the renderer can measure its own
|
|
282
|
+
// viewport, so a fit mode is resolved down here and reported back up.
|
|
283
|
+
useEffect(() => {
|
|
284
|
+
if (reportZoom !== undefined) onZoomResolved?.(reportZoom);
|
|
285
|
+
}, [onZoomResolved]);
|
|
286
|
+
return <div data-testid="view">{`${pageNumber}|${String(zoom)}|${rotation}`}</div>;
|
|
287
|
+
},
|
|
288
|
+
} satisfies AdapterModule),
|
|
289
|
+
);
|
|
290
|
+
return registry;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const view = () => screen.getByTestId("view").textContent;
|
|
294
|
+
|
|
295
|
+
describe("FileViewer — paging", () => {
|
|
296
|
+
it("shows no page control for a format that has no pages", async () => {
|
|
297
|
+
render(<FileViewer source={textSource} registry={textOnlyRegistry()} />);
|
|
298
|
+
await screen.findByText("hello world");
|
|
299
|
+
// An inert "1 of 0" over a text file is chrome with nothing in it.
|
|
300
|
+
expect(screen.queryByRole("group", { name: "Pages" })).not.toBeInTheDocument();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("pages with the buttons and stops at both ends", async () => {
|
|
304
|
+
render(<FileViewer source={textSource} registry={pagedRegistry()} />);
|
|
305
|
+
await screen.findByTestId("view");
|
|
306
|
+
|
|
307
|
+
const next = screen.getByRole("button", { name: "Next page" });
|
|
308
|
+
const previous = screen.getByRole("button", { name: "Previous page" });
|
|
309
|
+
expect(previous).toBeDisabled();
|
|
310
|
+
expect(screen.getByText("of 3")).toBeInTheDocument();
|
|
311
|
+
|
|
312
|
+
await userEvent.click(next);
|
|
313
|
+
expect(view()).toBe("2|fit-width|0");
|
|
314
|
+
await userEvent.click(next);
|
|
315
|
+
expect(next).toBeDisabled();
|
|
316
|
+
|
|
317
|
+
await userEvent.click(previous);
|
|
318
|
+
expect(view()).toBe("2|fit-width|0");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("takes a typed page number, and clamps one past the end", async () => {
|
|
322
|
+
render(<FileViewer source={textSource} registry={pagedRegistry()} />);
|
|
323
|
+
await screen.findByTestId("view");
|
|
324
|
+
|
|
325
|
+
const field = screen.getByRole("textbox", { name: "Page number" });
|
|
326
|
+
await userEvent.clear(field);
|
|
327
|
+
await userEvent.type(field, "3{Enter}");
|
|
328
|
+
expect(view()).toBe("3|fit-width|0");
|
|
329
|
+
|
|
330
|
+
await userEvent.clear(field);
|
|
331
|
+
await userEvent.type(field, "99{Enter}");
|
|
332
|
+
expect(view()).toBe("3|fit-width|0");
|
|
333
|
+
expect(field).toHaveValue("3");
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("abandons a half-typed page on Escape instead of navigating", async () => {
|
|
337
|
+
render(<FileViewer source={textSource} registry={pagedRegistry()} />);
|
|
338
|
+
await screen.findByTestId("view");
|
|
339
|
+
|
|
340
|
+
const field = screen.getByRole("textbox", { name: "Page number" });
|
|
341
|
+
await userEvent.clear(field);
|
|
342
|
+
await userEvent.type(field, "2{Escape}");
|
|
343
|
+
expect(view()).toBe("1|fit-width|0");
|
|
344
|
+
expect(field).toHaveValue("1");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("says where the reader is, in ONE live region", async () => {
|
|
348
|
+
render(<FileViewer source={textSource} registry={pagedRegistry()} />);
|
|
349
|
+
await screen.findByTestId("view");
|
|
350
|
+
|
|
351
|
+
// Paging repaints a canvas, which announces nothing on its own — and one
|
|
352
|
+
// region for the group, not one per control (loading-states.md).
|
|
353
|
+
const live = screen.getByText("Page 1 of 3");
|
|
354
|
+
expect(live).toHaveAttribute("aria-live", "polite");
|
|
355
|
+
await userEvent.click(screen.getByRole("button", { name: "Next page" }));
|
|
356
|
+
expect(screen.getByText("Page 2 of 3")).toBeInTheDocument();
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("lets an app own the page — controlled means controlled", async () => {
|
|
360
|
+
const onPageNumberChange = vi.fn();
|
|
361
|
+
render(
|
|
362
|
+
<FileViewer
|
|
363
|
+
source={textSource}
|
|
364
|
+
registry={pagedRegistry()}
|
|
365
|
+
pageNumber={2}
|
|
366
|
+
onPageNumberChange={onPageNumberChange}
|
|
367
|
+
/>,
|
|
368
|
+
);
|
|
369
|
+
await screen.findByTestId("view");
|
|
370
|
+
expect(view()).toBe("2|fit-width|0");
|
|
371
|
+
|
|
372
|
+
await userEvent.click(screen.getByRole("button", { name: "Next page" }));
|
|
373
|
+
expect(onPageNumberChange).toHaveBeenCalledWith(3);
|
|
374
|
+
// The app did not move it, so it did not move — a deep-linked page that
|
|
375
|
+
// drifted out from under its own URL would be worse than an inert button.
|
|
376
|
+
expect(view()).toBe("2|fit-width|0");
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
describe("FileViewer — zoom and rotation", () => {
|
|
381
|
+
it("shows neither control for a format that claims neither", async () => {
|
|
382
|
+
render(<FileViewer source={textSource} registry={pagedRegistry({ pages: true })} />);
|
|
383
|
+
await screen.findByTestId("view");
|
|
384
|
+
expect(screen.queryByRole("group", { name: "Zoom" })).not.toBeInTheDocument();
|
|
385
|
+
expect(screen.queryByRole("button", { name: "Rotate clockwise" })).not.toBeInTheDocument();
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("asks for a fit by default and lets the renderer resolve it", async () => {
|
|
389
|
+
render(<FileViewer source={textSource} registry={pagedRegistry(undefined, 1.5)} />);
|
|
390
|
+
await screen.findByTestId("view");
|
|
391
|
+
|
|
392
|
+
// The state is the REQUEST ("fit-width"); the percentage is what came back.
|
|
393
|
+
expect(view()).toBe("1|fit-width|0");
|
|
394
|
+
expect(screen.getByRole("combobox", { name: "Zoom level" })).toHaveTextContent("Fit width");
|
|
395
|
+
await waitFor(() => expect(screen.getByText("Zoom 150%")).toBeInTheDocument());
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("steps from what is ON SCREEN, not from the last number asked for", async () => {
|
|
399
|
+
// A page fitted to 150% steps to 200%, not to 125% — otherwise the first
|
|
400
|
+
// press of "+" makes the page smaller.
|
|
401
|
+
render(<FileViewer source={textSource} registry={pagedRegistry(undefined, 1.5)} />);
|
|
402
|
+
await screen.findByTestId("view");
|
|
403
|
+
await waitFor(() => expect(screen.getByText("Zoom 150%")).toBeInTheDocument());
|
|
404
|
+
|
|
405
|
+
await userEvent.click(screen.getByRole("button", { name: "Zoom in" }));
|
|
406
|
+
expect(view()).toBe("1|2|0");
|
|
407
|
+
expect(screen.getByRole("combobox", { name: "Zoom level" })).toHaveTextContent("200%");
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("stops at both ends of the ladder", async () => {
|
|
411
|
+
render(<FileViewer source={textSource} registry={pagedRegistry()} defaultZoom={3} />);
|
|
412
|
+
await screen.findByTestId("view");
|
|
413
|
+
expect(screen.getByRole("button", { name: "Zoom in" })).toBeDisabled();
|
|
414
|
+
|
|
415
|
+
await userEvent.click(screen.getByRole("button", { name: "Zoom out" }));
|
|
416
|
+
expect(view()).toBe("1|2|0");
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("turns the document a quarter at a time, all the way round", async () => {
|
|
420
|
+
render(<FileViewer source={textSource} registry={pagedRegistry()} />);
|
|
421
|
+
await screen.findByTestId("view");
|
|
422
|
+
|
|
423
|
+
const rotate = screen.getByRole("button", { name: "Rotate clockwise" });
|
|
424
|
+
for (const expected of ["90", "180", "270", "0"]) {
|
|
425
|
+
await userEvent.click(rotate);
|
|
426
|
+
expect(view()).toBe(`1|fit-width|${expected}`);
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("starts the next file at page 1, the right way up, at the same scale", async () => {
|
|
431
|
+
const { rerender } = render(<FileViewer source={textSource} registry={pagedRegistry()} />);
|
|
432
|
+
await screen.findByTestId("view");
|
|
433
|
+
await userEvent.click(screen.getByRole("button", { name: "Next page" }));
|
|
434
|
+
await userEvent.click(screen.getByRole("button", { name: "Rotate clockwise" }));
|
|
435
|
+
await userEvent.click(screen.getByRole("button", { name: "Zoom in" }));
|
|
436
|
+
expect(view()).toBe("2|1.25|90");
|
|
437
|
+
|
|
438
|
+
rerender(
|
|
439
|
+
<FileViewer
|
|
440
|
+
source={{ kind: "text", text: "other", name: "other.txt" }}
|
|
441
|
+
registry={pagedRegistry()}
|
|
442
|
+
/>,
|
|
443
|
+
);
|
|
444
|
+
// Page and rotation belong to the document. The scale is the READER's
|
|
445
|
+
// preference — resetting it would undo their choice on every file.
|
|
446
|
+
await waitFor(() => expect(view()).toBe("1|1.25|0"));
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
/* -------------------------------------------------------------------------- */
|
|
451
|
+
/* Highlights */
|
|
452
|
+
/* -------------------------------------------------------------------------- */
|
|
453
|
+
|
|
454
|
+
/** Renders what the shell handed it, so a test can assert on the seam itself. */
|
|
455
|
+
function spyRegistry(
|
|
456
|
+
highlight?: readonly DocumentAddressKind[],
|
|
457
|
+
options: { truncated?: boolean } = {},
|
|
458
|
+
) {
|
|
459
|
+
const manifest: AdapterManifest = {
|
|
460
|
+
id: "spy",
|
|
461
|
+
protocol: PROTOCOL_VERSION,
|
|
462
|
+
categories: ["text"],
|
|
463
|
+
capabilities: { text: true, ...(highlight ? { highlight } : {}) },
|
|
464
|
+
};
|
|
465
|
+
const registry = createRegistry();
|
|
466
|
+
registry.register(manifest, () =>
|
|
467
|
+
Promise.resolve({
|
|
468
|
+
manifest,
|
|
469
|
+
create: () => ({
|
|
470
|
+
// Reads the source rather than hard-coding a string, so a test can
|
|
471
|
+
// choose the document text it needs (case sensitivity, no matches).
|
|
472
|
+
load: (source, _context) =>
|
|
473
|
+
source
|
|
474
|
+
.text()
|
|
475
|
+
.then((text) => ({ kind: "spy", text, textTruncated: options.truncated ?? false })),
|
|
476
|
+
}),
|
|
477
|
+
Renderer: ({ highlights = [], activeHighlightId }: AdapterRendererProps) => (
|
|
478
|
+
<ul data-testid="seen">
|
|
479
|
+
<li data-testid="active">{activeHighlightId ?? "none"}</li>
|
|
480
|
+
{highlights.map((entry) => (
|
|
481
|
+
<li key={entry.id}>{`${entry.id}:${entry.status}:${String(entry.active)}`}</li>
|
|
482
|
+
))}
|
|
483
|
+
</ul>
|
|
484
|
+
),
|
|
485
|
+
} satisfies AdapterModule),
|
|
486
|
+
);
|
|
487
|
+
return registry;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const QUOTE: DocumentHighlight = { id: "c1", address: { kind: "quote", text: "WORLD" } };
|
|
491
|
+
|
|
492
|
+
describe("FileViewer — pointing at part of a document", () => {
|
|
493
|
+
it("hands the adapter located highlights, not raw requests", async () => {
|
|
494
|
+
render(
|
|
495
|
+
<FileViewer source={textSource} registry={spyRegistry(["quote"])} highlights={[QUOTE]} />,
|
|
496
|
+
);
|
|
497
|
+
// LOCATE ran in the shell: the quote is case-folded, found, and reported as
|
|
498
|
+
// resolved before the renderer ever sees it.
|
|
499
|
+
expect(await screen.findByText("c1:resolved:false")).toBeInTheDocument();
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it("reports a kind the adapter never declared as unsupported, and still renders", async () => {
|
|
503
|
+
render(<FileViewer source={textSource} registry={spyRegistry()} highlights={[QUOTE]} />);
|
|
504
|
+
expect(await screen.findByText("c1:unsupported:false")).toBeInTheDocument();
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it("forwards every provider prop — including the ones added after it shipped", async () => {
|
|
508
|
+
// The regression this locks: `FileViewer` used to peel off four props by
|
|
509
|
+
// name and spread the rest onto the frame, so `baseHeadingLevel` and every
|
|
510
|
+
// later provider prop type-checked, never reached the provider, and landed
|
|
511
|
+
// on the <section> as an unknown DOM attribute.
|
|
512
|
+
const { container } = render(
|
|
513
|
+
<FileViewer
|
|
514
|
+
source={textSource}
|
|
515
|
+
registry={spyRegistry(["quote"])}
|
|
516
|
+
highlights={[QUOTE]}
|
|
517
|
+
activeHighlightId="c1"
|
|
518
|
+
baseHeadingLevel={1}
|
|
519
|
+
/>,
|
|
520
|
+
);
|
|
521
|
+
expect(await screen.findByText("c1:resolved:true")).toBeInTheDocument();
|
|
522
|
+
expect(container.querySelector("[baseheadinglevel]")).toBeNull();
|
|
523
|
+
expect(container.querySelector("[activehighlightid]")).toBeNull();
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it("owns the active highlight when it is uncontrolled", async () => {
|
|
527
|
+
function Harness() {
|
|
528
|
+
const { actions } = useFileViewer();
|
|
529
|
+
return (
|
|
530
|
+
<>
|
|
531
|
+
<button onClick={actions.nextHighlight}>next</button>
|
|
532
|
+
<FileViewerContent />
|
|
533
|
+
</>
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
render(
|
|
537
|
+
<FileViewer
|
|
538
|
+
source={textSource}
|
|
539
|
+
registry={spyRegistry(["quote"])}
|
|
540
|
+
defaultHighlights={[QUOTE, { id: "c2", address: { kind: "quote", text: "hello" } }]}
|
|
541
|
+
>
|
|
542
|
+
<Harness />
|
|
543
|
+
</FileViewer>,
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
await screen.findByText("c1:resolved:false");
|
|
547
|
+
await userEvent.click(screen.getByRole("button", { name: "next" }));
|
|
548
|
+
// Document order, not the order asked for: "hello" precedes "world".
|
|
549
|
+
expect(screen.getByTestId("active")).toHaveTextContent("c2");
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it("never writes a controlled active id locally — it only reports", async () => {
|
|
553
|
+
const onActiveHighlightChange = vi.fn();
|
|
554
|
+
function Harness() {
|
|
555
|
+
const { actions } = useFileViewer();
|
|
556
|
+
return (
|
|
557
|
+
<>
|
|
558
|
+
<button onClick={actions.nextHighlight}>next</button>
|
|
559
|
+
<FileViewerContent />
|
|
560
|
+
</>
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
render(
|
|
564
|
+
<FileViewer
|
|
565
|
+
source={textSource}
|
|
566
|
+
registry={spyRegistry(["quote"])}
|
|
567
|
+
highlights={[QUOTE]}
|
|
568
|
+
activeHighlightId={null}
|
|
569
|
+
onActiveHighlightChange={onActiveHighlightChange}
|
|
570
|
+
>
|
|
571
|
+
<Harness />
|
|
572
|
+
</FileViewer>,
|
|
573
|
+
);
|
|
574
|
+
|
|
575
|
+
await screen.findByText("c1:resolved:false");
|
|
576
|
+
await userEvent.click(screen.getByRole("button", { name: "next" }));
|
|
577
|
+
expect(onActiveHighlightChange).toHaveBeenCalledWith("c1");
|
|
578
|
+
// The owner said `null` and has not said otherwise, so it is still null.
|
|
579
|
+
expect(screen.getByTestId("active")).toHaveTextContent("none");
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
describe("FileViewer — find-in-document", () => {
|
|
584
|
+
function Probe() {
|
|
585
|
+
const { state, meta } = useFileViewer();
|
|
586
|
+
return (
|
|
587
|
+
<p data-testid="find">
|
|
588
|
+
{`${String(meta.canFind)}/${String(state.find.matches)}/${String(state.find.activeIndex)}`}
|
|
589
|
+
</p>
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
it("offers find when the adapter can paint a range, without a declared flag", async () => {
|
|
594
|
+
// `capabilities.search` is an override in the OFF direction only. Read as
|
|
595
|
+
// the source of truth it would deny the find box to PDF, whose manifest
|
|
596
|
+
// predates the feature and never declared it.
|
|
597
|
+
render(
|
|
598
|
+
<FileViewer source={textSource} registry={spyRegistry(["range"])}>
|
|
599
|
+
<Probe />
|
|
600
|
+
</FileViewer>,
|
|
601
|
+
);
|
|
602
|
+
await waitFor(() => {
|
|
603
|
+
expect(screen.getByTestId("find")).toHaveTextContent("true/0/0");
|
|
604
|
+
});
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it("does not offer find when nothing could paint the result", async () => {
|
|
608
|
+
render(
|
|
609
|
+
<FileViewer source={textSource} registry={spyRegistry(["quote"])}>
|
|
610
|
+
<Probe />
|
|
611
|
+
</FileViewer>,
|
|
612
|
+
);
|
|
613
|
+
await waitFor(() => {
|
|
614
|
+
expect(screen.getByTestId("find")).toHaveTextContent("false/0/0");
|
|
615
|
+
});
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it("turns a query into resolved matches the adapter can draw", async () => {
|
|
619
|
+
function Harness() {
|
|
620
|
+
const { actions } = useFileViewer();
|
|
621
|
+
return (
|
|
622
|
+
<>
|
|
623
|
+
<button
|
|
624
|
+
onClick={() => {
|
|
625
|
+
actions.openFind();
|
|
626
|
+
actions.setFindQuery("l");
|
|
627
|
+
}}
|
|
628
|
+
>
|
|
629
|
+
search
|
|
630
|
+
</button>
|
|
631
|
+
<Probe />
|
|
632
|
+
<FileViewerContent />
|
|
633
|
+
</>
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
render(
|
|
637
|
+
<FileViewer source={textSource} registry={spyRegistry(["range"])}>
|
|
638
|
+
<Harness />
|
|
639
|
+
</FileViewer>,
|
|
640
|
+
);
|
|
641
|
+
|
|
642
|
+
await screen.findByTestId("seen");
|
|
643
|
+
await userEvent.click(screen.getByRole("button", { name: "search" }));
|
|
644
|
+
|
|
645
|
+
// "hello world" — three l's, and the first is current.
|
|
646
|
+
await waitFor(() => {
|
|
647
|
+
expect(screen.getByTestId("find")).toHaveTextContent("true/3/0");
|
|
648
|
+
});
|
|
649
|
+
expect(screen.getByText("find:0:resolved:true")).toBeInTheDocument();
|
|
650
|
+
expect(screen.getByText("find:2:resolved:false")).toBeInTheDocument();
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it("points the RENDERER at the find match, not at the citation knob", async () => {
|
|
654
|
+
// The regression this locks: the shell passed `state.activeHighlightId` —
|
|
655
|
+
// the citation knob — to the renderer, while marking the find match active
|
|
656
|
+
// in the list. Every renderer that navigates or scrolls keys off that id, so
|
|
657
|
+
// with no citation active (the ordinary find case) Enter repainted the mark
|
|
658
|
+
// and never scrolled to it, never turned the PDF's page, and never switched
|
|
659
|
+
// the workbook's sheet.
|
|
660
|
+
function Harness() {
|
|
661
|
+
const { actions } = useFileViewer();
|
|
662
|
+
return (
|
|
663
|
+
<>
|
|
664
|
+
<button
|
|
665
|
+
onClick={() => {
|
|
666
|
+
actions.openFind();
|
|
667
|
+
actions.setFindQuery("world");
|
|
668
|
+
}}
|
|
669
|
+
>
|
|
670
|
+
search
|
|
671
|
+
</button>
|
|
672
|
+
<FileViewerContent />
|
|
673
|
+
</>
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
render(
|
|
677
|
+
<FileViewer source={textSource} registry={spyRegistry(["range", "quote"])}>
|
|
678
|
+
<Harness />
|
|
679
|
+
</FileViewer>,
|
|
680
|
+
);
|
|
681
|
+
|
|
682
|
+
await screen.findByTestId("seen");
|
|
683
|
+
expect(screen.getByTestId("active")).toHaveTextContent("none");
|
|
684
|
+
await userEvent.click(screen.getByRole("button", { name: "search" }));
|
|
685
|
+
await waitFor(() => {
|
|
686
|
+
expect(screen.getByTestId("active")).toHaveTextContent("find:0");
|
|
687
|
+
});
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
it("lets the reader's own match outrank a citation, and only one is current", async () => {
|
|
691
|
+
function Harness() {
|
|
692
|
+
const { actions } = useFileViewer();
|
|
693
|
+
return (
|
|
694
|
+
<>
|
|
695
|
+
<button
|
|
696
|
+
onClick={() => {
|
|
697
|
+
actions.openFind();
|
|
698
|
+
actions.setFindQuery("world");
|
|
699
|
+
}}
|
|
700
|
+
>
|
|
701
|
+
search
|
|
702
|
+
</button>
|
|
703
|
+
<FileViewerContent />
|
|
704
|
+
</>
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
render(
|
|
708
|
+
<FileViewer
|
|
709
|
+
source={textSource}
|
|
710
|
+
registry={spyRegistry(["range", "quote"])}
|
|
711
|
+
highlights={[QUOTE]}
|
|
712
|
+
activeHighlightId="c1"
|
|
713
|
+
>
|
|
714
|
+
<Harness />
|
|
715
|
+
</FileViewer>,
|
|
716
|
+
);
|
|
717
|
+
|
|
718
|
+
expect(await screen.findByText("c1:resolved:true")).toBeInTheDocument();
|
|
719
|
+
await userEvent.click(screen.getByRole("button", { name: "search" }));
|
|
720
|
+
|
|
721
|
+
// Find wins while its box is open: it is what the reader's keystrokes move.
|
|
722
|
+
// Two things painted as "current" would make the reader's own match look
|
|
723
|
+
// like every other match.
|
|
724
|
+
await waitFor(() => {
|
|
725
|
+
expect(screen.getByText("find:0:resolved:true")).toBeInTheDocument();
|
|
726
|
+
});
|
|
727
|
+
expect(screen.getByText("c1:resolved:false")).toBeInTheDocument();
|
|
728
|
+
expect(screen.getByTestId("active")).toHaveTextContent("find:0");
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it("hands the citation back the moment the find bar closes", async () => {
|
|
732
|
+
function Harness() {
|
|
733
|
+
const { actions, state } = useFileViewer();
|
|
734
|
+
return (
|
|
735
|
+
<>
|
|
736
|
+
<button
|
|
737
|
+
onClick={() => {
|
|
738
|
+
actions.openFind();
|
|
739
|
+
actions.setFindQuery("world");
|
|
740
|
+
}}
|
|
741
|
+
>
|
|
742
|
+
search
|
|
743
|
+
</button>
|
|
744
|
+
<button onClick={actions.closeFind}>close</button>
|
|
745
|
+
<p data-testid="open">{String(state.find.open)}</p>
|
|
746
|
+
<FileViewerContent />
|
|
747
|
+
</>
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
render(
|
|
751
|
+
<FileViewer
|
|
752
|
+
source={textSource}
|
|
753
|
+
registry={spyRegistry(["range", "quote"])}
|
|
754
|
+
highlights={[QUOTE]}
|
|
755
|
+
activeHighlightId="c1"
|
|
756
|
+
>
|
|
757
|
+
<Harness />
|
|
758
|
+
</FileViewer>,
|
|
759
|
+
);
|
|
760
|
+
|
|
761
|
+
await screen.findByText("c1:resolved:true");
|
|
762
|
+
await userEvent.click(screen.getByRole("button", { name: "search" }));
|
|
763
|
+
await waitFor(() => {
|
|
764
|
+
expect(screen.getByTestId("active")).toHaveTextContent("find:0");
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
await userEvent.click(screen.getByRole("button", { name: "close" }));
|
|
768
|
+
// The query survives the close, but the citation is current again — the
|
|
769
|
+
// reader is no longer stepping through anything.
|
|
770
|
+
expect(screen.getByTestId("active")).toHaveTextContent("c1");
|
|
771
|
+
});
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
/** Open the find bar the way a reader does, and return the content region. */
|
|
775
|
+
async function openFind() {
|
|
776
|
+
const content = await screen.findByRole("region", { name: "File content" });
|
|
777
|
+
content.focus();
|
|
778
|
+
// Scoped to the frame, never `document`: a page may hold two viewers, and a
|
|
779
|
+
// document-level listener would let whichever mounted last win.
|
|
780
|
+
await userEvent.keyboard("{Control>}f{/Control}");
|
|
781
|
+
return content;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
describe("FileViewer — the find bar", () => {
|
|
785
|
+
it("stays out of the way until Ctrl/Cmd+F, then takes the caret", async () => {
|
|
786
|
+
render(<FileViewer source={textSource} registry={spyRegistry(["range"])} />);
|
|
787
|
+
await screen.findByTestId("seen");
|
|
788
|
+
expect(screen.queryByRole("search")).not.toBeInTheDocument();
|
|
789
|
+
|
|
790
|
+
await openFind();
|
|
791
|
+
|
|
792
|
+
expect(screen.getByRole("search", { name: "Find in document" })).toBeInTheDocument();
|
|
793
|
+
// A find box that does not take the caret sends the next keystroke to the
|
|
794
|
+
// document behind it.
|
|
795
|
+
expect(screen.getByRole("textbox", { name: "Find in document" })).toHaveFocus();
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
it("leaves the browser's own find alone when no find part is composed in", async () => {
|
|
799
|
+
// The regression this locks: the frame gated the shortcut on the ADAPTER's
|
|
800
|
+
// capability alone, so a hand-composed viewer that omits `FileViewerFind` —
|
|
801
|
+
// which every citation story does — swallowed Ctrl/Cmd+F, showed no box, and
|
|
802
|
+
// left the reader with neither the viewer's find nor the browser's.
|
|
803
|
+
render(
|
|
804
|
+
<FileViewerProvider source={textSource} registry={spyRegistry(["range"])}>
|
|
805
|
+
<FileViewerFrame>
|
|
806
|
+
<FileViewerContent />
|
|
807
|
+
</FileViewerFrame>
|
|
808
|
+
</FileViewerProvider>,
|
|
809
|
+
);
|
|
810
|
+
const content = await screen.findByRole("region", { name: "File content" });
|
|
811
|
+
content.focus();
|
|
812
|
+
|
|
813
|
+
const event = createEvent.keyDown(content, { key: "f", ctrlKey: true, bubbles: true });
|
|
814
|
+
fireEvent(content, event);
|
|
815
|
+
expect(event.defaultPrevented).toBe(false);
|
|
816
|
+
expect(screen.queryByRole("search")).not.toBeInTheDocument();
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
it("leaves the browser's own find alone when it could not paint a result", async () => {
|
|
820
|
+
render(<FileViewer source={textSource} registry={spyRegistry(["quote"])} />);
|
|
821
|
+
await screen.findByTestId("seen");
|
|
822
|
+
await openFind();
|
|
823
|
+
expect(screen.queryByRole("search")).not.toBeInTheDocument();
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
it("counts matches and steps through them with Enter / Shift+Enter", async () => {
|
|
827
|
+
render(<FileViewer source={textSource} registry={spyRegistry(["range"])} />);
|
|
828
|
+
await screen.findByTestId("seen");
|
|
829
|
+
await openFind();
|
|
830
|
+
await userEvent.keyboard("l");
|
|
831
|
+
|
|
832
|
+
// "hello world" — three l's.
|
|
833
|
+
await waitFor(() => {
|
|
834
|
+
expect(screen.getByRole("status")).toHaveTextContent("1 of 3");
|
|
835
|
+
});
|
|
836
|
+
await userEvent.keyboard("{Enter}");
|
|
837
|
+
expect(screen.getByRole("status")).toHaveTextContent("2 of 3");
|
|
838
|
+
await userEvent.keyboard("{Shift>}{Enter}{/Shift}");
|
|
839
|
+
expect(screen.getByRole("status")).toHaveTextContent("1 of 3");
|
|
840
|
+
// …and it wraps rather than dead-ending at the top.
|
|
841
|
+
await userEvent.keyboard("{Shift>}{Enter}{/Shift}");
|
|
842
|
+
expect(screen.getByRole("status")).toHaveTextContent("3 of 3");
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
it("says 'No matches' rather than counting to zero", async () => {
|
|
846
|
+
render(<FileViewer source={textSource} registry={spyRegistry(["range"])} />);
|
|
847
|
+
await screen.findByTestId("seen");
|
|
848
|
+
await openFind();
|
|
849
|
+
await userEvent.keyboard("zzz");
|
|
850
|
+
|
|
851
|
+
await waitFor(() => {
|
|
852
|
+
expect(screen.getByRole("status")).toHaveTextContent("No matches");
|
|
853
|
+
});
|
|
854
|
+
expect(screen.getByRole("status")).not.toHaveTextContent("0 of 0");
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
it("keeps the step controls focusable when there is nothing to step to", async () => {
|
|
858
|
+
// `aria-disabled`, never the native attribute: a focused control that goes
|
|
859
|
+
// `disabled` is dropped from the focus order, stranding a keyboard reader.
|
|
860
|
+
render(<FileViewer source={textSource} registry={spyRegistry(["range"])} />);
|
|
861
|
+
await screen.findByTestId("seen");
|
|
862
|
+
await openFind();
|
|
863
|
+
|
|
864
|
+
const next = screen.getByRole("button", { name: "Next match" });
|
|
865
|
+
expect(next).toHaveAttribute("aria-disabled", "true");
|
|
866
|
+
expect(next).not.toBeDisabled();
|
|
867
|
+
next.focus();
|
|
868
|
+
expect(next).toHaveFocus();
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
it("closes on Escape and hands the caret back to the document", async () => {
|
|
872
|
+
render(<FileViewer source={textSource} registry={spyRegistry(["range"])} />);
|
|
873
|
+
await screen.findByTestId("seen");
|
|
874
|
+
const content = await openFind();
|
|
875
|
+
await userEvent.keyboard("{Escape}");
|
|
876
|
+
|
|
877
|
+
expect(screen.queryByRole("search")).not.toBeInTheDocument();
|
|
878
|
+
expect(content).toHaveFocus();
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
it("matches case only when asked to", async () => {
|
|
882
|
+
render(
|
|
883
|
+
<FileViewer
|
|
884
|
+
source={{ kind: "text", text: "Hello hello", name: "notes.txt" }}
|
|
885
|
+
registry={spyRegistry(["range"])}
|
|
886
|
+
/>,
|
|
887
|
+
);
|
|
888
|
+
await screen.findByTestId("seen");
|
|
889
|
+
await openFind();
|
|
890
|
+
await userEvent.keyboard("hello");
|
|
891
|
+
await waitFor(() => {
|
|
892
|
+
expect(screen.getByRole("status")).toHaveTextContent("1 of 2");
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
await userEvent.click(screen.getByRole("button", { name: "Match case" }));
|
|
896
|
+
await waitFor(() => {
|
|
897
|
+
expect(screen.getByRole("status")).toHaveTextContent("1 of 1");
|
|
898
|
+
});
|
|
899
|
+
});
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
describe("FileViewer — a citation that cannot be located", () => {
|
|
903
|
+
const MISSING: DocumentHighlight = { id: "c9", address: { kind: "quote", text: "not in here" } };
|
|
904
|
+
|
|
905
|
+
it("says so, rather than showing an untouched document", async () => {
|
|
906
|
+
render(
|
|
907
|
+
<FileViewer source={textSource} registry={spyRegistry(["quote"])} highlights={[MISSING]} />,
|
|
908
|
+
);
|
|
909
|
+
expect(await screen.findByText(/Couldn't find that passage/)).toBeInTheDocument();
|
|
910
|
+
// Information, not a failure: the file itself opened fine.
|
|
911
|
+
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
it("distinguishes 'not in the document' from 'past what we previewed'", async () => {
|
|
915
|
+
render(
|
|
916
|
+
<FileViewer
|
|
917
|
+
source={textSource}
|
|
918
|
+
registry={spyRegistry(["quote"], { truncated: true })}
|
|
919
|
+
highlights={[MISSING]}
|
|
920
|
+
/>,
|
|
921
|
+
);
|
|
922
|
+
expect(await screen.findByText(/beyond the part of this document/)).toBeInTheDocument();
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
it("calls a kind the adapter never declared a capability gap, not a miss", async () => {
|
|
926
|
+
render(
|
|
927
|
+
<FileViewer
|
|
928
|
+
source={textSource}
|
|
929
|
+
registry={spyRegistry()}
|
|
930
|
+
highlights={[{ id: "c1", address: { kind: "rect", page: 1, rects: [] } }]}
|
|
931
|
+
/>,
|
|
932
|
+
);
|
|
933
|
+
expect(await screen.findByText(/can't point at part of a/)).toBeInTheDocument();
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
it("stays quiet for a fruitless SEARCH — the find bar already counts that", async () => {
|
|
937
|
+
render(<FileViewer source={textSource} registry={spyRegistry(["range"])} />);
|
|
938
|
+
await screen.findByTestId("seen");
|
|
939
|
+
await openFind();
|
|
940
|
+
await userEvent.keyboard("zzz");
|
|
941
|
+
|
|
942
|
+
await waitFor(() => {
|
|
943
|
+
expect(screen.getByRole("status")).toHaveTextContent("No matches");
|
|
944
|
+
});
|
|
945
|
+
expect(screen.queryByText(/Couldn't find that passage/)).not.toBeInTheDocument();
|
|
946
|
+
});
|
|
947
|
+
});
|