@fabrika/canvas-kernel 0.1.45 → 0.3.1

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 (70) hide show
  1. package/README.md +33 -67
  2. package/index.ts +20 -83
  3. package/package.json +69 -72
  4. package/src/client/AnnotationProvider.tsx +218 -255
  5. package/src/client/AnnotationSidebar.tsx +393 -338
  6. package/src/client/FileIcon.tsx +105 -66
  7. package/src/client/PlanRenderer.tsx +607 -551
  8. package/src/client/RenderErrorContext.ts +7 -7
  9. package/src/client/ResponsePreview.tsx +192 -173
  10. package/src/client/annotationDom.ts +43 -97
  11. package/src/client/annotationDraft.ts +106 -96
  12. package/src/client/appContext.tsx +40 -40
  13. package/src/client/components/Callout.tsx +20 -20
  14. package/src/client/components/Checklist.tsx +27 -29
  15. package/src/client/components/Choice.tsx +178 -159
  16. package/src/client/components/CodeBlock.tsx +27 -26
  17. package/src/client/components/Diff.tsx +16 -17
  18. package/src/client/components/FilePreview.tsx +102 -74
  19. package/src/client/components/Image.tsx +41 -39
  20. package/src/client/components/Markdown.tsx +33 -32
  21. package/src/client/components/Mermaid.tsx +593 -542
  22. package/src/client/components/Note.tsx +8 -8
  23. package/src/client/components/Priority.tsx +10 -12
  24. package/src/client/components/ResponseNote.tsx +16 -15
  25. package/src/client/components/Section.tsx +28 -26
  26. package/src/client/components/Table.tsx +33 -30
  27. package/src/client/components/Task.tsx +58 -60
  28. package/src/client/components/UserInput.tsx +169 -152
  29. package/src/client/components/index.ts +16 -16
  30. package/src/client/components/renderMarkdownChildren.tsx +86 -68
  31. package/src/client/diffLines.ts +51 -39
  32. package/src/client/generateMarkdown.ts +197 -191
  33. package/src/client/hostContext.tsx +39 -34
  34. package/src/client/index.ts +44 -125
  35. package/src/client/pageGlobals.ts +25 -0
  36. package/src/client/persistenceSchemas.ts +41 -0
  37. package/src/client/preact-all.ts +4 -4
  38. package/src/client/runtime.ts +58 -111
  39. package/src/client/styles.css +1 -74
  40. package/src/engine/compiler.ts +411 -323
  41. package/src/engine/file-admission.ts +25 -0
  42. package/src/engine/paths.ts +44 -44
  43. package/src/engine/session.ts +515 -626
  44. package/src/engine/sessionSchemas.ts +71 -0
  45. package/src/engine/watcher.ts +70 -60
  46. package/src/engine/websocket.ts +134 -132
  47. package/annotate.ts +0 -94
  48. package/daemon.ts +0 -46
  49. package/src/client/AnnotationDraftFooter.tsx +0 -76
  50. package/src/client/AnnotationEditor.tsx +0 -320
  51. package/src/client/AnnotationList.tsx +0 -190
  52. package/src/client/Popover.tsx +0 -361
  53. package/src/client/SessionContext.tsx +0 -3
  54. package/src/client/annotationContext.ts +0 -130
  55. package/src/client/annotationMarkdown.ts +0 -102
  56. package/src/client/annotationTarget.ts +0 -73
  57. package/src/client/blockTarget.ts +0 -127
  58. package/src/client/highlightRange.ts +0 -262
  59. package/src/client/popoverPosition.ts +0 -46
  60. package/src/client/regionTarget.ts +0 -180
  61. package/src/client/testing/dom.ts +0 -74
  62. package/src/client/textTarget.ts +0 -31
  63. package/src/client/useRegionAnnotation.tsx +0 -188
  64. package/src/client/useTextAnnotation.tsx +0 -194
  65. package/src/client/utils.ts +0 -33
  66. package/src/engine/channel.ts +0 -278
  67. package/src/engine/http-utils.ts +0 -14
  68. package/src/engine/lifecycle.ts +0 -302
  69. package/src/engine/router.ts +0 -16
  70. package/src/engine/waiter.ts +0 -188
@@ -1,576 +1,627 @@
1
- import React, { useEffect, useRef, useState, useCallback } from "react";
2
- import ReactDOM from "react-dom";
3
- import { useAnnotations, useCanvasFile } from "#canvas/runtime";
4
- import type { Annotation } from "#canvas/runtime";
5
- import { AnnotationCreatePopover } from "../Popover";
1
+ // biome-ignore-all lint/security/noDangerouslySetInnerHtml: Canvas modules already execute as code in this document.
2
+ import { useAnnotations, useCanvasFile } from '#canvas/runtime'
3
+ import type { Annotation } from '#canvas/runtime'
4
+ import { AnnotationCreatePopover } from '@fabrika/annotations'
5
+ import React, { useCallback, useEffect, useRef, useState } from 'react'
6
+ import ReactDOM from 'react-dom'
7
+ import { pageMermaid } from '../pageGlobals'
6
8
 
7
9
  interface MermaidProps {
8
- children?: React.ReactNode;
10
+ children?: React.ReactNode
9
11
  }
10
12
 
11
13
  interface NodePopoverState {
12
- anchorEl: HTMLElement;
13
- snippet: string;
14
- prefix: string;
14
+ anchorEl: HTMLElement
15
+ snippet: string
16
+ prefix: string
15
17
  }
16
18
 
17
- const ZOOM_STEP = 0.2;
18
- const ZOOM_MIN = 0.25;
19
- const ZOOM_MAX = 4;
19
+ const ZOOM_STEP = 0.2
20
+ const ZOOM_MIN = 0.25
21
+ const ZOOM_MAX = 4
20
22
 
21
23
  /** The wheel fields React's synthetic event and Preact's native one both carry.
22
24
  * Named structurally because preact/compat aliases no `React.WheelEvent`, and
23
25
  * a handler taking less than the event supplies is accepted by either. */
24
26
  interface WheelLike {
25
- deltaY: number;
26
- ctrlKey: boolean;
27
- metaKey: boolean;
28
- preventDefault(): void;
27
+ deltaY: number
28
+ ctrlKey: boolean
29
+ metaKey: boolean
30
+ preventDefault(): void
29
31
  }
30
32
 
