agent-canvas 0.1.3 → 0.1.4
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/bin/agent-canvas.ts +0 -4
- package/daemon/client/App.tsx +13 -6
- package/daemon/client/SessionSwitcher.tsx +14 -4
- package/daemon/client/components/Mermaid.tsx +288 -1
- package/daemon/dist/client.css +1 -1
- package/daemon/dist/client.js +15 -15
- package/daemon/dist/components.js +5 -5
- package/daemon/src/server.ts +11 -2
- package/package.json +5 -2
- package/skills/canvas/components.md +9 -0
package/bin/agent-canvas.ts
CHANGED
|
@@ -285,10 +285,6 @@ async function ensureDaemon(): Promise<void> {
|
|
|
285
285
|
|
|
286
286
|
child.unref();
|
|
287
287
|
|
|
288
|
-
if (child.pid) {
|
|
289
|
-
writeFileSync(PID_FILE, String(child.pid));
|
|
290
|
-
}
|
|
291
|
-
|
|
292
288
|
for (let i = 0; i < 30; i++) {
|
|
293
289
|
await new Promise((r) => setTimeout(r, 100));
|
|
294
290
|
if (await isDaemonRunning()) {
|
package/daemon/client/App.tsx
CHANGED
|
@@ -143,6 +143,7 @@ function App() {
|
|
|
143
143
|
const [openFiles, setOpenFiles] = useState<string[]>([]);
|
|
144
144
|
const wsRef = useRef<WebSocket | null>(null);
|
|
145
145
|
|
|
146
|
+
const [projectRoot, setProjectRoot] = useState<string | undefined>();
|
|
146
147
|
const [mobileSidebar, setMobileSidebar] = useState(false);
|
|
147
148
|
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
|
148
149
|
const [rightCollapsed, setRightCollapsed] = useState(false);
|
|
@@ -150,14 +151,19 @@ function App() {
|
|
|
150
151
|
// Dynamic document title
|
|
151
152
|
useEffect(() => {
|
|
152
153
|
const parts = ["Agent Canvas"];
|
|
153
|
-
if (
|
|
154
|
+
if (projectRoot) {
|
|
155
|
+
const segments = projectRoot.replace(/\/+$/, "").split("/");
|
|
156
|
+
parts.push(segments[segments.length - 1] || sessionId);
|
|
157
|
+
} else if (sessionId) {
|
|
158
|
+
parts.push(sessionId);
|
|
159
|
+
}
|
|
154
160
|
if (activeView.type === "file") {
|
|
155
161
|
parts.push(activeView.path.split("/").pop() || activeView.path);
|
|
156
162
|
} else if (compareRevision) {
|
|
157
163
|
parts.push("Compare");
|
|
158
164
|
}
|
|
159
165
|
document.title = parts.join(" — ");
|
|
160
|
-
}, [sessionId, activeView, compareRevision]);
|
|
166
|
+
}, [sessionId, projectRoot, activeView, compareRevision]);
|
|
161
167
|
|
|
162
168
|
// Scroll position restore per view
|
|
163
169
|
const scrollPositions = useRef<Map<string, number>>(new Map());
|
|
@@ -206,6 +212,7 @@ function App() {
|
|
|
206
212
|
setSelectedRevision(data.currentRevision);
|
|
207
213
|
setRevisions(data.revisions || []);
|
|
208
214
|
}
|
|
215
|
+
if (data.projectRoot) setProjectRoot(data.projectRoot);
|
|
209
216
|
})
|
|
210
217
|
.catch(() => {});
|
|
211
218
|
}, [sessionId]);
|
|
@@ -259,7 +266,7 @@ function App() {
|
|
|
259
266
|
<ActiveViewCtx.Provider value={{ setActiveView }}>
|
|
260
267
|
<div className="min-h-screen bg-bg-base">
|
|
261
268
|
{/* Left panel — fixed to viewport */}
|
|
262
|
-
<LeftPanel sessionId={sessionId} connected={connected} onMobileSidebar={() => setMobileSidebar(!mobileSidebar)} collapsed={leftCollapsed} onToggle={() => setLeftCollapsed((c) => !c)} />
|
|
269
|
+
<LeftPanel sessionId={sessionId} projectRoot={projectRoot} connected={connected} onMobileSidebar={() => setMobileSidebar(!mobileSidebar)} collapsed={leftCollapsed} onToggle={() => setLeftCollapsed((c) => !c)} />
|
|
263
270
|
|
|
264
271
|
{/* Right panel — fixed to viewport, resizable (hidden in compare mode) */}
|
|
265
272
|
{compareRevision === null && (
|
|
@@ -283,7 +290,7 @@ function App() {
|
|
|
283
290
|
{/* Mobile top bar — only visible < lg */}
|
|
284
291
|
<div className="lg:hidden sticky top-0 z-20 flex items-center justify-between px-4 py-2 border-b border-border-subtle bg-bg-surface">
|
|
285
292
|
<div className="flex items-center gap-2">
|
|
286
|
-
<SessionSwitcher currentSessionId={sessionId} />
|
|
293
|
+
<SessionSwitcher currentSessionId={sessionId} projectRoot={projectRoot} />
|
|
287
294
|
<span className={`w-1.5 h-1.5 rounded-full ${connected ? "bg-accent-green" : "bg-accent-red"}`} />
|
|
288
295
|
</div>
|
|
289
296
|
<div className="flex items-center gap-1">
|
|
@@ -367,7 +374,7 @@ function App() {
|
|
|
367
374
|
);
|
|
368
375
|
}
|
|
369
376
|
|
|
370
|
-
function LeftPanel({ sessionId, connected, onMobileSidebar, collapsed, onToggle }: { sessionId: string; connected: boolean; onMobileSidebar: () => void; collapsed: boolean; onToggle: () => void }) {
|
|
377
|
+
function LeftPanel({ sessionId, projectRoot, connected, onMobileSidebar, collapsed, onToggle }: { sessionId: string; projectRoot?: string; connected: boolean; onMobileSidebar: () => void; collapsed: boolean; onToggle: () => void }) {
|
|
371
378
|
return (
|
|
372
379
|
<>
|
|
373
380
|
{/* Collapsed toggle button */}
|
|
@@ -390,7 +397,7 @@ function LeftPanel({ sessionId, connected, onMobileSidebar, collapsed, onToggle
|
|
|
390
397
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" className="text-text-tertiary flex-shrink-0">
|
|
391
398
|
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
|
392
399
|
</svg>
|
|
393
|
-
<SessionSwitcher currentSessionId={sessionId} />
|
|
400
|
+
<SessionSwitcher currentSessionId={sessionId} projectRoot={projectRoot} />
|
|
394
401
|
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${connected ? "bg-accent-green" : "bg-accent-red"}`} />
|
|
395
402
|
<button
|
|
396
403
|
onClick={onToggle}
|
|
@@ -2,15 +2,25 @@ import React, { useEffect, useState, useRef } from "react";
|
|
|
2
2
|
|
|
3
3
|
interface SessionInfo {
|
|
4
4
|
id: string;
|
|
5
|
-
|
|
5
|
+
projectRoot?: string;
|
|
6
|
+
currentRevision: number;
|
|
6
7
|
updatedAt: string;
|
|
7
8
|
}
|
|
8
9
|
|
|
10
|
+
function sessionDisplayName(id: string, projectRoot?: string): string {
|
|
11
|
+
if (projectRoot) {
|
|
12
|
+
const parts = projectRoot.replace(/\/+$/, "").split("/");
|
|
13
|
+
return parts[parts.length - 1] || id;
|
|
14
|
+
}
|
|
15
|
+
return id.length > 30 ? id.slice(0, 30) + "..." : id;
|
|
16
|
+
}
|
|
17
|
+
|
|
9
18
|
interface SessionSwitcherProps {
|
|
10
19
|
currentSessionId: string;
|
|
20
|
+
projectRoot?: string;
|
|
11
21
|
}
|
|
12
22
|
|
|
13
|
-
export function SessionSwitcher({ currentSessionId }: SessionSwitcherProps) {
|
|
23
|
+
export function SessionSwitcher({ currentSessionId, projectRoot }: SessionSwitcherProps) {
|
|
14
24
|
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
|
15
25
|
const [open, setOpen] = useState(false);
|
|
16
26
|
const ref = useRef<HTMLDivElement>(null);
|
|
@@ -37,7 +47,7 @@ export function SessionSwitcher({ currentSessionId }: SessionSwitcherProps) {
|
|
|
37
47
|
}, [open]);
|
|
38
48
|
|
|
39
49
|
const otherSessions = sessions.filter((s) => s.id !== currentSessionId);
|
|
40
|
-
const displayName = currentSessionId
|
|
50
|
+
const displayName = sessionDisplayName(currentSessionId, projectRoot);
|
|
41
51
|
|
|
42
52
|
return (
|
|
43
53
|
<div className="relative" ref={ref}>
|
|
@@ -62,7 +72,7 @@ export function SessionSwitcher({ currentSessionId }: SessionSwitcherProps) {
|
|
|
62
72
|
className="flex items-center gap-2.5 px-4 py-2.5 text-body font-body text-text-secondary hover:bg-bg-surface hover:text-text-primary transition-colors"
|
|
63
73
|
>
|
|
64
74
|
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${s.currentRevision > 0 ? "bg-accent-green" : "bg-border-hover"}`} />
|
|
65
|
-
<span className="truncate">{s.id}</span>
|
|
75
|
+
<span className="truncate">{sessionDisplayName(s.id, s.projectRoot)}</span>
|
|
66
76
|
</a>
|
|
67
77
|
))}
|
|
68
78
|
</div>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React, { useEffect, useRef, useState, useCallback } from "react";
|
|
2
|
+
import ReactDOM from "react-dom";
|
|
2
3
|
import { useAnnotations, Annotation } from "#canvas/runtime";
|
|
3
4
|
import { AnnotationCreatePopover } from "../Popover";
|
|
4
5
|
|
|
@@ -12,12 +13,154 @@ interface NodePopoverState {
|
|
|
12
13
|
prefix: string;
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
const ZOOM_STEP = 0.2;
|
|
17
|
+
const ZOOM_MIN = 0.25;
|
|
18
|
+
const ZOOM_MAX = 4;
|
|
19
|
+
|
|
20
|
+
function ZoomToolbar({ zoom, onZoomIn, onZoomOut, onZoomReset, onFullscreen }: {
|
|
21
|
+
zoom: number;
|
|
22
|
+
onZoomIn: () => void;
|
|
23
|
+
onZoomOut: () => void;
|
|
24
|
+
onZoomReset: () => void;
|
|
25
|
+
onFullscreen?: () => void;
|
|
26
|
+
}) {
|
|
27
|
+
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";
|
|
28
|
+
return (
|
|
29
|
+
<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" }}>
|
|
30
|
+
<button className={btnClass} onClick={onZoomOut} title="Zoom out" disabled={zoom <= ZOOM_MIN}>
|
|
31
|
+
<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>
|
|
32
|
+
</button>
|
|
33
|
+
<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">
|
|
34
|
+
{Math.round(zoom * 100)}%
|
|
35
|
+
</button>
|
|
36
|
+
<button className={btnClass} onClick={onZoomIn} title="Zoom in" disabled={zoom >= ZOOM_MAX}>
|
|
37
|
+
<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>
|
|
38
|
+
</button>
|
|
39
|
+
{onFullscreen && (
|
|
40
|
+
<>
|
|
41
|
+
<div className="w-px h-4 bg-border-subtle mx-0.5" />
|
|
42
|
+
<button className={btnClass} onClick={onFullscreen} title="Fullscreen">
|
|
43
|
+
<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>
|
|
44
|
+
</button>
|
|
45
|
+
</>
|
|
46
|
+
)}
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function useZoom(initialZoom = 1) {
|
|
52
|
+
const [zoom, setZoom] = useState(initialZoom);
|
|
53
|
+
const zoomIn = useCallback(() => setZoom(z => Math.min(ZOOM_MAX, +(z + ZOOM_STEP).toFixed(2))), []);
|
|
54
|
+
const zoomOut = useCallback(() => setZoom(z => Math.max(ZOOM_MIN, +(z - ZOOM_STEP).toFixed(2))), []);
|
|
55
|
+
const zoomReset = useCallback(() => setZoom(1), []);
|
|
56
|
+
return { zoom, zoomIn, zoomOut, zoomReset, setZoom };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function MermaidFullscreenModal({ svgHtml, source, onClose }: {
|
|
60
|
+
svgHtml: string;
|
|
61
|
+
source: string;
|
|
62
|
+
onClose: () => void;
|
|
63
|
+
}) {
|
|
64
|
+
const modalContentRef = useRef<HTMLDivElement>(null);
|
|
65
|
+
const { zoom, zoomIn, zoomOut, zoomReset, setZoom } = useZoom(1);
|
|
66
|
+
const [pan, setPan] = useState({ x: 0, y: 0 });
|
|
67
|
+
const dragging = useRef<{ startX: number; startY: number; panX: number; panY: number } | null>(null);
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
71
|
+
if (e.key === "Escape") onClose();
|
|
72
|
+
};
|
|
73
|
+
window.addEventListener("keydown", onKeyDown);
|
|
74
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
75
|
+
}, [onClose]);
|
|
76
|
+
|
|
77
|
+
// Wheel zoom
|
|
78
|
+
const handleWheel = useCallback((e: React.WheelEvent) => {
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
|
|
81
|
+
setZoom(z => Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, +(z + delta).toFixed(2))));
|
|
82
|
+
}, [setZoom]);
|
|
83
|
+
|
|
84
|
+
// Pan via mouse drag
|
|
85
|
+
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
86
|
+
if (e.button !== 0) return;
|
|
87
|
+
dragging.current = { startX: e.clientX, startY: e.clientY, panX: pan.x, panY: pan.y };
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
}, [pan]);
|
|
90
|
+
|
|
91
|
+
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
|
92
|
+
if (!dragging.current) return;
|
|
93
|
+
setPan({
|
|
94
|
+
x: dragging.current.panX + (e.clientX - dragging.current.startX),
|
|
95
|
+
y: dragging.current.panY + (e.clientY - dragging.current.startY),
|
|
96
|
+
});
|
|
97
|
+
}, []);
|
|
98
|
+
|
|
99
|
+
const handleMouseUp = useCallback(() => {
|
|
100
|
+
dragging.current = null;
|
|
101
|
+
}, []);
|
|
102
|
+
|
|
103
|
+
const handleResetView = useCallback(() => {
|
|
104
|
+
zoomReset();
|
|
105
|
+
setPan({ x: 0, y: 0 });
|
|
106
|
+
}, [zoomReset]);
|
|
107
|
+
|
|
108
|
+
return ReactDOM.createPortal(
|
|
109
|
+
<div
|
|
110
|
+
style={{ position: "fixed", inset: 0, zIndex: 9999, background: "var(--color-bg-base)", display: "flex", flexDirection: "column" }}
|
|
111
|
+
onMouseMove={handleMouseMove}
|
|
112
|
+
onMouseUp={handleMouseUp}
|
|
113
|
+
onMouseLeave={handleMouseUp}
|
|
114
|
+
>
|
|
115
|
+
{/* Header bar */}
|
|
116
|
+
<div className="flex items-center justify-between px-4 py-2 border-b border-border-subtle" style={{ flexShrink: 0, background: "var(--color-bg-surface)" }}>
|
|
117
|
+
<span className="text-body text-text-secondary" style={{ fontSize: "13px" }}>Mermaid Diagram</span>
|
|
118
|
+
<div className="flex items-center gap-2">
|
|
119
|
+
<ZoomToolbar zoom={zoom} onZoomIn={zoomIn} onZoomOut={zoomOut} onZoomReset={handleResetView} />
|
|
120
|
+
<button
|
|
121
|
+
className="flex items-center justify-center w-8 h-8 rounded hover:bg-bg-elevated text-text-secondary hover:text-text-primary transition-colors"
|
|
122
|
+
onClick={onClose}
|
|
123
|
+
title="Close (Esc)"
|
|
124
|
+
>
|
|
125
|
+
<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>
|
|
126
|
+
</button>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
{/* Diagram area */}
|
|
130
|
+
<div
|
|
131
|
+
style={{ flex: 1, overflow: "hidden", cursor: dragging.current ? "grabbing" : "grab" }}
|
|
132
|
+
onWheel={handleWheel}
|
|
133
|
+
onMouseDown={handleMouseDown}
|
|
134
|
+
>
|
|
135
|
+
<div
|
|
136
|
+
ref={modalContentRef}
|
|
137
|
+
style={{
|
|
138
|
+
width: "100%",
|
|
139
|
+
height: "100%",
|
|
140
|
+
display: "flex",
|
|
141
|
+
alignItems: "center",
|
|
142
|
+
justifyContent: "center",
|
|
143
|
+
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
|
|
144
|
+
transformOrigin: "center center",
|
|
145
|
+
transition: dragging.current ? "none" : "transform 0.1s ease-out",
|
|
146
|
+
}}
|
|
147
|
+
dangerouslySetInnerHTML={{ __html: svgHtml }}
|
|
148
|
+
/>
|
|
149
|
+
</div>
|
|
150
|
+
</div>,
|
|
151
|
+
document.body,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
15
155
|
export function Mermaid({ children }: MermaidProps) {
|
|
16
156
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
17
157
|
const [error, setError] = useState<string | null>(null);
|
|
158
|
+
const [showFullscreen, setShowFullscreen] = useState(false);
|
|
159
|
+
const [svgHtml, setSvgHtml] = useState("");
|
|
18
160
|
const source = typeof children === "string" ? children : String(children ?? "");
|
|
19
161
|
const { annotations, addAnnotationWithId, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
|
|
20
162
|
const [nodePopover, setNodePopover] = useState<NodePopoverState | null>(null);
|
|
163
|
+
const { zoom, zoomIn, zoomOut, zoomReset, setZoom } = useZoom(1);
|
|
21
164
|
|
|
22
165
|
const annotationsRef = useRef(annotations);
|
|
23
166
|
annotationsRef.current = annotations;
|
|
@@ -47,6 +190,7 @@ export function Mermaid({ children }: MermaidProps) {
|
|
|
47
190
|
const { svg } = await mermaid.render(id, source.trim());
|
|
48
191
|
if (!cancelled && containerRef.current) {
|
|
49
192
|
containerRef.current.innerHTML = svg;
|
|
193
|
+
setSvgHtml(svg);
|
|
50
194
|
setupNodeAnnotations(containerRef.current, (el, snippet, prefix) => handleNodeClickRef.current(el, snippet, prefix));
|
|
51
195
|
highlightAnnotatedNodes(containerRef.current, annotationsRef.current);
|
|
52
196
|
}
|
|
@@ -75,6 +219,14 @@ export function Mermaid({ children }: MermaidProps) {
|
|
|
75
219
|
}
|
|
76
220
|
}, [annotations, activeAnnotationId]);
|
|
77
221
|
|
|
222
|
+
// Wheel zoom on inline diagram
|
|
223
|
+
const handleWheel = useCallback((e: React.WheelEvent) => {
|
|
224
|
+
if (!e.ctrlKey && !e.metaKey) return;
|
|
225
|
+
e.preventDefault();
|
|
226
|
+
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
|
|
227
|
+
setZoom(z => Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, +(z + delta).toFixed(2))));
|
|
228
|
+
}, [setZoom]);
|
|
229
|
+
|
|
78
230
|
if (error) {
|
|
79
231
|
return (
|
|
80
232
|
<div className="bg-accent-red-muted rounded-md p-4 mt-3 text-body text-accent-red">
|
|
@@ -88,7 +240,31 @@ export function Mermaid({ children }: MermaidProps) {
|
|
|
88
240
|
|
|
89
241
|
return (
|
|
90
242
|
<>
|
|
91
|
-
<div
|
|
243
|
+
<div className="mt-3 mermaid-container" data-md="mermaid" data-md-source={source.trim()}>
|
|
244
|
+
{/* Zoom toolbar */}
|
|
245
|
+
<div className="flex justify-end mb-1">
|
|
246
|
+
<ZoomToolbar
|
|
247
|
+
zoom={zoom}
|
|
248
|
+
onZoomIn={zoomIn}
|
|
249
|
+
onZoomOut={zoomOut}
|
|
250
|
+
onZoomReset={zoomReset}
|
|
251
|
+
onFullscreen={() => setShowFullscreen(true)}
|
|
252
|
+
/>
|
|
253
|
+
</div>
|
|
254
|
+
{/* Zoomable diagram */}
|
|
255
|
+
<div style={{ overflow: "auto", maxHeight: "70vh" }} onWheel={handleWheel}>
|
|
256
|
+
<div
|
|
257
|
+
ref={containerRef}
|
|
258
|
+
className="flex justify-center"
|
|
259
|
+
style={{
|
|
260
|
+
transform: `scale(${zoom})`,
|
|
261
|
+
transformOrigin: "top center",
|
|
262
|
+
transition: "transform 0.1s ease-out",
|
|
263
|
+
minHeight: zoom < 1 ? undefined : undefined,
|
|
264
|
+
}}
|
|
265
|
+
/>
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
92
268
|
{nodePopover && (
|
|
93
269
|
<AnnotationCreatePopover
|
|
94
270
|
anchorEl={nodePopover.anchorEl}
|
|
@@ -108,6 +284,13 @@ export function Mermaid({ children }: MermaidProps) {
|
|
|
108
284
|
onCancel={() => setNodePopover(null)}
|
|
109
285
|
/>
|
|
110
286
|
)}
|
|
287
|
+
{showFullscreen && svgHtml && (
|
|
288
|
+
<MermaidFullscreenModal
|
|
289
|
+
svgHtml={svgHtml}
|
|
290
|
+
source={source}
|
|
291
|
+
onClose={() => setShowFullscreen(false)}
|
|
292
|
+
/>
|
|
293
|
+
)}
|
|
111
294
|
</>
|
|
112
295
|
);
|
|
113
296
|
}
|
|
@@ -188,6 +371,57 @@ function setupNodeAnnotations(
|
|
|
188
371
|
if (!text) continue;
|
|
189
372
|
makeClickable(msgText as SVGElement, () => text, "Edge");
|
|
190
373
|
}
|
|
374
|
+
|
|
375
|
+
// ER diagram: entities
|
|
376
|
+
for (const entity of container.querySelectorAll(".entityBox")) {
|
|
377
|
+
const group = entity.closest("g") as SVGElement | null;
|
|
378
|
+
if (!group || group.hasAttribute("data-clickable")) continue;
|
|
379
|
+
const labelEl = group.querySelector(".entityLabel");
|
|
380
|
+
if (!labelEl) continue;
|
|
381
|
+
group.setAttribute("data-clickable", "true");
|
|
382
|
+
makeClickable(group, () => labelEl.textContent?.trim() || "Entity", "Node");
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ER diagram: relationship labels
|
|
386
|
+
for (const label of container.querySelectorAll(".relationshipLabel")) {
|
|
387
|
+
const text = label.textContent?.trim();
|
|
388
|
+
if (!text) continue;
|
|
389
|
+
const group = label.closest("g") as SVGElement | null;
|
|
390
|
+
const target = group || label as SVGElement;
|
|
391
|
+
if (target.hasAttribute("data-clickable")) continue;
|
|
392
|
+
target.setAttribute("data-clickable", "true");
|
|
393
|
+
makeClickable(target, () => text, "Edge");
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Class diagram: class groups
|
|
397
|
+
for (const classGroup of container.querySelectorAll(".classGroup")) {
|
|
398
|
+
const el = classGroup as SVGElement;
|
|
399
|
+
if (el.hasAttribute("data-clickable")) continue;
|
|
400
|
+
const labelEl = el.querySelector(".classTitle, .classTitleText, text");
|
|
401
|
+
if (!labelEl) continue;
|
|
402
|
+
el.setAttribute("data-clickable", "true");
|
|
403
|
+
makeClickable(el, () => labelEl.textContent?.trim() || "Class", "Node");
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// State diagram: states
|
|
407
|
+
for (const state of container.querySelectorAll(".statediagram-state")) {
|
|
408
|
+
const el = state as SVGElement;
|
|
409
|
+
if (el.hasAttribute("data-clickable")) continue;
|
|
410
|
+
const labelEl = el.querySelector(".state-title, text");
|
|
411
|
+
if (!labelEl) continue;
|
|
412
|
+
el.setAttribute("data-clickable", "true");
|
|
413
|
+
makeClickable(el, () => labelEl.textContent?.trim() || "State", "Node");
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Mindmap: nodes
|
|
417
|
+
for (const node of container.querySelectorAll(".mindmap-node")) {
|
|
418
|
+
const el = node as SVGElement;
|
|
419
|
+
if (el.hasAttribute("data-clickable")) continue;
|
|
420
|
+
const labelEl = el.querySelector(".mindmap-node-label, text");
|
|
421
|
+
if (!labelEl) continue;
|
|
422
|
+
el.setAttribute("data-clickable", "true");
|
|
423
|
+
makeClickable(el, () => labelEl.textContent?.trim() || "Node", "Node");
|
|
424
|
+
}
|
|
191
425
|
}
|
|
192
426
|
|
|
193
427
|
function highlightAnnotatedNodes(container: HTMLElement, annotations: Annotation[], activeId?: string | null) {
|
|
@@ -274,4 +508,57 @@ function highlightAnnotatedNodes(container: HTMLElement, annotations: Annotation
|
|
|
274
508
|
el.style.outlineOffset = "2px";
|
|
275
509
|
el.style.borderRadius = "4px";
|
|
276
510
|
}
|
|
511
|
+
|
|
512
|
+
// Helper for group-based highlights (ER entities, class groups, states, mindmap nodes)
|
|
513
|
+
const highlightGroups = (
|
|
514
|
+
selector: string,
|
|
515
|
+
labelSelector: string,
|
|
516
|
+
findGroup: (el: Element) => SVGElement | null,
|
|
517
|
+
) => {
|
|
518
|
+
for (const el of container.querySelectorAll(selector)) {
|
|
519
|
+
const group = findGroup(el);
|
|
520
|
+
if (!group || group.hasAttribute("data-annotated")) continue;
|
|
521
|
+
const labelEl = group.querySelector(labelSelector);
|
|
522
|
+
const text = labelEl?.textContent?.trim();
|
|
523
|
+
if (!text || !annotatedSnippets.has(text)) continue;
|
|
524
|
+
const annId = annotatedSnippets.get(text)!;
|
|
525
|
+
group.setAttribute("data-annotated", annId);
|
|
526
|
+
const isActive = annId === activeId;
|
|
527
|
+
const target = group.querySelector("rect, circle, ellipse, polygon") as SVGElement || group;
|
|
528
|
+
target.style.outline = isActive
|
|
529
|
+
? "2px solid var(--color-highlight-border)"
|
|
530
|
+
: "2px solid var(--color-highlight-annotation)";
|
|
531
|
+
target.style.outlineOffset = "2px";
|
|
532
|
+
target.style.borderRadius = "4px";
|
|
533
|
+
}
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
// ER diagram: entities
|
|
537
|
+
highlightGroups(".entityBox", ".entityLabel", (el) => el.closest("g") as SVGElement | null);
|
|
538
|
+
|
|
539
|
+
// ER diagram: relationship labels
|
|
540
|
+
for (const label of container.querySelectorAll(".relationshipLabel")) {
|
|
541
|
+
const group = label.closest("g") as SVGElement | null;
|
|
542
|
+
const target = group || label as SVGElement;
|
|
543
|
+
if (target.hasAttribute("data-annotated")) continue;
|
|
544
|
+
const text = label.textContent?.trim();
|
|
545
|
+
if (!text || !annotatedSnippets.has(text)) continue;
|
|
546
|
+
const annId = annotatedSnippets.get(text)!;
|
|
547
|
+
target.setAttribute("data-annotated", annId);
|
|
548
|
+
const isActive = annId === activeId;
|
|
549
|
+
(target as SVGElement).style.outline = isActive
|
|
550
|
+
? "2px solid var(--color-highlight-border)"
|
|
551
|
+
: "2px solid var(--color-highlight-annotation)";
|
|
552
|
+
(target as SVGElement).style.outlineOffset = "2px";
|
|
553
|
+
(target as SVGElement).style.borderRadius = "4px";
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Class diagram
|
|
557
|
+
highlightGroups(".classGroup", ".classTitle, .classTitleText, text", (el) => el as SVGElement);
|
|
558
|
+
|
|
559
|
+
// State diagram
|
|
560
|
+
highlightGroups(".statediagram-state", ".state-title, text", (el) => el as SVGElement);
|
|
561
|
+
|
|
562
|
+
// Mindmap
|
|
563
|
+
highlightGroups(".mindmap-node", ".mindmap-node-label, text", (el) => el as SVGElement);
|
|
277
564
|
}
|