@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.
- package/dist/RaiBridge.d.ts.map +1 -1
- package/dist/RaiBridge.js +106 -4
- package/dist/RaiBridge.js.map +1 -1
- package/dist/RaidCanvas.d.ts +82 -0
- package/dist/RaidCanvas.d.ts.map +1 -0
- package/dist/RaidCanvas.js +748 -0
- package/dist/RaidCanvas.js.map +1 -0
- package/dist/X6Shapes.d.ts +16 -1
- package/dist/X6Shapes.d.ts.map +1 -1
- package/dist/X6Shapes.js +107 -18
- package/dist/X6Shapes.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/semanticRules.d.ts +55 -0
- package/dist/semanticRules.d.ts.map +1 -0
- package/dist/semanticRules.js +201 -0
- package/dist/semanticRules.js.map +1 -0
- package/dist/types.d.ts +11 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -0
- package/dist/types.js.map +1 -1
- package/package.json +10 -2
- package/src/RaiBridge.ts +121 -4
- package/src/RaidCanvas.tsx +999 -0
- package/src/X6Shapes.ts +119 -18
- package/src/index.ts +25 -0
- package/src/semanticRules.ts +237 -0
- package/src/types.ts +13 -1
|
@@ -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
|
+
};
|