31
33
  function ZoomToolbar({ zoom, onZoomIn, onZoomOut, onZoomReset, onFullscreen }: {
32
- zoom: number;
33
- onZoomIn: () => void;
34
- onZoomOut: () => void;
35
- onZoomReset: () => void;
36
- onFullscreen?: () => void;
34
+ zoom: number
35
+ onZoomIn: () => void
36
+ onZoomOut: () => void
37
+ onZoomReset: () => void
38
+ onFullscreen?: () => void
37
39
  }) {
38
- const btnClass = "flex items-center justify-center w-7 h-7 rounded hover:bg-bg-elevated text-text-secondary hover:text-text-primary transition-colors";
39
- return (
40
- <div className="flex items-center gap-0.5 bg-bg-surface border border-border-subtle rounded-md px-1 py-0.5" style={{ fontSize: "12px" }}>
41
- <button className={btnClass} onClick={onZoomOut} title="Zoom out" disabled={zoom <= ZOOM_MIN}>
42
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="5" y1="12" x2="19" y2="12"/></svg>
43
- </button>
44
- <button className="px-1.5 h-7 rounded hover:bg-bg-elevated text-text-tertiary hover:text-text-primary transition-colors tabular-nums" onClick={onZoomReset} title="Reset zoom">
45
- {Math.round(zoom * 100)}%
46
- </button>
47
- <button className={btnClass} onClick={onZoomIn} title="Zoom in" disabled={zoom >= ZOOM_MAX}>
48
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
49
- </button>
50
- {onFullscreen && (
51
- <>
52
- <div className="w-px h-4 bg-border-subtle mx-0.5" />
53
- <button className={btnClass} onClick={onFullscreen} title="Fullscreen">
54
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
55
- </button>
56
- </>
57
- )}
58
- </div>
59
- );
40
+ const btnClass =
41
+ 'flex items-center justify-center w-7 h-7 rounded hover:bg-bg-elevated text-text-secondary hover:text-text-primary transition-colors'
42
+ return (
43
+ <div className="flex items-center gap-0.5 bg-bg-surface border border-border-subtle rounded-md px-1 py-0.5" style={{ fontSize: '12px' }}>
44
+ <button className={btnClass} onClick={onZoomOut} title="Zoom out" disabled={zoom <= ZOOM_MIN}>
45
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
46
+ <line x1="5" y1="12" x2="19" y2="12" />
47
+ </svg>
48
+ </button>
49
+ <button
50
+ className="px-1.5 h-7 rounded hover:bg-bg-elevated text-text-tertiary hover:text-text-primary transition-colors tabular-nums"
51
+ onClick={onZoomReset}
52
+ title="Reset zoom"
53
+ >
54
+ {Math.round(zoom * 100)}%
55
+ </button>
56
+ <button className={btnClass} onClick={onZoomIn} title="Zoom in" disabled={zoom >= ZOOM_MAX}>
57
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
58
+ <line x1="12" y1="5" x2="12" y2="19" />
59
+ <line x1="5" y1="12" x2="19" y2="12" />
60
+ </svg>
61
+ </button>
62
+ {onFullscreen && (
63
+ <>
64
+ <div className="w-px h-4 bg-border-subtle mx-0.5" />
65
+ <button className={btnClass} onClick={onFullscreen} title="Fullscreen">
66
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
67
+ <polyline points="15 3 21 3 21 9" />
68
+ <polyline points="9 21 3 21 3 15" />
69
+ <line x1="21" y1="3" x2="14" y2="10" />
70
+ <line x1="3" y1="21" x2="10" y2="14" />
71
+ </svg>
72
+ </button>
73
+ </>
74
+ )}
75
+ </div>
76
+ )
60
77
  }
61
78
 
62
79
  function useZoom(initialZoom = 1) {
63
- const [zoom, setZoom] = useState(initialZoom);
64
- const zoomIn = useCallback(() => setZoom(z => Math.min(ZOOM_MAX, +(z + ZOOM_STEP).toFixed(2))), []);
65
- const zoomOut = useCallback(() => setZoom(z => Math.max(ZOOM_MIN, +(z - ZOOM_STEP).toFixed(2))), []);
66
- const zoomReset = useCallback(() => setZoom(1), []);
67
- return { zoom, zoomIn, zoomOut, zoomReset, setZoom };
80
+ const [zoom, setZoom] = useState(initialZoom)
81
+ const zoomIn = useCallback(() => setZoom(z => Math.min(ZOOM_MAX, +(z + ZOOM_STEP).toFixed(2))), [])
82
+ const zoomOut = useCallback(() => setZoom(z => Math.max(ZOOM_MIN, +(z - ZOOM_STEP).toFixed(2))), [])
83
+ const zoomReset = useCallback(() => setZoom(1), [])
84
+ return { zoom, zoomIn, zoomOut, zoomReset, setZoom }
68
85
  }
69
86
 
