@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 { Button } from "@elabs-ai/components-ui";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
|
+
import { expect, userEvent, waitFor, within } from "storybook/test";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
SAMPLE_DOCX_DATA_URI,
|
|
7
|
+
SAMPLE_PPTX_DATA_URI,
|
|
8
|
+
SAMPLE_XLSX_DATA_URI,
|
|
9
|
+
} from "../adapters/office-fixture";
|
|
10
|
+
import { SAMPLE_PDF_DATA_URI } from "../adapters/pdf/pdf-fixture";
|
|
11
|
+
import { ViewerError } from "../core/errors";
|
|
12
|
+
import { createRegistry } from "../core/registry";
|
|
13
|
+
import { PROTOCOL_VERSION } from "../core/types";
|
|
14
|
+
import {
|
|
15
|
+
FileViewer,
|
|
16
|
+
FileViewerContent,
|
|
17
|
+
FileViewerFrame,
|
|
18
|
+
FileViewerProvider,
|
|
19
|
+
FileViewerToolbar,
|
|
20
|
+
} from "./file-viewer";
|
|
21
|
+
import { useFileViewer } from "./file-viewer-context";
|
|
22
|
+
import { FileViewerPager } from "./file-viewer-pager";
|
|
23
|
+
import { FileViewerRotate, FileViewerZoom } from "./file-viewer-zoom";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `FileViewer` renders any file the registry can claim. The adapter decides how
|
|
27
|
+
* a format is parsed and drawn; this shell owns the chrome and the state grid,
|
|
28
|
+
* so every format gets the same identity row, skeleton, empty and error states.
|
|
29
|
+
*/
|
|
30
|
+
const meta = {
|
|
31
|
+
title: "Viewer/FileViewer",
|
|
32
|
+
component: FileViewer,
|
|
33
|
+
tags: ["autodocs"],
|
|
34
|
+
parameters: {
|
|
35
|
+
layout: "padded",
|
|
36
|
+
docs: {
|
|
37
|
+
description: {
|
|
38
|
+
component:
|
|
39
|
+
"Point it at a File, a Blob, a URL or a string and it detects the format, " +
|
|
40
|
+
"loads the matching adapter on demand, and renders it with brand-ui components. " +
|
|
41
|
+
"Formats are added by registering an adapter, never by editing this component.",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
decorators: [
|
|
46
|
+
(Story) => (
|
|
47
|
+
<div className="h-[420px] w-full max-w-3xl">
|
|
48
|
+
<Story />
|
|
49
|
+
</div>
|
|
50
|
+
),
|
|
51
|
+
],
|
|
52
|
+
} satisfies Meta<typeof FileViewer>;
|
|
53
|
+
|
|
54
|
+
export default meta;
|
|
55
|
+
type Story = StoryObj<typeof meta>;
|
|
56
|
+
|
|
57
|
+
/* -------------------------------------------------------------------------- */
|
|
58
|
+
/* Fixtures */
|
|
59
|
+
/* -------------------------------------------------------------------------- */
|
|
60
|
+
|
|
61
|
+
const CSV = `region,orders,revenue,note
|
|
62
|
+
EMEA,1240,982400,"Includes Ireland, remapped in Q2"
|
|
63
|
+
AMER,2110,1704300,Steady
|
|
64
|
+
APAC,880,613900,"Two launches, one delayed"
|
|
65
|
+
`;
|
|
66
|
+
|
|
67
|
+
const JSON_TEXT = JSON.stringify(
|
|
68
|
+
{
|
|
69
|
+
pipeline: "nightly-ingest",
|
|
70
|
+
runs: [
|
|
71
|
+
{ id: "r-8812", status: "succeeded", durationMs: 41200, rows: 1_204_881 },
|
|
72
|
+
{ id: "r-8811", status: "failed", durationMs: 980, error: "source unreachable" },
|
|
73
|
+
],
|
|
74
|
+
owner: { team: "Data Platform", contact: "data-platform@example.test" },
|
|
75
|
+
},
|
|
76
|
+
null,
|
|
77
|
+
2,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const LOG = `2026-08-10T06:00:01Z INFO starting nightly-ingest
|
|
81
|
+
2026-08-10T06:00:02Z INFO connected to source (12 shards)
|
|
82
|
+
2026-08-10T06:41:13Z WARN shard 7 retried once
|
|
83
|
+
2026-08-10T06:41:14Z INFO wrote 1204881 rows
|
|
84
|
+
`;
|
|
85
|
+
|
|
86
|
+
const MARKDOWN = `# Nightly ingest
|
|
87
|
+
|
|
88
|
+
Runs at **06:00 UTC** and writes into the \`warehouse.raw\` schema.
|
|
89
|
+
|
|
90
|
+
## What it does
|
|
91
|
+
|
|
92
|
+
1. Reads every shard the source advertises
|
|
93
|
+
2. Normalises timestamps to UTC
|
|
94
|
+
3. Writes one partition per shard
|
|
95
|
+
|
|
96
|
+
> A shard that fails is retried once, then reported. Partial runs are never
|
|
97
|
+
> published.
|
|
98
|
+
|
|
99
|
+
See the [runbook](https://example.com/runbook) for the escalation path.
|
|
100
|
+
|
|
101
|
+
\`\`\`bash
|
|
102
|
+
pnpm ingest --since 2026-08-09
|
|
103
|
+
\`\`\`
|
|
104
|
+
`;
|
|
105
|
+
|
|
106
|
+
const SOURCE = `import { useContext } from "react";
|
|
107
|
+
|
|
108
|
+
import { FileViewerContext } from "./file-viewer-context";
|
|
109
|
+
|
|
110
|
+
/** Read and drive the viewer from anywhere inside the provider. */
|
|
111
|
+
export function useFileViewer(): FileViewerContextValue {
|
|
112
|
+
const value = useContext(FileViewerContext);
|
|
113
|
+
if (!value) {
|
|
114
|
+
// A control outside the provider has no state to drive — say so here
|
|
115
|
+
// rather than failing later with an undefined read.
|
|
116
|
+
throw new Error("useFileViewer must be used inside a <FileViewerProvider>.");
|
|
117
|
+
}
|
|
118
|
+
return value;
|
|
119
|
+
}
|
|
120
|
+
`;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A real image file, inline so the story needs no network. Drawn as SVG rather
|
|
124
|
+
* than a base64 blob so the fixture is readable — and deliberately in the file's
|
|
125
|
+
* OWN colours, because the viewer shows a document as authored and only themes
|
|
126
|
+
* the chrome around it.
|
|
127
|
+
*/
|
|
128
|
+
const DIAGRAM_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="480" height="300" viewBox="0 0 480 300">
|
|
129
|
+
<rect width="480" height="300" fill="whitesmoke"/>
|
|
130
|
+
<text x="24" y="44" font-family="Helvetica, Arial, sans-serif" font-size="18" fill="dimgray">Nightly ingest — rows per shard</text>
|
|
131
|
+
<line x1="24" y1="250" x2="456" y2="250" stroke="darkgray" stroke-width="2"/>
|
|
132
|
+
<rect x="48" y="150" width="52" height="100" fill="steelblue"/>
|
|
133
|
+
<rect x="120" y="96" width="52" height="154" fill="steelblue"/>
|
|
134
|
+
<rect x="192" y="182" width="52" height="68" fill="steelblue"/>
|
|
135
|
+
<rect x="264" y="120" width="52" height="130" fill="steelblue"/>
|
|
136
|
+
<rect x="336" y="206" width="52" height="44" fill="indianred"/>
|
|
137
|
+
<text x="336" y="274" font-family="Helvetica, Arial, sans-serif" font-size="13" fill="indianred">retried</text>
|
|
138
|
+
</svg>`;
|
|
139
|
+
|
|
140
|
+
const DIAGRAM = `data:image/svg+xml;utf8,${encodeURIComponent(DIAGRAM_SVG)}`;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A real, playable ~1.5 s silent 8-bit mono WAV, synthesized here so the story
|
|
144
|
+
* needs no network origin (`docs/CSP-AND-NETWORK.md`) and no binary fixture.
|
|
145
|
+
* Same technique as `@elabs-ai/components-ai`'s audio-player story —
|
|
146
|
+
* copied rather than imported, because a Layer-2 package may not reach sideways.
|
|
147
|
+
*/
|
|
148
|
+
function silentWav(seconds = 1.5, sampleRate = 8000): string {
|
|
149
|
+
const samples = Math.floor(seconds * sampleRate);
|
|
150
|
+
const bytes = new Uint8Array(44 + samples).fill(128); // 128 == silence, 8-bit unsigned
|
|
151
|
+
const view = new DataView(bytes.buffer);
|
|
152
|
+
const ascii = (offset: number, text: string) => {
|
|
153
|
+
for (let i = 0; i < text.length; i += 1) view.setUint8(offset + i, text.charCodeAt(i));
|
|
154
|
+
};
|
|
155
|
+
ascii(0, "RIFF");
|
|
156
|
+
view.setUint32(4, 36 + samples, true);
|
|
157
|
+
ascii(8, "WAVEfmt ");
|
|
158
|
+
view.setUint32(16, 16, true); // PCM header size
|
|
159
|
+
view.setUint16(20, 1, true); // format: PCM
|
|
160
|
+
view.setUint16(22, 1, true); // channels: mono
|
|
161
|
+
view.setUint32(24, sampleRate, true);
|
|
162
|
+
view.setUint32(28, sampleRate, true); // byte rate
|
|
163
|
+
view.setUint16(32, 1, true); // block align
|
|
164
|
+
view.setUint16(34, 8, true); // bits per sample
|
|
165
|
+
ascii(36, "data");
|
|
166
|
+
view.setUint32(40, samples, true);
|
|
167
|
+
|
|
168
|
+
let binary = "";
|
|
169
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
170
|
+
return `data:audio/wav;base64,${btoa(binary)}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const TONE = silentWav();
|
|
174
|
+
|
|
175
|
+
/* -------------------------------------------------------------------------- */
|
|
176
|
+
/* Formats */
|
|
177
|
+
/* -------------------------------------------------------------------------- */
|
|
178
|
+
|
|
179
|
+
export const Default: Story = {
|
|
180
|
+
args: { source: { kind: "text", text: JSON_TEXT, name: "pipeline-runs.json" } },
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/** A real CSV parser, so a quoted comma stays in one cell. */
|
|
184
|
+
export const Csv: Story = {
|
|
185
|
+
name: "CSV",
|
|
186
|
+
args: { source: { kind: "text", text: CSV, name: "sales-by-region.csv" } },
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
export const PlainText: Story = {
|
|
190
|
+
args: { source: { kind: "text", text: LOG, name: "nightly-ingest.log" } },
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* A markdown file read as a DOCUMENT — real headings, lists and links drawn with
|
|
195
|
+
* the library's own prose primitives, so a README in the viewer looks like the
|
|
196
|
+
* same README in a chat answer or the editor's preview.
|
|
197
|
+
*
|
|
198
|
+
* `streamdown` is an optional peer. No Streamdown plugins are installed, which
|
|
199
|
+
* is a deliberate trade: fenced code renders as an unhighlighted block rather
|
|
200
|
+
* than costing a consumer four more packages to open one file. A source file
|
|
201
|
+
* opened directly still gets Shiki — see `Source code` below.
|
|
202
|
+
*/
|
|
203
|
+
export const Markdown: Story = {
|
|
204
|
+
args: { source: { kind: "text", text: MARKDOWN, name: "README.md" } },
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* A source file, tokenized by Shiki at load and coloured entirely with
|
|
209
|
+
* `var(--code-*)` references — so switching the theme recolours the document
|
|
210
|
+
* without re-highlighting it, and no raw hex ever enters the model.
|
|
211
|
+
*
|
|
212
|
+
* `shiki` is an optional peer, and this adapter claims only extensions it has a
|
|
213
|
+
* grammar for. A `.log`, a `.env` or an unknown extension keeps falling through
|
|
214
|
+
* to the plain-text backstop rather than failing to open.
|
|
215
|
+
*/
|
|
216
|
+
export const SourceCode: Story = {
|
|
217
|
+
name: "Source code",
|
|
218
|
+
args: { source: { kind: "text", text: SOURCE, name: "use-file-viewer.ts" } },
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
export const Image: Story = {
|
|
222
|
+
args: {
|
|
223
|
+
source: {
|
|
224
|
+
kind: "url",
|
|
225
|
+
url: DIAGRAM,
|
|
226
|
+
name: "rows-per-shard.svg",
|
|
227
|
+
mediaType: "image/svg+xml",
|
|
228
|
+
// The description travels with the source, so a screen-reader user gets
|
|
229
|
+
// what the picture says — not just that a picture is there.
|
|
230
|
+
alt: "Bar chart of rows per shard; shard 7 is the shortest bar and is marked as retried.",
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* A real PDF, opened by pdf.js on a worker: the page is rasterized in the file's
|
|
237
|
+
* own colours, and a transparent text layer over it stays selectable.
|
|
238
|
+
*
|
|
239
|
+
* The pages STACK and the pane scrolls, the way every document reader works —
|
|
240
|
+
* page 2 is a gesture away, not behind a button. Only the pages near the viewport
|
|
241
|
+
* are mounted, so the cost follows the screen rather than the page count.
|
|
242
|
+
*
|
|
243
|
+
* The pager, the scale and the rotate button are the SHELL's (ADR 0026), not the
|
|
244
|
+
* adapter's — which is why the same controls drive the deck below, and why an app
|
|
245
|
+
* can move any of them into its own header.
|
|
246
|
+
*
|
|
247
|
+
* `pdfjs-dist` is an optional peer — a consumer who never opens a PDF never
|
|
248
|
+
* installs it, and one who forgot sees the `ParserMissing` panel below.
|
|
249
|
+
*/
|
|
250
|
+
export const Pdf: Story = {
|
|
251
|
+
name: "PDF",
|
|
252
|
+
args: {
|
|
253
|
+
source: {
|
|
254
|
+
kind: "url",
|
|
255
|
+
url: SAMPLE_PDF_DATA_URI,
|
|
256
|
+
name: "quarterly-report.pdf",
|
|
257
|
+
mediaType: "application/pdf",
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Video and audio use the NATIVE elements on purpose — the platform's own
|
|
264
|
+
* transport brings keyboard control, captions, picture-in-picture and the OS
|
|
265
|
+
* media keys, none of which a custom skin gets for free. The adapter streams
|
|
266
|
+
* from a URL and never buffers the bytes, so a 2 GB recording seeks instantly.
|
|
267
|
+
*
|
|
268
|
+
* The fixture is audio because a real, playable video cannot be synthesized
|
|
269
|
+
* inline; the element and the chrome are the same for both.
|
|
270
|
+
*/
|
|
271
|
+
export const Audio: Story = {
|
|
272
|
+
args: {
|
|
273
|
+
source: {
|
|
274
|
+
kind: "url",
|
|
275
|
+
url: TONE,
|
|
276
|
+
name: "standup-recording.wav",
|
|
277
|
+
mediaType: "audio/wav",
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The settled "can't play this" outcome — a codec the browser does not ship, or
|
|
284
|
+
* a truncated file. It is an error, not a capability gap: the format IS
|
|
285
|
+
* supported, this particular file just will not decode. No retry is offered,
|
|
286
|
+
* because retrying cannot install a codec.
|
|
287
|
+
*/
|
|
288
|
+
export const MediaUndecodable: Story = {
|
|
289
|
+
name: "Media (undecodable)",
|
|
290
|
+
args: {
|
|
291
|
+
source: {
|
|
292
|
+
kind: "url",
|
|
293
|
+
url: "data:video/mp4;base64,AAAA",
|
|
294
|
+
name: "briefing.mp4",
|
|
295
|
+
mediaType: "video/mp4",
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* A real Word file. mammoth resolves Word's styles, numbering and images; the
|
|
302
|
+
* adapter then parses that into a block model and draws it with the library's own
|
|
303
|
+
* prose primitives — so a `.docx` inherits the theme, the type scale and real
|
|
304
|
+
* heading semantics instead of arriving as a slab of foreign markup.
|
|
305
|
+
*
|
|
306
|
+
* It shows the document's STRUCTURE, not Word's page layout: no page breaks, no
|
|
307
|
+
* columns, no margins. The toolbar's download is the answer for the real thing.
|
|
308
|
+
*/
|
|
309
|
+
export const Word: Story = {
|
|
310
|
+
args: {
|
|
311
|
+
source: {
|
|
312
|
+
kind: "url",
|
|
313
|
+
url: SAMPLE_DOCX_DATA_URI,
|
|
314
|
+
name: "quarterly-review.docx",
|
|
315
|
+
mediaType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* A real workbook, every sheet reachable. SheetJS is asked only for the DATA —
|
|
322
|
+
* never its `sheet_to_html` helper — so the grid is the same `Table` the CSV
|
|
323
|
+
* adapter renders, with the theme, the density dial and keyboard semantics.
|
|
324
|
+
*
|
|
325
|
+
* `xlsx` is an optional peer with two npm-only advisories; see
|
|
326
|
+
* `docs/CONSUMING.md` before installing it in a build that opens untrusted files.
|
|
327
|
+
*/
|
|
328
|
+
export const Excel: Story = {
|
|
329
|
+
args: {
|
|
330
|
+
source: {
|
|
331
|
+
kind: "url",
|
|
332
|
+
url: SAMPLE_XLSX_DATA_URI,
|
|
333
|
+
name: "quarter.xlsx",
|
|
334
|
+
mediaType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* A real deck, read as an outline: title, text at its authored indent level, and
|
|
341
|
+
* the speaker notes — which are followed through the slide's own relationship,
|
|
342
|
+
* not by matching slide numbers.
|
|
343
|
+
*
|
|
344
|
+
* Slides stack and scroll, like the PDF above: a deck is skimmed by scrolling
|
|
345
|
+
* through it, not by clicking "next" once per slide.
|
|
346
|
+
*
|
|
347
|
+
* There is no PowerPoint library here; a `.pptx` is a zip of XML, so jszip opens
|
|
348
|
+
* it and the platform's `DOMParser` reads it. Positional design (layout, images,
|
|
349
|
+
* charts, transitions) is deliberately absent rather than half-reproduced.
|
|
350
|
+
*/
|
|
351
|
+
export const PowerPoint: Story = {
|
|
352
|
+
args: {
|
|
353
|
+
source: {
|
|
354
|
+
kind: "url",
|
|
355
|
+
url: SAMPLE_PPTX_DATA_URI,
|
|
356
|
+
name: "quarterly-review.pptx",
|
|
357
|
+
mediaType: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
/* -------------------------------------------------------------------------- */
|
|
363
|
+
/* States */
|
|
364
|
+
/* -------------------------------------------------------------------------- */
|
|
365
|
+
|
|
366
|
+
/** No file chosen yet — an invitation, not a failure. */
|
|
367
|
+
export const Empty: Story = { args: {} };
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* The layout-shaped skeleton. Held open by an adapter that never resolves, so
|
|
371
|
+
* the state can be inspected rather than glimpsed.
|
|
372
|
+
*/
|
|
373
|
+
export const Loading: Story = {
|
|
374
|
+
args: {
|
|
375
|
+
source: { kind: "text", text: LOG, name: "nightly-ingest.log" },
|
|
376
|
+
registry: (() => {
|
|
377
|
+
const registry = createRegistry();
|
|
378
|
+
registry.register(
|
|
379
|
+
{ id: "slow", protocol: PROTOCOL_VERSION, categories: ["text"], mediaTypes: ["text/"] },
|
|
380
|
+
() =>
|
|
381
|
+
Promise.resolve({
|
|
382
|
+
manifest: { id: "slow", protocol: PROTOCOL_VERSION, categories: ["text"] },
|
|
383
|
+
create: () => ({ load: () => new Promise<never>(() => undefined) }),
|
|
384
|
+
Renderer: () => null,
|
|
385
|
+
}),
|
|
386
|
+
);
|
|
387
|
+
return registry;
|
|
388
|
+
})(),
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
/** No adapter claims the file. Not retryable — retrying changes nothing. */
|
|
393
|
+
export const UnsupportedFormat: Story = {
|
|
394
|
+
args: {
|
|
395
|
+
source: {
|
|
396
|
+
kind: "text",
|
|
397
|
+
text: "%PDF-1.7",
|
|
398
|
+
name: "contract.pdf",
|
|
399
|
+
mediaType: "application/pdf",
|
|
400
|
+
},
|
|
401
|
+
registry: (() => {
|
|
402
|
+
const registry = createRegistry();
|
|
403
|
+
registry.register(
|
|
404
|
+
{ id: "text", protocol: PROTOCOL_VERSION, categories: ["text"] },
|
|
405
|
+
() => import("../adapters/text/text-adapter"),
|
|
406
|
+
);
|
|
407
|
+
return registry;
|
|
408
|
+
})(),
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* The optional parser package is not installed. The message names it, because
|
|
414
|
+
* "couldn't open this file" would leave the reader with nothing to do.
|
|
415
|
+
*/
|
|
416
|
+
export const ParserMissing: Story = {
|
|
417
|
+
args: {
|
|
418
|
+
source: { kind: "text", text: CSV, name: "sales-by-region.csv" },
|
|
419
|
+
registry: (() => {
|
|
420
|
+
const registry = createRegistry();
|
|
421
|
+
registry.register(
|
|
422
|
+
{
|
|
423
|
+
id: "csv",
|
|
424
|
+
protocol: PROTOCOL_VERSION,
|
|
425
|
+
extensions: ["csv"],
|
|
426
|
+
requires: ["papaparse"],
|
|
427
|
+
},
|
|
428
|
+
() => Promise.reject(new Error("Cannot find module 'papaparse'")),
|
|
429
|
+
);
|
|
430
|
+
return registry;
|
|
431
|
+
})(),
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
/** A settled read failure — the one error state that offers a retry. */
|
|
436
|
+
export const ReadFailed: Story = {
|
|
437
|
+
args: {
|
|
438
|
+
source: { kind: "text", text: LOG, name: "nightly-ingest.log" },
|
|
439
|
+
registry: (() => {
|
|
440
|
+
const registry = createRegistry();
|
|
441
|
+
registry.register(
|
|
442
|
+
{ id: "broken", protocol: PROTOCOL_VERSION, categories: ["text"], mediaTypes: ["text/"] },
|
|
443
|
+
() =>
|
|
444
|
+
Promise.resolve({
|
|
445
|
+
manifest: { id: "broken", protocol: PROTOCOL_VERSION, categories: ["text"] },
|
|
446
|
+
create: () => ({
|
|
447
|
+
// A typed failure, not a bare Error: the CODE picks the copy and
|
|
448
|
+
// decides whether a retry is offered, so a story that says
|
|
449
|
+
// "read failed" has to throw one.
|
|
450
|
+
load: () =>
|
|
451
|
+
Promise.reject(
|
|
452
|
+
new ViewerError("read-failed", "The network connection was lost.", {
|
|
453
|
+
fileName: "nightly-ingest.log",
|
|
454
|
+
}),
|
|
455
|
+
),
|
|
456
|
+
}),
|
|
457
|
+
Renderer: () => null,
|
|
458
|
+
}),
|
|
459
|
+
);
|
|
460
|
+
return registry;
|
|
461
|
+
})(),
|
|
462
|
+
},
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
/** A parent that fetches the file itself can hold the viewer not-ready. */
|
|
466
|
+
export const ParentLoading: Story = {
|
|
467
|
+
args: { source: { kind: "text", text: LOG, name: "nightly-ingest.log" }, loading: true },
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
/* -------------------------------------------------------------------------- */
|
|
471
|
+
/* Composition */
|
|
472
|
+
/* -------------------------------------------------------------------------- */
|
|
473
|
+
|
|
474
|
+
/** Reads the viewer state from outside the frame — no prop-drilling, no refs. */
|
|
475
|
+
function FormatBadge() {
|
|
476
|
+
const { state } = useFileViewer();
|
|
477
|
+
return (
|
|
478
|
+
<p className="text-meta text-muted-foreground mb-2">
|
|
479
|
+
{state.status === "ready"
|
|
480
|
+
? `Showing ${state.source?.extension.toUpperCase() ?? ""} · ${state.source?.category ?? ""}`
|
|
481
|
+
: `Status: ${state.status}`}
|
|
482
|
+
</p>
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* The parts, arranged by hand. `FileViewerProvider` is the state boundary, so a
|
|
488
|
+
* control **outside** the frame but inside the provider reads the same state the
|
|
489
|
+
* viewer does — the reason this is a compound component rather than one prop bag.
|
|
490
|
+
*/
|
|
491
|
+
export const Composed: Story = {
|
|
492
|
+
render: () => (
|
|
493
|
+
<FileViewerProvider source={{ kind: "text", text: JSON_TEXT, name: "pipeline-runs.json" }}>
|
|
494
|
+
<FormatBadge />
|
|
495
|
+
<FileViewerFrame className="h-[380px]">
|
|
496
|
+
<FileViewerToolbar />
|
|
497
|
+
<FileViewerContent />
|
|
498
|
+
</FileViewerFrame>
|
|
499
|
+
</FileViewerProvider>
|
|
500
|
+
),
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
/* -------------------------------------------------------------------------- */
|
|
504
|
+
/* Page, scale and rotation (ADR 0026) */
|
|
505
|
+
/* -------------------------------------------------------------------------- */
|
|
506
|
+
|
|
507
|
+
const PDF_SOURCE = {
|
|
508
|
+
kind: "url",
|
|
509
|
+
url: SAMPLE_PDF_DATA_URI,
|
|
510
|
+
name: "quarterly-report.pdf",
|
|
511
|
+
mediaType: "application/pdf",
|
|
512
|
+
} as const;
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* The same file opened at page 2, at a fixed 125%.
|
|
516
|
+
*
|
|
517
|
+
* Page and scale are the SHELL's state, so they are ordinary props with the
|
|
518
|
+
* controlled/uncontrolled trio — which is what makes a deep link ("open the
|
|
519
|
+
* contract at page 2") a one-liner instead of a reach inside the canvas.
|
|
520
|
+
*
|
|
521
|
+
* There is no rotate button here, and that is the point: the PDF manifest does
|
|
522
|
+
* not claim `rotate`, so the control does not exist. A capability is a promise
|
|
523
|
+
* the renderer keeps — chrome for one it has not implemented would be a lie the
|
|
524
|
+
* reader can click.
|
|
525
|
+
*/
|
|
526
|
+
export const PageAndZoom: Story = {
|
|
527
|
+
args: {
|
|
528
|
+
source: PDF_SOURCE,
|
|
529
|
+
defaultPageNumber: 2,
|
|
530
|
+
defaultZoom: 1.25,
|
|
531
|
+
},
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Rotation, on the format that implements it.
|
|
536
|
+
*
|
|
537
|
+
* `image` DOES claim `rotate`, so the button appears and turns the picture a
|
|
538
|
+
* quarter at a time. It also honours the fit modes and the fixed stops, which the
|
|
539
|
+
* manifest had been claiming for a renderer that did neither.
|
|
540
|
+
*/
|
|
541
|
+
export const Rotated: Story = {
|
|
542
|
+
args: {
|
|
543
|
+
source: {
|
|
544
|
+
kind: "url",
|
|
545
|
+
url: DIAGRAM,
|
|
546
|
+
name: "rows-per-shard.svg",
|
|
547
|
+
mediaType: "image/svg+xml",
|
|
548
|
+
alt: "Bar chart of rows per shard; shard 7 is the shortest bar and is marked as retried.",
|
|
549
|
+
},
|
|
550
|
+
defaultRotation: 90,
|
|
551
|
+
},
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* The controls moved OUT of the viewer, into the app's own header.
|
|
556
|
+
*
|
|
557
|
+
* They are parts over the provider, not adapter chrome: anything inside
|
|
558
|
+
* `FileViewerProvider` reads and drives the same page and scale, so a workspace
|
|
559
|
+
* can put the pager beside a breadcrumb and keep the document pane clean. This is
|
|
560
|
+
* the composition the old design could not express at all — page and zoom were
|
|
561
|
+
* `useState` inside the PDF renderer, so there could only ever be one pager, and
|
|
562
|
+
* only inside the canvas.
|
|
563
|
+
*/
|
|
564
|
+
export const ChromeInTheAppHeader: Story = {
|
|
565
|
+
render: () => (
|
|
566
|
+
<FileViewerProvider source={PDF_SOURCE}>
|
|
567
|
+
<div className="mb-2 flex items-center justify-between gap-2">
|
|
568
|
+
<p className="text-meta text-muted-foreground">Contracts / Q3 / quarterly-report.pdf</p>
|
|
569
|
+
<div className="flex items-center gap-1">
|
|
570
|
+
<FileViewerPager />
|
|
571
|
+
<FileViewerZoom />
|
|
572
|
+
<FileViewerRotate />
|
|
573
|
+
</div>
|
|
574
|
+
</div>
|
|
575
|
+
<FileViewerFrame className="h-[360px]">
|
|
576
|
+
<FileViewerContent />
|
|
577
|
+
</FileViewerFrame>
|
|
578
|
+
</FileViewerProvider>
|
|
579
|
+
),
|
|
580
|
+
play: async ({ canvasElement }) => {
|
|
581
|
+
const canvas = within(canvasElement);
|
|
582
|
+
// Prove the header really drives the document. Under continuous scroll page
|
|
583
|
+
// 2 is already MOUNTED, so its presence proves nothing — what has to follow
|
|
584
|
+
// the button outside the frame is the viewport.
|
|
585
|
+
await canvas.findByRole("img", { name: /Page 1/ }, { timeout: 15_000 });
|
|
586
|
+
const pane = canvasElement.querySelector<HTMLElement>('[data-slot="file-viewer-content"]');
|
|
587
|
+
expect(pane?.scrollTop).toBe(0);
|
|
588
|
+
await userEvent.click(canvas.getByRole("button", { name: "Next page" }));
|
|
589
|
+
await waitFor(() => expect(pane?.scrollTop).toBeGreaterThan(0));
|
|
590
|
+
await waitFor(() => expect(canvas.getByRole("img", { name: /Page 2/ })).toBeInTheDocument());
|
|
591
|
+
},
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* The other direction: the reader scrolls, and the chrome follows.
|
|
596
|
+
*
|
|
597
|
+
* A paginated document is a COLUMN, not a flipbook — page 2 sits one gesture
|
|
598
|
+
* below page 1, and only the pages near the viewport are ever mounted, so a
|
|
599
|
+
* 900-page file costs what is on screen rather than what is in the file. The page
|
|
600
|
+
* number is a two-way binding: type "2" and the column scrolls, scroll and the
|
|
601
|
+
* field updates.
|
|
602
|
+
*/
|
|
603
|
+
export const ContinuousScroll: Story = {
|
|
604
|
+
args: { source: PDF_SOURCE },
|
|
605
|
+
play: async ({ canvasElement }) => {
|
|
606
|
+
const canvas = within(canvasElement);
|
|
607
|
+
await canvas.findByRole("img", { name: /Page 1/ }, { timeout: 15_000 });
|
|
608
|
+
const pane = canvasElement.querySelector<HTMLElement>('[data-slot="file-viewer-content"]');
|
|
609
|
+
const field = canvas.getByRole("textbox", { name: "Page number" });
|
|
610
|
+
expect(field).toHaveValue("1");
|
|
611
|
+
|
|
612
|
+
// Scroll the way a reader does — the pane, not a control — and the pager has
|
|
613
|
+
// to arrive at page 2 on its own.
|
|
614
|
+
pane?.scrollTo({ top: pane.scrollHeight });
|
|
615
|
+
pane?.dispatchEvent(new Event("scroll"));
|
|
616
|
+
await waitFor(() => expect(field).toHaveValue("2"));
|
|
617
|
+
},
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
/* -------------------------------------------------------------------------- */
|
|
621
|
+
/* Pointing at part of a document (ADR 0025) */
|
|
622
|
+
/* -------------------------------------------------------------------------- */
|
|
623
|
+
|
|
624
|
+
/** The citations a RAG answer would hand the viewer, quoted from the log above. */
|
|
625
|
+
const CITATIONS = [
|
|
626
|
+
{ id: "c1", label: "12 shards were advertised", address: { kind: "quote", text: "12 shards" } },
|
|
627
|
+
{ id: "c2", label: "one shard was retried", address: { kind: "quote", text: "shard 7 retried" } },
|
|
628
|
+
{ id: "c3", label: "the row count", address: { kind: "quote", text: "1204881 rows" } },
|
|
629
|
+
] as const;
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Open the find row the way a reader does — focus the document, press the
|
|
633
|
+
* platform's find shortcut, type — and leave it open so the story SHOWS the
|
|
634
|
+
* search state instead of describing it. Also what puts the row in front of the
|
|
635
|
+
* blocking interaction + axe pass.
|
|
636
|
+
*/
|
|
637
|
+
function openFindAndSearch(query: string) {
|
|
638
|
+
return async ({ canvasElement }: { canvasElement: HTMLElement }) => {
|
|
639
|
+
const canvas = within(canvasElement);
|
|
640
|
+
// Wait for the file to have actually LOADED, not merely for the pane to
|
|
641
|
+
// exist: the shortcut is a no-op until the adapter has published text to
|
|
642
|
+
// search (the pane holds a skeleton before that), so pressing it early
|
|
643
|
+
// silently does nothing.
|
|
644
|
+
const pane = await waitFor(() => {
|
|
645
|
+
const el = canvasElement.querySelector<HTMLElement>('[data-slot="file-viewer-content"]');
|
|
646
|
+
expect(el?.querySelector("pre")).not.toBeNull();
|
|
647
|
+
return el as HTMLElement;
|
|
648
|
+
});
|
|
649
|
+
pane.focus();
|
|
650
|
+
await userEvent.keyboard("{Control>}f{/Control}");
|
|
651
|
+
const box = await canvas.findByRole("textbox", { name: "Find in document" });
|
|
652
|
+
await userEvent.type(box, query);
|
|
653
|
+
await waitFor(() => expect(canvas.getByRole("status")).toHaveTextContent(/of/));
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** Prev / next over the located citations, driven from outside the frame. */
|
|
658
|
+
function CitationStepper() {
|
|
659
|
+
const { state, actions, meta } = useFileViewer();
|
|
660
|
+
const located = meta.resolvedHighlights.filter(
|
|
661
|
+
(highlight) => highlight.source === "citation" && highlight.status === "resolved",
|
|
662
|
+
);
|
|
663
|
+
const current = located.find((highlight) => highlight.id === state.activeHighlightId);
|
|
664
|
+
return (
|
|
665
|
+
<div className="mb-2 flex items-center gap-2">
|
|
666
|
+
<Button size="sm" variant="outline" onClick={actions.previousHighlight}>
|
|
667
|
+
Previous passage
|
|
668
|
+
</Button>
|
|
669
|
+
<Button size="sm" variant="outline" onClick={actions.nextHighlight}>
|
|
670
|
+
Next passage
|
|
671
|
+
</Button>
|
|
672
|
+
<span aria-live="polite" className="text-meta text-muted-foreground">
|
|
673
|
+
{current
|
|
674
|
+
? `Passage ${String(current.index ?? 0)} of ${String(located.length)} — ${current.label ?? ""}`
|
|
675
|
+
: `${String(located.length)} passages`}
|
|
676
|
+
</span>
|
|
677
|
+
</div>
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* A chat answer cites three passages; clicking one scrolls the document to it
|
|
683
|
+
* and marks it. The app owns the citation list and which one is current — the
|
|
684
|
+
* viewer only locates and paints them, so the same list drives a sidebar, a
|
|
685
|
+
* stepper, or a chat bubble without any of them talking to each other.
|
|
686
|
+
*
|
|
687
|
+
* The current passage is never signalled by colour alone: it also carries an
|
|
688
|
+
* outline and `aria-current="true"` (WCAG 1.4.1).
|
|
689
|
+
*/
|
|
690
|
+
export const Citations: Story = {
|
|
691
|
+
render: () => (
|
|
692
|
+
<FileViewerProvider
|
|
693
|
+
source={{ kind: "text", text: LOG, name: "nightly-ingest.log" }}
|
|
694
|
+
defaultHighlights={[...CITATIONS]}
|
|
695
|
+
defaultActiveHighlightId="c2"
|
|
696
|
+
>
|
|
697
|
+
<CitationStepper />
|
|
698
|
+
<FileViewerFrame className="h-[340px]">
|
|
699
|
+
<FileViewerToolbar />
|
|
700
|
+
<FileViewerContent />
|
|
701
|
+
</FileViewerFrame>
|
|
702
|
+
</FileViewerProvider>
|
|
703
|
+
),
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* A quote that is not in the document is a STATE, not a no-op. The reader
|
|
708
|
+
* clicked a source link, so silence would leave them unable to tell whether the
|
|
709
|
+
* viewer broke, the passage moved, or they mis-clicked.
|
|
710
|
+
*/
|
|
711
|
+
export const PassageNotFound: Story = {
|
|
712
|
+
args: {
|
|
713
|
+
source: { kind: "text", text: LOG, name: "nightly-ingest.log" },
|
|
714
|
+
highlights: [{ id: "c1", address: { kind: "quote", text: "a sentence that is not there" } }],
|
|
715
|
+
},
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Find-in-document — press **Ctrl/Cmd + F** with the viewer focused. It paints
|
|
720
|
+
* through the same mark layer citations use, so a document never grows two
|
|
721
|
+
* highlight systems that disagree about what a mark looks like.
|
|
722
|
+
*
|
|
723
|
+
* The shortcut is bound to the FRAME, not `document`: a page may hold several
|
|
724
|
+
* viewers, and the browser's own find is untouched everywhere else.
|
|
725
|
+
*/
|
|
726
|
+
export const FindInDocument: Story = {
|
|
727
|
+
args: { source: { kind: "text", text: LOG, name: "nightly-ingest.log" } },
|
|
728
|
+
// The story OPENS the search rather than telling the reader to press a key:
|
|
729
|
+
// a state nobody can see is a state nobody reviews — and this is what puts
|
|
730
|
+
// the find row in front of the blocking axe pass.
|
|
731
|
+
play: openFindAndSearch("shard"),
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* The citations for the PDF fixture: one geometric, one quoted.
|
|
736
|
+
*
|
|
737
|
+
* The `rect` is the interesting one — its box is in page FRACTIONS, so it is
|
|
738
|
+
* correct at every zoom stop and after any resize, and it needed no text
|
|
739
|
+
* extraction at all. That is the address an OCR or layout-aware chunker
|
|
740
|
+
* produces, and the PDF adapter is the only one that can honour it.
|
|
741
|
+
*/
|
|
742
|
+
const PDF_CITATIONS = [
|
|
743
|
+
{
|
|
744
|
+
id: "p1",
|
|
745
|
+
label: "The report's own title",
|
|
746
|
+
address: {
|
|
747
|
+
kind: "rect",
|
|
748
|
+
page: 1,
|
|
749
|
+
rects: [{ x: 0.11, y: 0.084, width: 0.53, height: 0.034 }],
|
|
750
|
+
},
|
|
751
|
+
},
|
|
752
|
+
{
|
|
753
|
+
id: "p2",
|
|
754
|
+
label: "Where the appendix starts",
|
|
755
|
+
address: { kind: "quote", text: "Appendix - page two" },
|
|
756
|
+
},
|
|
757
|
+
] as const;
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* A PDF page is pixels, so a citation is a BOX over the raster rather than a
|
|
761
|
+
* `<mark>` — same state, same stepper, different paint. Stepping to the second
|
|
762
|
+
* passage turns to page 2 on its own: the quote was located in the text
|
|
763
|
+
* projection, and the document's index says which page that stretch came from.
|
|
764
|
+
*
|
|
765
|
+
* The current box is not distinguished by colour alone — it is also drawn twice
|
|
766
|
+
* as thick (WCAG 1.4.1).
|
|
767
|
+
*/
|
|
768
|
+
export const PdfCitations: Story = {
|
|
769
|
+
name: "PDF citations",
|
|
770
|
+
render: () => (
|
|
771
|
+
<FileViewerProvider
|
|
772
|
+
source={{
|
|
773
|
+
kind: "url",
|
|
774
|
+
url: SAMPLE_PDF_DATA_URI,
|
|
775
|
+
name: "quarterly-report.pdf",
|
|
776
|
+
mediaType: "application/pdf",
|
|
777
|
+
}}
|
|
778
|
+
defaultHighlights={[...PDF_CITATIONS]}
|
|
779
|
+
defaultActiveHighlightId="p1"
|
|
780
|
+
>
|
|
781
|
+
<CitationStepper />
|
|
782
|
+
<FileViewerFrame className="h-[520px]">
|
|
783
|
+
<FileViewerToolbar />
|
|
784
|
+
<FileViewerContent />
|
|
785
|
+
</FileViewerFrame>
|
|
786
|
+
</FileViewerProvider>
|
|
787
|
+
),
|
|
788
|
+
};
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* A Word document marks the WORDS. The passage is underlined where it falls —
|
|
792
|
+
* mid-paragraph, through a bold run, inside one bullet, in a single table cell —
|
|
793
|
+
* and the document keeps reading as a document around it.
|
|
794
|
+
*
|
|
795
|
+
* Word's own page layout is gone by the time this renders (see `Word` above), so
|
|
796
|
+
* there is no page to draw a box on: the marks are the whole answer here.
|
|
797
|
+
*/
|
|
798
|
+
export const WordCitations: Story = {
|
|
799
|
+
name: "Word citations",
|
|
800
|
+
render: () => (
|
|
801
|
+
<FileViewerProvider
|
|
802
|
+
source={{
|
|
803
|
+
kind: "url",
|
|
804
|
+
url: SAMPLE_DOCX_DATA_URI,
|
|
805
|
+
name: "quarterly-review.docx",
|
|
806
|
+
mediaType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
807
|
+
}}
|
|
808
|
+
defaultHighlights={[
|
|
809
|
+
{
|
|
810
|
+
id: "w1",
|
|
811
|
+
label: "The growth claim",
|
|
812
|
+
address: { kind: "quote", text: "Revenue grew 18%" },
|
|
813
|
+
},
|
|
814
|
+
{ id: "w2", label: "How EMEA did", address: { kind: "quote", text: "EMEA beat plan" } },
|
|
815
|
+
{ id: "w3", label: "The number behind it", address: { kind: "quote", text: "4.2M" } },
|
|
816
|
+
]}
|
|
817
|
+
defaultActiveHighlightId="w1"
|
|
818
|
+
>
|
|
819
|
+
<CitationStepper />
|
|
820
|
+
<FileViewerFrame className="h-[420px]">
|
|
821
|
+
<FileViewerToolbar />
|
|
822
|
+
<FileViewerContent />
|
|
823
|
+
</FileViewerFrame>
|
|
824
|
+
</FileViewerProvider>
|
|
825
|
+
),
|
|
826
|
+
};
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Markdown is addressed by its SOURCE, which is why its citations are plated
|
|
830
|
+
* blocks rather than `<mark>`s: source offset 212 can land inside `**bold**`,
|
|
831
|
+
* two of whose characters are never drawn. So a passage lights up the paragraph,
|
|
832
|
+
* heading, list item or fence it lives in — enough to point a reader at it, and
|
|
833
|
+
* incapable of being subtly wrong about which words it covers.
|
|
834
|
+
*
|
|
835
|
+
* The current plate doubles its rail as well as warming its fill, and carries
|
|
836
|
+
* `aria-current="true"` (WCAG 1.4.1).
|
|
837
|
+
*/
|
|
838
|
+
export const MarkdownCitations: Story = {
|
|
839
|
+
name: "Markdown citations",
|
|
840
|
+
render: () => (
|
|
841
|
+
<FileViewerProvider
|
|
842
|
+
source={{ kind: "text", text: MARKDOWN, name: "nightly-ingest.md" }}
|
|
843
|
+
defaultHighlights={[
|
|
844
|
+
{ id: "m1", label: "When it runs", address: { kind: "quote", text: "06:00 UTC" } },
|
|
845
|
+
{
|
|
846
|
+
id: "m2",
|
|
847
|
+
label: "What happens to a failed shard",
|
|
848
|
+
address: { kind: "quote", text: "retried once, then reported" },
|
|
849
|
+
},
|
|
850
|
+
{ id: "m3", label: "How to run it", address: { kind: "quote", text: "pnpm ingest" } },
|
|
851
|
+
]}
|
|
852
|
+
defaultActiveHighlightId="m2"
|
|
853
|
+
>
|
|
854
|
+
<CitationStepper />
|
|
855
|
+
<FileViewerFrame className="h-[420px]">
|
|
856
|
+
<FileViewerToolbar />
|
|
857
|
+
<FileViewerContent />
|
|
858
|
+
</FileViewerFrame>
|
|
859
|
+
</FileViewerProvider>
|
|
860
|
+
),
|
|
861
|
+
};
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* A spreadsheet marks the CELL. Rows are what the projection is indexed by, but
|
|
865
|
+
* the mark is still character-granular, so a citation lands inside one cell and
|
|
866
|
+
* leaves the rest of the row alone.
|
|
867
|
+
*
|
|
868
|
+
* Stepping to the third citation switches TAB as well as scrolling: the passage
|
|
869
|
+
* lives on Headcount, and a mark on a sheet nobody is looking at is the same as
|
|
870
|
+
* no mark at all.
|
|
871
|
+
*/
|
|
872
|
+
export const SpreadsheetCitations: Story = {
|
|
873
|
+
name: "Spreadsheet citations",
|
|
874
|
+
render: () => (
|
|
875
|
+
<FileViewerProvider
|
|
876
|
+
source={{
|
|
877
|
+
kind: "url",
|
|
878
|
+
url: SAMPLE_XLSX_DATA_URI,
|
|
879
|
+
name: "quarter.xlsx",
|
|
880
|
+
mediaType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
881
|
+
}}
|
|
882
|
+
defaultHighlights={[
|
|
883
|
+
{ id: "x1", label: "The biggest region", address: { kind: "quote", text: "6100000" } },
|
|
884
|
+
{ id: "x2", label: "Where APAC landed", address: { kind: "quote", text: "1800000" } },
|
|
885
|
+
{
|
|
886
|
+
id: "x3",
|
|
887
|
+
label: "The team behind it (other sheet)",
|
|
888
|
+
address: { kind: "quote", text: "Engineering" },
|
|
889
|
+
},
|
|
890
|
+
]}
|
|
891
|
+
defaultActiveHighlightId="x1"
|
|
892
|
+
>
|
|
893
|
+
<CitationStepper />
|
|
894
|
+
<FileViewerFrame className="h-[420px]">
|
|
895
|
+
<FileViewerToolbar />
|
|
896
|
+
<FileViewerContent />
|
|
897
|
+
</FileViewerFrame>
|
|
898
|
+
</FileViewerProvider>
|
|
899
|
+
),
|
|
900
|
+
};
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* A deck is read as an outline, so a citation marks the line it came from — a
|
|
904
|
+
* bullet, a title, a table row, or the speaker notes. Stepping to a passage on
|
|
905
|
+
* another slide PAGES there first, the same way the PDF turns to the cited page.
|
|
906
|
+
*/
|
|
907
|
+
export const DeckCitations: Story = {
|
|
908
|
+
name: "Deck citations",
|
|
909
|
+
render: () => (
|
|
910
|
+
<FileViewerProvider
|
|
911
|
+
source={{
|
|
912
|
+
kind: "url",
|
|
913
|
+
url: SAMPLE_PPTX_DATA_URI,
|
|
914
|
+
name: "quarter.pptx",
|
|
915
|
+
mediaType: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
916
|
+
}}
|
|
917
|
+
defaultHighlights={[
|
|
918
|
+
{ id: "d1", label: "The headline", address: { kind: "quote", text: "Revenue grew 18%" } },
|
|
919
|
+
{
|
|
920
|
+
id: "d2",
|
|
921
|
+
label: "What the presenter says",
|
|
922
|
+
address: { kind: "quote", text: "hand over to Sam" },
|
|
923
|
+
},
|
|
924
|
+
{
|
|
925
|
+
id: "d3",
|
|
926
|
+
label: "The number (next slide)",
|
|
927
|
+
address: { kind: "quote", text: "4.2M" },
|
|
928
|
+
},
|
|
929
|
+
]}
|
|
930
|
+
defaultActiveHighlightId="d1"
|
|
931
|
+
>
|
|
932
|
+
<CitationStepper />
|
|
933
|
+
<FileViewerFrame className="h-[520px]">
|
|
934
|
+
<FileViewerToolbar />
|
|
935
|
+
<FileViewerContent />
|
|
936
|
+
</FileViewerFrame>
|
|
937
|
+
</FileViewerProvider>
|
|
938
|
+
),
|
|
939
|
+
};
|
|
940
|
+
|
|
941
|
+
/** The same search over a source file — marks survive syntax colouring. */
|
|
942
|
+
export const FindInSourceCode: Story = {
|
|
943
|
+
args: { source: { kind: "text", text: SOURCE, name: "use-file-viewer.ts" } },
|
|
944
|
+
// A word that appears several times AND inside a coloured token, so the story
|
|
945
|
+
// shows marks surviving syntax colouring rather than only sitting beside it.
|
|
946
|
+
play: openFindAndSearch("provider"),
|
|
947
|
+
};
|