@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 @@
|
|
|
1
|
+
export { MermaidDiagram, type MermaidDiagramProps } from "./mermaid-diagram";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
|
|
3
|
+
import { MermaidDiagram } from "./mermaid-diagram";
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: "Editor/MermaidDiagram",
|
|
7
|
+
component: MermaidDiagram,
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
parameters: { layout: "padded" },
|
|
10
|
+
} satisfies Meta<typeof MermaidDiagram>;
|
|
11
|
+
|
|
12
|
+
export default meta;
|
|
13
|
+
type Story = StoryObj<typeof meta>;
|
|
14
|
+
|
|
15
|
+
export const Default: Story = {
|
|
16
|
+
args: {
|
|
17
|
+
label: "Order flow",
|
|
18
|
+
chart: `graph TD
|
|
19
|
+
A[Browse repos] --> B{Markdown file?}
|
|
20
|
+
B -- yes --> C[Open in reader]
|
|
21
|
+
B -- no --> D[Open in code editor]
|
|
22
|
+
C --> E[Edit + commit]`,
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const Sequence: Story = {
|
|
27
|
+
args: {
|
|
28
|
+
label: "Commit sequence",
|
|
29
|
+
chart: `sequenceDiagram
|
|
30
|
+
participant U as User
|
|
31
|
+
participant W as Workbench
|
|
32
|
+
participant G as GitHub
|
|
33
|
+
U->>W: Save (⌘S)
|
|
34
|
+
W->>G: PUT /contents (base sha)
|
|
35
|
+
G-->>W: 200 new sha
|
|
36
|
+
W-->>U: Committed`,
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const InvalidSource: Story = {
|
|
41
|
+
args: { chart: "graph TD; A--> ::nope::" },
|
|
42
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { render, screen, waitFor, within } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const renderMock = vi.fn(async (_id: string, chart: string) => {
|
|
5
|
+
if (chart.includes("boom")) throw new Error("Parse error on line 1");
|
|
6
|
+
return {
|
|
7
|
+
svg: `<svg data-chart="ok"><g class="node"><rect></rect><text>Microsoft Graph API</text></g><g class="node"><rect></rect><text>Worker</text></g></svg>`,
|
|
8
|
+
};
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
vi.mock("mermaid", () => ({
|
|
12
|
+
default: { initialize: vi.fn(), render: renderMock },
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
import { MermaidDiagram } from "./mermaid-diagram";
|
|
16
|
+
|
|
17
|
+
describe("MermaidDiagram", () => {
|
|
18
|
+
it("renders the mermaid SVG with an accessible name", async () => {
|
|
19
|
+
render(<MermaidDiagram chart={"graph TD; A-->B"} label="Flow" />);
|
|
20
|
+
await waitFor(() => expect(screen.getByRole("img", { name: "Flow" })).toBeInTheDocument());
|
|
21
|
+
expect(renderMock).toHaveBeenCalled();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("renders an inline error block (not a throw) for invalid source", async () => {
|
|
25
|
+
render(<MermaidDiagram chart={"graph TD; boom"} />);
|
|
26
|
+
await waitFor(() => expect(screen.getByRole("alert")).toBeInTheDocument());
|
|
27
|
+
expect(screen.getByText(/Parse error/)).toBeInTheDocument();
|
|
28
|
+
expect(screen.getByText(/graph TD; boom/)).toBeInTheDocument();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("shows a loading placeholder before the engine resolves", () => {
|
|
32
|
+
render(<MermaidDiagram chart={"graph TD; A-->B"} />);
|
|
33
|
+
expect(screen.getByRole("status")).toBeInTheDocument();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("MermaidDiagram — search highlighting", () => {
|
|
38
|
+
it("marks matching nodes as hits and the active line's node as active", async () => {
|
|
39
|
+
const { container, rerender } = render(
|
|
40
|
+
<MermaidDiagram chart={"graph TD; A-->B"} highlightTerm="graph api" />,
|
|
41
|
+
);
|
|
42
|
+
await waitFor(() => expect(container.querySelector("g.node")).not.toBeNull());
|
|
43
|
+
const nodes = container.querySelectorAll("g.node");
|
|
44
|
+
expect(nodes[0]).toHaveClass("wb-dg-hit");
|
|
45
|
+
expect(nodes[0]).not.toHaveClass("wb-dg-hit-active");
|
|
46
|
+
expect(nodes[1]).not.toHaveClass("wb-dg-hit");
|
|
47
|
+
|
|
48
|
+
rerender(
|
|
49
|
+
<MermaidDiagram
|
|
50
|
+
chart={"graph TD; A-->B"}
|
|
51
|
+
highlightTerm="graph api"
|
|
52
|
+
activeText=" a[Microsoft Graph API]"
|
|
53
|
+
/>,
|
|
54
|
+
);
|
|
55
|
+
expect(container.querySelectorAll("g.node")[0]).toHaveClass("wb-dg-hit-active");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("clears hit classes when the term is removed", async () => {
|
|
59
|
+
const { container, rerender } = render(
|
|
60
|
+
<MermaidDiagram chart={"graph TD; A-->B"} highlightTerm="worker" />,
|
|
61
|
+
);
|
|
62
|
+
await waitFor(() => expect(container.querySelectorAll("g.node")[1]).toHaveClass("wb-dg-hit"));
|
|
63
|
+
rerender(<MermaidDiagram chart={"graph TD; A-->B"} />);
|
|
64
|
+
expect(container.querySelectorAll("g.node")[1]).not.toHaveClass("wb-dg-hit");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("MermaidDiagram — viewer chrome", () => {
|
|
69
|
+
it("expands into a large modal", async () => {
|
|
70
|
+
const { default: userEvent } = await import("@testing-library/user-event");
|
|
71
|
+
render(<MermaidDiagram chart={"graph TD; A-->B"} label="Flow" />);
|
|
72
|
+
await waitFor(() => expect(screen.getByRole("img", { name: "Flow" })).toBeInTheDocument());
|
|
73
|
+
await userEvent.click(screen.getByRole("button", { name: "Expand diagram" }));
|
|
74
|
+
const dialog = await screen.findByRole("dialog");
|
|
75
|
+
// The modal carries its own large rendering (the page behind is aria-hidden).
|
|
76
|
+
expect(within(dialog).getByRole("img", { name: "Flow" })).toBeInTheDocument();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("offers an SVG download next to copy", async () => {
|
|
80
|
+
render(<MermaidDiagram chart={"graph TD; A-->B"} />);
|
|
81
|
+
await waitFor(() =>
|
|
82
|
+
expect(screen.getByRole("button", { name: "Download diagram as SVG" })).toBeInTheDocument(),
|
|
83
|
+
);
|
|
84
|
+
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MermaidDiagram — the branded Mermaid renderer (#L1).
|
|
5
|
+
*
|
|
6
|
+
* Renders a ```mermaid source string as an SVG diagram, themed from the active
|
|
7
|
+
* semantic tokens (no raw colors here): the mermaid engine is initialized with
|
|
8
|
+
* `theme: "base"` + `themeVariables` resolved at render time from the CSS
|
|
9
|
+
* variables in scope, so the diagram follows every `data-theme` — including
|
|
10
|
+
* runtime switches (a MutationObserver re-renders on theme change).
|
|
11
|
+
*
|
|
12
|
+
* The `mermaid` package is loaded lazily on first render, so consumers that
|
|
13
|
+
* never show a diagram never download the engine. Invalid sources render an
|
|
14
|
+
* inline error block (message + source), never a thrown render.
|
|
15
|
+
*/
|
|
16
|
+
import { Button, Dialog, DialogContent, DialogTitle } from "@elabs-ai/components-ui";
|
|
17
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
18
|
+
import { Download, Maximize2 } from "lucide-react";
|
|
19
|
+
import { forwardRef, useEffect, useRef, useState, type HTMLAttributes } from "react";
|
|
20
|
+
|
|
21
|
+
import { oklchToHex } from "@elabs-ai/components-tokens";
|
|
22
|
+
|
|
23
|
+
import { CopyButton } from "../copy-button";
|
|
24
|
+
import { MermaidViewer } from "./mermaid-viewer";
|
|
25
|
+
import { offendingToken, remediateReservedIds } from "./remediate";
|
|
26
|
+
|
|
27
|
+
export interface MermaidDiagramProps extends HTMLAttributes<HTMLDivElement> {
|
|
28
|
+
/** Mermaid source (the fence body). */
|
|
29
|
+
chart: string;
|
|
30
|
+
/** Accessible name for the rendered diagram. Default "Diagram". */
|
|
31
|
+
label?: string;
|
|
32
|
+
/** Show the hover copy-source button. Default true. */
|
|
33
|
+
copyable?: boolean;
|
|
34
|
+
/** Show the hover expand-to-modal button. Default true. */
|
|
35
|
+
expandable?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* In-document search term (≥2 chars): nodes/edge labels whose text contains
|
|
38
|
+
* it get the hit stroke — the diagram participates in document search.
|
|
39
|
+
*/
|
|
40
|
+
highlightTerm?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Source text of the ACTIVE search hit (the clicked finding's line). The
|
|
43
|
+
* matching hit is promoted to the primary "active" stroke.
|
|
44
|
+
*/
|
|
45
|
+
activeText?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Semantic token → mermaid `themeVariables` mapping (resolved per render). */
|
|
49
|
+
const TOKEN_VARS: Record<string, string> = {
|
|
50
|
+
background: "--background",
|
|
51
|
+
mainBkg: "--card",
|
|
52
|
+
primaryColor: "--muted",
|
|
53
|
+
primaryTextColor: "--foreground",
|
|
54
|
+
primaryBorderColor: "--border-strong",
|
|
55
|
+
secondaryColor: "--secondary",
|
|
56
|
+
tertiaryColor: "--muted",
|
|
57
|
+
lineColor: "--muted-foreground",
|
|
58
|
+
textColor: "--foreground",
|
|
59
|
+
nodeBorder: "--border-strong",
|
|
60
|
+
clusterBkg: "--surface-muted",
|
|
61
|
+
clusterBorder: "--border",
|
|
62
|
+
titleColor: "--foreground",
|
|
63
|
+
edgeLabelBackground: "--background",
|
|
64
|
+
errorBkgColor: "--destructive",
|
|
65
|
+
errorTextColor: "--destructive-foreground",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
let renderSeq = 0;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Search-hit strokes for rendered nodes — token-driven, shared by the inline
|
|
72
|
+
* render and the expanded viewer (a `<style>` is document-global wherever it
|
|
73
|
+
* mounts, so one copy per diagram instance is enough).
|
|
74
|
+
*/
|
|
75
|
+
const HIT_CSS = `
|
|
76
|
+
.wb-dg-hit :is(rect, polygon, circle, ellipse, path.basic) { stroke: var(--warning) !important; stroke-width: 2.5px !important; }
|
|
77
|
+
.wb-dg-hit-active :is(rect, polygon, circle, ellipse, path.basic) { stroke: var(--primary) !important; stroke-width: 3px !important; }
|
|
78
|
+
.wb-dg-hit.edgeLabel { outline: 2px solid var(--warning); border-radius: 2px; }
|
|
79
|
+
.wb-dg-hit-active.edgeLabel { outline: 2px solid var(--primary); border-radius: 2px; }
|
|
80
|
+
`;
|
|
81
|
+
|
|
82
|
+
/** Does mermaid's color lib (khroma) understand this format already? */
|
|
83
|
+
const KHROMA_SAFE_RE = /^(#|rgba?\(|hsla?\()/i;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Normalize any CSS color (incl. oklch tokens) to a hex/rgb string the mermaid
|
|
87
|
+
* engine (khroma) can manipulate. oklch converts mathematically (browsers do
|
|
88
|
+
* NOT re-serialize it to rgb — Chromium's canvas keeps the oklch string);
|
|
89
|
+
* anything else unknown falls back to canvas serialization, then raw.
|
|
90
|
+
*/
|
|
91
|
+
function normalizeColor(value: string, ctx: CanvasRenderingContext2D | null): string {
|
|
92
|
+
const v = value.trim();
|
|
93
|
+
if (!v || KHROMA_SAFE_RE.test(v)) return v;
|
|
94
|
+
const fromOklch = oklchToHex(v);
|
|
95
|
+
if (fromOklch) return fromOklch;
|
|
96
|
+
if (ctx) {
|
|
97
|
+
try {
|
|
98
|
+
ctx.fillStyle = "#000";
|
|
99
|
+
ctx.fillStyle = v;
|
|
100
|
+
if (KHROMA_SAFE_RE.test(ctx.fillStyle)) return ctx.fillStyle;
|
|
101
|
+
} catch {
|
|
102
|
+
// fall through to raw
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return v;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function resolveThemeVariables(el: HTMLElement): Record<string, string> {
|
|
109
|
+
const styles = getComputedStyle(el);
|
|
110
|
+
let ctx: CanvasRenderingContext2D | null = null;
|
|
111
|
+
try {
|
|
112
|
+
ctx = document.createElement("canvas").getContext("2d");
|
|
113
|
+
} catch {
|
|
114
|
+
ctx = null;
|
|
115
|
+
}
|
|
116
|
+
const vars: Record<string, string> = {
|
|
117
|
+
fontFamily: styles.getPropertyValue("--font-sans").trim() || "inherit",
|
|
118
|
+
};
|
|
119
|
+
for (const [mermaidVar, token] of Object.entries(TOKEN_VARS)) {
|
|
120
|
+
const raw = styles.getPropertyValue(token).trim();
|
|
121
|
+
if (raw) vars[mermaidVar] = normalizeColor(raw, ctx);
|
|
122
|
+
}
|
|
123
|
+
return vars;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export const MermaidDiagram = forwardRef<HTMLDivElement, MermaidDiagramProps>(
|
|
127
|
+
function MermaidDiagram(
|
|
128
|
+
{
|
|
129
|
+
chart,
|
|
130
|
+
label = "Diagram",
|
|
131
|
+
copyable = true,
|
|
132
|
+
expandable = true,
|
|
133
|
+
highlightTerm,
|
|
134
|
+
activeText,
|
|
135
|
+
className,
|
|
136
|
+
...props
|
|
137
|
+
},
|
|
138
|
+
ref,
|
|
139
|
+
) {
|
|
140
|
+
const hostRef = useRef<HTMLDivElement | null>(null);
|
|
141
|
+
const svgHostRef = useRef<HTMLDivElement | null>(null);
|
|
142
|
+
const [svg, setSvg] = useState<string | null>(null);
|
|
143
|
+
const [error, setError] = useState<string | null>(null);
|
|
144
|
+
const [expanded, setExpanded] = useState(false);
|
|
145
|
+
|
|
146
|
+
const downloadSvg = () => {
|
|
147
|
+
if (!svg) return;
|
|
148
|
+
const blob = new Blob([svg], { type: "image/svg+xml" });
|
|
149
|
+
const url = URL.createObjectURL(blob);
|
|
150
|
+
const a = document.createElement("a");
|
|
151
|
+
a.href = url;
|
|
152
|
+
a.download = `${label.toLowerCase().replace(/[^a-z0-9]+/g, "-") || "diagram"}.svg`;
|
|
153
|
+
a.click();
|
|
154
|
+
URL.revokeObjectURL(url);
|
|
155
|
+
};
|
|
156
|
+
// Bumped by the observer when the governing data-theme changes.
|
|
157
|
+
const [themeVersion, setThemeVersion] = useState(0);
|
|
158
|
+
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
const host = hostRef.current;
|
|
161
|
+
if (!host) return;
|
|
162
|
+
const scope = host.closest("[data-theme]") ?? document.documentElement;
|
|
163
|
+
const observer = new MutationObserver(() => setThemeVersion((v) => v + 1));
|
|
164
|
+
observer.observe(scope, { attributes: true, attributeFilter: ["data-theme"] });
|
|
165
|
+
return () => observer.disconnect();
|
|
166
|
+
}, []);
|
|
167
|
+
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
let cancelled = false;
|
|
170
|
+
const host = hostRef.current;
|
|
171
|
+
if (!host || !chart.trim()) {
|
|
172
|
+
setSvg(null);
|
|
173
|
+
setError(null);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
(async () => {
|
|
177
|
+
try {
|
|
178
|
+
const mermaid = (await import("mermaid")).default;
|
|
179
|
+
if (cancelled) return;
|
|
180
|
+
mermaid.initialize({
|
|
181
|
+
startOnLoad: false,
|
|
182
|
+
securityLevel: "strict",
|
|
183
|
+
suppressErrorRendering: true,
|
|
184
|
+
theme: "base",
|
|
185
|
+
themeVariables: resolveThemeVariables(host),
|
|
186
|
+
});
|
|
187
|
+
const render = (source: string) => mermaid.render(`brand-mermaid-${++renderSeq}`, source);
|
|
188
|
+
let out;
|
|
189
|
+
try {
|
|
190
|
+
out = await render(chart);
|
|
191
|
+
} catch (firstErr) {
|
|
192
|
+
// Reserved-keyword node ids ("graph[...]", "end[...]") are the
|
|
193
|
+
// most common authoring mistake — remediate the id (labels stay)
|
|
194
|
+
// and retry once instead of failing the reader.
|
|
195
|
+
const token = offendingToken(
|
|
196
|
+
firstErr instanceof Error ? firstErr.message : String(firstErr),
|
|
197
|
+
);
|
|
198
|
+
const fixed = token ? remediateReservedIds(chart, token) : null;
|
|
199
|
+
if (!fixed) throw firstErr;
|
|
200
|
+
out = await render(fixed);
|
|
201
|
+
}
|
|
202
|
+
if (cancelled) return;
|
|
203
|
+
setSvg(out.svg);
|
|
204
|
+
setError(null);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
if (cancelled) return;
|
|
207
|
+
setSvg(null);
|
|
208
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
209
|
+
}
|
|
210
|
+
})();
|
|
211
|
+
return () => {
|
|
212
|
+
cancelled = true;
|
|
213
|
+
};
|
|
214
|
+
}, [chart, themeVersion]);
|
|
215
|
+
|
|
216
|
+
// Mark search hits on the rendered nodes (class toggles only — the SVG
|
|
217
|
+
// markup is mermaid's; we never rebuild it for a highlight change).
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
const root = svgHostRef.current;
|
|
220
|
+
if (!root) return;
|
|
221
|
+
const term = (highlightTerm ?? "").trim().toLowerCase();
|
|
222
|
+
const active = (activeText ?? "").trim().toLowerCase();
|
|
223
|
+
for (const node of root.querySelectorAll<SVGGElement>("g.node, g.edgeLabel")) {
|
|
224
|
+
const text = (node.textContent ?? "").trim().toLowerCase();
|
|
225
|
+
const hit = term.length >= 2 && text.length > 0 && text.includes(term);
|
|
226
|
+
node.classList.toggle("wb-dg-hit", hit);
|
|
227
|
+
node.classList.toggle(
|
|
228
|
+
"wb-dg-hit-active",
|
|
229
|
+
hit && active.length > 0 && (active.includes(text) || text.includes(active)),
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
}, [svg, highlightTerm, activeText]);
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<div
|
|
236
|
+
ref={(el) => {
|
|
237
|
+
hostRef.current = el;
|
|
238
|
+
if (typeof ref === "function") ref(el);
|
|
239
|
+
else if (ref) ref.current = el;
|
|
240
|
+
}}
|
|
241
|
+
data-testid="mermaid-diagram"
|
|
242
|
+
className={cn("group/mermaid relative", className)}
|
|
243
|
+
{...props}
|
|
244
|
+
>
|
|
245
|
+
{svg && (copyable || expandable) ? (
|
|
246
|
+
<div className="absolute end-2 top-2 z-10 flex gap-1 opacity-0 transition-opacity duration-fast ease-standard focus-within:opacity-100 group-hover/mermaid:opacity-100 motion-reduce:transition-none">
|
|
247
|
+
{expandable ? (
|
|
248
|
+
<Button
|
|
249
|
+
variant="outline"
|
|
250
|
+
size="icon-sm"
|
|
251
|
+
aria-label="Expand diagram"
|
|
252
|
+
onClick={() => setExpanded(true)}
|
|
253
|
+
>
|
|
254
|
+
<Maximize2 className="size-3.5" />
|
|
255
|
+
</Button>
|
|
256
|
+
) : null}
|
|
257
|
+
<Button
|
|
258
|
+
variant="outline"
|
|
259
|
+
size="icon-sm"
|
|
260
|
+
aria-label="Download diagram as SVG"
|
|
261
|
+
onClick={downloadSvg}
|
|
262
|
+
>
|
|
263
|
+
<Download className="size-3.5" />
|
|
264
|
+
</Button>
|
|
265
|
+
{copyable ? (
|
|
266
|
+
<CopyButton
|
|
267
|
+
value={chart}
|
|
268
|
+
label={false}
|
|
269
|
+
aria-label="Copy diagram source"
|
|
270
|
+
size="icon-sm"
|
|
271
|
+
/>
|
|
272
|
+
) : null}
|
|
273
|
+
</div>
|
|
274
|
+
) : null}
|
|
275
|
+
{error ? (
|
|
276
|
+
<div
|
|
277
|
+
role="alert"
|
|
278
|
+
className="space-y-2 border-s-2 border-s-destructive bg-destructive/10 p-3 text-body"
|
|
279
|
+
>
|
|
280
|
+
<p className="font-medium text-destructive-text">Diagram failed to render</p>
|
|
281
|
+
<p className="text-muted-foreground">{error}</p>
|
|
282
|
+
<pre className="overflow-x-auto rounded bg-muted p-2 font-mono text-code text-foreground">
|
|
283
|
+
{chart}
|
|
284
|
+
</pre>
|
|
285
|
+
</div>
|
|
286
|
+
) : svg ? (
|
|
287
|
+
<>
|
|
288
|
+
<style>{HIT_CSS}</style>
|
|
289
|
+
<div
|
|
290
|
+
ref={svgHostRef}
|
|
291
|
+
role="img"
|
|
292
|
+
aria-label={label}
|
|
293
|
+
className="overflow-x-auto rounded-md bg-card p-3 [&_svg]:mx-auto [&_svg]:h-auto [&_svg]:max-w-full"
|
|
294
|
+
// Mermaid output; securityLevel "strict" sanitizes the source.
|
|
295
|
+
dangerouslySetInnerHTML={{ __html: svg }}
|
|
296
|
+
/>
|
|
297
|
+
</>
|
|
298
|
+
) : (
|
|
299
|
+
<div
|
|
300
|
+
role="status"
|
|
301
|
+
aria-label="Rendering diagram…"
|
|
302
|
+
className="h-24 animate-pulse rounded-md bg-surface-muted motion-reduce:animate-none"
|
|
303
|
+
/>
|
|
304
|
+
)}
|
|
305
|
+
|
|
306
|
+
{expandable ? (
|
|
307
|
+
<Dialog open={expanded} onOpenChange={setExpanded}>
|
|
308
|
+
<DialogContent className="flex h-[88dvh] w-[92vw] max-w-[92vw] flex-col p-4 sm:max-w-[92vw]">
|
|
309
|
+
<DialogTitle className="sr-only">{label}</DialogTitle>
|
|
310
|
+
{/* Hit strokes ship via the shared HIT_CSS <style> above. */}
|
|
311
|
+
{svg ? <MermaidViewer svg={svg} label={label} /> : null}
|
|
312
|
+
</DialogContent>
|
|
313
|
+
</Dialog>
|
|
314
|
+
) : null}
|
|
315
|
+
</div>
|
|
316
|
+
);
|
|
317
|
+
},
|
|
318
|
+
);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { diagramNodeLabel, fitTransform, MermaidViewer } from "./mermaid-viewer";
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
describe("fitTransform", () => {
|
|
9
|
+
it("returns null while the container is unmeasured (pre-layout dialog)", () => {
|
|
10
|
+
expect(fitTransform({ width: 0, height: 0 }, { width: 800, height: 600 })).toBeNull();
|
|
11
|
+
expect(fitTransform({ width: 10, height: 10 }, { width: 800, height: 600 })).toBeNull();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("returns null for an unmeasured diagram", () => {
|
|
15
|
+
expect(fitTransform({ width: 1000, height: 800 }, { width: 0, height: 0 })).toBeNull();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("NEVER upscales: a small diagram fits at 100%, centered", () => {
|
|
19
|
+
const t = fitTransform({ width: 1024, height: 624 }, { width: 500, height: 300 });
|
|
20
|
+
expect(t).not.toBeNull();
|
|
21
|
+
// Contain-fit would be 2× — fit means "all visible", not "blown up".
|
|
22
|
+
expect(t!.scale).toBe(1);
|
|
23
|
+
expect(t!.tx).toBe((1024 - 500) / 2);
|
|
24
|
+
expect(t!.ty).toBe((624 - 300) / 2);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("downscales a large diagram to the contain-fit scale", () => {
|
|
28
|
+
const t = fitTransform({ width: 1024, height: 624 }, { width: 2000, height: 600 });
|
|
29
|
+
expect(t).not.toBeNull();
|
|
30
|
+
// Limiting axis: width → (1024-24)/2000 = 0.5.
|
|
31
|
+
expect(t!.scale).toBe(0.5);
|
|
32
|
+
expect(t!.tx).toBe((1024 - 2000 * 0.5) / 2);
|
|
33
|
+
expect(t!.ty).toBe((624 - 600 * 0.5) / 2);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("clamps the scale into the zoom range", () => {
|
|
37
|
+
const t = fitTransform({ width: 100, height: 100 }, { width: 10_000, height: 10_000 });
|
|
38
|
+
expect(t!.scale).toBe(0.2);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("diagramNodeLabel", () => {
|
|
43
|
+
const parse = (markup: string): Element => {
|
|
44
|
+
const host = document.createElement("div");
|
|
45
|
+
host.innerHTML = markup;
|
|
46
|
+
return host.firstElementChild!;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
it("joins htmlLabels paragraph lines with a separator", () => {
|
|
50
|
+
const node = parse(
|
|
51
|
+
`<g class="node" id="a"><foreignObject><div><span class="nodeLabel"><p>Microsoft Graph API</p><p>Outlook / Calendar</p></span></div></foreignObject></g>`,
|
|
52
|
+
);
|
|
53
|
+
expect(diagramNodeLabel(node)).toBe("Microsoft Graph API · Outlook / Calendar");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("joins tspan lines with a separator", () => {
|
|
57
|
+
const node = parse(
|
|
58
|
+
`<g class="node" id="b"><text><tspan>Line one</tspan><tspan>Line two</tspan></text></g>`,
|
|
59
|
+
);
|
|
60
|
+
expect(diagramNodeLabel(node)).toBe("Line one · Line two");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("falls back to textContent when there are no segment elements", () => {
|
|
64
|
+
const node = parse(`<g class="node" id="c"><text>Plain label</text></g>`);
|
|
65
|
+
expect(diagramNodeLabel(node)).toBe("Plain label");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("MermaidViewer — node finder", () => {
|
|
70
|
+
const SVG = `<svg viewBox="0 0 100 100"><g class="node" id="node-a"><foreignObject><div><span class="nodeLabel"><p>Microsoft Graph API</p><p>Outlook / Calendar</p></span></div></foreignObject></g></svg>`;
|
|
71
|
+
|
|
72
|
+
it("lists nodes with separated label lines and persists the active highlight", async () => {
|
|
73
|
+
const { container } = render(<MermaidViewer svg={SVG} label="diagram" />);
|
|
74
|
+
const item = await screen.findByRole("button", {
|
|
75
|
+
name: "Microsoft Graph API · Outlook / Calendar",
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
fireEvent.click(item);
|
|
79
|
+
|
|
80
|
+
const node = container.querySelector("#node-a")!;
|
|
81
|
+
await waitFor(() => expect(node.classList.contains("wb-dg-hit-active")).toBe(true));
|
|
82
|
+
|
|
83
|
+
// The active mark persists even when a query no longer matches the node.
|
|
84
|
+
fireEvent.change(screen.getByRole("searchbox", { name: "Find in diagram" }), {
|
|
85
|
+
target: { value: "zz-no-match" },
|
|
86
|
+
});
|
|
87
|
+
await waitFor(() => expect(node.classList.contains("wb-dg-hit-active")).toBe(true));
|
|
88
|
+
});
|
|
89
|
+
});
|