70
87
  function MermaidFullscreenModal({ svgHtml, source, onClose }: {
71
- svgHtml: string;
72
- source: string;
73
- onClose: () => void;
88
+ svgHtml: string
89
+ source: string
90
+ onClose: () => void
74
91
  }) {
75
- const modalContentRef = useRef<HTMLDivElement>(null);
76
- const { zoom, zoomIn, zoomOut, zoomReset, setZoom } = useZoom(1);
77
- const [pan, setPan] = useState({ x: 0, y: 0 });
78
- const dragging = useRef<{ startX: number; startY: number; panX: number; panY: number } | null>(null);
79
-
80
- useEffect(() => {
81
- const onKeyDown = (e: KeyboardEvent) => {
82
- if (e.key === "Escape") onClose();
83
- };
84
- window.addEventListener("keydown", onKeyDown);
85
- return () => window.removeEventListener("keydown", onKeyDown);
86
- }, [onClose]);
87
-
88
- // Wheel zoom
89
- const handleWheel = useCallback((e: WheelLike) => {
90
- e.preventDefault();
91
- const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
92
- setZoom(z => Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, +(z + delta).toFixed(2))));
93
- }, [setZoom]);
94
-
95
- // Pan via mouse drag
96
- const handleMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
97
- if (e.button !== 0) return;
98
- dragging.current = { startX: e.clientX, startY: e.clientY, panX: pan.x, panY: pan.y };
99
- e.preventDefault();
100
- }, [pan]);
101
-
102
- const handleMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
103
- if (!dragging.current) return;
104
- setPan({
105
- x: dragging.current.panX + (e.clientX - dragging.current.startX),
106
- y: dragging.current.panY + (e.clientY - dragging.current.startY),
107
- });
108
- }, []);
109
-
110
- const handleMouseUp = useCallback(() => {
111
- dragging.current = null;
112
- }, []);
113
-
114
- const handleResetView = useCallback(() => {
115
- zoomReset();
116
- setPan({ x: 0, y: 0 });
117
- }, [zoomReset]);
118
-
119
- return ReactDOM.createPortal(
120
- <div
121
- style={{ position: "fixed", inset: 0, zIndex: 9999, background: "var(--color-bg-base)", display: "flex", flexDirection: "column" }}
122
- onMouseMove={handleMouseMove}
123
- onMouseUp={handleMouseUp}
124
- onMouseLeave={handleMouseUp}
125
- >
126
- {/* Header bar */}
127
- <div className="flex items-center justify-between px-4 py-2 border-b border-border-subtle" style={{ flexShrink: 0, background: "var(--color-bg-surface)" }}>
128
- <span className="text-body text-text-secondary" style={{ fontSize: "13px" }}>Mermaid Diagram</span>
129
- <div className="flex items-center gap-2">
130
- <ZoomToolbar zoom={zoom} onZoomIn={zoomIn} onZoomOut={zoomOut} onZoomReset={handleResetView} />
131
- <button
132
- className="flex items-center justify-center w-8 h-8 rounded hover:bg-bg-elevated text-text-secondary hover:text-text-primary transition-colors"
133
- onClick={onClose}
134
- title="Close (Esc)"
135
- >
136
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
137
- </button>
138
- </div>
139
- </div>
140
- {/* Diagram area */}
141
- <div
142
- style={{ flex: 1, overflow: "hidden", cursor: dragging.current ? "grabbing" : "grab" }}
143
- onWheel={handleWheel}
144
- onMouseDown={handleMouseDown}
145
- >
146
- <div
147
- ref={modalContentRef}
148
- style={{
149
- width: "100%",
150
- height: "100%",
151
- display: "flex",
152
- alignItems: "center",
153
- justifyContent: "center",
154
- transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
155
- transformOrigin: "center center",
156
- transition: dragging.current ? "none" : "transform 0.1s ease-out",
157
- }}
158
- dangerouslySetInnerHTML={{ __html: svgHtml }}
159
- />
160
- </div>
161
- </div>,
162
- document.body,
163
- );
92
+ const modalContentRef = useRef<HTMLDivElement>(null)
93
+ const { zoom, zoomIn, zoomOut, zoomReset, setZoom } = useZoom(1)
94
+ const [pan, setPan] = useState({ x: 0, y: 0 })
95
+ const dragging = useRef<{ startX: number; startY: number; panX: number; panY: number } | null>(null)
96
+
97
+ useEffect(() => {
98
+ const onKeyDown = (e: KeyboardEvent) => {
99
+ if (e.key === 'Escape') onClose()
100
+ }
101
+ window.addEventListener('keydown', onKeyDown)
102
+ return () => window.removeEventListener('keydown', onKeyDown)
103
+ }, [onClose])
104
+
105
+ // Wheel zoom
106
+ const handleWheel = useCallback((e: WheelLike) => {
107
+ e.preventDefault()
108
+ const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP
109
+ setZoom(z => Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, +(z + delta).toFixed(2))))
110
+ }, [setZoom])
111
+
112
+ // Pan via mouse drag
113
+ const handleMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
114
+ if (e.button !== 0) return
115
+ dragging.current = { startX: e.clientX, startY: e.clientY, panX: pan.x, panY: pan.y }
116
+ e.preventDefault()
117
+ }, [pan])
118
+
119
+ const handleMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
120
+ if (!dragging.current) return
121
+ setPan({
122
+ x: dragging.current.panX + (e.clientX - dragging.current.startX),
123
+ y: dragging.current.panY + (e.clientY - dragging.current.startY),
124
+ })
125
+ }, [])
126
+
127
+ const handleMouseUp = useCallback(() => {
128
+ dragging.current = null
129
+ }, [])
130
+
131
+ const handleResetView = useCallback(() => {
132
+ zoomReset()
133
+ setPan({ x: 0, y: 0 })
134
+ }, [zoomReset])
135
+
136
+ return ReactDOM.createPortal(
137
+ <div
138
+ style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'var(--color-bg-base)', display: 'flex', flexDirection: 'column' }}
139
+ onMouseMove={handleMouseMove}
140
+ onMouseUp={handleMouseUp}
141
+ onMouseLeave={handleMouseUp}
142
+ >
143
+ {/* Header bar */}
144
+ <div
145
+ className="flex items-center justify-between px-4 py-2 border-b border-border-subtle"
146
+ style={{ flexShrink: 0, background: 'var(--color-bg-surface)' }}
147
+ >
148
+ <span className="text-body text-text-secondary" style={{ fontSize: '13px' }}>Mermaid Diagram</span>
149
+ <div className="flex items-center gap-2">
150
+ <ZoomToolbar zoom={zoom} onZoomIn={zoomIn} onZoomOut={zoomOut} onZoomReset={handleResetView} />
151
+ <button
152
+ className="flex items-center justify-center w-8 h-8 rounded hover:bg-bg-elevated text-text-secondary hover:text-text-primary transition-colors"
153
+ onClick={onClose}
154
+ title="Close (Esc)"
155
+ >
156
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
157
+ <line x1="18" y1="6" x2="6" y2="18" />
158
+ <line x1="6" y1="6" x2="18" y2="18" />
159
+ </svg>
160
+ </button>
161
+ </div>
162
+ </div>
163
+ {/* Diagram area */}
164
+ <div
165
+ style={{ flex: 1, overflow: 'hidden', cursor: dragging.current ? 'grabbing' : 'grab' }}
166
+ onWheel={handleWheel}
167
+ onMouseDown={handleMouseDown}
168
+ >
169
+ <div
170
+ ref={modalContentRef}
171
+ style={{
172
+ width: '100%',
173
+ height: '100%',
174
+ display: 'flex',
175
+ alignItems: 'center',
176
+ justifyContent: 'center',
177
+ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
178
+ transformOrigin: 'center center',
179
+ transition: dragging.current ? 'none' : 'transform 0.1s ease-out',
180
+ }}
181
+ dangerouslySetInnerHTML={{ __html: svgHtml }}
182
+ />
183
+ </div>
184
+ </div>,
185
+ document.body,
186
+ )
164
187
  }
165
188
 
