@pixerate/editor-svelte 0.3.12 → 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,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,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
+ }