@dr2rai/raid-canvas 0.1.0 → 0.3.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,999 @@
1
+ /**
2
+ * @file RaidCanvas.tsx
3
+ * @description Reusable React component wrapping AntV X6 and RaiBridge for
4
+ * AOAIM (Activity-Object-AI Model) interactive visual editing, Manhattan orthogonal
5
+ * connector routing, drag-and-drop shape stencils, semantic anti-entropy wiring,
6
+ * and aim-* SVG synchronization.
7
+ *
8
+ * Honors Alan Kay's Dynabook vision of dynamic, malleable visual objects
9
+ * and Rainer Burkhardt's C++ GrafObj graphical hierarchy contracts.
10
+ */
11
+
12
+ import React, {
13
+ useEffect,
14
+ useRef,
15
+ useState,
16
+ useCallback,
17
+ useImperativeHandle,
18
+ } from 'react';
19
+ import { Graph, Shape, Edge } from '@antv/x6';
20
+ import { RaiBridge } from './RaiBridge.js';
21
+ import {
22
+ registerAimShapes,
23
+ configureAimGraph,
24
+ createAimNode,
25
+ applyEdgeRouting,
26
+ CascaisPalette,
27
+ getDefaultNodeBounds,
28
+ getDefaultNodeName,
29
+ } from './X6Shapes.js';
30
+ import {
31
+ validateSemanticConnection,
32
+ getSemanticEdgeKind,
33
+ getSemanticEdgeStereotype,
34
+ } from './semanticRules.js';
35
+ import type { AimOntologyKind, AimRoutingMode, RaidNodeData, RaidEdgeData } from './types.js';
36
+
37
+ export interface RaidCanvasProps {
38
+ /** The raw SVG string carrying aim-* ontological attributes (preferred) */
39
+ svg?: string;
40
+ /** Raw aim-* SVG string alias (supported for compatibility) */
41
+ svgContent?: string;
42
+ /** Whether the canvas allows dragging and editing, or behaves as a pan/zoom viewer */
43
+ readOnly?: boolean;
44
+ /** Default routing mode for edges ('manhattan', 'normal', 'smooth'). Default: 'manhattan' */
45
+ defaultRouting?: AimRoutingMode;
46
+ /** Optional CSS class name for the outer wrapper */
47
+ className?: string;
48
+ /** Optional inline styles for outer wrapper */
49
+ style?: React.CSSProperties;
50
+ /** Callback fired when user moves a node, adjusts edge, or drops a shape */
51
+ onChange?: (updatedSvg: string) => void;
52
+ /** Callback returning serialized SVG DOM upon canvas change or save trigger */
53
+ onSave?: (svg: string) => void;
54
+ /** Callback fired when an entity is selected, providing its ontological metadata */
55
+ onSelect?: (selection: { id: string; kind: AimOntologyKind; label: string } | null) => void;
56
+ /** Callback fired emitting IDs of selected nodes/edges */
57
+ onSelectionChange?: (selectedIds: string[]) => void;
58
+ /** Callback fired when a stencil is dropped onto the canvas */
59
+ onDropStencil?: (kind: AimOntologyKind, point: { x: number; y: number }) => void;
60
+ /** Whether to render built-in navigation controls (Zoom In/Out, Fit, Center, Reset). Default: true */
61
+ showToolbar?: boolean;
62
+ }
63
+
64
+ export interface RaidCanvasHandle {
65
+ /** Access underlying AntV X6 Graph instance */
66
+ getGraph: () => Graph | null;
67
+ /** Get current serialized SVG */
68
+ getSvg: () => string;
69
+ /** Add a new AOAIM archetype node to the canvas */
70
+ addNode: (
71
+ kind: AimOntologyKind,
72
+ x?: number,
73
+ y?: number,
74
+ customData?: Partial<RaidNodeData>,
75
+ ) => string;
76
+ /** Update properties of an existing node (label, stereotype, dimensions, etc.) */
77
+ updateNode: (id: string, updates: Partial<RaidNodeData>) => void;
78
+ /** Update properties of an existing edge (kind, label, stereotype, routing, ports) */
79
+ updateEdge: (id: string, updates: Partial<RaidEdgeData>) => void;
80
+ /** Set routing mode across the canvas or for new edges */
81
+ setRoutingMode: (mode: AimRoutingMode, applyToAllEdges?: boolean) => void;
82
+ /** Get the current active canvas routing mode */
83
+ getRoutingMode: () => AimRoutingMode;
84
+ /** Delete currently selected cell(s) */
85
+ deleteSelection: () => void;
86
+ /** Clear all cells on the canvas */
87
+ clear: () => void;
88
+ /** Center canvas content */
89
+ center: () => void;
90
+ /** Zoom to fit content */
91
+ zoomToFit: () => void;
92
+ /** Reset view to 100% zoom and center */
93
+ resetView: () => void;
94
+ /** Zoom in */
95
+ zoomIn: () => void;
96
+ /** Zoom out */
97
+ zoomOut: () => void;
98
+ }
99
+
100
+ /**
101
+ * Declarative drop-in React component for rendering and interacting with
102
+ * AOAIM diagrams conforming to the ontological aim-* SVG contract.
103
+ */
104
+ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
105
+ function RaidCanvas(
106
+ {
107
+ svg,
108
+ svgContent,
109
+ readOnly = false,
110
+ defaultRouting = 'manhattan',
111
+ className,
112
+ style,
113
+ onChange,
114
+ onSave,
115
+ onSelect,
116
+ onSelectionChange,
117
+ onDropStencil,
118
+ showToolbar = true,
119
+ },
120
+ ref,
121
+ ) {
122
+ const containerRef = useRef<HTMLDivElement | null>(null);
123
+ const graphRef = useRef<Graph | null>(null);
124
+ const bridgeRef = useRef<RaiBridge>(new RaiBridge());
125
+ const lastSerializedSvgRef = useRef<string>('');
126
+ const isHydratingRef = useRef<boolean>(false);
127
+ const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
128
+ const selectedCellIdRef = useRef<string | null>(null);
129
+ const clearEdgeToolsRef = useRef<(() => void) | null>(null);
130
+ const routingModeRef = useRef<AimRoutingMode>(defaultRouting);
131
+ routingModeRef.current = defaultRouting;
132
+
133
+ const [zoomLevel, setZoomLevel] = useState<number>(100);
134
+ const [isDragOver, setIsDragOver] = useState<boolean>(false);
135
+
136
+ // Unify svg vs svgContent props
137
+ const activeSvg = svgContent ?? svg ?? '';
138
+
139
+ // Callback refs to maintain stable graph event listeners
140
+ const onChangeRef = useRef(onChange);
141
+ onChangeRef.current = onChange;
142
+
143
+ const onSaveRef = useRef(onSave);
144
+ onSaveRef.current = onSave;
145
+
146
+ const onSelectRef = useRef(onSelect);
147
+ onSelectRef.current = onSelect;
148
+
149
+ const onSelectionChangeRef = useRef(onSelectionChange);
150
+ onSelectionChangeRef.current = onSelectionChange;
151
+
152
+ const onDropStencilRef = useRef(onDropStencil);
153
+ onDropStencilRef.current = onDropStencil;
154
+
155
+ const svgPropRef = useRef(activeSvg);
156
+ svgPropRef.current = activeSvg;
157
+
158
+ // Helper: Debounced Serialization and notification
159
+ const triggerDebouncedChange = useCallback(() => {
160
+ if (isHydratingRef.current) return;
161
+
162
+ if (debounceTimerRef.current) {
163
+ clearTimeout(debounceTimerRef.current);
164
+ }
165
+
166
+ debounceTimerRef.current = setTimeout(() => {
167
+ if (!graphRef.current) return;
168
+ const currentBaseSvg = svgPropRef.current;
169
+ const updatedSvg = bridgeRef.current.serializeToSvg(
170
+ graphRef.current,
171
+ currentBaseSvg,
172
+ );
173
+ lastSerializedSvgRef.current = updatedSvg;
174
+
175
+ onChangeRef.current?.(updatedSvg);
176
+ onSaveRef.current?.(updatedSvg);
177
+ }, 100);
178
+ }, []);
179
+
180
+ // --------------------------------------------------------------------------
181
+ // Toolbar Actions
182
+ // --------------------------------------------------------------------------
183
+ const handleZoomIn = useCallback(() => {
184
+ const graph = graphRef.current;
185
+ if (!graph) return;
186
+ graph.zoom(0.2);
187
+ setZoomLevel(Math.round(graph.zoom() * 100));
188
+ }, []);
189
+
190
+ const handleZoomOut = useCallback(() => {
191
+ const graph = graphRef.current;
192
+ if (!graph) return;
193
+ graph.zoom(-0.2);
194
+ setZoomLevel(Math.round(graph.zoom() * 100));
195
+ }, []);
196
+
197
+ const handleFitToContent = useCallback(() => {
198
+ const graph = graphRef.current;
199
+ if (!graph) return;
200
+ graph.zoomToFit({ padding: 32, maxScale: 1.5 });
201
+ setZoomLevel(Math.round(graph.zoom() * 100));
202
+ }, []);
203
+
204
+ const handleCenter = useCallback(() => {
205
+ const graph = graphRef.current;
206
+ if (!graph) return;
207
+ graph.centerContent();
208
+ }, []);
209
+
210
+ const handleResetView = useCallback(() => {
211
+ const graph = graphRef.current;
212
+ if (!graph) return;
213
+ graph.zoom(1, { absolute: true });
214
+ graph.centerContent();
215
+ setZoomLevel(100);
216
+ }, []);
217
+
218
+ // --------------------------------------------------------------------------
219
+ // Imperative Ref Handle API
220
+ // --------------------------------------------------------------------------
221
+ useImperativeHandle(
222
+ ref,
223
+ () => ({
224
+ getGraph: () => graphRef.current,
225
+ getSvg: () => {
226
+ if (!graphRef.current) return svgPropRef.current;
227
+ return bridgeRef.current.serializeToSvg(
228
+ graphRef.current,
229
+ svgPropRef.current,
230
+ );
231
+ },
232
+ addNode: (kind, x, y, customData) => {
233
+ const graph = graphRef.current;
234
+ if (!graph) return '';
235
+
236
+ const defaultBounds = getDefaultNodeBounds(kind, x ?? 200, y ?? 150);
237
+ const defaultName = getDefaultNodeName(kind);
238
+ const id = `${kind.toUpperCase()}_${Date.now().toString(36).slice(-4)}`;
239
+
240
+ const nodeData: RaidNodeData = {
241
+ id,
242
+ kind,
243
+ displayName: customData?.displayName ?? defaultName,
244
+ ...(customData?.stereotype !== undefined ? { stereotype: customData.stereotype } : {}),
245
+ ...(customData?.attributes !== undefined ? { attributes: customData.attributes } : {}),
246
+ ...(customData?.methods !== undefined ? { methods: customData.methods } : {}),
247
+ bounds: {
248
+ ...defaultBounds,
249
+ ...(customData?.bounds ?? {}),
250
+ },
251
+ };
252
+
253
+ const nodeMeta = createAimNode(nodeData);
254
+ graph.addNode(nodeMeta);
255
+
256
+ selectedCellIdRef.current = id;
257
+ onSelectRef.current?.({ id, kind, label: nodeData.displayName });
258
+ onSelectionChangeRef.current?.([id]);
259
+ triggerDebouncedChange();
260
+
261
+ return id;
262
+ },
263
+ updateNode: (id, updates) => {
264
+ const graph = graphRef.current;
265
+ if (!graph) return;
266
+ const node = graph.getCellById(id);
267
+ if (!node || !node.isNode()) return;
268
+
269
+ const currentData = (node.getData() ?? {}) as RaidNodeData;
270
+ const nextData: RaidNodeData = {
271
+ ...currentData,
272
+ ...updates,
273
+ id: updates.id ?? currentData.id ?? id,
274
+ kind: updates.kind ?? currentData.kind ?? 'act',
275
+ displayName: updates.displayName ?? currentData.displayName ?? id,
276
+ bounds: {
277
+ ...(currentData.bounds ?? { x: 0, y: 0, width: 140, height: 60 }),
278
+ ...(updates.bounds ?? {}),
279
+ },
280
+ };
281
+ node.setData(nextData);
282
+
283
+ const displayName = nextData.displayName;
284
+ const stereotype = nextData.stereotype;
285
+ const labelText = stereotype ? `${stereotype}\n${displayName}` : displayName;
286
+
287
+ if (nextData.kind === 'cls') {
288
+ node.setAttrByPath('title/text', displayName);
289
+ if (nextData.attributes !== undefined) {
290
+ node.setAttrByPath('attributes/text', nextData.attributes.join('\n'));
291
+ }
292
+ if (nextData.methods !== undefined) {
293
+ node.setAttrByPath('methods/text', nextData.methods.join('\n'));
294
+ }
295
+ } else {
296
+ node.setAttrByPath('label/text', labelText);
297
+ }
298
+
299
+ if (updates.bounds) {
300
+ node.setPosition(updates.bounds.x, updates.bounds.y);
301
+ node.setSize(updates.bounds.width, updates.bounds.height);
302
+ }
303
+
304
+ triggerDebouncedChange();
305
+ },
306
+ updateEdge: (id, updates) => {
307
+ const graph = graphRef.current;
308
+ if (!graph) return;
309
+ const edge = graph.getCellById(id);
310
+ if (!edge || !edge.isEdge()) return;
311
+
312
+ const currentData = (edge.getData() ?? {}) as RaidEdgeData;
313
+ const nextData = { ...currentData, ...updates };
314
+ edge.setData(nextData);
315
+
316
+ if (updates.routing !== undefined) {
317
+ applyEdgeRouting(edge, updates.routing);
318
+ }
319
+
320
+ if (updates.sourcePort !== undefined) {
321
+ const currentSource = edge.getSource() as { cell?: string };
322
+ if (currentSource.cell) {
323
+ edge.setSource({ cell: currentSource.cell, port: updates.sourcePort });
324
+ }
325
+ }
326
+
327
+ if (updates.targetPort !== undefined) {
328
+ const currentTarget = edge.getTarget() as { cell?: string };
329
+ if (currentTarget.cell) {
330
+ edge.setTarget({ cell: currentTarget.cell, port: updates.targetPort });
331
+ }
332
+ }
333
+
334
+ if (updates.label !== undefined || updates.stereotype !== undefined) {
335
+ const text = updates.label ?? updates.stereotype ?? '';
336
+ edge.setLabels(
337
+ text
338
+ ? [
339
+ {
340
+ attrs: {
341
+ text: {
342
+ text,
343
+ fill: CascaisPalette.TextSecondary,
344
+ fontSize: 11,
345
+ },
346
+ },
347
+ position: 0.5,
348
+ },
349
+ ]
350
+ : [],
351
+ );
352
+ }
353
+
354
+ triggerDebouncedChange();
355
+ },
356
+ setRoutingMode: (mode, applyToAll = true) => {
357
+ routingModeRef.current = mode;
358
+ if (applyToAll && graphRef.current) {
359
+ for (const edge of graphRef.current.getEdges()) {
360
+ applyEdgeRouting(edge, mode);
361
+ const currentData = (edge.getData() ?? {}) as RaidEdgeData;
362
+ edge.setData({ ...currentData, routing: mode });
363
+ }
364
+ triggerDebouncedChange();
365
+ }
366
+ },
367
+ getRoutingMode: () => routingModeRef.current,
368
+ deleteSelection: () => {
369
+ const graph = graphRef.current;
370
+ if (!graph || readOnly) return;
371
+ if (selectedCellIdRef.current) {
372
+ const cell = graph.getCellById(selectedCellIdRef.current);
373
+ if (cell) {
374
+ clearEdgeToolsRef.current?.();
375
+ graph.removeCell(cell);
376
+ selectedCellIdRef.current = null;
377
+ onSelectRef.current?.(null);
378
+ onSelectionChangeRef.current?.([]);
379
+ triggerDebouncedChange();
380
+ }
381
+ }
382
+ },
383
+ clear: () => {
384
+ const graph = graphRef.current;
385
+ if (!graph || readOnly) return;
386
+ graph.clearCells();
387
+ selectedCellIdRef.current = null;
388
+ onSelectRef.current?.(null);
389
+ onSelectionChangeRef.current?.([]);
390
+ triggerDebouncedChange();
391
+ },
392
+ center: handleCenter,
393
+ zoomToFit: handleFitToContent,
394
+ resetView: handleResetView,
395
+ zoomIn: handleZoomIn,
396
+ zoomOut: handleZoomOut,
397
+ }),
398
+ [
399
+ handleCenter,
400
+ handleFitToContent,
401
+ handleResetView,
402
+ handleZoomIn,
403
+ handleZoomOut,
404
+ readOnly,
405
+ triggerDebouncedChange,
406
+ ],
407
+ );
408
+
409
+ // --------------------------------------------------------------------------
410
+ // 1. Mount Graph & Event Listeners
411
+ // --------------------------------------------------------------------------
412
+ useEffect(() => {
413
+ if (!containerRef.current) return;
414
+
415
+ registerAimShapes();
416
+
417
+ const container = containerRef.current;
418
+ const initialWidth = container.clientWidth || 800;
419
+ const initialHeight = container.clientHeight || 600;
420
+
421
+ const graph = new Graph({
422
+ container,
423
+ width: initialWidth,
424
+ height: initialHeight,
425
+ autoResize: false,
426
+ grid: {
427
+ visible: true,
428
+ type: 'dot',
429
+ args: {
430
+ color: CascaisPalette.SilverLine,
431
+ thickness: 1,
432
+ },
433
+ },
434
+ highlighting: {
435
+ magnetAvailable: {
436
+ name: 'stroke',
437
+ args: {
438
+ padding: 3,
439
+ attrs: {
440
+ stroke: CascaisPalette.HeraldicGreen,
441
+ strokeWidth: 2,
442
+ },
443
+ },
444
+ },
445
+ magnetAdsorbed: {
446
+ name: 'stroke',
447
+ args: {
448
+ padding: 4,
449
+ attrs: {
450
+ stroke: CascaisPalette.HeraldicGreen,
451
+ strokeWidth: 3,
452
+ fill: CascaisPalette.HeraldicGreen,
453
+ },
454
+ },
455
+ },
456
+ },
457
+ interacting: {
458
+ nodeMovable: !readOnly,
459
+ edgeMovable: !readOnly,
460
+ edgeLabelMovable: !readOnly,
461
+ arrowheadMovable: !readOnly,
462
+ vertexMovable: !readOnly,
463
+ vertexAddable: !readOnly,
464
+ vertexDeletable: !readOnly,
465
+ },
466
+ panning: {
467
+ enabled: true,
468
+ eventTypes: readOnly
469
+ ? ['leftMouseDown', 'rightMouseDown']
470
+ : ['rightMouseDown', 'mouseWheel'],
471
+ },
472
+ mousewheel: {
473
+ enabled: true,
474
+ modifiers: ['ctrl', 'meta'],
475
+ },
476
+ connecting: {
477
+ router: 'manhattan',
478
+ connector: { name: 'rounded', args: { radius: 8 } },
479
+ allowBlank: false,
480
+ allowLoop: false,
481
+ allowNode: true,
482
+ allowPort: true,
483
+ allowEdge: false,
484
+ highlight: true,
485
+ validateConnection({ sourceCell, targetCell }) {
486
+ if (!sourceCell || !targetCell) return false;
487
+ if (sourceCell === targetCell) return false;
488
+ if (!sourceCell.isNode() || !targetCell.isNode()) return false;
489
+
490
+ const sourceKind = (
491
+ (sourceCell.getData() as Partial<RaidNodeData>)?.kind ?? 'act'
492
+ ) as AimOntologyKind;
493
+ const targetKind = (
494
+ (targetCell.getData() as Partial<RaidNodeData>)?.kind ?? 'act'
495
+ ) as AimOntologyKind;
496
+
497
+ return validateSemanticConnection(sourceKind, targetKind);
498
+ },
499
+ createEdge() {
500
+ const edge = new Shape.Edge({
501
+ shape: 'aim-edge',
502
+ });
503
+ applyEdgeRouting(edge, routingModeRef.current);
504
+ return edge;
505
+ },
506
+ },
507
+ });
508
+
509
+ configureAimGraph(graph);
510
+ graphRef.current = graph;
511
+
512
+ let activeToolEdge: Edge | null = null;
513
+
514
+ const clearEdgeTools = () => {
515
+ if (activeToolEdge) {
516
+ try {
517
+ activeToolEdge.removeTools();
518
+ } catch {
519
+ // Ignore if cell was already removed
520
+ }
521
+ activeToolEdge = null;
522
+ }
523
+ };
524
+ clearEdgeToolsRef.current = clearEdgeTools;
525
+
526
+ const setEdgeTools = (edge: Edge) => {
527
+ clearEdgeTools();
528
+ if (readOnly) return;
529
+ activeToolEdge = edge;
530
+ try {
531
+ edge.addTools([
532
+ {
533
+ name: 'source-arrowhead',
534
+ args: {
535
+ attrs: {
536
+ fill: CascaisPalette.NetGold,
537
+ stroke: '#FFFFFF',
538
+ strokeWidth: 2,
539
+ cursor: 'grab',
540
+ },
541
+ },
542
+ },
543
+ {
544
+ name: 'target-arrowhead',
545
+ args: {
546
+ attrs: {
547
+ fill: CascaisPalette.NetGold,
548
+ stroke: '#FFFFFF',
549
+ strokeWidth: 2,
550
+ cursor: 'grab',
551
+ },
552
+ },
553
+ },
554
+ {
555
+ name: 'vertices',
556
+ args: {
557
+ attrs: {
558
+ fill: CascaisPalette.WarmGraphite,
559
+ stroke: '#FFFFFF',
560
+ strokeWidth: 1.5,
561
+ },
562
+ },
563
+ },
564
+ ]);
565
+ } catch {
566
+ // Fallback if tools fail to attach
567
+ }
568
+ };
569
+
570
+ // Handle edge connections and re-connections with automatic semantic wiring
571
+ graph.on('edge:connected', ({ edge, isNew }) => {
572
+ const sourceCell = edge.getSourceCell();
573
+ const targetCell = edge.getTargetCell();
574
+ if (sourceCell?.isNode() && targetCell?.isNode()) {
575
+ const sourceKind = (
576
+ (sourceCell.getData() as Partial<RaidNodeData>)?.kind ?? 'act'
577
+ ) as AimOntologyKind;
578
+ const targetKind = (
579
+ (targetCell.getData() as Partial<RaidNodeData>)?.kind ?? 'act'
580
+ ) as AimOntologyKind;
581
+
582
+ const edgeKind = getSemanticEdgeKind(sourceKind, targetKind);
583
+ const currentData = (edge.getData() ?? {}) as Partial<RaidEdgeData>;
584
+ const stereotype = currentData.stereotype ?? getSemanticEdgeStereotype(sourceKind, targetKind);
585
+
586
+ edge.setData({
587
+ ...currentData,
588
+ id: edge.id,
589
+ kind: edgeKind,
590
+ sourceId: sourceCell.id,
591
+ targetId: targetCell.id,
592
+ sourcePort: edge.getSourcePortId(),
593
+ targetPort: edge.getTargetPortId(),
594
+ label: currentData.label ?? stereotype,
595
+ stereotype,
596
+ routing: currentData.routing ?? routingModeRef.current,
597
+ bendPoints: currentData.bendPoints ?? [],
598
+ });
599
+
600
+ if (isNew) {
601
+ applyEdgeRouting(edge, routingModeRef.current);
602
+ if (stereotype) {
603
+ edge.setLabels([
604
+ {
605
+ attrs: {
606
+ text: {
607
+ text: stereotype,
608
+ fill: CascaisPalette.TextSecondary,
609
+ fontSize: 11,
610
+ },
611
+ },
612
+ position: 0.5,
613
+ },
614
+ ]);
615
+ }
616
+ } else {
617
+ // Re-anchored: if edge was selected, re-attach tools
618
+ if (selectedCellIdRef.current === edge.id) {
619
+ setEdgeTools(edge);
620
+ }
621
+ }
622
+ }
623
+ triggerDebouncedChange();
624
+ });
625
+
626
+ // Canvas change events
627
+ graph.on('node:change:position', triggerDebouncedChange);
628
+ graph.on('node:change:size', triggerDebouncedChange);
629
+ graph.on('edge:change:vertices', triggerDebouncedChange);
630
+ graph.on('edge:change:source', triggerDebouncedChange);
631
+ graph.on('edge:change:target', triggerDebouncedChange);
632
+ graph.on('cell:added', triggerDebouncedChange);
633
+ graph.on('cell:removed', ({ cell }) => {
634
+ if (cell === activeToolEdge) {
635
+ activeToolEdge = null;
636
+ }
637
+ triggerDebouncedChange();
638
+ });
639
+
640
+ // Zoom listener to keep toolbar indicator accurate
641
+ graph.on('scale', () => {
642
+ setZoomLevel(Math.round(graph.zoom() * 100));
643
+ });
644
+
645
+ // Selection listeners
646
+ graph.on('cell:click', ({ cell }) => {
647
+ const id = String(cell.id);
648
+ selectedCellIdRef.current = id;
649
+
650
+ if (cell.isNode()) {
651
+ clearEdgeTools();
652
+ const data = (cell.getData() ?? {}) as Partial<RaidNodeData>;
653
+ const kind = (data.kind ?? 'act') as AimOntologyKind;
654
+ const label =
655
+ data.displayName ??
656
+ (cell.getAttrByPath('label/text') as string) ??
657
+ (cell.getAttrByPath('title/text') as string) ??
658
+ id;
659
+ onSelectRef.current?.({ id, kind, label });
660
+ } else if (cell.isEdge()) {
661
+ setEdgeTools(cell);
662
+ const data = (cell.getData() ?? {}) as Partial<RaidEdgeData>;
663
+ const label =
664
+ data.label ??
665
+ (cell.getLabels()?.[0]?.attrs?.['text']?.['text'] as string) ??
666
+ '';
667
+ onSelectRef.current?.({ id, kind: 'act', label });
668
+ }
669
+
670
+ onSelectionChangeRef.current?.([id]);
671
+ });
672
+
673
+ graph.on('blank:click', () => {
674
+ clearEdgeTools();
675
+ selectedCellIdRef.current = null;
676
+ onSelectRef.current?.(null);
677
+ onSelectionChangeRef.current?.([]);
678
+ });
679
+
680
+ // Keyboard listener for deletion
681
+ const handleKeyDown = (e: KeyboardEvent) => {
682
+ if (readOnly) return;
683
+ const activeTag = (document.activeElement?.tagName ?? '').toLowerCase();
684
+ if (
685
+ activeTag === 'input' ||
686
+ activeTag === 'textarea' ||
687
+ (document.activeElement as HTMLElement)?.isContentEditable
688
+ ) {
689
+ return;
690
+ }
691
+
692
+ if (e.key === 'Delete' || e.key === 'Backspace') {
693
+ if (selectedCellIdRef.current && graphRef.current) {
694
+ const cell = graphRef.current.getCellById(selectedCellIdRef.current);
695
+ if (cell) {
696
+ e.preventDefault();
697
+ clearEdgeTools();
698
+ graphRef.current.removeCell(cell);
699
+ selectedCellIdRef.current = null;
700
+ onSelectRef.current?.(null);
701
+ onSelectionChangeRef.current?.([]);
702
+ triggerDebouncedChange();
703
+ }
704
+ }
705
+ }
706
+ };
707
+
708
+ window.addEventListener('keydown', handleKeyDown);
709
+
710
+ // Auto-resize observer for fluid layouts
711
+ const resizeObserver = new ResizeObserver((entries) => {
712
+ for (const entry of entries) {
713
+ const { width, height } = entry.contentRect;
714
+ if (width > 0 && height > 0 && graphRef.current) {
715
+ graphRef.current.resize(width, height);
716
+ }
717
+ }
718
+ });
719
+
720
+ resizeObserver.observe(container);
721
+
722
+ // Initial Hydration
723
+ if (svgPropRef.current) {
724
+ isHydratingRef.current = true;
725
+ try {
726
+ bridgeRef.current.hydrateFromSvg(svgPropRef.current, graph);
727
+ lastSerializedSvgRef.current = svgPropRef.current;
728
+ graph.centerContent();
729
+ } catch (err) {
730
+ console.error('RaidCanvas hydration error:', err);
731
+ } finally {
732
+ isHydratingRef.current = false;
733
+ }
734
+ }
735
+
736
+ // Cleanup
737
+ return () => {
738
+ if (debounceTimerRef.current) {
739
+ clearTimeout(debounceTimerRef.current);
740
+ }
741
+ window.removeEventListener('keydown', handleKeyDown);
742
+ resizeObserver.disconnect();
743
+ clearEdgeToolsRef.current = null;
744
+ graph.dispose();
745
+ graphRef.current = null;
746
+ };
747
+ }, [readOnly, triggerDebouncedChange]);
748
+
749
+ // --------------------------------------------------------------------------
750
+ // 2. React to External SVG Changes
751
+ // --------------------------------------------------------------------------
752
+ useEffect(() => {
753
+ const graph = graphRef.current;
754
+ if (!graph || !activeSvg) return;
755
+
756
+ // Ignore if this change originated from our own serialization
757
+ if (activeSvg === lastSerializedSvgRef.current) {
758
+ return;
759
+ }
760
+
761
+ isHydratingRef.current = true;
762
+ try {
763
+ bridgeRef.current.hydrateFromSvg(activeSvg, graph);
764
+ lastSerializedSvgRef.current = activeSvg;
765
+ graph.centerContent();
766
+ } catch (err) {
767
+ console.error('RaidCanvas re-hydration error:', err);
768
+ } finally {
769
+ isHydratingRef.current = false;
770
+ }
771
+ }, [activeSvg]);
772
+
773
+ // --------------------------------------------------------------------------
774
+ // 3. HTML5 Drag-and-Drop Stencil Dropzone
775
+ // --------------------------------------------------------------------------
776
+ const handleDragOver = (e: React.DragEvent) => {
777
+ if (readOnly) return;
778
+ const hasStencil =
779
+ e.dataTransfer.types.includes('application/aoaim-kind') ||
780
+ e.dataTransfer.types.includes('text/plain');
781
+ if (hasStencil) {
782
+ e.preventDefault();
783
+ e.dataTransfer.dropEffect = 'copy';
784
+ if (!isDragOver) setIsDragOver(true);
785
+ }
786
+ };
787
+
788
+ const handleDragLeave = (e: React.DragEvent) => {
789
+ if (readOnly) return;
790
+ if (e.currentTarget.contains(e.relatedTarget as Node)) return;
791
+ setIsDragOver(false);
792
+ };
793
+
794
+ const handleDrop = (e: React.DragEvent) => {
795
+ if (readOnly) return;
796
+ e.preventDefault();
797
+ setIsDragOver(false);
798
+
799
+ const kind = (e.dataTransfer.getData('application/aoaim-kind') ||
800
+ e.dataTransfer.getData('text/plain')) as AimOntologyKind;
801
+
802
+ if (!kind || !graphRef.current || !containerRef.current) return;
803
+
804
+ const rect = containerRef.current.getBoundingClientRect();
805
+ const clientX = e.clientX - rect.left;
806
+ const clientY = e.clientY - rect.top;
807
+
808
+ const graph = graphRef.current;
809
+ const localPos = graph.clientToLocal({ x: clientX, y: clientY });
810
+
811
+ const defaultBounds = getDefaultNodeBounds(kind, localPos.x, localPos.y);
812
+ const bounds = {
813
+ ...defaultBounds,
814
+ x: Math.round(localPos.x - defaultBounds.width / 2),
815
+ y: Math.round(localPos.y - defaultBounds.height / 2),
816
+ };
817
+
818
+ const defaultName = getDefaultNodeName(kind);
819
+ const id = `${kind.toUpperCase()}_${Date.now().toString(36).slice(-4)}`;
820
+
821
+ const nodeData: RaidNodeData = {
822
+ id,
823
+ kind,
824
+ displayName: defaultName,
825
+ bounds,
826
+ };
827
+
828
+ const nodeMeta = createAimNode(nodeData);
829
+ graph.addNode(nodeMeta);
830
+
831
+ selectedCellIdRef.current = id;
832
+ onSelectRef.current?.({ id, kind, label: defaultName });
833
+ onSelectionChangeRef.current?.([id]);
834
+ onDropStencilRef.current?.(kind, { x: bounds.x, y: bounds.y });
835
+ triggerDebouncedChange();
836
+ };
837
+
838
+ return (
839
+ <div
840
+ className={`raid-canvas-wrapper ${className ?? ''}`.trim()}
841
+ onDragOver={handleDragOver}
842
+ onDragLeave={handleDragLeave}
843
+ onDrop={handleDrop}
844
+ style={{
845
+ position: 'relative',
846
+ width: '100%',
847
+ height: '100%',
848
+ minHeight: 300,
849
+ overflow: 'hidden',
850
+ background: isDragOver ? '#F0FDF4' : CascaisPalette.ChalkWhite,
851
+ outline: isDragOver ? `2px dashed ${CascaisPalette.HeraldicGreen}` : 'none',
852
+ outlineOffset: -4,
853
+ transition: 'background-color 0.2s ease, outline 0.2s ease',
854
+ ...style,
855
+ }}
856
+ >
857
+ {/* Graph Mount Target */}
858
+ <div
859
+ ref={containerRef}
860
+ style={{
861
+ width: '100%',
862
+ height: '100%',
863
+ position: 'absolute',
864
+ inset: 0,
865
+ }}
866
+ />
867
+
868
+ {/* Dropzone Overlay Badge (active during drag over) */}
869
+ {isDragOver && (
870
+ <div
871
+ style={{
872
+ position: 'absolute',
873
+ top: 16,
874
+ left: '50%',
875
+ transform: 'translateX(-50%)',
876
+ zIndex: 20,
877
+ padding: '6px 16px',
878
+ background: '#10B981',
879
+ color: '#FFFFFF',
880
+ borderRadius: 20,
881
+ fontSize: 12,
882
+ fontWeight: 600,
883
+ boxShadow: '0 4px 12px rgba(16, 185, 129, 0.3)',
884
+ pointerEvents: 'none',
885
+ }}
886
+ >
887
+ Drop to Instantiate AOAIM Node
888
+ </div>
889
+ )}
890
+
891
+ {/* Built-in Navigation & Zoom Controls */}
892
+ {showToolbar && (
893
+ <div
894
+ className="raid-canvas-toolbar"
895
+ style={{
896
+ position: 'absolute',
897
+ bottom: 16,
898
+ left: 16,
899
+ zIndex: 10,
900
+ display: 'inline-flex',
901
+ alignItems: 'center',
902
+ gap: 2,
903
+ padding: '4px 6px',
904
+ background: 'rgba(255, 255, 255, 0.92)',
905
+ backdropFilter: 'blur(8px)',
906
+ border: `1px solid ${CascaisPalette.SilverLineDark}`,
907
+ borderRadius: 8,
908
+ boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
909
+ fontFamily: 'Inter, system-ui, sans-serif',
910
+ userSelect: 'none',
911
+ }}
912
+ >
913
+ <button
914
+ type="button"
915
+ title="Zoom In"
916
+ aria-label="Zoom In"
917
+ onClick={handleZoomIn}
918
+ style={toolbarBtnStyle}
919
+ >
920
+
921
+ </button>
922
+ <button
923
+ type="button"
924
+ title="Zoom Out"
925
+ aria-label="Zoom Out"
926
+ onClick={handleZoomOut}
927
+ style={toolbarBtnStyle}
928
+ >
929
+
930
+ </button>
931
+ <span
932
+ style={{
933
+ padding: '0 6px',
934
+ fontSize: 11,
935
+ fontWeight: 600,
936
+ color: CascaisPalette.TextSecondary,
937
+ minWidth: 42,
938
+ textAlign: 'center',
939
+ }}
940
+ >
941
+ {zoomLevel}%
942
+ </span>
943
+ <div
944
+ style={{
945
+ width: 1,
946
+ height: 16,
947
+ background: CascaisPalette.SilverLine,
948
+ margin: '0 2px',
949
+ }}
950
+ />
951
+ <button
952
+ type="button"
953
+ title="Fit to Content"
954
+ aria-label="Fit to Content"
955
+ onClick={handleFitToContent}
956
+ style={toolbarBtnStyle}
957
+ >
958
+
959
+ </button>
960
+ <button
961
+ type="button"
962
+ title="Center Content"
963
+ aria-label="Center Content"
964
+ onClick={handleCenter}
965
+ style={toolbarBtnStyle}
966
+ >
967
+ 🎯
968
+ </button>
969
+ <button
970
+ type="button"
971
+ title="Reset View (100%)"
972
+ aria-label="Reset View"
973
+ onClick={handleResetView}
974
+ style={toolbarBtnStyle}
975
+ >
976
+
977
+ </button>
978
+ </div>
979
+ )}
980
+ </div>
981
+ );
982
+ },
983
+ );
984
+
985
+ const toolbarBtnStyle: React.CSSProperties = {
986
+ display: 'inline-flex',
987
+ alignItems: 'center',
988
+ justifyContent: 'center',
989
+ width: 28,
990
+ height: 28,
991
+ padding: 0,
992
+ border: 'none',
993
+ background: 'transparent',
994
+ borderRadius: 6,
995
+ cursor: 'pointer',
996
+ fontSize: 12,
997
+ color: CascaisPalette.WarmGraphite,
998
+ transition: 'background-color 0.15s ease',
999
+ };