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