@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,353 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Markdown adapter — a `.md` file read as a DOCUMENT, not as source.
|
|
5
|
+
*
|
|
6
|
+
* Renders through `streamdown`, the same markdown engine
|
|
7
|
+
* `@elabs-ai/components-ai`'s `MarkdownView` and
|
|
8
|
+
* `@elabs-ai/components-editor`'s preview use, mapped onto the same
|
|
9
|
+
* `Prose*` primitives from `@elabs-ai/components-ui`. That is the
|
|
10
|
+
* point: a README must not look like three different documents depending on
|
|
11
|
+
* which pane it opened in. The element map below is deliberately a near-copy of
|
|
12
|
+
* `MarkdownView`'s — the packages are siblings and may not import each other,
|
|
13
|
+
* and the shared half that could move down (the `Prose*` set, the Streamdown
|
|
14
|
+
* locale bridge) already has.
|
|
15
|
+
*
|
|
16
|
+
* What this does NOT do, on purpose:
|
|
17
|
+
*
|
|
18
|
+
* - **No plugins.** `@streamdown/code`, `/math`, `/cjk` and `/mermaid` are four
|
|
19
|
+
* more packages a consumer would have to install to open one file. Fenced code
|
|
20
|
+
* is rendered as an unhighlighted block by the element map below (Streamdown
|
|
21
|
+
* emits a bare `<code>` without the code plugin, so the `<pre>` is ours); a
|
|
22
|
+
* source file opened directly still gets Shiki from the `code` adapter, and a
|
|
23
|
+
* diagram fence reads as its own text rather than as a picture.
|
|
24
|
+
* - **No streaming.** A file arrives settled or not at all, so `mode="static"`
|
|
25
|
+
* and `parseIncompleteMarkdown={false}` — the incomplete-token repair that
|
|
26
|
+
* makes Streamdown good at chat is only overhead for a file on disk.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
ProseBlockquote,
|
|
31
|
+
ProseHeading,
|
|
32
|
+
ProseInlineCode,
|
|
33
|
+
ProseLink,
|
|
34
|
+
ProseList,
|
|
35
|
+
ProseListItem,
|
|
36
|
+
ProseText,
|
|
37
|
+
StatePanel,
|
|
38
|
+
cn,
|
|
39
|
+
useLocale,
|
|
40
|
+
useStreamdownTranslations,
|
|
41
|
+
} from "@elabs-ai/components-ui";
|
|
42
|
+
import type { ProseHeadingLevel, ResolvedFileSource } from "@elabs-ai/components-ui";
|
|
43
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
44
|
+
import { createContext, use, useMemo, useRef } from "react";
|
|
45
|
+
import type * as StreamdownExports from "streamdown";
|
|
46
|
+
|
|
47
|
+
import { toViewerError } from "../../core/errors";
|
|
48
|
+
import { toMarkRanges, type MarkRanges } from "../../core/highlight-marks";
|
|
49
|
+
import { useScrollActiveHighlightIntoView } from "../../core/use-highlight-scroll";
|
|
50
|
+
import type {
|
|
51
|
+
AdapterDocument,
|
|
52
|
+
AdapterLoadContext,
|
|
53
|
+
AdapterModule,
|
|
54
|
+
AdapterRendererProps,
|
|
55
|
+
FileAdapter,
|
|
56
|
+
} from "../../core/types";
|
|
57
|
+
import { blockMark, blockMarkAttributes, type PositionedNode } from "./markdown-marks";
|
|
58
|
+
import { markdownManifest } from "./markdown-manifest";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Characters kept before truncation. Markdown is parsed into an element tree,
|
|
62
|
+
* which costs more than a `<pre>` — this sits between the code and plain-text
|
|
63
|
+
* limits for that reason.
|
|
64
|
+
*/
|
|
65
|
+
export const MARKDOWN_CHARACTER_LIMIT = 1_000_000;
|
|
66
|
+
|
|
67
|
+
export interface MarkdownDocument extends AdapterDocument {
|
|
68
|
+
kind: "markdown";
|
|
69
|
+
text: string;
|
|
70
|
+
/** Total characters in the file, when more than what is shown. */
|
|
71
|
+
totalCharacters?: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Type-only, so it erases: the runtime edge is the dynamic import below.
|
|
75
|
+
type StreamdownModule = typeof StreamdownExports;
|
|
76
|
+
type StreamdownComponents = NonNullable<
|
|
77
|
+
ComponentProps<StreamdownModule["Streamdown"]>["components"]
|
|
78
|
+
>;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The engine, resolved once per page.
|
|
82
|
+
*
|
|
83
|
+
* Loaded in `load()` rather than imported at the top of this module for the
|
|
84
|
+
* reason every other adapter does it: `streamdown` is an OPTIONAL peer, and a
|
|
85
|
+
* static edge would make this module unresolvable — not merely heavier — for a
|
|
86
|
+
* consumer who never installs it. The renderer reads the same cached module,
|
|
87
|
+
* which is guaranteed to be populated because a document only exists once
|
|
88
|
+
* `load()` has resolved.
|
|
89
|
+
*/
|
|
90
|
+
let streamdown: StreamdownModule | undefined;
|
|
91
|
+
|
|
92
|
+
async function loadStreamdown(): Promise<StreamdownModule> {
|
|
93
|
+
// Dynamic: the ONLY edge to the optional peer (heavy-deps:check).
|
|
94
|
+
streamdown ??= await import("streamdown");
|
|
95
|
+
return streamdown;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
class MarkdownAdapter implements FileAdapter {
|
|
99
|
+
async load(source: ResolvedFileSource, context: AdapterLoadContext): Promise<MarkdownDocument> {
|
|
100
|
+
let raw: string;
|
|
101
|
+
try {
|
|
102
|
+
raw = await source.text(context.signal);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
throw toViewerError(error, "read-failed", { fileName: source.name });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Before returning: a document this adapter's Renderer cannot draw is a
|
|
108
|
+
// failed load, not a blank pane. A missing peer surfaces from here as
|
|
109
|
+
// `parser-missing` (see `parserMissingError`).
|
|
110
|
+
await loadStreamdown();
|
|
111
|
+
|
|
112
|
+
const truncated = raw.length > MARKDOWN_CHARACTER_LIMIT;
|
|
113
|
+
return {
|
|
114
|
+
kind: "markdown",
|
|
115
|
+
text: truncated ? raw.slice(0, MARKDOWN_CHARACTER_LIMIT) : raw,
|
|
116
|
+
totalCharacters: truncated ? raw.length : undefined,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* -------------------------------------------------------------------------- */
|
|
122
|
+
/* Renderer */
|
|
123
|
+
/* -------------------------------------------------------------------------- */
|
|
124
|
+
|
|
125
|
+
/** Never past `h6`, whatever the offset — an `h7` is not an element. */
|
|
126
|
+
const clampHeadingLevel = (level: number): ProseHeadingLevel =>
|
|
127
|
+
Math.min(6, Math.max(1, level)) as ProseHeadingLevel;
|
|
128
|
+
|
|
129
|
+
/* -------------------------------------------------------------------------- */
|
|
130
|
+
/* Highlight plates */
|
|
131
|
+
/* -------------------------------------------------------------------------- */
|
|
132
|
+
|
|
133
|
+
const EMPTY_MARKS: MarkRanges = { ranges: [], activeIndex: -1 };
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The marks, read by the block components.
|
|
137
|
+
*
|
|
138
|
+
* A context rather than a closure over `buildProseComponents`, so the element
|
|
139
|
+
* map stays memoized on the heading offset alone: rebuilding it per keystroke
|
|
140
|
+
* of a find-as-you-type would hand Streamdown a new `components` object every
|
|
141
|
+
* time, which it treats as a reason to re-render every block in the file.
|
|
142
|
+
*/
|
|
143
|
+
const MarkContext = createContext<MarkRanges>(EMPTY_MARKS);
|
|
144
|
+
|
|
145
|
+
/** Set once a block has plated, so a nested one does not plate on top of it. */
|
|
146
|
+
const PlatedContext = createContext(false);
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The plate for one block, and a wrapper that stops its children plating again.
|
|
150
|
+
*
|
|
151
|
+
* A loose list item renders as `li > p`, and both nodes' source spans contain
|
|
152
|
+
* the same mark — without the suppression the reader sees two stacked fills for
|
|
153
|
+
* one citation.
|
|
154
|
+
*/
|
|
155
|
+
function usePlate(node: PositionedNode | undefined, className?: string) {
|
|
156
|
+
const marks = use(MarkContext);
|
|
157
|
+
const plated = use(PlatedContext);
|
|
158
|
+
const mark = plated ? { marked: false, active: false } : blockMark(marks, node);
|
|
159
|
+
return {
|
|
160
|
+
props: blockMarkAttributes(mark, className),
|
|
161
|
+
wrap: (children: ReactNode) =>
|
|
162
|
+
mark.marked ? <PlatedContext value={true}>{children}</PlatedContext> : children,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/*
|
|
167
|
+
* The plating blocks are named components rather than inline arrows in the map
|
|
168
|
+
* below: they call a hook, and a lowercase `p:` key is not a component name as
|
|
169
|
+
* far as the rules-of-hooks lint can tell.
|
|
170
|
+
*/
|
|
171
|
+
|
|
172
|
+
const MarkdownParagraph: NonNullable<StreamdownComponents["p"]> = ({
|
|
173
|
+
node,
|
|
174
|
+
className,
|
|
175
|
+
children,
|
|
176
|
+
...props
|
|
177
|
+
}) => {
|
|
178
|
+
const plate = usePlate(node, className);
|
|
179
|
+
return (
|
|
180
|
+
<ProseText {...plate.props} {...props}>
|
|
181
|
+
{plate.wrap(children)}
|
|
182
|
+
</ProseText>
|
|
183
|
+
);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const MarkdownListItem: NonNullable<StreamdownComponents["li"]> = ({
|
|
187
|
+
node,
|
|
188
|
+
className,
|
|
189
|
+
children,
|
|
190
|
+
...props
|
|
191
|
+
}) => {
|
|
192
|
+
const plate = usePlate(node, className);
|
|
193
|
+
return (
|
|
194
|
+
<ProseListItem {...plate.props} {...props}>
|
|
195
|
+
{plate.wrap(children)}
|
|
196
|
+
</ProseListItem>
|
|
197
|
+
);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const MarkdownCode: NonNullable<StreamdownComponents["code"]> = ({
|
|
201
|
+
node,
|
|
202
|
+
className,
|
|
203
|
+
children,
|
|
204
|
+
...props
|
|
205
|
+
}) => {
|
|
206
|
+
const fence = /\blanguage-/.test(className ?? "");
|
|
207
|
+
// The plate goes on the `<pre>` — the whole fence is what a citation points
|
|
208
|
+
// at. Its own `bg-surface-muted` wins over the plate's fill (see
|
|
209
|
+
// `blockMarkAttributes`), so the code keeps its ground and the rail is what
|
|
210
|
+
// says it is cited.
|
|
211
|
+
const plate = usePlate(
|
|
212
|
+
fence ? node : undefined,
|
|
213
|
+
"bg-surface-muted text-code overflow-x-auto rounded-md p-3 font-mono whitespace-pre",
|
|
214
|
+
);
|
|
215
|
+
// A fence needs its own `<pre>`. With no `@streamdown/code` plugin installed
|
|
216
|
+
// Streamdown emits a BARE `<code data-block>` — no block wrapper — which the
|
|
217
|
+
// surrounding prose's `whitespace-normal` then collapses into one run-on
|
|
218
|
+
// line. The fence is unhighlighted here by design (see the module docblock);
|
|
219
|
+
// it still has to read as a block of code.
|
|
220
|
+
return fence ? (
|
|
221
|
+
<pre {...plate.props}>
|
|
222
|
+
<code className={className} {...props}>
|
|
223
|
+
{plate.wrap(children)}
|
|
224
|
+
</code>
|
|
225
|
+
</pre>
|
|
226
|
+
) : (
|
|
227
|
+
<ProseInlineCode className={className} {...props}>
|
|
228
|
+
{children}
|
|
229
|
+
</ProseInlineCode>
|
|
230
|
+
);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/** Everything that does not depend on the heading offset. */
|
|
234
|
+
const SHARED_COMPONENTS: StreamdownComponents = {
|
|
235
|
+
p: MarkdownParagraph,
|
|
236
|
+
a: ({ node: _node, ...props }) => <ProseLink {...props} />,
|
|
237
|
+
// `ref` is stripped: ProseList's intersection ref type (ul & ol) is narrower
|
|
238
|
+
// than the per-element ref react-markdown passes.
|
|
239
|
+
ul: ({ node: _node, ref: _ref, ...props }) => <ProseList {...props} />,
|
|
240
|
+
ol: ({ node: _node, ref: _ref, ...props }) => <ProseList ordered {...props} />,
|
|
241
|
+
li: MarkdownListItem,
|
|
242
|
+
blockquote: ({ node: _node, ...props }) => <ProseBlockquote {...props} />,
|
|
243
|
+
code: MarkdownCode,
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Map the markdown element tree onto the Prose primitives.
|
|
248
|
+
*
|
|
249
|
+
* Built per `baseHeadingLevel` (memoized by the renderer, not rebuilt each
|
|
250
|
+
* render): the map is otherwise pure and Streamdown puts it into a context, so
|
|
251
|
+
* a fresh object per render would re-render every block.
|
|
252
|
+
*
|
|
253
|
+
* The heading offset is the reason this takes an argument at all. A viewed file
|
|
254
|
+
* carries its own heading tree, correct only relative to the page hosting it —
|
|
255
|
+
* a README's `#` inside an app that already has an `<h1>` would otherwise put
|
|
256
|
+
* two `h1`s in a screen reader's flat heading list. Same seam, same arithmetic
|
|
257
|
+
* as `@elabs-ai/components-ai`'s `MarkdownView`.
|
|
258
|
+
*/
|
|
259
|
+
function buildProseComponents(baseHeadingLevel: ProseHeadingLevel): StreamdownComponents {
|
|
260
|
+
const heading =
|
|
261
|
+
(markdownLevel: number): StreamdownComponents["h1"] =>
|
|
262
|
+
({ node, className, children, ...props }) => {
|
|
263
|
+
const plate = usePlate(node, className);
|
|
264
|
+
return (
|
|
265
|
+
<ProseHeading
|
|
266
|
+
level={clampHeadingLevel(markdownLevel + baseHeadingLevel - 1)}
|
|
267
|
+
{...plate.props}
|
|
268
|
+
{...props}
|
|
269
|
+
>
|
|
270
|
+
{plate.wrap(children)}
|
|
271
|
+
</ProseHeading>
|
|
272
|
+
);
|
|
273
|
+
};
|
|
274
|
+
return {
|
|
275
|
+
h1: heading(1),
|
|
276
|
+
h2: heading(2),
|
|
277
|
+
h3: heading(3),
|
|
278
|
+
h4: heading(4),
|
|
279
|
+
h5: heading(5),
|
|
280
|
+
h6: heading(6),
|
|
281
|
+
...SHARED_COMPONENTS,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function MarkdownRenderer({
|
|
286
|
+
document: doc,
|
|
287
|
+
className,
|
|
288
|
+
baseHeadingLevel = 2,
|
|
289
|
+
highlights,
|
|
290
|
+
activeHighlightId,
|
|
291
|
+
}: AdapterRendererProps) {
|
|
292
|
+
const markdown = doc as MarkdownDocument;
|
|
293
|
+
const { t, formatNumber } = useLocale();
|
|
294
|
+
const translations = useStreamdownTranslations();
|
|
295
|
+
const components = useMemo(() => buildProseComponents(baseHeadingLevel), [baseHeadingLevel]);
|
|
296
|
+
const container = useRef<HTMLDivElement>(null);
|
|
297
|
+
const marks = useMemo(
|
|
298
|
+
() => toMarkRanges(highlights, markdown.text.length),
|
|
299
|
+
[highlights, markdown.text],
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
useScrollActiveHighlightIntoView(container, activeHighlightId);
|
|
303
|
+
|
|
304
|
+
if (markdown.text.trim().length === 0) {
|
|
305
|
+
return <StatePanel kind="empty" title={t("viewer.file.empty")} className={className} />;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const Streamdown = streamdown?.Streamdown;
|
|
309
|
+
if (!Streamdown) return null;
|
|
310
|
+
|
|
311
|
+
return (
|
|
312
|
+
// No `overflow-auto` here: `FileViewerContent` is the scroll boundary, and
|
|
313
|
+
// nesting a second one clips the last block above the outer pane's padding.
|
|
314
|
+
<div ref={container} className={cn("flex flex-col gap-2", className)}>
|
|
315
|
+
{markdown.totalCharacters !== undefined && (
|
|
316
|
+
// Not an error — the file is fine, we are showing part of it.
|
|
317
|
+
<p role="status" className="text-meta text-muted-foreground">
|
|
318
|
+
{t("viewer.text.truncated", {
|
|
319
|
+
shown: formatNumber(markdown.text.length),
|
|
320
|
+
total: formatNumber(markdown.totalCharacters),
|
|
321
|
+
})}
|
|
322
|
+
</p>
|
|
323
|
+
)}
|
|
324
|
+
{/* `max-w-prose` because this is genuine long-form reading, not a label:
|
|
325
|
+
a README run edge-to-edge across a wide pane loses the line. The pane's
|
|
326
|
+
own padding is the page margin — the article adds none. */}
|
|
327
|
+
<article className="mx-auto max-w-prose space-y-3">
|
|
328
|
+
{/* `mode="static"` is what makes the plates addressable: it hands the
|
|
329
|
+
whole file to ONE parse, so every node's `position.start.offset` is
|
|
330
|
+
an offset into `document.text`. Splitting into blocks (streaming
|
|
331
|
+
mode) would restart them per block. */}
|
|
332
|
+
<MarkContext value={marks}>
|
|
333
|
+
<Streamdown
|
|
334
|
+
mode="static"
|
|
335
|
+
parseIncompleteMarkdown={false}
|
|
336
|
+
components={components}
|
|
337
|
+
translations={translations}
|
|
338
|
+
>
|
|
339
|
+
{markdown.text}
|
|
340
|
+
</Streamdown>
|
|
341
|
+
</MarkContext>
|
|
342
|
+
</article>
|
|
343
|
+
</div>
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const adapterModule: AdapterModule = {
|
|
348
|
+
manifest: markdownManifest,
|
|
349
|
+
create: () => new MarkdownAdapter(),
|
|
350
|
+
Renderer: MarkdownRenderer,
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
export default adapterModule;
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
* `.mdx` is deliberately NOT claimed: it is JavaScript wearing markdown, and
|
|
8
|
+
* rendering it would mean evaluating a foreign file's code — the one thing a
|
|
9
|
+
* viewer must never do. An `.mdx` falls through to the plain-text adapter.
|
|
10
|
+
*/
|
|
11
|
+
export const markdownManifest: AdapterManifest = {
|
|
12
|
+
id: "markdown",
|
|
13
|
+
protocol: PROTOCOL_VERSION,
|
|
14
|
+
extensions: ["md", "markdown", "mdown", "mkd"],
|
|
15
|
+
mediaTypes: ["text/markdown"],
|
|
16
|
+
// Both kinds address the SOURCE, which is what `text` is here — and both are
|
|
17
|
+
// painted as a whole-block plate rather than as characters, because a source
|
|
18
|
+
// offset can land inside markup the reader never sees. See `markdown-marks.ts`.
|
|
19
|
+
capabilities: { text: true, search: true, highlight: ["quote", "range"] },
|
|
20
|
+
requires: ["streamdown"],
|
|
21
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { MarkRanges } from "../../core/highlight-marks";
|
|
4
|
+
import { blockMark, blockMarkAttributes } from "./markdown-marks";
|
|
5
|
+
|
|
6
|
+
const at = (start: number, end: number) => ({
|
|
7
|
+
position: { start: { offset: start }, end: { offset: end } },
|
|
8
|
+
});
|
|
9
|
+
const marks = (ranges: [number, number][], activeIndex = -1): MarkRanges => ({
|
|
10
|
+
ranges,
|
|
11
|
+
activeIndex,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe("blockMark", () => {
|
|
15
|
+
it("marks a block a range falls inside", () => {
|
|
16
|
+
expect(blockMark(marks([[12, 20]]), at(10, 30))).toEqual({ marked: true, active: false });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("marks a block a range only overlaps", () => {
|
|
20
|
+
// A citation running across a block break belongs to BOTH blocks — that is
|
|
21
|
+
// what keeps one passage reading as one passage.
|
|
22
|
+
expect(blockMark(marks([[5, 15]]), at(10, 30)).marked).toBe(true);
|
|
23
|
+
expect(blockMark(marks([[25, 40]]), at(10, 30)).marked).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("leaves the neighbours alone when a range abuts a boundary", () => {
|
|
27
|
+
expect(blockMark(marks([[0, 10]]), at(10, 30)).marked).toBe(false);
|
|
28
|
+
expect(blockMark(marks([[30, 40]]), at(10, 30)).marked).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("reports the active range separately from the rest", () => {
|
|
32
|
+
const two = marks(
|
|
33
|
+
[
|
|
34
|
+
[12, 14],
|
|
35
|
+
[50, 55],
|
|
36
|
+
],
|
|
37
|
+
1,
|
|
38
|
+
);
|
|
39
|
+
expect(blockMark(two, at(10, 30))).toEqual({ marked: true, active: false });
|
|
40
|
+
expect(blockMark(two, at(45, 60))).toEqual({ marked: true, active: true });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("marks nothing for a node with no source position", () => {
|
|
44
|
+
// rehype-raw and generated nodes have none; a plate guessed from a missing
|
|
45
|
+
// position would land on an arbitrary block.
|
|
46
|
+
expect(blockMark(marks([[0, 100]]), {})).toEqual({ marked: false, active: false });
|
|
47
|
+
expect(blockMark(marks([[0, 100]]), undefined)).toEqual({ marked: false, active: false });
|
|
48
|
+
expect(blockMark(marks([[0, 100]]), at(10, 10))).toEqual({ marked: false, active: false });
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("blockMarkAttributes", () => {
|
|
53
|
+
it("adds nothing to an unmarked block but its own class", () => {
|
|
54
|
+
expect(blockMarkAttributes({ marked: false, active: false })).toEqual({});
|
|
55
|
+
expect(blockMarkAttributes({ marked: false, active: false }, "prose")).toEqual({
|
|
56
|
+
className: "prose",
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("gives a marked block the shared slot, and the active one a name for AT", () => {
|
|
61
|
+
const marked = blockMarkAttributes({ marked: true, active: false });
|
|
62
|
+
expect(marked["data-slot"]).toBe("highlight-block");
|
|
63
|
+
expect(marked["data-active"]).toBeUndefined();
|
|
64
|
+
expect(marked["aria-current"]).toBeUndefined();
|
|
65
|
+
|
|
66
|
+
const active = blockMarkAttributes({ marked: true, active: true });
|
|
67
|
+
// Presence form, not `"true"`: one selector finds the current highlight
|
|
68
|
+
// whichever painter drew it.
|
|
69
|
+
expect(active["data-active"]).toBe("");
|
|
70
|
+
expect(active["aria-current"]).toBe("true");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("distinguishes the current plate by more than its colour", () => {
|
|
74
|
+
// WCAG 1.4.1: the rail doubles in width as well as changing hue, so the
|
|
75
|
+
// current passage survives greyscale.
|
|
76
|
+
expect(blockMarkAttributes({ marked: true, active: false }).className).toContain("border-s-2");
|
|
77
|
+
expect(blockMarkAttributes({ marked: true, active: true }).className).toContain("border-s-4");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("keeps the block's own classes alongside the plate's", () => {
|
|
81
|
+
expect(blockMarkAttributes({ marked: true, active: false }, "font-mono").className).toContain(
|
|
82
|
+
"font-mono",
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown highlights are BLOCK-shaped, and that is a deliberate downgrade.
|
|
3
|
+
*
|
|
4
|
+
* Every other text adapter marks characters, because its `document.text` is the
|
|
5
|
+
* thing on screen. Markdown's is not: `text` is the SOURCE, so offset 12 might
|
|
6
|
+
* land in the middle of `**bold**` — two of whose characters are never drawn at
|
|
7
|
+
* all — or inside a link's URL, which the reader only sees as a word. There is
|
|
8
|
+
* no honest character-level mapping from source offsets to rendered text
|
|
9
|
+
* without re-implementing the parser's own position bookkeeping for inline
|
|
10
|
+
* nodes, and getting that subtly wrong would put marks on the wrong words while
|
|
11
|
+
* looking exactly as confident as a correct one.
|
|
12
|
+
*
|
|
13
|
+
* So a markdown mark says "this passage is in HERE" — the paragraph, heading,
|
|
14
|
+
* list item or fence whose source span the mark touches is plated whole. That
|
|
15
|
+
* is enough for the case the feature exists for (a citation pointing a reader at
|
|
16
|
+
* the passage an answer came from) and it cannot be subtly wrong: a block either
|
|
17
|
+
* contains the source range or it does not.
|
|
18
|
+
*
|
|
19
|
+
* The offsets are trustworthy because the renderer runs Streamdown in
|
|
20
|
+
* `mode="static"`, which hands the WHOLE file to one parse — no per-block
|
|
21
|
+
* splitting, no incomplete-token repair — so every hast node's
|
|
22
|
+
* `position.start.offset` is an offset into `document.text` itself.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { cn } from "@elabs-ai/components-ui";
|
|
26
|
+
|
|
27
|
+
import type { MarkRanges } from "../../core/highlight-marks";
|
|
28
|
+
|
|
29
|
+
/** The subset of a unist `Position` this needs. Structural, so no unist import. */
|
|
30
|
+
export interface SourcePosition {
|
|
31
|
+
start?: { offset?: number };
|
|
32
|
+
end?: { offset?: number };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A hast node as the component overrides receive it. */
|
|
36
|
+
export interface PositionedNode {
|
|
37
|
+
position?: SourcePosition;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Whether a block carries any mark, and whether it carries the current one. */
|
|
41
|
+
export interface BlockMark {
|
|
42
|
+
marked: boolean;
|
|
43
|
+
active: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const UNMARKED: BlockMark = { marked: false, active: false };
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Which marks land inside one block's source span.
|
|
50
|
+
*
|
|
51
|
+
* Half-open on both sides and strict, so a mark ending exactly where a block
|
|
52
|
+
* begins belongs to the block before it — otherwise the paragraph after a
|
|
53
|
+
* marked heading would light up too, on every citation.
|
|
54
|
+
*/
|
|
55
|
+
export function blockMark(marks: MarkRanges, node: PositionedNode | undefined): BlockMark {
|
|
56
|
+
const start = node?.position?.start?.offset;
|
|
57
|
+
const end = node?.position?.end?.offset;
|
|
58
|
+
if (start === undefined || end === undefined || end <= start) return UNMARKED;
|
|
59
|
+
|
|
60
|
+
let marked = false;
|
|
61
|
+
let active = false;
|
|
62
|
+
marks.ranges.forEach(([from, to], index) => {
|
|
63
|
+
if (from >= end || to <= start) return;
|
|
64
|
+
marked = true;
|
|
65
|
+
if (index === marks.activeIndex) active = true;
|
|
66
|
+
});
|
|
67
|
+
return marked ? { marked, active } : UNMARKED;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The attributes a plated block carries.
|
|
72
|
+
*
|
|
73
|
+
* `data-slot`/`data-active` are the same seam the character marks use, so one
|
|
74
|
+
* selector finds "the current highlight" whatever adapter drew it — that is what
|
|
75
|
+
* `useScrollActiveHighlightIntoView` scrolls to. `aria-current` is the part that
|
|
76
|
+
* is not decoration: the active block is announced as the current one rather
|
|
77
|
+
* than being distinguishable only by a warmer plate (WCAG 1.4.1).
|
|
78
|
+
*/
|
|
79
|
+
export interface BlockMarkAttributes {
|
|
80
|
+
"data-slot"?: "highlight-block";
|
|
81
|
+
/**
|
|
82
|
+
* Present-or-absent, never `"false"` — the same shape `MatchHighlight`'s marks
|
|
83
|
+
* and the PDF's boxes emit, so one selector (`[data-active]`) finds the current
|
|
84
|
+
* highlight whichever painter drew it.
|
|
85
|
+
*/
|
|
86
|
+
"data-active"?: "";
|
|
87
|
+
"aria-current"?: "true";
|
|
88
|
+
className?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Plate classes.
|
|
93
|
+
*
|
|
94
|
+
* A marked block gets a fill AND a rail; the current one doubles the rail's
|
|
95
|
+
* width on top of warming the fill, so "which one am I on" survives greyscale
|
|
96
|
+
* and a colour-vision difference. The negative margins let the plate bleed into
|
|
97
|
+
* the prose column's own gutter instead of shifting the text when it appears.
|
|
98
|
+
*/
|
|
99
|
+
const MARKED_CLASS = "bg-highlight/40 border-s-2 border-highlight rounded-e-sm ps-2 -ms-2";
|
|
100
|
+
const ACTIVE_CLASS =
|
|
101
|
+
"bg-highlight-active/40 border-s-4 border-highlight-active rounded-e-sm ps-2 -ms-2";
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Spreadable attributes for a block, given how the marks fell on it.
|
|
105
|
+
*
|
|
106
|
+
* The block's OWN classes are merged last and therefore win, which is the
|
|
107
|
+
* deliberate answer for a fenced code block: `bg-surface-muted` beats the
|
|
108
|
+
* plate's fill, so the code keeps its own ground and the rail alone says it is
|
|
109
|
+
* cited. Leaving the order to CSS source order instead would make that outcome
|
|
110
|
+
* accidental.
|
|
111
|
+
*/
|
|
112
|
+
export function blockMarkAttributes(mark: BlockMark, className?: string): BlockMarkAttributes {
|
|
113
|
+
if (!mark.marked) return className === undefined ? {} : { className };
|
|
114
|
+
return {
|
|
115
|
+
"data-slot": "highlight-block",
|
|
116
|
+
...(mark.active ? { "data-active": "" as const, "aria-current": "true" as const } : {}),
|
|
117
|
+
className: cn(mark.active ? ACTIVE_CLASS : MARKED_CLASS, className),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import type { AdapterRendererProps } from "../../core/types";
|
|
5
|
+
import adapterModule, { type MediaDocument } from "./media-adapter";
|
|
6
|
+
|
|
7
|
+
function source(overrides: Partial<Record<string, unknown>> = {}) {
|
|
8
|
+
return {
|
|
9
|
+
name: "briefing.mp4",
|
|
10
|
+
mediaType: "video/mp4",
|
|
11
|
+
category: "video",
|
|
12
|
+
extension: "mp4",
|
|
13
|
+
bytes: () => Promise.resolve(new ArrayBuffer(0)),
|
|
14
|
+
text: () => Promise.resolve(""),
|
|
15
|
+
url: () => Promise.resolve("blob:media"),
|
|
16
|
+
revoke: vi.fn(),
|
|
17
|
+
...overrides,
|
|
18
|
+
} as unknown as AdapterRendererProps["source"];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function renderMedia(media: "video" | "audio", name: string) {
|
|
22
|
+
const { Renderer } = adapterModule;
|
|
23
|
+
const document: MediaDocument = { kind: "media", url: "blob:media", media };
|
|
24
|
+
return render(<Renderer document={document} source={source({ name, category: media })} />);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("media adapter — load", () => {
|
|
28
|
+
it("streams from a URL instead of buffering the whole file", async () => {
|
|
29
|
+
const bytes = vi.fn();
|
|
30
|
+
const adapter = adapterModule.create();
|
|
31
|
+
const document = await adapter.load(source({ bytes }), {});
|
|
32
|
+
|
|
33
|
+
expect(document).toMatchObject({ kind: "media", media: "video", url: "blob:media" });
|
|
34
|
+
// A 2 GB video read into memory to feed a <video> element is the wrong shape
|
|
35
|
+
// at every size — the element streams and seeks from the URL itself.
|
|
36
|
+
expect(bytes).not.toHaveBeenCalled();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("picks the audio element from the source's own category", async () => {
|
|
40
|
+
const adapter = adapterModule.create();
|
|
41
|
+
const document = await adapter.load(source({ category: "audio", name: "call.mp3" }), {});
|
|
42
|
+
expect(document).toMatchObject({ media: "audio" });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("revokes the object URL it minted", async () => {
|
|
46
|
+
const revoke = vi.fn();
|
|
47
|
+
const adapter = adapterModule.create();
|
|
48
|
+
await adapter.load(source({ revoke }), {});
|
|
49
|
+
adapter.dispose?.();
|
|
50
|
+
expect(revoke).toHaveBeenCalledTimes(1);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("media adapter — rendering", () => {
|
|
55
|
+
it("renders a native, controllable video with an accessible name", () => {
|
|
56
|
+
const { container } = renderMedia("video", "briefing.mp4");
|
|
57
|
+
const video = container.querySelector("video");
|
|
58
|
+
expect(video).toHaveAttribute("controls");
|
|
59
|
+
expect(video).toHaveAttribute("aria-label", "briefing.mp4 player");
|
|
60
|
+
// Sound that starts on its own is the reason browsers block autoplay.
|
|
61
|
+
expect(video).not.toHaveAttribute("autoplay");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("renders an audio element for audio", () => {
|
|
65
|
+
const { container } = renderMedia("audio", "call.mp3");
|
|
66
|
+
expect(container.querySelector("audio")).toBeInTheDocument();
|
|
67
|
+
expect(container.querySelector("video")).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("states an undecodable codec as a settled failure, with no retry", () => {
|
|
71
|
+
const { container } = renderMedia("video", "briefing.mp4");
|
|
72
|
+
fireEvent.error(container.querySelector("video") as HTMLVideoElement);
|
|
73
|
+
|
|
74
|
+
const alert = screen.getByRole("alert");
|
|
75
|
+
expect(alert).toHaveTextContent("This browser can't play briefing.mp4.");
|
|
76
|
+
// Retrying cannot install a codec.
|
|
77
|
+
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
|
78
|
+
});
|
|
79
|
+
});
|