166
189
  export function Mermaid({ children }: MermaidProps) {
167
- const containerRef = useRef<HTMLDivElement>(null);
168
- const [error, setError] = useState<string | null>(null);
169
- const [showFullscreen, setShowFullscreen] = useState(false);
170
- const [svgHtml, setSvgHtml] = useState("");
171
- const source = typeof children === "string" ? children : String(children ?? "");
172
- const { annotations, addAnnotationWithId, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
173
- const canvasFile = useCanvasFile();
174
- const [nodePopover, setNodePopover] = useState<NodePopoverState | null>(null);
175
- const { zoom, zoomIn, zoomOut, zoomReset, setZoom } = useZoom(1);
176
-
177
- const annotationsRef = useRef(annotations);
178
- annotationsRef.current = annotations;
179
-
180
- const handleNodeClick = useCallback((anchorEl: HTMLElement, snippet: string, prefix: string) => {
181
- const diagramSnippet = `[Diagram ${prefix.toLowerCase()}] ${snippet}`;
182
- const existing = annotationsRef.current.find((a) => a.snippet === diagramSnippet);
183
- if (existing) {
184
- setActiveAnnotationId(existing.id);
185
- } else {
186
- setNodePopover({ anchorEl, snippet, prefix });
187
- }
188
- }, [setActiveAnnotationId]);
189
-
190
- const handleNodeClickRef = useRef(handleNodeClick);
191
- handleNodeClickRef.current = handleNodeClick;
192
-
193
- useEffect(() => {
194
- let cancelled = false;
195
- const render = async () => {
196
- try {
197
- const mermaid = (window as any).mermaid;
198
- if (!mermaid) { setError("Mermaid library not loaded"); return; }
199
- const theme = document.documentElement.dataset.theme === "light" ? "default" : "dark";
200
- mermaid.initialize({ startOnLoad: false, theme });
201
- const id = `mermaid-${Math.random().toString(36).slice(2)}`;
202
- const { svg } = await mermaid.render(id, source.trim());
203
- if (!cancelled && containerRef.current) {
204
- containerRef.current.innerHTML = svg;
205
- setSvgHtml(svg);
206
- setupNodeAnnotations(containerRef.current, (el, snippet, prefix) => handleNodeClickRef.current(el, snippet, prefix));
207
- highlightAnnotatedNodes(containerRef.current, annotationsRef.current);
208
- }
209
- setError(null);
210
- } catch (e: any) {
211
- if (!cancelled) setError(e.message || "Invalid diagram");
212
- }
213
- };
214
- render();
215
-
216
- // Re-render on theme change
217
- const observer = new MutationObserver((mutations) => {
218
- for (const m of mutations) {
219
- if (m.attributeName === "data-theme") render();
220
- }
221
- });
222
- observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
223
-
224
- return () => { cancelled = true; observer.disconnect(); };
225
- }, [source]);
226
-
227
- // Update visual highlights when annotations change
228
- useEffect(() => {
229
- if (containerRef.current) {
230
- highlightAnnotatedNodes(containerRef.current, annotations, activeAnnotationId);
231
- }
232
- }, [annotations, activeAnnotationId]);
233
-
234
- // Wheel zoom on inline diagram
235
- const handleWheel = useCallback((e: WheelLike) => {
236
- if (!e.ctrlKey && !e.metaKey) return;
237
- e.preventDefault();
238
- const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
239
- setZoom(z => Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, +(z + delta).toFixed(2))));
240
- }, [setZoom]);
241
-
242
- if (error) {
243
- return (
244
- <div className="bg-accent-red-muted rounded-md p-4 mt-3 text-body text-accent-red">
245
- Mermaid error: {error}
246
- <pre className="mt-2 text-tiny text-text-tertiary whitespace-pre-wrap">{source}</pre>
247
- </div>
248
- );
249
- }
250
-
251
- const scrollContainer = containerRef.current?.closest("#plan-scroll-container") as HTMLElement | null;
252
-
253
- return (
254
- <>
255
- <div className="mt-3 mermaid-container" data-md="mermaid" data-md-source={source.trim()}>
256
- {/* Zoom toolbar */}
257
- <div className="flex justify-end mb-1">
258
- <ZoomToolbar
259
- zoom={zoom}
260
- onZoomIn={zoomIn}
261
- onZoomOut={zoomOut}
262
- onZoomReset={zoomReset}
263
- onFullscreen={() => setShowFullscreen(true)}
264
- />
265
- </div>
266
- {/* Zoomable diagram */}
267
- <div style={{ overflow: "auto", maxHeight: "70vh" }} onWheel={handleWheel}>
268
- <div
269
- ref={containerRef}
270
- className="flex justify-center"
271
- style={{
272
- transform: `scale(${zoom})`,
273
- transformOrigin: "top center",
274
- transition: "transform 0.1s ease-out",
275
- minHeight: zoom < 1 ? undefined : undefined,
276
- }}
277
- />
278
- </div>
279
- </div>
280
- {nodePopover && (
281
- <AnnotationCreatePopover
282
- anchorEl={nodePopover.anchorEl}
283
- scrollContainer={scrollContainer}
284
- snippet={nodePopover.snippet}
285
- header={
286
- <div style={{ fontSize: "12px", color: "var(--color-text-tertiary)", marginBottom: "8px", lineHeight: "1.4", display: "flex", alignItems: "center", gap: "6px" }}>
287
- <span style={{ width: "8px", height: "8px", borderRadius: "50%", background: "var(--color-accent-amber)", flexShrink: 0 }} />
288
- {nodePopover.snippet}
289
- </div>
290
- }
291
- onAdd={(note, images) => {
292
- const id = `ann-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
293
- addAnnotationWithId(id, `[Diagram ${nodePopover.prefix.toLowerCase()}] ${nodePopover.snippet}`, note, undefined, undefined, images, canvasFile || undefined);
294
- setNodePopover(null);
295
- }}
296
- onCancel={() => setNodePopover(null)}
297
- />
298
- )}
299
- {showFullscreen && svgHtml && (
300
- <MermaidFullscreenModal
301
- svgHtml={svgHtml}
302
- source={source}
303
- onClose={() => setShowFullscreen(false)}
304
- />
305
- )}
306
- </>
307
- );
190
+ const containerRef = useRef<HTMLDivElement>(null)
191
+ const [error, setError] = useState<string | null>(null)
192
+ const [showFullscreen, setShowFullscreen] = useState(false)
193
+ const [svgHtml, setSvgHtml] = useState('')
194
+ const source = typeof children === 'string' ? children : String(children ?? '')
195
+ const { annotations, addAnnotationWithId, activeAnnotationId, setActiveAnnotationId } = useAnnotations()
196
+ const canvasFile = useCanvasFile()
197
+ const [nodePopover, setNodePopover] = useState<NodePopoverState | null>(null)
198
+ const { zoom, zoomIn, zoomOut, zoomReset, setZoom } = useZoom(1)
199
+
200
+ const annotationsRef = useRef(annotations)
201
+ annotationsRef.current = annotations
202
+
203
+ const handleNodeClick = useCallback((anchorEl: HTMLElement, snippet: string, prefix: string) => {
204
+ const diagramSnippet = `[Diagram ${prefix.toLowerCase()}] ${snippet}`
205
+ const existing = annotationsRef.current.find((a) => a.snippet === diagramSnippet)
206
+ if (existing) {
207
+ setActiveAnnotationId(existing.id)
208
+ } else {
209
+ setNodePopover({ anchorEl, snippet, prefix })
210
+ }
211
+ }, [setActiveAnnotationId])
212
+
213
+ const handleNodeClickRef = useRef(handleNodeClick)
214
+ handleNodeClickRef.current = handleNodeClick
215
+
216
+ useEffect(() => {
217
+ let cancelled = false
218
+ const render = async () => {
219
+ try {
220
+ const mermaid = pageMermaid()
221
+ if (!mermaid) {
222
+ setError('Mermaid library not loaded')
223
+ return
224
+ }
225
+ const theme = document.documentElement.dataset['theme'] === 'light' ? 'default' : 'dark'
226
+ mermaid.initialize({ startOnLoad: false, theme })
227
+ const id = `mermaid-${Math.random().toString(36).slice(2)}`
228
+ const { svg } = await mermaid.render(id, source.trim())
229
+ if (!cancelled && containerRef.current) {
230
+ containerRef.current.innerHTML = svg
231
+ setSvgHtml(svg)
232
+ setupNodeAnnotations(containerRef.current, (el, snippet, prefix) => handleNodeClickRef.current(el, snippet, prefix))
233
+ highlightAnnotatedNodes(containerRef.current, annotationsRef.current)
234
+ }
235
+ setError(null)
236
+ } catch (e) {
237
+ if (!cancelled) setError((e instanceof Error && e.message) || 'Invalid diagram')
238
+ }
239
+ }
240
+ render()
241
+
242
+ // Re-render on theme change
243
+ const observer = new MutationObserver((mutations) => {
244
+ for (const m of mutations) {
245
+ if (m.attributeName === 'data-theme') render()
246
+ }
247
+ })
248
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
249
+
250
+ return () => {
251
+ cancelled = true
252
+ observer.disconnect()
253
+ }
254
+ }, [source])
255
+
256
+ // Update visual highlights when annotations change
257
+ useEffect(() => {
258
+ if (containerRef.current) {
259
+ highlightAnnotatedNodes(containerRef.current, annotations, activeAnnotationId)
260
+ }
261
+ }, [annotations, activeAnnotationId])
262
+
263
+ // Wheel zoom on inline diagram
264
+ const handleWheel = useCallback((e: WheelLike) => {
265
+ if (!e.ctrlKey && !e.metaKey) return
266
+ e.preventDefault()
267
+ const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP
268
+ setZoom(z => Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, +(z + delta).toFixed(2))))
269
+ }, [setZoom])
270
+
271
+ if (error) {
272
+ return (
273
+ <div className="bg-accent-red-muted rounded-md p-4 mt-3 text-body text-accent-red">
274
+ Mermaid error: {error}
275
+ <pre className="mt-2 text-tiny text-text-tertiary whitespace-pre-wrap">{source}</pre>
276
+ </div>
277
+ )
278
+ }
279
+
280
+ const scrollContainer = containerRef.current?.closest('#plan-scroll-container') as HTMLElement | null
281
+
282
+ return (
283
+ <>
284
+ <div className="mt-3 mermaid-container" data-md="mermaid" data-md-source={source.trim()}>
285
+ {/* Zoom toolbar */}
286
+ <div className="flex justify-end mb-1">
287
+ <ZoomToolbar
288
+ zoom={zoom}
289
+ onZoomIn={zoomIn}
290
+ onZoomOut={zoomOut}
291
+ onZoomReset={zoomReset}
292
+ onFullscreen={() => setShowFullscreen(true)}
293
+ />
294
+ </div>
295
+ {/* Zoomable diagram */}
296
+ <div style={{ overflow: 'auto', maxHeight: '70vh' }} onWheel={handleWheel}>
297
+ <div
298
+ ref={containerRef}
299
+ className="flex justify-center"
300
+ style={{
301
+ transform: `scale(${zoom})`,
302
+ transformOrigin: 'top center',
303
+ transition: 'transform 0.1s ease-out',
304
+ minHeight: zoom < 1 ? undefined : undefined,
305
+ }}
306
+ />
307
+ </div>
308
+ </div>
309
+ {nodePopover && (
310
+ <AnnotationCreatePopover
311
+ anchorEl={nodePopover.anchorEl}
312
+ scrollContainer={scrollContainer}
313
+ snippet={nodePopover.snippet}
314
+ header={
315
+ <div
316
+ style={{
317
+ fontSize: '12px',
318
+ color: 'var(--color-text-tertiary)',
319
+ marginBottom: '8px',
320
+ lineHeight: '1.4',
321
+ display: 'flex',
322
+ alignItems: 'center',
323
+ gap: '6px',
324
+ }}
325
+ >
326
+ <span style={{ width: '8px', height: '8px', borderRadius: '50%', background: 'var(--color-accent-amber)', flexShrink: 0 }} />
327
+ {nodePopover.snippet}
328
+ </div>
329
+ }
330
+ onAdd={(note, images) => {
331
+ const id = `ann-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
332
+ addAnnotationWithId(
333
+ id,
334
+ `[Diagram ${nodePopover.prefix.toLowerCase()}] ${nodePopover.snippet}`,
335
+ note,
336
+ undefined,
337
+ undefined,
338
+ images,
339
+ canvasFile || undefined,
340
+ )
341
+ setNodePopover(null)
342
+ }}
343
+ onCancel={() => setNodePopover(null)}
344
+ />
345
+ )}
346
+ {showFullscreen && svgHtml && (
347
+ <MermaidFullscreenModal
348
+ svgHtml={svgHtml}
349
+ source={source}
350
+ onClose={() => setShowFullscreen(false)}
351
+ />
352
+ )}
353
+ </>
354
+ )
308
355
  }
