@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,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
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { isEventFromTextInput } from './createCanvasClipboard.svelte.js';
|
|
2
|
+
import type { CanvasNode, CanvasShortcutsOptions } from './types.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Attaches keyboard shortcut handlers for canvas interaction.
|
|
6
|
+
* Automatically ignores shortcuts when the user is typing into text inputs, textareas, or rich editors.
|
|
7
|
+
*
|
|
8
|
+
* @returns A cleanup function to remove event listeners.
|
|
9
|
+
*/
|
|
10
|
+
export function createCanvasShortcuts<TNode extends CanvasNode = CanvasNode>(
|
|
11
|
+
options: CanvasShortcutsOptions<TNode>
|
|
12
|
+
): () => void {
|
|
13
|
+
const target = options.targetElement ?? (typeof window !== 'undefined' ? window : null);
|
|
14
|
+
if (!target) return () => {};
|
|
15
|
+
|
|
16
|
+
function handleKeyDown(event: Event) {
|
|
17
|
+
const keyboardEvent = event as KeyboardEvent;
|
|
18
|
+
if (isEventFromTextInput(keyboardEvent)) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const isMod = keyboardEvent.metaKey || keyboardEvent.ctrlKey;
|
|
23
|
+
const isShift = keyboardEvent.shiftKey;
|
|
24
|
+
const key = keyboardEvent.key;
|
|
25
|
+
|
|
26
|
+
const selectedNodes = options.getSelectedNodes ? options.getSelectedNodes() : [];
|
|
27
|
+
|
|
28
|
+
if (key === 'Backspace' || key === 'Delete') {
|
|
29
|
+
if (options.onDelete && selectedNodes.length > 0) {
|
|
30
|
+
keyboardEvent.preventDefault();
|
|
31
|
+
options.onDelete(selectedNodes);
|
|
32
|
+
}
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (isMod) {
|
|
37
|
+
const lowerKey = key.toLowerCase();
|
|
38
|
+
|
|
39
|
+
if (lowerKey === 'c' && !isShift) {
|
|
40
|
+
if (options.onCopy) {
|
|
41
|
+
// We let the native copy event fire or call onCopy
|
|
42
|
+
options.onCopy();
|
|
43
|
+
}
|
|
44
|
+
} else if (lowerKey === 'x' && !isShift) {
|
|
45
|
+
if (options.onCut) {
|
|
46
|
+
options.onCut();
|
|
47
|
+
}
|
|
48
|
+
} else if (lowerKey === 'd' && !isShift) {
|
|
49
|
+
if (options.onDuplicate && selectedNodes.length > 0) {
|
|
50
|
+
keyboardEvent.preventDefault();
|
|
51
|
+
options.onDuplicate(selectedNodes);
|
|
52
|
+
}
|
|
53
|
+
} else if (lowerKey === 'a' && !isShift) {
|
|
54
|
+
if (options.onSelectAll) {
|
|
55
|
+
keyboardEvent.preventDefault();
|
|
56
|
+
options.onSelectAll();
|
|
57
|
+
}
|
|
58
|
+
} else if (lowerKey === 'g') {
|
|
59
|
+
if (isShift) {
|
|
60
|
+
if (options.onUngroup && selectedNodes.length > 0) {
|
|
61
|
+
keyboardEvent.preventDefault();
|
|
62
|
+
options.onUngroup(selectedNodes);
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
if (options.onGroup && selectedNodes.length > 0) {
|
|
66
|
+
keyboardEvent.preventDefault();
|
|
67
|
+
options.onGroup(selectedNodes);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} else if (key === '=' || key === '+') {
|
|
71
|
+
if (options.onZoomIn) {
|
|
72
|
+
keyboardEvent.preventDefault();
|
|
73
|
+
options.onZoomIn();
|
|
74
|
+
}
|
|
75
|
+
} else if (key === '-') {
|
|
76
|
+
if (options.onZoomOut) {
|
|
77
|
+
keyboardEvent.preventDefault();
|
|
78
|
+
options.onZoomOut();
|
|
79
|
+
}
|
|
80
|
+
} else if (key === '0') {
|
|
81
|
+
if (options.onFitView) {
|
|
82
|
+
keyboardEvent.preventDefault();
|
|
83
|
+
options.onFitView();
|
|
84
|
+
}
|
|
85
|
+
} else if (lowerKey === 'f') {
|
|
86
|
+
if (options.onSearch) {
|
|
87
|
+
keyboardEvent.preventDefault();
|
|
88
|
+
options.onSearch();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
target.addEventListener('keydown', handleKeyDown as EventListener);
|
|
95
|
+
|
|
96
|
+
return () => {
|
|
97
|
+
target.removeEventListener('keydown', handleKeyDown as EventListener);
|
|
98
|
+
};
|
|
99
|
+
}
|