@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.
@@ -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
+ }