@pixerate/editor-svelte 0.10.1 → 0.11.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,112 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createCanvasMultiDrag } from '../createCanvasMultiDrag.svelte.js';
3
+ import {
4
+ getMarqueeSelectionPreset,
5
+ handleDeselectOnEscape
6
+ } from '../createCanvasSelection.svelte.js';
7
+ import type { CanvasNode } from '../types.js';
8
+
9
+ describe('createCanvasMultiDrag & Selection Helpers', () => {
10
+ const sampleNodes: CanvasNode[] = [
11
+ { id: 'node-lead', position: { x: 100, y: 100 }, data: {} },
12
+ { id: 'node-sub-1', position: { x: 100, y: 180 }, data: {} },
13
+ { id: 'node-sub-2', position: { x: 100, y: 260 }, data: {} }
14
+ ];
15
+
16
+ it('returns null and does not activate multi-drag if only 1 node is selected', () => {
17
+ const multiDrag = createCanvasMultiDrag();
18
+ const state = multiDrag.startMultiDrag('node-lead', ['node-lead'], sampleNodes);
19
+
20
+ expect(state).toBeNull();
21
+ expect(multiDrag.isMultiDrag).toBe(false);
22
+ expect(multiDrag.count).toBe(0);
23
+ });
24
+
25
+ it('computes relative follower offsets, stack fan-out, and rotational tilt for multi-selection', () => {
26
+ const multiDrag = createCanvasMultiDrag({
27
+ stackOffsetStepX: 5,
28
+ stackOffsetStepY: 6,
29
+ rotationAngles: [-2, 3]
30
+ });
31
+
32
+ const state = multiDrag.startMultiDrag(
33
+ 'node-lead',
34
+ ['node-lead', 'node-sub-1', 'node-sub-2'],
35
+ sampleNodes
36
+ );
37
+
38
+ expect(state).not.toBeNull();
39
+ expect(multiDrag.isMultiDrag).toBe(true);
40
+ expect(multiDrag.count).toBe(3);
41
+ expect(multiDrag.leadId).toBe('node-lead');
42
+
43
+ // Check follower 1
44
+ const f1 = multiDrag.getFollowerTransform('node-sub-1');
45
+ expect(f1.isFollower).toBe(true);
46
+ expect(f1.stackOffsetX).toBe(5);
47
+ expect(f1.stackOffsetY).toBe(6);
48
+ expect(f1.rotationDeg).toBe(-2);
49
+ expect(f1.zIndex).toBe(49); // baseZIndex (50) - 1
50
+
51
+ // Check follower 2
52
+ const f2 = multiDrag.getFollowerTransform('node-sub-2');
53
+ expect(f2.isFollower).toBe(true);
54
+ expect(f2.stackOffsetX).toBe(10);
55
+ expect(f2.stackOffsetY).toBe(12);
56
+ expect(f2.rotationDeg).toBe(3);
57
+ expect(f2.zIndex).toBe(48); // baseZIndex (50) - 2
58
+
59
+ // Non-follower check
60
+ const fLead = multiDrag.getFollowerTransform('node-lead');
61
+ expect(fLead.isFollower).toBe(false);
62
+ });
63
+
64
+ it('restores original positions upon drag cancellation', () => {
65
+ const multiDrag = createCanvasMultiDrag();
66
+ multiDrag.startMultiDrag(
67
+ 'node-lead',
68
+ ['node-lead', 'node-sub-1'],
69
+ sampleNodes
70
+ );
71
+
72
+ let restoredItems: any[] = [];
73
+ const restores = multiDrag.cancelMultiDrag((items) => {
74
+ restoredItems = items;
75
+ });
76
+
77
+ expect(restores).toHaveLength(1);
78
+ expect(restores[0].id).toBe('node-sub-1');
79
+ expect(restores[0].originalPosition).toEqual({ x: 100, y: 180 });
80
+ expect(restoredItems).toEqual(restores);
81
+ expect(multiDrag.isMultiDrag).toBe(false);
82
+ });
83
+
84
+ it('generates marquee selection preset with spacebar panning awareness', () => {
85
+ const normalPreset = getMarqueeSelectionPreset({ isSpacebarPanning: false });
86
+ expect(normalPreset.selectionOnDrag).toBe(true);
87
+ expect(normalPreset.panOnDrag).toEqual([1, 2]);
88
+
89
+ const panPreset = getMarqueeSelectionPreset({ isSpacebarPanning: true });
90
+ expect(panPreset.selectionOnDrag).toBe(false);
91
+ expect(panPreset.panOnDrag).toEqual([0, 1, 2]);
92
+ });
93
+
94
+ it('handles deselect on Escape key press', () => {
95
+ let deselected = false;
96
+ const handled = handleDeselectOnEscape(
97
+ { key: 'Escape', code: 'Escape' } as KeyboardEvent,
98
+ () => {
99
+ deselected = true;
100
+ }
101
+ );
102
+
103
+ expect(handled).toBe(true);
104
+ expect(deselected).toBe(true);
105
+
106
+ const ignored = handleDeselectOnEscape(
107
+ { key: 'Enter', code: 'Enter' } as KeyboardEvent,
108
+ () => {}
109
+ );
110
+ expect(ignored).toBe(false);
111
+ });
112
+ });
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { flushSync } from 'svelte';
3
+ import { Position } from '@xyflow/svelte';
4
+ import {
5
+ preserveNodeMeasurements,
6
+ normalizeNodeHandles,
7
+ createCanvasNodeSync,
8
+ type HandleConfig
9
+ } from '../createCanvasNodeSync.svelte.js';
10
+ import type { CanvasNode } from '../types.js';
11
+
12
+ describe('createCanvasNodeSync & Node Measurement Caching', () => {
13
+ it('preserves measured dimensions from existing nodes to newly created nodes', () => {
14
+ const existingNodes: CanvasNode[] = [
15
+ {
16
+ id: 'node-1',
17
+ position: { x: 100, y: 100 },
18
+ data: {},
19
+ measured: { width: 280, height: 160 }
20
+ } as any,
21
+ {
22
+ id: 'node-2',
23
+ position: { x: 200, y: 200 },
24
+ data: {}
25
+ } as any
26
+ ];
27
+
28
+ const freshNodes: CanvasNode[] = [
29
+ { id: 'node-1', position: { x: 100, y: 100 }, data: { title: 'Updated' } } as any,
30
+ { id: 'node-2', position: { x: 200, y: 200 }, data: { title: 'Fresh' } } as any,
31
+ { id: 'node-3', position: { x: 300, y: 300 }, data: { title: 'Brand New' } } as any
32
+ ];
33
+
34
+ const preserved = preserveNodeMeasurements(freshNodes, existingNodes);
35
+
36
+ expect((preserved[0] as any).measured).toEqual({ width: 280, height: 160 });
37
+ expect((preserved[1] as any).measured).toBeUndefined();
38
+ expect((preserved[2] as any).measured).toBeUndefined();
39
+ });
40
+
41
+ it('normalizes node handles to prevent xyflow forceInitialRender selection flash', () => {
42
+ const rawNode: CanvasNode = {
43
+ id: 'node-1',
44
+ position: { x: 0, y: 0 },
45
+ data: {}
46
+ };
47
+
48
+ const defaultHandles: HandleConfig[] = [
49
+ { id: null, type: 'target', position: Position.Left, x: 0, y: 50, width: 12, height: 12 },
50
+ { id: null, type: 'source', position: Position.Right, x: 260, y: 50, width: 12, height: 12 }
51
+ ];
52
+
53
+ const normalized = normalizeNodeHandles(rawNode, defaultHandles);
54
+
55
+ expect((normalized as any).handles).toHaveLength(2);
56
+ expect((normalized as any).handles[0].position).toBe(Position.Left);
57
+ expect((normalized as any).handles[1].position).toBe(Position.Right);
58
+ });
59
+
60
+ it('synchronizes domain items to nodes and edges while caching measurements across ticks', () => {
61
+ let domainItems = [
62
+ { id: 'item-1', name: 'Task 1', dependencies: [] },
63
+ { id: 'item-2', name: 'Task 2', dependencies: ['item-1'] }
64
+ ];
65
+
66
+ let sync: ReturnType<typeof createCanvasNodeSync<any>>;
67
+
68
+ const cleanup = $effect.root(() => {
69
+ sync = createCanvasNodeSync({
70
+ items: () => domainItems,
71
+ toNode: (item) => ({
72
+ id: item.id,
73
+ position: { x: 10, y: 20 },
74
+ data: { label: item.name }
75
+ }),
76
+ toEdges: (items) =>
77
+ items.flatMap((item) =>
78
+ item.dependencies.map((depId) => ({
79
+ id: `edge-${depId}-${item.id}`,
80
+ source: depId,
81
+ target: item.id
82
+ }))
83
+ ),
84
+ defaultHandles: () => [
85
+ { id: 'in', type: 'target', position: Position.Left },
86
+ { id: 'out', type: 'source', position: Position.Right }
87
+ ]
88
+ });
89
+ });
90
+
91
+ flushSync();
92
+
93
+ expect(sync!.nodes).toHaveLength(2);
94
+ expect(sync!.edges).toHaveLength(1);
95
+ expect(sync!.edges[0].id).toBe('edge-item-1-item-2');
96
+ expect((sync!.nodes[0] as any).handles).toHaveLength(2);
97
+
98
+ // Simulate xyflow measuring node-1
99
+ sync!.updateNodeMeasurements('item-1', 250, 120);
100
+ expect((sync!.nodes[0] as any).measured).toEqual({ width: 250, height: 120 });
101
+
102
+ cleanup();
103
+ });
104
+ });
@@ -0,0 +1,175 @@
1
+ import type { XYPosition } from '@xyflow/svelte';
2
+ import type { CanvasNode } from './types.js';
3
+
4
+ export interface MultiDragFollowerOffset {
5
+ deltaX: number;
6
+ deltaY: number;
7
+ originalPosition: XYPosition;
8
+ stackOffsetX: number;
9
+ stackOffsetY: number;
10
+ rotationDeg: number;
11
+ }
12
+
13
+ export interface MultiDragState {
14
+ leadId: string;
15
+ offsets: Record<string, MultiDragFollowerOffset>;
16
+ count: number;
17
+ }
18
+
19
+ export interface CreateCanvasMultiDragOptions {
20
+ /**
21
+ * Base horizontal fan-out offset per stacked follower card (default: 4px).
22
+ */
23
+ stackOffsetStepX?: number;
24
+ /**
25
+ * Base vertical fan-out offset per stacked follower card (default: 4px).
26
+ */
27
+ stackOffsetStepY?: number;
28
+ /**
29
+ * Alternating angular tilt in degrees for follower cards (default: [-2, 2.5, -3, 3]).
30
+ */
31
+ rotationAngles?: number[];
32
+ /**
33
+ * Base z-index for stacked cards during active dragging (default: 50).
34
+ */
35
+ baseZIndex?: number;
36
+ }
37
+
38
+ export interface FollowerTransform {
39
+ isFollower: boolean;
40
+ stackOffsetX: number;
41
+ stackOffsetY: number;
42
+ rotationDeg: number;
43
+ zIndex: number;
44
+ }
45
+
46
+ /**
47
+ * Headless Svelte 5 rune orchestrating multi-node drag operations with stacked visual fanning,
48
+ * dynamic z-index layering, and position restoration.
49
+ */
50
+ export function createCanvasMultiDrag(options: CreateCanvasMultiDragOptions = {}) {
51
+ const stepX = options.stackOffsetStepX ?? 4;
52
+ const stepY = options.stackOffsetStepY ?? 4;
53
+ const angles = options.rotationAngles ?? [-2, 2.5, -3, 3];
54
+ const baseZIndex = options.baseZIndex ?? 50;
55
+
56
+ let dragState = $state<MultiDragState | null>(null);
57
+
58
+ function startMultiDrag(
59
+ leadNodeId: string,
60
+ selectedNodeIds: string[] | Set<string>,
61
+ nodes: CanvasNode[] | Map<string, CanvasNode>
62
+ ): MultiDragState | null {
63
+ const selectedSet =
64
+ selectedNodeIds instanceof Set ? selectedNodeIds : new Set(selectedNodeIds);
65
+
66
+ if (selectedSet.size <= 1 || !selectedSet.has(leadNodeId)) {
67
+ dragState = null;
68
+ return null;
69
+ }
70
+
71
+ const nodeMap =
72
+ nodes instanceof Map ? nodes : new Map(nodes.map((n) => [n.id, n]));
73
+
74
+ const leadNode = nodeMap.get(leadNodeId);
75
+ if (!leadNode) {
76
+ dragState = null;
77
+ return null;
78
+ }
79
+
80
+ const leadPos = leadNode.position ?? { x: 0, y: 0 };
81
+ const offsets: Record<string, MultiDragFollowerOffset> = {};
82
+ let followerIndex = 0;
83
+
84
+ for (const id of selectedSet) {
85
+ if (id === leadNodeId) continue;
86
+ const follower = nodeMap.get(id);
87
+ const fPos = follower?.position ?? { x: 0, y: 0 };
88
+
89
+ const angle = angles[followerIndex % angles.length];
90
+ offsets[id] = {
91
+ deltaX: fPos.x - leadPos.x,
92
+ deltaY: fPos.y - leadPos.y,
93
+ originalPosition: { ...fPos },
94
+ stackOffsetX: (followerIndex + 1) * stepX,
95
+ stackOffsetY: (followerIndex + 1) * stepY,
96
+ rotationDeg: angle
97
+ };
98
+ followerIndex++;
99
+ }
100
+
101
+ const state: MultiDragState = {
102
+ leadId: leadNodeId,
103
+ offsets,
104
+ count: selectedSet.size
105
+ };
106
+
107
+ dragState = state;
108
+ return state;
109
+ }
110
+
111
+ function getFollowerTransform(nodeId: string): FollowerTransform {
112
+ if (!dragState || !(nodeId in dragState.offsets)) {
113
+ return {
114
+ isFollower: false,
115
+ stackOffsetX: 0,
116
+ stackOffsetY: 0,
117
+ rotationDeg: 0,
118
+ zIndex: 0
119
+ };
120
+ }
121
+
122
+ const offset = dragState.offsets[nodeId];
123
+ const followerKeys = Object.keys(dragState.offsets);
124
+ const index = followerKeys.indexOf(nodeId);
125
+
126
+ return {
127
+ isFollower: true,
128
+ stackOffsetX: offset.stackOffsetX,
129
+ stackOffsetY: offset.stackOffsetY,
130
+ rotationDeg: offset.rotationDeg,
131
+ zIndex: baseZIndex - (index + 1)
132
+ };
133
+ }
134
+
135
+ function cancelMultiDrag(
136
+ applyRestore?: (restores: { id: string; originalPosition: XYPosition }[]) => void
137
+ ): { id: string; originalPosition: XYPosition }[] {
138
+ if (!dragState) return [];
139
+
140
+ const restores = Object.entries(dragState.offsets).map(([id, offset]) => ({
141
+ id,
142
+ originalPosition: { ...offset.originalPosition }
143
+ }));
144
+
145
+ if (applyRestore) {
146
+ applyRestore(restores);
147
+ }
148
+
149
+ dragState = null;
150
+ return restores;
151
+ }
152
+
153
+ function commitMultiDrag(): void {
154
+ dragState = null;
155
+ }
156
+
157
+ return {
158
+ get dragState() {
159
+ return dragState;
160
+ },
161
+ get isMultiDrag() {
162
+ return Boolean(dragState && dragState.count > 1);
163
+ },
164
+ get count() {
165
+ return dragState ? dragState.count : 0;
166
+ },
167
+ get leadId() {
168
+ return dragState?.leadId ?? null;
169
+ },
170
+ startMultiDrag,
171
+ getFollowerTransform,
172
+ cancelMultiDrag,
173
+ commitMultiDrag
174
+ };
175
+ }
@@ -0,0 +1,150 @@
1
+ import { untrack } from 'svelte';
2
+ import type { Position } from '@xyflow/svelte';
3
+ import type { CanvasNode, CanvasEdge } from './types.js';
4
+
5
+ export interface HandleConfig {
6
+ id?: string | null;
7
+ type: 'source' | 'target';
8
+ position: Position;
9
+ x?: number;
10
+ y?: number;
11
+ width?: number;
12
+ height?: number;
13
+ }
14
+
15
+ export interface NodeMeasurement {
16
+ width: number;
17
+ height: number;
18
+ }
19
+
20
+ /**
21
+ * Preserves cached .measured dimensions from existing nodes to newly constructed nodes.
22
+ * Resolves measurements without subscribing to reactive updates on the existing collection.
23
+ */
24
+ export function preserveNodeMeasurements<TNode extends CanvasNode = CanvasNode>(
25
+ newNodes: TNode[],
26
+ existingNodes: TNode[] | Map<string, TNode>
27
+ ): TNode[] {
28
+ const map =
29
+ existingNodes instanceof Map
30
+ ? existingNodes
31
+ : new Map(existingNodes.map((n) => [n.id, n]));
32
+
33
+ for (const node of newNodes) {
34
+ const prev = map.get(node.id);
35
+ if (prev && (prev as any).measured) {
36
+ (node as any).measured = { ...(prev as any).measured };
37
+ }
38
+ }
39
+ return newNodes;
40
+ }
41
+
42
+ /**
43
+ * Pre-populates handle geometry on a node to prevent @xyflow/system's getNodesInside
44
+ * from evaluating `forceInitialRender = !node.internals.handleBounds` and triggering
45
+ * a false full-canvas selection on the first frame of marquee drag-selection.
46
+ */
47
+ export function normalizeNodeHandles<TNode extends CanvasNode = CanvasNode>(
48
+ node: TNode,
49
+ handles: HandleConfig[] = []
50
+ ): TNode {
51
+ (node as any).handles = handles;
52
+ return node;
53
+ }
54
+
55
+ export interface CreateCanvasNodeSyncOptions<
56
+ TItem,
57
+ TNode extends CanvasNode = CanvasNode,
58
+ TEdge extends CanvasEdge = CanvasEdge
59
+ > {
60
+ /**
61
+ * Reactive getter returning raw domain items.
62
+ */
63
+ items: () => TItem[];
64
+ /**
65
+ * Factory function converting a domain item into a CanvasNode.
66
+ */
67
+ toNode: (
68
+ item: TItem,
69
+ index: number,
70
+ context: { existingNode?: TNode; prevNodesMap: Map<string, TNode> }
71
+ ) => TNode;
72
+ /**
73
+ * Optional factory function converting domain items and generated nodes into CanvasEdges.
74
+ */
75
+ toEdges?: (items: TItem[], nodes: TNode[]) => TEdge[];
76
+ /**
77
+ * Optional default handle configuration provider for connectable nodes.
78
+ */
79
+ defaultHandles?: (node: TNode) => HandleConfig[] | undefined;
80
+ }
81
+
82
+ /**
83
+ * Headless Svelte 5 rune that synchronizes reactive domain items with SvelteFlow nodes/edges,
84
+ * automatically untracking previous node reads, caching .measured dimensions, and normalizing
85
+ * handle bounds to prevent Svelte 5 effect_update_depth_exceeded and marquee drag flashes.
86
+ */
87
+ export function createCanvasNodeSync<
88
+ TItem,
89
+ TNode extends CanvasNode = CanvasNode,
90
+ TEdge extends CanvasEdge = CanvasEdge
91
+ >(options: CreateCanvasNodeSyncOptions<TItem, TNode, TEdge>) {
92
+ let nodes = $state<TNode[]>([]);
93
+ let edges = $state<TEdge[]>([]);
94
+
95
+ $effect.pre(() => {
96
+ const rawItems = options.items();
97
+
98
+ // Untrack the read of current nodes to avoid recursive effect loops
99
+ const existingNodesMap = untrack(() => new Map(nodes.map((n) => [n.id, n])));
100
+
101
+ const newNodes = rawItems.map((item, index) => {
102
+ const existing = existingNodesMap.get((item as any)?.id);
103
+ const node = options.toNode(item, index, {
104
+ existingNode: existing,
105
+ prevNodesMap: existingNodesMap
106
+ });
107
+
108
+ // Automatically preserve measured dimensions if available
109
+ if (existing && (existing as any).measured && !(node as any).measured) {
110
+ (node as any).measured = { ...(existing as any).measured };
111
+ }
112
+
113
+ // Pre-populate handles if provided
114
+ if (options.defaultHandles && !(node as any).handles) {
115
+ const handles = options.defaultHandles(node);
116
+ if (handles) {
117
+ (node as any).handles = handles;
118
+ }
119
+ }
120
+
121
+ return node;
122
+ });
123
+
124
+ const newEdges = options.toEdges ? options.toEdges(rawItems, newNodes) : [];
125
+
126
+ nodes = newNodes;
127
+ edges = newEdges;
128
+ });
129
+
130
+ return {
131
+ get nodes() {
132
+ return nodes;
133
+ },
134
+ set nodes(value: TNode[]) {
135
+ nodes = value;
136
+ },
137
+ get edges() {
138
+ return edges;
139
+ },
140
+ set edges(value: TEdge[]) {
141
+ edges = value;
142
+ },
143
+ updateNodeMeasurements(id: string, width: number, height: number) {
144
+ const node = nodes.find((n) => n.id === id);
145
+ if (node) {
146
+ (node as any).measured = { width, height };
147
+ }
148
+ }
149
+ };
150
+ }
@@ -0,0 +1,51 @@
1
+ import type { CanvasInteractionPresetConfig } from './types.js';
2
+
3
+ export interface CanvasSelectionOptions {
4
+ /**
5
+ * Whether spacebar panning is currently active (suspends marquee selection).
6
+ */
7
+ isSpacebarPanning?: boolean;
8
+ /**
9
+ * Selection mode: 'partial' (default) selects nodes intersected by selection box,
10
+ * 'full' requires nodes to be fully enclosed.
11
+ */
12
+ selectionMode?: 'partial' | 'full';
13
+ /**
14
+ * Additional keys that trigger multi-selection (default: ['Shift', 'Meta']).
15
+ */
16
+ multiSelectionKey?: string[];
17
+ }
18
+
19
+ /**
20
+ * Generates an interaction preset configured for left-click marquee drag-selection
21
+ * while preserving middle-mouse and spacebar panning.
22
+ */
23
+ export function getMarqueeSelectionPreset(
24
+ options: CanvasSelectionOptions = {}
25
+ ): CanvasInteractionPresetConfig {
26
+ const isSpacebarPanning = options.isSpacebarPanning ?? false;
27
+
28
+ return {
29
+ snapGrid: [25, 25],
30
+ panOnDrag: isSpacebarPanning ? [0, 1, 2] : [1, 2],
31
+ panOnScroll: false,
32
+ selectionOnDrag: !isSpacebarPanning,
33
+ colorMode: 'system' as any,
34
+ multiSelectionKey: options.multiSelectionKey ?? ['Shift', 'Meta'],
35
+ selectionMode: (options.selectionMode ?? 'partial') as any
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Keyboard listener helper to deselect nodes on Escape.
41
+ */
42
+ export function handleDeselectOnEscape(
43
+ event: KeyboardEvent,
44
+ onDeselect: () => void
45
+ ): boolean {
46
+ if (event.key === 'Escape' || event.code === 'Escape') {
47
+ onDeselect();
48
+ return true;
49
+ }
50
+ return false;
51
+ }