@pixerate/editor-svelte 0.4.0 → 0.5.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,226 @@
1
+ import dagre from '@dagrejs/dagre';
2
+ import type { Rect, XYPosition } from '@xyflow/svelte';
3
+ import type {
4
+ CanvasEdge,
5
+ CanvasLayoutOptions,
6
+ CanvasNode,
7
+ DirectionalDisplacementOptions
8
+ } from './types.js';
9
+
10
+ /**
11
+ * Calculates a bounding rectangle for a set of nodes if not provided by getNodesBounds.
12
+ */
13
+ export function computeNodesBoundingBox<TNode extends CanvasNode = CanvasNode>(
14
+ nodes: TNode[],
15
+ defaultWidth = 200,
16
+ defaultHeight = 60
17
+ ): Rect {
18
+ if (nodes.length === 0) {
19
+ return { x: 0, y: 0, width: 0, height: 0 };
20
+ }
21
+
22
+ let minX = Infinity;
23
+ let minY = Infinity;
24
+ let maxX = -Infinity;
25
+ let maxY = -Infinity;
26
+
27
+ for (const node of nodes) {
28
+ const width = node.measured?.width ?? (node as any).width ?? defaultWidth;
29
+ const height = node.measured?.height ?? (node as any).height ?? defaultHeight;
30
+
31
+ minX = Math.min(minX, node.position.x);
32
+ minY = Math.min(minY, node.position.y);
33
+ maxX = Math.max(maxX, node.position.x + width);
34
+ maxY = Math.max(maxY, node.position.y + height);
35
+ }
36
+
37
+ return {
38
+ x: minX,
39
+ y: minY,
40
+ width: maxX - minX,
41
+ height: maxY - minY
42
+ };
43
+ }
44
+
45
+ /**
46
+ * Pure directional displacement calculation.
47
+ * Shifts downstream or surrounding nodes along an axis to clear space for newly inserted child nodes.
48
+ */
49
+ export function calculateDirectionalDisplacement<TNode extends CanvasNode = CanvasNode>(params: {
50
+ existingNodes: TNode[];
51
+ originNode: TNode;
52
+ childNodes: TNode[];
53
+ getNodesBounds?: (nodes: TNode[]) => Rect;
54
+ options?: DirectionalDisplacementOptions;
55
+ }): Map<string, XYPosition> {
56
+ const {
57
+ existingNodes,
58
+ originNode,
59
+ childNodes,
60
+ getNodesBounds,
61
+ options = {}
62
+ } = params;
63
+
64
+ const {
65
+ direction = 'right',
66
+ gap = 40,
67
+ spreadSiblingLanes = false,
68
+ siblingTolerance = 50
69
+ } = options;
70
+
71
+ const displacedMap = new Map<string, XYPosition>();
72
+ if (childNodes.length === 0 || existingNodes.length === 0) {
73
+ return displacedMap;
74
+ }
75
+
76
+ const childBounds = getNodesBounds
77
+ ? getNodesBounds(childNodes)
78
+ : computeNodesBoundingBox(childNodes);
79
+
80
+ const originWidth = originNode.measured?.width ?? (originNode as any).width ?? 200;
81
+ const originHeight = originNode.measured?.height ?? (originNode as any).height ?? 60;
82
+
83
+ // The delta space required along primary axis
84
+ const deltaPrimary = (direction === 'right' || direction === 'left')
85
+ ? childBounds.width + gap
86
+ : childBounds.height + gap;
87
+
88
+ for (const node of existingNodes) {
89
+ if (node.id === originNode.id) continue;
90
+
91
+ let newX = node.position.x;
92
+ let newY = node.position.y;
93
+ let moved = false;
94
+
95
+ if (direction === 'right') {
96
+ // Downstream nodes strictly to the right of the origin
97
+ if (node.position.x >= originNode.position.x + originWidth * 0.5) {
98
+ newX += deltaPrimary;
99
+ moved = true;
100
+ } else if (spreadSiblingLanes) {
101
+ // Sibling nodes occupying the same vertical space as the exploded cluster
102
+ const isVerticallyOverlapping =
103
+ node.position.y + ((node as any).height ?? 60) >= childBounds.y - gap &&
104
+ node.position.y <= childBounds.y + childBounds.height + gap;
105
+
106
+ if (isVerticallyOverlapping && Math.abs(node.position.x - originNode.position.x) <= siblingTolerance) {
107
+ const pushDirection = node.position.y >= originNode.position.y ? 1 : -1;
108
+ newY += pushDirection * (childBounds.height * 0.5 + gap);
109
+ moved = true;
110
+ }
111
+ }
112
+ } else if (direction === 'left') {
113
+ if (node.position.x <= originNode.position.x) {
114
+ newX -= deltaPrimary;
115
+ moved = true;
116
+ }
117
+ } else if (direction === 'down') {
118
+ if (node.position.y >= originNode.position.y + originHeight * 0.5) {
119
+ newY += deltaPrimary;
120
+ moved = true;
121
+ }
122
+ } else if (direction === 'up') {
123
+ if (node.position.y <= originNode.position.y) {
124
+ newY -= deltaPrimary;
125
+ moved = true;
126
+ }
127
+ }
128
+
129
+ if (moved) {
130
+ displacedMap.set(node.id, { x: newX, y: newY });
131
+ }
132
+ }
133
+
134
+ return displacedMap;
135
+ }
136
+
137
+ /**
138
+ * Pure hierarchical Dagre reflow displacement calculation.
139
+ * Calculates new coordinates for surrounding nodes using Dagre hierarchical layout,
140
+ * anchoring the origin node at its current position to eliminate layout jumping.
141
+ */
142
+ export function calculateReflowDisplacement<
143
+ TNode extends CanvasNode = CanvasNode,
144
+ TEdge extends CanvasEdge = CanvasEdge
145
+ >(params: {
146
+ existingNodes: TNode[];
147
+ existingEdges: TEdge[];
148
+ originNode: TNode;
149
+ childNodes: TNode[];
150
+ childEdges?: TEdge[];
151
+ getNodesBounds?: (nodes: TNode[]) => Rect;
152
+ options?: CanvasLayoutOptions;
153
+ }): Map<string, XYPosition> {
154
+ const {
155
+ existingNodes,
156
+ existingEdges,
157
+ originNode,
158
+ childNodes,
159
+ childEdges = [],
160
+ options = {}
161
+ } = params;
162
+
163
+ const {
164
+ direction = 'LR',
165
+ ranksep = 80,
166
+ nodesep = 30,
167
+ defaultNodeWidth = 200,
168
+ defaultNodeHeight = 60
169
+ } = options;
170
+
171
+ const displacedMap = new Map<string, XYPosition>();
172
+ if (existingNodes.length === 0) return displacedMap;
173
+
174
+ const allNodes = [...existingNodes, ...childNodes];
175
+ const allEdges = [...existingEdges, ...childEdges];
176
+
177
+ const dagreGraph = new dagre.graphlib.Graph();
178
+ dagreGraph.setDefaultEdgeLabel(() => ({}));
179
+ dagreGraph.setGraph({ rankdir: direction, ranksep, nodesep });
180
+
181
+ const allNodeIds = new Set(allNodes.map((n) => n.id));
182
+
183
+ allNodes.forEach((node) => {
184
+ const nodeWidth = node.measured?.width ?? (node as any).width ?? defaultNodeWidth;
185
+ const nodeHeight = node.measured?.height ?? (node as any).height ?? defaultNodeHeight;
186
+ dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
187
+ });
188
+
189
+ allEdges.forEach((edge) => {
190
+ if (allNodeIds.has(edge.source) && allNodeIds.has(edge.target)) {
191
+ dagreGraph.setEdge(edge.source, edge.target);
192
+ }
193
+ });
194
+
195
+ dagre.layout(dagreGraph);
196
+
197
+ const dagreOrigin = dagreGraph.node(originNode.id);
198
+ if (!dagreOrigin) return displacedMap;
199
+
200
+ const originWidth = originNode.measured?.width ?? (originNode as any).width ?? defaultNodeWidth;
201
+ const originHeight = originNode.measured?.height ?? (originNode as any).height ?? defaultNodeHeight;
202
+
203
+ // Calculate offset so origin node remains anchored at its exact initial coordinates
204
+ const anchorOffsetX = originNode.position.x - (dagreOrigin.x - originWidth / 2);
205
+ const anchorOffsetY = originNode.position.y - (dagreOrigin.y - originHeight / 2);
206
+
207
+ existingNodes.forEach((node) => {
208
+ if (node.id === originNode.id) return;
209
+
210
+ const dagreNode = dagreGraph.node(node.id);
211
+ if (!dagreNode) return;
212
+
213
+ const nodeWidth = node.measured?.width ?? (node as any).width ?? defaultNodeWidth;
214
+ const nodeHeight = node.measured?.height ?? (node as any).height ?? defaultNodeHeight;
215
+
216
+ const targetX = dagreNode.x - nodeWidth / 2 + anchorOffsetX;
217
+ const targetY = dagreNode.y - nodeHeight / 2 + anchorOffsetY;
218
+
219
+ // Only include if position actually shifted
220
+ if (Math.abs(targetX - node.position.x) > 0.5 || Math.abs(targetY - node.position.y) > 0.5) {
221
+ displacedMap.set(node.id, { x: targetX, y: targetY });
222
+ }
223
+ });
224
+
225
+ return displacedMap;
226
+ }
@@ -0,0 +1,368 @@
1
+ import { cubicOut } from 'svelte/easing';
2
+ import type { Rect, XYPosition } from '@xyflow/svelte';
3
+ import type {
4
+ CanvasEdge,
5
+ CanvasNode,
6
+ CollapseNodeOptions,
7
+ ExplodeNodeOptions,
8
+ ExplosionSnapshot,
9
+ NodeTransition
10
+ } from './types.js';
11
+ import {
12
+ calculateDirectionalDisplacement,
13
+ calculateReflowDisplacement
14
+ } from './createCanvasDisplacement.js';
15
+ import {
16
+ createFanOutTrajectory,
17
+ linearTrajectory,
18
+ runMultiNodeTransition
19
+ } from './createCanvasTrajectory.js';
20
+
21
+ export interface CanvasGraphTarget<
22
+ TNode extends CanvasNode = CanvasNode,
23
+ TEdge extends CanvasEdge = CanvasEdge
24
+ > {
25
+ nodes: TNode[];
26
+ edges: TEdge[];
27
+ setNodes: (nodes: TNode[], triggerCallback?: boolean) => void;
28
+ setEdges: (edges: TEdge[], triggerCallback?: boolean) => void;
29
+ addNodeOrNodes: (nodes: TNode | TNode[], animateFrom?: XYPosition, save?: boolean) => void;
30
+ addEdges: (edges: TEdge[], save?: boolean) => void;
31
+ removeNodes: (nodes: TNode[], save?: boolean) => void;
32
+ removeEdges: (edges: TEdge[], save?: boolean) => void;
33
+ createEdge?: (sourceId: string, targetId: string, options?: any) => TEdge;
34
+ onSave?: () => void;
35
+ }
36
+
37
+ export interface CreateCanvasExplosionOptions<TNode extends CanvasNode = CanvasNode> {
38
+ getNodesBounds?: (nodes: TNode[]) => Rect;
39
+ onSave?: () => void;
40
+ }
41
+
42
+ /**
43
+ * Headless Svelte 5 rune managing canvas node explosion, spatial layout displacement,
44
+ * coordinated multi-node animation paths, and reversible collapsing.
45
+ */
46
+ export function createCanvasExplosion<
47
+ TNode extends CanvasNode = CanvasNode,
48
+ TEdge extends CanvasEdge = CanvasEdge
49
+ >(
50
+ graph: CanvasGraphTarget<TNode, TEdge>,
51
+ options: CreateCanvasExplosionOptions<TNode> = {}
52
+ ) {
53
+ let explodedNodeIds = $state<string[]>([]);
54
+ const snapshots = new Map<string, ExplosionSnapshot<TNode, TEdge>>();
55
+ let activeAnimationCancel: (() => void) | null = null;
56
+
57
+ function isExploded(nodeId: string): boolean {
58
+ return explodedNodeIds.includes(nodeId);
59
+ }
60
+
61
+ function explodeNode(
62
+ parentId: string,
63
+ explodeOptions: ExplodeNodeOptions<TNode, TEdge>
64
+ ): boolean {
65
+ if (isExploded(parentId)) {
66
+ return false;
67
+ }
68
+
69
+ const parentNode = graph.nodes.find((n) => n.id === parentId);
70
+ if (!parentNode) {
71
+ return false;
72
+ }
73
+
74
+ const {
75
+ childNodes,
76
+ childEdges = [],
77
+ connectParentToChildren = 'all',
78
+ displacementStrategy = 'directional',
79
+ directionalOptions = {},
80
+ reflowOptions = {},
81
+ trajectory,
82
+ duration = 500,
83
+ staggerDelay = 20,
84
+ easing = cubicOut,
85
+ onComplete
86
+ } = explodeOptions;
87
+
88
+ if (childNodes.length === 0) {
89
+ return false;
90
+ }
91
+
92
+ // 1. Calculate displacement for existing nodes
93
+ let displacementMap: Map<string, XYPosition>;
94
+ if (displacementStrategy === 'dagre') {
95
+ displacementMap = calculateReflowDisplacement({
96
+ existingNodes: graph.nodes,
97
+ existingEdges: graph.edges,
98
+ originNode: parentNode,
99
+ childNodes,
100
+ childEdges,
101
+ getNodesBounds: options.getNodesBounds,
102
+ options: {
103
+ ...reflowOptions,
104
+ direction: (reflowOptions.direction as any) ?? 'LR'
105
+ }
106
+ });
107
+ } else if (displacementStrategy === 'none') {
108
+ displacementMap = new Map();
109
+ } else {
110
+ displacementMap = calculateDirectionalDisplacement({
111
+ existingNodes: graph.nodes,
112
+ originNode: parentNode,
113
+ childNodes,
114
+ getNodesBounds: options.getNodesBounds,
115
+ options: directionalOptions
116
+ });
117
+ }
118
+
119
+ // 2. Build snapshot of displaced nodes before mutating positions
120
+ const displacedNodesSnapshot = Array.from(displacementMap.entries()).map(([id]) => {
121
+ const node = graph.nodes.find((n) => n.id === id);
122
+ return {
123
+ id,
124
+ originalPosition: { ...(node?.position ?? { x: 0, y: 0 }) }
125
+ };
126
+ });
127
+
128
+ // 3. Prepare child edges & parent connection edges
129
+ const edgesToAdd: TEdge[] = [...childEdges];
130
+ const defaultCreateEdge = (sourceId: string, targetId: string): TEdge => {
131
+ if (graph.createEdge) {
132
+ return graph.createEdge(sourceId, targetId);
133
+ }
134
+ return {
135
+ id: crypto.randomUUID(),
136
+ type: 'default',
137
+ source: sourceId,
138
+ target: targetId
139
+ } as unknown as TEdge;
140
+ };
141
+
142
+ if (connectParentToChildren === 'all' || connectParentToChildren === true) {
143
+ childNodes.forEach((child) => {
144
+ const exists = edgesToAdd.some((e) => e.source === parentId && e.target === child.id);
145
+ if (!exists) {
146
+ edgesToAdd.push(defaultCreateEdge(parentId, child.id));
147
+ }
148
+ });
149
+ } else if (connectParentToChildren === 'first' && childNodes.length > 0) {
150
+ const firstChild = childNodes[0];
151
+ const exists = edgesToAdd.some((e) => e.source === parentId && e.target === firstChild.id);
152
+ if (!exists) {
153
+ edgesToAdd.push(defaultCreateEdge(parentId, firstChild.id));
154
+ }
155
+ }
156
+
157
+ const snapshot: ExplosionSnapshot<TNode, TEdge> = {
158
+ parentId,
159
+ childNodeIds: childNodes.map((n) => n.id),
160
+ childEdgeIds: edgesToAdd.map((e) => e.id),
161
+ displacedNodes: displacedNodesSnapshot,
162
+ timestamp: Date.now()
163
+ };
164
+ snapshots.set(parentId, snapshot);
165
+
166
+ // 4. Position child nodes initially at origin node coordinate
167
+ const originPos = { ...parentNode.position };
168
+ const targetPositions = childNodes.map((child) => ({ ...child.position }));
169
+
170
+ childNodes.forEach((child) => {
171
+ child.position = { ...originPos };
172
+ if (!child.data) child.data = {} as any;
173
+ (child.data as any).scale = 0;
174
+ (child.data as any).opacity = 0;
175
+ });
176
+
177
+ // Add nodes and edges to graph without saving yet
178
+ graph.addNodeOrNodes(childNodes, undefined, false);
179
+ if (edgesToAdd.length > 0) {
180
+ graph.addEdges(edgesToAdd, false);
181
+ }
182
+
183
+ // 5. Build transition list
184
+ const chosenTrajectory = trajectory ?? createFanOutTrajectory({ curvature: 0.35 });
185
+ const transitions: NodeTransition<TNode>[] = [];
186
+ const effectiveStagger = duration === 0 ? 0 : staggerDelay;
187
+
188
+ // Expanding child transitions
189
+ childNodes.forEach((child, i) => {
190
+ transitions.push({
191
+ node: child,
192
+ from: originPos,
193
+ to: targetPositions[i],
194
+ trajectory: chosenTrajectory,
195
+ delay: i * effectiveStagger,
196
+ duration,
197
+ easing,
198
+ index: i,
199
+ total: childNodes.length
200
+ });
201
+ });
202
+
203
+ // Displaced node transitions
204
+ displacementMap.forEach((targetPos, nodeId) => {
205
+ const node = graph.nodes.find((n) => n.id === nodeId);
206
+ if (node) {
207
+ transitions.push({
208
+ node,
209
+ from: { ...node.position },
210
+ to: targetPos,
211
+ trajectory: linearTrajectory,
212
+ duration,
213
+ easing
214
+ });
215
+ }
216
+ });
217
+
218
+ // 6. Run coordinated animation
219
+ if (activeAnimationCancel) {
220
+ activeAnimationCancel();
221
+ }
222
+
223
+ activeAnimationCancel = runMultiNodeTransition(transitions, {
224
+ defaultDuration: duration,
225
+ defaultEasing: easing,
226
+ onUpdate: () => {
227
+ graph.setNodes([...graph.nodes]);
228
+ },
229
+ onComplete: () => {
230
+ graph.setNodes([...graph.nodes]);
231
+ explodedNodeIds = [...explodedNodeIds, parentId];
232
+ activeAnimationCancel = null;
233
+ options.onSave?.();
234
+ graph.onSave?.();
235
+ onComplete?.();
236
+ }
237
+ });
238
+
239
+ return true;
240
+ }
241
+
242
+ function collapseNode(
243
+ parentId: string,
244
+ collapseOptions: CollapseNodeOptions = {}
245
+ ): boolean {
246
+ const snapshot = snapshots.get(parentId);
247
+ if (!snapshot) {
248
+ return false;
249
+ }
250
+
251
+ const parentNode = graph.nodes.find((n) => n.id === parentId);
252
+ if (!parentNode) {
253
+ return false;
254
+ }
255
+
256
+ const {
257
+ duration = 400,
258
+ staggerDelay = 15,
259
+ easing = cubicOut,
260
+ trajectory,
261
+ onComplete
262
+ } = collapseOptions;
263
+
264
+ const childIdSet = new Set(snapshot.childNodeIds);
265
+ const activeChildNodes = graph.nodes.filter((n) => childIdSet.has(n.id));
266
+ const originPos = { ...parentNode.position };
267
+
268
+ // Reverse trajectory pulls nodes back toward parent while collapsing scale
269
+ const reverseTrajectory = trajectory ?? createFanOutTrajectory({
270
+ curvature: 0.2,
271
+ startScale: 1,
272
+ endScale: 0,
273
+ startOpacity: 1,
274
+ endOpacity: 0
275
+ });
276
+
277
+ const transitions: NodeTransition<TNode>[] = [];
278
+ const effectiveStagger = duration === 0 ? 0 : staggerDelay;
279
+
280
+ // Collapsing child transitions back into origin
281
+ activeChildNodes.forEach((child, i) => {
282
+ transitions.push({
283
+ node: child,
284
+ from: { ...child.position },
285
+ to: originPos,
286
+ trajectory: reverseTrajectory,
287
+ delay: (activeChildNodes.length - 1 - i) * effectiveStagger,
288
+ duration,
289
+ easing,
290
+ index: i,
291
+ total: activeChildNodes.length
292
+ });
293
+ });
294
+
295
+ // Displaced node restore transitions
296
+ snapshot.displacedNodes.forEach(({ id, originalPosition }) => {
297
+ const node = graph.nodes.find((n) => n.id === id);
298
+ if (node) {
299
+ transitions.push({
300
+ node,
301
+ from: { ...node.position },
302
+ to: originalPosition,
303
+ trajectory: linearTrajectory,
304
+ duration,
305
+ easing
306
+ });
307
+ }
308
+ });
309
+
310
+ if (activeAnimationCancel) {
311
+ activeAnimationCancel();
312
+ }
313
+
314
+ activeAnimationCancel = runMultiNodeTransition(transitions, {
315
+ defaultDuration: duration,
316
+ defaultEasing: easing,
317
+ onUpdate: () => {
318
+ graph.setNodes([...graph.nodes]);
319
+ },
320
+ onComplete: () => {
321
+ // Animation completed: prune child nodes and edges
322
+ graph.removeNodes(activeChildNodes, false);
323
+
324
+ const edgeIdSet = new Set(snapshot.childEdgeIds);
325
+ const edgesToRemove = graph.edges.filter((e) => edgeIdSet.has(e.id));
326
+ if (edgesToRemove.length > 0) {
327
+ graph.removeEdges(edgesToRemove, false);
328
+ }
329
+
330
+ snapshots.delete(parentId);
331
+ explodedNodeIds = explodedNodeIds.filter((id) => id !== parentId);
332
+ activeAnimationCancel = null;
333
+
334
+ options.onSave?.();
335
+ graph.onSave?.();
336
+ onComplete?.();
337
+ }
338
+ });
339
+
340
+ return true;
341
+ }
342
+
343
+ function toggleExplode(
344
+ parentId: string,
345
+ explodeOptions: ExplodeNodeOptions<TNode, TEdge>,
346
+ collapseOptions?: CollapseNodeOptions
347
+ ): boolean {
348
+ if (isExploded(parentId)) {
349
+ return collapseNode(parentId, collapseOptions);
350
+ }
351
+ return explodeNode(parentId, explodeOptions);
352
+ }
353
+
354
+ function getSnapshot(parentId: string) {
355
+ return snapshots.get(parentId);
356
+ }
357
+
358
+ return {
359
+ get explodedNodeIds() {
360
+ return explodedNodeIds;
361
+ },
362
+ isExploded,
363
+ explodeNode,
364
+ collapseNode,
365
+ toggleExplode,
366
+ getSnapshot
367
+ };
368
+ }