@elabs-ai/components-editor 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 +149 -0
- package/dist/chunk-LBC5VJBD.js +3748 -0
- package/dist/chunk-LBC5VJBD.js.map +1 -0
- package/dist/index.css +233 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +199 -0
- package/dist/index.js +362 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/monaco-environment.d.ts +2 -0
- package/dist/lib/monaco-environment.js +44 -0
- package/dist/lib/monaco-environment.js.map +1 -0
- package/dist/markdown/frontmatter.d.ts +11 -0
- package/dist/markdown/frontmatter.js +33 -0
- package/dist/markdown/frontmatter.js.map +1 -0
- package/dist/markdown/index.css +233 -0
- package/dist/markdown/index.css.map +1 -0
- package/dist/markdown/index.d.ts +1486 -0
- package/dist/markdown/index.js +4882 -0
- package/dist/markdown/index.js.map +1 -0
- package/dist/markdown/parse.d.ts +25 -0
- package/dist/markdown/parse.js +14 -0
- package/dist/markdown/parse.js.map +1 -0
- package/dist/markdown-editor-DfBZibAn.d.ts +702 -0
- package/package.json +101 -0
- package/src/ai-objects/ai-objects-integration.test.tsx +128 -0
- package/src/ai-objects/ai-objects.stories.tsx +233 -0
- package/src/ai-objects/ai-objects.test.tsx +205 -0
- package/src/ai-objects/decision-card.tsx +178 -0
- package/src/ai-objects/directives.ts +136 -0
- package/src/ai-objects/entity.tsx +181 -0
- package/src/ai-objects/index.ts +34 -0
- package/src/ai-objects/knowledge-card.tsx +143 -0
- package/src/calc-block/calc-block.stories.tsx +297 -0
- package/src/calc-block/calc-block.test.tsx +164 -0
- package/src/calc-block/calc-block.tsx +398 -0
- package/src/calc-block/calc-editor-monaco.ts +214 -0
- package/src/calc-block/calc-editor-prose.ts +128 -0
- package/src/calc-block/calc-editor.css +80 -0
- package/src/calc-block/calc-editor.stories.tsx +272 -0
- package/src/calc-block/calc-editor.test.ts +228 -0
- package/src/calc-block/calc-editor.ts +270 -0
- package/src/calc-block/calc-inline.stories.tsx +44 -0
- package/src/calc-block/calc-inline.test.tsx +44 -0
- package/src/calc-block/calc-inline.tsx +75 -0
- package/src/calc-block/index.ts +20 -0
- package/src/calc-block/types.ts +164 -0
- package/src/code-editor/code-editor.stories.tsx +218 -0
- package/src/code-editor/code-editor.test.tsx +134 -0
- package/src/code-editor/code-editor.tsx +250 -0
- package/src/code-editor/index.ts +6 -0
- package/src/code-workspace/code-workspace.stories.tsx +51 -0
- package/src/code-workspace/code-workspace.test.tsx +146 -0
- package/src/code-workspace/code-workspace.tsx +253 -0
- package/src/code-workspace/index.ts +6 -0
- package/src/copy-button/copy-button.tsx +51 -0
- package/src/copy-button/index.ts +1 -0
- package/src/css.d.ts +3 -0
- package/src/diff-editor/diff-editor.stories.tsx +44 -0
- package/src/diff-editor/diff-editor.test.tsx +68 -0
- package/src/diff-editor/diff-editor.tsx +151 -0
- package/src/diff-editor/index.ts +1 -0
- package/src/editor-context-menu/editor-context-menu.tsx +120 -0
- package/src/editor-context-menu/index.ts +1 -0
- package/src/editor-toolbar/editor-toolbar.tsx +83 -0
- package/src/editor-toolbar/index.ts +1 -0
- package/src/index.ts +48 -0
- package/src/lib/editor-completions-monaco.test.ts +206 -0
- package/src/lib/editor-completions-monaco.ts +127 -0
- package/src/lib/editor-completions.test.ts +156 -0
- package/src/lib/editor-completions.ts +153 -0
- package/src/lib/editor-content-access-prose.test.ts +411 -0
- package/src/lib/editor-content-access-prose.ts +172 -0
- package/src/lib/editor-content-access.test.ts +182 -0
- package/src/lib/editor-content-access.ts +109 -0
- package/src/lib/languages.ts +32 -0
- package/src/lib/markdown/diff.test.ts +80 -0
- package/src/lib/markdown/diff.ts +194 -0
- package/src/lib/markdown/directives.ts +342 -0
- package/src/lib/markdown/frontmatter.ts +50 -0
- package/src/lib/markdown/markdown-scale.test.ts +65 -0
- package/src/lib/markdown/markdown-scale.ts +62 -0
- package/src/lib/markdown/merge.test.ts +64 -0
- package/src/lib/markdown/merge.ts +158 -0
- package/src/lib/markdown/slugify.test.ts +156 -0
- package/src/lib/markdown/slugify.ts +47 -0
- package/src/lib/monaco-environment.ts +103 -0
- package/src/lib/monaco-theme-bridge.ts +293 -0
- package/src/lib/use-data-theme.ts +61 -0
- package/src/markdown/frontmatter.ts +14 -0
- package/src/markdown/index.ts +323 -0
- package/src/markdown/parse.test.ts +60 -0
- package/src/markdown/parse.ts +38 -0
- package/src/markdown-academic/citations.test.ts +77 -0
- package/src/markdown-academic/citations.tsx +442 -0
- package/src/markdown-academic/footnotes.tsx +235 -0
- package/src/markdown-academic/index.ts +32 -0
- package/src/markdown-academic/math.tsx +163 -0
- package/src/markdown-academic/toc.tsx +88 -0
- package/src/markdown-editor/completions/completions-menu.tsx +96 -0
- package/src/markdown-editor/completions/completions-prose.test.ts +356 -0
- package/src/markdown-editor/completions/completions-prose.ts +313 -0
- package/src/markdown-editor/completions/completions-widget.tsx +75 -0
- package/src/markdown-editor/completions/index.ts +37 -0
- package/src/markdown-editor/directive-nodes.ts +151 -0
- package/src/markdown-editor/directive-views.test.tsx +189 -0
- package/src/markdown-editor/directive-views.tsx +795 -0
- package/src/markdown-editor/exit-keymap.test.ts +88 -0
- package/src/markdown-editor/exit-keymap.ts +87 -0
- package/src/markdown-editor/index.ts +6 -0
- package/src/markdown-editor/markdown-editor.css +250 -0
- package/src/markdown-editor/markdown-editor.directives.test.tsx +105 -0
- package/src/markdown-editor/markdown-editor.fill.test.ts +34 -0
- package/src/markdown-editor/markdown-editor.paste-embed.test.tsx +297 -0
- package/src/markdown-editor/markdown-editor.stories.tsx +560 -0
- package/src/markdown-editor/markdown-editor.strictmode.test.tsx +38 -0
- package/src/markdown-editor/markdown-editor.table.test.tsx +287 -0
- package/src/markdown-editor/markdown-editor.tsx +560 -0
- package/src/markdown-editor/milkdown-react/editor.tsx +33 -0
- package/src/markdown-editor/milkdown-react/index.ts +8 -0
- package/src/markdown-editor/milkdown-react/types.ts +27 -0
- package/src/markdown-editor/milkdown-react/use-editor.ts +27 -0
- package/src/markdown-editor/milkdown-react/use-get-editor.ts +56 -0
- package/src/markdown-editor/milkdown-react/use-instance.ts +23 -0
- package/src/markdown-editor/paste-embed.ts +355 -0
- package/src/markdown-editor/slash/brand-slash-commands.test.ts +194 -0
- package/src/markdown-editor/slash/brand-slash-commands.ts +324 -0
- package/src/markdown-editor/slash/brand-slash-plugin.test.ts +254 -0
- package/src/markdown-editor/slash/brand-slash-plugin.ts +385 -0
- package/src/markdown-editor/slash/index.ts +107 -0
- package/src/markdown-editor/slash/insert-directive.test.ts +263 -0
- package/src/markdown-editor/slash/insert-directive.ts +345 -0
- package/src/markdown-editor/slash/monaco-slash-menu.test.tsx +198 -0
- package/src/markdown-editor/slash/monaco-slash-menu.tsx +356 -0
- package/src/markdown-editor/slash/shortcut-monaco.ts +69 -0
- package/src/markdown-editor/slash/shortcut.test.ts +157 -0
- package/src/markdown-editor/slash/shortcut.ts +57 -0
- package/src/markdown-editor/slash/slash-menu.stories.tsx +139 -0
- package/src/markdown-editor/slash/slash-menu.tsx +115 -0
- package/src/markdown-editor/slash/slash-scroll.test.tsx +52 -0
- package/src/markdown-editor/slash/slash-widget.tsx +97 -0
- package/src/markdown-editor/slash/source-slash-trigger.test.ts +42 -0
- package/src/markdown-editor/slash/source-slash-trigger.ts +46 -0
- package/src/markdown-editor/table-view.tsx +249 -0
- package/src/markdown-iteration/directive.tsx +92 -0
- package/src/markdown-iteration/edit-context.ts +56 -0
- package/src/markdown-iteration/index.ts +24 -0
- package/src/markdown-iteration/iteration-block.test.tsx +138 -0
- package/src/markdown-iteration/iteration-builder-dialog.stories.tsx +293 -0
- package/src/markdown-iteration/iteration-builder-dialog.tsx +312 -0
- package/src/markdown-iteration/iteration-builder.test.ts +252 -0
- package/src/markdown-iteration/iteration-builder.ts +303 -0
- package/src/markdown-iteration/iteration.test.ts +56 -0
- package/src/markdown-iteration/iteration.tsx +333 -0
- package/src/markdown-iteration/template-dialog.stories.tsx +63 -0
- package/src/markdown-iteration/template-dialog.test.tsx +104 -0
- package/src/markdown-iteration/template-dialog.tsx +116 -0
- package/src/markdown-outline/document-outline.stories.tsx +76 -0
- package/src/markdown-outline/document-outline.tsx +83 -0
- package/src/markdown-outline/index.ts +2 -0
- package/src/markdown-outline/markdown-outline.test.ts +49 -0
- package/src/markdown-outline/markdown-outline.ts +60 -0
- package/src/markdown-preview/code-fence.tsx +193 -0
- package/src/markdown-preview/index.ts +8 -0
- package/src/markdown-preview/markdown-preview-academic.stories.tsx +203 -0
- package/src/markdown-preview/markdown-preview-academic.test.tsx +257 -0
- package/src/markdown-preview/markdown-preview-iteration-calc.test.tsx +71 -0
- package/src/markdown-preview/markdown-preview-iteration-nested.test.tsx +58 -0
- package/src/markdown-preview/markdown-preview-iteration.stories.tsx +187 -0
- package/src/markdown-preview/markdown-preview-iteration.test.tsx +90 -0
- package/src/markdown-preview/markdown-preview-linking.test.tsx +61 -0
- package/src/markdown-preview/markdown-preview-transclusion.test.tsx +33 -0
- package/src/markdown-preview/markdown-preview.stories.tsx +108 -0
- package/src/markdown-preview/markdown-preview.test.tsx +411 -0
- package/src/markdown-preview/markdown-preview.tsx +1541 -0
- package/src/markdown-toolbar/index.ts +8 -0
- package/src/markdown-toolbar/markdown-commands.test.ts +84 -0
- package/src/markdown-toolbar/markdown-commands.ts +104 -0
- package/src/markdown-toolbar/markdown-toolbar.stories.tsx +56 -0
- package/src/markdown-toolbar/markdown-toolbar.tsx +263 -0
- package/src/markdown-workspace/focus-writing.test.ts +52 -0
- package/src/markdown-workspace/focus-writing.ts +38 -0
- package/src/markdown-workspace/index.ts +6 -0
- package/src/markdown-workspace/markdown-workspace.stories.tsx +688 -0
- package/src/markdown-workspace/markdown-workspace.test.tsx +326 -0
- package/src/markdown-workspace/markdown-workspace.tsx +787 -0
- package/src/mermaid-diagram/index.ts +1 -0
- package/src/mermaid-diagram/mermaid-diagram.stories.tsx +42 -0
- package/src/mermaid-diagram/mermaid-diagram.test.tsx +86 -0
- package/src/mermaid-diagram/mermaid-diagram.tsx +318 -0
- package/src/mermaid-diagram/mermaid-viewer.test.tsx +89 -0
- package/src/mermaid-diagram/mermaid-viewer.tsx +367 -0
- package/src/mermaid-diagram/real-parse.test.ts +42 -0
- package/src/mermaid-diagram/remediate.test.ts +60 -0
- package/src/mermaid-diagram/remediate.ts +124 -0
- package/src/mermaid-workspace/index.ts +1 -0
- package/src/mermaid-workspace/mermaid-workspace.stories.tsx +28 -0
- package/src/mermaid-workspace/mermaid-workspace.tsx +71 -0
- package/src/metric-block/index.ts +1 -0
- package/src/metric-block/metric-block.stories.tsx +40 -0
- package/src/metric-block/metric-block.tsx +14 -0
- package/src/monaco-workers.d.ts +10 -0
- package/src/prose/index.ts +14 -0
- package/src/prose/prose.stories.tsx +44 -0
- package/src/prose/prose.test.tsx +44 -0
- package/src/prose/prose.tsx +21 -0
- package/src/timeline/index.ts +18 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DocumentOutline (#L6) — the quiet table-of-contents rail for a markdown
|
|
5
|
+
* document. Pure presentation: pass `items` from `parseMarkdownOutline` /
|
|
6
|
+
* `useMarkdownOutline`, drive `activeId` from your scroll observer, and handle
|
|
7
|
+
* `onSelect` (e.g. scroll the matching `data-sourcepos` block into view).
|
|
8
|
+
*/
|
|
9
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
10
|
+
import { forwardRef, useMemo, type HTMLAttributes, type ReactNode } from "react";
|
|
11
|
+
|
|
12
|
+
import { parseMarkdownOutline, type MarkdownOutlineItem } from "./markdown-outline";
|
|
13
|
+
|
|
14
|
+
/** Memoized outline of a markdown source. */
|
|
15
|
+
export function useMarkdownOutline(markdown: string): MarkdownOutlineItem[] {
|
|
16
|
+
return useMemo(() => parseMarkdownOutline(markdown), [markdown]);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface DocumentOutlineProps extends Omit<HTMLAttributes<HTMLElement>, "onSelect"> {
|
|
20
|
+
items: MarkdownOutlineItem[];
|
|
21
|
+
/** The outline item currently in view. */
|
|
22
|
+
activeId?: string;
|
|
23
|
+
onSelect?: (item: MarkdownOutlineItem) => void;
|
|
24
|
+
/**
|
|
25
|
+
* Per-entry hover affordances (pin/copy-link…), rendered at the row end.
|
|
26
|
+
* Revealed on row hover/focus; stays visible while it contains a pressed
|
|
27
|
+
* toggle (`aria-pressed="true"`).
|
|
28
|
+
*/
|
|
29
|
+
itemActions?: (item: MarkdownOutlineItem) => ReactNode;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const DocumentOutline = forwardRef<HTMLElement, DocumentOutlineProps>(
|
|
33
|
+
function DocumentOutline({ items, activeId, onSelect, itemActions, className, ...props }, ref) {
|
|
34
|
+
const minLevel = items.reduce<number>((min, it) => Math.min(min, it.level), 6);
|
|
35
|
+
return (
|
|
36
|
+
<nav
|
|
37
|
+
ref={ref}
|
|
38
|
+
aria-label="Document outline"
|
|
39
|
+
className={cn("text-body", className)}
|
|
40
|
+
{...props}
|
|
41
|
+
>
|
|
42
|
+
{/* TOC grammar: a structural hairline rail; each entry overlays it with a
|
|
43
|
+
2px segment — primary for the active heading, visible on hover — so
|
|
44
|
+
position-in-document reads at a glance (the accent-rail channel from
|
|
45
|
+
the separation grammar, not a generic list). */}
|
|
46
|
+
<ul className="m-0 list-none border-s border-border p-0">
|
|
47
|
+
{items.map((item) => {
|
|
48
|
+
const actions = itemActions?.(item);
|
|
49
|
+
return (
|
|
50
|
+
<li key={item.id} className={cn(actions && "group/outline-item relative")}>
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
aria-current={item.id === activeId ? "true" : undefined}
|
|
54
|
+
onClick={() => onSelect?.(item)}
|
|
55
|
+
className={cn(
|
|
56
|
+
"-ms-px block w-full truncate border-s-2 py-1 pe-2 text-start text-caption",
|
|
57
|
+
"transition-colors duration-fast ease-standard motion-reduce:transition-none",
|
|
58
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
|
|
59
|
+
item.id === activeId
|
|
60
|
+
? "border-s-primary font-medium text-foreground"
|
|
61
|
+
: "border-s-transparent text-muted-foreground hover:border-s-border-strong hover:text-foreground",
|
|
62
|
+
actions && "pe-8",
|
|
63
|
+
)}
|
|
64
|
+
style={{ paddingInlineStart: `${(item.level - minLevel) * 0.875 + 0.75}rem` }}
|
|
65
|
+
>
|
|
66
|
+
{item.text}
|
|
67
|
+
</button>
|
|
68
|
+
{actions ? (
|
|
69
|
+
<span className="absolute end-0.5 top-1/2 -translate-y-1/2 opacity-0 transition-opacity duration-fast ease-standard focus-within:opacity-100 group-hover/outline-item:opacity-100 has-[[aria-pressed=true]]:opacity-100 motion-reduce:transition-none">
|
|
70
|
+
{actions}
|
|
71
|
+
</span>
|
|
72
|
+
) : null}
|
|
73
|
+
</li>
|
|
74
|
+
);
|
|
75
|
+
})}
|
|
76
|
+
</ul>
|
|
77
|
+
{items.length === 0 ? (
|
|
78
|
+
<p className="px-2 py-1 text-caption text-muted-foreground">No headings yet.</p>
|
|
79
|
+
) : null}
|
|
80
|
+
</nav>
|
|
81
|
+
);
|
|
82
|
+
},
|
|
83
|
+
);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { parseMarkdownOutline } from "./markdown-outline";
|
|
4
|
+
|
|
5
|
+
const DOC = `---
|
|
6
|
+
title: Plan
|
|
7
|
+
---
|
|
8
|
+
# Reporting migration plan
|
|
9
|
+
|
|
10
|
+
Intro text.
|
|
11
|
+
|
|
12
|
+
## Phase overview
|
|
13
|
+
|
|
14
|
+
\`\`\`md
|
|
15
|
+
# not a heading (fenced)
|
|
16
|
+
\`\`\`
|
|
17
|
+
|
|
18
|
+
### Discovery & *catalog*
|
|
19
|
+
|
|
20
|
+
## Phase overview
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
describe("parseMarkdownOutline", () => {
|
|
24
|
+
it("extracts headings with levels and stripped-source lines", () => {
|
|
25
|
+
const items = parseMarkdownOutline(DOC);
|
|
26
|
+
expect(items.map((i) => [i.level, i.text])).toEqual([
|
|
27
|
+
[1, "Reporting migration plan"],
|
|
28
|
+
[2, "Phase overview"],
|
|
29
|
+
[3, "Discovery & catalog"],
|
|
30
|
+
[2, "Phase overview"],
|
|
31
|
+
]);
|
|
32
|
+
// Line 1 of the STRIPPED body is the H1 (frontmatter removed).
|
|
33
|
+
expect(items[0]!.line).toBe(1);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("skips headings inside fenced code blocks", () => {
|
|
37
|
+
expect(parseMarkdownOutline(DOC).some((i) => i.text.includes("not a heading"))).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("dedupes repeated slugs", () => {
|
|
41
|
+
const ids = parseMarkdownOutline(DOC).map((i) => i.id);
|
|
42
|
+
expect(ids).toContain("phase-overview");
|
|
43
|
+
expect(ids).toContain("phase-overview-1");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("returns [] for heading-free documents", () => {
|
|
47
|
+
expect(parseMarkdownOutline("just text\n\nmore text")).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown outline extraction (#L6) — the pure half of `DocumentOutline`.
|
|
3
|
+
*
|
|
4
|
+
* `parseMarkdownOutline` walks ATX headings (`#` … `######`) outside fenced
|
|
5
|
+
* code blocks and returns the document outline. Line numbers are 1-based and
|
|
6
|
+
* relative to the frontmatter-STRIPPED body — the same coordinate space as the
|
|
7
|
+
* `data-sourcepos` attributes `MarkdownPreview` stamps on rendered blocks, so
|
|
8
|
+
* outline → block lookup is a direct equality on the start line.
|
|
9
|
+
*/
|
|
10
|
+
import { parseFrontmatter } from "../lib/markdown/frontmatter";
|
|
11
|
+
import { plainText, slugifyHeading, uniqueSlug } from "../lib/markdown/slugify";
|
|
12
|
+
|
|
13
|
+
export interface MarkdownOutlineItem {
|
|
14
|
+
/** Stable slug (GitHub-style, deduped with `-n` suffixes). */
|
|
15
|
+
id: string;
|
|
16
|
+
/** Plain heading text (inline markdown stripped). */
|
|
17
|
+
text: string;
|
|
18
|
+
/** Heading level 1–6. */
|
|
19
|
+
level: 1 | 2 | 3 | 4 | 5 | 6;
|
|
20
|
+
/** 1-based line in the frontmatter-stripped source (matches `data-sourcepos`). */
|
|
21
|
+
line: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const HEADING_RE = /^(#{1,6})\s+(.*?)\s*#*\s*$/;
|
|
25
|
+
const FENCE_RE = /^(```|~~~)/;
|
|
26
|
+
|
|
27
|
+
export function parseMarkdownOutline(markdown: string): MarkdownOutlineItem[] {
|
|
28
|
+
let body = markdown;
|
|
29
|
+
try {
|
|
30
|
+
body = parseFrontmatter(markdown).content;
|
|
31
|
+
} catch {
|
|
32
|
+
// Transient-invalid frontmatter while typing — outline the raw source.
|
|
33
|
+
}
|
|
34
|
+
const lines = body.split("\n");
|
|
35
|
+
const items: MarkdownOutlineItem[] = [];
|
|
36
|
+
const used = new Map<string, number>();
|
|
37
|
+
let inFence = false;
|
|
38
|
+
|
|
39
|
+
for (let i = 0; i < lines.length; i++) {
|
|
40
|
+
const line = lines[i]!;
|
|
41
|
+
if (FENCE_RE.test(line.trimStart())) {
|
|
42
|
+
inFence = !inFence;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (inFence) continue;
|
|
46
|
+
const match = HEADING_RE.exec(line);
|
|
47
|
+
if (!match) continue;
|
|
48
|
+
const text = plainText(match[2] ?? "");
|
|
49
|
+
if (!text) continue;
|
|
50
|
+
const base = slugifyHeading(text);
|
|
51
|
+
const id = uniqueSlug(base, used);
|
|
52
|
+
items.push({
|
|
53
|
+
id,
|
|
54
|
+
text,
|
|
55
|
+
level: match[1]!.length as MarkdownOutlineItem["level"],
|
|
56
|
+
line: i + 1,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return items;
|
|
60
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CodeFence — the highlighted non-mermaid code fence inside MarkdownPreview.
|
|
5
|
+
*
|
|
6
|
+
* Highlighting rides on the SAME engine the rest of the workspace already
|
|
7
|
+
* ships (`@streamdown/code`, Streamdown's shiki plugin — cached highlighters,
|
|
8
|
+
* sync-after-first-tokenize), but the theme is a shiki **CSS-variables theme**:
|
|
9
|
+
* every token color resolves to a `var(--md-code-*)` reference that this
|
|
10
|
+
* component maps onto the semantic tokens below. One theme, correct in every
|
|
11
|
+
* `data-theme` (light, dark, …) — no per-theme shiki theme, no raw
|
|
12
|
+
* colors, and a runtime theme switch recolors already-tokenized code for free.
|
|
13
|
+
*
|
|
14
|
+
* Until shiki finishes loading (or for a fence with no language tag) the raw
|
|
15
|
+
* fence text renders as before — highlighting is a progressive enhancement.
|
|
16
|
+
*/
|
|
17
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
18
|
+
import { code as codeHighlighter } from "@streamdown/code";
|
|
19
|
+
import {
|
|
20
|
+
useEffect,
|
|
21
|
+
useRef,
|
|
22
|
+
useState,
|
|
23
|
+
type CSSProperties,
|
|
24
|
+
type HTMLAttributes,
|
|
25
|
+
type ReactNode,
|
|
26
|
+
} from "react";
|
|
27
|
+
import type { BundledLanguage, ThemedToken, TokensResult } from "shiki";
|
|
28
|
+
import { createCssVariablesTheme } from "shiki";
|
|
29
|
+
|
|
30
|
+
import { CopyButton } from "../copy-button";
|
|
31
|
+
|
|
32
|
+
/** Extract the fence language from react-markdown's `language-*` className. */
|
|
33
|
+
export function fenceLanguage(className?: string): string | undefined {
|
|
34
|
+
return /\blanguage-([\w+#.-]+)\b/.exec(className ?? "")?.[1];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One shiki theme for BOTH slots (the plugin API is [light, dark]): colors are
|
|
39
|
+
* pure CSS-variable references, so the active `data-theme` decides the actual
|
|
40
|
+
* values — both slots resolve identically by construction.
|
|
41
|
+
*/
|
|
42
|
+
const cssVariablesTheme = createCssVariablesTheme({
|
|
43
|
+
name: "brand-tokens",
|
|
44
|
+
variablePrefix: "--md-code-",
|
|
45
|
+
fontStyle: true,
|
|
46
|
+
});
|
|
47
|
+
const SHIKI_THEMES: [typeof cssVariablesTheme, typeof cssVariablesTheme] = [
|
|
48
|
+
cssVariablesTheme,
|
|
49
|
+
cssVariablesTheme,
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The `--md-code-*` seams mapped onto semantic tokens (scoped to the fence, so
|
|
54
|
+
* nothing leaks into `themes.css`). Chart tokens carry the categorical hues —
|
|
55
|
+
* they are the only themed accent ramp guaranteed to exist in every theme.
|
|
56
|
+
*/
|
|
57
|
+
const SHIKI_TOKEN_VARS = cn(
|
|
58
|
+
"[--md-code-foreground:var(--foreground)]",
|
|
59
|
+
"[--md-code-background:transparent]",
|
|
60
|
+
"[--md-code-token-comment:var(--muted-foreground)]",
|
|
61
|
+
"[--md-code-token-constant:var(--chart-1)]",
|
|
62
|
+
"[--md-code-token-function:var(--chart-3)]",
|
|
63
|
+
"[--md-code-token-keyword:var(--chart-4)]",
|
|
64
|
+
"[--md-code-token-link:var(--primary)]",
|
|
65
|
+
"[--md-code-token-parameter:var(--chart-5)]",
|
|
66
|
+
"[--md-code-token-punctuation:var(--muted-foreground)]",
|
|
67
|
+
"[--md-code-token-string-expression:var(--chart-2)]",
|
|
68
|
+
"[--md-code-token-string:var(--chart-2)]",
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
// Shiki encodes font style as bitflags: 1 = italic, 2 = bold, 4 = underline.
|
|
72
|
+
const hasFontFlag = (fontStyle: number | undefined, flag: number) =>
|
|
73
|
+
((fontStyle ?? 0) & flag) === flag;
|
|
74
|
+
|
|
75
|
+
function tokenStyle(token: ThemedToken): CSSProperties {
|
|
76
|
+
return {
|
|
77
|
+
// htmlStyle.color carries the theme var; token.color is the fallback path.
|
|
78
|
+
color: (token.htmlStyle as Record<string, string> | undefined)?.color ?? token.color,
|
|
79
|
+
fontStyle: hasFontFlag(token.fontStyle, 1) ? "italic" : undefined,
|
|
80
|
+
fontWeight: hasFontFlag(token.fontStyle, 2) ? "bold" : undefined,
|
|
81
|
+
textDecoration: hasFontFlag(token.fontStyle, 4) ? "underline" : undefined,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Tokenize via the shared plugin. Returns `null` until the highlighter is
|
|
87
|
+
* ready (first render of a language) — cached fences resolve synchronously.
|
|
88
|
+
*/
|
|
89
|
+
function useHighlightedTokens(codeText: string, language: string | undefined) {
|
|
90
|
+
const [result, setResult] = useState<TokensResult | null>(null);
|
|
91
|
+
const keyRef = useRef({ codeText, language });
|
|
92
|
+
|
|
93
|
+
// Invalidate stale tokens synchronously during render (no flash of the
|
|
94
|
+
// previous fence's tokens when the source changes).
|
|
95
|
+
if (keyRef.current.codeText !== codeText || keyRef.current.language !== language) {
|
|
96
|
+
keyRef.current = { codeText, language };
|
|
97
|
+
setResult(null);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (!language) return undefined; // no language tag — keep the plain text
|
|
102
|
+
let cancelled = false;
|
|
103
|
+
const sync = codeHighlighter.highlight(
|
|
104
|
+
// Unknown languages fall back to "text" inside the plugin.
|
|
105
|
+
{ code: codeText, language: language as BundledLanguage, themes: SHIKI_THEMES },
|
|
106
|
+
(r) => {
|
|
107
|
+
if (!cancelled) setResult(r);
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
if (sync && !cancelled) setResult(sync);
|
|
111
|
+
return () => {
|
|
112
|
+
cancelled = true;
|
|
113
|
+
};
|
|
114
|
+
}, [codeText, language]);
|
|
115
|
+
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface CodeFenceProps extends HTMLAttributes<HTMLElement> {
|
|
120
|
+
/** Raw fence text (trailing newline already stripped). */
|
|
121
|
+
codeText: string;
|
|
122
|
+
/** The fence's language tag (` ```ts `), if any. */
|
|
123
|
+
language?: string;
|
|
124
|
+
/** This fence contains the active in-document search hit. */
|
|
125
|
+
searchActive?: boolean;
|
|
126
|
+
/** Fallback content (the un-highlighted fence) while shiki loads. */
|
|
127
|
+
children?: ReactNode;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function CodeFence({
|
|
131
|
+
codeText,
|
|
132
|
+
language,
|
|
133
|
+
searchActive,
|
|
134
|
+
className,
|
|
135
|
+
children,
|
|
136
|
+
...props
|
|
137
|
+
}: CodeFenceProps) {
|
|
138
|
+
const tokens = useHighlightedTokens(codeText, language)?.tokens ?? null;
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<div
|
|
142
|
+
data-code-fence={language ?? ""}
|
|
143
|
+
className={cn("group/code-fence relative my-3", className)}
|
|
144
|
+
{...props}
|
|
145
|
+
>
|
|
146
|
+
<pre
|
|
147
|
+
data-search-active={searchActive ? "" : undefined}
|
|
148
|
+
className={cn(
|
|
149
|
+
"!my-0 overflow-x-auto rounded-md p-3 font-mono text-code",
|
|
150
|
+
SHIKI_TOKEN_VARS,
|
|
151
|
+
searchActive ? "bg-primary/10" : "bg-surface-muted",
|
|
152
|
+
)}
|
|
153
|
+
>
|
|
154
|
+
{tokens ? (
|
|
155
|
+
<code>
|
|
156
|
+
{tokens.map((line, lineIdx) => (
|
|
157
|
+
// Lines are positionally stable for a given source string (the
|
|
158
|
+
// whole list is rebuilt when `codeText` changes).
|
|
159
|
+
<span key={`line-${lineIdx}`} className="block">
|
|
160
|
+
{line.length === 0
|
|
161
|
+
? "\n"
|
|
162
|
+
: line.map((token, tokenIdx) => (
|
|
163
|
+
<span key={`token-${lineIdx}-${tokenIdx}`} style={tokenStyle(token)}>
|
|
164
|
+
{token.content}
|
|
165
|
+
</span>
|
|
166
|
+
))}
|
|
167
|
+
</span>
|
|
168
|
+
))}
|
|
169
|
+
</code>
|
|
170
|
+
) : (
|
|
171
|
+
children
|
|
172
|
+
)}
|
|
173
|
+
</pre>
|
|
174
|
+
|
|
175
|
+
<div className="absolute end-2 top-2 flex items-center gap-1">
|
|
176
|
+
<CopyButton
|
|
177
|
+
value={codeText}
|
|
178
|
+
label={false}
|
|
179
|
+
size="icon-sm"
|
|
180
|
+
className="opacity-0 transition-opacity duration-fast ease-standard focus-visible:opacity-100 group-hover/code-fence:opacity-100 motion-reduce:transition-none"
|
|
181
|
+
/>
|
|
182
|
+
{language ? (
|
|
183
|
+
<span
|
|
184
|
+
aria-hidden="true"
|
|
185
|
+
className="pointer-events-none select-none rounded-sm bg-surface-muted px-1.5 py-0.5 font-mono text-meta text-muted-foreground"
|
|
186
|
+
>
|
|
187
|
+
{language}
|
|
188
|
+
</span>
|
|
189
|
+
) : null}
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// KaTeX styling — consumers must import this once at their app entry. The story
|
|
2
|
+
// imports it so the `Math` stories render correctly in Storybook.
|
|
3
|
+
import "katex/dist/katex.min.css";
|
|
4
|
+
|
|
5
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
6
|
+
import { expect, within } from "storybook/test";
|
|
7
|
+
|
|
8
|
+
import { MarkdownPreview } from "./markdown-preview";
|
|
9
|
+
import type { CitationData } from "../markdown-academic/citations";
|
|
10
|
+
|
|
11
|
+
const meta = {
|
|
12
|
+
title: "Editor/MarkdownPreview/Academic",
|
|
13
|
+
component: MarkdownPreview,
|
|
14
|
+
tags: ["autodocs"],
|
|
15
|
+
parameters: {
|
|
16
|
+
layout: "padded",
|
|
17
|
+
docs: {
|
|
18
|
+
description: {
|
|
19
|
+
component:
|
|
20
|
+
"The opt-in **academic layer** for `MarkdownPreview`: branded footnotes " +
|
|
21
|
+
"(`footnotes`), `$…$` / `$$…$$` math via KaTeX (`math`), Pandoc / Better-BibTeX " +
|
|
22
|
+
"citations + a generated bibliography (`resolveCitation`), and a generated `::toc` " +
|
|
23
|
+
"(`toc`). The BibTeX/CSL database, the math engine, and CSL formatting stay in the " +
|
|
24
|
+
"app — the library renders. Math requires the consumer to load KaTeX CSS once " +
|
|
25
|
+
'(`import "katex/dist/katex.min.css"`).',
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
} satisfies Meta<typeof MarkdownPreview>;
|
|
30
|
+
|
|
31
|
+
export default meta;
|
|
32
|
+
type Story = StoryObj<typeof meta>;
|
|
33
|
+
|
|
34
|
+
/* ------------------------------------------------------------------ */
|
|
35
|
+
/* A small in-app citation database (would be BibTeX/CSL in real apps). */
|
|
36
|
+
/* ------------------------------------------------------------------ */
|
|
37
|
+
const CITES: Record<string, CitationData> = {
|
|
38
|
+
knuth1984: {
|
|
39
|
+
author: "Knuth",
|
|
40
|
+
year: 1984,
|
|
41
|
+
title: "Literate Programming",
|
|
42
|
+
container: "The Computer Journal",
|
|
43
|
+
doi: "10.1093/comjnl/27.2.97",
|
|
44
|
+
},
|
|
45
|
+
dijkstra1968: {
|
|
46
|
+
author: "Dijkstra",
|
|
47
|
+
year: 1968,
|
|
48
|
+
title: "Go To Statement Considered Harmful",
|
|
49
|
+
container: "Communications of the ACM",
|
|
50
|
+
url: "https://dl.acm.org/doi/10.1145/362929.362947",
|
|
51
|
+
},
|
|
52
|
+
turing1936: {
|
|
53
|
+
author: "Turing",
|
|
54
|
+
year: 1936,
|
|
55
|
+
title: "On Computable Numbers",
|
|
56
|
+
container: "Proc. London Math. Soc.",
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
const resolveCitation = (key: string): CitationData | null => CITES[key] ?? null;
|
|
60
|
+
|
|
61
|
+
export const Footnotes: Story = {
|
|
62
|
+
args: {
|
|
63
|
+
footnotes: true,
|
|
64
|
+
children: `# Field notes
|
|
65
|
+
|
|
66
|
+
The estate ran on nine legacy dashboards.[^scope] Consolidation cut that to one.[^win]
|
|
67
|
+
|
|
68
|
+
Both numbers were verified against the warehouse before sign-off.[^scope]
|
|
69
|
+
|
|
70
|
+
[^scope]: Counted from the migration manifest — see the [scope sheet](https://example.com/scope).
|
|
71
|
+
[^win]: Post-launch audit, 2026-Q1.`,
|
|
72
|
+
},
|
|
73
|
+
play: async ({ canvasElement }) => {
|
|
74
|
+
const canvas = within(canvasElement);
|
|
75
|
+
await expect(
|
|
76
|
+
await canvas.findByRole("region", { name: "Footnotes" }, { timeout: 8000 }),
|
|
77
|
+
).toBeVisible();
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const Math: Story = {
|
|
82
|
+
args: {
|
|
83
|
+
math: true,
|
|
84
|
+
children: `# Cost model
|
|
85
|
+
|
|
86
|
+
The unit cost is $c = \\frac{f + v\\,n}{n}$, fixed cost $f$ amortized over $n$ runs.
|
|
87
|
+
|
|
88
|
+
Total spend over the window:
|
|
89
|
+
|
|
90
|
+
$$
|
|
91
|
+
S = \\sum_{i=1}^{N} c_i n_i = f N + v \\sum_{i=1}^{N} n_i
|
|
92
|
+
$$
|
|
93
|
+
|
|
94
|
+
So the marginal cost approaches $v$ as $n \\to \\infty$.`,
|
|
95
|
+
},
|
|
96
|
+
play: async ({ canvasElement }) => {
|
|
97
|
+
const canvas = within(canvasElement);
|
|
98
|
+
await canvas.findByRole("heading", { name: "Cost model" }, { timeout: 8000 });
|
|
99
|
+
await expect(canvasElement.querySelectorAll('[role="math"]').length).toBeGreaterThan(0);
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const CitationsNumeric: Story = {
|
|
104
|
+
name: "Citations — numeric + bibliography",
|
|
105
|
+
args: {
|
|
106
|
+
resolveCitation,
|
|
107
|
+
children: `# Related work
|
|
108
|
+
|
|
109
|
+
Literate programming [@knuth1984] reframed source as exposition, building on the
|
|
110
|
+
structured-programming turn [@dijkstra1968]. The theoretical floor was set decades
|
|
111
|
+
earlier [@turing1936, p. 230]. See also [@knuth1984; @dijkstra1968].
|
|
112
|
+
|
|
113
|
+
::bibliography`,
|
|
114
|
+
},
|
|
115
|
+
play: async ({ canvasElement }) => {
|
|
116
|
+
const canvas = within(canvasElement);
|
|
117
|
+
await expect(
|
|
118
|
+
await canvas.findByRole("region", { name: "References" }, { timeout: 8000 }),
|
|
119
|
+
).toBeVisible();
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export const CitationsAuthorYear: Story = {
|
|
124
|
+
name: "Citations — author-year",
|
|
125
|
+
args: {
|
|
126
|
+
resolveCitation,
|
|
127
|
+
citationStyle: "author-year",
|
|
128
|
+
children: `Knuth's literate programming [@knuth1984] inverts the usual order — see also
|
|
129
|
+
the earlier argument [-@dijkstra1968] for structure.
|
|
130
|
+
|
|
131
|
+
::references`,
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export const TableOfContents: Story = {
|
|
136
|
+
name: "Generated TOC",
|
|
137
|
+
args: {
|
|
138
|
+
toc: true,
|
|
139
|
+
children: `::toc
|
|
140
|
+
|
|
141
|
+
# Architecture
|
|
142
|
+
|
|
143
|
+
Overview prose.
|
|
144
|
+
|
|
145
|
+
## Data layer
|
|
146
|
+
|
|
147
|
+
How storage works.
|
|
148
|
+
|
|
149
|
+
## Service layer
|
|
150
|
+
|
|
151
|
+
How services compose.
|
|
152
|
+
|
|
153
|
+
# Operations
|
|
154
|
+
|
|
155
|
+
### Monitoring
|
|
156
|
+
|
|
157
|
+
Dashboards and alerts.`,
|
|
158
|
+
},
|
|
159
|
+
play: async ({ canvasElement }) => {
|
|
160
|
+
const canvas = within(canvasElement);
|
|
161
|
+
const nav = await canvas.findByRole("navigation", { name: "Contents" }, { timeout: 8000 });
|
|
162
|
+
await expect(within(nav).getAllByRole("link").length).toBeGreaterThan(1);
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Everything together — the shape of a short research note: a TOC, inline math,
|
|
168
|
+
* a footnote, citations, and a generated bibliography in one document.
|
|
169
|
+
*/
|
|
170
|
+
export const AcademicPaper: Story = {
|
|
171
|
+
args: {
|
|
172
|
+
footnotes: true,
|
|
173
|
+
math: true,
|
|
174
|
+
toc: true,
|
|
175
|
+
resolveCitation,
|
|
176
|
+
children: `::toc
|
|
177
|
+
|
|
178
|
+
# Amortizing fixed cost
|
|
179
|
+
|
|
180
|
+
We model per-run cost as $c = \\frac{f + v n}{n}$, following the structured-cost
|
|
181
|
+
argument [@dijkstra1968].[^model] As $n \\to \\infty$, $c \\to v$.
|
|
182
|
+
|
|
183
|
+
$$
|
|
184
|
+
S = f N + v \\sum_{i=1}^{N} n_i
|
|
185
|
+
$$
|
|
186
|
+
|
|
187
|
+
## Prior art
|
|
188
|
+
|
|
189
|
+
Literate exposition of such models traces to Knuth [@knuth1984].
|
|
190
|
+
|
|
191
|
+
::bibliography
|
|
192
|
+
|
|
193
|
+
[^model]: Fixed cost $f$ is treated as constant across the window.`,
|
|
194
|
+
},
|
|
195
|
+
play: async ({ canvasElement }) => {
|
|
196
|
+
const canvas = within(canvasElement);
|
|
197
|
+
await expect(
|
|
198
|
+
await canvas.findByRole("navigation", { name: "Contents" }, { timeout: 8000 }),
|
|
199
|
+
).toBeVisible();
|
|
200
|
+
await expect(canvas.getByRole("region", { name: "References" })).toBeVisible();
|
|
201
|
+
await expect(canvas.getByRole("region", { name: "Footnotes" })).toBeVisible();
|
|
202
|
+
},
|
|
203
|
+
};
|