@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,367 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MermaidViewer — the expanded diagram surface: wheel-zoom at the cursor,
|
|
5
|
+
* drag-pan, fit/100% controls, and a left search panel that filters the
|
|
6
|
+
* RENDERED nodes, highlights every hit and zooms to the selected one.
|
|
7
|
+
*/
|
|
8
|
+
import { Button, Input } from "@elabs-ai/components-ui";
|
|
9
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
10
|
+
import { Maximize, Minus, Plus } from "lucide-react";
|
|
11
|
+
import {
|
|
12
|
+
useCallback,
|
|
13
|
+
useEffect,
|
|
14
|
+
useMemo,
|
|
15
|
+
useRef,
|
|
16
|
+
useState,
|
|
17
|
+
type PointerEvent as ReactPointerEvent,
|
|
18
|
+
type WheelEvent as ReactWheelEvent,
|
|
19
|
+
} from "react";
|
|
20
|
+
|
|
21
|
+
interface DiagramHit {
|
|
22
|
+
id: string;
|
|
23
|
+
label: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface Transform {
|
|
27
|
+
scale: number;
|
|
28
|
+
tx: number;
|
|
29
|
+
ty: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const MIN_SCALE = 0.2;
|
|
33
|
+
const MAX_SCALE = 6;
|
|
34
|
+
const HIT_CLASS = "wb-dg-hit";
|
|
35
|
+
const HIT_ACTIVE_CLASS = "wb-dg-hit-active";
|
|
36
|
+
|
|
37
|
+
const clampScale = (s: number) => Math.min(MAX_SCALE, Math.max(MIN_SCALE, s));
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Fit-to-viewport transform (centered), or `null` while either box is
|
|
41
|
+
* unmeasured. Guarding here is what fixes the "opens at MIN_SCALE pinned
|
|
42
|
+
* top-left" defect: the dialog mounts BEFORE layout, so a 0-sized container
|
|
43
|
+
* used to produce a negative → clamped-to-minimum scale.
|
|
44
|
+
*/
|
|
45
|
+
export function fitTransform(
|
|
46
|
+
container: { width: number; height: number },
|
|
47
|
+
natural: { width: number; height: number },
|
|
48
|
+
pad = 24,
|
|
49
|
+
): Transform | null {
|
|
50
|
+
if (container.width <= pad || container.height <= pad) return null;
|
|
51
|
+
if (!natural.width || !natural.height) return null;
|
|
52
|
+
// Fit means "make it all visible", never "blow it up": small diagrams stay
|
|
53
|
+
// at natural size (100%), centered — upscaling reads as a broken zoom.
|
|
54
|
+
const scale = clampScale(
|
|
55
|
+
Math.min(1, (container.width - pad) / natural.width, (container.height - pad) / natural.height),
|
|
56
|
+
);
|
|
57
|
+
return {
|
|
58
|
+
scale,
|
|
59
|
+
tx: (container.width - natural.width * scale) / 2,
|
|
60
|
+
ty: (container.height - natural.height * scale) / 2,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Human-readable label for a rendered diagram node. Mermaid renders multi-line
|
|
66
|
+
* labels as sibling text containers (`tspan` lines, or `p`/`span` inside the
|
|
67
|
+
* htmlLabels foreignObject); raw `textContent` concatenates them WITHOUT
|
|
68
|
+
* separators ("Microsoft Graph APIOutlook / C…"). Join the leaf segments with
|
|
69
|
+
* a "·" instead.
|
|
70
|
+
*/
|
|
71
|
+
export function diagramNodeLabel(node: Element): string {
|
|
72
|
+
const candidates = Array.from(node.querySelectorAll("tspan, p, span"));
|
|
73
|
+
const leaves = candidates.filter((el) => !el.querySelector("tspan, p, span"));
|
|
74
|
+
const parts = leaves
|
|
75
|
+
.map((el) => (el.textContent ?? "").trim().replace(/\s+/g, " "))
|
|
76
|
+
.filter(Boolean);
|
|
77
|
+
if (parts.length === 0) return (node.textContent ?? "").trim().replace(/\s+/g, " ");
|
|
78
|
+
return parts.join(" · ");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function MermaidViewer({ svg, label }: { svg: string; label: string }) {
|
|
82
|
+
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
83
|
+
const stageRef = useRef<HTMLDivElement | null>(null);
|
|
84
|
+
const [transform, setTransform] = useState<Transform>({ scale: 1, tx: 0, ty: 0 });
|
|
85
|
+
const [hits, setHits] = useState<DiagramHit[]>([]);
|
|
86
|
+
const [query, setQuery] = useState("");
|
|
87
|
+
const [activeHit, setActiveHit] = useState<string | null>(null);
|
|
88
|
+
const dragRef = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null);
|
|
89
|
+
/** The user took over (zoom/pan) — stop auto-fitting on container resize. */
|
|
90
|
+
const userDrivenRef = useRef(false);
|
|
91
|
+
|
|
92
|
+
/** Natural (untransformed) svg size in px. */
|
|
93
|
+
const naturalSize = useRef<{ w: number; h: number }>({ w: 0, h: 0 });
|
|
94
|
+
|
|
95
|
+
const fit = useCallback((): boolean => {
|
|
96
|
+
const container = containerRef.current;
|
|
97
|
+
const { w, h } = naturalSize.current;
|
|
98
|
+
if (!container) return false;
|
|
99
|
+
const next = fitTransform(
|
|
100
|
+
{ width: container.clientWidth, height: container.clientHeight },
|
|
101
|
+
{ width: w, height: h },
|
|
102
|
+
);
|
|
103
|
+
if (next) setTransform(next);
|
|
104
|
+
return next !== null;
|
|
105
|
+
}, []);
|
|
106
|
+
|
|
107
|
+
// Mount: size the svg naturally, collect searchable nodes, fit to view.
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
const stage = stageRef.current;
|
|
110
|
+
const svgEl = stage?.querySelector("svg");
|
|
111
|
+
if (!stage || !svgEl) return;
|
|
112
|
+
const viewBox = svgEl.viewBox?.baseVal;
|
|
113
|
+
const w = viewBox?.width || svgEl.getBoundingClientRect().width || 800;
|
|
114
|
+
const h = viewBox?.height || svgEl.getBoundingClientRect().height || 600;
|
|
115
|
+
// Replace mermaid's inline style WHOLESALE (it ships `max-width: Xpx`
|
|
116
|
+
// which silently beat per-property overrides — the fit math then used the
|
|
117
|
+
// viewBox size while the svg rendered capped: the "26% blob" defect).
|
|
118
|
+
svgEl.setAttribute("style", `max-width:none;width:${w}px;height:${h}px;`);
|
|
119
|
+
svgEl.setAttribute("width", String(w));
|
|
120
|
+
svgEl.setAttribute("height", String(h));
|
|
121
|
+
// Trust the PIXELS, not the assumption: measure the rendered box (the
|
|
122
|
+
// stage transform is still identity at mount). Fall back to viewBox.
|
|
123
|
+
const rect = svgEl.getBoundingClientRect();
|
|
124
|
+
naturalSize.current = {
|
|
125
|
+
w: rect.width > 0 ? rect.width : w,
|
|
126
|
+
h: rect.height > 0 ? rect.height : h,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const found: DiagramHit[] = [];
|
|
130
|
+
const seen = new Set<string>();
|
|
131
|
+
for (const node of svgEl.querySelectorAll<SVGGElement>("g.node, g.edgeLabel")) {
|
|
132
|
+
const text = diagramNodeLabel(node);
|
|
133
|
+
if (!text || !node.id) continue;
|
|
134
|
+
if (seen.has(node.id)) continue;
|
|
135
|
+
seen.add(node.id);
|
|
136
|
+
found.push({ id: node.id, label: text });
|
|
137
|
+
}
|
|
138
|
+
setHits(found);
|
|
139
|
+
// The dialog animates open — retry across the first frames until the
|
|
140
|
+
// container has real dimensions (belt to the ResizeObserver's braces).
|
|
141
|
+
if (!fit()) {
|
|
142
|
+
let tries = 0;
|
|
143
|
+
let raf = 0;
|
|
144
|
+
const attempt = () => {
|
|
145
|
+
if (userDrivenRef.current) return;
|
|
146
|
+
if (!fit() && ++tries < 30) raf = requestAnimationFrame(attempt);
|
|
147
|
+
};
|
|
148
|
+
raf = requestAnimationFrame(attempt);
|
|
149
|
+
return () => cancelAnimationFrame(raf);
|
|
150
|
+
}
|
|
151
|
+
}, [svg, fit]);
|
|
152
|
+
|
|
153
|
+
// The dialog mounts before layout settles, so the mount-time fit() can see a
|
|
154
|
+
// 0-sized container (fitTransform skips it). Re-fit when the container gains
|
|
155
|
+
// or changes size — until the user zooms/pans, after which their view wins.
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
const container = containerRef.current;
|
|
158
|
+
if (!container || typeof ResizeObserver === "undefined") return;
|
|
159
|
+
const observer = new ResizeObserver(() => {
|
|
160
|
+
if (!userDrivenRef.current) fit();
|
|
161
|
+
});
|
|
162
|
+
observer.observe(container);
|
|
163
|
+
return () => observer.disconnect();
|
|
164
|
+
}, [fit]);
|
|
165
|
+
|
|
166
|
+
// Highlight matching nodes as the query changes.
|
|
167
|
+
const q = query.trim().toLowerCase();
|
|
168
|
+
const matches = useMemo(
|
|
169
|
+
() => (q.length >= 2 ? hits.filter((hit) => hit.label.toLowerCase().includes(q)) : []),
|
|
170
|
+
[hits, q],
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
const svgEl = stageRef.current?.querySelector("svg");
|
|
175
|
+
if (!svgEl) return;
|
|
176
|
+
const matchIds = new Set(matches.map((m) => m.id));
|
|
177
|
+
for (const node of svgEl.querySelectorAll<SVGGElement>("g.node, g.edgeLabel")) {
|
|
178
|
+
node.classList.toggle(HIT_CLASS, matchIds.has(node.id));
|
|
179
|
+
// The ACTIVE highlight is independent of the query filter: after the
|
|
180
|
+
// user clicks a result (and the query changes or clears), the found
|
|
181
|
+
// node must stay visibly marked.
|
|
182
|
+
node.classList.toggle(HIT_ACTIVE_CLASS, node.id === activeHit);
|
|
183
|
+
}
|
|
184
|
+
}, [matches, activeHit]);
|
|
185
|
+
|
|
186
|
+
const zoomAt = useCallback((clientX: number, clientY: number, factor: number) => {
|
|
187
|
+
const container = containerRef.current;
|
|
188
|
+
if (!container) return;
|
|
189
|
+
userDrivenRef.current = true;
|
|
190
|
+
const rect = container.getBoundingClientRect();
|
|
191
|
+
setTransform((t) => {
|
|
192
|
+
const scale = clampScale(t.scale * factor);
|
|
193
|
+
const px = (clientX - rect.left - t.tx) / t.scale;
|
|
194
|
+
const py = (clientY - rect.top - t.ty) / t.scale;
|
|
195
|
+
return { scale, tx: clientX - rect.left - px * scale, ty: clientY - rect.top - py * scale };
|
|
196
|
+
});
|
|
197
|
+
}, []);
|
|
198
|
+
|
|
199
|
+
const zoomCenter = (factor: number) => {
|
|
200
|
+
const container = containerRef.current;
|
|
201
|
+
if (!container) return;
|
|
202
|
+
const rect = container.getBoundingClientRect();
|
|
203
|
+
zoomAt(rect.left + rect.width / 2, rect.top + rect.height / 2, factor);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const zoomToHit = useCallback((id: string) => {
|
|
207
|
+
const container = containerRef.current;
|
|
208
|
+
const svgEl = stageRef.current?.querySelector("svg");
|
|
209
|
+
const node = svgEl?.querySelector<SVGGElement>(`[id="${CSS.escape(id)}"]`);
|
|
210
|
+
if (!container || !svgEl || !node) return;
|
|
211
|
+
userDrivenRef.current = true;
|
|
212
|
+
setActiveHit(id);
|
|
213
|
+
setTransform((t) => {
|
|
214
|
+
const nodeRect = node.getBoundingClientRect();
|
|
215
|
+
const containerRect = container.getBoundingClientRect();
|
|
216
|
+
// Node center in svg-space (invert the current transform).
|
|
217
|
+
const cx = (nodeRect.left + nodeRect.width / 2 - containerRect.left - t.tx) / t.scale;
|
|
218
|
+
const cy = (nodeRect.top + nodeRect.height / 2 - containerRect.top - t.ty) / t.scale;
|
|
219
|
+
const scale = clampScale(Math.max(t.scale, 1.25));
|
|
220
|
+
return {
|
|
221
|
+
scale,
|
|
222
|
+
tx: containerRect.width / 2 - cx * scale,
|
|
223
|
+
ty: containerRect.height / 2 - cy * scale,
|
|
224
|
+
};
|
|
225
|
+
});
|
|
226
|
+
}, []);
|
|
227
|
+
|
|
228
|
+
const onWheel = (e: ReactWheelEvent) => {
|
|
229
|
+
e.preventDefault();
|
|
230
|
+
zoomAt(e.clientX, e.clientY, e.deltaY < 0 ? 1.12 : 1 / 1.12);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
234
|
+
if (e.button !== 0) return;
|
|
235
|
+
userDrivenRef.current = true;
|
|
236
|
+
dragRef.current = { x: e.clientX, y: e.clientY, tx: transform.tx, ty: transform.ty };
|
|
237
|
+
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
|
238
|
+
};
|
|
239
|
+
const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
240
|
+
const drag = dragRef.current;
|
|
241
|
+
if (!drag) return;
|
|
242
|
+
setTransform((t) => ({
|
|
243
|
+
...t,
|
|
244
|
+
tx: drag.tx + (e.clientX - drag.x),
|
|
245
|
+
ty: drag.ty + (e.clientY - drag.y),
|
|
246
|
+
}));
|
|
247
|
+
};
|
|
248
|
+
const onPointerUp = () => {
|
|
249
|
+
dragRef.current = null;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
return (
|
|
253
|
+
<div className="flex min-h-0 flex-1 gap-3">
|
|
254
|
+
{/* Search rail */}
|
|
255
|
+
<div className="flex w-60 shrink-0 flex-col border-e border-border pe-3">
|
|
256
|
+
<Input
|
|
257
|
+
autoFocus
|
|
258
|
+
type="search"
|
|
259
|
+
placeholder="Find in diagram…"
|
|
260
|
+
aria-label="Find in diagram"
|
|
261
|
+
value={query}
|
|
262
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
263
|
+
spellCheck={false}
|
|
264
|
+
className="h-8 text-body"
|
|
265
|
+
/>
|
|
266
|
+
<p aria-live="polite" className="px-1 pt-1.5 text-meta text-muted-foreground tabular-nums">
|
|
267
|
+
{q.length >= 2
|
|
268
|
+
? `${matches.length} ${matches.length === 1 ? "node" : "nodes"}`
|
|
269
|
+
: `${hits.length} nodes · type to filter`}
|
|
270
|
+
</p>
|
|
271
|
+
<ul className="m-0 mt-1 min-h-0 flex-1 list-none overflow-auto p-0">
|
|
272
|
+
{(q.length >= 2 ? matches : hits).map((hit) => (
|
|
273
|
+
<li key={hit.id}>
|
|
274
|
+
<button
|
|
275
|
+
type="button"
|
|
276
|
+
onClick={() => zoomToHit(hit.id)}
|
|
277
|
+
aria-pressed={activeHit === hit.id}
|
|
278
|
+
className={cn(
|
|
279
|
+
"w-full truncate rounded-md px-2 py-1.5 text-start text-caption transition-colors duration-fast ease-standard motion-reduce:transition-none",
|
|
280
|
+
"hover:bg-accent hover:text-accent-foreground",
|
|
281
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
|
|
282
|
+
activeHit === hit.id
|
|
283
|
+
? "bg-accent font-medium text-foreground"
|
|
284
|
+
: "text-muted-foreground",
|
|
285
|
+
)}
|
|
286
|
+
>
|
|
287
|
+
{hit.label}
|
|
288
|
+
</button>
|
|
289
|
+
</li>
|
|
290
|
+
))}
|
|
291
|
+
</ul>
|
|
292
|
+
</div>
|
|
293
|
+
|
|
294
|
+
{/* Stage */}
|
|
295
|
+
<div className="relative min-h-0 min-w-0 flex-1">
|
|
296
|
+
<div className="absolute end-2 top-2 z-10 flex gap-1">
|
|
297
|
+
<Button
|
|
298
|
+
variant="outline"
|
|
299
|
+
size="icon-sm"
|
|
300
|
+
aria-label="Zoom out"
|
|
301
|
+
onClick={() => zoomCenter(1 / 1.25)}
|
|
302
|
+
>
|
|
303
|
+
<Minus className="size-3.5" />
|
|
304
|
+
</Button>
|
|
305
|
+
<Button
|
|
306
|
+
variant="outline"
|
|
307
|
+
size="icon-sm"
|
|
308
|
+
aria-label="Zoom in"
|
|
309
|
+
onClick={() => zoomCenter(1.25)}
|
|
310
|
+
>
|
|
311
|
+
<Plus className="size-3.5" />
|
|
312
|
+
</Button>
|
|
313
|
+
<Button
|
|
314
|
+
variant="outline"
|
|
315
|
+
size="sm"
|
|
316
|
+
className="h-7 px-2 font-mono text-meta tabular-nums"
|
|
317
|
+
aria-label="Reset zoom to 100%"
|
|
318
|
+
onClick={() => {
|
|
319
|
+
userDrivenRef.current = true;
|
|
320
|
+
setTransform((t) => ({ ...t, scale: 1 }));
|
|
321
|
+
}}
|
|
322
|
+
>
|
|
323
|
+
{Math.round(transform.scale * 100)}%
|
|
324
|
+
</Button>
|
|
325
|
+
<Button
|
|
326
|
+
variant="outline"
|
|
327
|
+
size="icon-sm"
|
|
328
|
+
aria-label="Fit diagram"
|
|
329
|
+
onClick={() => {
|
|
330
|
+
// An explicit fit hands control back: keep fitting on resize.
|
|
331
|
+
userDrivenRef.current = false;
|
|
332
|
+
fit();
|
|
333
|
+
}}
|
|
334
|
+
>
|
|
335
|
+
<Maximize className="size-3.5" />
|
|
336
|
+
</Button>
|
|
337
|
+
</div>
|
|
338
|
+
|
|
339
|
+
<div
|
|
340
|
+
ref={containerRef}
|
|
341
|
+
role="img"
|
|
342
|
+
aria-label={label}
|
|
343
|
+
className={cn(
|
|
344
|
+
"h-full w-full touch-none select-none overflow-hidden rounded-md bg-surface-muted/50",
|
|
345
|
+
dragRef.current ? "cursor-grabbing" : "cursor-grab",
|
|
346
|
+
)}
|
|
347
|
+
onWheel={onWheel}
|
|
348
|
+
onPointerDown={onPointerDown}
|
|
349
|
+
onPointerMove={onPointerMove}
|
|
350
|
+
onPointerUp={onPointerUp}
|
|
351
|
+
onPointerCancel={onPointerUp}
|
|
352
|
+
onDoubleClick={(e) => zoomAt(e.clientX, e.clientY, 1.5)}
|
|
353
|
+
>
|
|
354
|
+
<div
|
|
355
|
+
ref={stageRef}
|
|
356
|
+
style={{
|
|
357
|
+
transform: `translate(${transform.tx}px, ${transform.ty}px) scale(${transform.scale})`,
|
|
358
|
+
transformOrigin: "0 0",
|
|
359
|
+
}}
|
|
360
|
+
// Same sanitized engine output as the inline rendering.
|
|
361
|
+
dangerouslySetInnerHTML={{ __html: svg }}
|
|
362
|
+
/>
|
|
363
|
+
</div>
|
|
364
|
+
</div>
|
|
365
|
+
</div>
|
|
366
|
+
);
|
|
367
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression proof against the REAL mermaid parser (no mocks): the exact
|
|
3
|
+
* failure pattern from the workbench live audit (`graph` as a node id) fails
|
|
4
|
+
* as-is and parses after remediation.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
import { offendingToken, remediateReservedIds } from "./remediate";
|
|
9
|
+
|
|
10
|
+
const REAL_WORLD = `flowchart TD
|
|
11
|
+
graph[Microsoft Graph: Outlook, Calendar, Teams, Attachments]
|
|
12
|
+
web[Approved web URLs and bounded small-site crawls]
|
|
13
|
+
postgres[(Postgres + pgvector: source of truth)]
|
|
14
|
+
graph -->|delta sync, MSAL device-code auth| worker
|
|
15
|
+
web -->|approved crawl jobs| worker
|
|
16
|
+
worker --> graph
|
|
17
|
+
subgraph host["Local host - all exposed ports bind to 127.0.0.1"]
|
|
18
|
+
langflow_port["localhost:7860"]
|
|
19
|
+
end
|
|
20
|
+
admin -. later .-> review`;
|
|
21
|
+
|
|
22
|
+
describe("real mermaid parser", () => {
|
|
23
|
+
it("fails on the raw source, parses after remediation", async () => {
|
|
24
|
+
const mermaid = (await import("mermaid")).default;
|
|
25
|
+
mermaid.initialize({ startOnLoad: false, suppressErrorRendering: true });
|
|
26
|
+
|
|
27
|
+
let firstError: Error | null = null;
|
|
28
|
+
try {
|
|
29
|
+
await mermaid.parse(REAL_WORLD);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
firstError = err as Error;
|
|
32
|
+
}
|
|
33
|
+
expect(firstError, "raw source should fail (reserved id)").not.toBeNull();
|
|
34
|
+
|
|
35
|
+
const token = offendingToken(firstError!.message);
|
|
36
|
+
expect(token).toBe("graph");
|
|
37
|
+
|
|
38
|
+
const fixed = remediateReservedIds(REAL_WORLD, token!);
|
|
39
|
+
expect(fixed).not.toBeNull();
|
|
40
|
+
await expect(mermaid.parse(fixed!)).resolves.toBeTruthy();
|
|
41
|
+
}, 30_000);
|
|
42
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { offendingToken, remediateReservedIds } from "./remediate";
|
|
4
|
+
|
|
5
|
+
const CHART = `flowchart TD
|
|
6
|
+
graph[Microsoft Graph: Outlook, Calendar, Teams]
|
|
7
|
+
web[Approved web URLs]
|
|
8
|
+
graph -->|delta sync| worker
|
|
9
|
+
worker --> graph
|
|
10
|
+
subgraph host["Local host"]
|
|
11
|
+
pg[Postgres]
|
|
12
|
+
end`;
|
|
13
|
+
|
|
14
|
+
describe("offendingToken", () => {
|
|
15
|
+
it("extracts the token from a mermaid parse error", () => {
|
|
16
|
+
expect(offendingToken("Expecting 'SEMI', 'NEWLINE', got 'GRAPH'")).toBe("graph");
|
|
17
|
+
expect(offendingToken("no token here")).toBeNull();
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("remediateReservedIds", () => {
|
|
22
|
+
it("rewrites reserved node ids but keeps keyword positions", () => {
|
|
23
|
+
const fixed = remediateReservedIds(CHART, "graph")!;
|
|
24
|
+
expect(fixed).toContain("flowchart TD"); // declaration untouched
|
|
25
|
+
expect(fixed).toContain("graph_[Microsoft Graph: Outlook, Calendar, Teams]");
|
|
26
|
+
expect(fixed).toContain("graph_ -->|delta sync| worker");
|
|
27
|
+
expect(fixed).toContain("worker --> graph_");
|
|
28
|
+
expect(fixed).toContain('subgraph host["Local host"]'); // contains "graph" as substring — untouched
|
|
29
|
+
expect(fixed).toContain("\n end"); // terminator untouched
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("keeps the declaration line when the diagram uses `graph TD`", () => {
|
|
33
|
+
const fixed = remediateReservedIds("graph TD\n graph[Node]\n graph --> b", "graph")!;
|
|
34
|
+
expect(fixed.startsWith("graph TD")).toBe(true);
|
|
35
|
+
expect(fixed).toContain("graph_[Node]");
|
|
36
|
+
expect(fixed).toContain("graph_ --> b");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("never touches label text, quotes, or edge text", () => {
|
|
40
|
+
const src =
|
|
41
|
+
'flowchart TD\n graph[Microsoft Graph API]\n a -->|graph sync| graph\n b["graph notes"]';
|
|
42
|
+
const fixed = remediateReservedIds(src, "graph")!;
|
|
43
|
+
expect(fixed).toContain("graph_[Microsoft Graph API]");
|
|
44
|
+
expect(fixed).toContain("|graph sync| graph_");
|
|
45
|
+
expect(fixed).toContain('b["graph notes"]');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("preserves lone `end` terminators while fixing `end` ids", () => {
|
|
49
|
+
const src = "flowchart TD\n end[Finish]\n a --> end\n subgraph s\n b\n end";
|
|
50
|
+
const fixed = remediateReservedIds(src, "end")!;
|
|
51
|
+
expect(fixed).toContain("end_[Finish]");
|
|
52
|
+
expect(fixed).toContain("a --> end_");
|
|
53
|
+
expect(fixed.trimEnd().endsWith("end")).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("returns null for non-reserved tokens or no-op input", () => {
|
|
57
|
+
expect(remediateReservedIds(CHART, "worker")).toBeNull();
|
|
58
|
+
expect(remediateReservedIds("flowchart TD\n a --> b", "class")).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mermaid reserved-identifier remediation.
|
|
3
|
+
*
|
|
4
|
+
* Authors routinely use flowchart KEYWORDS as node ids (`graph[Microsoft
|
|
5
|
+
* Graph]`, `end[End]`, `class[...]`) — mermaid hard-fails ("got 'GRAPH'")
|
|
6
|
+
* even though the intent is unambiguous. Instead of punting the error to the
|
|
7
|
+
* reader, the diagram component extracts the offending token from the parse
|
|
8
|
+
* error, rewrites that identifier with a trailing underscore, and retries
|
|
9
|
+
* once. Labels, quoted strings and edge text (`|…|`) are never touched —
|
|
10
|
+
* only the invisible id changes.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const FLOWCHART_RESERVED = new Set([
|
|
14
|
+
"graph",
|
|
15
|
+
"flowchart",
|
|
16
|
+
"subgraph",
|
|
17
|
+
"end",
|
|
18
|
+
"style",
|
|
19
|
+
"linkstyle",
|
|
20
|
+
"classdef",
|
|
21
|
+
"class",
|
|
22
|
+
"click",
|
|
23
|
+
"direction",
|
|
24
|
+
"default",
|
|
25
|
+
"state",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
/** Pull the offending token out of a mermaid parse error ("… got 'GRAPH'"). */
|
|
29
|
+
export function offendingToken(errorMessage: string): string | null {
|
|
30
|
+
const m = /got '([A-Za-z_]+)'/.exec(errorMessage);
|
|
31
|
+
return m ? m[1]!.toLowerCase() : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A true diagram declaration line: keyword + direction and nothing else. */
|
|
35
|
+
const DECLARATION_RE = /^(graph|flowchart)\s+(tb|td|bt|rl|lr)\s*;?\s*$/;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Replace `re` matches only OUTSIDE label/quote/edge-text segments:
|
|
39
|
+
* `[...]`, `(...)`, `{...}`, `"..."` and `|...|` contents stay verbatim.
|
|
40
|
+
*/
|
|
41
|
+
function replaceOutsideLabels(line: string, re: RegExp, repl: string): string {
|
|
42
|
+
let out = "";
|
|
43
|
+
let buf = "";
|
|
44
|
+
let depth = 0;
|
|
45
|
+
let inQuote = false;
|
|
46
|
+
let inPipe = false;
|
|
47
|
+
|
|
48
|
+
const flush = () => {
|
|
49
|
+
out += buf.replace(re, repl);
|
|
50
|
+
buf = "";
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
for (const ch of line) {
|
|
54
|
+
if (inQuote) {
|
|
55
|
+
out += ch;
|
|
56
|
+
if (ch === '"') inQuote = false;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (depth === 0 && ch === "|") {
|
|
60
|
+
if (!inPipe) flush();
|
|
61
|
+
inPipe = !inPipe;
|
|
62
|
+
out += ch;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (inPipe) {
|
|
66
|
+
out += ch;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (ch === '"') {
|
|
70
|
+
flush();
|
|
71
|
+
out += ch;
|
|
72
|
+
inQuote = true;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (ch === "[" || ch === "(" || ch === "{") {
|
|
76
|
+
if (depth === 0) flush();
|
|
77
|
+
depth++;
|
|
78
|
+
out += ch;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (ch === "]" || ch === ")" || ch === "}") {
|
|
82
|
+
if (depth > 0) depth--;
|
|
83
|
+
if (depth === 0) {
|
|
84
|
+
out += ch;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
out += ch;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (depth === 0) buf += ch;
|
|
91
|
+
else out += ch;
|
|
92
|
+
}
|
|
93
|
+
flush();
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Rewrite a reserved word used as a node id to `<word>_`. Structural keyword
|
|
99
|
+
* positions are preserved: declaration lines (`flowchart TD`), `subgraph`
|
|
100
|
+
* keyword lines and lone `end` terminators stay intact. Returns the
|
|
101
|
+
* rewritten source, or null when nothing remediable changed.
|
|
102
|
+
*/
|
|
103
|
+
export function remediateReservedIds(chart: string, token: string): string | null {
|
|
104
|
+
const t = token.toLowerCase();
|
|
105
|
+
if (!FLOWCHART_RESERVED.has(t)) return null;
|
|
106
|
+
const re = new RegExp(`\\b${t}\\b`, "gi");
|
|
107
|
+
let changed = false;
|
|
108
|
+
const repl = `${t}_`;
|
|
109
|
+
|
|
110
|
+
const out = chart
|
|
111
|
+
.split("\n")
|
|
112
|
+
.map((line) => {
|
|
113
|
+
const trimmed = line.trim().toLowerCase();
|
|
114
|
+
if (trimmed === t) return line; // lone keyword (e.g. subgraph terminator `end`)
|
|
115
|
+
if (DECLARATION_RE.test(trimmed)) return line; // `graph TD` / `flowchart LR`
|
|
116
|
+
if (t === "subgraph" && trimmed.startsWith("subgraph")) return line;
|
|
117
|
+
const next = replaceOutsideLabels(line, re, repl);
|
|
118
|
+
if (next !== line) changed = true;
|
|
119
|
+
return next;
|
|
120
|
+
})
|
|
121
|
+
.join("\n");
|
|
122
|
+
|
|
123
|
+
return changed ? out : null;
|
|
124
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { MermaidWorkspace, type MermaidWorkspaceProps } from "./mermaid-workspace";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
|
|
3
|
+
import { MermaidWorkspace } from "./mermaid-workspace";
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: "Editor/MermaidWorkspace",
|
|
7
|
+
component: MermaidWorkspace,
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
parameters: { layout: "fullscreen" },
|
|
10
|
+
} satisfies Meta<typeof MermaidWorkspace>;
|
|
11
|
+
|
|
12
|
+
export default meta;
|
|
13
|
+
type Story = StoryObj<typeof meta>;
|
|
14
|
+
|
|
15
|
+
export const Default: Story = {
|
|
16
|
+
args: {
|
|
17
|
+
defaultValue: `graph TD
|
|
18
|
+
A[Open document] --> B{Mode}
|
|
19
|
+
B -- read --> C[Rendered preview]
|
|
20
|
+
B -- edit --> D[WYSIWYG]
|
|
21
|
+
B -- source --> E[Monaco]`,
|
|
22
|
+
},
|
|
23
|
+
render: (args) => (
|
|
24
|
+
<div className="h-[480px]">
|
|
25
|
+
<MermaidWorkspace {...args} />
|
|
26
|
+
</div>
|
|
27
|
+
),
|
|
28
|
+
};
|