@pixerate/editor-svelte 0.3.12 → 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.
@@ -0,0 +1,145 @@
1
+ import type { XYPosition } from '@xyflow/svelte';
2
+ import type { CanvasNode, CanvasDockingCallbacks } from './types.js';
3
+
4
+ export interface CreateCanvasDockingOptions<TNode extends CanvasNode = CanvasNode>
5
+ extends CanvasDockingCallbacks<TNode> {
6
+ isDockTarget?: (target: TNode) => boolean;
7
+ restorePositionOnDock?: boolean;
8
+ onSave?: () => void;
9
+ }
10
+
11
+ /**
12
+ * Headless Svelte 5 rune managing canvas node dragging, docking intersection, and connection tracking.
13
+ */
14
+ export function createCanvasDocking<TNode extends CanvasNode = CanvasNode>(
15
+ options: CreateCanvasDockingOptions<TNode> = {}
16
+ ) {
17
+ let draggedNode = $state.raw<TNode | null>(null);
18
+ let draggedInitialPosition = $state.raw<XYPosition | null>(null);
19
+ let connectingHandle = $state.raw<string>('');
20
+ let connectingNode = $state.raw<TNode | null>(null);
21
+ let currentHoverTargets = $state.raw<Set<string>>(new Set());
22
+
23
+ const isDockTarget = options.isDockTarget ?? ((target: TNode) => (target as any).type === 'boardNode');
24
+
25
+ function handleNodeDragStart(params: {
26
+ targetNode: TNode | null;
27
+ event?: MouseEvent | TouchEvent;
28
+ }): void {
29
+ draggedNode = params.targetNode;
30
+ draggedInitialPosition = params.targetNode?.position ? { ...params.targetNode.position } : null;
31
+ currentHoverTargets = new Set();
32
+ }
33
+
34
+ function handleNodeDrag(
35
+ params: {
36
+ targetNode: TNode | null;
37
+ event?: MouseEvent | TouchEvent;
38
+ },
39
+ getIntersectingNodes: (node: TNode) => TNode[]
40
+ ): void {
41
+ draggedNode = params.targetNode;
42
+ if (!draggedNode) return;
43
+
44
+ const intersecting = getIntersectingNodes(draggedNode);
45
+ const currentDockTargets = intersecting.filter(isDockTarget);
46
+ const newHoverSet = new Set(currentDockTargets.map((t) => t.id));
47
+
48
+ // Check for left targets
49
+ currentHoverTargets.forEach((targetId) => {
50
+ if (!newHoverSet.has(targetId)) {
51
+ const leftNode = intersecting.find((n) => n.id === targetId) ?? ({ id: targetId } as TNode);
52
+ options.onDockLeave?.(leftNode, draggedNode!);
53
+ }
54
+ });
55
+
56
+ // Check for newly entered targets
57
+ currentDockTargets.forEach((target) => {
58
+ if (!currentHoverTargets.has(target.id)) {
59
+ options.onDockHover?.(target, draggedNode!);
60
+ }
61
+ });
62
+
63
+ currentHoverTargets = newHoverSet;
64
+ }
65
+
66
+ function handleNodeDragStop(
67
+ params: {
68
+ targetNode: TNode | null;
69
+ event?: MouseEvent | TouchEvent;
70
+ },
71
+ getIntersectingNodes: (node: TNode) => TNode[],
72
+ updateNodePosition?: (nodeId: string, position: XYPosition) => void
73
+ ): void {
74
+ draggedNode = params.targetNode;
75
+
76
+ if (draggedNode) {
77
+ const intersecting = getIntersectingNodes(draggedNode);
78
+ const dockTarget = intersecting.find(isDockTarget);
79
+
80
+ if (dockTarget) {
81
+ options.onDockDrop?.(dockTarget, draggedNode);
82
+
83
+ if (options.restorePositionOnDock !== false && draggedInitialPosition && updateNodePosition) {
84
+ updateNodePosition(draggedNode.id, draggedInitialPosition);
85
+ }
86
+ }
87
+ }
88
+
89
+ // Reset state
90
+ draggedNode = null;
91
+ draggedInitialPosition = null;
92
+ currentHoverTargets = new Set();
93
+
94
+ options.onSave?.();
95
+ }
96
+
97
+ function handleConnectStart(
98
+ params: {
99
+ nodeId: string | null;
100
+ handleId?: string | null;
101
+ handleType: 'source' | 'target' | null;
102
+ },
103
+ getNode: (id: string) => TNode | undefined
104
+ ): void {
105
+ connectingHandle = params.handleType ?? '';
106
+ connectingNode = params.nodeId ? (getNode(params.nodeId) ?? null) : null;
107
+ }
108
+
109
+ function handleConnectEnd(): void {
110
+ connectingHandle = '';
111
+ connectingNode = null;
112
+ }
113
+
114
+ function reset(): void {
115
+ draggedNode = null;
116
+ draggedInitialPosition = null;
117
+ connectingHandle = '';
118
+ connectingNode = null;
119
+ currentHoverTargets = new Set();
120
+ }
121
+
122
+ return {
123
+ get draggedNode() {
124
+ return draggedNode;
125
+ },
126
+ get draggedInitialPosition() {
127
+ return draggedInitialPosition;
128
+ },
129
+ get connectingHandle() {
130
+ return connectingHandle;
131
+ },
132
+ get connectingNode() {
133
+ return connectingNode;
134
+ },
135
+ get isDragging() {
136
+ return draggedNode !== null;
137
+ },
138
+ handleNodeDragStart,
139
+ handleNodeDrag,
140
+ handleNodeDragStop,
141
+ handleConnectStart,
142
+ handleConnectEnd,
143
+ reset
144
+ };
145
+ }
@@ -0,0 +1,330 @@
1
+ import { getConnectedEdges, type XYPosition } from '@xyflow/svelte';
2
+ import { cubicOut } from 'svelte/easing';
3
+ import type { CanvasNode, CanvasEdge, GraphDifferences } from './types.js';
4
+
5
+ /**
6
+ * Pure calculation of graph differences between a current state and a baseline state.
7
+ */
8
+ export function calculateGraphDifferences<
9
+ TNode extends CanvasNode = CanvasNode,
10
+ TEdge extends CanvasEdge = CanvasEdge
11
+ >(
12
+ currentState: { nodes?: TNode[]; edges?: TEdge[] },
13
+ baselineState: { nodes?: TNode[]; edges?: TEdge[] }
14
+ ): GraphDifferences<TNode, TEdge> {
15
+ const currentNodes = currentState.nodes ?? [];
16
+ const currentEdges = currentState.edges ?? [];
17
+ const baselineNodes = baselineState.nodes ?? [];
18
+ const baselineEdges = baselineState.edges ?? [];
19
+
20
+ // Map baseline for fast O(1) lookup
21
+ const baselineNodeMap = new Map(baselineNodes.map((n) => [n.id, n]));
22
+ const baselineEdgeMap = new Map(baselineEdges.map((e) => [e.id, e]));
23
+ const currentNodeMap = new Map(currentNodes.map((n) => [n.id, n]));
24
+ const currentEdgeMap = new Map(currentEdges.map((e) => [e.id, e]));
25
+
26
+ const removedNodes = baselineNodes.filter((oldNode) => !currentNodeMap.has(oldNode.id));
27
+ const removedEdges = baselineEdges.filter((oldEdge) => !currentEdgeMap.has(oldEdge.id));
28
+
29
+ const changedNodes = currentNodes.filter((newNode) => {
30
+ const oldNode = baselineNodeMap.get(newNode.id);
31
+ if (!oldNode) return true;
32
+ return JSON.stringify(newNode) !== JSON.stringify(oldNode);
33
+ });
34
+
35
+ const changedEdges = currentEdges.filter((newEdge) => {
36
+ const oldEdge = baselineEdgeMap.get(newEdge.id);
37
+ if (!oldEdge) return true;
38
+ return JSON.stringify(newEdge) !== JSON.stringify(oldEdge);
39
+ });
40
+
41
+ return { changedNodes, changedEdges, removedNodes, removedEdges };
42
+ }
43
+
44
+ /**
45
+ * Pure function to replace a node in a graph, optionally preserving its connected edges.
46
+ */
47
+ export function replaceNodeInGraph<
48
+ TNode extends CanvasNode = CanvasNode,
49
+ TEdge extends CanvasEdge = CanvasEdge
50
+ >(
51
+ nodes: TNode[],
52
+ edges: TEdge[],
53
+ oldNode: TNode,
54
+ newNode: TNode,
55
+ keepEdges = false
56
+ ): { nodes: TNode[]; edges: TEdge[] } {
57
+ const updatedNodes = nodes.map((n) => (n.id === oldNode.id ? newNode : n));
58
+
59
+ if (!keepEdges) {
60
+ return { nodes: updatedNodes, edges };
61
+ }
62
+
63
+ const updatedEdges = edges.map((edge) => {
64
+ if (edge.source === oldNode.id || edge.target === oldNode.id) {
65
+ return {
66
+ ...edge,
67
+ id: crypto.randomUUID(),
68
+ source: edge.source === oldNode.id ? newNode.id : edge.source,
69
+ target: edge.target === oldNode.id ? newNode.id : edge.target
70
+ };
71
+ }
72
+ return edge;
73
+ });
74
+
75
+ return {
76
+ nodes: updatedNodes,
77
+ edges: updatedEdges
78
+ };
79
+ }
80
+
81
+ export interface CreateCanvasGraphOptions<
82
+ TNode extends CanvasNode = CanvasNode,
83
+ TEdge extends CanvasEdge = CanvasEdge
84
+ > {
85
+ initialNodes?: TNode[];
86
+ initialEdges?: TEdge[];
87
+ onNodesChange?: (nodes: TNode[]) => void;
88
+ onEdgesChange?: (edges: TEdge[]) => void;
89
+ onSave?: () => void;
90
+ createEdge?: (
91
+ sourceId: string,
92
+ targetId: string,
93
+ options?: { label?: string; animated?: boolean; data?: any }
94
+ ) => TEdge;
95
+ }
96
+
97
+ /**
98
+ * Headless Svelte 5 rune managing canvas graph state and operations.
99
+ */
100
+ export function createCanvasGraph<
101
+ TNode extends CanvasNode = CanvasNode,
102
+ TEdge extends CanvasEdge = CanvasEdge
103
+ >(options: CreateCanvasGraphOptions<TNode, TEdge> = {}) {
104
+ let nodes = $state.raw<TNode[]>(options.initialNodes ?? []);
105
+ let edges = $state.raw<TEdge[]>(options.initialEdges ?? []);
106
+
107
+ function setNodes(newNodes: TNode[], triggerCallback = true) {
108
+ nodes = newNodes;
109
+ if (triggerCallback) {
110
+ options.onNodesChange?.(nodes);
111
+ }
112
+ }
113
+
114
+ function setEdges(newEdges: TEdge[], triggerCallback = true) {
115
+ edges = newEdges;
116
+ if (triggerCallback) {
117
+ options.onEdgesChange?.(edges);
118
+ }
119
+ }
120
+
121
+ function animateNodePosition(
122
+ node: TNode,
123
+ targetPosition: XYPosition,
124
+ duration = 400,
125
+ easing: (t: number) => number = cubicOut
126
+ ) {
127
+ const delay = (node.data as any)?.animationDelay ?? 0;
128
+ const startPosition = { ...node.position };
129
+ const startTime = Date.now() + delay;
130
+
131
+ function step() {
132
+ const currentTime = Date.now();
133
+ if (currentTime < startTime) {
134
+ if (typeof requestAnimationFrame !== 'undefined') {
135
+ requestAnimationFrame(step);
136
+ }
137
+ return;
138
+ }
139
+
140
+ const progress = Math.min((currentTime - startTime) / duration, 1);
141
+ const eased = easing(progress);
142
+
143
+ const currentPos = {
144
+ x: startPosition.x + (targetPosition.x - startPosition.x) * eased,
145
+ y: startPosition.y + (targetPosition.y - startPosition.y) * eased
146
+ };
147
+
148
+ // Mutate position in place without recreating the entire array every frame
149
+ const targetNode = nodes.find((n) => n.id === node.id);
150
+ if (targetNode) {
151
+ targetNode.position = currentPos;
152
+ }
153
+
154
+ if (progress < 1) {
155
+ if (typeof requestAnimationFrame !== 'undefined') {
156
+ requestAnimationFrame(step);
157
+ }
158
+ } else {
159
+ // Animation completed: trigger final reactive update and change event
160
+ setNodes([...nodes]);
161
+ }
162
+ }
163
+
164
+ step();
165
+ }
166
+
167
+ function addNodeOrNodes(
168
+ nodeOrNodesToAdd: TNode | TNode[],
169
+ animateFromPosition?: XYPosition,
170
+ save = true
171
+ ): void {
172
+ const toAdd = Array.isArray(nodeOrNodesToAdd) ? nodeOrNodesToAdd : [nodeOrNodesToAdd];
173
+ const existingMap = new Map(nodes.map((n) => [n.id, n]));
174
+
175
+ toAdd.forEach((node) => {
176
+ if (!existingMap.has(node.id)) {
177
+ existingMap.set(node.id, node);
178
+ }
179
+ });
180
+
181
+ setNodes(Array.from(existingMap.values()));
182
+
183
+ if (animateFromPosition) {
184
+ toAdd.forEach((node) => {
185
+ const target = { ...node.position };
186
+ node.position = { ...animateFromPosition };
187
+ animateNodePosition(node, target);
188
+ });
189
+ }
190
+
191
+ if (save) options.onSave?.();
192
+ }
193
+
194
+ function addEdge(edgeToAdd: TEdge, save = true): void {
195
+ setEdges([...edges, edgeToAdd]);
196
+ if (save) options.onSave?.();
197
+ }
198
+
199
+ function addEdges(edgesToAdd: TEdge[], save = true): void {
200
+ setEdges([...edges, ...edgesToAdd]);
201
+ if (save) options.onSave?.();
202
+ }
203
+
204
+ function removeNodes(nodesToRemove: TNode[], save = true): void {
205
+ const idsToRemove = new Set(nodesToRemove.map((n) => n.id));
206
+ setNodes(nodes.filter((n) => !idsToRemove.has(n.id)));
207
+ if (save) options.onSave?.();
208
+ }
209
+
210
+ function removeEdges(edgesToRemove: TEdge[], save = true): void {
211
+ const idsToRemove = new Set(edgesToRemove.map((e) => e.id));
212
+ setEdges(edges.filter((e) => !idsToRemove.has(e.id)));
213
+ if (save) options.onSave?.();
214
+ }
215
+
216
+ function replaceNode(
217
+ oldNode: TNode,
218
+ newNode: TNode,
219
+ save = true,
220
+ keepEdges = false
221
+ ): void {
222
+ const result = replaceNodeInGraph(nodes, edges, oldNode, newNode, keepEdges);
223
+ setNodes(result.nodes);
224
+ if (result.edges !== edges) {
225
+ setEdges(result.edges);
226
+ }
227
+ if (save) options.onSave?.();
228
+ }
229
+
230
+ function addConnectedNodes(params: {
231
+ nodesToAdd: TNode[];
232
+ inboundNodes?: TNode[];
233
+ outboundNodes?: TNode[];
234
+ animatedEdges?: boolean;
235
+ animateFromPosition?: XYPosition;
236
+ inboundEdgeData?: any;
237
+ outboundEdgeData?: any;
238
+ getEdgeLabel?: (source: TNode, target: TNode) => string;
239
+ }): void {
240
+ const {
241
+ nodesToAdd,
242
+ inboundNodes = [],
243
+ outboundNodes = [],
244
+ animatedEdges = false,
245
+ animateFromPosition,
246
+ inboundEdgeData = {},
247
+ outboundEdgeData = {},
248
+ getEdgeLabel
249
+ } = params;
250
+
251
+ const defaultCreateEdge = (
252
+ sourceId: string,
253
+ targetId: string,
254
+ opt?: { label?: string; animated?: boolean; data?: any }
255
+ ): TEdge => {
256
+ if (options.createEdge) {
257
+ return options.createEdge(sourceId, targetId, opt);
258
+ }
259
+ return {
260
+ id: crypto.randomUUID(),
261
+ type: 'default',
262
+ source: sourceId,
263
+ target: targetId,
264
+ label: opt?.label ?? '',
265
+ animated: opt?.animated ?? false,
266
+ data: opt?.data ?? {}
267
+ } as unknown as TEdge;
268
+ };
269
+
270
+ nodesToAdd.forEach((addedNode) => {
271
+ addNodeOrNodes(addedNode, animateFromPosition, false);
272
+
273
+ inboundNodes.forEach((sourceNode) => {
274
+ const label = getEdgeLabel ? getEdgeLabel(sourceNode, addedNode) : '';
275
+ addEdge(
276
+ defaultCreateEdge(sourceNode.id, addedNode.id, {
277
+ label,
278
+ animated: animatedEdges,
279
+ data: inboundEdgeData
280
+ }),
281
+ false
282
+ );
283
+ });
284
+
285
+ outboundNodes.forEach((targetNode) => {
286
+ const label = getEdgeLabel ? getEdgeLabel(addedNode, targetNode) : '';
287
+ addEdge(
288
+ defaultCreateEdge(addedNode.id, targetNode.id, {
289
+ label,
290
+ animated: animatedEdges,
291
+ data: outboundEdgeData
292
+ }),
293
+ false
294
+ );
295
+ });
296
+ });
297
+
298
+ options.onSave?.();
299
+ }
300
+
301
+ function getDifferences(baseline: { nodes?: TNode[]; edges?: TEdge[] }) {
302
+ return calculateGraphDifferences({ nodes, edges }, baseline);
303
+ }
304
+
305
+ return {
306
+ get nodes() {
307
+ return nodes;
308
+ },
309
+ set nodes(val: TNode[]) {
310
+ setNodes(val);
311
+ },
312
+ get edges() {
313
+ return edges;
314
+ },
315
+ set edges(val: TEdge[]) {
316
+ setEdges(val);
317
+ },
318
+ setNodes,
319
+ setEdges,
320
+ addNodeOrNodes,
321
+ addEdge,
322
+ addEdges,
323
+ removeNodes,
324
+ removeEdges,
325
+ replaceNode,
326
+ addConnectedNodes,
327
+ animateNodePosition,
328
+ getDifferences
329
+ };
330
+ }
@@ -0,0 +1,112 @@
1
+ import type { ColorMode } from '@xyflow/svelte';
2
+ import { isEventFromTextInput } from './createCanvasClipboard.svelte.js';
3
+ import type { CanvasInteractionPresetConfig } from './types.js';
4
+
5
+ export interface CreateCanvasInteractionsOptions {
6
+ targetElement?: HTMLElement | Window;
7
+ onPanModeChange?: (active: boolean) => void;
8
+ onTrackpadDetected?: () => void;
9
+ }
10
+
11
+ /**
12
+ * Headless Svelte 5 rune managing canvas pan interactions (spacebar panning, trackpad pinch detection).
13
+ */
14
+ export function createCanvasInteractions(options: CreateCanvasInteractionsOptions = {}) {
15
+ let isSpacebarPanning = $state.raw(false);
16
+ let trackpadDetected = $state.raw(false);
17
+ let toggledPanMode = false;
18
+
19
+ function handleKeyDown(event: KeyboardEvent) {
20
+ if (event.key === ' ' || event.code === 'Space') {
21
+ if (isSpacebarPanning || isEventFromTextInput(event)) return;
22
+ isSpacebarPanning = true;
23
+ toggledPanMode = true;
24
+ options.onPanModeChange?.(true);
25
+
26
+ if (typeof document !== 'undefined') {
27
+ document.body.classList.add('cursor-grab');
28
+ }
29
+ }
30
+ }
31
+
32
+ function handleKeyUp(event: KeyboardEvent) {
33
+ if (event.key === ' ' || event.code === 'Space') {
34
+ if (!isSpacebarPanning && !toggledPanMode) return;
35
+ isSpacebarPanning = false;
36
+ toggledPanMode = false;
37
+ options.onPanModeChange?.(false);
38
+
39
+ if (typeof document !== 'undefined') {
40
+ document.body.classList.remove('cursor-grab');
41
+ }
42
+ }
43
+ }
44
+
45
+ function handleWheel(event: WheelEvent) {
46
+ if (event.ctrlKey && !trackpadDetected) {
47
+ trackpadDetected = true;
48
+ options.onTrackpadDetected?.();
49
+ }
50
+ }
51
+
52
+ function attachListeners(target?: HTMLElement | Window): () => void {
53
+ const el = target ?? options.targetElement ?? (typeof window !== 'undefined' ? window : null);
54
+ if (!el) return () => {};
55
+
56
+ el.addEventListener('keydown', handleKeyDown as EventListener);
57
+ el.addEventListener('keyup', handleKeyUp as EventListener);
58
+ el.addEventListener('wheel', handleWheel as EventListener, { passive: true });
59
+
60
+ return () => {
61
+ el.removeEventListener('keydown', handleKeyDown as EventListener);
62
+ el.removeEventListener('keyup', handleKeyUp as EventListener);
63
+ el.removeEventListener('wheel', handleWheel as EventListener);
64
+ if (typeof document !== 'undefined') {
65
+ document.body.classList.remove('cursor-grab');
66
+ }
67
+ };
68
+ }
69
+
70
+ return {
71
+ get isSpacebarPanning() {
72
+ return isSpacebarPanning;
73
+ },
74
+ get trackpadDetected() {
75
+ return trackpadDetected;
76
+ },
77
+ attachListeners,
78
+ destroy: attachListeners()
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Default interaction configuration presets for SvelteFlow.
84
+ */
85
+ export const defaultSvelteFlowPreset: CanvasInteractionPresetConfig = {
86
+ snapGrid: [25, 25],
87
+ panOnDrag: [0, 1, 2, 3, 4],
88
+ panOnScroll: false,
89
+ selectionOnDrag: false,
90
+ colorMode: 'system' as ColorMode,
91
+ multiSelectionKey: ['Shift', 'Meta']
92
+ };
93
+
94
+ /**
95
+ * Miro-compatible interaction preset (middle/right drag to pan, left drag to select).
96
+ */
97
+ export const miroCompatiblePreset: CanvasInteractionPresetConfig = {
98
+ ...defaultSvelteFlowPreset,
99
+ panOnDrag: [1, 2],
100
+ selectionOnDrag: true
101
+ };
102
+
103
+ /**
104
+ * Helper to ensure panels restore pointer events after selection rectangle releases.
105
+ */
106
+ export function restorePanelPointerEvents(panelSelector = '.svelte-flow__panel'): void {
107
+ if (typeof document === 'undefined') return;
108
+ const panels = document.querySelectorAll(panelSelector);
109
+ panels.forEach((panel) => {
110
+ (panel as HTMLElement).style.pointerEvents = 'auto';
111
+ });
112
+ }
@@ -0,0 +1,120 @@
1
+ import dagre from '@dagrejs/dagre';
2
+ import { Position, type XYPosition, type Rect } from '@xyflow/svelte';
3
+ import type { CanvasNode, CanvasEdge, CanvasLayoutOptions } from './types.js';
4
+
5
+ /**
6
+ * Calculates upper-left flow coordinate for an element to be centered in the viewport.
7
+ */
8
+ export function getCenteredNodePosition(
9
+ width = 0,
10
+ height = 0,
11
+ screenToFlowPosition: (position: XYPosition) => XYPosition,
12
+ screenDimensions?: { width: number; height: number }
13
+ ): XYPosition {
14
+ const rect = screenDimensions ?? (typeof document !== 'undefined'
15
+ ? { width: document.documentElement.clientWidth, height: document.documentElement.clientHeight }
16
+ : { width: 1920, height: 1080 });
17
+
18
+ const flowPos = screenToFlowPosition({ x: rect.width / 2, y: rect.height / 2 });
19
+ return {
20
+ x: flowPos.x - width / 2,
21
+ y: flowPos.y - height / 2
22
+ };
23
+ }
24
+
25
+ /**
26
+ * Updates positions of nodes so their collective bounding box is centered in the current viewport.
27
+ */
28
+ export function centerNodes<TNode extends CanvasNode = CanvasNode>(
29
+ nodes: TNode[],
30
+ screenToFlowPosition: (position: XYPosition) => XYPosition,
31
+ getNodesBounds: (nodes: TNode[]) => Rect,
32
+ screenDimensions?: { width: number; height: number }
33
+ ): TNode[] {
34
+ if (nodes.length === 0) {
35
+ return [];
36
+ }
37
+
38
+ const bounds = getNodesBounds(nodes);
39
+ const boundingBoxOrigin = { x: bounds.x, y: bounds.y };
40
+
41
+ const centeredPosition = getCenteredNodePosition(
42
+ bounds.width,
43
+ bounds.height,
44
+ screenToFlowPosition,
45
+ screenDimensions
46
+ );
47
+
48
+ const deltaX = centeredPosition.x - boundingBoxOrigin.x;
49
+ const deltaY = centeredPosition.y - boundingBoxOrigin.y;
50
+
51
+ return nodes.map((node) => ({
52
+ ...node,
53
+ position: {
54
+ x: node.position.x + deltaX,
55
+ y: node.position.y + deltaY
56
+ }
57
+ }));
58
+ }
59
+
60
+ /**
61
+ * Automatically calculates directed hierarchical layout of nodes and edges using Dagre.
62
+ */
63
+ export function getLayoutedNodes<
64
+ TNode extends CanvasNode = CanvasNode,
65
+ TEdge extends CanvasEdge = CanvasEdge
66
+ >(
67
+ nodes: TNode[],
68
+ edges: TEdge[],
69
+ getNodesBounds: (nodes: TNode[]) => Rect,
70
+ options: CanvasLayoutOptions = {}
71
+ ): TNode[] {
72
+ if (nodes.length === 0) return [];
73
+
74
+ const {
75
+ direction = 'LR',
76
+ ranksep = 100,
77
+ nodesep = 25,
78
+ defaultNodeWidth = 200,
79
+ defaultNodeHeight = 200
80
+ } = options;
81
+
82
+ const isHorizontal = direction === 'LR' || direction === 'RL';
83
+ const bounds = getNodesBounds(nodes);
84
+
85
+ const dagreGraph = new dagre.graphlib.Graph();
86
+ dagreGraph.setDefaultEdgeLabel(() => ({}));
87
+ dagreGraph.setGraph({ rankdir: direction, ranksep, nodesep });
88
+
89
+ const nodeIds = new Set(nodes.map((node) => node.id));
90
+
91
+ nodes.forEach((node) => {
92
+ const nodeWidth = node.measured?.width ?? (node as any).width ?? defaultNodeWidth;
93
+ const nodeHeight = node.measured?.height ?? (node as any).height ?? defaultNodeHeight;
94
+ dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
95
+ });
96
+
97
+ edges.forEach((edge) => {
98
+ if (nodeIds.has(edge.source) && nodeIds.has(edge.target)) {
99
+ dagreGraph.setEdge(edge.source, edge.target);
100
+ }
101
+ });
102
+
103
+ dagre.layout(dagreGraph);
104
+
105
+ return nodes.map((node) => {
106
+ const nodeWithPosition = dagreGraph.node(node.id);
107
+ const nodeWidth = node.measured?.width ?? (node as any).width ?? defaultNodeWidth;
108
+ const nodeHeight = node.measured?.height ?? (node as any).height ?? defaultNodeHeight;
109
+
110
+ return {
111
+ ...node,
112
+ targetPosition: isHorizontal ? Position.Left : Position.Top,
113
+ sourcePosition: isHorizontal ? Position.Right : Position.Bottom,
114
+ position: {
115
+ x: bounds.x + (nodeWithPosition?.x ?? 0) - nodeWidth / 2,
116
+ y: bounds.y + (nodeWithPosition?.y ?? 0) - nodeHeight / 2
117
+ }
118
+ };
119
+ });
120
+ }