@liminis/editor 0.3.0 → 0.4.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.
Files changed (31) hide show
  1. package/LICENSE +0 -13
  2. package/README.md +90 -68
  3. package/dist/app/editor/CorrectionPanelPlugin.js +10 -11
  4. package/dist/app/editor/DragHandlePlugin.js +1 -1
  5. package/dist/app/editor/SelectionContextMenuPlugin.js +4 -4
  6. package/dist/app/editor/nodes/C4Component.js +8 -10
  7. package/dist/app/editor/nodes/C4Node.d.ts +1 -1
  8. package/dist/app/editor/nodes/DiagramContextMenu.js +5 -5
  9. package/dist/headless.d.ts +3 -5
  10. package/dist/headless.js +2 -4
  11. package/dist/index.d.ts +1 -1
  12. package/dist/styles.css +425 -454
  13. package/docs/decisions/adr-93-liminis-editor-defined-aliases.md +28 -1
  14. package/docs/decisions/adr-98-invert-token-direction.md +309 -0
  15. package/package.json +2 -2
  16. package/dist/app/editor/c4/C4InteractiveRenderer.d.ts +0 -35
  17. package/dist/app/editor/c4/C4InteractiveRenderer.js +0 -299
  18. package/dist/app/editor/c4/edge-clipping.d.ts +0 -24
  19. package/dist/app/editor/c4/edge-clipping.js +0 -139
  20. package/dist/app/editor/c4/hooks/useC4DiagramDrag.d.ts +0 -38
  21. package/dist/app/editor/c4/hooks/useC4DiagramDrag.js +0 -112
  22. package/dist/app/editor/c4/layout.d.ts +0 -25
  23. package/dist/app/editor/c4/layout.js +0 -839
  24. package/dist/app/editor/c4/parser.d.ts +0 -19
  25. package/dist/app/editor/c4/parser.js +0 -410
  26. package/dist/app/editor/c4/render-to-string.d.ts +0 -24
  27. package/dist/app/editor/c4/render-to-string.js +0 -34
  28. package/dist/app/editor/c4/renderer.d.ts +0 -64
  29. package/dist/app/editor/c4/renderer.js +0 -569
  30. package/dist/app/editor/c4/types.d.ts +0 -203
  31. package/dist/app/editor/c4/types.js +0 -43
