@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.
- package/dist/canvas/index.d.ts +572 -0
- package/dist/canvas/index.js +1197 -0
- package/dist/canvas/index.js.map +1 -0
- package/package.json +24 -1
- package/src/canvas/README.md +167 -0
- package/src/canvas/__tests__/canvas-clipboard.test.ts +154 -0
- package/src/canvas/__tests__/canvas-docking.test.ts +136 -0
- package/src/canvas/__tests__/canvas-explosion.test.ts +249 -0
- package/src/canvas/__tests__/canvas-graph.test.ts +159 -0
- package/src/canvas/__tests__/canvas-interactions.test.ts +86 -0
- package/src/canvas/__tests__/canvas-layout.test.ts +75 -0
- package/src/canvas/__tests__/canvas-shortcuts.test.ts +59 -0
- package/src/canvas/createCanvasClipboard.svelte.ts +219 -0
- package/src/canvas/createCanvasDisplacement.ts +226 -0
- package/src/canvas/createCanvasDocking.svelte.ts +145 -0
- package/src/canvas/createCanvasExplosion.svelte.ts +368 -0
- package/src/canvas/createCanvasGraph.svelte.ts +330 -0
- package/src/canvas/createCanvasInteractions.svelte.ts +112 -0
- package/src/canvas/createCanvasLayout.ts +120 -0
- package/src/canvas/createCanvasShortcuts.ts +99 -0
- package/src/canvas/createCanvasTrajectory.ts +188 -0
- package/src/canvas/index.d.ts +572 -0
- package/src/canvas/index.ts +68 -0
- package/src/canvas/types.ts +212 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { createCanvasDocking } from '../createCanvasDocking.svelte.js';
|
|
3
|
+
import type { CanvasNode } from '../types.js';
|
|
4
|
+
|
|
5
|
+
describe('createCanvasDocking', () => {
|
|
6
|
+
const createSampleNode = (id: string, type = 'default', x = 0, y = 0): CanvasNode => ({
|
|
7
|
+
id,
|
|
8
|
+
type,
|
|
9
|
+
position: { x, y },
|
|
10
|
+
data: {}
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('initializes with null drag and connect states', () => {
|
|
14
|
+
const docking = createCanvasDocking();
|
|
15
|
+
|
|
16
|
+
expect(docking.isDragging).toBe(false);
|
|
17
|
+
expect(docking.draggedNode).toBeNull();
|
|
18
|
+
expect(docking.draggedInitialPosition).toBeNull();
|
|
19
|
+
expect(docking.connectingHandle).toBe('');
|
|
20
|
+
expect(docking.connectingNode).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('tracks drag start, records initial position, and sets isDragging', () => {
|
|
24
|
+
const docking = createCanvasDocking();
|
|
25
|
+
const node = createSampleNode('n1', 'default', 100, 200);
|
|
26
|
+
|
|
27
|
+
docking.handleNodeDragStart({ targetNode: node });
|
|
28
|
+
|
|
29
|
+
expect(docking.isDragging).toBe(true);
|
|
30
|
+
expect(docking.draggedNode).toBe(node);
|
|
31
|
+
expect(docking.draggedInitialPosition).toEqual({ x: 100, y: 200 });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('fires onDockHover and onDockLeave as intersecting dock targets change', () => {
|
|
35
|
+
const onDockHover = vi.fn();
|
|
36
|
+
const onDockLeave = vi.fn();
|
|
37
|
+
const docking = createCanvasDocking({
|
|
38
|
+
isDockTarget: (n) => n.type === 'dockArea',
|
|
39
|
+
onDockHover,
|
|
40
|
+
onDockLeave
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const draggedNode = createSampleNode('dragged', 'item', 50, 50);
|
|
44
|
+
const dockNode1 = createSampleNode('dock-1', 'dockArea', 0, 0);
|
|
45
|
+
const dockNode2 = createSampleNode('dock-2', 'dockArea', 200, 200);
|
|
46
|
+
const normalNode = createSampleNode('normal', 'item', 50, 50);
|
|
47
|
+
|
|
48
|
+
docking.handleNodeDragStart({ targetNode: draggedNode });
|
|
49
|
+
|
|
50
|
+
// Drag intersecting with dockNode1 and a non-dock normalNode
|
|
51
|
+
docking.handleNodeDrag(
|
|
52
|
+
{ targetNode: draggedNode },
|
|
53
|
+
() => [dockNode1, normalNode]
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
expect(onDockHover).toHaveBeenCalledTimes(1);
|
|
57
|
+
expect(onDockHover).toHaveBeenCalledWith(dockNode1, draggedNode);
|
|
58
|
+
expect(onDockLeave).not.toHaveBeenCalled();
|
|
59
|
+
|
|
60
|
+
// Drag moves over dockNode2, leaving dockNode1
|
|
61
|
+
docking.handleNodeDrag(
|
|
62
|
+
{ targetNode: draggedNode },
|
|
63
|
+
() => [dockNode2]
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
expect(onDockLeave).toHaveBeenCalledTimes(1);
|
|
67
|
+
expect(onDockLeave).toHaveBeenCalledWith(expect.objectContaining({ id: 'dock-1' }), draggedNode);
|
|
68
|
+
expect(onDockHover).toHaveBeenCalledTimes(2);
|
|
69
|
+
expect(onDockHover).toHaveBeenLastCalledWith(dockNode2, draggedNode);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('fires onDockDrop and restores position on dock stop', () => {
|
|
73
|
+
const onDockDrop = vi.fn();
|
|
74
|
+
const onSave = vi.fn();
|
|
75
|
+
const updateNodePosition = vi.fn();
|
|
76
|
+
|
|
77
|
+
const docking = createCanvasDocking({
|
|
78
|
+
isDockTarget: (n) => n.type === 'dockArea',
|
|
79
|
+
restorePositionOnDock: true,
|
|
80
|
+
onDockDrop,
|
|
81
|
+
onSave
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const draggedNode = createSampleNode('dragged', 'item', 100, 100);
|
|
85
|
+
const dockNode = createSampleNode('dock', 'dockArea', 0, 0);
|
|
86
|
+
|
|
87
|
+
docking.handleNodeDragStart({ targetNode: draggedNode });
|
|
88
|
+
|
|
89
|
+
// Node was dragged to new position
|
|
90
|
+
draggedNode.position = { x: 300, y: 300 };
|
|
91
|
+
|
|
92
|
+
docking.handleNodeDragStop(
|
|
93
|
+
{ targetNode: draggedNode },
|
|
94
|
+
() => [dockNode],
|
|
95
|
+
updateNodePosition
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
expect(onDockDrop).toHaveBeenCalledWith(dockNode, draggedNode);
|
|
99
|
+
expect(updateNodePosition).toHaveBeenCalledWith('dragged', { x: 100, y: 100 });
|
|
100
|
+
expect(onSave).toHaveBeenCalled();
|
|
101
|
+
expect(docking.isDragging).toBe(false);
|
|
102
|
+
expect(docking.draggedNode).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('tracks handle connect start and end', () => {
|
|
106
|
+
const docking = createCanvasDocking();
|
|
107
|
+
const node = createSampleNode('source-node');
|
|
108
|
+
const getNode = (id: string) => (id === 'source-node' ? node : undefined);
|
|
109
|
+
|
|
110
|
+
docking.handleConnectStart(
|
|
111
|
+
{ nodeId: 'source-node', handleId: 'h1', handleType: 'source' },
|
|
112
|
+
getNode
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
expect(docking.connectingHandle).toBe('source');
|
|
116
|
+
expect(docking.connectingNode).toBe(node);
|
|
117
|
+
|
|
118
|
+
docking.handleConnectEnd();
|
|
119
|
+
|
|
120
|
+
expect(docking.connectingHandle).toBe('');
|
|
121
|
+
expect(docking.connectingNode).toBeNull();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('resets all state cleanly', () => {
|
|
125
|
+
const docking = createCanvasDocking();
|
|
126
|
+
const node = createSampleNode('test');
|
|
127
|
+
|
|
128
|
+
docking.handleNodeDragStart({ targetNode: node });
|
|
129
|
+
expect(docking.isDragging).toBe(true);
|
|
130
|
+
|
|
131
|
+
docking.reset();
|
|
132
|
+
expect(docking.isDragging).toBe(false);
|
|
133
|
+
expect(docking.draggedNode).toBeNull();
|
|
134
|
+
expect(docking.draggedInitialPosition).toBeNull();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
linearTrajectory,
|
|
4
|
+
createFanOutTrajectory,
|
|
5
|
+
createBezierTrajectory,
|
|
6
|
+
runMultiNodeTransition
|
|
7
|
+
} from '../createCanvasTrajectory.js';
|
|
8
|
+
import {
|
|
9
|
+
computeNodesBoundingBox,
|
|
10
|
+
calculateDirectionalDisplacement,
|
|
11
|
+
calculateReflowDisplacement
|
|
12
|
+
} from '../createCanvasDisplacement.js';
|
|
13
|
+
import { createCanvasExplosion } from '../createCanvasExplosion.svelte.js';
|
|
14
|
+
import { createCanvasGraph } from '../createCanvasGraph.svelte.js';
|
|
15
|
+
import type { CanvasNode, CanvasEdge } from '../types.js';
|
|
16
|
+
|
|
17
|
+
describe('Canvas Trajectory Math & Transition Runner', () => {
|
|
18
|
+
it('evaluates linear trajectory at progress intervals', () => {
|
|
19
|
+
const origin = { x: 100, y: 100 };
|
|
20
|
+
const target = { x: 300, y: 200 };
|
|
21
|
+
|
|
22
|
+
const start = linearTrajectory(origin, target, 0, 0, 1);
|
|
23
|
+
expect(start.position).toEqual({ x: 100, y: 100 });
|
|
24
|
+
expect(start.scale).toBe(0);
|
|
25
|
+
|
|
26
|
+
const mid = linearTrajectory(origin, target, 0.5, 0, 1);
|
|
27
|
+
expect(mid.position).toEqual({ x: 200, y: 150 });
|
|
28
|
+
expect(mid.scale).toBe(0.5);
|
|
29
|
+
|
|
30
|
+
const end = linearTrajectory(origin, target, 1, 0, 1);
|
|
31
|
+
expect(end.position).toEqual({ x: 300, y: 200 });
|
|
32
|
+
expect(end.scale).toBe(1);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('creates fan-out curved trajectory with scale and opacity', () => {
|
|
36
|
+
const origin = { x: 50, y: 100 };
|
|
37
|
+
const targetTop = { x: 250, y: 50 };
|
|
38
|
+
const targetBottom = { x: 250, y: 150 };
|
|
39
|
+
|
|
40
|
+
const trajectory = createFanOutTrajectory({
|
|
41
|
+
curvature: 0.5,
|
|
42
|
+
startScale: 0.2,
|
|
43
|
+
endScale: 1
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const topPoint = trajectory(origin, targetTop, 0.5, 0, 2);
|
|
47
|
+
const bottomPoint = trajectory(origin, targetBottom, 0.5, 1, 2);
|
|
48
|
+
|
|
49
|
+
expect(topPoint.scale).toBeCloseTo(0.6);
|
|
50
|
+
expect(bottomPoint.scale).toBeCloseTo(0.6);
|
|
51
|
+
// Trajectory arc deflects top and bottom differently based on normalized index
|
|
52
|
+
expect(topPoint.position.y).not.toBe(bottomPoint.position.y);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('creates custom bezier trajectory with control point calculator', () => {
|
|
56
|
+
const trajectory = createBezierTrajectory((orig, tgt) => ({
|
|
57
|
+
x: orig.x,
|
|
58
|
+
y: tgt.y
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
const pt = trajectory({ x: 0, y: 0 }, { x: 100, y: 100 }, 0.5, 0, 1);
|
|
62
|
+
// Quadratic Bezier at t=0.5 with P0=(0,0), P1=(0,100), P2=(100,100)
|
|
63
|
+
// x = 0.25*0 + 0.5*0 + 0.25*100 = 25
|
|
64
|
+
// y = 0.25*0 + 0.5*100 + 0.25*100 = 75
|
|
65
|
+
expect(pt.position.x).toBe(25);
|
|
66
|
+
expect(pt.position.y).toBe(75);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('runs multi-node transitions and notifies on completion', () => {
|
|
70
|
+
const node: CanvasNode = {
|
|
71
|
+
id: 'n1',
|
|
72
|
+
position: { x: 0, y: 0 },
|
|
73
|
+
data: {}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const onComplete = vi.fn();
|
|
77
|
+
const cancel = runMultiNodeTransition(
|
|
78
|
+
[
|
|
79
|
+
{
|
|
80
|
+
node,
|
|
81
|
+
from: { x: 0, y: 0 },
|
|
82
|
+
to: { x: 100, y: 100 },
|
|
83
|
+
duration: 0 // Immediate completion for testing
|
|
84
|
+
}
|
|
85
|
+
],
|
|
86
|
+
{ onComplete }
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
expect(node.position).toEqual({ x: 100, y: 100 });
|
|
90
|
+
expect(onComplete).toHaveBeenCalled();
|
|
91
|
+
cancel();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('Spatial Displacement Solvers', () => {
|
|
96
|
+
const createNode = (id: string, x: number, y: number, w = 150, h = 50): CanvasNode => ({
|
|
97
|
+
id,
|
|
98
|
+
position: { x, y },
|
|
99
|
+
measured: { width: w, height: h },
|
|
100
|
+
data: {}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('computes bounding box for a set of nodes', () => {
|
|
104
|
+
const nodes = [createNode('a', 10, 20, 100, 40), createNode('b', 50, 100, 200, 80)];
|
|
105
|
+
const bbox = computeNodesBoundingBox(nodes);
|
|
106
|
+
|
|
107
|
+
expect(bbox.x).toBe(10);
|
|
108
|
+
expect(bbox.y).toBe(20);
|
|
109
|
+
expect(bbox.width).toBe(240); // 50 + 200 - 10 = 240
|
|
110
|
+
expect(bbox.height).toBe(160); // 100 + 80 - 20 = 160
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('calculates directional displacement shifting downstream nodes to make space', () => {
|
|
114
|
+
const origin = createNode('origin', 100, 100, 100, 50);
|
|
115
|
+
const downstream = createNode('downstream', 300, 100, 100, 50);
|
|
116
|
+
const upstream = createNode('upstream', 0, 100, 100, 50);
|
|
117
|
+
|
|
118
|
+
// Child cluster with width=200
|
|
119
|
+
const children = [
|
|
120
|
+
createNode('c1', 150, 80, 200, 40),
|
|
121
|
+
createNode('c2', 150, 120, 200, 40)
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
const displaced = calculateDirectionalDisplacement({
|
|
125
|
+
existingNodes: [origin, downstream, upstream],
|
|
126
|
+
originNode: origin,
|
|
127
|
+
childNodes: children,
|
|
128
|
+
options: { direction: 'right', gap: 40 }
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Child cluster width is 200, gap is 40 -> deltaPrimary = 240
|
|
132
|
+
expect(displaced.has('downstream')).toBe(true);
|
|
133
|
+
expect(displaced.get('downstream')?.x).toBe(300 + 240);
|
|
134
|
+
// Upstream node remains untouched
|
|
135
|
+
expect(displaced.has('upstream')).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('calculates Dagre reflow displacement anchoring origin node position', () => {
|
|
139
|
+
const origin = createNode('origin', 100, 100, 120, 60);
|
|
140
|
+
const downstream = createNode('downstream', 300, 100, 120, 60);
|
|
141
|
+
const edges: CanvasEdge[] = [{ id: 'e1', source: 'origin', target: 'downstream' }];
|
|
142
|
+
|
|
143
|
+
const children = [createNode('c1', 0, 0, 120, 60), createNode('c2', 0, 0, 120, 60)];
|
|
144
|
+
const childEdges: CanvasEdge[] = [
|
|
145
|
+
{ id: 'ec1', source: 'origin', target: 'c1' },
|
|
146
|
+
{ id: 'ec2', source: 'c1', target: 'downstream' }
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
const displaced = calculateReflowDisplacement({
|
|
150
|
+
existingNodes: [origin, downstream],
|
|
151
|
+
existingEdges: edges,
|
|
152
|
+
originNode: origin,
|
|
153
|
+
childNodes: children,
|
|
154
|
+
childEdges,
|
|
155
|
+
options: { direction: 'LR', ranksep: 100 }
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Downstream node should be pushed further to make space for child rank
|
|
159
|
+
expect(displaced.has('downstream')).toBe(true);
|
|
160
|
+
expect(displaced.get('downstream')!.x).toBeGreaterThan(origin.position.x);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('createCanvasExplosion Rune Lifecycle & Reversibility', () => {
|
|
165
|
+
const createNode = (id: string, x: number, y: number, w = 150, h = 50): CanvasNode => ({
|
|
166
|
+
id,
|
|
167
|
+
position: { x, y },
|
|
168
|
+
measured: { width: w, height: h },
|
|
169
|
+
data: {}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('explodes node, connects children, shifts downstream nodes, and collapses reversibly', () => {
|
|
173
|
+
const origin = createNode('origin', 100, 100);
|
|
174
|
+
const downstream = createNode('downstream', 300, 100);
|
|
175
|
+
const initialEdge: CanvasEdge = { id: 'e1', source: 'origin', target: 'downstream' };
|
|
176
|
+
|
|
177
|
+
const graph = createCanvasGraph({
|
|
178
|
+
initialNodes: [origin, downstream],
|
|
179
|
+
initialEdges: [initialEdge]
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const explosion = createCanvasExplosion(graph);
|
|
183
|
+
|
|
184
|
+
expect(explosion.isExploded('origin')).toBe(false);
|
|
185
|
+
|
|
186
|
+
const child1 = createNode('c1', 250, 70);
|
|
187
|
+
const child2 = createNode('c2', 250, 130);
|
|
188
|
+
|
|
189
|
+
// 1. Explode
|
|
190
|
+
const exploded = explosion.explodeNode('origin', {
|
|
191
|
+
childNodes: [child1, child2],
|
|
192
|
+
duration: 0, // instantaneous for sync unit testing
|
|
193
|
+
connectParentToChildren: 'all',
|
|
194
|
+
directionalOptions: { direction: 'right', gap: 50 }
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
expect(exploded).toBe(true);
|
|
198
|
+
expect(explosion.isExploded('origin')).toBe(true);
|
|
199
|
+
|
|
200
|
+
// Check that children were added
|
|
201
|
+
expect(graph.nodes.some((n) => n.id === 'c1')).toBe(true);
|
|
202
|
+
expect(graph.nodes.some((n) => n.id === 'c2')).toBe(true);
|
|
203
|
+
|
|
204
|
+
// Check that parent was connected to children
|
|
205
|
+
expect(graph.edges.some((e) => e.source === 'origin' && e.target === 'c1')).toBe(true);
|
|
206
|
+
expect(graph.edges.some((e) => e.source === 'origin' && e.target === 'c2')).toBe(true);
|
|
207
|
+
|
|
208
|
+
// Check that downstream node was displaced rightward
|
|
209
|
+
const displacedDownstream = graph.nodes.find((n) => n.id === 'downstream')!;
|
|
210
|
+
expect(displacedDownstream.position.x).toBeGreaterThan(300);
|
|
211
|
+
|
|
212
|
+
// 2. Collapse
|
|
213
|
+
const collapsed = explosion.collapseNode('origin', {
|
|
214
|
+
duration: 0
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
expect(collapsed).toBe(true);
|
|
218
|
+
expect(explosion.isExploded('origin')).toBe(false);
|
|
219
|
+
|
|
220
|
+
// Check that children were removed from graph
|
|
221
|
+
expect(graph.nodes.some((n) => n.id === 'c1')).toBe(false);
|
|
222
|
+
expect(graph.nodes.some((n) => n.id === 'c2')).toBe(false);
|
|
223
|
+
|
|
224
|
+
// Check that child edges were pruned
|
|
225
|
+
expect(graph.edges.some((e) => e.source === 'origin' && e.target === 'c1')).toBe(false);
|
|
226
|
+
|
|
227
|
+
// Check that downstream node returned to its exact pre-explosion baseline coordinate
|
|
228
|
+
const restoredDownstream = graph.nodes.find((n) => n.id === 'downstream')!;
|
|
229
|
+
expect(restoredDownstream.position.x).toBe(300);
|
|
230
|
+
expect(restoredDownstream.position.y).toBe(100);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('toggles explosion with toggleExplode', () => {
|
|
234
|
+
const origin = createNode('origin', 100, 100);
|
|
235
|
+
const graph = createCanvasGraph({ initialNodes: [origin] });
|
|
236
|
+
const explosion = createCanvasExplosion(graph);
|
|
237
|
+
|
|
238
|
+
const child = createNode('c1', 200, 100);
|
|
239
|
+
|
|
240
|
+
explosion.toggleExplode('origin', {
|
|
241
|
+
childNodes: [child],
|
|
242
|
+
duration: 0
|
|
243
|
+
});
|
|
244
|
+
expect(explosion.isExploded('origin')).toBe(true);
|
|
245
|
+
|
|
246
|
+
explosion.toggleExplode('origin', { childNodes: [child], duration: 0 }, { duration: 0 });
|
|
247
|
+
expect(explosion.isExploded('origin')).toBe(false);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
createCanvasGraph,
|
|
4
|
+
calculateGraphDifferences,
|
|
5
|
+
replaceNodeInGraph
|
|
6
|
+
} from '../createCanvasGraph.svelte.js';
|
|
7
|
+
import type { CanvasNode, CanvasEdge } from '../types.js';
|
|
8
|
+
|
|
9
|
+
describe('createCanvasGraph & Graph Operations', () => {
|
|
10
|
+
const createSampleNode = (id: string, x = 0, y = 0): CanvasNode => ({
|
|
11
|
+
id,
|
|
12
|
+
position: { x, y },
|
|
13
|
+
data: { label: `Node ${id}` }
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const createSampleEdge = (id: string, source: string, target: string): CanvasEdge => ({
|
|
17
|
+
id,
|
|
18
|
+
source,
|
|
19
|
+
target
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('initializes with default empty or provided nodes and edges', () => {
|
|
23
|
+
const graph = createCanvasGraph({
|
|
24
|
+
initialNodes: [createSampleNode('1')],
|
|
25
|
+
initialEdges: [createSampleEdge('e1', '1', '2')]
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
expect(graph.nodes).toHaveLength(1);
|
|
29
|
+
expect(graph.edges).toHaveLength(1);
|
|
30
|
+
expect(graph.nodes[0].id).toBe('1');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('adds nodes and ignores duplicates', () => {
|
|
34
|
+
const onNodesChange = vi.fn();
|
|
35
|
+
const graph = createCanvasGraph({ onNodesChange });
|
|
36
|
+
|
|
37
|
+
graph.addNodeOrNodes(createSampleNode('1'));
|
|
38
|
+
expect(graph.nodes).toHaveLength(1);
|
|
39
|
+
expect(onNodesChange).toHaveBeenCalledTimes(1);
|
|
40
|
+
|
|
41
|
+
// Adding existing node does not duplicate
|
|
42
|
+
graph.addNodeOrNodes(createSampleNode('1'));
|
|
43
|
+
expect(graph.nodes).toHaveLength(1);
|
|
44
|
+
|
|
45
|
+
// Adding multiple nodes
|
|
46
|
+
graph.addNodeOrNodes([createSampleNode('2'), createSampleNode('3')]);
|
|
47
|
+
expect(graph.nodes).toHaveLength(3);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('adds and removes edges cleanly', () => {
|
|
51
|
+
const onEdgesChange = vi.fn();
|
|
52
|
+
const graph = createCanvasGraph({ onEdgesChange });
|
|
53
|
+
|
|
54
|
+
const e1 = createSampleEdge('e1', '1', '2');
|
|
55
|
+
const e2 = createSampleEdge('e2', '2', '3');
|
|
56
|
+
|
|
57
|
+
graph.addEdge(e1);
|
|
58
|
+
expect(graph.edges).toHaveLength(1);
|
|
59
|
+
|
|
60
|
+
graph.addEdges([e2]);
|
|
61
|
+
expect(graph.edges).toHaveLength(2);
|
|
62
|
+
|
|
63
|
+
graph.removeEdges([e1]);
|
|
64
|
+
expect(graph.edges).toHaveLength(1);
|
|
65
|
+
expect(graph.edges[0].id).toBe('e2');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('removes nodes by reference or ID matching', () => {
|
|
69
|
+
const n1 = createSampleNode('1');
|
|
70
|
+
const n2 = createSampleNode('2');
|
|
71
|
+
const graph = createCanvasGraph({ initialNodes: [n1, n2] });
|
|
72
|
+
|
|
73
|
+
graph.removeNodes([n1]);
|
|
74
|
+
expect(graph.nodes).toHaveLength(1);
|
|
75
|
+
expect(graph.nodes[0].id).toBe('2');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('replaces a node and preserves connected edges when keepEdges is true', () => {
|
|
79
|
+
const n1 = createSampleNode('1');
|
|
80
|
+
const n2 = createSampleNode('2');
|
|
81
|
+
const e1 = createSampleEdge('e1', '1', '2');
|
|
82
|
+
const n1Replacement = createSampleNode('new-1', 10, 10);
|
|
83
|
+
|
|
84
|
+
const result = replaceNodeInGraph([n1, n2], [e1], n1, n1Replacement, true);
|
|
85
|
+
expect(result.nodes.some((n) => n.id === 'new-1')).toBe(true);
|
|
86
|
+
expect(result.nodes.some((n) => n.id === '1')).toBe(false);
|
|
87
|
+
|
|
88
|
+
// Edge should have updated source to new-1
|
|
89
|
+
expect(result.edges).toHaveLength(1);
|
|
90
|
+
expect(result.edges[0].source).toBe('new-1');
|
|
91
|
+
expect(result.edges[0].target).toBe('2');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('replaces a node without preserving edges when keepEdges is false', () => {
|
|
95
|
+
const n1 = createSampleNode('1');
|
|
96
|
+
const n2 = createSampleNode('2');
|
|
97
|
+
const e1 = createSampleEdge('e1', '1', '2');
|
|
98
|
+
const n1Replacement = createSampleNode('new-1', 10, 10);
|
|
99
|
+
|
|
100
|
+
const result = replaceNodeInGraph([n1, n2], [e1], n1, n1Replacement, false);
|
|
101
|
+
expect(result.nodes.some((n) => n.id === 'new-1')).toBe(true);
|
|
102
|
+
expect(result.edges).toHaveLength(1);
|
|
103
|
+
expect(result.edges[0].source).toBe('1'); // Unchanged
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('adds connected nodes connecting inbound and outbound targets', () => {
|
|
107
|
+
const graph = createCanvasGraph();
|
|
108
|
+
const inNode = createSampleNode('in');
|
|
109
|
+
const outNode = createSampleNode('out');
|
|
110
|
+
const midNode = createSampleNode('mid');
|
|
111
|
+
|
|
112
|
+
graph.addNodeOrNodes([inNode, outNode]);
|
|
113
|
+
|
|
114
|
+
graph.addConnectedNodes({
|
|
115
|
+
nodesToAdd: [midNode],
|
|
116
|
+
inboundNodes: [inNode],
|
|
117
|
+
outboundNodes: [outNode],
|
|
118
|
+
getEdgeLabel: (src, tgt) => `${src.id}->${tgt.id}`
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(graph.nodes).toHaveLength(3);
|
|
122
|
+
expect(graph.edges).toHaveLength(2);
|
|
123
|
+
expect(graph.edges.find((e) => e.source === 'in' && e.target === 'mid')?.label).toBe('in->mid');
|
|
124
|
+
expect(graph.edges.find((e) => e.source === 'mid' && e.target === 'out')?.label).toBe('mid->out');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('accurately calculates graph differences against a baseline', () => {
|
|
128
|
+
const n1 = createSampleNode('1', 0, 0);
|
|
129
|
+
const n2 = createSampleNode('2', 50, 50);
|
|
130
|
+
const e1 = createSampleEdge('e1', '1', '2');
|
|
131
|
+
|
|
132
|
+
const baseline = {
|
|
133
|
+
nodes: [n1, n2],
|
|
134
|
+
edges: [e1]
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// Node 2 position changed, node 3 added, edge 1 removed, edge 2 added
|
|
138
|
+
const n2Changed = createSampleNode('2', 100, 100);
|
|
139
|
+
const n3Added = createSampleNode('3', 200, 200);
|
|
140
|
+
const e2Added = createSampleEdge('e2', '2', '3');
|
|
141
|
+
|
|
142
|
+
const current = {
|
|
143
|
+
nodes: [n1, n2Changed, n3Added],
|
|
144
|
+
edges: [e2Added]
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const diff = calculateGraphDifferences(current, baseline);
|
|
148
|
+
|
|
149
|
+
expect(diff.changedNodes).toHaveLength(2); // n2 changed + n3 newly added
|
|
150
|
+
expect(diff.changedNodes.map((n) => n.id)).toContain('2');
|
|
151
|
+
expect(diff.changedNodes.map((n) => n.id)).toContain('3');
|
|
152
|
+
expect(diff.removedNodes).toHaveLength(0);
|
|
153
|
+
|
|
154
|
+
expect(diff.removedEdges).toHaveLength(1);
|
|
155
|
+
expect(diff.removedEdges[0].id).toBe('e1');
|
|
156
|
+
expect(diff.changedEdges).toHaveLength(1);
|
|
157
|
+
expect(diff.changedEdges[0].id).toBe('e2');
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
createCanvasInteractions,
|
|
4
|
+
defaultSvelteFlowPreset,
|
|
5
|
+
miroCompatiblePreset,
|
|
6
|
+
restorePanelPointerEvents
|
|
7
|
+
} from '../createCanvasInteractions.svelte.js';
|
|
8
|
+
|
|
9
|
+
describe('createCanvasInteractions', () => {
|
|
10
|
+
it('exposes valid default presets', () => {
|
|
11
|
+
expect(defaultSvelteFlowPreset.snapGrid).toEqual([25, 25]);
|
|
12
|
+
expect(defaultSvelteFlowPreset.panOnScroll).toBe(false);
|
|
13
|
+
expect(defaultSvelteFlowPreset.selectionOnDrag).toBe(false);
|
|
14
|
+
|
|
15
|
+
expect(miroCompatiblePreset.selectionOnDrag).toBe(true);
|
|
16
|
+
expect(miroCompatiblePreset.panOnDrag).toEqual([1, 2]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('toggles spacebar panning mode on Space keydown and keyup', () => {
|
|
20
|
+
const target = new EventTarget();
|
|
21
|
+
const onPanModeChange = vi.fn();
|
|
22
|
+
|
|
23
|
+
const interactions = createCanvasInteractions({
|
|
24
|
+
targetElement: target as unknown as HTMLElement,
|
|
25
|
+
onPanModeChange
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const cleanup = interactions.attachListeners(target as unknown as HTMLElement);
|
|
29
|
+
|
|
30
|
+
// Press Space
|
|
31
|
+
const downEvent = new KeyboardEvent('keydown', { key: ' ' });
|
|
32
|
+
target.dispatchEvent(downEvent);
|
|
33
|
+
|
|
34
|
+
expect(interactions.isSpacebarPanning).toBe(true);
|
|
35
|
+
expect(onPanModeChange).toHaveBeenCalledWith(true);
|
|
36
|
+
|
|
37
|
+
// Release Space
|
|
38
|
+
const upEvent = new KeyboardEvent('keyup', { key: ' ' });
|
|
39
|
+
target.dispatchEvent(upEvent);
|
|
40
|
+
|
|
41
|
+
expect(interactions.isSpacebarPanning).toBe(false);
|
|
42
|
+
expect(onPanModeChange).toHaveBeenCalledWith(false);
|
|
43
|
+
|
|
44
|
+
cleanup();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('detects trackpad pinch on wheel event with ctrlKey', () => {
|
|
48
|
+
const target = new EventTarget();
|
|
49
|
+
const onTrackpadDetected = vi.fn();
|
|
50
|
+
|
|
51
|
+
const interactions = createCanvasInteractions({
|
|
52
|
+
targetElement: target as unknown as HTMLElement,
|
|
53
|
+
onTrackpadDetected
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const cleanup = interactions.attachListeners(target as unknown as HTMLElement);
|
|
57
|
+
|
|
58
|
+
expect(interactions.trackpadDetected).toBe(false);
|
|
59
|
+
|
|
60
|
+
// Normal wheel without ctrlKey
|
|
61
|
+
const wheelEvent = new WheelEvent('wheel', { ctrlKey: false });
|
|
62
|
+
target.dispatchEvent(wheelEvent);
|
|
63
|
+
expect(interactions.trackpadDetected).toBe(false);
|
|
64
|
+
expect(onTrackpadDetected).not.toHaveBeenCalled();
|
|
65
|
+
|
|
66
|
+
// Trackpad pinch with ctrlKey
|
|
67
|
+
const pinchEvent = new WheelEvent('wheel', { ctrlKey: true });
|
|
68
|
+
target.dispatchEvent(pinchEvent);
|
|
69
|
+
expect(interactions.trackpadDetected).toBe(true);
|
|
70
|
+
expect(onTrackpadDetected).toHaveBeenCalledTimes(1);
|
|
71
|
+
|
|
72
|
+
cleanup();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('restores pointer events on DOM panels', () => {
|
|
76
|
+
const panel = document.createElement('div');
|
|
77
|
+
panel.className = 'svelte-flow__panel';
|
|
78
|
+
panel.style.pointerEvents = 'none';
|
|
79
|
+
document.body.appendChild(panel);
|
|
80
|
+
|
|
81
|
+
restorePanelPointerEvents();
|
|
82
|
+
|
|
83
|
+
expect(panel.style.pointerEvents).toBe('auto');
|
|
84
|
+
document.body.removeChild(panel);
|
|
85
|
+
});
|
|
86
|
+
});
|