@exellix/diagrams-toolkit 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 +95 -0
- package/package.json +41 -0
- package/src/ConceptSummaryStrip.jsx +75 -0
- package/src/DefaultGraphEdge.jsx +57 -0
- package/src/EmptyState.jsx +16 -0
- package/src/ErrorState.jsx +20 -0
- package/src/FlowCanvas.jsx +103 -0
- package/src/GraphCanvas.jsx +455 -0
- package/src/GraphCanvasZoomControl.jsx +59 -0
- package/src/InspectorShell.jsx +64 -0
- package/src/JsonDocumentPanel.jsx +95 -0
- package/src/ResourceDataTable.jsx +180 -0
- package/src/StatusBadge.jsx +41 -0
- package/src/SyncStateBanner.jsx +58 -0
- package/src/ViewTabStrip.jsx +39 -0
- package/src/ZoomControls.jsx +80 -0
- package/src/index.js +39 -0
- package/src/lib/computeEdgeBezierPath.js +32 -0
- package/src/lib/graphCanvasLayout.js +99 -0
- package/src/lib/graphCanvasZoom.js +33 -0
- package/src/lib/graphViewportResizePan.js +99 -0
- package/src/styles.css +4 -0
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
forwardRef,
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useImperativeHandle,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from 'react';
|
|
10
|
+
import { GraphCanvasZoomControl } from './GraphCanvasZoomControl.jsx';
|
|
11
|
+
import { DefaultGraphEdge } from './DefaultGraphEdge.jsx';
|
|
12
|
+
import {
|
|
13
|
+
clampGraphCanvasScale,
|
|
14
|
+
graphCanvasPercentToScale,
|
|
15
|
+
graphCanvasScaleToPercent,
|
|
16
|
+
} from './lib/graphCanvasZoom.js';
|
|
17
|
+
import { computeGraphViewportResizePan } from './lib/graphViewportResizePan.js';
|
|
18
|
+
import { DEFAULT_NODE_CARD_WIDTH, GRAPH_NODE_CARD_APPROX_HEIGHT } from './lib/graphCanvasLayout.js';
|
|
19
|
+
|
|
20
|
+
const DEFAULT_TRANSFORM = { x: 0, y: 0, scale: 0.8 };
|
|
21
|
+
|
|
22
|
+
const ARROW_DEFS = (
|
|
23
|
+
<defs>
|
|
24
|
+
<marker id="arrowhead-normal" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
|
25
|
+
<polygon points="0 0, 10 3.5, 0 7" fill="#cbd5e1" />
|
|
26
|
+
</marker>
|
|
27
|
+
<marker id="arrowhead-condition" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
|
28
|
+
<polygon points="0 0, 10 3.5, 0 7" fill="#f59e0b" />
|
|
29
|
+
</marker>
|
|
30
|
+
<marker id="arrowhead-teal" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
|
31
|
+
<polygon points="0 0, 10 3.5, 0 7" fill="#0d9488" />
|
|
32
|
+
</marker>
|
|
33
|
+
<marker id="arrowhead-selected" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
|
34
|
+
<polygon points="0 0, 10 3.5, 0 7" fill="#4f46e5" />
|
|
35
|
+
</marker>
|
|
36
|
+
<marker id="arrowhead-virtual" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
|
37
|
+
<polygon points="0 0, 10 3.5, 0 7" fill="#94a3b8" />
|
|
38
|
+
</marker>
|
|
39
|
+
</defs>
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Interactive graph topology canvas: pan, zoom, node drag, SVG edges, optional zoom controls.
|
|
44
|
+
*
|
|
45
|
+
* @param {{
|
|
46
|
+
* nodes: object[],
|
|
47
|
+
* edges: object[],
|
|
48
|
+
* canvasNodes?: object[],
|
|
49
|
+
* transform?: GraphTransform,
|
|
50
|
+
* onTransformChange?: (t: GraphTransform) => void,
|
|
51
|
+
* defaultTransform?: GraphTransform,
|
|
52
|
+
* onNodesChange?: (nodes: object[]) => void,
|
|
53
|
+
* selectedItem?: { type: string, id?: string, obj?: object } | null,
|
|
54
|
+
* onSelectItem?: (item: { type: string, id?: string, obj?: object } | null) => void,
|
|
55
|
+
* renderNode: (node: object, ctx: { selected: boolean, filteredOut?: boolean, highlightRole?: string }) => React.ReactNode,
|
|
56
|
+
* renderEdge?: (edge: object, ctx: { sourceNode: object, targetNode: object, selected: boolean, index: number, filteredOut?: boolean }) => React.ReactNode,
|
|
57
|
+
* renderExtraEdges?: () => React.ReactNode,
|
|
58
|
+
* onNodePointerDown?: (e: React.PointerEvent, node: object) => void,
|
|
59
|
+
* isNodeDraggable?: (node: object) => boolean,
|
|
60
|
+
* onNodeDragStart?: () => void,
|
|
61
|
+
* onNodeDragEnd?: (moved: boolean) => void,
|
|
62
|
+
* fitViewNodes?: object[],
|
|
63
|
+
* fitViewRequest?: number,
|
|
64
|
+
* viewportResizeExtraNodes?: object[],
|
|
65
|
+
* nodeWidth?: number,
|
|
66
|
+
* nodeHeight?: number,
|
|
67
|
+
* anchorY?: number,
|
|
68
|
+
* showZoomControls?: boolean,
|
|
69
|
+
* enableKeyboardZoom?: boolean,
|
|
70
|
+
* className?: string,
|
|
71
|
+
* children?: React.ReactNode,
|
|
72
|
+
* }} props
|
|
73
|
+
* @param {React.Ref<{ fitViewToContent: () => void, applyCanvasZoomScale: (scale: number) => void, getTransform: () => GraphTransform }>} ref
|
|
74
|
+
*/
|
|
75
|
+
export const GraphCanvas = forwardRef(function GraphCanvas(
|
|
76
|
+
{
|
|
77
|
+
nodes,
|
|
78
|
+
edges,
|
|
79
|
+
canvasNodes,
|
|
80
|
+
transform: controlledTransform,
|
|
81
|
+
onTransformChange,
|
|
82
|
+
defaultTransform = DEFAULT_TRANSFORM,
|
|
83
|
+
onNodesChange,
|
|
84
|
+
selectedItem = null,
|
|
85
|
+
onSelectItem,
|
|
86
|
+
renderNode,
|
|
87
|
+
renderEdge,
|
|
88
|
+
renderExtraEdges,
|
|
89
|
+
onNodePointerDown,
|
|
90
|
+
isNodeDraggable = (node) => !node?.isVirtual,
|
|
91
|
+
onNodeDragStart,
|
|
92
|
+
onNodeDragEnd,
|
|
93
|
+
fitViewNodes,
|
|
94
|
+
fitViewRequest = 0,
|
|
95
|
+
viewportResizeExtraNodes = [],
|
|
96
|
+
nodeWidth = DEFAULT_NODE_CARD_WIDTH,
|
|
97
|
+
nodeHeight = GRAPH_NODE_CARD_APPROX_HEIGHT,
|
|
98
|
+
anchorY = 60,
|
|
99
|
+
showZoomControls = true,
|
|
100
|
+
enableKeyboardZoom = true,
|
|
101
|
+
className = '',
|
|
102
|
+
children,
|
|
103
|
+
},
|
|
104
|
+
ref,
|
|
105
|
+
) {
|
|
106
|
+
const containerRef = useRef(null);
|
|
107
|
+
const [internalTransform, setInternalTransform] = useState(defaultTransform);
|
|
108
|
+
const transform = controlledTransform ?? internalTransform;
|
|
109
|
+
const transformRef = useRef(transform);
|
|
110
|
+
transformRef.current = transform;
|
|
111
|
+
|
|
112
|
+
const setTransform = useCallback(
|
|
113
|
+
(updater) => {
|
|
114
|
+
const next = typeof updater === 'function' ? updater(transformRef.current) : updater;
|
|
115
|
+
transformRef.current = next;
|
|
116
|
+
if (onTransformChange) onTransformChange(next);
|
|
117
|
+
else setInternalTransform(next);
|
|
118
|
+
},
|
|
119
|
+
[onTransformChange],
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const [dragState, setDragState] = useState(null);
|
|
123
|
+
const nodeDragMovedRef = useRef(false);
|
|
124
|
+
const viewportSizeRef = useRef(null);
|
|
125
|
+
const pendingFitViewRef = useRef(false);
|
|
126
|
+
|
|
127
|
+
const nodesToRender = canvasNodes ?? nodes;
|
|
128
|
+
const nodeById = Object.fromEntries(nodesToRender.map((n) => [n.id, n]));
|
|
129
|
+
const boundsNodes = fitViewNodes ?? nodesToRender;
|
|
130
|
+
|
|
131
|
+
const fitViewToContent = useCallback(() => {
|
|
132
|
+
const el = containerRef.current;
|
|
133
|
+
if (!el) return;
|
|
134
|
+
const rect = el.getBoundingClientRect();
|
|
135
|
+
const vw = rect.width;
|
|
136
|
+
const vh = rect.height;
|
|
137
|
+
const list = boundsNodes;
|
|
138
|
+
if (list.length === 0) {
|
|
139
|
+
setTransform({ x: Math.round(vw * 0.06), y: Math.round(vh * 0.08), scale: 0.85 });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
let minX = Infinity;
|
|
143
|
+
let minY = Infinity;
|
|
144
|
+
let maxX = -Infinity;
|
|
145
|
+
let maxY = -Infinity;
|
|
146
|
+
for (const n of list) {
|
|
147
|
+
minX = Math.min(minX, n.x);
|
|
148
|
+
minY = Math.min(minY, n.y);
|
|
149
|
+
maxX = Math.max(maxX, n.x + nodeWidth);
|
|
150
|
+
maxY = Math.max(maxY, n.y + nodeHeight);
|
|
151
|
+
}
|
|
152
|
+
const bw = Math.max(maxX - minX, 420);
|
|
153
|
+
const bh = Math.max(maxY - minY, 280);
|
|
154
|
+
const pad = 56;
|
|
155
|
+
const s = Math.min(1.25, Math.max(0.1, Math.min((vw - 2 * pad) / bw, (vh - 2 * pad) / bh)));
|
|
156
|
+
const cx = (minX + maxX) / 2;
|
|
157
|
+
const cy = (minY + maxY) / 2;
|
|
158
|
+
setTransform({
|
|
159
|
+
scale: s,
|
|
160
|
+
x: vw / 2 - cx * s,
|
|
161
|
+
y: vh / 2 - cy * s,
|
|
162
|
+
});
|
|
163
|
+
}, [boundsNodes, nodeWidth, nodeHeight, setTransform]);
|
|
164
|
+
|
|
165
|
+
const applyCanvasZoomScale = useCallback(
|
|
166
|
+
(targetScale) => {
|
|
167
|
+
const el = containerRef.current;
|
|
168
|
+
const rect = el?.getBoundingClientRect();
|
|
169
|
+
const anchorX = rect ? rect.width / 2 : 0;
|
|
170
|
+
const anchorYCenter = rect ? rect.height / 2 : 0;
|
|
171
|
+
setTransform((prev) => {
|
|
172
|
+
const newScale = clampGraphCanvasScale(targetScale);
|
|
173
|
+
return {
|
|
174
|
+
scale: newScale,
|
|
175
|
+
x: anchorX - (anchorX - prev.x) * (newScale / prev.scale),
|
|
176
|
+
y: anchorYCenter - (anchorYCenter - prev.y) * (newScale / prev.scale),
|
|
177
|
+
};
|
|
178
|
+
});
|
|
179
|
+
},
|
|
180
|
+
[setTransform],
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
useImperativeHandle(
|
|
184
|
+
ref,
|
|
185
|
+
() => ({
|
|
186
|
+
fitViewToContent,
|
|
187
|
+
applyCanvasZoomScale,
|
|
188
|
+
getTransform: () => transformRef.current,
|
|
189
|
+
getContainer: () => containerRef.current,
|
|
190
|
+
}),
|
|
191
|
+
[fitViewToContent, applyCanvasZoomScale],
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
useLayoutEffect(() => {
|
|
195
|
+
if (fitViewRequest <= 0) return;
|
|
196
|
+
pendingFitViewRef.current = true;
|
|
197
|
+
}, [fitViewRequest]);
|
|
198
|
+
|
|
199
|
+
useLayoutEffect(() => {
|
|
200
|
+
if (!pendingFitViewRef.current || !containerRef.current) return;
|
|
201
|
+
pendingFitViewRef.current = false;
|
|
202
|
+
fitViewToContent();
|
|
203
|
+
}, [fitViewRequest, boundsNodes, fitViewToContent]);
|
|
204
|
+
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
const el = containerRef.current;
|
|
207
|
+
if (!el) return undefined;
|
|
208
|
+
|
|
209
|
+
const applyResizePan = () => {
|
|
210
|
+
const rect = el.getBoundingClientRect();
|
|
211
|
+
const nextSize = { width: rect.width, height: rect.height };
|
|
212
|
+
const prevSize = viewportSizeRef.current;
|
|
213
|
+
viewportSizeRef.current = nextSize;
|
|
214
|
+
if (!prevSize || pendingFitViewRef.current) return;
|
|
215
|
+
|
|
216
|
+
const { x: panX, y: panY } = computeGraphViewportResizePan({
|
|
217
|
+
prevSize,
|
|
218
|
+
nextSize,
|
|
219
|
+
transform: transformRef.current,
|
|
220
|
+
nodes,
|
|
221
|
+
extraNodes: viewportResizeExtraNodes,
|
|
222
|
+
cardWidth: nodeWidth,
|
|
223
|
+
cardHeight: nodeHeight,
|
|
224
|
+
});
|
|
225
|
+
if (Math.abs(panX) < 0.5 && Math.abs(panY) < 0.5) return;
|
|
226
|
+
setTransform((prev) => ({ ...prev, x: prev.x + panX, y: prev.y + panY }));
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
applyResizePan();
|
|
230
|
+
const ro = new ResizeObserver(applyResizePan);
|
|
231
|
+
ro.observe(el);
|
|
232
|
+
return () => ro.disconnect();
|
|
233
|
+
}, [nodes, viewportResizeExtraNodes, nodeWidth, nodeHeight, setTransform]);
|
|
234
|
+
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
if (!enableKeyboardZoom) return undefined;
|
|
237
|
+
const isEditableTarget = (el) => {
|
|
238
|
+
if (!(el instanceof HTMLElement)) return false;
|
|
239
|
+
const tag = el.tagName;
|
|
240
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
|
241
|
+
if (el.isContentEditable) return true;
|
|
242
|
+
return Boolean(el.closest('[contenteditable="true"]'));
|
|
243
|
+
};
|
|
244
|
+
const onKeyDown = (e) => {
|
|
245
|
+
if (isEditableTarget(e.target)) return;
|
|
246
|
+
const mod = e.ctrlKey || e.metaKey;
|
|
247
|
+
if (!mod) return;
|
|
248
|
+
if (e.key === '=' || e.key === '+') {
|
|
249
|
+
e.preventDefault();
|
|
250
|
+
applyCanvasZoomScale(transformRef.current.scale * 1.2);
|
|
251
|
+
} else if (e.key === '-' || e.key === '_') {
|
|
252
|
+
e.preventDefault();
|
|
253
|
+
applyCanvasZoomScale(transformRef.current.scale * 0.8);
|
|
254
|
+
} else if (e.key === '0') {
|
|
255
|
+
e.preventDefault();
|
|
256
|
+
fitViewToContent();
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
window.addEventListener('keydown', onKeyDown);
|
|
260
|
+
return () => window.removeEventListener('keydown', onKeyDown);
|
|
261
|
+
}, [enableKeyboardZoom, applyCanvasZoomScale, fitViewToContent]);
|
|
262
|
+
|
|
263
|
+
const handleWheel = (e) => {
|
|
264
|
+
if (!containerRef.current) return;
|
|
265
|
+
e.preventDefault();
|
|
266
|
+
const zoomSensitivity = 0.001;
|
|
267
|
+
const delta = -e.deltaY * zoomSensitivity;
|
|
268
|
+
const newScale = clampGraphCanvasScale(transform.scale * Math.exp(delta));
|
|
269
|
+
const rect = containerRef.current.getBoundingClientRect();
|
|
270
|
+
const mouseX = e.clientX - rect.left;
|
|
271
|
+
const mouseY = e.clientY - rect.top;
|
|
272
|
+
setTransform((prev) => ({
|
|
273
|
+
scale: newScale,
|
|
274
|
+
x: mouseX - (mouseX - prev.x) * (newScale / prev.scale),
|
|
275
|
+
y: mouseY - (mouseY - prev.y) * (newScale / prev.scale),
|
|
276
|
+
}));
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const handlePointerDown = (e) => {
|
|
280
|
+
if (
|
|
281
|
+
e.target.closest('.node-element') ||
|
|
282
|
+
e.target.closest('.edge-element') ||
|
|
283
|
+
e.target.closest('.panel-element') ||
|
|
284
|
+
e.target.closest('.graph-zoom-controls')
|
|
285
|
+
) {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
onSelectItem?.(null);
|
|
289
|
+
setDragState({
|
|
290
|
+
type: 'pan',
|
|
291
|
+
startX: e.clientX,
|
|
292
|
+
startY: e.clientY,
|
|
293
|
+
initialX: transform.x,
|
|
294
|
+
initialY: transform.y,
|
|
295
|
+
});
|
|
296
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const handlePointerMove = (e) => {
|
|
300
|
+
if (!dragState) return;
|
|
301
|
+
if (dragState.type === 'pan') {
|
|
302
|
+
setTransform((prev) => ({
|
|
303
|
+
...prev,
|
|
304
|
+
x: dragState.initialX + (e.clientX - dragState.startX),
|
|
305
|
+
y: dragState.initialY + (e.clientY - dragState.startY),
|
|
306
|
+
}));
|
|
307
|
+
} else if (dragState.type === 'node' && onNodesChange) {
|
|
308
|
+
const dx = (e.clientX - dragState.startX) / transform.scale;
|
|
309
|
+
const dy = (e.clientY - dragState.startY) / transform.scale;
|
|
310
|
+
if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) {
|
|
311
|
+
if (!nodeDragMovedRef.current) {
|
|
312
|
+
nodeDragMovedRef.current = true;
|
|
313
|
+
onNodeDragStart?.();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
onNodesChange(
|
|
317
|
+
nodes.map((n) =>
|
|
318
|
+
n.id === dragState.nodeId
|
|
319
|
+
? { ...n, x: dragState.initialNodeX + dx, y: dragState.initialNodeY + dy }
|
|
320
|
+
: n,
|
|
321
|
+
),
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const handlePointerUp = (e) => {
|
|
327
|
+
if (!dragState) return;
|
|
328
|
+
if (
|
|
329
|
+
typeof e.currentTarget.hasPointerCapture === 'function' &&
|
|
330
|
+
e.currentTarget.hasPointerCapture(e.pointerId)
|
|
331
|
+
) {
|
|
332
|
+
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
333
|
+
}
|
|
334
|
+
if (dragState.type === 'node') {
|
|
335
|
+
onNodeDragEnd?.(nodeDragMovedRef.current);
|
|
336
|
+
nodeDragMovedRef.current = false;
|
|
337
|
+
}
|
|
338
|
+
setDragState(null);
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const handleNodePointerDown = (e, node) => {
|
|
342
|
+
onNodePointerDown?.(e, node);
|
|
343
|
+
if (e.defaultPrevented) return;
|
|
344
|
+
e.stopPropagation();
|
|
345
|
+
onSelectItem?.({ type: 'node', id: node.id, obj: node });
|
|
346
|
+
nodeDragMovedRef.current = false;
|
|
347
|
+
if (!isNodeDraggable(node) || !onNodesChange) return;
|
|
348
|
+
setDragState({
|
|
349
|
+
type: 'node',
|
|
350
|
+
nodeId: node.id,
|
|
351
|
+
startX: e.clientX,
|
|
352
|
+
startY: e.clientY,
|
|
353
|
+
initialNodeX: node.x,
|
|
354
|
+
initialNodeY: node.y,
|
|
355
|
+
});
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const handleEdgeClick = (edge) => {
|
|
359
|
+
onSelectItem?.({ type: 'edge', id: `${edge.from}-${edge.to}`, obj: edge });
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const renderEdgeFn = renderEdge ?? ((edge, ctx) => (
|
|
363
|
+
<DefaultGraphEdge
|
|
364
|
+
key={`${edge.from}-${edge.to}-${ctx.index}`}
|
|
365
|
+
edge={edge}
|
|
366
|
+
sourceNode={ctx.sourceNode}
|
|
367
|
+
targetNode={ctx.targetNode}
|
|
368
|
+
selected={ctx.selected}
|
|
369
|
+
filteredOut={ctx.filteredOut}
|
|
370
|
+
onClick={handleEdgeClick}
|
|
371
|
+
nodeWidth={nodeWidth}
|
|
372
|
+
anchorY={anchorY}
|
|
373
|
+
/>
|
|
374
|
+
));
|
|
375
|
+
|
|
376
|
+
return (
|
|
377
|
+
<main
|
|
378
|
+
ref={containerRef}
|
|
379
|
+
className={`flex-1 min-h-0 min-w-0 relative outline-none overflow-hidden ${className}`}
|
|
380
|
+
onWheel={handleWheel}
|
|
381
|
+
onPointerDown={handlePointerDown}
|
|
382
|
+
onPointerMove={handlePointerMove}
|
|
383
|
+
onPointerUp={handlePointerUp}
|
|
384
|
+
onPointerLeave={handlePointerUp}
|
|
385
|
+
style={{ touchAction: 'none' }}
|
|
386
|
+
>
|
|
387
|
+
<div
|
|
388
|
+
className="absolute inset-0 pointer-events-none opacity-40"
|
|
389
|
+
style={{
|
|
390
|
+
backgroundImage: 'radial-gradient(circle, #94a3b8 1px, transparent 1px)',
|
|
391
|
+
backgroundSize: `${24 * transform.scale}px ${24 * transform.scale}px`,
|
|
392
|
+
backgroundPosition: `${transform.x}px ${transform.y}px`,
|
|
393
|
+
}}
|
|
394
|
+
/>
|
|
395
|
+
|
|
396
|
+
<div
|
|
397
|
+
className="absolute inset-0 z-10 origin-top-left overflow-visible"
|
|
398
|
+
style={{
|
|
399
|
+
transform: `translate(${transform.x}px, ${transform.y}px) scale(${transform.scale})`,
|
|
400
|
+
}}
|
|
401
|
+
>
|
|
402
|
+
<svg
|
|
403
|
+
className="absolute inset-0 h-full w-full overflow-visible pointer-events-none bg-transparent"
|
|
404
|
+
style={{ zIndex: 0 }}
|
|
405
|
+
>
|
|
406
|
+
{ARROW_DEFS}
|
|
407
|
+
{renderExtraEdges?.()}
|
|
408
|
+
{edges.map((edge, idx) => {
|
|
409
|
+
const source = nodeById[edge.from];
|
|
410
|
+
const target = nodeById[edge.to];
|
|
411
|
+
if (!source || !target) return null;
|
|
412
|
+
const selected = selectedItem?.type === 'edge' && selectedItem?.obj === edge;
|
|
413
|
+
return (
|
|
414
|
+
<React.Fragment key={`${edge.from}-${edge.to}-${idx}`}>
|
|
415
|
+
{renderEdgeFn(edge, {
|
|
416
|
+
sourceNode: source,
|
|
417
|
+
targetNode: target,
|
|
418
|
+
selected,
|
|
419
|
+
index: idx,
|
|
420
|
+
filteredOut: false,
|
|
421
|
+
})}
|
|
422
|
+
</React.Fragment>
|
|
423
|
+
);
|
|
424
|
+
})}
|
|
425
|
+
</svg>
|
|
426
|
+
|
|
427
|
+
<div className="absolute top-0 left-0" style={{ zIndex: 1 }}>
|
|
428
|
+
{nodesToRender.map((node) => {
|
|
429
|
+
const selected = selectedItem?.type === 'node' && selectedItem?.id === node.id;
|
|
430
|
+
return (
|
|
431
|
+
<div key={node.id} className="node-element absolute">
|
|
432
|
+
{renderNode(node, {
|
|
433
|
+
selected,
|
|
434
|
+
onPointerDown: (e) => handleNodePointerDown(e, node),
|
|
435
|
+
})}
|
|
436
|
+
</div>
|
|
437
|
+
);
|
|
438
|
+
})}
|
|
439
|
+
</div>
|
|
440
|
+
</div>
|
|
441
|
+
|
|
442
|
+
{showZoomControls ? (
|
|
443
|
+
<GraphCanvasZoomControl
|
|
444
|
+
scalePercent={graphCanvasScaleToPercent(transform.scale)}
|
|
445
|
+
onScalePercentChange={(pct) => applyCanvasZoomScale(graphCanvasPercentToScale(pct))}
|
|
446
|
+
onFitView={fitViewToContent}
|
|
447
|
+
/>
|
|
448
|
+
) : null}
|
|
449
|
+
|
|
450
|
+
{children}
|
|
451
|
+
</main>
|
|
452
|
+
);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
GraphCanvas.displayName = 'GraphCanvas';
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Crosshair } from 'lucide-react';
|
|
3
|
+
import {
|
|
4
|
+
GRAPH_CANVAS_MAX_PERCENT,
|
|
5
|
+
GRAPH_CANVAS_MIN_PERCENT,
|
|
6
|
+
} from './lib/graphCanvasZoom.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Graph canvas zoom: slider + fit view.
|
|
10
|
+
*
|
|
11
|
+
* @param {{
|
|
12
|
+
* scalePercent: number,
|
|
13
|
+
* onScalePercentChange: (percent: number) => void,
|
|
14
|
+
* onFitView: () => void,
|
|
15
|
+
* minPercent?: number,
|
|
16
|
+
* maxPercent?: number,
|
|
17
|
+
* className?: string,
|
|
18
|
+
* }} props
|
|
19
|
+
*/
|
|
20
|
+
export function GraphCanvasZoomControl({
|
|
21
|
+
scalePercent,
|
|
22
|
+
onScalePercentChange,
|
|
23
|
+
onFitView,
|
|
24
|
+
minPercent = GRAPH_CANVAS_MIN_PERCENT,
|
|
25
|
+
maxPercent = GRAPH_CANVAS_MAX_PERCENT,
|
|
26
|
+
className = '',
|
|
27
|
+
}) {
|
|
28
|
+
const pct = Math.min(maxPercent, Math.max(minPercent, Math.round(scalePercent)));
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div
|
|
32
|
+
className={`graph-zoom-controls panel-element absolute bottom-6 left-6 flex items-center gap-2 bg-white border border-slate-200 rounded-lg shadow-md z-30 pointer-events-auto px-2.5 py-2 min-w-[10rem] ${className}`}
|
|
33
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
34
|
+
>
|
|
35
|
+
<input
|
|
36
|
+
type="range"
|
|
37
|
+
min={minPercent}
|
|
38
|
+
max={maxPercent}
|
|
39
|
+
step={5}
|
|
40
|
+
value={pct}
|
|
41
|
+
onChange={(e) => onScalePercentChange(Number(e.target.value))}
|
|
42
|
+
className="graph-zoom-range flex-1 min-w-[6rem] h-1.5 cursor-pointer accent-indigo-600"
|
|
43
|
+
aria-label="Canvas zoom"
|
|
44
|
+
aria-valuemin={minPercent}
|
|
45
|
+
aria-valuemax={maxPercent}
|
|
46
|
+
aria-valuenow={pct}
|
|
47
|
+
/>
|
|
48
|
+
<button
|
|
49
|
+
type="button"
|
|
50
|
+
className="shrink-0 p-1.5 rounded-md hover:bg-slate-50 text-slate-600 border border-slate-200"
|
|
51
|
+
onClick={onFitView}
|
|
52
|
+
title="Fit graph in view"
|
|
53
|
+
aria-label="Fit graph in view"
|
|
54
|
+
>
|
|
55
|
+
<Crosshair size={16} aria-hidden />
|
|
56
|
+
</button>
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import React, { useCallback, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {{
|
|
5
|
+
* width: number,
|
|
6
|
+
* minWidth?: number,
|
|
7
|
+
* maxWidth?: number,
|
|
8
|
+
* onResize: (width: number) => void,
|
|
9
|
+
* children: React.ReactNode,
|
|
10
|
+
* className?: string,
|
|
11
|
+
* }} props
|
|
12
|
+
*/
|
|
13
|
+
export function InspectorShell({
|
|
14
|
+
width,
|
|
15
|
+
minWidth = 320,
|
|
16
|
+
maxWidth = 720,
|
|
17
|
+
onResize,
|
|
18
|
+
children,
|
|
19
|
+
className = '',
|
|
20
|
+
}) {
|
|
21
|
+
const dragging = useRef(false);
|
|
22
|
+
|
|
23
|
+
const onPointerDown = useCallback(
|
|
24
|
+
(e) => {
|
|
25
|
+
e.preventDefault();
|
|
26
|
+
dragging.current = true;
|
|
27
|
+
const startX = e.clientX;
|
|
28
|
+
const startW = width;
|
|
29
|
+
|
|
30
|
+
const onMove = (ev) => {
|
|
31
|
+
if (!dragging.current) return;
|
|
32
|
+
const delta = startX - ev.clientX;
|
|
33
|
+
const next = Math.min(maxWidth, Math.max(minWidth, startW + delta));
|
|
34
|
+
onResize(next);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const onUp = () => {
|
|
38
|
+
dragging.current = false;
|
|
39
|
+
window.removeEventListener('pointermove', onMove);
|
|
40
|
+
window.removeEventListener('pointerup', onUp);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
window.addEventListener('pointermove', onMove);
|
|
44
|
+
window.addEventListener('pointerup', onUp);
|
|
45
|
+
},
|
|
46
|
+
[width, minWidth, maxWidth, onResize],
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<aside
|
|
51
|
+
className={`relative shrink-0 border-l border-slate-200 bg-white flex flex-col min-h-0 ${className}`}
|
|
52
|
+
style={{ width }}
|
|
53
|
+
>
|
|
54
|
+
<div
|
|
55
|
+
className="absolute left-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-indigo-200/60 z-10"
|
|
56
|
+
onPointerDown={onPointerDown}
|
|
57
|
+
role="separator"
|
|
58
|
+
aria-orientation="vertical"
|
|
59
|
+
aria-label="Resize inspector"
|
|
60
|
+
/>
|
|
61
|
+
{children}
|
|
62
|
+
</aside>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import React, { useMemo, useState } from 'react';
|
|
2
|
+
import { Check, RotateCcw } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @param {{
|
|
6
|
+
* value: string,
|
|
7
|
+
* onChange: (value: string) => void,
|
|
8
|
+
* onApply?: () => void,
|
|
9
|
+
* onRevert?: () => void,
|
|
10
|
+
* error?: string | null,
|
|
11
|
+
* dirty?: boolean,
|
|
12
|
+
* readOnly?: boolean,
|
|
13
|
+
* title?: string,
|
|
14
|
+
* className?: string,
|
|
15
|
+
* }} props
|
|
16
|
+
*/
|
|
17
|
+
export function JsonDocumentPanel({
|
|
18
|
+
value,
|
|
19
|
+
onChange,
|
|
20
|
+
onApply,
|
|
21
|
+
onRevert,
|
|
22
|
+
error,
|
|
23
|
+
dirty = false,
|
|
24
|
+
readOnly = false,
|
|
25
|
+
title = 'JSON document',
|
|
26
|
+
className = '',
|
|
27
|
+
}) {
|
|
28
|
+
const [formatError, setFormatError] = useState('');
|
|
29
|
+
|
|
30
|
+
const handleFormat = () => {
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(value);
|
|
33
|
+
onChange(JSON.stringify(parsed, null, 2));
|
|
34
|
+
setFormatError('');
|
|
35
|
+
} catch (e) {
|
|
36
|
+
setFormatError(e instanceof Error ? e.message : String(e));
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const statusLabel = useMemo(() => {
|
|
41
|
+
if (error || formatError) return 'Invalid JSON';
|
|
42
|
+
if (dirty) return 'Draft differs';
|
|
43
|
+
return 'In sync';
|
|
44
|
+
}, [error, formatError, dirty]);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div className={`flex flex-col flex-1 min-h-0 bg-slate-100 ${className}`}>
|
|
48
|
+
<div className="flex flex-wrap items-center gap-2 px-4 py-2.5 bg-white border-b border-slate-200 shrink-0">
|
|
49
|
+
<span className="text-sm font-semibold text-slate-800 mr-2">{title}</span>
|
|
50
|
+
{!readOnly && onApply ? (
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
onClick={onApply}
|
|
54
|
+
disabled={Boolean(error) || !dirty}
|
|
55
|
+
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-semibold text-white bg-indigo-600 hover:bg-indigo-700 disabled:opacity-45 rounded-md"
|
|
56
|
+
>
|
|
57
|
+
<Check size={14} /> Apply
|
|
58
|
+
</button>
|
|
59
|
+
) : null}
|
|
60
|
+
{!readOnly && onRevert ? (
|
|
61
|
+
<button
|
|
62
|
+
type="button"
|
|
63
|
+
onClick={onRevert}
|
|
64
|
+
disabled={!dirty}
|
|
65
|
+
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm text-slate-700 border border-slate-200 hover:bg-slate-50 disabled:opacity-45 rounded-md"
|
|
66
|
+
>
|
|
67
|
+
<RotateCcw size={14} /> Revert
|
|
68
|
+
</button>
|
|
69
|
+
) : null}
|
|
70
|
+
{!readOnly ? (
|
|
71
|
+
<button
|
|
72
|
+
type="button"
|
|
73
|
+
onClick={handleFormat}
|
|
74
|
+
className="px-3 py-1.5 text-sm text-slate-700 border border-slate-200 hover:bg-slate-50 rounded-md"
|
|
75
|
+
>
|
|
76
|
+
Format
|
|
77
|
+
</button>
|
|
78
|
+
) : null}
|
|
79
|
+
<span className="ml-auto text-xs text-slate-500">{statusLabel}</span>
|
|
80
|
+
</div>
|
|
81
|
+
{(error || formatError) && (
|
|
82
|
+
<p className="px-4 py-2 text-xs font-mono text-red-700 bg-red-50 border-b border-red-100">
|
|
83
|
+
{error || formatError}
|
|
84
|
+
</p>
|
|
85
|
+
)}
|
|
86
|
+
<textarea
|
|
87
|
+
value={value}
|
|
88
|
+
onChange={(e) => onChange(e.target.value)}
|
|
89
|
+
readOnly={readOnly}
|
|
90
|
+
spellCheck={false}
|
|
91
|
+
className="flex-1 min-h-0 w-full p-4 font-mono text-sm bg-slate-950 text-emerald-100 resize-none border-0 focus:outline-none focus:ring-2 focus:ring-indigo-500/30"
|
|
92
|
+
/>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|