@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,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Word markup → a block model.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists at all
|
|
5
|
+
*
|
|
6
|
+
* mammoth converts a `.docx` into an HTML **string**. Rendering that string
|
|
7
|
+
* would mean `dangerouslySetInnerHTML`, which is three separate problems: it
|
|
8
|
+
* puts third-party markup past the token layer, it needs a sanitizer to be safe
|
|
9
|
+
* at all, and it makes the surface unusable under a Trusted-Types policy (a raw
|
|
10
|
+
* `innerHTML` write throws during commit and takes the React root down with it —
|
|
11
|
+
* see `docs/CSP-AND-NETWORK.md`).
|
|
12
|
+
*
|
|
13
|
+
* So the HTML is parsed and thrown away, and what survives is a small model of
|
|
14
|
+
* blocks and inline runs that React renders as real elements. **The parse IS the
|
|
15
|
+
* allowlist**: a tag this file does not name contributes nothing but its text,
|
|
16
|
+
* so there is no denylist to keep up to date, no `<script>` to strip, and no
|
|
17
|
+
* sanitizer dependency. It is also why the viewer adds no CSP sink.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createTextIndexBuilder, type TextIndex } from "../../core/text-index";
|
|
21
|
+
|
|
22
|
+
/** A styled span of text inside a block. */
|
|
23
|
+
export interface DocxRun {
|
|
24
|
+
text: string;
|
|
25
|
+
bold?: boolean;
|
|
26
|
+
italic?: boolean;
|
|
27
|
+
/** Present only for a link whose scheme survived {@link safeHref}. */
|
|
28
|
+
href?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type DocxBlock =
|
|
32
|
+
| { type: "heading"; level: 1 | 2 | 3 | 4 | 5 | 6; runs: DocxRun[] }
|
|
33
|
+
| { type: "paragraph"; runs: DocxRun[] }
|
|
34
|
+
| { type: "list"; ordered: boolean; items: DocxRun[][] }
|
|
35
|
+
| { type: "table"; head?: string[]; rows: string[][] }
|
|
36
|
+
| { type: "image"; src: string; alt?: string };
|
|
37
|
+
|
|
38
|
+
/** Schemes a link may keep. Anything else (notably `javascript:`) becomes plain text. */
|
|
39
|
+
const SAFE_SCHEMES = ["http:", "https:", "mailto:", "tel:"];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The href, or `undefined` when it is not a scheme we will hand to the browser.
|
|
43
|
+
* Relative hrefs are dropped too — a Word document's internal bookmarks point at
|
|
44
|
+
* anchors that do not exist on the host page.
|
|
45
|
+
*/
|
|
46
|
+
export function safeHref(value: string | null): string | undefined {
|
|
47
|
+
if (!value) return undefined;
|
|
48
|
+
try {
|
|
49
|
+
const url = new URL(value, "https://viewer.invalid");
|
|
50
|
+
return SAFE_SCHEMES.includes(url.protocol) && !value.startsWith("#") ? url.href : undefined;
|
|
51
|
+
} catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface RunStyle {
|
|
57
|
+
bold?: boolean;
|
|
58
|
+
italic?: boolean;
|
|
59
|
+
href?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Collect the inline runs under a node, carrying style down the tree. */
|
|
63
|
+
function collectRuns(node: Node, style: RunStyle = {}, out: DocxRun[] = []): DocxRun[] {
|
|
64
|
+
for (const child of Array.from(node.childNodes)) {
|
|
65
|
+
if (child.nodeType === 3 /* TEXT_NODE */) {
|
|
66
|
+
const text = child.nodeValue ?? "";
|
|
67
|
+
if (text) out.push({ text, ...style });
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (child.nodeType !== 1 /* ELEMENT_NODE */) continue;
|
|
71
|
+
|
|
72
|
+
const element = child as Element;
|
|
73
|
+
const tag = element.tagName.toLowerCase();
|
|
74
|
+
if (tag === "br") {
|
|
75
|
+
out.push({ text: "\n", ...style });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const next: RunStyle = { ...style };
|
|
79
|
+
if (tag === "strong" || tag === "b") next.bold = true;
|
|
80
|
+
if (tag === "em" || tag === "i") next.italic = true;
|
|
81
|
+
if (tag === "a") {
|
|
82
|
+
const href = safeHref(element.getAttribute("href"));
|
|
83
|
+
if (href) next.href = href;
|
|
84
|
+
}
|
|
85
|
+
collectRuns(element, next, out);
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Merge adjacent runs that carry identical styling — fewer, longer spans. */
|
|
91
|
+
function compact(runs: DocxRun[]): DocxRun[] {
|
|
92
|
+
const merged: DocxRun[] = [];
|
|
93
|
+
for (const run of runs) {
|
|
94
|
+
const last = merged[merged.length - 1];
|
|
95
|
+
if (last && last.bold === run.bold && last.italic === run.italic && last.href === run.href) {
|
|
96
|
+
last.text += run.text;
|
|
97
|
+
} else {
|
|
98
|
+
merged.push({ ...run });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return merged.filter((run) => run.text.trim() !== "" || run.text.includes("\n"));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function textOf(node: Node): string {
|
|
105
|
+
return compact(collectRuns(node))
|
|
106
|
+
.map((run) => run.text)
|
|
107
|
+
.join("")
|
|
108
|
+
.trim();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function listItems(element: Element): DocxRun[][] {
|
|
112
|
+
const items: DocxRun[][] = [];
|
|
113
|
+
for (const child of Array.from(element.children)) {
|
|
114
|
+
if (child.tagName.toLowerCase() !== "li") continue;
|
|
115
|
+
// A nested list inside an `<li>` is flattened into its own items rather than
|
|
116
|
+
// being rendered as a sub-list: the depth is not worth a recursive component
|
|
117
|
+
// in a preview, and losing it never loses the WORDS.
|
|
118
|
+
const runs = compact(collectRuns(child));
|
|
119
|
+
if (runs.length > 0) items.push(runs);
|
|
120
|
+
for (const nested of Array.from(child.children)) {
|
|
121
|
+
const tag = nested.tagName.toLowerCase();
|
|
122
|
+
if (tag === "ul" || tag === "ol") items.push(...listItems(nested));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return items;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function tableBlock(element: Element): DocxBlock | undefined {
|
|
129
|
+
const rows = Array.from(element.querySelectorAll("tr"));
|
|
130
|
+
if (rows.length === 0) return undefined;
|
|
131
|
+
|
|
132
|
+
const cellsOf = (row: Element) =>
|
|
133
|
+
Array.from(row.children)
|
|
134
|
+
.filter((cell) => ["td", "th"].includes(cell.tagName.toLowerCase()))
|
|
135
|
+
.map(textOf);
|
|
136
|
+
|
|
137
|
+
const [firstRow, ...restRows] = rows;
|
|
138
|
+
if (!firstRow) return undefined;
|
|
139
|
+
|
|
140
|
+
// Word only emits `<th>` when the author marked a header row. Without it the
|
|
141
|
+
// first row is ordinary data, and promoting it would invent a header.
|
|
142
|
+
const isHeader = firstRow.querySelector("th") !== null;
|
|
143
|
+
return isHeader
|
|
144
|
+
? { type: "table", head: cellsOf(firstRow), rows: restRows.map(cellsOf) }
|
|
145
|
+
: { type: "table", rows: rows.map(cellsOf) };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const HEADING_LEVELS: Record<string, 1 | 2 | 3 | 4 | 5 | 6> = {
|
|
149
|
+
h1: 1,
|
|
150
|
+
h2: 2,
|
|
151
|
+
h3: 3,
|
|
152
|
+
h4: 4,
|
|
153
|
+
h5: 5,
|
|
154
|
+
h6: 6,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Parse mammoth's HTML into blocks.
|
|
159
|
+
*
|
|
160
|
+
* Takes a `DOMParser` factory so the caller decides where the parser comes from
|
|
161
|
+
* — the browser's own in an app, jsdom's in a test — instead of this module
|
|
162
|
+
* reaching for a global that may not exist on a server.
|
|
163
|
+
*/
|
|
164
|
+
export function htmlToBlocks(html: string, parse: (markup: string) => Document): DocxBlock[] {
|
|
165
|
+
const document = parse(`<body>${html}</body>`);
|
|
166
|
+
const blocks: DocxBlock[] = [];
|
|
167
|
+
|
|
168
|
+
const walk = (parent: Element) => {
|
|
169
|
+
for (const element of Array.from(parent.children)) {
|
|
170
|
+
const tag = element.tagName.toLowerCase();
|
|
171
|
+
const level = HEADING_LEVELS[tag];
|
|
172
|
+
|
|
173
|
+
if (level) {
|
|
174
|
+
const runs = compact(collectRuns(element));
|
|
175
|
+
if (runs.length > 0) blocks.push({ type: "heading", level, runs });
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (tag === "p") {
|
|
179
|
+
// A `<p>` holding only an image is the image, not an empty paragraph.
|
|
180
|
+
const image = element.querySelector("img");
|
|
181
|
+
const runs = compact(collectRuns(element));
|
|
182
|
+
if (image && runs.length === 0) {
|
|
183
|
+
blocks.push(imageBlock(image));
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (runs.length > 0) blocks.push({ type: "paragraph", runs });
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (tag === "ul" || tag === "ol") {
|
|
190
|
+
const items = listItems(element);
|
|
191
|
+
if (items.length > 0) blocks.push({ type: "list", ordered: tag === "ol", items });
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (tag === "table") {
|
|
195
|
+
const table = tableBlock(element);
|
|
196
|
+
if (table) blocks.push(table);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (tag === "img") {
|
|
200
|
+
blocks.push(imageBlock(element));
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
// Anything else — a `div`, a `section`, a tag mammoth grew in a later
|
|
204
|
+
// version — contributes its CHILDREN, never itself. That is what makes
|
|
205
|
+
// this an allowlist that cannot silently let markup through.
|
|
206
|
+
walk(element);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
walk(document.body);
|
|
211
|
+
return blocks;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function imageBlock(element: Element): DocxBlock {
|
|
215
|
+
const alt = element.getAttribute("alt")?.trim();
|
|
216
|
+
return {
|
|
217
|
+
type: "image",
|
|
218
|
+
// mammoth inlines images as `data:` URIs by default, so there is no remote
|
|
219
|
+
// origin here — and no `src` at all is better than a URL we did not vet.
|
|
220
|
+
src: element.getAttribute("src") ?? "",
|
|
221
|
+
...(alt ? { alt } : {}),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Written before a list item's own text in the projection. */
|
|
226
|
+
export const DOCX_LIST_BULLET = "• ";
|
|
227
|
+
|
|
228
|
+
/** Written between two cells of the same row. */
|
|
229
|
+
export const DOCX_CELL_SEPARATOR = "\t";
|
|
230
|
+
|
|
231
|
+
/** The header row's `row`, so one field can name every row of a table. */
|
|
232
|
+
export const DOCX_HEAD_ROW = -1;
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Where a stretch of the projection came from in the block model.
|
|
236
|
+
*
|
|
237
|
+
* Deliberately one shape with optional fields rather than a union: the renderer
|
|
238
|
+
* looks a span up by "this block, that item" in three places, and a union would
|
|
239
|
+
* make each of those a type narrowing exercise for no gain.
|
|
240
|
+
*/
|
|
241
|
+
export interface DocxRef {
|
|
242
|
+
/** Index into the `blocks` array. */
|
|
243
|
+
block: number;
|
|
244
|
+
/** Which item, for a `list` block. */
|
|
245
|
+
item?: number;
|
|
246
|
+
/** Which row, for a `table` block. {@link DOCX_HEAD_ROW} is the header. */
|
|
247
|
+
row?: number;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Plain-text projection of a parsed document, plus the map back into the blocks.
|
|
252
|
+
*
|
|
253
|
+
* Rows rather than cells are the finest ref: one builder has one separator, and
|
|
254
|
+
* a row's cells are joined by a tab while the rows themselves are joined by a
|
|
255
|
+
* newline. The renderer walks a row's cells the same way it walks a paragraph's
|
|
256
|
+
* runs — accumulating lengths — so the granularity of the MARK is still the
|
|
257
|
+
* character, even though the granularity of the REF is the row.
|
|
258
|
+
*/
|
|
259
|
+
export function blocksToTextWithMap(blocks: DocxBlock[]): TextIndex<DocxRef> {
|
|
260
|
+
const builder = createTextIndexBuilder<DocxRef>();
|
|
261
|
+
blocks.forEach((block, index) => {
|
|
262
|
+
if (block.type === "heading" || block.type === "paragraph") {
|
|
263
|
+
builder.push(block.runs.map((run) => run.text).join(""), { block: index });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (block.type === "list") {
|
|
267
|
+
block.items.forEach((item, item_) => {
|
|
268
|
+
builder.push(`${DOCX_LIST_BULLET}${item.map((run) => run.text).join("")}`, {
|
|
269
|
+
block: index,
|
|
270
|
+
item: item_,
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (block.type === "table") {
|
|
276
|
+
if (block.head) {
|
|
277
|
+
builder.push(block.head.join(DOCX_CELL_SEPARATOR), { block: index, row: DOCX_HEAD_ROW });
|
|
278
|
+
}
|
|
279
|
+
block.rows.forEach((row, rowIndex) => {
|
|
280
|
+
builder.push(row.join(DOCX_CELL_SEPARATOR), { block: index, row: rowIndex });
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
return builder.build();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Plain-text projection of a parsed document — powers search, copy and the raw
|
|
289
|
+
* view. A thin wrapper so the projection has exactly one definition: it is the
|
|
290
|
+
* index's own text, never a second assembly that could drift from it.
|
|
291
|
+
*/
|
|
292
|
+
export function blocksToText(blocks: DocxBlock[]): string {
|
|
293
|
+
return blocksToTextWithMap(blocks).text;
|
|
294
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { normalizeFileSource } from "@elabs-ai/components-ui";
|
|
2
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import imageModule, { type ImageDocument } from "./image-adapter";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* jsdom parses `<img>` but never fetches it, so neither `load` nor `error` ever
|
|
9
|
+
* fires — the real browser event this adapter measures with simply does not
|
|
10
|
+
* exist here. The stub reports one fixed size so the load can complete; the
|
|
11
|
+
* decode path itself is exercised in the Storybook stories, in a real browser.
|
|
12
|
+
*/
|
|
13
|
+
const ORIGINAL_IMAGE = globalThis.Image;
|
|
14
|
+
|
|
15
|
+
function stubImage(behaviour: "load" | "error") {
|
|
16
|
+
globalThis.Image = class {
|
|
17
|
+
naturalWidth = 800;
|
|
18
|
+
naturalHeight = 600;
|
|
19
|
+
onload: (() => void) | null = null;
|
|
20
|
+
onerror: (() => void) | null = null;
|
|
21
|
+
set src(_value: string) {
|
|
22
|
+
queueMicrotask(() => (behaviour === "load" ? this.onload?.() : this.onerror?.()));
|
|
23
|
+
}
|
|
24
|
+
} as unknown as typeof Image;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const png = () => new Blob([new Uint8Array([0x89, 0x50])], { type: "image/png" });
|
|
28
|
+
const sourceFor = (alt?: string) =>
|
|
29
|
+
normalizeFileSource({ kind: "blob", blob: png(), name: "photo.png", alt });
|
|
30
|
+
|
|
31
|
+
beforeEach(() => stubImage("load"));
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
globalThis.Image = ORIGINAL_IMAGE;
|
|
34
|
+
vi.restoreAllMocks();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("image adapter — loading", () => {
|
|
38
|
+
it("mints a URL and reports the intrinsic size, so the box can be reserved", async () => {
|
|
39
|
+
const doc = (await imageModule.create().load(sourceFor(), {})) as ImageDocument;
|
|
40
|
+
expect(doc.url).toMatch(/^blob:/);
|
|
41
|
+
expect(doc.width).toBe(800);
|
|
42
|
+
expect(doc.height).toBe(600);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("still loads when the size cannot be measured — measuring is not the load", async () => {
|
|
46
|
+
stubImage("error");
|
|
47
|
+
const doc = (await imageModule.create().load(sourceFor(), {})) as ImageDocument;
|
|
48
|
+
expect(doc.url).toMatch(/^blob:/);
|
|
49
|
+
expect(doc.width).toBeUndefined();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("revokes the URL it minted when disposed", async () => {
|
|
53
|
+
const revoke = vi.spyOn(URL, "revokeObjectURL");
|
|
54
|
+
const adapter = imageModule.create();
|
|
55
|
+
await adapter.load(sourceFor(), {});
|
|
56
|
+
adapter.dispose?.();
|
|
57
|
+
expect(revoke).toHaveBeenCalledTimes(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("leaves a public remote URL alone — nothing was minted, nothing to revoke", async () => {
|
|
61
|
+
const revoke = vi.spyOn(URL, "revokeObjectURL");
|
|
62
|
+
const remote = normalizeFileSource({ kind: "url", url: "https://x.test/p.png" });
|
|
63
|
+
const doc = (await imageModule.create().load(remote, {})) as ImageDocument;
|
|
64
|
+
expect(doc.url).toBe("https://x.test/p.png");
|
|
65
|
+
expect(revoke).not.toHaveBeenCalled();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("image adapter — rendering", () => {
|
|
70
|
+
const doc: ImageDocument = { kind: "image", url: "blob:x", width: 800, height: 600 };
|
|
71
|
+
|
|
72
|
+
it("renders a real <img> with its intrinsic dimensions, not a canvas", () => {
|
|
73
|
+
const { container } = render(<imageModule.Renderer document={doc} source={sourceFor()} />);
|
|
74
|
+
const img = container.querySelector("img");
|
|
75
|
+
expect(img).toHaveAttribute("width", "800");
|
|
76
|
+
expect(img).toHaveAttribute("height", "600");
|
|
77
|
+
expect(container.querySelector("canvas")).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("is decorative with no description, and named when the source carries one", () => {
|
|
81
|
+
const { container, rerender } = render(
|
|
82
|
+
<imageModule.Renderer document={doc} source={sourceFor()} />,
|
|
83
|
+
);
|
|
84
|
+
expect(container.querySelector("img")).toHaveAttribute("alt", "");
|
|
85
|
+
|
|
86
|
+
rerender(<imageModule.Renderer document={doc} source={sourceFor("A red bicycle")} />);
|
|
87
|
+
expect(screen.getByRole("img", { name: "A red bicycle" })).toBeInTheDocument();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("reports a broken image as a terminal alert, not a stuck placeholder", () => {
|
|
91
|
+
const { container } = render(<imageModule.Renderer document={doc} source={sourceFor()} />);
|
|
92
|
+
fireEvent.error(container.querySelector("img") as HTMLImageElement);
|
|
93
|
+
expect(screen.getByRole("alert")).toHaveTextContent("photo.png");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("honours the zoom and rotation its manifest claims (ADR 0026)", () => {
|
|
97
|
+
// The manifest declared both while the renderer implemented neither, so the
|
|
98
|
+
// shell offered controls that did nothing. These assertions are what stops
|
|
99
|
+
// that claim from going hollow again.
|
|
100
|
+
expect(imageModule.manifest.capabilities).toMatchObject({ zoom: true, rotate: true });
|
|
101
|
+
|
|
102
|
+
const { container, rerender } = render(
|
|
103
|
+
<imageModule.Renderer document={doc} source={sourceFor()} zoom={2} rotation={90} />,
|
|
104
|
+
);
|
|
105
|
+
const img = container.querySelector("img") as HTMLImageElement;
|
|
106
|
+
// At a fixed scale the intrinsic cap has to come off, or "200%" silently
|
|
107
|
+
// stops at the pane's width.
|
|
108
|
+
expect(img.style.width).toBe("1600px");
|
|
109
|
+
expect(img.style.height).toBe("1200px");
|
|
110
|
+
// A quarter turn gets a box with the ROTATED bounds (the axes swap) and the
|
|
111
|
+
// image centred inside it. Without the box, a transform overflows the pane
|
|
112
|
+
// on all four sides and scrolling can only ever reach two of them.
|
|
113
|
+
expect(img.style.transform).toBe("translate(-50%, -50%) rotate(90deg)");
|
|
114
|
+
expect((img.parentElement as HTMLElement).style.width).toBe("1200px");
|
|
115
|
+
expect((img.parentElement as HTMLElement).style.height).toBe("1600px");
|
|
116
|
+
|
|
117
|
+
rerender(<imageModule.Renderer document={doc} source={sourceFor()} zoom="fit-page" />);
|
|
118
|
+
// Re-queried on purpose: dropping the rotation box changes the tree shape,
|
|
119
|
+
// so React swaps the element rather than patching the old one.
|
|
120
|
+
const fitted = container.querySelector("img") as HTMLImageElement;
|
|
121
|
+
expect(fitted.style.width).toBe("");
|
|
122
|
+
expect(fitted.className).toContain("max-h-full");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("fits the ROTATED bounds, so a turned image is never clipped", () => {
|
|
126
|
+
// Fitting and turning at once is the case a transform gets wrong on its
|
|
127
|
+
// own: the image is sized to the pane's width, then rotated into a box
|
|
128
|
+
// taller than the pane. The fit has to read the pane's other axis, which is
|
|
129
|
+
// what the container units do.
|
|
130
|
+
const { container } = render(
|
|
131
|
+
<imageModule.Renderer document={doc} source={sourceFor()} zoom="fit-width" rotation={90} />,
|
|
132
|
+
);
|
|
133
|
+
const img = container.querySelector("img") as HTMLImageElement;
|
|
134
|
+
expect(img.className).toContain("max-h-[100cqw]");
|
|
135
|
+
expect(img.className).toContain("max-w-[100cqh]");
|
|
136
|
+
expect(img.className).not.toContain("max-w-full");
|
|
137
|
+
// Container units answer only inside a size container, and only a definite
|
|
138
|
+
// height makes one.
|
|
139
|
+
expect((img.parentElement as HTMLElement).className).toContain("[container-type:size]");
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Image adapter — the reference implementation of "adapters emit data".
|
|
5
|
+
*
|
|
6
|
+
* It renders an `<img>`, never a `<canvas>`. anyview draws images to a canvas
|
|
7
|
+
* (`ImageAdapter.ts:118`, `PageRenderer.tsx:128`), which throws away the alt
|
|
8
|
+
* text, the browser's own decoding and zoom, and the ability to select or save
|
|
9
|
+
* the image — with no route back, because its API has no alt-text path at all.
|
|
10
|
+
* Here the description travels on the `FileSource` itself, so a screen reader
|
|
11
|
+
* user gets the same information a sighted user does.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { cn, StatePanel, useLocale } from "@elabs-ai/components-ui";
|
|
15
|
+
import { useEffect, useState } from "react";
|
|
16
|
+
|
|
17
|
+
import type {
|
|
18
|
+
AdapterDocument,
|
|
19
|
+
AdapterLoadContext,
|
|
20
|
+
AdapterModule,
|
|
21
|
+
AdapterRendererProps,
|
|
22
|
+
FileAdapter,
|
|
23
|
+
} from "../../core/types";
|
|
24
|
+
import { imageManifest } from "./image-manifest";
|
|
25
|
+
import { toViewerError } from "../../core/errors";
|
|
26
|
+
import type { ResolvedFileSource } from "@elabs-ai/components-ui";
|
|
27
|
+
|
|
28
|
+
export interface ImageDocument extends AdapterDocument {
|
|
29
|
+
kind: "image";
|
|
30
|
+
/** A URL an `<img>` can load — the source's own URL, or a minted object URL. */
|
|
31
|
+
url: string;
|
|
32
|
+
/** Intrinsic size, once decoded. Used to reserve the box and to label the file. */
|
|
33
|
+
width?: number;
|
|
34
|
+
height?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Decode just enough to learn the intrinsic size. Never rejects the load. */
|
|
38
|
+
function measure(
|
|
39
|
+
url: string,
|
|
40
|
+
signal?: AbortSignal,
|
|
41
|
+
): Promise<{ width: number; height: number } | undefined> {
|
|
42
|
+
if (typeof Image !== "function") return Promise.resolve(undefined);
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
const img = new Image();
|
|
45
|
+
const done = (value?: { width: number; height: number }) => {
|
|
46
|
+
img.onload = null;
|
|
47
|
+
img.onerror = null;
|
|
48
|
+
resolve(value);
|
|
49
|
+
};
|
|
50
|
+
img.onload = () => done({ width: img.naturalWidth, height: img.naturalHeight });
|
|
51
|
+
// A measurement failure is not a load failure — the <img> will surface it.
|
|
52
|
+
img.onerror = () => done(undefined);
|
|
53
|
+
signal?.addEventListener("abort", () => done(undefined), { once: true });
|
|
54
|
+
img.src = url;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
class ImageAdapter implements FileAdapter {
|
|
59
|
+
#source?: ResolvedFileSource;
|
|
60
|
+
|
|
61
|
+
async load(source: ResolvedFileSource, context: AdapterLoadContext): Promise<ImageDocument> {
|
|
62
|
+
this.#source = source;
|
|
63
|
+
try {
|
|
64
|
+
const url = await source.url(context.signal);
|
|
65
|
+
const size = await measure(url, context.signal);
|
|
66
|
+
return { kind: "image", url, ...size };
|
|
67
|
+
} catch (error) {
|
|
68
|
+
throw toViewerError(error, "read-failed", { fileName: source.name });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
dispose(): void {
|
|
73
|
+
// Releases the object URL this load minted, if any. A remote public image
|
|
74
|
+
// never minted one, and `revoke()` is a no-op there.
|
|
75
|
+
this.#source?.revoke();
|
|
76
|
+
this.#source = undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function ImageRenderer({
|
|
81
|
+
document: doc,
|
|
82
|
+
source,
|
|
83
|
+
className,
|
|
84
|
+
zoom = "fit-page",
|
|
85
|
+
rotation = 0,
|
|
86
|
+
}: AdapterRendererProps) {
|
|
87
|
+
const image = doc as ImageDocument;
|
|
88
|
+
const { t } = useLocale();
|
|
89
|
+
const [failed, setFailed] = useState(false);
|
|
90
|
+
|
|
91
|
+
useEffect(() => setFailed(false), [image.url]);
|
|
92
|
+
|
|
93
|
+
if (failed) {
|
|
94
|
+
// A terminal, settled failure (loading-states.md): the browser tried and
|
|
95
|
+
// gave up, so this is not a transient not-ready state.
|
|
96
|
+
return (
|
|
97
|
+
<div className={cn("flex min-h-full flex-col justify-center p-4", className)}>
|
|
98
|
+
<StatePanel
|
|
99
|
+
kind="error"
|
|
100
|
+
title={t("viewer.error.imageFailedTitle")}
|
|
101
|
+
description={t("viewer.error.imageFailed", { name: source.name })}
|
|
102
|
+
/>
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const fitting = typeof zoom !== "number";
|
|
108
|
+
// A quarter turn swaps the axes: what was the image's height is now the width
|
|
109
|
+
// the reader sees. Everything below that treats 90/270 differently is that one
|
|
110
|
+
// fact — 180 leaves the bounds alone and needs none of it.
|
|
111
|
+
const quarter = rotation === 90 || rotation === 270;
|
|
112
|
+
const scaled =
|
|
113
|
+
fitting || image.width === undefined || image.height === undefined
|
|
114
|
+
? undefined
|
|
115
|
+
: { width: image.width * zoom, height: image.height * zoom };
|
|
116
|
+
// A `transform` does not change layout, so a turned image at a fixed scale
|
|
117
|
+
// needs a box with the ROTATED bounds. Without one it overflows the pane on
|
|
118
|
+
// every side, and the half above the top edge is unreachable — scrolling only
|
|
119
|
+
// ever reaches transform overflow past the END edges.
|
|
120
|
+
const boxed = quarter && scaled !== undefined;
|
|
121
|
+
|
|
122
|
+
const img = (
|
|
123
|
+
<img
|
|
124
|
+
src={image.url}
|
|
125
|
+
// An image with no author description is decorative to AT — an empty alt
|
|
126
|
+
// is correct and deliberate, not a missing label.
|
|
127
|
+
alt={source.alt ?? ""}
|
|
128
|
+
width={image.width}
|
|
129
|
+
height={image.height}
|
|
130
|
+
onError={() => setFailed(true)}
|
|
131
|
+
className={cn(
|
|
132
|
+
"block object-contain",
|
|
133
|
+
!quarter && zoom === "fit-page" && "max-h-full max-w-full",
|
|
134
|
+
!quarter && zoom === "fit-width" && "h-auto max-w-full",
|
|
135
|
+
// Turned on its side, the pane's HEIGHT caps the image's width and its
|
|
136
|
+
// width caps the height. Container units read the pane directly, so the
|
|
137
|
+
// fit needs no measurement. Both fit modes converge here deliberately:
|
|
138
|
+
// an image whose rotated width filled the pane would overflow the top
|
|
139
|
+
// edge as well, which is the unreachable half described above.
|
|
140
|
+
fitting && quarter && "max-h-[100cqw] max-w-[100cqh]",
|
|
141
|
+
// At a fixed scale the intrinsic cap has to come off, or "200%" would
|
|
142
|
+
// silently stop at the pane's width.
|
|
143
|
+
!fitting && "max-w-none",
|
|
144
|
+
boxed && "absolute top-1/2 left-1/2",
|
|
145
|
+
)}
|
|
146
|
+
style={{
|
|
147
|
+
...scaled,
|
|
148
|
+
...(rotation === 0
|
|
149
|
+
? undefined
|
|
150
|
+
: {
|
|
151
|
+
transform: boxed
|
|
152
|
+
? `translate(-50%, -50%) rotate(${rotation}deg)`
|
|
153
|
+
: `rotate(${rotation}deg)`,
|
|
154
|
+
}),
|
|
155
|
+
}}
|
|
156
|
+
/>
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
// Centred on both axes: a small image pinned to the top-left of a tall pane
|
|
161
|
+
// reads as a layout accident. While fitting, the box takes the pane's height
|
|
162
|
+
// so `max-h-full` has something definite to resolve against; at a fixed
|
|
163
|
+
// scale it grows instead, and `FileViewerContent` scrolls it.
|
|
164
|
+
<div
|
|
165
|
+
className={cn(
|
|
166
|
+
"flex items-center justify-center",
|
|
167
|
+
fitting ? "h-full" : "min-h-full",
|
|
168
|
+
// Only a size container can answer `cqh`, and only a definite height
|
|
169
|
+
// makes one — which is exactly the fitting case.
|
|
170
|
+
fitting && quarter && "[container-type:size]",
|
|
171
|
+
className,
|
|
172
|
+
)}
|
|
173
|
+
>
|
|
174
|
+
{quarter && scaled ? (
|
|
175
|
+
<div className="relative shrink-0" style={{ width: scaled.height, height: scaled.width }}>
|
|
176
|
+
{img}
|
|
177
|
+
</div>
|
|
178
|
+
) : (
|
|
179
|
+
img
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const adapterModule: AdapterModule = {
|
|
186
|
+
manifest: imageManifest,
|
|
187
|
+
create: () => new ImageAdapter(),
|
|
188
|
+
Renderer: ImageRenderer,
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
export default adapterModule;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AdapterManifest } from "../../core/types";
|
|
2
|
+
import { PROTOCOL_VERSION } from "../../core/types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Eager, data-only. Split from the adapter module so `createDefaultRegistry`
|
|
6
|
+
* can answer "can this be opened, and what controls apply" without pulling the
|
|
7
|
+
* renderer into the entry chunk.
|
|
8
|
+
*/
|
|
9
|
+
export const imageManifest: AdapterManifest = {
|
|
10
|
+
id: "image",
|
|
11
|
+
protocol: PROTOCOL_VERSION,
|
|
12
|
+
categories: ["image"],
|
|
13
|
+
mediaTypes: ["image/"],
|
|
14
|
+
capabilities: { zoom: true, rotate: true },
|
|
15
|
+
};
|