@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.
- package/LICENSE +0 -13
- package/README.md +90 -68
- package/dist/app/editor/CorrectionPanelPlugin.js +10 -11
- package/dist/app/editor/DragHandlePlugin.js +1 -1
- package/dist/app/editor/SelectionContextMenuPlugin.js +4 -4
- package/dist/app/editor/nodes/C4Component.js +8 -10
- package/dist/app/editor/nodes/C4Node.d.ts +1 -1
- package/dist/app/editor/nodes/DiagramContextMenu.js +5 -5
- package/dist/headless.d.ts +3 -5
- package/dist/headless.js +2 -4
- package/dist/index.d.ts +1 -1
- package/dist/styles.css +425 -454
- package/docs/decisions/adr-93-liminis-editor-defined-aliases.md +28 -1
- package/docs/decisions/adr-98-invert-token-direction.md +309 -0
- package/package.json +2 -2
- package/dist/app/editor/c4/C4InteractiveRenderer.d.ts +0 -35
- package/dist/app/editor/c4/C4InteractiveRenderer.js +0 -299
- package/dist/app/editor/c4/edge-clipping.d.ts +0 -24
- package/dist/app/editor/c4/edge-clipping.js +0 -139
- package/dist/app/editor/c4/hooks/useC4DiagramDrag.d.ts +0 -38
- package/dist/app/editor/c4/hooks/useC4DiagramDrag.js +0 -112
- package/dist/app/editor/c4/layout.d.ts +0 -25
- package/dist/app/editor/c4/layout.js +0 -839
- package/dist/app/editor/c4/parser.d.ts +0 -19
- package/dist/app/editor/c4/parser.js +0 -410
- package/dist/app/editor/c4/render-to-string.d.ts +0 -24
- package/dist/app/editor/c4/render-to-string.js +0 -34
- package/dist/app/editor/c4/renderer.d.ts +0 -64
- package/dist/app/editor/c4/renderer.js +0 -569
- package/dist/app/editor/c4/types.d.ts +0 -203
- 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 };
|