309
356
 
310
357
  function setupNodeAnnotations(
311
- container: HTMLElement,
312
- showPopover: (anchorEl: HTMLElement, snippet: string, prefix: string) => void,
358
+ container: HTMLElement,
359
+ showPopover: (anchorEl: HTMLElement, snippet: string, prefix: string) => void,
313
360
  ) {
314
- const makeClickable = (el: SVGElement, getSnippet: () => string, prefix: string) => {
315
- el.style.cursor = "pointer";
316
- el.addEventListener("mouseenter", () => { el.style.filter = "brightness(1.2)"; });
317
- el.addEventListener("mouseleave", () => { el.style.filter = ""; });
318
- el.addEventListener("click", (e) => {
319
- e.stopPropagation();
320
- e.preventDefault();
321
- showPopover(el as unknown as HTMLElement, getSnippet(), prefix);
322
- });
323
- };
324
-
325
- // Nodes
326
- for (const node of container.querySelectorAll(".node")) {
327
- makeClickable(node as SVGElement, () => {
328
- const labelEl = node.querySelector(".nodeLabel") || node.querySelector("foreignObject span") || node.querySelector("text");
329
- return labelEl?.textContent?.trim() || "Node";
330
- }, "Node");
331
- }
332
-
333
- // Edge paths (connections)
334
- for (const edge of container.querySelectorAll(".edgePath")) {
335
- const el = edge as SVGElement;
336
- // Widen hit area for thin lines
337
- const path = el.querySelector("path");
338
- if (path) {
339
- path.style.strokeWidth = Math.max(parseFloat(path.style.strokeWidth || "1"), 8) + "px";
340
- path.style.strokeOpacity = "0";
341
- const clone = path.cloneNode(true) as SVGPathElement;
342
- clone.style.strokeWidth = "";
343
- clone.style.strokeOpacity = "";
344
- el.insertBefore(clone, path);
345
- }
346
- // Find the label for this edge (sibling edgeLabel)
347
- const edgeId = el.id || "";
348
- makeClickable(el, () => {
349
- // Try to find matching edge label
350
- const labels = container.querySelectorAll(".edgeLabel");
351
- for (const label of labels) {
352
- if (label.id && edgeId && label.id.replace("label", "").includes(edgeId.replace("L-", "").replace("edge", ""))) {
353
- const text = label.textContent?.trim();
354
- if (text) return text;
355
- }
356
- }
357
- // Fallback: find nearest edge label by position
358
- return "Connection";
359
- }, "Edge");
360
- }
361
-
362
- // Edge labels
363
- for (const label of container.querySelectorAll(".edgeLabel")) {
364
- const text = label.textContent?.trim();
365
- if (!text) continue;
366
- makeClickable(label as SVGElement, () => text, "Edge");
367
- }
368
-
369
- // Sequence diagram: actors (participants)
370
- for (const actor of container.querySelectorAll(".actor")) {
371
- const textEl = actor.closest("g")?.querySelector("text") || actor.parentElement?.querySelector("text");
372
- if (!textEl) continue;
373
- // The actor rect and text are siblings in a <g> — make the whole <g> clickable
374
- const group = actor.closest("g") as SVGElement | null;
375
- if (!group || group.hasAttribute("data-clickable")) continue;
376
- group.setAttribute("data-clickable", "true");
377
- makeClickable(group, () => textEl.textContent?.trim() || "Participant", "Node");
378
- }
379
-
380
- // Sequence diagram: message labels
381
- for (const msgText of container.querySelectorAll(".messageText")) {
382
- const text = msgText.textContent?.trim();
383
- if (!text) continue;
384
- makeClickable(msgText as SVGElement, () => text, "Edge");
385
- }
386
-
387
- // ER diagram: entities
388
- for (const entity of container.querySelectorAll(".entityBox")) {
389
- const group = entity.closest("g") as SVGElement | null;
390
- if (!group || group.hasAttribute("data-clickable")) continue;
391
- const labelEl = group.querySelector(".entityLabel");
392
- if (!labelEl) continue;
393
- group.setAttribute("data-clickable", "true");
394
- makeClickable(group, () => labelEl.textContent?.trim() || "Entity", "Node");
395
- }
396
-
397
- // ER diagram: relationship labels
398
- for (const label of container.querySelectorAll(".relationshipLabel")) {
399
- const text = label.textContent?.trim();
400
- if (!text) continue;
401
- const group = label.closest("g") as SVGElement | null;
402
- const target = group || label as SVGElement;
403
- if (target.hasAttribute("data-clickable")) continue;
404
- target.setAttribute("data-clickable", "true");
405
- makeClickable(target, () => text, "Edge");
406
- }
407
-
408
- // Class diagram: class groups
409
- for (const classGroup of container.querySelectorAll(".classGroup")) {
410
- const el = classGroup as SVGElement;
411
- if (el.hasAttribute("data-clickable")) continue;
412
- const labelEl = el.querySelector(".classTitle, .classTitleText, text");
413
- if (!labelEl) continue;
414
- el.setAttribute("data-clickable", "true");
415
- makeClickable(el, () => labelEl.textContent?.trim() || "Class", "Node");
416
- }
417
-
418
- // State diagram: states
419
- for (const state of container.querySelectorAll(".statediagram-state")) {
420
- const el = state as SVGElement;
421
- if (el.hasAttribute("data-clickable")) continue;
422
- const labelEl = el.querySelector(".state-title, text");
423
- if (!labelEl) continue;
424
- el.setAttribute("data-clickable", "true");
425
- makeClickable(el, () => labelEl.textContent?.trim() || "State", "Node");
426
- }
427
-
428
- // Mindmap: nodes
429
- for (const node of container.querySelectorAll(".mindmap-node")) {
430
- const el = node as SVGElement;
431
- if (el.hasAttribute("data-clickable")) continue;
432
- const labelEl = el.querySelector(".mindmap-node-label, text");
433
- if (!labelEl) continue;
434
- el.setAttribute("data-clickable", "true");
435
- makeClickable(el, () => labelEl.textContent?.trim() || "Node", "Node");
436
- }
361
+ const makeClickable = (el: SVGElement, getSnippet: () => string, prefix: string) => {
362
+ el.style.cursor = 'pointer'
363
+ el.addEventListener('mouseenter', () => {
364
+ el.style.filter = 'brightness(1.2)'
365
+ })
366
+ el.addEventListener('mouseleave', () => {
367
+ el.style.filter = ''
368
+ })
369
+ el.addEventListener('click', (e) => {
370
+ e.stopPropagation()
371
+ e.preventDefault()
372
+ showPopover(el as unknown as HTMLElement, getSnippet(), prefix)
373
+ })
374
+ }
375
+
376
+ // Nodes
377
+ for (const node of container.querySelectorAll('.node')) {
378
+ makeClickable(node as SVGElement, () => {
379
+ const labelEl = node.querySelector('.nodeLabel') || node.querySelector('foreignObject span') || node.querySelector('text')
380
+ return labelEl?.textContent?.trim() || 'Node'
381
+ }, 'Node')
382
+ }
383
+
384
+ // Edge paths (connections)
385
+ for (const edge of container.querySelectorAll('.edgePath')) {
386
+ const el = edge as SVGElement
387
+ // Widen hit area for thin lines
388
+ const path = el.querySelector('path')
389
+ if (path) {
390
+ path.style.strokeWidth = Math.max(parseFloat(path.style.strokeWidth || '1'), 8) + 'px'
391
+ path.style.strokeOpacity = '0'
392
+ const clone = path.cloneNode(true) as SVGPathElement
393
+ clone.style.strokeWidth = ''
394
+ clone.style.strokeOpacity = ''
395
+ el.insertBefore(clone, path)
396
+ }
397
+ // Find the label for this edge (sibling edgeLabel)
398
+ const edgeId = el.id || ''
399
+ makeClickable(el, () => {
400
+ // Try to find matching edge label
401
+ const labels = container.querySelectorAll('.edgeLabel')
402
+ for (const label of labels) {
403
+ if (label.id && edgeId && label.id.replace('label', '').includes(edgeId.replace('L-', '').replace('edge', ''))) {
404
+ const text = label.textContent?.trim()
405
+ if (text) return text
406
+ }
407
+ }
408
+ // Fallback: find nearest edge label by position
409
+ return 'Connection'
410
+ }, 'Edge')
411
+ }
412
+
413
+ // Edge labels
414
+ for (const label of container.querySelectorAll('.edgeLabel')) {
415
+ const text = label.textContent?.trim()
416
+ if (!text) continue
417
+ makeClickable(label as SVGElement, () => text, 'Edge')
418
+ }
419
+
420
+ // Sequence diagram: actors (participants)
421
+ for (const actor of container.querySelectorAll('.actor')) {
422
+ const textEl = actor.closest('g')?.querySelector('text') || actor.parentElement?.querySelector('text')
423
+ if (!textEl) continue
424
+ // The actor rect and text are siblings in a <g> — make the whole <g> clickable
425
+ const group = actor.closest('g') as SVGElement | null
426
+ if (!group || group.hasAttribute('data-clickable')) continue
427
+ group.setAttribute('data-clickable', 'true')
428
+ makeClickable(group, () => textEl.textContent?.trim() || 'Participant', 'Node')
429
+ }
430
+
431
+ // Sequence diagram: message labels
432
+ for (const msgText of container.querySelectorAll('.messageText')) {
433
+ const text = msgText.textContent?.trim()
434
+ if (!text) continue
435
+ makeClickable(msgText as SVGElement, () => text, 'Edge')
436
+ }
437
+
438
+ // ER diagram: entities
439
+ for (const entity of container.querySelectorAll('.entityBox')) {
440
+ const group = entity.closest('g') as SVGElement | null
441
+ if (!group || group.hasAttribute('data-clickable')) continue
442
+ const labelEl = group.querySelector('.entityLabel')
443
+ if (!labelEl) continue
444
+ group.setAttribute('data-clickable', 'true')
445
+ makeClickable(group, () => labelEl.textContent?.trim() || 'Entity', 'Node')
446
+ }
447
+
448
+ // ER diagram: relationship labels
449
+ for (const label of container.querySelectorAll('.relationshipLabel')) {
450
+ const text = label.textContent?.trim()
451
+ if (!text) continue
452
+ const group = label.closest('g') as SVGElement | null
453
+ const target = group || label as SVGElement
454
+ if (target.hasAttribute('data-clickable')) continue
455
+ target.setAttribute('data-clickable', 'true')
456
+ makeClickable(target, () => text, 'Edge')
457
+ }
458
+
459
+ // Class diagram: class groups
460
+ for (const classGroup of container.querySelectorAll('.classGroup')) {
461
+ const el = classGroup as SVGElement
462
+ if (el.hasAttribute('data-clickable')) continue
463
+ const labelEl = el.querySelector('.classTitle, .classTitleText, text')
464
+ if (!labelEl) continue
465
+ el.setAttribute('data-clickable', 'true')
466
+ makeClickable(el, () => labelEl.textContent?.trim() || 'Class', 'Node')
467
+ }
468
+
469
+ // State diagram: states
470
+ for (const state of container.querySelectorAll('.statediagram-state')) {
471
+ const el = state as SVGElement
472
+ if (el.hasAttribute('data-clickable')) continue
473
+ const labelEl = el.querySelector('.state-title, text')
474
+ if (!labelEl) continue
475
+ el.setAttribute('data-clickable', 'true')
476
+ makeClickable(el, () => labelEl.textContent?.trim() || 'State', 'Node')
477
+ }
478
+
479
+ // Mindmap: nodes
480
+ for (const node of container.querySelectorAll('.mindmap-node')) {
481
+ const el = node as SVGElement
482
+ if (el.hasAttribute('data-clickable')) continue
483
+ const labelEl = el.querySelector('.mindmap-node-label, text')
484
+ if (!labelEl) continue
485
+ el.setAttribute('data-clickable', 'true')
486
+ makeClickable(el, () => labelEl.textContent?.trim() || 'Node', 'Node')
487
+ }
437
488
  }
