1import type { Action } from 'svelte/action';
2
3interface DraggableParameters {
4 handle?: string;
5 bounds?: 'parent' | 'window';
6 enabled?: boolean;
7}
8
9interface DraggableAttributes {
10 ondragstart?: (e: CustomEvent<{ x: number; y: number }>) => void;
11 ondragmove?: (e: CustomEvent<{ x: number; y: number }>) => void;
12 ondragend?: (e: CustomEvent<{ x: number; y: number }>) => void;
13}
14
15export const draggable: Action<HTMLElement, DraggableParameters, DraggableAttributes> = (node, params = {}) => {
16 let x = 0;
17 let y = 0;
18 let startX = 0;
19 let startY = 0;
20 let isDragging = false;
21
22 let isEnabled = params.enabled !== undefined ? params.enabled : true;
23 const handleSelector = params.handle ?? null;
24 const bounds = params.bounds ?? 'window';
25
26 const handleElement = handleSelector ? node.querySelector<HTMLElement>(handleSelector) : node;
27 if (!handleElement) {
28 console.warn('Draggable handle element not found');
29 return;
30 }
31
32 // Ensure proper positioning
33 if (getComputedStyle(node).position === 'static') {
34 node.style.position = 'fixed';
35 }
36
37 function getNodePosition() {
38 const rect = node.getBoundingClientRect();
39 return {
40 x: rect.left,
41 y: rect.top
42 };
43 }
44
45 function updatePosition(newX: number, newY: number) {
46 x = newX;
47 y = newY;
48 node.style.left = `${x}px`;
49 node.style.top = `${y}px`;
50 }
51
52 function onMouseDown(event: MouseEvent) {
53 if (!isEnabled || event.button !== 0) return;
54
55 // Check if we're clicking a button in the title bar
56 const target = event.target as HTMLElement;
57 if (target.tagName === 'BUTTON' || target.closest('button')) {
58 return;
59 }
60
61 isDragging = true;
62
63 // Get current position
64 const pos = getNodePosition();
65 x = pos.x;
66 y = pos.y;
67
68 startX = event.clientX - x;
69 startY = event.clientY - y;
70
71 node.dispatchEvent(new CustomEvent('dragstart', {
72 detail: { x, y }
73 }));
74
75 // Add dragging class for styling
76 node.classList.add('dragging');
77
78 event.preventDefault();
79 }
80
81 function onMouseMove(event: MouseEvent) {
82 if (!isDragging || !isEnabled) return;
83
84 let newX = event.clientX - startX;
85 let newY = event.clientY - startY;
86
87 if (bounds === 'window') {
88 newX = Math.max(0, Math.min(newX, window.innerWidth - node.offsetWidth));
89 newY = Math.max(0, Math.min(newY, window.innerHeight - node.offsetHeight));
90 } else if (bounds === 'parent' && node.parentElement) {
91 const parent = node.parentElement;
92 newX = Math.max(0, Math.min(newX, parent.offsetWidth - node.offsetWidth));
93 newY = Math.max(0, Math.min(newY, parent.offsetHeight - node.offsetHeight));
94 }
95
96 requestAnimationFrame(() => {
97 updatePosition(newX, newY);
98 node.dispatchEvent(new CustomEvent('dragmove', {
99 detail: { x: newX, y: newY }
100 }));
101 });
102 }
103
104 function onMouseUp() {
105 if (!isDragging) return;
106 isDragging = false;
107
108 node.classList.remove('dragging');
109
110 node.dispatchEvent(new CustomEvent('dragend', {
111 detail: { x, y }
112 }));
113 }
114
115 handleElement.style.cursor = isEnabled ? 'move' : 'default';
116 handleElement.addEventListener('mousedown', onMouseDown);
117 window.addEventListener('mousemove', onMouseMove, { passive: true });
118 window.addEventListener('mouseup', onMouseUp);
119
120 return {
121 update(newParams: DraggableParameters) {
122 isEnabled = newParams.enabled !== undefined ? newParams.enabled : true;
123 handleElement.style.cursor = isEnabled ? 'move' : 'default';
124 Object.assign(params, newParams);
125 },
126 destroy() {
127 handleElement.removeEventListener('mousedown', onMouseDown);
128 window.removeEventListener('mousemove', onMouseMove);
129 window.removeEventListener('mouseup', onMouseUp);
130 }
131 };
132};