@pixerate/editor-svelte 0.3.11 → 0.4.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 +345 -0
- package/dist/canvas/index.js +672 -0
- package/dist/canvas/index.js.map +1 -0
- package/dist/index.js +11 -19
- package/dist/index.js.map +1 -1
- package/package.json +24 -1
- package/src/EditableTextNodeEditor.svelte +4 -7
- 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-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/createCanvasDocking.svelte.ts +145 -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/index.d.ts +345 -0
- package/src/canvas/index.ts +46 -0
- package/src/canvas/types.ts +96 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
getCenteredNodePosition,
|
|
4
|
+
centerNodes,
|
|
5
|
+
getLayoutedNodes
|
|
6
|
+
} from '../createCanvasLayout.js';
|
|
7
|
+
import type { CanvasNode, CanvasEdge } from '../types.js';
|
|
8
|
+
|
|
9
|
+
describe('createCanvasLayout & Geometry Helpers', () => {
|
|
10
|
+
it('calculates centered node position correctly', () => {
|
|
11
|
+
const screenDimensions = { width: 1000, height: 800 };
|
|
12
|
+
const screenToFlowPosition = (pos: { x: number; y: number }) => ({ x: pos.x, y: pos.y });
|
|
13
|
+
|
|
14
|
+
const pos = getCenteredNodePosition(200, 100, screenToFlowPosition, screenDimensions);
|
|
15
|
+
// Center of 1000x800 is (500, 400). Top-left of 200x100 is (500 - 100, 400 - 50) = (400, 350)
|
|
16
|
+
expect(pos.x).toBe(400);
|
|
17
|
+
expect(pos.y).toBe(350);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('centers multiple nodes using centerNodes', () => {
|
|
21
|
+
const screenDimensions = { width: 1000, height: 800 };
|
|
22
|
+
const screenToFlowPosition = (pos: { x: number; y: number }) => pos;
|
|
23
|
+
|
|
24
|
+
const nodes: CanvasNode[] = [
|
|
25
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
26
|
+
{ id: '2', position: { x: 200, y: 100 }, data: {} }
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const getNodesBounds = () => ({ x: 0, y: 0, width: 300, height: 200 });
|
|
30
|
+
|
|
31
|
+
const centered = centerNodes(nodes, screenToFlowPosition, getNodesBounds, screenDimensions);
|
|
32
|
+
|
|
33
|
+
// Center of viewport is (500, 400). Upper left for 300x200 bounding box is (350, 300).
|
|
34
|
+
// DeltaX = 350 - 0 = 350, DeltaY = 300 - 0 = 300.
|
|
35
|
+
expect(centered[0].position.x).toBe(350);
|
|
36
|
+
expect(centered[0].position.y).toBe(300);
|
|
37
|
+
expect(centered[1].position.x).toBe(550);
|
|
38
|
+
expect(centered[1].position.y).toBe(400);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('computes Dagre hierarchical layout positions for nodes and edges', () => {
|
|
42
|
+
const nodes: CanvasNode[] = [
|
|
43
|
+
{ id: 'root', position: { x: 0, y: 0 }, data: {}, measured: { width: 100, height: 100 } },
|
|
44
|
+
{ id: 'child1', position: { x: 0, y: 0 }, data: {}, measured: { width: 100, height: 100 } },
|
|
45
|
+
{ id: 'child2', position: { x: 0, y: 0 }, data: {}, measured: { width: 100, height: 100 } }
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const edges: CanvasEdge[] = [
|
|
49
|
+
{ id: 'e1', source: 'root', target: 'child1' },
|
|
50
|
+
{ id: 'e2', source: 'root', target: 'child2' }
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const getNodesBounds = () => ({ x: 100, y: 100, width: 400, height: 400 });
|
|
54
|
+
|
|
55
|
+
const layouted = getLayoutedNodes(nodes, edges, getNodesBounds, {
|
|
56
|
+
direction: 'LR',
|
|
57
|
+
ranksep: 80,
|
|
58
|
+
nodesep: 40
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(layouted).toHaveLength(3);
|
|
62
|
+
|
|
63
|
+
const root = layouted.find((n) => n.id === 'root')!;
|
|
64
|
+
const child1 = layouted.find((n) => n.id === 'child1')!;
|
|
65
|
+
const child2 = layouted.find((n) => n.id === 'child2')!;
|
|
66
|
+
|
|
67
|
+
// Root should be to the left of child nodes in 'LR' layout
|
|
68
|
+
expect(root.position.x).toBeLessThan(child1.position.x);
|
|
69
|
+
expect(root.position.x).toBeLessThan(child2.position.x);
|
|
70
|
+
|
|
71
|
+
// Target and source positions should be configured for horizontal layout
|
|
72
|
+
expect(root.sourcePosition).toBe('right');
|
|
73
|
+
expect(child1.targetPosition).toBe('left');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { createCanvasShortcuts } from '../createCanvasShortcuts.js';
|
|
3
|
+
import type { CanvasNode } from '../types.js';
|
|
4
|
+
|
|
5
|
+
describe('createCanvasShortcuts', () => {
|
|
6
|
+
it('attaches listener, handles hotkeys, and cleans up', () => {
|
|
7
|
+
const target = new EventTarget();
|
|
8
|
+
const onDelete = vi.fn();
|
|
9
|
+
const onDuplicate = vi.fn();
|
|
10
|
+
const onSelectAll = vi.fn();
|
|
11
|
+
|
|
12
|
+
const selectedNodes: CanvasNode[] = [{ id: 'node-1', position: { x: 0, y: 0 }, data: {} }];
|
|
13
|
+
|
|
14
|
+
const destroy = createCanvasShortcuts({
|
|
15
|
+
targetElement: target as unknown as HTMLElement,
|
|
16
|
+
getSelectedNodes: () => selectedNodes,
|
|
17
|
+
onDelete,
|
|
18
|
+
onDuplicate,
|
|
19
|
+
onSelectAll
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Trigger Backspace -> onDelete
|
|
23
|
+
const deleteEvent = new CustomEvent('keydown', { cancelable: true });
|
|
24
|
+
Object.assign(deleteEvent, { key: 'Backspace' });
|
|
25
|
+
target.dispatchEvent(deleteEvent);
|
|
26
|
+
|
|
27
|
+
expect(onDelete).toHaveBeenCalledWith(selectedNodes);
|
|
28
|
+
|
|
29
|
+
// Trigger Meta + D -> onDuplicate
|
|
30
|
+
const dupEvent = new CustomEvent('keydown', { cancelable: true });
|
|
31
|
+
Object.assign(dupEvent, { key: 'd', metaKey: true });
|
|
32
|
+
target.dispatchEvent(dupEvent);
|
|
33
|
+
|
|
34
|
+
expect(onDuplicate).toHaveBeenCalledWith(selectedNodes);
|
|
35
|
+
|
|
36
|
+
// Trigger Meta + A -> onSelectAll
|
|
37
|
+
const allEvent = new CustomEvent('keydown', { cancelable: true });
|
|
38
|
+
Object.assign(allEvent, { key: 'a', metaKey: true });
|
|
39
|
+
target.dispatchEvent(allEvent);
|
|
40
|
+
|
|
41
|
+
expect(onSelectAll).toHaveBeenCalledTimes(1);
|
|
42
|
+
|
|
43
|
+
// Trigger shortcut while targeting an input -> should be ignored
|
|
44
|
+
const inputEl = { tagName: 'INPUT' } as unknown as HTMLElement;
|
|
45
|
+
const ignoredEvent = new CustomEvent('keydown', { cancelable: true });
|
|
46
|
+
Object.defineProperty(ignoredEvent, 'target', { value: inputEl });
|
|
47
|
+
Object.assign(ignoredEvent, { key: 'Backspace' });
|
|
48
|
+
target.dispatchEvent(ignoredEvent);
|
|
49
|
+
|
|
50
|
+
expect(onDelete).toHaveBeenCalledTimes(1); // Not called again
|
|
51
|
+
|
|
52
|
+
// Destroy listeners
|
|
53
|
+
destroy();
|
|
54
|
+
|
|
55
|
+
// Dispatching after destroy should do nothing
|
|
56
|
+
target.dispatchEvent(deleteEvent);
|
|
57
|
+
expect(onDelete).toHaveBeenCalledTimes(1);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { tick } from 'svelte';
|
|
2
|
+
import type { XYPosition } from '@xyflow/svelte';
|
|
3
|
+
import type { CanvasNode, CanvasEdge, CanvasClipboardPayload } from './types.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Checks if an event originated from a text input, textarea, contenteditable element, or rich editor.
|
|
7
|
+
*/
|
|
8
|
+
export function isEventFromTextInput(event: Event | { target: EventTarget | null }): boolean {
|
|
9
|
+
const target = event.target as HTMLElement | null;
|
|
10
|
+
if (!target) return false;
|
|
11
|
+
if (
|
|
12
|
+
target.tagName === 'INPUT' ||
|
|
13
|
+
target.tagName === 'TEXTAREA' ||
|
|
14
|
+
target.tagName === 'SELECT' ||
|
|
15
|
+
target.isContentEditable ||
|
|
16
|
+
target.closest?.('[contenteditable="true"]') ||
|
|
17
|
+
target.closest?.('.ProseMirror')
|
|
18
|
+
) {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Validates that an object conforms to minimal CanvasNode shape.
|
|
26
|
+
*/
|
|
27
|
+
export function isValidCanvasNode(obj: unknown): obj is CanvasNode {
|
|
28
|
+
return (
|
|
29
|
+
typeof obj === 'object' &&
|
|
30
|
+
obj !== null &&
|
|
31
|
+
'id' in obj &&
|
|
32
|
+
typeof (obj as any).id === 'string' &&
|
|
33
|
+
'position' in obj &&
|
|
34
|
+
typeof (obj as any).position === 'object' &&
|
|
35
|
+
(obj as any).position !== null &&
|
|
36
|
+
'x' in (obj as any).position &&
|
|
37
|
+
'y' in (obj as any).position
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Checks if a string is a valid HTTP/HTTPS URL.
|
|
43
|
+
*/
|
|
44
|
+
export function isValidUrl(url: string): boolean {
|
|
45
|
+
try {
|
|
46
|
+
const parsed = new URL(url);
|
|
47
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface CreateCanvasClipboardOptions<
|
|
54
|
+
TNode extends CanvasNode = CanvasNode,
|
|
55
|
+
TEdge extends CanvasEdge = CanvasEdge
|
|
56
|
+
> {
|
|
57
|
+
onPasteNodes?: (nodes: TNode[], edges?: TEdge[]) => void | Promise<void>;
|
|
58
|
+
onPasteUrl?: (url: string, position?: XYPosition) => void;
|
|
59
|
+
onPasteText?: (text: string, position?: XYPosition) => void;
|
|
60
|
+
onPasteImage?: (blob: File) => void | Promise<void>;
|
|
61
|
+
screenToFlowPosition?: (position: XYPosition) => XYPosition;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Headless Svelte 5 rune managing canvas copy, cut, and paste interactions.
|
|
66
|
+
*/
|
|
67
|
+
export function createCanvasClipboard<
|
|
68
|
+
TNode extends CanvasNode = CanvasNode,
|
|
69
|
+
TEdge extends CanvasEdge = CanvasEdge
|
|
70
|
+
>(options: CreateCanvasClipboardOptions<TNode, TEdge> = {}) {
|
|
71
|
+
function copySelectedNodes(
|
|
72
|
+
selectedNodes: TNode[],
|
|
73
|
+
connectedEdges?: TEdge[],
|
|
74
|
+
event?: ClipboardEvent
|
|
75
|
+
): boolean {
|
|
76
|
+
if (event && isEventFromTextInput(event)) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (selectedNodes.length === 0) return false;
|
|
81
|
+
|
|
82
|
+
const payload: CanvasClipboardPayload<TNode, TEdge> = {
|
|
83
|
+
version: 1,
|
|
84
|
+
nodes: selectedNodes,
|
|
85
|
+
edges: connectedEdges
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const serialized = JSON.stringify(payload);
|
|
89
|
+
|
|
90
|
+
if (event?.clipboardData) {
|
|
91
|
+
event.clipboardData.setData('application/json', serialized);
|
|
92
|
+
event.clipboardData.setData('text/plain', serialized);
|
|
93
|
+
event.preventDefault();
|
|
94
|
+
} else if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
|
95
|
+
navigator.clipboard.writeText(serialized).catch(() => {});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function cutSelectedNodes(
|
|
102
|
+
selectedNodes: TNode[],
|
|
103
|
+
connectedEdges?: TEdge[],
|
|
104
|
+
onRemove?: (nodes: TNode[]) => void,
|
|
105
|
+
event?: ClipboardEvent
|
|
106
|
+
): boolean {
|
|
107
|
+
if (event && isEventFromTextInput(event)) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const copied = copySelectedNodes(selectedNodes, connectedEdges, event);
|
|
112
|
+
if (copied && onRemove) {
|
|
113
|
+
onRemove(selectedNodes);
|
|
114
|
+
}
|
|
115
|
+
return copied;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function handlePasteEvent(event: ClipboardEvent): Promise<boolean> {
|
|
119
|
+
if (isEventFromTextInput(event)) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const clipboardData = event.clipboardData;
|
|
124
|
+
if (!clipboardData) return false;
|
|
125
|
+
|
|
126
|
+
// 1. Check for files (images)
|
|
127
|
+
const files = clipboardData.files;
|
|
128
|
+
if (files && files.length > 0) {
|
|
129
|
+
const imageFile = Array.from(files).find((f) => f.type.startsWith('image/'));
|
|
130
|
+
if (imageFile && options.onPasteImage) {
|
|
131
|
+
event.preventDefault();
|
|
132
|
+
await options.onPasteImage(imageFile);
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 2. Check for serialized nodes (application/json or json in text/plain)
|
|
138
|
+
const rawJson =
|
|
139
|
+
clipboardData.getData('application/json') || clipboardData.getData('text/plain');
|
|
140
|
+
|
|
141
|
+
if (rawJson) {
|
|
142
|
+
try {
|
|
143
|
+
const parsed = JSON.parse(rawJson);
|
|
144
|
+
let rawNodes: TNode[] | undefined;
|
|
145
|
+
let rawEdges: TEdge[] | undefined;
|
|
146
|
+
|
|
147
|
+
if (Array.isArray(parsed) && parsed.every(isValidCanvasNode)) {
|
|
148
|
+
rawNodes = parsed as TNode[];
|
|
149
|
+
} else if (
|
|
150
|
+
typeof parsed === 'object' &&
|
|
151
|
+
parsed !== null &&
|
|
152
|
+
Array.isArray((parsed as any).nodes) &&
|
|
153
|
+
(parsed as any).nodes.every(isValidCanvasNode)
|
|
154
|
+
) {
|
|
155
|
+
rawNodes = (parsed as any).nodes;
|
|
156
|
+
rawEdges = (parsed as any).edges;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (rawNodes && rawNodes.length > 0) {
|
|
160
|
+
event.preventDefault();
|
|
161
|
+
|
|
162
|
+
// Create ID mapping for cloned nodes
|
|
163
|
+
const idMap = new Map<string, string>();
|
|
164
|
+
const newNodes: TNode[] = rawNodes.map((node) => {
|
|
165
|
+
const newId = crypto.randomUUID();
|
|
166
|
+
idMap.set(node.id, newId);
|
|
167
|
+
return {
|
|
168
|
+
...node,
|
|
169
|
+
id: newId,
|
|
170
|
+
selected: true,
|
|
171
|
+
position: {
|
|
172
|
+
x: node.position.x + 40,
|
|
173
|
+
y: node.position.y + 40
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// Clone edges mapping them to the newly generated node IDs
|
|
179
|
+
const newEdges: TEdge[] | undefined = rawEdges?.map((edge) => ({
|
|
180
|
+
...edge,
|
|
181
|
+
id: crypto.randomUUID(),
|
|
182
|
+
source: idMap.get(edge.source) ?? edge.source,
|
|
183
|
+
target: idMap.get(edge.target) ?? edge.target
|
|
184
|
+
}));
|
|
185
|
+
|
|
186
|
+
if (options.onPasteNodes) {
|
|
187
|
+
await options.onPasteNodes(newNodes, newEdges);
|
|
188
|
+
await tick();
|
|
189
|
+
}
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
} catch {
|
|
193
|
+
// Not JSON, fall through to text/URL processing
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 3. Check for URLs or plain text
|
|
198
|
+
const text = clipboardData.getData('text/plain')?.trim();
|
|
199
|
+
if (text) {
|
|
200
|
+
if (isValidUrl(text) && options.onPasteUrl) {
|
|
201
|
+
event.preventDefault();
|
|
202
|
+
options.onPasteUrl(text);
|
|
203
|
+
return true;
|
|
204
|
+
} else if (options.onPasteText) {
|
|
205
|
+
event.preventDefault();
|
|
206
|
+
options.onPasteText(text);
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
copySelectedNodes,
|
|
216
|
+
cutSelectedNodes,
|
|
217
|
+
handlePasteEvent
|
|
218
|
+
};
|
|
219
|
+
}
|
|
@@ -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
|
+
}
|