@@ -1,299 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- /**
3
- * C4InteractiveRenderer - Interactive SVG renderer with drag support
4
- *
5
- * Wraps the C4Renderer with drag-and-drop functionality for manual layout mode.
6
- * Maintains local position state during drag and recalculates edges in real-time.
7
- */
8
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
9
- import { C4RendererContent, computeLegendInfo } from './renderer.js';
10
- import { layoutC4Diagram } from './layout.js';
11
- import { useC4DiagramDrag } from './hooks/useC4DiagramDrag.js';
12
- /** Synthetic ID used to store legend position in manual positions map */
13
- export const LEGEND_POSITION_ID = '__legend__';
14
- /**
15
- * Collect all node IDs and their positions from the layout tree.
16
- */
17
- function collectNodePositions(nodes) {
18
- const positions = {};
19
- for (const node of nodes) {
20
- positions[node.id] = { x: node.x, y: node.y };
21
- if (node.children) {
22
- Object.assign(positions, collectNodePositions(node.children));
23
- }
24
- }
25
- return positions;
26
- }
27
- /**
28
- * Build a map from element ID to all its descendant IDs (recursive).
29
- */
30
- function collectDescendantIds(elements) {
31
- const map = new Map();
32
- function getDescendants(element) {
33
- const ids = [];
34
- for (const child of element.children) {
35
- ids.push(child.id);
36
- ids.push(...getDescendants(child));
37
- }
38
- return ids;
39
- }
40
- function visit(element) {
41
- const descendants = getDescendants(element);
42
- if (descendants.length > 0) {
43
- map.set(element.id, descendants);
44
- }
45
- for (const child of element.children) {
46
- visit(child);
47
- }
48
- }
49
- for (const element of elements) {
50
- visit(element);
51
- }
52
- return map;
53
- }
54
- /**
55
- * Interactive C4 diagram renderer with drag support.
56
- *
57
- * When isEditMode is true, nodes can be dragged to new positions.
58
- * Edges and boundaries are recalculated in real-time during drag.
59
- */
60
- export function C4InteractiveRenderer({ diagram, isDarkMode, isEditMode, manualPositions, onPositionChange, }) {
61
- const svgRef = useRef(null);
62
- // Local positions during drag (merged with persisted positions)
63
- const [dragPositions, setDragPositions] = useState({});
64
- // Ref mirror of dragPositions — always current, avoids stale closure in handleNodeDragEnd
65
- const dragPositionsRef = useRef({});
66
- // rAF handle for throttling drag updates
67
- const rafRef = useRef(null);
68
- const pendingDragRef = useRef(null);
69
- // Track drag start position for computing delta (used to move children)
70
- const dragStartPosRef = useRef(null);
71
- // Map from element ID to its descendant IDs (for moving children with boundary)
72
- const descendantMap = useMemo(() => {
73
- return collectDescendantIds(diagram.elements);
74
- }, [diagram.elements]);
75
- // Compute the auto-layout once for seeding positions on first drag
76
- const autoLayout = useMemo(() => {
77
- return layoutC4Diagram(diagram);
78
- }, [diagram]);
79
- // Merge persisted positions with drag positions
80
- const effectivePositions = useMemo(() => ({
81
- ...manualPositions,
82
- ...dragPositions,
83
- }), [manualPositions, dragPositions]);
84
- // Compute layout using effective positions
85
- const layout = useMemo(() => {
86
- const hasPositions = Object.keys(effectivePositions).length > 0;
87
- return layoutC4Diagram(diagram, undefined, hasPositions ? effectivePositions : undefined);
88
- }, [diagram, effectivePositions]);
89
- /**
90
- * Apply drag delta to a node and all its descendants.
91
- * Returns an object with updated positions for the node and its children.
92
- */
93
- const applyDragWithChildren = useCallback((nodeId, x, y, basePositions) => {
94
- const updates = { [nodeId]: { x, y } };
95
- const childIds = descendantMap.get(nodeId);
96
- if (childIds && dragStartPosRef.current) {
97
- const dx = x - dragStartPosRef.current.x;
98
- const dy = y - dragStartPosRef.current.y;
99
- for (const childId of childIds) {
100
- const childPos = basePositions[childId];
101
- if (childPos) {
102
- updates[childId] = { x: childPos.x + dx, y: childPos.y + dy };
103
- }
104
- }
105
- }
106
- return updates;
107
- }, [descendantMap]);
108
- // Snapshot of positions at drag start (before any delta is applied to children)
109
- const dragStartPositionsRef = useRef({});
110
- // Helper to update both drag state and ref mirror
111
- const updateDragPositions = useCallback((updater) => {
112
- setDragPositions(prev => {
113
- const next = updater(prev);
114
- dragPositionsRef.current = next;
115
- return next;
116
- });
117
- }, []);
118
- // Handle real-time position updates during drag, throttled to rAF
119
- const handleNodeDrag = useCallback((nodeId, x, y) => {
120
- // On first move of a new drag, clear any stale positions from previous drag
121
- if (!dragStartPosRef.current && Object.keys(dragPositionsRef.current).length > 0) {
122
- dragPositionsRef.current = {};
123
- setDragPositions({});
124
- }
125
- pendingDragRef.current = { nodeId, x, y };
126
- if (rafRef.current === null) {
127
- rafRef.current = requestAnimationFrame(() => {
128
- rafRef.current = null;
129
- const pending = pendingDragRef.current;
130
- if (pending) {
131
- updateDragPositions(prev => {
132
- // On first drag, seed all node positions from current layout
133
- // to prevent other nodes from jumping to default placement
134
- if (Object.keys(prev).length === 0 && Object.keys(manualPositions).length === 0) {
135
- const seeded = collectNodePositions(autoLayout.nodes);
136
- // Record start positions for delta calculation
137
- if (!dragStartPosRef.current) {
138
- dragStartPosRef.current = seeded[pending.nodeId] ?? { x: pending.x, y: pending.y };
139
- dragStartPositionsRef.current = { ...seeded };
140
- }
141
- const updates = applyDragWithChildren(pending.nodeId, pending.x, pending.y, dragStartPositionsRef.current);
142
- return { ...seeded, ...updates };
143
- }
144
- // Record start position on first move if not already set
145
- if (!dragStartPosRef.current) {
146
- const currentPos = { ...manualPositions, ...prev };
147
- dragStartPosRef.current = currentPos[pending.nodeId] ?? { x: pending.x, y: pending.y };
148
- dragStartPositionsRef.current = { ...currentPos };
149
- }
150
- const updates = applyDragWithChildren(pending.nodeId, pending.x, pending.y, dragStartPositionsRef.current);
151
- return { ...prev, ...updates };
152
- });
153
- }
154
- });
155
- }
156
- }, [manualPositions, autoLayout.nodes, applyDragWithChildren, updateDragPositions]);
157
- // Handle drag end - persist positions
158
- // Uses dragPositionsRef (not dragPositions state) to avoid stale closure race
159
- const handleNodeDragEnd = useCallback((nodeId, x, y) => {
160
- // Cancel any pending rAF
161
- if (rafRef.current !== null) {
162
- cancelAnimationFrame(rafRef.current);
163
- rafRef.current = null;
164
- }
165
- // Read latest drag positions from ref (immune to stale closure)
166
- const currentDragPositions = dragPositionsRef.current;
167
- // Merge all positions and persist
168
- const newPositions = {
169
- ...manualPositions,
170
- ...currentDragPositions,
171
- };
172
- // If this is the first drag, populate all other nodes with their current auto-layout positions
173
- if (Object.keys(manualPositions).length === 0) {
174
- const autoPositions = collectNodePositions(autoLayout.nodes);
175
- for (const [id, pos] of Object.entries(autoPositions)) {
176
- if (!newPositions[id]) {
177
- newPositions[id] = pos;
178
- }
179
- }
180
- }
181
- // Apply final position with children
182
- const basePositions = dragStartPositionsRef.current;
183
- const updates = applyDragWithChildren(nodeId, x, y, basePositions);
184
- Object.assign(newPositions, updates);
185
- onPositionChange(newPositions);
186
- // Don't clear dragPositions here — manualPositions hasn't absorbed the
187
- // new values yet (Lexical update listener is async). Clearing now would
188
- // cause effectivePositions to revert to stale manualPositions, producing
189
- // a visual snap-back. Instead, clear in the effect below when
190
- // manualPositions catches up.
191
- dragStartPosRef.current = null;
192
- dragStartPositionsRef.current = {};
193
- }, [manualPositions, autoLayout.nodes, onPositionChange, applyDragWithChildren]);
194
- // Set up drag hook (window-level listeners handle mousemove/mouseup)
195
- const { draggedNodeId, startNodeDrag } = useC4DiagramDrag({
196
- svgRef,
197
- onNodeDrag: handleNodeDrag,
198
- onNodeDragEnd: handleNodeDragEnd,
199
- enabled: isEditMode,
200
- });
201
- // Clear drag positions once manualPositions has absorbed the persisted values.
202
- // We verify absorption by comparing values — this avoids both:
203
- // - Clearing mid-drag (draggedNodeId guard)
204
- // - Clearing before Lexical propagates (value comparison)
205
- useEffect(() => {
206
- if (draggedNodeId || Object.keys(dragPositionsRef.current).length === 0)
207
- return;
208
- // Reset case: manualPositions was cleared (e.g., "Reset to Auto Layout")
209
- if (Object.keys(manualPositions).length === 0) {
210
- dragPositionsRef.current = {};
211
- setDragPositions({});
212
- return;
213
- }
214
- // Normal case: only clear once manualPositions contains the drag values
215
- const absorbed = Object.entries(dragPositionsRef.current).every(([id, pos]) => {
216
- const mp = manualPositions[id];
217
- return mp?.x === pos.x && mp?.y === pos.y;
218
- });
219
- if (absorbed) {
220
- dragPositionsRef.current = {};
221
- setDragPositions({});
222
- }
223
- }, [manualPositions, draggedNodeId]);
224
- // Compute legend info for hit area and position override
225
- const legendInfo = useMemo(() => computeLegendInfo(layout), [layout]);
226
- const legendPositionOverride = useMemo(() => {
227
- const pos = effectivePositions[LEGEND_POSITION_ID];
228
- return pos ?? null;
229
- }, [effectivePositions]);
230
- // Render with interactive wrappers
231
- return (_jsx(C4InteractiveSvg, { layout: layout, isDarkMode: isDarkMode, isEditMode: isEditMode, draggedNodeId: draggedNodeId, svgRef: svgRef, onNodeMouseDown: startNodeDrag, legendInfo: legendInfo, legendPositionOverride: legendPositionOverride }));
232
- }
233
- /**
234
- * SVG wrapper that adds interactive overlays for drag handling.
235
- */
236
- function C4InteractiveSvg({ layout, isDarkMode, isEditMode, draggedNodeId, svgRef, onNodeMouseDown, legendInfo, legendPositionOverride, }) {
237
- // Get colors based on theme
238
- const handleColor = isDarkMode ? '#a0a0a0' : '#505050';
239
- // Create hit areas for each node (and legend if present)
240
- const hitAreas = useMemo(() => {
241
- const areas = [];
242
- function collectNodes(nodes) {
243
- for (const node of nodes) {
244
- areas.push({
245
- id: node.id,
246
- x: node.x,
247
- y: node.y,
248
- width: node.width,
249
- height: node.height,
250
- });
251
- }
252
- }
253
- collectNodes(layout.nodes);
254
- // Add legend as a draggable hit area
255
- if (legendInfo) {
256
- const lx = legendPositionOverride?.x ?? legendInfo.x;
257
- const ly = legendPositionOverride?.y ?? legendInfo.y;
258
- areas.push({
259
- id: LEGEND_POSITION_ID,
260
- x: lx,
261
- y: ly,
262
- width: legendInfo.width,
263
- height: legendInfo.height,
264
- });
265
- }
266
- return areas;
267
- }, [layout.nodes, legendInfo, legendPositionOverride]);
268
- // Compute SVG dimensions that include potential legend overflow,
269
- // mirroring the behavior of the non-interactive C4Renderer.
270
- const { svgWidth, svgHeight } = useMemo(() => {
271
- let w = layout.width;
272
- let h = layout.height;
273
- if (legendInfo) {
274
- const lx = legendPositionOverride?.x ?? legendInfo.x;
275
- const ly = legendPositionOverride?.y ?? legendInfo.y;
276
- const legendRight = lx + legendInfo.width - layout.viewBoxX;
277
- const legendBottom = ly + legendInfo.height - layout.viewBoxY;
278
- if (legendRight > w)
279
- w = legendRight;
280
- if (legendBottom > h)
281
- h = legendBottom;
282
- }
283
- return { svgWidth: w, svgHeight: h };
284
- }, [layout.width, layout.height, layout.viewBoxX, layout.viewBoxY, legendInfo, legendPositionOverride]);
285
- return (_jsx("div", { style: { position: 'relative' }, children: _jsxs("svg", { ref: svgRef, width: svgWidth, height: svgHeight, viewBox: `${layout.viewBoxX} ${layout.viewBoxY} ${svgWidth} ${svgHeight}`, xmlns: "http://www.w3.org/2000/svg", "data-diagram": "c4", style: {
286
- fontFamily: 'system-ui, -apple-system, sans-serif',
287
- cursor: isEditMode ? (draggedNodeId ? 'grabbing' : 'default') : 'default',
288
- }, children: [_jsx(C4RendererContent, { layout: layout, isDarkMode: isDarkMode, legendPositionOverride: legendPositionOverride }), isEditMode && (_jsx("g", { className: "interactive-layer", children: hitAreas.map((area) => (_jsx("rect", { "data-node-id": area.id, x: area.x, y: area.y, width: area.width, height: area.height, fill: "transparent", stroke: "transparent", style: {
289
- cursor: draggedNodeId === area.id ? 'grabbing' : 'grab',
290
- }, onMouseDown: (e) => onNodeMouseDown(area.id, area.x, area.y, e) }, area.id))) })), isEditMode && (_jsx("g", { className: "drag-handles-layer", children: hitAreas.map((area) => (_jsx(DragHandle, { x: area.x + area.width - 20, y: area.y + 4, color: handleColor, isDragging: draggedNodeId === area.id }, `handle-${area.id}`))) }))] }) }));
291
- }
292
- /**
293
- * Drag handle indicator (grip icon).
294
- */
295
- function DragHandle({ x, y, color, isDragging, }) {
296
- const opacity = isDragging ? 0.8 : 0.4;
297
- return (_jsxs("g", { transform: `translate(${x}, ${y})`, opacity: opacity, style: { pointerEvents: 'none' }, children: [_jsx("circle", { cx: 4, cy: 4, r: 1.5, fill: color }), _jsx("circle", { cx: 12, cy: 4, r: 1.5, fill: color }), _jsx("circle", { cx: 4, cy: 8, r: 1.5, fill: color }), _jsx("circle", { cx: 12, cy: 8, r: 1.5, fill: color }), _jsx("circle", { cx: 4, cy: 12, r: 1.5, fill: color }), _jsx("circle", { cx: 12, cy: 12, r: 1.5, fill: color })] }));
298
- }
299
- export default C4InteractiveRenderer;
@@ -1,24 +0,0 @@
1
- /**
2
- * Edge line clipping utilities for C4 diagrams.
3
- *
4
- * Computes where edge polylines intersect label bounding boxes and splits
5
- * them into visible segments, creating clean gaps around label text.
6
- */
7
- import type { Point } from './types.js';
8
- /**
9
- * Estimate the half-width and half-height of a label's bounding box.
10
- */
11
- export declare function estimateLabelSize(label: string, fontSize: number): {
12
- halfW: number;
13
- halfH: number;
14
- };
15
- /**
16
- * Build clipped edge path strings that leave a gap where the label sits.
17
- *
18
- * Transforms points into the label's local coordinate system (accounting for
19
- * rotation), finds where line segments cross the label bounding box, and
20
- * splits the polyline into visible portions outside the box.
21
- *
22
- * @returns SVG path data strings for each visible segment
23
- */
24
- export declare function buildClippedEdgePaths(points: Point[], labelCenter: Point, labelHalfW: number, labelHalfH: number, angleDeg: number): string[];
@@ -1,139 +0,0 @@
1
- /**
2
- * Edge line clipping utilities for C4 diagrams.
3
- *
4
- * Computes where edge polylines intersect label bounding boxes and splits
5
- * them into visible segments, creating clean gaps around label text.
6
- */
7
- // =============================================================================
8
- // CONSTANTS
9
- // =============================================================================
10
- /** Average character width as a ratio of font size (for monospace-ish system fonts) */
11
- const AVG_CHAR_WIDTH_RATIO = 0.55;
12
- /** Horizontal padding around label text for the clipping box */
13
- const LABEL_PADDING_X = 3;
14
- /** Vertical padding around label text for the clipping box */
15
- const LABEL_PADDING_Y = 4;
16
- // =============================================================================
17
- // LABEL SIZE ESTIMATION
18
- // =============================================================================
19
- /**
20
- * Estimate the half-width and half-height of a label's bounding box.
21
- */
22
- export function estimateLabelSize(label, fontSize) {
23
- const avgCharWidth = fontSize * AVG_CHAR_WIDTH_RATIO;
24
- const halfW = (label.length * avgCharWidth) / 2 + LABEL_PADDING_X;
25
- const halfH = fontSize / 2 + LABEL_PADDING_Y;
26
- return { halfW, halfH };
27
- }
28
- // =============================================================================
29
- // POLYLINE CLIPPING
30
- // =============================================================================
31
- /**
32
- * Build clipped edge path strings that leave a gap where the label sits.
33
- *
34
- * Transforms points into the label's local coordinate system (accounting for
35
- * rotation), finds where line segments cross the label bounding box, and
36
- * splits the polyline into visible portions outside the box.
37
- *
38
- * @returns SVG path data strings for each visible segment
39
- */
40
- export function buildClippedEdgePaths(points, labelCenter, labelHalfW, labelHalfH, angleDeg) {
41
- const angleRad = (-angleDeg * Math.PI) / 180;
42
- const cosA = Math.cos(angleRad);
43
- const sinA = Math.sin(angleRad);
44
- const toLocal = (p) => ({
45
- x: (p.x - labelCenter.x) * cosA - (p.y - labelCenter.y) * sinA,
46
- y: (p.x - labelCenter.x) * sinA + (p.y - labelCenter.y) * cosA,
47
- });
48
- const isInBox = (p) => {
49
- const l = toLocal(p);
50
- return Math.abs(l.x) < labelHalfW && Math.abs(l.y) < labelHalfH;
51
- };
52
- const findCrossings = (p1, p2) => {
53
- const l1 = toLocal(p1);
54
- const l2 = toLocal(p2);
55
- const dx = l2.x - l1.x;
56
- const dy = l2.y - l1.y;
57
- const ts = [];
58
- for (const bx of [-labelHalfW, labelHalfW]) {
59
- if (dx !== 0) {
60
- const t = (bx - l1.x) / dx;
61
- if (t > 0 && t < 1) {
62
- const y = l1.y + t * dy;
63
- if (Math.abs(y) <= labelHalfH)
64
- ts.push(t);
65
- }
66
- }
67
- }
68
- for (const by of [-labelHalfH, labelHalfH]) {
69
- if (dy !== 0) {
70
- const t = (by - l1.y) / dy;
71
- if (t > 0 && t < 1) {
72
- const x = l1.x + t * dx;
73
- if (Math.abs(x) <= labelHalfW)
74
- ts.push(t);
75
- }
76
- }
77
- }
78
- return ts.sort((a, b) => a - b);
79
- };
80
- const lerp = (p1, p2, t) => ({
81
- x: p1.x + (p2.x - p1.x) * t,
82
- y: p1.y + (p2.y - p1.y) * t,
83
- });
84
- const visibleSegments = [];
85
- let current = [];
86
- for (let i = 0; i < points.length; i++) {
87
- const p = points[i];
88
- const inside = isInBox(p);
89
- if (i === 0) {
90
- if (!inside)
91
- current.push(p);
92
- continue;
93
- }
94
- const prev = points[i - 1];
95
- const prevInside = isInBox(prev);
96
- const crossings = findCrossings(prev, p);
97
- if (!prevInside && !inside && crossings.length === 0) {
98
- current.push(p);
99
- }
100
- else if (!prevInside && !inside && crossings.length === 1) {
101
- // Tangent touch — treat as no clipping
102
- current.push(p);
103
- }
104
- else if (!prevInside && !inside && crossings.length >= 2) {
105
- current.push(lerp(prev, p, crossings[0]));
106
- visibleSegments.push(current);
107
- current = [lerp(prev, p, crossings[crossings.length - 1]), p];
108
- }
109
- else if (!prevInside && inside) {
110
- if (crossings.length > 0) {
111
- current.push(lerp(prev, p, crossings[0]));
112
- }
113
- if (current.length >= 2)
114
- visibleSegments.push(current);
115
- current = [];
116
- }
117
- else if (prevInside && !inside) {
118
- if (crossings.length > 0) {
119
- current = [lerp(prev, p, crossings[crossings.length - 1])];
120
- }
121
- current.push(p);
122
- }
123
- // both inside — skip
124
- }
125
- if (current.length >= 2) {
126
- visibleSegments.push(current);
127
- }
128
- const paths = visibleSegments
129
- .filter((seg) => seg.length >= 2)
130
- .map((seg) => seg.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' '));
131
- // Fallback: if clipping consumed the entire edge, draw the original path
132
- // rather than leaving a floating arrowhead with no line
133
- if (paths.length === 0 && points.length >= 2) {
134
- return [
135
- points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' '),
136
- ];
137
- }
138
- return paths;
139
- }
@@ -1,38 +0,0 @@
1
- /**
2
- * useC4DiagramDrag - Hook for drag interaction on C4 diagram elements
3
- *
4
- * Provides drag-and-drop functionality for repositioning C4 diagram elements.
5
- * Uses window-level listeners during drag so the interaction continues even
6
- * when the cursor leaves the SVG bounds.
7
- */
8
- import { RefObject } from 'react';
9
- export interface UseC4DiagramDragProps {
10
- /** Reference to the SVG element */
11
- svgRef: RefObject<SVGSVGElement | null>;
12
- /** Callback fired during drag with new position */
13
- onNodeDrag?: (nodeId: string, x: number, y: number) => void;
14
- /** Callback fired when drag ends */
15
- onNodeDragEnd?: (nodeId: string, x: number, y: number) => void;
16
- /** Whether drag interaction is enabled */
17
- enabled: boolean;
18
- }
19
- export interface UseC4DiagramDragReturn {
20
- /** ID of the node currently being dragged */
21
- draggedNodeId: string | null;
22
- /** Whether a drag is in progress */
23
- isDragging: boolean;
24
- /** Start dragging a node */
25
- startNodeDrag: (nodeId: string, nodeX: number, nodeY: number, e: React.MouseEvent) => void;
26
- /** Convert screen coordinates to SVG coordinates */
27
- screenToSvg: (clientX: number, clientY: number) => {
28
- x: number;
29
- y: number;
30
- } | null;
31
- }
32
- /**
33
- * Hook for managing drag interactions on C4 diagram nodes.
34
- *
35
- * During a drag, mousemove and mouseup are handled on `window` so the
36
- * interaction continues seamlessly when the cursor moves outside the SVG.
37
- */
38
- export declare function useC4DiagramDrag({ svgRef, onNodeDrag, onNodeDragEnd, enabled, }: UseC4DiagramDragProps): UseC4DiagramDragReturn;
@@ -1,112 +0,0 @@
1
- /**
2
- * useC4DiagramDrag - Hook for drag interaction on C4 diagram elements
3
- *
4
- * Provides drag-and-drop functionality for repositioning C4 diagram elements.
5
- * Uses window-level listeners during drag so the interaction continues even
6
- * when the cursor leaves the SVG bounds.
7
- */
8
- import { useCallback, useEffect, useRef, useState } from 'react';
9
- /**
10
- * Hook for managing drag interactions on C4 diagram nodes.
11
- *
12
- * During a drag, mousemove and mouseup are handled on `window` so the
13
- * interaction continues seamlessly when the cursor moves outside the SVG.
14
- */
15
- export function useC4DiagramDrag({ svgRef, onNodeDrag, onNodeDragEnd, enabled, }) {
16
- const [draggedNodeId, setDraggedNodeId] = useState(null);
17
- // Track the offset from cursor to node origin for smooth dragging
18
- const dragOffsetRef = useRef({ x: 0, y: 0 });
19
- // Track the last known position for dragEnd callback
20
- const lastPositionRef = useRef({ x: 0, y: 0 });
21
- // Locked CTM inverse captured at drag start — prevents the accelerating
22
- // feedback loop where canvas expansion changes the scale mid-drag
23
- const lockedCtmInverseRef = useRef(null);
24
- // Refs for callbacks so window listeners always see latest values
25
- const onNodeDragRef = useRef(onNodeDrag);
26
- onNodeDragRef.current = onNodeDrag;
27
- const onNodeDragEndRef = useRef(onNodeDragEnd);
28
- onNodeDragEndRef.current = onNodeDragEnd;
29
- const draggedNodeIdRef = useRef(null);
30
- /**
31
- * Convert screen (client) coordinates to SVG coordinates.
32
- * Uses the locked CTM from drag start if available, otherwise live CTM.
33
- */
34
- const screenToSvg = useCallback((clientX, clientY) => {
35
- const svg = svgRef.current;
36
- if (!svg)
37
- return null;
38
- const point = svg.createSVGPoint();
39
- point.x = clientX;
40
- point.y = clientY;
41
- const inverse = lockedCtmInverseRef.current ?? svg.getScreenCTM()?.inverse();
42
- if (!inverse)
43
- return null;
44
- const svgPoint = point.matrixTransform(inverse);
45
- return { x: svgPoint.x, y: svgPoint.y };
46
- }, [svgRef]);
47
- const screenToSvgRef = useRef(screenToSvg);
48
- screenToSvgRef.current = screenToSvg;
49
- /**
50
- * Start dragging a node.
51
- * Locks the screen-to-SVG transform so canvas resizing during drag
52
- * doesn't cause accelerating movement.
53
- */
54
- const startNodeDrag = useCallback((nodeId, nodeX, nodeY, e) => {
55
- if (!enabled)
56
- return;
57
- e.stopPropagation();
58
- e.preventDefault();
59
- // Lock the CTM at drag start
60
- const svg = svgRef.current;
61
- const ctm = svg?.getScreenCTM();
62
- lockedCtmInverseRef.current = ctm ? ctm.inverse() : null;
63
- const svgPoint = screenToSvg(e.clientX, e.clientY);
64
- if (!svgPoint)
65
- return;
66
- dragOffsetRef.current = {
67
- x: svgPoint.x - nodeX,
68
- y: svgPoint.y - nodeY,
69
- };
70
- lastPositionRef.current = { x: nodeX, y: nodeY };
71
- draggedNodeIdRef.current = nodeId;
72
- setDraggedNodeId(nodeId);
73
- }, [enabled, screenToSvg, svgRef]);
74
- // Attach window-level listeners while dragging
75
- useEffect(() => {
76
- if (!draggedNodeId)
77
- return;
78
- const handleMouseMove = (e) => {
79
- const nodeId = draggedNodeIdRef.current;
80
- if (!nodeId)
81
- return;
82
- const svgPoint = screenToSvgRef.current(e.clientX, e.clientY);
83
- if (!svgPoint)
84
- return;
85
- const newX = svgPoint.x - dragOffsetRef.current.x;
86
- const newY = svgPoint.y - dragOffsetRef.current.y;
87
- lastPositionRef.current = { x: newX, y: newY };
88
- onNodeDragRef.current?.(nodeId, newX, newY);
89
- };
90
- const handleMouseUp = () => {
91
- const nodeId = draggedNodeIdRef.current;
92
- if (!nodeId)
93
- return;
94
- onNodeDragEndRef.current?.(nodeId, lastPositionRef.current.x, lastPositionRef.current.y);
95
- draggedNodeIdRef.current = null;
96
- lockedCtmInverseRef.current = null;
97
- setDraggedNodeId(null);
98
- };
99
- window.addEventListener('mousemove', handleMouseMove);
100
- window.addEventListener('mouseup', handleMouseUp);
101
- return () => {
102
- window.removeEventListener('mousemove', handleMouseMove);
103
- window.removeEventListener('mouseup', handleMouseUp);
104
- };
105
- }, [draggedNodeId]);
106
- return {
107
- draggedNodeId,
108
- isDragging: draggedNodeId !== null,
109
- startNodeDrag,
110
- screenToSvg,
111
- };
112
- }
@@ -1,25 +0,0 @@
1
- /**
2
- * C4 Layout Engine
3
- *
4
- * Uses @dagrejs/dagre for directed graph auto-layout of C4 architecture diagrams.
5
- * Supports nested elements (systems containing containers containing components)
6
- * with proper boundary group padding.
7
- */
8
- import type { C4Diagram, LayoutResult, LayoutNode, LayoutEdge, LayoutOptions, Point, ManualLayout } from './types.js';
9
- /**
10
- * Layout a C4 diagram using dagre for auto-positioning,
11
- * or manual positions if provided.
12
- *
13
- * @param diagram - The parsed C4 diagram AST
14
- * @param options - Layout configuration options
15
- * @param manualPositions - Optional manual positions for elements (bypasses dagre)
16
- * @returns Layout result with positioned nodes and routed edges
17
- */
18
- export declare function layoutC4Diagram(diagram: C4Diagram, options?: LayoutOptions, manualPositions?: Record<string, {
19
- x: number;
20
- y: number;
21
- }>): LayoutResult;
22
- /**
23
- * Re-export types for convenience.
24
- */
25
- export type { LayoutResult, LayoutNode, LayoutEdge, LayoutOptions, Point, ManualLayout };