@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.
Files changed (140) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +143 -0
  3. package/dist/chunk-2NQ4RSJ3.js +18 -0
  4. package/dist/chunk-2NQ4RSJ3.js.map +1 -0
  5. package/dist/chunk-3U3MESBU.js +21 -0
  6. package/dist/chunk-3U3MESBU.js.map +1 -0
  7. package/dist/chunk-4OFQYALG.js +147 -0
  8. package/dist/chunk-4OFQYALG.js.map +1 -0
  9. package/dist/chunk-5OMONA2G.js +87 -0
  10. package/dist/chunk-5OMONA2G.js.map +1 -0
  11. package/dist/chunk-AORNMH77.js +58 -0
  12. package/dist/chunk-AORNMH77.js.map +1 -0
  13. package/dist/chunk-BCF3RWRL.js +18 -0
  14. package/dist/chunk-BCF3RWRL.js.map +1 -0
  15. package/dist/chunk-FELIAGKI.js +22 -0
  16. package/dist/chunk-FELIAGKI.js.map +1 -0
  17. package/dist/chunk-GGR7BNW4.js +18 -0
  18. package/dist/chunk-GGR7BNW4.js.map +1 -0
  19. package/dist/chunk-H3OAON3D.js +22 -0
  20. package/dist/chunk-H3OAON3D.js.map +1 -0
  21. package/dist/chunk-HMG2ERXH.js +17 -0
  22. package/dist/chunk-HMG2ERXH.js.map +1 -0
  23. package/dist/chunk-KOMDRWOU.js +21 -0
  24. package/dist/chunk-KOMDRWOU.js.map +1 -0
  25. package/dist/chunk-NMA57QZ7.js +117 -0
  26. package/dist/chunk-NMA57QZ7.js.map +1 -0
  27. package/dist/chunk-RGWDIQJR.js +26 -0
  28. package/dist/chunk-RGWDIQJR.js.map +1 -0
  29. package/dist/chunk-SLXRUYT3.js +91 -0
  30. package/dist/chunk-SLXRUYT3.js.map +1 -0
  31. package/dist/chunk-UJIJECEF.js +21 -0
  32. package/dist/chunk-UJIJECEF.js.map +1 -0
  33. package/dist/chunk-UL43NGUG.js +68 -0
  34. package/dist/chunk-UL43NGUG.js.map +1 -0
  35. package/dist/chunk-VO2273Z2.js +55 -0
  36. package/dist/chunk-VO2273Z2.js.map +1 -0
  37. package/dist/code-adapter-ADZ4UOGN.js +226 -0
  38. package/dist/code-adapter-ADZ4UOGN.js.map +1 -0
  39. package/dist/csv-adapter-6VU3FFVU.js +100 -0
  40. package/dist/csv-adapter-6VU3FFVU.js.map +1 -0
  41. package/dist/docx-adapter-5CQDHWTD.js +383 -0
  42. package/dist/docx-adapter-5CQDHWTD.js.map +1 -0
  43. package/dist/image-adapter-WOHZR24J.js +132 -0
  44. package/dist/image-adapter-WOHZR24J.js.map +1 -0
  45. package/dist/index.d.ts +1353 -0
  46. package/dist/index.js +1364 -0
  47. package/dist/index.js.map +1 -0
  48. package/dist/json-adapter-ZUW5GQHE.js +90 -0
  49. package/dist/json-adapter-ZUW5GQHE.js.map +1 -0
  50. package/dist/markdown-adapter-YC6WTBS4.js +214 -0
  51. package/dist/markdown-adapter-YC6WTBS4.js.map +1 -0
  52. package/dist/media-adapter-MCTB4GBH.js +78 -0
  53. package/dist/media-adapter-MCTB4GBH.js.map +1 -0
  54. package/dist/pdf-adapter-5PMKEXUD.js +424 -0
  55. package/dist/pdf-adapter-5PMKEXUD.js.map +1 -0
  56. package/dist/pptx-adapter-6GEQLS2Z.js +351 -0
  57. package/dist/pptx-adapter-6GEQLS2Z.js.map +1 -0
  58. package/dist/text-adapter-NFNWB5W3.js +81 -0
  59. package/dist/text-adapter-NFNWB5W3.js.map +1 -0
  60. package/dist/xlsx-adapter-CM2Y6AKQ.js +167 -0
  61. package/dist/xlsx-adapter-CM2Y6AKQ.js.map +1 -0
  62. package/package.json +103 -0
  63. package/src/adapters/code/code-adapter.test.tsx +221 -0
  64. package/src/adapters/code/code-adapter.tsx +272 -0
  65. package/src/adapters/code/code-language.test.ts +45 -0
  66. package/src/adapters/code/code-language.ts +100 -0
  67. package/src/adapters/code/code-manifest.ts +19 -0
  68. package/src/adapters/code/code-theme.test.ts +46 -0
  69. package/src/adapters/code/code-theme.ts +99 -0
  70. package/src/adapters/csv/csv-adapter.test.tsx +169 -0
  71. package/src/adapters/csv/csv-adapter.tsx +146 -0
  72. package/src/adapters/csv/csv-manifest.ts +20 -0
  73. package/src/adapters/docx/docx-adapter.test.tsx +192 -0
  74. package/src/adapters/docx/docx-adapter.tsx +328 -0
  75. package/src/adapters/docx/docx-manifest.ts +18 -0
  76. package/src/adapters/docx/docx-model.test.ts +181 -0
  77. package/src/adapters/docx/docx-model.ts +294 -0
  78. package/src/adapters/image/image-adapter.test.tsx +141 -0
  79. package/src/adapters/image/image-adapter.tsx +191 -0
  80. package/src/adapters/image/image-manifest.ts +15 -0
  81. package/src/adapters/index.ts +64 -0
  82. package/src/adapters/json/json-adapter.test.tsx +77 -0
  83. package/src/adapters/json/json-adapter.tsx +141 -0
  84. package/src/adapters/json/json-manifest.ts +11 -0
  85. package/src/adapters/markdown/markdown-adapter.test.tsx +228 -0
  86. package/src/adapters/markdown/markdown-adapter.tsx +353 -0
  87. package/src/adapters/markdown/markdown-manifest.ts +21 -0
  88. package/src/adapters/markdown/markdown-marks.test.ts +85 -0
  89. package/src/adapters/markdown/markdown-marks.ts +119 -0
  90. package/src/adapters/media/media-adapter.test.tsx +79 -0
  91. package/src/adapters/media/media-adapter.tsx +123 -0
  92. package/src/adapters/media/media-manifest.ts +17 -0
  93. package/src/adapters/office-fixture.ts +239 -0
  94. package/src/adapters/pdf/pdf-adapter.test.tsx +489 -0
  95. package/src/adapters/pdf/pdf-adapter.tsx +599 -0
  96. package/src/adapters/pdf/pdf-engine.ts +171 -0
  97. package/src/adapters/pdf/pdf-fixture.ts +27 -0
  98. package/src/adapters/pdf/pdf-manifest.ts +30 -0
  99. package/src/adapters/pdf/pdf-text.test.ts +171 -0
  100. package/src/adapters/pdf/pdf-text.ts +202 -0
  101. package/src/adapters/pptx/pptx-adapter.test.tsx +241 -0
  102. package/src/adapters/pptx/pptx-adapter.tsx +341 -0
  103. package/src/adapters/pptx/pptx-manifest.ts +19 -0
  104. package/src/adapters/pptx/pptx-model.test.ts +219 -0
  105. package/src/adapters/pptx/pptx-model.ts +248 -0
  106. package/src/adapters/text/text-adapter.test.tsx +125 -0
  107. package/src/adapters/text/text-adapter.tsx +117 -0
  108. package/src/adapters/text/text-manifest.ts +19 -0
  109. package/src/adapters/xlsx/xlsx-adapter.test.tsx +159 -0
  110. package/src/adapters/xlsx/xlsx-adapter.tsx +250 -0
  111. package/src/adapters/xlsx/xlsx-manifest.ts +26 -0
  112. package/src/components/grid-text.test.ts +68 -0
  113. package/src/components/grid-text.ts +77 -0
  114. package/src/components/marked-text.tsx +36 -0
  115. package/src/components/sheet-table.tsx +142 -0
  116. package/src/core/errors.ts +120 -0
  117. package/src/core/highlight-marks.test.ts +95 -0
  118. package/src/core/highlight-marks.ts +95 -0
  119. package/src/core/highlight-resolve.test.ts +208 -0
  120. package/src/core/highlight-resolve.ts +191 -0
  121. package/src/core/highlight.ts +125 -0
  122. package/src/core/registry.test.ts +172 -0
  123. package/src/core/registry.ts +188 -0
  124. package/src/core/scroll-host.ts +42 -0
  125. package/src/core/text-index.test.ts +95 -0
  126. package/src/core/text-index.ts +159 -0
  127. package/src/core/types.ts +285 -0
  128. package/src/core/use-highlight-scroll.ts +59 -0
  129. package/src/core/use-page-control.ts +47 -0
  130. package/src/core/use-paged-scroll.ts +194 -0
  131. package/src/core/use-viewport-size.ts +54 -0
  132. package/src/core/zoom.ts +56 -0
  133. package/src/file-viewer/file-viewer-context.tsx +239 -0
  134. package/src/file-viewer/file-viewer-find.tsx +204 -0
  135. package/src/file-viewer/file-viewer-pager.tsx +120 -0
  136. package/src/file-viewer/file-viewer-zoom.tsx +166 -0
  137. package/src/file-viewer/file-viewer.stories.tsx +947 -0
  138. package/src/file-viewer/file-viewer.test.tsx +947 -0
  139. package/src/file-viewer/file-viewer.tsx +1143 -0
  140. package/src/index.ts +157 -0
