@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,117 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Plain-text adapter — the fallback for anything readable as UTF-8.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately does NOT do SYNTAX highlighting — that is the separate `code`
|
|
7
|
+
* adapter, registered at a higher priority for code extensions. Keeping them
|
|
8
|
+
* apart means a 40 MB log file never drags a tokenizer into the bundle, and a
|
|
9
|
+
* consumer can drop the tokenizer without losing plain text. (Citation and
|
|
10
|
+
* find-in-document marks are a different thing entirely and DO render here.)
|
|
11
|
+
*
|
|
12
|
+
* Large files are truncated at load, not at render: putting ten million
|
|
13
|
+
* characters into one `<pre>` locks the main thread with no way back. The cut is
|
|
14
|
+
* reported so the UI can say so rather than silently lying about the content.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { useMemo, useRef } from "react";
|
|
18
|
+
|
|
19
|
+
import { MatchHighlight, cn, useLocale } from "@elabs-ai/components-ui";
|
|
20
|
+
import type { ResolvedFileSource } from "@elabs-ai/components-ui";
|
|
21
|
+
|
|
22
|
+
import { toViewerError } from "../../core/errors";
|
|
23
|
+
import { toMarkRanges } from "../../core/highlight-marks";
|
|
24
|
+
import { useScrollActiveHighlightIntoView } from "../../core/use-highlight-scroll";
|
|
25
|
+
import type {
|
|
26
|
+
AdapterDocument,
|
|
27
|
+
AdapterLoadContext,
|
|
28
|
+
AdapterModule,
|
|
29
|
+
AdapterRendererProps,
|
|
30
|
+
FileAdapter,
|
|
31
|
+
} from "../../core/types";
|
|
32
|
+
import { textManifest } from "./text-manifest";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Characters kept before truncation. ~2M is comfortably past any file a person
|
|
36
|
+
* reads in a preview pane and still renders in one frame.
|
|
37
|
+
*/
|
|
38
|
+
export const TEXT_CHARACTER_LIMIT = 2_000_000;
|
|
39
|
+
|
|
40
|
+
export interface TextDocument extends AdapterDocument {
|
|
41
|
+
kind: "text";
|
|
42
|
+
text: string;
|
|
43
|
+
/** Total characters in the file, when more than what is shown. */
|
|
44
|
+
totalCharacters?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
class TextAdapter implements FileAdapter {
|
|
48
|
+
async load(source: ResolvedFileSource, context: AdapterLoadContext): Promise<TextDocument> {
|
|
49
|
+
let raw: string;
|
|
50
|
+
try {
|
|
51
|
+
raw = await source.text(context.signal);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
throw toViewerError(error, "read-failed", { fileName: source.name });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (raw.length <= TEXT_CHARACTER_LIMIT) return { kind: "text", text: raw };
|
|
57
|
+
return {
|
|
58
|
+
kind: "text",
|
|
59
|
+
text: raw.slice(0, TEXT_CHARACTER_LIMIT),
|
|
60
|
+
totalCharacters: raw.length,
|
|
61
|
+
// So a passage the shell cannot find is reported as "past the part we
|
|
62
|
+
// previewed" rather than "not in this document" — different news.
|
|
63
|
+
textTruncated: true,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function TextRenderer({
|
|
69
|
+
document: doc,
|
|
70
|
+
className,
|
|
71
|
+
highlights,
|
|
72
|
+
activeHighlightId,
|
|
73
|
+
}: AdapterRendererProps) {
|
|
74
|
+
const text = doc as TextDocument;
|
|
75
|
+
const { t, formatNumber } = useLocale();
|
|
76
|
+
const container = useRef<HTMLDivElement>(null);
|
|
77
|
+
const { ranges, activeIndex } = useMemo(
|
|
78
|
+
() => toMarkRanges(highlights, text.text.length),
|
|
79
|
+
[highlights, text.text.length],
|
|
80
|
+
);
|
|
81
|
+
useScrollActiveHighlightIntoView(container, activeHighlightId);
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
// No `overflow-auto` here: `FileViewerContent` is the scroll boundary, and
|
|
85
|
+
// nesting a second one clips the text above the outer pane's own padding.
|
|
86
|
+
<div ref={container} className={cn("flex flex-col gap-2", className)}>
|
|
87
|
+
{text.totalCharacters !== undefined && (
|
|
88
|
+
// Not an error — the file is fine, we are showing part of it. A status
|
|
89
|
+
// region, so AT hears it once rather than on every scroll.
|
|
90
|
+
<p role="status" className="text-meta text-muted-foreground">
|
|
91
|
+
{t("viewer.text.truncated", {
|
|
92
|
+
shown: formatNumber(text.text.length),
|
|
93
|
+
total: formatNumber(text.totalCharacters),
|
|
94
|
+
})}
|
|
95
|
+
</p>
|
|
96
|
+
)}
|
|
97
|
+
<pre className="text-code font-mono break-words whitespace-pre-wrap">
|
|
98
|
+
{ranges.length === 0 ? (
|
|
99
|
+
text.text
|
|
100
|
+
) : (
|
|
101
|
+
// `MatchHighlight` renders the WHOLE string with the matches wrapped,
|
|
102
|
+
// so nothing is lost when a highlight is present — a screen reader
|
|
103
|
+
// still reads the file continuously.
|
|
104
|
+
<MatchHighlight text={text.text} ranges={ranges} activeIndex={activeIndex} />
|
|
105
|
+
)}
|
|
106
|
+
</pre>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const adapterModule: AdapterModule = {
|
|
112
|
+
manifest: textManifest,
|
|
113
|
+
create: () => new TextAdapter(),
|
|
114
|
+
Renderer: TextRenderer,
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export default adapterModule;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { AdapterManifest } from "../../core/types";
|
|
2
|
+
import { PROTOCOL_VERSION } from "../../core/types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Eager, data-only (see `image-manifest.ts`).
|
|
6
|
+
*
|
|
7
|
+
* Claims only broad CATEGORIES, so any adapter naming an extension or an exact
|
|
8
|
+
* MIME outranks it automatically — this is the "readable as text" backstop.
|
|
9
|
+
*/
|
|
10
|
+
export const textManifest: AdapterManifest = {
|
|
11
|
+
id: "text",
|
|
12
|
+
protocol: PROTOCOL_VERSION,
|
|
13
|
+
categories: ["text", "code", "data", "unknown"],
|
|
14
|
+
mediaTypes: ["text/"],
|
|
15
|
+
// `rect` is absent on purpose: a plain-text file has no geometry to point at,
|
|
16
|
+
// and a manifest that claimed it would let an app offer an affordance the
|
|
17
|
+
// renderer can never honour.
|
|
18
|
+
capabilities: { text: true, search: true, highlight: ["quote", "range"] },
|
|
19
|
+
};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { normalizeFileSource } from "@elabs-ai/components-ui";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import type { ResolvedHighlight } from "../../core/highlight";
|
|
7
|
+
import { SAMPLE_XLSX_BASE64 } from "../office-fixture";
|
|
8
|
+
import xlsxModule, { type XlsxDocument } from "./xlsx-adapter";
|
|
9
|
+
|
|
10
|
+
const MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
|
11
|
+
|
|
12
|
+
function fixtureSource(name = "quarter.xlsx") {
|
|
13
|
+
const bytes = Uint8Array.from(atob(SAMPLE_XLSX_BASE64), (char) => char.charCodeAt(0));
|
|
14
|
+
return normalizeFileSource({
|
|
15
|
+
kind: "buffer",
|
|
16
|
+
buffer: bytes.buffer as ArrayBuffer,
|
|
17
|
+
name,
|
|
18
|
+
mediaType: MEDIA_TYPE,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const load = () => xlsxModule.create().load(fixtureSource(), {}) as Promise<XlsxDocument>;
|
|
23
|
+
|
|
24
|
+
describe("xlsx adapter — a real workbook through SheetJS", () => {
|
|
25
|
+
it("reads every sheet, keeping the tab names the author gave them", async () => {
|
|
26
|
+
const doc = await load();
|
|
27
|
+
expect(doc.sheets.map((sheet) => sheet.name)).toEqual(["Revenue", "Headcount"]);
|
|
28
|
+
expect(doc.pageCount).toBe(2);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("takes the first row as headers and the rest as rows", async () => {
|
|
32
|
+
const [revenue] = (await load()).sheets;
|
|
33
|
+
expect(revenue?.columns).toEqual(["Region", "Revenue", "Closed"]);
|
|
34
|
+
expect(revenue?.rows[0]?.slice(0, 2)).toEqual(["EMEA", "4200000"]);
|
|
35
|
+
expect(revenue?.rows).toHaveLength(3);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("shows a date as a date, not as Excel's serial day number", async () => {
|
|
39
|
+
const [revenue] = (await load()).sheets;
|
|
40
|
+
// Without `cellDates` this cell reads "46112", which means nothing to a reader.
|
|
41
|
+
expect(revenue?.rows[0]?.[2]).toBe("2026-03-31");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("does not claim truncation when every row fits", async () => {
|
|
45
|
+
for (const sheet of (await load()).sheets) expect(sheet.totalRows).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("projects the whole workbook to text so search and copy cross sheets", async () => {
|
|
49
|
+
const doc = await load();
|
|
50
|
+
expect(doc.text).toContain("Headcount");
|
|
51
|
+
expect(doc.text).toContain("Engineering\t34");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("reports a file that is not a workbook as parse-failed", async () => {
|
|
55
|
+
const source = normalizeFileSource({
|
|
56
|
+
kind: "text",
|
|
57
|
+
text: "not a workbook",
|
|
58
|
+
name: "broken.xlsx",
|
|
59
|
+
mediaType: MEDIA_TYPE,
|
|
60
|
+
});
|
|
61
|
+
await expect(xlsxModule.create().load(source, {})).rejects.toMatchObject({
|
|
62
|
+
code: "parse-failed",
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("xlsx renderer", () => {
|
|
68
|
+
it("renders a real table, and offers the sheets as tabs", async () => {
|
|
69
|
+
const doc = await load();
|
|
70
|
+
render(<xlsxModule.Renderer document={doc} source={fixtureSource()} />);
|
|
71
|
+
|
|
72
|
+
expect(screen.getByRole("tablist", { name: "Sheets" })).toBeInTheDocument();
|
|
73
|
+
expect(screen.getByRole("columnheader", { name: "Region" })).toBeInTheDocument();
|
|
74
|
+
expect(screen.getByRole("cell", { name: "4200000" })).toBeInTheDocument();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("switches sheets without reloading the file", async () => {
|
|
78
|
+
const doc = await load();
|
|
79
|
+
render(<xlsxModule.Renderer document={doc} source={fixtureSource()} />);
|
|
80
|
+
|
|
81
|
+
await userEvent.click(screen.getByRole("tab", { name: "Headcount" }));
|
|
82
|
+
expect(await screen.findByRole("columnheader", { name: "Team" })).toBeInTheDocument();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("drops the tab strip for a single-sheet workbook — chrome that decides nothing", async () => {
|
|
86
|
+
const doc = await load();
|
|
87
|
+
const single: XlsxDocument = { ...doc, sheets: doc.sheets.slice(0, 1), pageCount: 1 };
|
|
88
|
+
render(<xlsxModule.Renderer document={single} source={fixtureSource()} />);
|
|
89
|
+
|
|
90
|
+
expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
|
|
91
|
+
expect(screen.getByRole("table")).toBeInTheDocument();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("xlsx renderer — highlighting", () => {
|
|
96
|
+
const cite = (id: string, range: [number, number], active = false): ResolvedHighlight => ({
|
|
97
|
+
id,
|
|
98
|
+
source: "citation",
|
|
99
|
+
status: "resolved",
|
|
100
|
+
address: { kind: "range", start: range[0], end: range[1] },
|
|
101
|
+
active,
|
|
102
|
+
range,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
/** Offsets of a passage in the workbook's own projection. */
|
|
106
|
+
const rangeOf = (doc: XlsxDocument, passage: string): [number, number] => {
|
|
107
|
+
const start = doc.text?.indexOf(passage) ?? -1;
|
|
108
|
+
expect(start).toBeGreaterThanOrEqual(0);
|
|
109
|
+
return [start, start + passage.length];
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const renderWith = async (
|
|
113
|
+
pick: (doc: XlsxDocument) => ResolvedHighlight[],
|
|
114
|
+
activeHighlightId?: string,
|
|
115
|
+
) => {
|
|
116
|
+
const doc = await load();
|
|
117
|
+
return render(
|
|
118
|
+
<xlsxModule.Renderer
|
|
119
|
+
document={doc}
|
|
120
|
+
source={fixtureSource()}
|
|
121
|
+
highlights={pick(doc)}
|
|
122
|
+
activeHighlightId={activeHighlightId}
|
|
123
|
+
/>,
|
|
124
|
+
);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
it("declares the address kinds it can actually honour", () => {
|
|
128
|
+
expect(xlsxModule.manifest.capabilities?.highlight).toEqual(["quote", "range"]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("marks one cell of the sheet on screen", async () => {
|
|
132
|
+
const { container } = await renderWith((doc) => [cite("a", rangeOf(doc, "4200000"))]);
|
|
133
|
+
const marks = Array.from(container.querySelectorAll("mark"));
|
|
134
|
+
expect(marks).toHaveLength(1);
|
|
135
|
+
expect(marks[0]?.closest("td")?.textContent).toBe("4200000");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("turns to the cited SHEET, the way the PDF turns to the cited page", async () => {
|
|
139
|
+
// "Engineering" is on Headcount, which is not the tab that opens.
|
|
140
|
+
const { container } = await renderWith(
|
|
141
|
+
(doc) => [cite("a", rangeOf(doc, "Engineering"), true)],
|
|
142
|
+
"a",
|
|
143
|
+
);
|
|
144
|
+
expect(await screen.findByRole("columnheader", { name: "Team" })).toBeInTheDocument();
|
|
145
|
+
expect(container.querySelector("mark")?.textContent).toBe("Engineering");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("flags the current passage for assistive tech, not by colour alone", async () => {
|
|
149
|
+
const { container } = await renderWith((doc) => [cite("a", rangeOf(doc, "EMEA"), true)], "a");
|
|
150
|
+
const active = container.querySelector('mark[data-active][data-slot="match-highlight-mark"]');
|
|
151
|
+
expect(active?.getAttribute("aria-current")).toBe("true");
|
|
152
|
+
expect(active?.textContent).toBe("EMEA");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("draws nothing when no highlight is passed", async () => {
|
|
156
|
+
const { container } = await renderWith(() => []);
|
|
157
|
+
expect(container.querySelectorAll("mark")).toHaveLength(0);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Spreadsheet adapter — a workbook becomes sheets of rows, and rows become a
|
|
5
|
+
* real `Table`.
|
|
6
|
+
*
|
|
7
|
+
* SheetJS is only ever asked for the DATA. Its `sheet_to_html` helper exists and
|
|
8
|
+
* is not used: it returns a markup string with its own inline styling, which is
|
|
9
|
+
* exactly the "adapter emits HTML" failure this package was built to avoid
|
|
10
|
+
* (ADR 0024 §3). `sheet_to_json({ header: 1 })` gives raw cell values, which the
|
|
11
|
+
* shared `SheetTable` renders with brand-ui components — so a spreadsheet
|
|
12
|
+
* inherits the theme, the density dial and the keyboard semantics.
|
|
13
|
+
*
|
|
14
|
+
* **`xlsx` is an optional peer with two known advisories.** The npm build
|
|
15
|
+
* (`0.18.5`) carries a prototype-pollution and a ReDoS advisory, both fixed only
|
|
16
|
+
* in SheetJS's self-hosted CDN builds, which are not published to npm. That is a
|
|
17
|
+
* documented, deliberate trade (`docs/CONSUMING.md` §6): a consumer who never
|
|
18
|
+
* opens a spreadsheet never installs it and is never exposed. Treat any workbook
|
|
19
|
+
* opened here as untrusted input.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { ResolvedFileSource } from "@elabs-ai/components-ui";
|
|
23
|
+
import { cn, Tabs, TabsContent, TabsList, TabsTrigger, useLocale } from "@elabs-ai/components-ui";
|
|
24
|
+
import { useEffect, useMemo, useState } from "react";
|
|
25
|
+
|
|
26
|
+
import { gridToText, type GridRef } from "../../components/grid-text";
|
|
27
|
+
import { SheetTable } from "../../components/sheet-table";
|
|
28
|
+
import { ViewerError, toViewerError } from "../../core/errors";
|
|
29
|
+
import { toMarkRanges } from "../../core/highlight-marks";
|
|
30
|
+
import { spanAt, type TextIndex } from "../../core/text-index";
|
|
31
|
+
import type {
|
|
32
|
+
AdapterDocument,
|
|
33
|
+
AdapterLoadContext,
|
|
34
|
+
AdapterModule,
|
|
35
|
+
AdapterRendererProps,
|
|
36
|
+
FileAdapter,
|
|
37
|
+
} from "../../core/types";
|
|
38
|
+
import { xlsxManifest } from "./xlsx-manifest";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Rows rendered per sheet before truncation. Same bound, same reason, as the CSV
|
|
42
|
+
* adapter: a preview pane is for looking, and a real analysis grid is
|
|
43
|
+
* `DataTable` in `@elabs-ai/components-data`.
|
|
44
|
+
*/
|
|
45
|
+
export const XLSX_ROW_LIMIT = 5_000;
|
|
46
|
+
|
|
47
|
+
export interface SheetModel {
|
|
48
|
+
/** The tab's own name, as authored in the workbook. */
|
|
49
|
+
name: string;
|
|
50
|
+
/** Header cells, taken from the sheet's first row. */
|
|
51
|
+
columns: string[];
|
|
52
|
+
/** Body rows, already capped at {@link XLSX_ROW_LIMIT}. */
|
|
53
|
+
rows: string[][];
|
|
54
|
+
/** Total body rows in the sheet, when more than what is shown. */
|
|
55
|
+
totalRows?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface XlsxDocument extends AdapterDocument {
|
|
59
|
+
kind: "xlsx";
|
|
60
|
+
sheets: SheetModel[];
|
|
61
|
+
/** Which sheet and row each stretch of `text` came from. */
|
|
62
|
+
textIndex?: TextIndex<GridRef>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Does this look like a workbook container at all?
|
|
67
|
+
*
|
|
68
|
+
* SheetJS sniffs its input and will happily read arbitrary text as a one-column
|
|
69
|
+
* CSV — so a damaged `.xlsx` comes back as a sheet holding one junk cell instead
|
|
70
|
+
* of failing. That is worse than an error: it looks like the file opened. Both
|
|
71
|
+
* containers this adapter claims have a fixed signature — OOXML/ODS is a zip
|
|
72
|
+
* (`PK\x03\x04`), legacy `.xls` is a CFB compound file — so anything else is
|
|
73
|
+
* rejected before SheetJS gets a chance to be helpful.
|
|
74
|
+
*/
|
|
75
|
+
export function looksLikeWorkbook(bytes: Uint8Array): boolean {
|
|
76
|
+
const zip = [0x50, 0x4b, 0x03, 0x04];
|
|
77
|
+
const cfb = [0xd0, 0xcf, 0x11, 0xe0];
|
|
78
|
+
return [zip, cfb].some((signature) => signature.every((byte, index) => bytes[index] === byte));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** SheetJS returns whatever the cell held — a date, a number, a formula result. */
|
|
82
|
+
function toCellText(value: unknown): string {
|
|
83
|
+
if (value === null || value === undefined) return "";
|
|
84
|
+
if (value instanceof Date) return value.toISOString().slice(0, 10);
|
|
85
|
+
return String(value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
class XlsxAdapter implements FileAdapter {
|
|
89
|
+
async load(source: ResolvedFileSource, context: AdapterLoadContext): Promise<XlsxDocument> {
|
|
90
|
+
let buffer: ArrayBuffer;
|
|
91
|
+
try {
|
|
92
|
+
buffer = await source.bytes(context.signal);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
throw toViewerError(error, "read-failed", { fileName: source.name });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const bytes = new Uint8Array(buffer);
|
|
98
|
+
if (!looksLikeWorkbook(bytes)) {
|
|
99
|
+
throw new ViewerError("parse-failed", "The file is not a spreadsheet container.", {
|
|
100
|
+
fileName: source.name,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Dynamic: the ONLY edge to the optional peer (heavy-deps:check).
|
|
105
|
+
const XLSX = await import("xlsx");
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
// `cellDates` so a date column reads as a date rather than as Excel's
|
|
109
|
+
// serial day number, which is meaningless to a person looking at it.
|
|
110
|
+
const workbook = XLSX.read(bytes, { type: "array", cellDates: true });
|
|
111
|
+
const sheets = workbook.SheetNames.map((name) => {
|
|
112
|
+
const sheet = workbook.Sheets[name];
|
|
113
|
+
const grid: unknown[][] = sheet
|
|
114
|
+
? XLSX.utils.sheet_to_json(sheet, { header: 1, blankrows: false, defval: "" })
|
|
115
|
+
: [];
|
|
116
|
+
const [header = [], ...body] = grid;
|
|
117
|
+
const columns = header.map(toCellText);
|
|
118
|
+
const rows = body.slice(0, XLSX_ROW_LIMIT).map((row) => row.map(toCellText));
|
|
119
|
+
return body.length > XLSX_ROW_LIMIT
|
|
120
|
+
? { name, columns, rows, totalRows: body.length }
|
|
121
|
+
: { name, columns, rows };
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (sheets.length === 0) {
|
|
125
|
+
throw new ViewerError("parse-failed", "The workbook contains no sheets.", {
|
|
126
|
+
fileName: source.name,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The projection is built by the shared grid builder rather than joined by
|
|
131
|
+
// hand, so the text a citation is addressed against and the map back into
|
|
132
|
+
// the cells cannot drift apart (`core/text-index.ts`).
|
|
133
|
+
const textIndex = gridToText(sheets);
|
|
134
|
+
return {
|
|
135
|
+
kind: "xlsx",
|
|
136
|
+
sheets,
|
|
137
|
+
pageCount: sheets.length,
|
|
138
|
+
text: textIndex.text,
|
|
139
|
+
textIndex,
|
|
140
|
+
};
|
|
141
|
+
} catch (error) {
|
|
142
|
+
throw toViewerError(error, "parse-failed", { fileName: source.name });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function XlsxRenderer({
|
|
148
|
+
document: doc,
|
|
149
|
+
className,
|
|
150
|
+
highlights,
|
|
151
|
+
activeHighlightId,
|
|
152
|
+
}: AdapterRendererProps) {
|
|
153
|
+
const workbook = doc as XlsxDocument;
|
|
154
|
+
const { t } = useLocale();
|
|
155
|
+
const [first] = workbook.sheets;
|
|
156
|
+
|
|
157
|
+
const marks = useMemo(
|
|
158
|
+
() => toMarkRanges(highlights, workbook.text?.length ?? 0),
|
|
159
|
+
[highlights, workbook.text],
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
// Where each row of each sheet begins in the projection. Built once per
|
|
163
|
+
// document rather than per tab, because switching sheets must not re-walk the
|
|
164
|
+
// spans of every OTHER sheet to answer a question about this one.
|
|
165
|
+
const starts = useMemo(() => {
|
|
166
|
+
const bySheet = new Map<number, Map<number, number>>();
|
|
167
|
+
for (const span of workbook.textIndex?.spans ?? []) {
|
|
168
|
+
let rows = bySheet.get(span.ref.sheet);
|
|
169
|
+
if (!rows) {
|
|
170
|
+
rows = new Map<number, number>();
|
|
171
|
+
bySheet.set(span.ref.sheet, rows);
|
|
172
|
+
}
|
|
173
|
+
rows.set(span.ref.row, span.start);
|
|
174
|
+
}
|
|
175
|
+
return bySheet;
|
|
176
|
+
}, [workbook.textIndex]);
|
|
177
|
+
const rowStartFor = (sheet: number) => (row: number) => starts.get(sheet)?.get(row);
|
|
178
|
+
|
|
179
|
+
// Turning to the cited SHEET is this format's half of "take me there" — the
|
|
180
|
+
// row scroll inside it belongs to `SheetTable`. Keyed on the sheet NAME, so a
|
|
181
|
+
// reader who switches tabs while the same citation stays active is not
|
|
182
|
+
// dragged back (the PDF pager makes the same trade).
|
|
183
|
+
const activeSheet = useMemo(() => {
|
|
184
|
+
const active = highlights?.find((highlight) => highlight.id === activeHighlightId);
|
|
185
|
+
if (!active || active.status !== "resolved" || !active.range || !workbook.textIndex) {
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
return spanAt(workbook.textIndex, active.range[0])?.ref.sheet;
|
|
189
|
+
}, [highlights, activeHighlightId, workbook.textIndex]);
|
|
190
|
+
const activeSheetName =
|
|
191
|
+
activeSheet === undefined ? undefined : workbook.sheets[activeSheet]?.name;
|
|
192
|
+
|
|
193
|
+
const [pinned, setPinned] = useState<string>();
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
if (activeSheetName !== undefined) setPinned(activeSheetName);
|
|
196
|
+
}, [activeSheetName]);
|
|
197
|
+
|
|
198
|
+
// A one-sheet workbook is the common case, and a tab strip holding a single
|
|
199
|
+
// tab is chrome that decides nothing — so it is not rendered.
|
|
200
|
+
if (!first) return null;
|
|
201
|
+
if (workbook.sheets.length === 1) {
|
|
202
|
+
return (
|
|
203
|
+
<SheetTable
|
|
204
|
+
columns={first.columns}
|
|
205
|
+
rows={first.rows}
|
|
206
|
+
totalRows={first.totalRows}
|
|
207
|
+
className={className}
|
|
208
|
+
marks={marks}
|
|
209
|
+
rowStart={rowStartFor(0)}
|
|
210
|
+
activeHighlightId={activeHighlightId}
|
|
211
|
+
/>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<Tabs
|
|
217
|
+
value={pinned ?? first.name}
|
|
218
|
+
onValueChange={setPinned}
|
|
219
|
+
className={cn("flex h-full min-h-0 flex-col gap-2", className)}
|
|
220
|
+
>
|
|
221
|
+
<TabsList aria-label={t("viewer.sheet.tabs")} className="shrink-0 self-start">
|
|
222
|
+
{workbook.sheets.map((sheet) => (
|
|
223
|
+
<TabsTrigger key={sheet.name} value={sheet.name}>
|
|
224
|
+
{sheet.name}
|
|
225
|
+
</TabsTrigger>
|
|
226
|
+
))}
|
|
227
|
+
</TabsList>
|
|
228
|
+
{workbook.sheets.map((sheet, index) => (
|
|
229
|
+
<TabsContent key={sheet.name} value={sheet.name} className="mt-0 min-h-0 flex-1">
|
|
230
|
+
<SheetTable
|
|
231
|
+
columns={sheet.columns}
|
|
232
|
+
rows={sheet.rows}
|
|
233
|
+
totalRows={sheet.totalRows}
|
|
234
|
+
marks={marks}
|
|
235
|
+
rowStart={rowStartFor(index)}
|
|
236
|
+
activeHighlightId={activeHighlightId}
|
|
237
|
+
/>
|
|
238
|
+
</TabsContent>
|
|
239
|
+
))}
|
|
240
|
+
</Tabs>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const adapterModule: AdapterModule = {
|
|
245
|
+
manifest: xlsxManifest,
|
|
246
|
+
create: () => new XlsxAdapter(),
|
|
247
|
+
Renderer: XlsxRenderer,
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
export default adapterModule;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type AdapterManifest, PROTOCOL_VERSION } from "../../core/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Excel workbooks. Data-only, eager — see `core/types.ts` for why the manifest
|
|
5
|
+
* and the loader are split.
|
|
6
|
+
*
|
|
7
|
+
* `.csv` is deliberately NOT claimed here even though SheetJS can read it: the
|
|
8
|
+
* `csv` adapter is a better answer for a delimited text file (it detects the
|
|
9
|
+
* delimiter and needs a far smaller parser), and claiming it would make which
|
|
10
|
+
* adapter wins depend on registration order.
|
|
11
|
+
*/
|
|
12
|
+
export const xlsxManifest: AdapterManifest = {
|
|
13
|
+
id: "xlsx",
|
|
14
|
+
protocol: PROTOCOL_VERSION,
|
|
15
|
+
extensions: ["xlsx", "xlsm", "xls", "ods"],
|
|
16
|
+
mediaTypes: [
|
|
17
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
18
|
+
"application/vnd.ms-excel",
|
|
19
|
+
"application/vnd.oasis.opendocument.spreadsheet",
|
|
20
|
+
],
|
|
21
|
+
// Addressed against the same grid projection the CSV adapter uses
|
|
22
|
+
// (`gridToText`), so a citation resolves the same way in a workbook and in the
|
|
23
|
+
// CSV export of one sheet. No `rect`: a cell has no page geometry.
|
|
24
|
+
capabilities: { pages: true, text: true, highlight: ["quote", "range"] },
|
|
25
|
+
requires: ["xlsx"],
|
|
26
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { chunkOffset } from "../core/text-index";
|
|
4
|
+
import { GRID_CELL_SEPARATOR, GRID_HEAD_ROW, GRID_NAME_ROW, gridToText } from "./grid-text";
|
|
5
|
+
|
|
6
|
+
/** `[the text this span covers, what it points at]` — the pair every test reads. */
|
|
7
|
+
const mapped = ({ text, spans }: ReturnType<typeof gridToText>) =>
|
|
8
|
+
spans.map((span) => [text.slice(span.start, span.end), span.ref]);
|
|
9
|
+
|
|
10
|
+
describe("gridToText", () => {
|
|
11
|
+
it("projects one nameless sheet as a header row and body rows", () => {
|
|
12
|
+
const index = gridToText([{ columns: ["Region", "Revenue"], rows: [["EMEA", "4.2M"]] }]);
|
|
13
|
+
|
|
14
|
+
expect(index.text).toBe("Region\tRevenue\nEMEA\t4.2M");
|
|
15
|
+
expect(mapped(index)).toEqual([
|
|
16
|
+
["Region\tRevenue", { sheet: 0, row: GRID_HEAD_ROW }],
|
|
17
|
+
["EMEA\t4.2M", { sheet: 0, row: 0 }],
|
|
18
|
+
]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("separates sheets with a blank line and names each one", () => {
|
|
22
|
+
const index = gridToText([
|
|
23
|
+
{ name: "Q3", columns: ["a"], rows: [["1"]] },
|
|
24
|
+
{ name: "Q4", columns: ["b"], rows: [["2"]] },
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
expect(index.text).toBe("Q3\na\n1\n\nQ4\nb\n2");
|
|
28
|
+
expect(mapped(index)).toEqual([
|
|
29
|
+
["Q3", { sheet: 0, row: GRID_NAME_ROW }],
|
|
30
|
+
["a", { sheet: 0, row: GRID_HEAD_ROW }],
|
|
31
|
+
["1", { sheet: 0, row: 0 }],
|
|
32
|
+
["Q4", { sheet: 1, row: GRID_NAME_ROW }],
|
|
33
|
+
["b", { sheet: 1, row: GRID_HEAD_ROW }],
|
|
34
|
+
["2", { sheet: 1, row: 0 }],
|
|
35
|
+
]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("puts the blank line before whatever the next sheet's FIRST line is", () => {
|
|
39
|
+
// The second sheet has no name, so the separator lands on its header.
|
|
40
|
+
const index = gridToText([
|
|
41
|
+
{ columns: ["a"], rows: [] },
|
|
42
|
+
{ columns: ["b"], rows: [] },
|
|
43
|
+
]);
|
|
44
|
+
expect(index.text).toBe("a\n\nb");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("skips a sheet with nothing in it rather than shifting every offset after it", () => {
|
|
48
|
+
const index = gridToText([
|
|
49
|
+
{ columns: ["a"], rows: [] },
|
|
50
|
+
{ columns: [], rows: [] },
|
|
51
|
+
{ columns: ["b"], rows: [] },
|
|
52
|
+
]);
|
|
53
|
+
expect(index.text).toBe("a\n\nb");
|
|
54
|
+
expect(index.spans.map((span) => span.ref.sheet)).toEqual([0, 2]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("keeps an empty cell in its row, so the cells after it stay put", () => {
|
|
58
|
+
const index = gridToText([{ columns: ["a", "b", "c"], rows: [["1", "", "3"]] }]);
|
|
59
|
+
const row = index.spans[1];
|
|
60
|
+
|
|
61
|
+
expect(index.text.slice(row?.start, row?.end)).toBe("1\t\t3");
|
|
62
|
+
// The third cell is where the projection says it is — an empty cell is a
|
|
63
|
+
// separator, not a hole (this is why rows, not cells, are the finest ref).
|
|
64
|
+
expect(chunkOffset(["1", "", "3"], 2, row?.start, GRID_CELL_SEPARATOR)).toBe(
|
|
65
|
+
index.text.indexOf("3"),
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
});
|