draggable.ts RawHistoryFolder
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 // Set initial position from existing inline styles or computed styles
33 const computedStyle = getComputedStyle(node);
34 const existingLeft = node.style.left || computedStyle.left;
35 const existingTop = node.style.top || computedStyle.top;
36
37 x = parseInt(existingLeft) || 0;
38 y = parseInt(existingTop) || 0;
39
40 // Ensure the element is positioned
41 const position = computedStyle.position;
42 if (position !== 'relative' && position !== 'absolute' && position !== 'fixed') {
43 node.style.position = 'fixed';
44 }
45
46 node.style.left = `${x}px`;
47 node.style.top = `${y}px`;
48
49 function onMouseDown(event: MouseEvent) {
50 if (!isEnabled) return;
51
52 // Only handle left mouse button
53 if (event.button !== 0) return;
54
55 isDragging = true;
56 const rect = node.getBoundingClientRect();
57
58 x = rect.left;
59 y = rect.top;
60 startX = event.clientX - x;
61 startY = event.clientY - y;
62
63 node.dispatchEvent(new CustomEvent('dragstart', {
64 detail: { x, y }
65 }));
66
67 // Prevent text selection while dragging
68 event.preventDefault();
69 }
70
71 function onMouseMove(event: MouseEvent) {
72 if (!isDragging || !isEnabled) return;
73
74 let newX = event.clientX - startX;
75 let newY = event.clientY - startY;
76
77 if (bounds === 'window') {
78 // Keep within window bounds
79 newX = Math.max(0, Math.min(newX, window.innerWidth - node.offsetWidth));
80 newY = Math.max(0, Math.min(newY, window.innerHeight - node.offsetHeight));
81 } else if (bounds === 'parent' && node.parentElement) {
82 // Keep within parent bounds
83 const parent = node.parentElement;
84 newX = Math.max(0, Math.min(newX, parent.offsetWidth - node.offsetWidth));
85 newY = Math.max(0, Math.min(newY, parent.offsetHeight - node.offsetHeight));
86 }
87
88 x = newX;
89 y = newY;
90
91 node.style.left = `${x}px`;
92 node.style.top = `${y}px`;
93
94 node.dispatchEvent(new CustomEvent('dragmove', {
95 detail: { x, y }
96 }));
97 }
98
99 function onMouseUp() {
100 if (!isDragging) return;
101 isDragging = false;
102
103 node.dispatchEvent(new CustomEvent('dragend', {
104 detail: { x, y }
105 }));
106 }
107
108 handleElement.style.cursor = isEnabled ? 'move' : 'default';
109 handleElement.addEventListener('mousedown', onMouseDown as EventListener);
110 window.addEventListener('mousemove', onMouseMove as EventListener);
111 window.addEventListener('mouseup', onMouseUp as EventListener);
112
113 return {
114 update(newParams: DraggableParameters) {
115 const newEnabled = newParams.enabled !== undefined ? newParams.enabled : true;
116 isEnabled = newEnabled;
117 handleElement.style.cursor = isEnabled ? 'move' : 'default';
118 Object.assign(params, newParams);
119 },
120 destroy() {
121 handleElement.removeEventListener('mousedown', onMouseDown as EventListener);
122 window.removeEventListener('mousemove', onMouseMove as EventListener);
123 window.removeEventListener('mouseup', onMouseUp as EventListener);
124 }
125 };
126};