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