@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.
Files changed (207) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -0
  3. package/dist/chunk-LBC5VJBD.js +3748 -0
  4. package/dist/chunk-LBC5VJBD.js.map +1 -0
  5. package/dist/index.css +233 -0
  6. package/dist/index.css.map +1 -0
  7. package/dist/index.d.ts +199 -0
  8. package/dist/index.js +362 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/lib/monaco-environment.d.ts +2 -0
  11. package/dist/lib/monaco-environment.js +44 -0
  12. package/dist/lib/monaco-environment.js.map +1 -0
  13. package/dist/markdown/frontmatter.d.ts +11 -0
  14. package/dist/markdown/frontmatter.js +33 -0
  15. package/dist/markdown/frontmatter.js.map +1 -0
  16. package/dist/markdown/index.css +233 -0
  17. package/dist/markdown/index.css.map +1 -0
  18. package/dist/markdown/index.d.ts +1486 -0
  19. package/dist/markdown/index.js +4882 -0
  20. package/dist/markdown/index.js.map +1 -0
  21. package/dist/markdown/parse.d.ts +25 -0
  22. package/dist/markdown/parse.js +14 -0
  23. package/dist/markdown/parse.js.map +1 -0
  24. package/dist/markdown-editor-DfBZibAn.d.ts +702 -0
  25. package/package.json +101 -0
  26. package/src/ai-objects/ai-objects-integration.test.tsx +128 -0
  27. package/src/ai-objects/ai-objects.stories.tsx +233 -0
  28. package/src/ai-objects/ai-objects.test.tsx +205 -0
  29. package/src/ai-objects/decision-card.tsx +178 -0
  30. package/src/ai-objects/directives.ts +136 -0
  31. package/src/ai-objects/entity.tsx +181 -0
  32. package/src/ai-objects/index.ts +34 -0
  33. package/src/ai-objects/knowledge-card.tsx +143 -0
  34. package/src/calc-block/calc-block.stories.tsx +297 -0
  35. package/src/calc-block/calc-block.test.tsx +164 -0
  36. package/src/calc-block/calc-block.tsx +398 -0
  37. package/src/calc-block/calc-editor-monaco.ts +214 -0
  38. package/src/calc-block/calc-editor-prose.ts +128 -0
  39. package/src/calc-block/calc-editor.css +80 -0
  40. package/src/calc-block/calc-editor.stories.tsx +272 -0
  41. package/src/calc-block/calc-editor.test.ts +228 -0
  42. package/src/calc-block/calc-editor.ts +270 -0
  43. package/src/calc-block/calc-inline.stories.tsx +44 -0
  44. package/src/calc-block/calc-inline.test.tsx +44 -0
  45. package/src/calc-block/calc-inline.tsx +75 -0
  46. package/src/calc-block/index.ts +20 -0
  47. package/src/calc-block/types.ts +164 -0
  48. package/src/code-editor/code-editor.stories.tsx +218 -0
  49. package/src/code-editor/code-editor.test.tsx +134 -0
  50. package/src/code-editor/code-editor.tsx +250 -0
  51. package/src/code-editor/index.ts +6 -0
  52. package/src/code-workspace/code-workspace.stories.tsx +51 -0
  53. package/src/code-workspace/code-workspace.test.tsx +146 -0
  54. package/src/code-workspace/code-workspace.tsx +253 -0
  55. package/src/code-workspace/index.ts +6 -0
  56. package/src/copy-button/copy-button.tsx +51 -0
  57. package/src/copy-button/index.ts +1 -0
  58. package/src/css.d.ts +3 -0
  59. package/src/diff-editor/diff-editor.stories.tsx +44 -0
  60. package/src/diff-editor/diff-editor.test.tsx +68 -0
  61. package/src/diff-editor/diff-editor.tsx +151 -0
  62. package/src/diff-editor/index.ts +1 -0
  63. package/src/editor-context-menu/editor-context-menu.tsx +120 -0
  64. package/src/editor-context-menu/index.ts +1 -0
  65. package/src/editor-toolbar/editor-toolbar.tsx +83 -0
  66. package/src/editor-toolbar/index.ts +1 -0
  67. package/src/index.ts +48 -0
  68. package/src/lib/editor-completions-monaco.test.ts +206 -0
  69. package/src/lib/editor-completions-monaco.ts +127 -0
  70. package/src/lib/editor-completions.test.ts +156 -0
  71. package/src/lib/editor-completions.ts +153 -0
  72. package/src/lib/editor-content-access-prose.test.ts +411 -0
  73. package/src/lib/editor-content-access-prose.ts +172 -0
  74. package/src/lib/editor-content-access.test.ts +182 -0
  75. package/src/lib/editor-content-access.ts +109 -0
  76. package/src/lib/languages.ts +32 -0
  77. package/src/lib/markdown/diff.test.ts +80 -0
  78. package/src/lib/markdown/diff.ts +194 -0
  79. package/src/lib/markdown/directives.ts +342 -0
  80. package/src/lib/markdown/frontmatter.ts +50 -0
  81. package/src/lib/markdown/markdown-scale.test.ts +65 -0
  82. package/src/lib/markdown/markdown-scale.ts +62 -0
  83. package/src/lib/markdown/merge.test.ts +64 -0
  84. package/src/lib/markdown/merge.ts +158 -0
  85. package/src/lib/markdown/slugify.test.ts +156 -0
  86. package/src/lib/markdown/slugify.ts +47 -0
  87. package/src/lib/monaco-environment.ts +103 -0
  88. package/src/lib/monaco-theme-bridge.ts +293 -0
  89. package/src/lib/use-data-theme.ts +61 -0
  90. package/src/markdown/frontmatter.ts +14 -0
  91. package/src/markdown/index.ts +323 -0
  92. package/src/markdown/parse.test.ts +60 -0
  93. package/src/markdown/parse.ts +38 -0
  94. package/src/markdown-academic/citations.test.ts +77 -0
  95. package/src/markdown-academic/citations.tsx +442 -0
  96. package/src/markdown-academic/footnotes.tsx +235 -0
  97. package/src/markdown-academic/index.ts +32 -0
  98. package/src/markdown-academic/math.tsx +163 -0
  99. package/src/markdown-academic/toc.tsx +88 -0
  100. package/src/markdown-editor/completions/completions-menu.tsx +96 -0
  101. package/src/markdown-editor/completions/completions-prose.test.ts +356 -0
  102. package/src/markdown-editor/completions/completions-prose.ts +313 -0
  103. package/src/markdown-editor/completions/completions-widget.tsx +75 -0
  104. package/src/markdown-editor/completions/index.ts +37 -0
  105. package/src/markdown-editor/directive-nodes.ts +151 -0
  106. package/src/markdown-editor/directive-views.test.tsx +189 -0
  107. package/src/markdown-editor/directive-views.tsx +795 -0
  108. package/src/markdown-editor/exit-keymap.test.ts +88 -0
  109. package/src/markdown-editor/exit-keymap.ts +87 -0
  110. package/src/markdown-editor/index.ts +6 -0
  111. package/src/markdown-editor/markdown-editor.css +250 -0
  112. package/src/markdown-editor/markdown-editor.directives.test.tsx +105 -0
  113. package/src/markdown-editor/markdown-editor.fill.test.ts +34 -0
  114. package/src/markdown-editor/markdown-editor.paste-embed.test.tsx +297 -0
  115. package/src/markdown-editor/markdown-editor.stories.tsx +560 -0
  116. package/src/markdown-editor/markdown-editor.strictmode.test.tsx +38 -0
  117. package/src/markdown-editor/markdown-editor.table.test.tsx +287 -0
  118. package/src/markdown-editor/markdown-editor.tsx +560 -0
  119. package/src/markdown-editor/milkdown-react/editor.tsx +33 -0
  120. package/src/markdown-editor/milkdown-react/index.ts +8 -0
  121. package/src/markdown-editor/milkdown-react/types.ts +27 -0
  122. package/src/markdown-editor/milkdown-react/use-editor.ts +27 -0
  123. package/src/markdown-editor/milkdown-react/use-get-editor.ts +56 -0
  124. package/src/markdown-editor/milkdown-react/use-instance.ts +23 -0
  125. package/src/markdown-editor/paste-embed.ts +355 -0
  126. package/src/markdown-editor/slash/brand-slash-commands.test.ts +194 -0
  127. package/src/markdown-editor/slash/brand-slash-commands.ts +324 -0
  128. package/src/markdown-editor/slash/brand-slash-plugin.test.ts +254 -0
  129. package/src/markdown-editor/slash/brand-slash-plugin.ts +385 -0
  130. package/src/markdown-editor/slash/index.ts +107 -0
  131. package/src/markdown-editor/slash/insert-directive.test.ts +263 -0
  132. package/src/markdown-editor/slash/insert-directive.ts +345 -0
  133. package/src/markdown-editor/slash/monaco-slash-menu.test.tsx +198 -0
  134. package/src/markdown-editor/slash/monaco-slash-menu.tsx +356 -0
  135. package/src/markdown-editor/slash/shortcut-monaco.ts +69 -0
  136. package/src/markdown-editor/slash/shortcut.test.ts +157 -0
  137. package/src/markdown-editor/slash/shortcut.ts +57 -0
  138. package/src/markdown-editor/slash/slash-menu.stories.tsx +139 -0
  139. package/src/markdown-editor/slash/slash-menu.tsx +115 -0
  140. package/src/markdown-editor/slash/slash-scroll.test.tsx +52 -0
  141. package/src/markdown-editor/slash/slash-widget.tsx +97 -0
  142. package/src/markdown-editor/slash/source-slash-trigger.test.ts +42 -0
  143. package/src/markdown-editor/slash/source-slash-trigger.ts +46 -0
  144. package/src/markdown-editor/table-view.tsx +249 -0
  145. package/src/markdown-iteration/directive.tsx +92 -0
  146. package/src/markdown-iteration/edit-context.ts +56 -0
  147. package/src/markdown-iteration/index.ts +24 -0
  148. package/src/markdown-iteration/iteration-block.test.tsx +138 -0
  149. package/src/markdown-iteration/iteration-builder-dialog.stories.tsx +293 -0
  150. package/src/markdown-iteration/iteration-builder-dialog.tsx +312 -0
  151. package/src/markdown-iteration/iteration-builder.test.ts +252 -0
  152. package/src/markdown-iteration/iteration-builder.ts +303 -0
  153. package/src/markdown-iteration/iteration.test.ts +56 -0
  154. package/src/markdown-iteration/iteration.tsx +333 -0
  155. package/src/markdown-iteration/template-dialog.stories.tsx +63 -0
  156. package/src/markdown-iteration/template-dialog.test.tsx +104 -0
  157. package/src/markdown-iteration/template-dialog.tsx +116 -0
  158. package/src/markdown-outline/document-outline.stories.tsx +76 -0
  159. package/src/markdown-outline/document-outline.tsx +83 -0
  160. package/src/markdown-outline/index.ts +2 -0
  161. package/src/markdown-outline/markdown-outline.test.ts +49 -0
  162. package/src/markdown-outline/markdown-outline.ts +60 -0
  163. package/src/markdown-preview/code-fence.tsx +193 -0
  164. package/src/markdown-preview/index.ts +8 -0
  165. package/src/markdown-preview/markdown-preview-academic.stories.tsx +203 -0
  166. package/src/markdown-preview/markdown-preview-academic.test.tsx +257 -0
  167. package/src/markdown-preview/markdown-preview-iteration-calc.test.tsx +71 -0
  168. package/src/markdown-preview/markdown-preview-iteration-nested.test.tsx +58 -0
  169. package/src/markdown-preview/markdown-preview-iteration.stories.tsx +187 -0
  170. package/src/markdown-preview/markdown-preview-iteration.test.tsx +90 -0
  171. package/src/markdown-preview/markdown-preview-linking.test.tsx +61 -0
  172. package/src/markdown-preview/markdown-preview-transclusion.test.tsx +33 -0
  173. package/src/markdown-preview/markdown-preview.stories.tsx +108 -0
  174. package/src/markdown-preview/markdown-preview.test.tsx +411 -0
  175. package/src/markdown-preview/markdown-preview.tsx +1541 -0
  176. package/src/markdown-toolbar/index.ts +8 -0
  177. package/src/markdown-toolbar/markdown-commands.test.ts +84 -0
  178. package/src/markdown-toolbar/markdown-commands.ts +104 -0
  179. package/src/markdown-toolbar/markdown-toolbar.stories.tsx +56 -0
  180. package/src/markdown-toolbar/markdown-toolbar.tsx +263 -0
  181. package/src/markdown-workspace/focus-writing.test.ts +52 -0
  182. package/src/markdown-workspace/focus-writing.ts +38 -0
  183. package/src/markdown-workspace/index.ts +6 -0
  184. package/src/markdown-workspace/markdown-workspace.stories.tsx +688 -0
  185. package/src/markdown-workspace/markdown-workspace.test.tsx +326 -0
  186. package/src/markdown-workspace/markdown-workspace.tsx +787 -0
  187. package/src/mermaid-diagram/index.ts +1 -0
  188. package/src/mermaid-diagram/mermaid-diagram.stories.tsx +42 -0
  189. package/src/mermaid-diagram/mermaid-diagram.test.tsx +86 -0
  190. package/src/mermaid-diagram/mermaid-diagram.tsx +318 -0
  191. package/src/mermaid-diagram/mermaid-viewer.test.tsx +89 -0
  192. package/src/mermaid-diagram/mermaid-viewer.tsx +367 -0
  193. package/src/mermaid-diagram/real-parse.test.ts +42 -0
  194. package/src/mermaid-diagram/remediate.test.ts +60 -0
  195. package/src/mermaid-diagram/remediate.ts +124 -0
  196. package/src/mermaid-workspace/index.ts +1 -0
  197. package/src/mermaid-workspace/mermaid-workspace.stories.tsx +28 -0
  198. package/src/mermaid-workspace/mermaid-workspace.tsx +71 -0
  199. package/src/metric-block/index.ts +1 -0
  200. package/src/metric-block/metric-block.stories.tsx +40 -0
  201. package/src/metric-block/metric-block.tsx +14 -0
  202. package/src/monaco-workers.d.ts +10 -0
  203. package/src/prose/index.ts +14 -0
  204. package/src/prose/prose.stories.tsx +44 -0
  205. package/src/prose/prose.test.tsx +44 -0
  206. package/src/prose/prose.tsx +21 -0
  207. 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
+ };