@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,77 @@
1
+ /**
2
+ * The one text projection every tabular format is addressed against.
3
+ *
4
+ * CSV and XLSX arrive through different parsers and render into the same
5
+ * `SheetTable`; they should also be *addressable* the same way, or a citation
6
+ * that resolves in a workbook would miss in the CSV export of the same data.
7
+ * So the projection is written once here and both adapters call it.
8
+ *
9
+ * Shape — a sheet's name (when it has one), then its header row, then its body
10
+ * rows; cells joined by a tab, rows by a newline, sheets by a blank line:
11
+ *
12
+ * ```
13
+ * Q3
14
+ * Region\tRevenue
15
+ * EMEA\t4.2M
16
+ *
17
+ * Q4
18
+ * …
19
+ * ```
20
+ *
21
+ * **Rows, not cells, are the finest ref** — the same trade the Word adapter
22
+ * makes. A cell-granular index would need a span per cell and a separator per
23
+ * level; a row-granular one needs neither, because a cell's offset is the sum of
24
+ * the cells before it ({@link chunkOffset}). The granularity of the REF is the
25
+ * row; the granularity of the MARK is still the character.
26
+ */
27
+
28
+ import { createTextIndexBuilder, type TextIndex } from "../core/text-index";
29
+
30
+ /** Between two cells of the same row. */
31
+ export const GRID_CELL_SEPARATOR = "\t";
32
+ /** Between two rows of the same sheet. */
33
+ export const GRID_ROW_SEPARATOR = "\n";
34
+ /** Between two sheets of the same workbook. */
35
+ export const GRID_SHEET_SEPARATOR = "\n\n";
36
+
37
+ /** The `row` of a sheet's own name line. */
38
+ export const GRID_NAME_ROW = -2;
39
+ /** The `row` of a sheet's header row. */
40
+ export const GRID_HEAD_ROW = -1;
41
+
42
+ /** Where a stretch of the projection came from in the grid. */
43
+ export interface GridRef {
44
+ /** Index into the sheets. `0` for a single-sheet format like CSV. */
45
+ sheet: number;
46
+ /** Body row index, or {@link GRID_HEAD_ROW} / {@link GRID_NAME_ROW}. */
47
+ row: number;
48
+ }
49
+
50
+ /** What {@link gridToText} needs from a sheet — the shape both adapters already hold. */
51
+ export interface GridSheetInput {
52
+ /** The tab's name. Absent for a single-sheet format, which has no tab. */
53
+ name?: string;
54
+ columns: readonly string[];
55
+ rows: readonly (readonly string[])[];
56
+ }
57
+
58
+ /** Project a workbook (or a one-sheet CSV) to text, with the map back to it. */
59
+ export function gridToText(sheets: readonly GridSheetInput[]): TextIndex<GridRef> {
60
+ const builder = createTextIndexBuilder<GridRef>({ separator: GRID_ROW_SEPARATOR });
61
+ sheets.forEach((sheet, index) => {
62
+ // The blank line goes before whatever this sheet's FIRST line turns out to
63
+ // be — its name, or its header when it has no name.
64
+ let separator = index === 0 ? undefined : GRID_SHEET_SEPARATOR;
65
+ const push = (chunk: string, row: number) => {
66
+ builder.push(chunk, { sheet: index, row }, separator);
67
+ if (chunk.length > 0) separator = undefined;
68
+ };
69
+
70
+ if (sheet.name) push(sheet.name, GRID_NAME_ROW);
71
+ push(sheet.columns.join(GRID_CELL_SEPARATOR), GRID_HEAD_ROW);
72
+ sheet.rows.forEach((row, rowIndex) => {
73
+ push(row.join(GRID_CELL_SEPARATOR), rowIndex);
74
+ });
75
+ });
76
+ return builder.build();
77
+ }
@@ -0,0 +1,36 @@
1
+ "use client";
2
+
3
+ /**
4
+ * One slice of text, with whatever marks land in it.
5
+ *
6
+ * The last step of the highlight funnel, and the same three lines in every
7
+ * adapter that draws its text in pieces — a Word run, a spreadsheet cell, a
8
+ * slide's bullet. Each of them holds document offsets for its own slice and
9
+ * needs the ranges rebased before a `<mark>` layer can paint them, so the
10
+ * rebase-then-paint pair is named once here rather than copied a fourth time
11
+ * (`.claude/rules/design-first.md` — patterns over instances).
12
+ *
13
+ * `start === undefined` means "this slice is not in the projection" (an adapter
14
+ * with no index, a cell past the truncation bound), which renders as plain text
15
+ * rather than as an error: a document that cannot be addressed is still a
16
+ * document that can be read.
17
+ */
18
+
19
+ import { MatchHighlight } from "@elabs-ai/components-ui";
20
+
21
+ import { localizeRanges, type MarkRanges } from "../core/highlight-marks";
22
+
23
+ export interface MarkedTextProps {
24
+ text: string;
25
+ /** Marks in PROJECTION offsets — rebased here, not by the caller. */
26
+ marks: MarkRanges | undefined;
27
+ /** Where this slice begins in the projection. */
28
+ start: number | undefined;
29
+ }
30
+
31
+ export function MarkedText({ text, marks, start }: MarkedTextProps) {
32
+ const local =
33
+ !marks || start === undefined ? undefined : localizeRanges(marks, start, start + text.length);
34
+ if (!local || local.ranges.length === 0) return <>{text}</>;
35
+ return <MatchHighlight text={text} ranges={local.ranges} activeIndex={local.activeIndex} />;
36
+ }
@@ -0,0 +1,142 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The one grid every tabular format renders into.
5
+ *
6
+ * CSV and XLSX arrive through completely different parsers and end up wanting
7
+ * exactly the same thing: a header row, body rows, a truncation notice and an
8
+ * `sr-only` caption. That is a PATTERN, not a coincidence, so it is named once
9
+ * here rather than copied a second time (`.claude/rules/design-first.md` —
10
+ * patterns over instances).
11
+ *
12
+ * It stays deliberately dumb: no sorting, no filtering, no virtualization. A
13
+ * preview pane is for looking; past a few thousand rows the right component is
14
+ * `DataTable` in `@elabs-ai/components-data`, which virtualizes.
15
+ */
16
+
17
+ import {
18
+ cn,
19
+ Table,
20
+ TableBody,
21
+ TableCaption,
22
+ TableCell,
23
+ TableHead,
24
+ TableHeader,
25
+ TableRow,
26
+ useLocale,
27
+ } from "@elabs-ai/components-ui";
28
+ import { useRef } from "react";
29
+
30
+ import { type MarkRanges } from "../core/highlight-marks";
31
+ import { chunkOffset } from "../core/text-index";
32
+ import { useScrollActiveHighlightIntoView } from "../core/use-highlight-scroll";
33
+ import { GRID_CELL_SEPARATOR, GRID_HEAD_ROW } from "./grid-text";
34
+ import { MarkedText } from "./marked-text";
35
+
36
+ export interface SheetTableProps {
37
+ /** Header cells. May be empty — the grid still renders its body. */
38
+ columns: string[];
39
+ /** Body rows, already capped by the caller. */
40
+ rows: string[][];
41
+ /** Total body rows in the file, when more exist than `rows` holds. */
42
+ totalRows?: number;
43
+ className?: string;
44
+ /** Marks to paint, in projection offsets. */
45
+ marks?: MarkRanges;
46
+ /**
47
+ * Where a row's first cell begins in the projection — {@link GRID_HEAD_ROW}
48
+ * for the header. A function rather than an array so a workbook can answer
49
+ * for the sheet being drawn without slicing an index per tab.
50
+ */
51
+ rowStart?: (row: number) => number | undefined;
52
+ /** Scrolls the current mark into this grid's own viewport when it changes. */
53
+ activeHighlightId?: string | null;
54
+ }
55
+
56
+ export function SheetTable({
57
+ columns,
58
+ rows,
59
+ totalRows,
60
+ className,
61
+ marks,
62
+ rowStart,
63
+ activeHighlightId,
64
+ }: SheetTableProps) {
65
+ const { t, formatNumber } = useLocale();
66
+ const viewport = useRef<HTMLDivElement>(null);
67
+
68
+ useScrollActiveHighlightIntoView(viewport, activeHighlightId);
69
+
70
+ return (
71
+ <div className={cn("flex h-full min-h-0 flex-col gap-2", className)}>
72
+ {totalRows !== undefined && (
73
+ // A status, not an error: the file is fine, we are simply showing part
74
+ // of it (loading-states.md — a capability bound is news, not an alarm).
75
+ <p role="status" className="text-meta text-muted-foreground shrink-0">
76
+ {t("viewer.table.truncated", { count: formatNumber(rows.length) })}
77
+ </p>
78
+ )}
79
+ {/* The grid keeps its OWN viewport, unlike the flowing formats: a workbook
80
+ puts a sheet-tab bar above it that must not scroll away with the rows.
81
+ A table cell is not focusable, so a scrollable region wrapping one is
82
+ unreachable from a keyboard (WCAG 2.1.1) — `tabIndex={0}` makes it a
83
+ real stop that arrow keys and Page Up/Down drive. `group`, not
84
+ `region`, so a sheet does not mint a second landmark inside the
85
+ viewer's content region. */}
86
+ <div
87
+ ref={viewport}
88
+ tabIndex={0}
89
+ role="group"
90
+ aria-label={t("viewer.content")}
91
+ className="focus-visible:ring-ring min-h-0 flex-1 overflow-auto focus-visible:outline-none focus-visible:ring-2"
92
+ >
93
+ <Table>
94
+ <TableCaption className="sr-only">
95
+ {t("viewer.table.caption", {
96
+ rows: formatNumber(totalRows ?? rows.length),
97
+ columns: formatNumber(columns.length),
98
+ })}
99
+ </TableCaption>
100
+ <TableHeader>
101
+ <TableRow>
102
+ {columns.map((column, index) => (
103
+ // The file's own header text is the only identity a column has;
104
+ // an empty one still needs a cell so the grid stays aligned.
105
+ <TableHead key={`${column}-${String(index)}`} scope="col">
106
+ <MarkedText
107
+ text={column}
108
+ marks={marks}
109
+ start={chunkOffset(
110
+ columns,
111
+ index,
112
+ rowStart?.(GRID_HEAD_ROW),
113
+ GRID_CELL_SEPARATOR,
114
+ )}
115
+ />
116
+ </TableHead>
117
+ ))}
118
+ </TableRow>
119
+ </TableHeader>
120
+ <TableBody>
121
+ {rows.map((row, rowIndex) => {
122
+ const start = rowStart?.(rowIndex);
123
+ return (
124
+ <TableRow key={rowIndex}>
125
+ {columns.map((_, columnIndex) => (
126
+ <TableCell key={columnIndex} className="whitespace-pre-wrap">
127
+ <MarkedText
128
+ text={row[columnIndex] ?? ""}
129
+ marks={marks}
130
+ start={chunkOffset(row, columnIndex, start, GRID_CELL_SEPARATOR)}
131
+ />
132
+ </TableCell>
133
+ ))}
134
+ </TableRow>
135
+ );
136
+ })}
137
+ </TableBody>
138
+ </Table>
139
+ </div>
140
+ </div>
141
+ );
142
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Typed failures, so the UI can decide what to SHOW without string-matching a
3
+ * message.
4
+ *
5
+ * The `message` on these errors is developer-facing — it goes to the console and
6
+ * to bug reports. Everything the user reads is looked up from {@link ViewerErrorCode}
7
+ * through `t()` (ADR 0017), which is why the code, not the prose, is the contract.
8
+ */
9
+
10
+ /** Why a file could not be shown. */
11
+ export type ViewerErrorCode =
12
+ /** No registered adapter claims this file. */
13
+ | "unsupported-format"
14
+ /** An adapter was built against a different registry protocol. */
15
+ | "protocol-mismatch"
16
+ /** The adapter exists but its optional peer dependency is not installed. */
17
+ | "parser-missing"
18
+ /** The bytes could not be obtained (network, permissions, a revoked blob). */
19
+ | "read-failed"
20
+ /** The bytes arrived but the parser rejected them. */
21
+ | "parse-failed"
22
+ /** The load was cancelled — a new source, or an unmounted view. */
23
+ | "aborted";
24
+
25
+ export interface ViewerErrorOptions extends ErrorOptions {
26
+ /** The file this is about, for the developer-facing message. */
27
+ fileName?: string;
28
+ /** For `parser-missing`: the packages a consumer has to install. */
29
+ packages?: string[];
30
+ }
31
+
32
+ /** A failure a `FileViewer` can render, carrying a machine-readable {@link ViewerErrorCode}. */
33
+ export class ViewerError extends Error {
34
+ readonly code: ViewerErrorCode;
35
+ readonly fileName?: string;
36
+ readonly packages?: string[];
37
+
38
+ constructor(code: ViewerErrorCode, message: string, options: ViewerErrorOptions = {}) {
39
+ super(message, { cause: options.cause });
40
+ this.name = "ViewerError";
41
+ this.code = code;
42
+ this.fileName = options.fileName;
43
+ this.packages = options.packages;
44
+ }
45
+ }
46
+
47
+ /** True for a {@link ViewerError}, without relying on `instanceof` across bundles. */
48
+ export function isViewerError(value: unknown): value is ViewerError {
49
+ return value instanceof ViewerError || (value instanceof Error && value.name === "ViewerError");
50
+ }
51
+
52
+ /** True when the failure is a cancellation rather than something to report. */
53
+ export function isAbort(value: unknown): boolean {
54
+ if (isViewerError(value)) return value.code === "aborted";
55
+ return value instanceof Error && (value.name === "AbortError" || value.name === "TimeoutError");
56
+ }
57
+
58
+ /**
59
+ * Wrap an unknown throw as a {@link ViewerError}, preserving an existing one and
60
+ * mapping an abort to the `aborted` code so callers do not report cancellations
61
+ * as failures.
62
+ */
63
+ export function toViewerError(
64
+ value: unknown,
65
+ fallbackCode: ViewerErrorCode,
66
+ options: ViewerErrorOptions = {},
67
+ ): ViewerError {
68
+ if (isViewerError(value)) return value;
69
+ if (isAbort(value)) {
70
+ return new ViewerError("aborted", "The file load was cancelled.", { ...options, cause: value });
71
+ }
72
+ const message = value instanceof Error ? value.message : String(value);
73
+ return new ViewerError(fallbackCode, message, { ...options, cause: value });
74
+ }
75
+
76
+ /**
77
+ * A dynamic `import()` of an OPTIONAL peer that is not installed.
78
+ *
79
+ * Bundlers and Node report this differently (`ERR_MODULE_NOT_FOUND`,
80
+ * `Cannot find module`, `Failed to resolve module specifier`, Vite's
81
+ * `Failed to fetch dynamically imported module`), so match on the shapes rather
82
+ * than one runtime's wording. A false positive here is cheap — the user is told
83
+ * to install a package instead of seeing a raw parse error — and a false
84
+ * negative is the anyview failure mode this exists to avoid: an unhelpful
85
+ * module-resolution stack trace where a "install papaparse to open CSV files"
86
+ * message belongs.
87
+ */
88
+ export function isModuleNotFound(value: unknown): boolean {
89
+ if (!(value instanceof Error)) return false;
90
+ if ("code" in value && value.code === "ERR_MODULE_NOT_FOUND") return true;
91
+ return /cannot find module|failed to resolve|dynamically imported module|module not found/i.test(
92
+ value.message,
93
+ );
94
+ }
95
+
96
+ /**
97
+ * The `parser-missing` error, worded the same wherever the peer turns out to be
98
+ * absent.
99
+ *
100
+ * There are TWO such places, which is why this is a function and not a literal
101
+ * at one call site. The registry catches it when the adapter MODULE cannot be
102
+ * fetched; `FileViewerProvider` catches it when the module loaded fine and the
103
+ * adapter's own `await import("mammoth")` — inside `load()`, where every parser
104
+ * engine is actually reached — rejects. Without the second, a consumer who
105
+ * skipped an optional peer was told their file was damaged and offered a retry
106
+ * that could never work.
107
+ */
108
+ export function parserMissingError(
109
+ adapterId: string,
110
+ packages: string[],
111
+ options: ViewerErrorOptions = {},
112
+ ): ViewerError {
113
+ return new ViewerError(
114
+ "parser-missing",
115
+ packages.length > 0
116
+ ? `The "${adapterId}" adapter needs ${packages.join(", ")}, which is not installed.`
117
+ : `The "${adapterId}" adapter could not be loaded.`,
118
+ { ...options, packages },
119
+ );
120
+ }
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import type { ResolvedHighlight } from "./highlight";
4
+ import { localizeRanges, toMarkRanges } from "./highlight-marks";
5
+
6
+ const resolved = (
7
+ id: string,
8
+ range: [number, number],
9
+ extra: Partial<ResolvedHighlight> = {},
10
+ ): ResolvedHighlight => ({
11
+ id,
12
+ source: "citation",
13
+ status: "resolved",
14
+ address: { kind: "range", start: range[0], end: range[1] },
15
+ active: false,
16
+ range,
17
+ ...extra,
18
+ });
19
+
20
+ describe("toMarkRanges", () => {
21
+ it("is empty for no highlights at all", () => {
22
+ expect(toMarkRanges(undefined, 10)).toEqual({ ranges: [], activeIndex: -1 });
23
+ expect(toMarkRanges([], 10)).toEqual({ ranges: [], activeIndex: -1 });
24
+ });
25
+
26
+ it("keeps only the entries that actually located", () => {
27
+ const misses: ResolvedHighlight[] = [
28
+ { ...resolved("a", [0, 2]), status: "not-found", range: undefined, reason: "absent" },
29
+ { ...resolved("b", [0, 2]), status: "unsupported", range: undefined },
30
+ { ...resolved("c", [0, 2]), status: "pending", range: undefined },
31
+ ];
32
+ expect(toMarkRanges(misses, 10).ranges).toEqual([]);
33
+ expect(toMarkRanges([...misses, resolved("d", [4, 6])], 10).ranges).toEqual([[4, 6]]);
34
+ });
35
+
36
+ it("sorts and merges, so out-of-order citations still paint one run", () => {
37
+ const marks = toMarkRanges(
38
+ [resolved("b", [6, 9]), resolved("a", [0, 3]), resolved("c", [3, 5])],
39
+ 10,
40
+ );
41
+ expect(marks.ranges).toEqual([
42
+ [0, 5],
43
+ [6, 9],
44
+ ]);
45
+ });
46
+
47
+ it("points activeIndex at the MERGED mark containing the active range", () => {
48
+ // Three requests, two marks: the active one is the second REQUEST but sits
49
+ // inside the first mark. Counting against the unmerged list would be wrong.
50
+ const marks = toMarkRanges(
51
+ [resolved("a", [0, 3]), resolved("b", [3, 5], { active: true }), resolved("c", [7, 9])],
52
+ 10,
53
+ );
54
+ expect(marks.ranges).toEqual([
55
+ [0, 5],
56
+ [7, 9],
57
+ ]);
58
+ expect(marks.activeIndex).toBe(0);
59
+ });
60
+
61
+ it("reports no active mark when nothing is active", () => {
62
+ expect(toMarkRanges([resolved("a", [0, 3])], 10).activeIndex).toBe(-1);
63
+ });
64
+
65
+ it("clamps to the text it will actually be painted on", () => {
66
+ expect(toMarkRanges([resolved("a", [8, 40])], 10).ranges).toEqual([[8, 10]]);
67
+ });
68
+ });
69
+
70
+ describe("localizeRanges", () => {
71
+ const marks = toMarkRanges(
72
+ [resolved("a", [2, 6]), resolved("b", [10, 14], { active: true })],
73
+ 20,
74
+ );
75
+
76
+ it("rebases a range onto the slice that contains it", () => {
77
+ expect(localizeRanges(marks, 0, 8)).toEqual({ ranges: [[2, 6]], activeIndex: -1 });
78
+ });
79
+
80
+ it("carries the active flag only into the slice the active range touches", () => {
81
+ expect(localizeRanges(marks, 8, 16)).toEqual({ ranges: [[2, 6]], activeIndex: 0 });
82
+ });
83
+
84
+ it("clips a range that straddles the slice boundary, so it survives in both", () => {
85
+ // A citation crossing a line break must still read as one continuous run.
86
+ const straddling = toMarkRanges([resolved("a", [3, 9], { active: true })], 20);
87
+ expect(localizeRanges(straddling, 0, 6)).toEqual({ ranges: [[3, 6]], activeIndex: 0 });
88
+ expect(localizeRanges(straddling, 6, 12)).toEqual({ ranges: [[0, 3]], activeIndex: 0 });
89
+ });
90
+
91
+ it("is empty for a slice no range reaches", () => {
92
+ expect(localizeRanges(marks, 16, 20)).toEqual({ ranges: [], activeIndex: -1 });
93
+ expect(localizeRanges(marks, 5, 5)).toEqual({ ranges: [], activeIndex: -1 });
94
+ });
95
+ });
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The MAP + PAINT half of the highlight funnel, for adapters whose document IS
3
+ * its own text projection — plain text, code, and anything else that renders
4
+ * `document.text` verbatim.
5
+ *
6
+ * The shell has already done LOCATE (every quote is now a character range). All
7
+ * that is left is to hand those ranges to `MatchHighlight` and say which one is
8
+ * current. An adapter with a richer model (pages, blocks, cells) maps the same
9
+ * ranges through its own `textIndex` instead and does not use this.
10
+ */
11
+
12
+ import { normalizeRanges, type MatchRange } from "@elabs-ai/components-ui";
13
+
14
+ import type { ResolvedHighlight } from "./highlight";
15
+
16
+ /** What `MatchHighlight` takes: merged ranges, plus which merged mark is current. */
17
+ export interface MarkRanges {
18
+ /** Clamped, sorted and merged — the same ranges `MatchHighlight` will paint. */
19
+ readonly ranges: readonly MatchRange[];
20
+ /** Index into {@link MarkRanges.ranges}, or `-1` when none is current. */
21
+ readonly activeIndex: number;
22
+ }
23
+
24
+ const EMPTY: MarkRanges = { ranges: [], activeIndex: -1 };
25
+
26
+ /**
27
+ * Fold resolved highlights into the ranges a `<mark>` layer paints.
28
+ *
29
+ * Merging happens HERE rather than being left to `MatchHighlight`, because the
30
+ * two would otherwise disagree about what "the third mark" is: overlapping
31
+ * citations collapse into one mark, and an `activeIndex` counted against the
32
+ * unmerged list would then point at the wrong one — or past the end. The active
33
+ * entry is located by which merged range CONTAINS its start, so it stays right
34
+ * no matter how many requests collapsed into that mark.
35
+ *
36
+ * Non-`range` addresses and unresolved entries are skipped: an adapter paints
37
+ * what it can and the shell is what tells the reader about the rest.
38
+ */
39
+ export function toMarkRanges(
40
+ highlights: readonly ResolvedHighlight[] | undefined,
41
+ textLength: number,
42
+ ): MarkRanges {
43
+ if (!highlights || highlights.length === 0) return EMPTY;
44
+
45
+ const located = highlights.filter(
46
+ (highlight): highlight is ResolvedHighlight & { range: MatchRange } =>
47
+ highlight.status === "resolved" && highlight.range !== undefined,
48
+ );
49
+ if (located.length === 0) return EMPTY;
50
+
51
+ const ranges = normalizeRanges(
52
+ located.map((highlight) => highlight.range),
53
+ textLength,
54
+ );
55
+
56
+ const active = located.find((highlight) => highlight.active);
57
+ const activeStart = active?.range[0];
58
+ const activeIndex =
59
+ activeStart === undefined
60
+ ? -1
61
+ : ranges.findIndex(([start, end]) => activeStart >= start && activeStart < end);
62
+
63
+ return { ranges, activeIndex };
64
+ }
65
+
66
+ /**
67
+ * Re-express document ranges relative to one slice of the document — a code
68
+ * token, a table cell, a block of prose.
69
+ *
70
+ * A renderer that draws its text in pieces cannot hand whole-document offsets to
71
+ * a `<mark>` layer that only knows about the piece in front of it. Ranges are
72
+ * clipped to the slice and rebased to 0; the ones that miss it entirely drop
73
+ * out, which is also how a caller learns there is nothing to mark here (an empty
74
+ * `ranges`). A range spanning several slices survives in each of them, so a
75
+ * citation crossing a line break stays one visual run.
76
+ */
77
+ export function localizeRanges(
78
+ { ranges, activeIndex }: MarkRanges,
79
+ start: number,
80
+ end: number,
81
+ ): MarkRanges {
82
+ if (ranges.length === 0 || start >= end) return EMPTY;
83
+
84
+ const local: MatchRange[] = [];
85
+ let localActive = -1;
86
+ ranges.forEach(([from, to], index) => {
87
+ const clippedFrom = Math.max(from, start);
88
+ const clippedTo = Math.min(to, end);
89
+ if (clippedFrom >= clippedTo) return;
90
+ if (index === activeIndex) localActive = local.length;
91
+ local.push([clippedFrom - start, clippedTo - start]);
92
+ });
93
+
94
+ return local.length === 0 ? EMPTY : { ranges: local, activeIndex: localActive };
95
+ }