@@ -0,0 +1,167 @@
1
+ "use client";
2
+ "use client";
3
+ import {
4
+ xlsxManifest
5
+ } from "./chunk-RGWDIQJR.js";
6
+ import {
7
+ SheetTable,
8
+ gridToText
9
+ } from "./chunk-NMA57QZ7.js";
10
+ import "./chunk-2NQ4RSJ3.js";
11
+ import {
12
+ spanAt
13
+ } from "./chunk-UL43NGUG.js";
14
+ import {
15
+ toMarkRanges
16
+ } from "./chunk-VO2273Z2.js";
17
+ import {
18
+ ViewerError,
19
+ toViewerError
20
+ } from "./chunk-AORNMH77.js";
21
+
22
+ // src/adapters/xlsx/xlsx-adapter.tsx
23
+ import { cn, Tabs, TabsContent, TabsList, TabsTrigger, useLocale } from "@elabs-ai/components-ui";
24
+ import { useEffect, useMemo, useState } from "react";
25
+ import { jsx, jsxs } from "react/jsx-runtime";
26
+ var XLSX_ROW_LIMIT = 5e3;
27
+ function looksLikeWorkbook(bytes) {
28
+ const zip = [80, 75, 3, 4];
29
+ const cfb = [208, 207, 17, 224];
30
+ return [zip, cfb].some((signature) => signature.every((byte, index) => bytes[index] === byte));
31
+ }
32
+ function toCellText(value) {
33
+ if (value === null || value === void 0) return "";
34
+ if (value instanceof Date) return value.toISOString().slice(0, 10);
35
+ return String(value);
36
+ }
37
+ var XlsxAdapter = class {
38
+ async load(source, context) {
39
+ let buffer;
40
+ try {
41
+ buffer = await source.bytes(context.signal);
42
+ } catch (error) {
43
+ throw toViewerError(error, "read-failed", { fileName: source.name });
44
+ }
45
+ const bytes = new Uint8Array(buffer);
46
+ if (!looksLikeWorkbook(bytes)) {
47
+ throw new ViewerError("parse-failed", "The file is not a spreadsheet container.", {
48
+ fileName: source.name
49
+ });
50
+ }
51
+ const XLSX = await import("xlsx");
52
+ try {
53
+ const workbook = XLSX.read(bytes, { type: "array", cellDates: true });
54
+ const sheets = workbook.SheetNames.map((name) => {
55
+ const sheet = workbook.Sheets[name];
56
+ const grid = sheet ? XLSX.utils.sheet_to_json(sheet, { header: 1, blankrows: false, defval: "" }) : [];
57
+ const [header = [], ...body] = grid;
58
+ const columns = header.map(toCellText);
59
+ const rows = body.slice(0, XLSX_ROW_LIMIT).map((row) => row.map(toCellText));
60
+ return body.length > XLSX_ROW_LIMIT ? { name, columns, rows, totalRows: body.length } : { name, columns, rows };
61
+ });
62
+ if (sheets.length === 0) {
63
+ throw new ViewerError("parse-failed", "The workbook contains no sheets.", {
64
+ fileName: source.name
65
+ });
66
+ }
67
+ const textIndex = gridToText(sheets);
68
+ return {
69
+ kind: "xlsx",
70
+ sheets,
71
+ pageCount: sheets.length,
72
+ text: textIndex.text,
73
+ textIndex
74
+ };
75
+ } catch (error) {
76
+ throw toViewerError(error, "parse-failed", { fileName: source.name });
77
+ }
78
+ }
79
+ };
80
+ function XlsxRenderer({
81
+ document: doc,
82
+ className,
83
+ highlights,
84
+ activeHighlightId
85
+ }) {
86
+ const workbook = doc;
87
+ const { t } = useLocale();
88
+ const [first] = workbook.sheets;
89
+ const marks = useMemo(
90
+ () => toMarkRanges(highlights, workbook.text?.length ?? 0),
91
+ [highlights, workbook.text]
92
+ );
93
+ const starts = useMemo(() => {
94
+ const bySheet = /* @__PURE__ */ new Map();
95
+ for (const span of workbook.textIndex?.spans ?? []) {
96
+ let rows = bySheet.get(span.ref.sheet);
97
+ if (!rows) {
98
+ rows = /* @__PURE__ */ new Map();
99
+ bySheet.set(span.ref.sheet, rows);
100
+ }
101
+ rows.set(span.ref.row, span.start);
102
+ }
103
+ return bySheet;
104
+ }, [workbook.textIndex]);
105
+ const rowStartFor = (sheet) => (row) => starts.get(sheet)?.get(row);
106
+ const activeSheet = useMemo(() => {
107
+ const active = highlights?.find((highlight) => highlight.id === activeHighlightId);
108
+ if (!active || active.status !== "resolved" || !active.range || !workbook.textIndex) {
109
+ return void 0;
110
+ }
111
+ return spanAt(workbook.textIndex, active.range[0])?.ref.sheet;
112
+ }, [highlights, activeHighlightId, workbook.textIndex]);
113
+ const activeSheetName = activeSheet === void 0 ? void 0 : workbook.sheets[activeSheet]?.name;
114
+ const [pinned, setPinned] = useState();
115
+ useEffect(() => {
116
+ if (activeSheetName !== void 0) setPinned(activeSheetName);
117
+ }, [activeSheetName]);
118
+ if (!first) return null;
119
+ if (workbook.sheets.length === 1) {
120
+ return /* @__PURE__ */ jsx(
121
+ SheetTable,
122
+ {
123
+ columns: first.columns,
124
+ rows: first.rows,
125
+ totalRows: first.totalRows,
126
+ className,
127
+ marks,
128
+ rowStart: rowStartFor(0),
129
+ activeHighlightId
130
+ }
131
+ );
132
+ }
133
+ return /* @__PURE__ */ jsxs(
134
+ Tabs,
135
+ {
136
+ value: pinned ?? first.name,
137
+ onValueChange: setPinned,
138
+ className: cn("flex h-full min-h-0 flex-col gap-2", className),
139
+ children: [
140
+ /* @__PURE__ */ jsx(TabsList, { "aria-label": t("viewer.sheet.tabs"), className: "shrink-0 self-start", children: workbook.sheets.map((sheet) => /* @__PURE__ */ jsx(TabsTrigger, { value: sheet.name, children: sheet.name }, sheet.name)) }),
141
+ workbook.sheets.map((sheet, index) => /* @__PURE__ */ jsx(TabsContent, { value: sheet.name, className: "mt-0 min-h-0 flex-1", children: /* @__PURE__ */ jsx(
142
+ SheetTable,
143
+ {
144
+ columns: sheet.columns,
145
+ rows: sheet.rows,
146
+ totalRows: sheet.totalRows,
147
+ marks,
148
+ rowStart: rowStartFor(index),
149
+ activeHighlightId
150
+ }
151
+ ) }, sheet.name))
152
+ ]
153
+ }
154
+ );
155
+ }
156
+ var adapterModule = {
157
+ manifest: xlsxManifest,
158
+ create: () => new XlsxAdapter(),
159
+ Renderer: XlsxRenderer
160
+ };
161
+ var xlsx_adapter_default = adapterModule;
162
+ export {
163
+ XLSX_ROW_LIMIT,
164
+ xlsx_adapter_default as default,
165
+ looksLikeWorkbook
166
+ };
167
+ //# sourceMappingURL=xlsx-adapter-CM2Y6AKQ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/adapters/xlsx/xlsx-adapter.tsx"],"sourcesContent":["\"use client\";\n\n/**\n * Spreadsheet adapter — a workbook becomes sheets of rows, and rows become a\n * real `Table`.\n *\n * SheetJS is only ever asked for the DATA. Its `sheet_to_html` helper exists and\n * is not used: it returns a markup string with its own inline styling, which is\n * exactly the \"adapter emits HTML\" failure this package was built to avoid\n * (ADR 0024 §3). `sheet_to_json({ header: 1 })` gives raw cell values, which the\n * shared `SheetTable` renders with brand-ui components — so a spreadsheet\n * inherits the theme, the density dial and the keyboard semantics.\n *\n * **`xlsx` is an optional peer with two known advisories.** The npm build\n * (`0.18.5`) carries a prototype-pollution and a ReDoS advisory, both fixed only\n * in SheetJS's self-hosted CDN builds, which are not published to npm. That is a\n * documented, deliberate trade (`docs/CONSUMING.md` §6): a consumer who never\n * opens a spreadsheet never installs it and is never exposed. Treat any workbook\n * opened here as untrusted input.\n */\n\nimport type { ResolvedFileSource } from \"@elabs-ai/components-ui\";\nimport { cn, Tabs, TabsContent, TabsList, TabsTrigger, useLocale } from \"@elabs-ai/components-ui\";\nimport { useEffect, useMemo, useState } from \"react\";\n\nimport { gridToText, type GridRef } from \"../../components/grid-text\";\nimport { SheetTable } from \"../../components/sheet-table\";\nimport { ViewerError, toViewerError } from \"../../core/errors\";\nimport { toMarkRanges } from \"../../core/highlight-marks\";\nimport { spanAt, type TextIndex } from \"../../core/text-index\";\nimport type {\n AdapterDocument,\n AdapterLoadContext,\n AdapterModule,\n AdapterRendererProps,\n FileAdapter,\n} from \"../../core/types\";\nimport { xlsxManifest } from \"./xlsx-manifest\";\n\n/**\n * Rows rendered per sheet before truncation. Same bound, same reason, as the CSV\n * adapter: a preview pane is for looking, and a real analysis grid is\n * `DataTable` in `@elabs-ai/components-data`.\n */\nexport const XLSX_ROW_LIMIT = 5_000;\n\nexport interface SheetModel {\n /** The tab's own name, as authored in the workbook. */\n name: string;\n /** Header cells, taken from the sheet's first row. */\n columns: string[];\n /** Body rows, already capped at {@link XLSX_ROW_LIMIT}. */\n rows: string[][];\n /** Total body rows in the sheet, when more than what is shown. */\n totalRows?: number;\n}\n\nexport interface XlsxDocument extends AdapterDocument {\n kind: \"xlsx\";\n sheets: SheetModel[];\n /** Which sheet and row each stretch of `text` came from. */\n textIndex?: TextIndex<GridRef>;\n}\n\n/**\n * Does this look like a workbook container at all?\n *\n * SheetJS sniffs its input and will happily read arbitrary text as a one-column\n * CSV — so a damaged `.xlsx` comes back as a sheet holding one junk cell instead\n * of failing. That is worse than an error: it looks like the file opened. Both\n * containers this adapter claims have a fixed signature — OOXML/ODS is a zip\n * (`PK\\x03\\x04`), legacy `.xls` is a CFB compound file — so anything else is\n * rejected before SheetJS gets a chance to be helpful.\n */\nexport function looksLikeWorkbook(bytes: Uint8Array): boolean {\n const zip = [0x50, 0x4b, 0x03, 0x04];\n const cfb = [0xd0, 0xcf, 0x11, 0xe0];\n return [zip, cfb].some((signature) => signature.every((byte, index) => bytes[index] === byte));\n}\n\n/** SheetJS returns whatever the cell held — a date, a number, a formula result. */\nfunction toCellText(value: unknown): string {\n if (value === null || value === undefined) return \"\";\n if (value instanceof Date) return value.toISOString().slice(0, 10);\n return String(value);\n}\n\nclass XlsxAdapter implements FileAdapter {\n async load(source: ResolvedFileSource, context: AdapterLoadContext): Promise<XlsxDocument> {\n let buffer: ArrayBuffer;\n try {\n buffer = await source.bytes(context.signal);\n } catch (error) {\n throw toViewerError(error, \"read-failed\", { fileName: source.name });\n }\n\n const bytes = new Uint8Array(buffer);\n if (!looksLikeWorkbook(bytes)) {\n throw new ViewerError(\"parse-failed\", \"The file is not a spreadsheet container.\", {\n fileName: source.name,\n });\n }\n\n // Dynamic: the ONLY edge to the optional peer (heavy-deps:check).\n const XLSX = await import(\"xlsx\");\n\n try {\n // `cellDates` so a date column reads as a date rather than as Excel's\n // serial day number, which is meaningless to a person looking at it.\n const workbook = XLSX.read(bytes, { type: \"array\", cellDates: true });\n const sheets = workbook.SheetNames.map((name) => {\n const sheet = workbook.Sheets[name];\n const grid: unknown[][] = sheet\n ? XLSX.utils.sheet_to_json(sheet, { header: 1, blankrows: false, defval: \"\" })\n : [];\n const [header = [], ...body] = grid;\n const columns = header.map(toCellText);\n const rows = body.slice(0, XLSX_ROW_LIMIT).map((row) => row.map(toCellText));\n return body.length > XLSX_ROW_LIMIT\n ? { name, columns, rows, totalRows: body.length }\n : { name, columns, rows };\n });\n\n if (sheets.length === 0) {\n throw new ViewerError(\"parse-failed\", \"The workbook contains no sheets.\", {\n fileName: source.name,\n });\n }\n\n // The projection is built by the shared grid builder rather than joined by\n // hand, so the text a citation is addressed against and the map back into\n // the cells cannot drift apart (`core/text-index.ts`).\n const textIndex = gridToText(sheets);\n return {\n kind: \"xlsx\",\n sheets,\n pageCount: sheets.length,\n text: textIndex.text,\n textIndex,\n };\n } catch (error) {\n throw toViewerError(error, \"parse-failed\", { fileName: source.name });\n }\n }\n}\n\nfunction XlsxRenderer({\n document: doc,\n className,\n highlights,\n activeHighlightId,\n}: AdapterRendererProps) {\n const workbook = doc as XlsxDocument;\n const { t } = useLocale();\n const [first] = workbook.sheets;\n\n const marks = useMemo(\n () => toMarkRanges(highlights, workbook.text?.length ?? 0),\n [highlights, workbook.text],\n );\n\n // Where each row of each sheet begins in the projection. Built once per\n // document rather than per tab, because switching sheets must not re-walk the\n // spans of every OTHER sheet to answer a question about this one.\n const starts = useMemo(() => {\n const bySheet = new Map<number, Map<number, number>>();\n for (const span of workbook.textIndex?.spans ?? []) {\n let rows = bySheet.get(span.ref.sheet);\n if (!rows) {\n rows = new Map<number, number>();\n bySheet.set(span.ref.sheet, rows);\n }\n rows.set(span.ref.row, span.start);\n }\n return bySheet;\n }, [workbook.textIndex]);\n const rowStartFor = (sheet: number) => (row: number) => starts.get(sheet)?.get(row);\n\n // Turning to the cited SHEET is this format's half of \"take me there\" — the\n // row scroll inside it belongs to `SheetTable`. Keyed on the sheet NAME, so a\n // reader who switches tabs while the same citation stays active is not\n // dragged back (the PDF pager makes the same trade).\n const activeSheet = useMemo(() => {\n const active = highlights?.find((highlight) => highlight.id === activeHighlightId);\n if (!active || active.status !== \"resolved\" || !active.range || !workbook.textIndex) {\n return undefined;\n }\n return spanAt(workbook.textIndex, active.range[0])?.ref.sheet;\n }, [highlights, activeHighlightId, workbook.textIndex]);\n const activeSheetName =\n activeSheet === undefined ? undefined : workbook.sheets[activeSheet]?.name;\n\n const [pinned, setPinned] = useState<string>();\n useEffect(() => {\n if (activeSheetName !== undefined) setPinned(activeSheetName);\n }, [activeSheetName]);\n\n // A one-sheet workbook is the common case, and a tab strip holding a single\n // tab is chrome that decides nothing — so it is not rendered.\n if (!first) return null;\n if (workbook.sheets.length === 1) {\n return (\n <SheetTable\n columns={first.columns}\n rows={first.rows}\n totalRows={first.totalRows}\n className={className}\n marks={marks}\n rowStart={rowStartFor(0)}\n activeHighlightId={activeHighlightId}\n />\n );\n }\n\n return (\n <Tabs\n value={pinned ?? first.name}\n onValueChange={setPinned}\n className={cn(\"flex h-full min-h-0 flex-col gap-2\", className)}\n >\n <TabsList aria-label={t(\"viewer.sheet.tabs\")} className=\"shrink-0 self-start\">\n {workbook.sheets.map((sheet) => (\n <TabsTrigger key={sheet.name} value={sheet.name}>\n {sheet.name}\n </TabsTrigger>\n ))}\n </TabsList>\n {workbook.sheets.map((sheet, index) => (\n <TabsContent key={sheet.name} value={sheet.name} className=\"mt-0 min-h-0 flex-1\">\n <SheetTable\n columns={sheet.columns}\n rows={sheet.rows}\n totalRows={sheet.totalRows}\n marks={marks}\n rowStart={rowStartFor(index)}\n activeHighlightId={activeHighlightId}\n />\n </TabsContent>\n ))}\n </Tabs>\n );\n}\n\nconst adapterModule: AdapterModule = {\n manifest: xlsxManifest,\n create: () => new XlsxAdapter(),\n Renderer: XlsxRenderer,\n};\n\nexport default adapterModule;\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,103 @@
1
+ {
2
+ "name": "@elabs-ai/components-viewer",
3
+ "version": "4.0.0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/mreimitz/elabs-components.git",
8
+ "directory": "packages/viewer"
9
+ },
10
+ "type": "module",
11
+ "files": [
12
+ "dist",
13
+ "src"
14
+ ],
15
+ "sideEffects": [
16
+ "**/*.css"
17
+ ],
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "default": "./dist/index.js"
22
+ }
23
+ },
24
+ "publishConfig": {
25
+ "access": "public",
26
+ "registry": "https://registry.npmjs.org/"
27
+ },
28
+ "dependencies": {
29
+ "@tanstack/react-virtual": "^3.14.2",
30
+ "lucide-react": "^0.577.0"
31
+ },
32
+ "peerDependencies": {
33
+ "papaparse": "^5.4.1",
34
+ "react": "^18.2.0 || ^19.0.0",
35
+ "react-dom": "^18.2.0 || ^19.0.0",
36
+ "pdfjs-dist": "^6.0.0",
37
+ "mammoth": "^1.12.0",
38
+ "xlsx": "^0.18.5",
39
+ "jszip": "^3.10.1",
40
+ "shiki": "^3.22.0",
41
+ "streamdown": "^2.4.0",
42
+ "@elabs-ai/components-tokens": "4.0.0",
43
+ "@elabs-ai/components-ui": "4.0.0"
44
+ },
45
+ "peerDependenciesMeta": {
46
+ "papaparse": {
47
+ "optional": true
48
+ },
49
+ "pdfjs-dist": {
50
+ "optional": true
51
+ },
52
+ "mammoth": {
53
+ "optional": true
54
+ },
55
+ "xlsx": {
56
+ "optional": true
57
+ },
58
+ "jszip": {
59
+ "optional": true
60
+ },
61
+ "shiki": {
62
+ "optional": true
63
+ },
64
+ "streamdown": {
65
+ "optional": true
66
+ }
67
+ },
68
+ "devDependencies": {
69
+ "@testing-library/jest-dom": "^6.6.3",
70
+ "@testing-library/react": "^16.1.0",
71
+ "@testing-library/user-event": "^14.5.2",
72
+ "@types/papaparse": "^5.3.15",
73
+ "@types/react": "^19.0.2",
74
+ "@types/react-dom": "^19.0.2",
75
+ "@vitejs/plugin-react": "^4.3.4",
76
+ "eslint": "^9.17.0",
77
+ "jsdom": "^25.0.1",
78
+ "jszip": "^3.10.1",
79
+ "mammoth": "^1.12.0",
80
+ "papaparse": "^5.4.1",
81
+ "pdfjs-dist": "^6.2.108",
82
+ "xlsx": "^0.18.5",
83
+ "react": "^19.0.0",
84
+ "react-dom": "^19.0.0",
85
+ "shiki": "^3.22.0",
86
+ "streamdown": "^2.4.0",
87
+ "tsup": "^8.3.5",
88
+ "typescript": "^5.7.3",
89
+ "vitest": "^3.0.2",
90
+ "@elabs-ai/components-eslint-config": "0.1.0",
91
+ "@elabs-ai/components-typescript-config": "0.1.0",
92
+ "@elabs-ai/components-tokens": "4.0.0",
93
+ "@elabs-ai/components-ui": "4.0.0"
94
+ },
95
+ "scripts": {
96
+ "build": "tsup && node ../../scripts/link-dist-css.mjs",
97
+ "typecheck": "tsc --noEmit",
98
+ "test": "vitest run",
99
+ "test:watch": "vitest",
100
+ "lint": "eslint .",
101
+ "clean": "rm -rf dist .turbo coverage"
102
+ }
103
+ }
@@ -0,0 +1,221 @@
1
+ import { normalizeFileSource } from "@elabs-ai/components-ui";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import type { ResolvedHighlight } from "../../core/highlight";
6
+ import codeModule, { CODE_CHARACTER_LIMIT, type CodeDocument } from "./code-adapter";
7
+
8
+ const cite = (id: string, range: [number, number], active = false): ResolvedHighlight => ({
9
+ id,
10
+ source: "citation",
11
+ status: "resolved",
12
+ address: { kind: "range", start: range[0], end: range[1] },
13
+ active,
14
+ range,
15
+ });
16
+
17
+ const SAMPLE = `// a greeting
18
+ export const greet = (name: string) => \`hello \${name}\`;
19
+ `;
20
+
21
+ function source(text: string, name = "greet.ts") {
22
+ return normalizeFileSource({ kind: "text", text, name });
23
+ }
24
+
25
+ const load = (text = SAMPLE, name?: string) =>
26
+ codeModule.create().load(source(text, name), {}) as Promise<CodeDocument>;
27
+
28
+ /**
29
+ * Offset of the `greet` IDENTIFIER — not the "greet" inside the comment's
30
+ * "greeting" on line 1, which is what a plain `indexOf("greet")` finds.
31
+ */
32
+ const IDENTIFIER = SAMPLE.indexOf("greet =");
33
+
34
+ describe("code adapter — tokenizing", () => {
35
+ it("resolves the grammar from the file name", async () => {
36
+ const doc = await load();
37
+ expect(doc.language).toBe("typescript");
38
+ });
39
+
40
+ it("returns one entry per line, in order", async () => {
41
+ const doc = await load();
42
+ // Trailing newline means a final empty line, which is real: a `<pre>` that
43
+ // dropped it would render one line short of the file.
44
+ expect(doc.lines).toHaveLength(3);
45
+ expect(doc.lines[0]?.map((token) => token.text).join("")).toBe("// a greeting");
46
+ expect(doc.lines[2]).toEqual([]);
47
+ });
48
+
49
+ it("colours tokens with brand token references, never a literal", async () => {
50
+ const doc = await load();
51
+ const colours = doc.lines.flat().map((token) => token.color);
52
+ expect(colours.some((colour) => colour === "var(--code-comment)")).toBe(true);
53
+ expect(colours.some((colour) => colour === "var(--code-keyword)")).toBe(true);
54
+ // The whole reason for the CSS-variable theme: a raw hex would freeze the
55
+ // document in whichever theme happened to be active when it was parsed.
56
+ for (const colour of colours) expect(colour).not.toMatch(/^#/);
57
+ });
58
+
59
+ it("distinguishes a comment from a keyword — the theme really is applied", async () => {
60
+ const doc = await load();
61
+ const comment = doc.lines[0]?.[0];
62
+ const keyword = doc.lines[1]?.find((token) => token.text.trim() === "export");
63
+ expect(comment?.color).not.toBe(keyword?.color);
64
+ expect(comment?.italic).toBe(true);
65
+ });
66
+
67
+ it("falls back to plain lines for a name it has no grammar for", async () => {
68
+ const doc = await load("just words\n", "notes.unknownext");
69
+ expect(doc.language).toBeUndefined();
70
+ expect(doc.lines[0]).toEqual([{ text: "just words" }]);
71
+ });
72
+
73
+ it("exposes a plain-text projection for search and copy", async () => {
74
+ const doc = await load();
75
+ expect(doc.text).toBe(SAMPLE);
76
+ });
77
+
78
+ it("truncates a file too large to tokenize, and says so", async () => {
79
+ // Deliberately on the HIGHLIGHTED path: truncation has to happen BEFORE
80
+ // the grammar runs, and a test on the grammar-less path would still pass
81
+ // if the slice moved below the tokenize call. That is also why this one
82
+ // gets its own timeout — tokenizing at the limit is the work being
83
+ // bounded, and it exceeds the 5s default on a loaded machine.
84
+ const doc = await load(`const x = 1;\n`.repeat(40_000));
85
+ expect(doc.text).toHaveLength(CODE_CHARACTER_LIMIT);
86
+ expect(doc.totalCharacters).toBe(40_000 * 13);
87
+ }, 30_000);
88
+
89
+ it("reports an unreadable source as read-failed, not parse-failed", async () => {
90
+ const failing = {
91
+ ...source(""),
92
+ text: () => Promise.reject(new Error("network down")),
93
+ };
94
+ await expect(codeModule.create().load(failing, {})).rejects.toMatchObject({
95
+ code: "read-failed",
96
+ });
97
+ });
98
+ });
99
+
100
+ describe("code adapter — rendering", () => {
101
+ const Renderer = codeModule.Renderer;
102
+
103
+ it("renders every line of the file", async () => {
104
+ const doc = await load();
105
+ render(<Renderer document={doc} source={source(SAMPLE)} />);
106
+ expect(screen.getByText("// a greeting")).toBeInTheDocument();
107
+ });
108
+
109
+ it("keeps line numbers out of the accessibility tree", async () => {
110
+ const doc = await load();
111
+ const { container } = render(<Renderer document={doc} source={source(SAMPLE)} />);
112
+ const gutter = container.querySelectorAll('[aria-hidden="true"]');
113
+ // One per line — decorative, so a screen reader reads the code, not "1 2 3".
114
+ expect(gutter).toHaveLength(doc.lines.length);
115
+ expect(gutter[0]?.textContent).toBe("1");
116
+ });
117
+
118
+ it("announces truncation as a status, not an error", () => {
119
+ // Built directly rather than loaded: rendering half a million characters in
120
+ // jsdom to assert one line of copy costs seconds and proves nothing extra.
121
+ const doc: CodeDocument = {
122
+ kind: "code",
123
+ language: "typescript",
124
+ lines: [[{ text: "const x = 1;" }]],
125
+ text: "const x = 1;",
126
+ totalCharacters: 520_000,
127
+ };
128
+ render(<Renderer document={doc} source={source("")} />);
129
+ const status = screen.getByRole("status");
130
+ expect(status).toHaveTextContent(/Showing the first/);
131
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
132
+ });
133
+
134
+ it("shows an empty file as an empty state, not a blank pane", async () => {
135
+ const doc = await load("", "empty.ts");
136
+ render(<Renderer document={doc} source={source("", "empty.ts")} />);
137
+ expect(screen.getByText("This file is empty")).toBeInTheDocument();
138
+ });
139
+ });
140
+
141
+ describe("code adapter — highlighting", () => {
142
+ const Renderer = codeModule.Renderer;
143
+
144
+ it("declares the address kinds it can actually honour", () => {
145
+ expect(codeModule.manifest.capabilities?.highlight).toEqual(["quote", "range"]);
146
+ });
147
+
148
+ it("rejoins its token lines to exactly the text ranges are addressed against", async () => {
149
+ // The load-bearing invariant behind every offset below: if the tokenizer's
150
+ // lines and `document.text` ever disagreed, every mark would land wrong.
151
+ const doc = await load();
152
+ expect(doc.lines.map((tokens) => tokens.map((token) => token.text).join("")).join("\n")).toBe(
153
+ doc.text,
154
+ );
155
+ });
156
+
157
+ it("marks a range that falls inside one syntax token", async () => {
158
+ const doc = await load();
159
+ const start = IDENTIFIER;
160
+ const { container } = render(
161
+ <Renderer
162
+ document={doc}
163
+ source={source(SAMPLE)}
164
+ highlights={[cite("a", [start, start + 5])]}
165
+ />,
166
+ );
167
+ const marks = container.querySelectorAll("mark");
168
+ expect(marks).toHaveLength(1);
169
+ expect(marks[0]).toHaveTextContent("greet");
170
+ // The file still reads whole — the mark layer wraps, it does not replace.
171
+ expect(container.querySelector("pre")?.textContent).toContain("export const greet");
172
+ });
173
+
174
+ it("keeps a range spanning several tokens as one continuous run", async () => {
175
+ const doc = await load();
176
+ const start = SAMPLE.indexOf("export");
177
+ const end = IDENTIFIER + 5;
178
+ const { container } = render(
179
+ <Renderer document={doc} source={source(SAMPLE)} highlights={[cite("a", [start, end])]} />,
180
+ );
181
+ const marks = Array.from(container.querySelectorAll("mark"));
182
+ // Several marks, because syntax colour lives on the token spans — but they
183
+ // are adjacent and together spell the requested passage.
184
+ expect(marks.length).toBeGreaterThan(1);
185
+ expect(marks.map((mark) => mark.textContent).join("")).toBe("export const greet");
186
+ });
187
+
188
+ it("carries the active flag onto every token piece of the current run", async () => {
189
+ const doc = await load();
190
+ const start = SAMPLE.indexOf("export");
191
+ const end = IDENTIFIER + 5;
192
+ const { container } = render(
193
+ <Renderer
194
+ document={doc}
195
+ source={source(SAMPLE)}
196
+ highlights={[cite("a", [start, end], true)]}
197
+ activeHighlightId="a"
198
+ />,
199
+ );
200
+ const marks = Array.from(container.querySelectorAll("mark"));
201
+ const active = Array.from(container.querySelectorAll("mark[data-active]"));
202
+ expect(active).toHaveLength(marks.length);
203
+ expect(active[0]?.getAttribute("aria-current")).toBe("true");
204
+ });
205
+
206
+ it("leaves untouched tokens rendering exactly as before", async () => {
207
+ const doc = await load();
208
+ const start = IDENTIFIER;
209
+ const { container } = render(
210
+ <Renderer
211
+ document={doc}
212
+ source={source(SAMPLE)}
213
+ highlights={[cite("a", [start, start + 5])]}
214
+ />,
215
+ );
216
+ const comment = screen.getByText("// a greeting");
217
+ expect(comment.tagName).toBe("SPAN");
218
+ expect(comment.querySelector("mark")).toBeNull();
219
+ expect(container.querySelectorAll("mark")).toHaveLength(1);
220
+ });
221
+ });