438
489
 
439
490
  function highlightAnnotatedNodes(container: HTMLElement, annotations: Annotation[], activeId?: string | null) {
440
- // Collect annotated diagram snippets
441
- const annotatedSnippets = new Map<string, string>();
442
- for (const ann of annotations) {
443
- const label = ann.snippet.match(/^\[Diagram (?:node|edge)\] (.+)$/)?.[1];
444
- if (label) annotatedSnippets.set(label, ann.id);
445
- }
446
-
447
- // Reset all node outlines
448
- for (const el of container.querySelectorAll("[data-annotated]")) {
449
- (el as SVGElement).style.outline = "";
450
- (el as SVGElement).style.outlineOffset = "";
451
- (el as SVGElement).style.borderRadius = "";
452
- el.removeAttribute("data-annotated");
453
- }
454
-
455
- if (annotatedSnippets.size === 0) return;
456
-
457
- // Highlight nodes
458
- for (const node of container.querySelectorAll(".node")) {
459
- const labelEl = node.querySelector(".nodeLabel") || node.querySelector("foreignObject span") || node.querySelector("text");
460
- const text = labelEl?.textContent?.trim();
461
- if (!text || !annotatedSnippets.has(text)) continue;
462
- const annId = annotatedSnippets.get(text)!;
463
- const el = node as SVGElement;
464
- el.setAttribute("data-annotated", annId);
465
- const isActive = annId === activeId;
466
- el.style.outline = isActive
467
- ? "2px solid var(--color-highlight-border)"
468
- : "2px solid var(--color-highlight-annotation)";
469
- el.style.outlineOffset = "2px";
470
- el.style.borderRadius = "4px";
471
- }
472
-
473
- // Highlight edge labels
474
- for (const label of container.querySelectorAll(".edgeLabel")) {
475
- const text = label.textContent?.trim();
476
- if (!text || !annotatedSnippets.has(text)) continue;
477
- const annId = annotatedSnippets.get(text)!;
478
- const el = label as SVGElement;
479
- el.setAttribute("data-annotated", annId);
480
- const isActive = annId === activeId;
481
- el.style.outline = isActive
482
- ? "2px solid var(--color-highlight-border)"
483
- : "2px solid var(--color-highlight-annotation)";
484
- el.style.outlineOffset = "2px";
485
- el.style.borderRadius = "4px";
486
- }
487
-
488
- // Highlight sequence diagram actors
489
- for (const actor of container.querySelectorAll(".actor")) {
490
- const group = actor.closest("g") as SVGElement | null;
491
- if (!group) continue;
492
- const textEl = group.querySelector("text");
493
- const text = textEl?.textContent?.trim();
494
- if (!text || !annotatedSnippets.has(text)) continue;
495
- if (group.hasAttribute("data-annotated")) continue;
496
- const annId = annotatedSnippets.get(text)!;
497
- group.setAttribute("data-annotated", annId);
498
- const isActive = annId === activeId;
499
- const rect = group.querySelector("rect");
500
- if (rect) {
501
- rect.style.outline = isActive
502
- ? "2px solid var(--color-highlight-border)"
503
- : "2px solid var(--color-highlight-annotation)";
504
- rect.style.outlineOffset = "2px";
505
- rect.style.borderRadius = "4px";
506
- }
507
- }
508
-
509
- // Highlight sequence diagram message labels
510
- for (const msgText of container.querySelectorAll(".messageText")) {
511
- const text = msgText.textContent?.trim();
512
- if (!text || !annotatedSnippets.has(text)) continue;
513
- const annId = annotatedSnippets.get(text)!;
514
- const el = msgText as SVGElement;
515
- el.setAttribute("data-annotated", annId);
516
- const isActive = annId === activeId;
517
- el.style.outline = isActive
518
- ? "2px solid var(--color-highlight-border)"
519
- : "2px solid var(--color-highlight-annotation)";
520
- el.style.outlineOffset = "2px";
521
- el.style.borderRadius = "4px";
522
- }
523
-
524
- // Helper for group-based highlights (ER entities, class groups, states, mindmap nodes)
525
- const highlightGroups = (
526
- selector: string,
527
- labelSelector: string,
528
- findGroup: (el: Element) => SVGElement | null,
529
- ) => {
530
- for (const el of container.querySelectorAll(selector)) {
531
- const group = findGroup(el);
532
- if (!group || group.hasAttribute("data-annotated")) continue;
533
- const labelEl = group.querySelector(labelSelector);
534
- const text = labelEl?.textContent?.trim();
535
- if (!text || !annotatedSnippets.has(text)) continue;
536
- const annId = annotatedSnippets.get(text)!;
537
- group.setAttribute("data-annotated", annId);
538
- const isActive = annId === activeId;
539
- const target = group.querySelector("rect, circle, ellipse, polygon") as SVGElement || group;
540
- target.style.outline = isActive
541
- ? "2px solid var(--color-highlight-border)"
542
- : "2px solid var(--color-highlight-annotation)";
543
- target.style.outlineOffset = "2px";
544
- target.style.borderRadius = "4px";
545
- }
546
- };
547
-
548
- // ER diagram: entities
549
- highlightGroups(".entityBox", ".entityLabel", (el) => el.closest("g") as SVGElement | null);
550
-
551
- // ER diagram: relationship labels
552
- for (const label of container.querySelectorAll(".relationshipLabel")) {
553
- const group = label.closest("g") as SVGElement | null;
554
- const target = group || label as SVGElement;
555
- if (target.hasAttribute("data-annotated")) continue;
556
- const text = label.textContent?.trim();
557
- if (!text || !annotatedSnippets.has(text)) continue;
558
- const annId = annotatedSnippets.get(text)!;
559
- target.setAttribute("data-annotated", annId);
560
- const isActive = annId === activeId;
561
- (target as SVGElement).style.outline = isActive
562
- ? "2px solid var(--color-highlight-border)"
563
- : "2px solid var(--color-highlight-annotation)";
564
- (target as SVGElement).style.outlineOffset = "2px";
565
- (target as SVGElement).style.borderRadius = "4px";
566
- }
567
-
568
- // Class diagram
569
- highlightGroups(".classGroup", ".classTitle, .classTitleText, text", (el) => el as SVGElement);
570
-
571
- // State diagram
572
- highlightGroups(".statediagram-state", ".state-title, text", (el) => el as SVGElement);
573
-
574
- // Mindmap
575
- highlightGroups(".mindmap-node", ".mindmap-node-label, text", (el) => el as SVGElement);
491
+ // Collect annotated diagram snippets
492
+ const annotatedSnippets = new Map<string, string>()
493
+ for (const ann of annotations) {
494
+ const label = ann.snippet.match(/^\[Diagram (?:node|edge)\] (.+)$/)?.[1]
495
+ if (label) annotatedSnippets.set(label, ann.id)
496
+ }
497
+
498
+ // Reset all node outlines
499
+ for (const el of container.querySelectorAll('[data-annotated]')) {
500
+ ;(el as SVGElement).style.outline = ''
501
+ ;(el as SVGElement).style.outlineOffset = ''
502
+ ;(el as SVGElement).style.borderRadius = ''
503
+ el.removeAttribute('data-annotated')
504
+ }
505
+
506
+ if (annotatedSnippets.size === 0) return
507
+
508
+ // Highlight nodes
509
+ for (const node of container.querySelectorAll('.node')) {
510
+ const labelEl = node.querySelector('.nodeLabel') || node.querySelector('foreignObject span') || node.querySelector('text')
511
+ const text = labelEl?.textContent?.trim()
512
+ if (!text || !annotatedSnippets.has(text)) continue
513
+ const annId = annotatedSnippets.get(text)!
514
+ const el = node as SVGElement
515
+ el.setAttribute('data-annotated', annId)
516
+ const isActive = annId === activeId
517
+ el.style.outline = isActive
518
+ ? '2px solid var(--color-highlight-border)'
519
+ : '2px solid var(--color-highlight-annotation)'
520
+ el.style.outlineOffset = '2px'
521
+ el.style.borderRadius = '4px'
522
+ }
523
+
524
+ // Highlight edge labels
525
+ for (const label of container.querySelectorAll('.edgeLabel')) {
526
+ const text = label.textContent?.trim()
527
+ if (!text || !annotatedSnippets.has(text)) continue
528
+ const annId = annotatedSnippets.get(text)!
529
+ const el = label as SVGElement
530
+ el.setAttribute('data-annotated', annId)
531
+ const isActive = annId === activeId
532
+ el.style.outline = isActive
533
+ ? '2px solid var(--color-highlight-border)'
534
+ : '2px solid var(--color-highlight-annotation)'
535
+ el.style.outlineOffset = '2px'
536
+ el.style.borderRadius = '4px'
537
+ }
538
+
539
+ // Highlight sequence diagram actors
540
+ for (const actor of container.querySelectorAll('.actor')) {
541
+ const group = actor.closest('g') as SVGElement | null
542
+ if (!group) continue
543
+ const textEl = group.querySelector('text')
544
+ const text = textEl?.textContent?.trim()
545
+ if (!text || !annotatedSnippets.has(text)) continue
546
+ if (group.hasAttribute('data-annotated')) continue
547
+ const annId = annotatedSnippets.get(text)!
548
+ group.setAttribute('data-annotated', annId)
549
+ const isActive = annId === activeId
550
+ const rect = group.querySelector('rect')
551
+ if (rect) {
552
+ rect.style.outline = isActive
553
+ ? '2px solid var(--color-highlight-border)'
554
+ : '2px solid var(--color-highlight-annotation)'
555
+ rect.style.outlineOffset = '2px'
556
+ rect.style.borderRadius = '4px'
557
+ }
558
+ }
559
+
560
+ // Highlight sequence diagram message labels
561
+ for (const msgText of container.querySelectorAll('.messageText')) {
562
+ const text = msgText.textContent?.trim()
563
+ if (!text || !annotatedSnippets.has(text)) continue
564
+ const annId = annotatedSnippets.get(text)!
565
+ const el = msgText as SVGElement
566
+ el.setAttribute('data-annotated', annId)
567
+ const isActive = annId === activeId
568
+ el.style.outline = isActive
569
+ ? '2px solid var(--color-highlight-border)'
570
+ : '2px solid var(--color-highlight-annotation)'
571
+ el.style.outlineOffset = '2px'
572
+ el.style.borderRadius = '4px'
573
+ }
574
+
575
+ // Helper for group-based highlights (ER entities, class groups, states, mindmap nodes)
576
+ const highlightGroups = (
577
+ selector: string,
578
+ labelSelector: string,
579
+ findGroup: (el: Element) => SVGElement | null,
580
+ ) => {
581
+ for (const el of container.querySelectorAll(selector)) {
582
+ const group = findGroup(el)
583
+ if (!group || group.hasAttribute('data-annotated')) continue
584
+ const labelEl = group.querySelector(labelSelector)
585
+ const text = labelEl?.textContent?.trim()
586
+ if (!text || !annotatedSnippets.has(text)) continue
587
+ const annId = annotatedSnippets.get(text)!
588
+ group.setAttribute('data-annotated', annId)
589
+ const isActive = annId === activeId
590
+ const target = group.querySelector('rect, circle, ellipse, polygon') as SVGElement || group
591
+ target.style.outline = isActive
592
+ ? '2px solid var(--color-highlight-border)'
593
+ : '2px solid var(--color-highlight-annotation)'
594
+ target.style.outlineOffset = '2px'
595
+ target.style.borderRadius = '4px'
596
+ }
597
+ }
598
+
599
+ // ER diagram: entities
600
+ highlightGroups('.entityBox', '.entityLabel', (el) => el.closest('g') as SVGElement | null)
601
+
602
+ // ER diagram: relationship labels
603
+ for (const label of container.querySelectorAll('.relationshipLabel')) {
604
+ const group = label.closest('g') as SVGElement | null
605
+ const target = group || label as SVGElement
606
+ if (target.hasAttribute('data-annotated')) continue
607
+ const text = label.textContent?.trim()
608
+ if (!text || !annotatedSnippets.has(text)) continue
609
+ const annId = annotatedSnippets.get(text)!
610
+ target.setAttribute('data-annotated', annId)
611
+ const isActive = annId === activeId
612
+ ;(target as SVGElement).style.outline = isActive
613
+ ? '2px solid var(--color-highlight-border)'
614
+ : '2px solid var(--color-highlight-annotation)'
615
+ ;(target as SVGElement).style.outlineOffset = '2px'
616
+ ;(target as SVGElement).style.borderRadius = '4px'
617
+ }
618
+
619
+ // Class diagram
620
+ highlightGroups('.classGroup', '.classTitle, .classTitleText, text', (el) => el as SVGElement)
621
+
622
+ // State diagram
623
+ highlightGroups('.statediagram-state', '.state-title, text', (el) => el as SVGElement)
624
+
625
+ // Mindmap
626
+ highlightGroups('.mindmap-node', '.mindmap-node-label, text', (el) => el as SVGElement)
576
627
  }