DraggableList.tsx
⎇
Raw
1import { type Accessor, type Setter, createSignal, onMount, onCleanup, type JSX, For, createMemo } from "solid-js";
2
3type DropPosition = "before" | "after" | null;
4
5interface DraggableListProps<T> {
6 items: Accessor<T[]>;
7 setItems: Setter<T[]>;
8 children: (
9 item: T,
10 index: number,
11 handlers: {
12 dragHandlers: {
13 draggable: boolean;
14 onDragStart: (e: DragEvent) => void;
15 onTouchStart: (e: TouchEvent) => void;
16 };
17 isDragging: Accessor<boolean>;
18 dropPosition: Accessor<DropPosition>;
19 },
20 ) => JSX.Element;
21}
22
23export function DraggableList<T>(props: DraggableListProps<T>) {
24 const [draggingItem, setDraggingItem] = createSignal<T | null>(null);
25 const [dragStartIndex, setDragStartIndex] = createSignal<number | null>(null);
26 const [dragOverIndex, setDragOverIndex] = createSignal<number | null>(null);
27
28 let listRef!: HTMLDivElement;
29 let rafId: number | null = null;
30 let lastClientY = 0;
31 let scrollInterval: undefined | number;
32
33 function scheduleUpdate(clientY: number) {
34 lastClientY = clientY;
35 if (rafId != null) return;
36 rafId = requestAnimationFrame(() => {
37 updateDragOverIndex(lastClientY);
38 rafId = null;
39 });
40 }
41
42 function startAutoScrollLoop() {
43 function step() {
44 const rect = listRef.getBoundingClientRect();
45 const edgeThreshold = 75;
46 const scrollAmount = (distance: number) => Math.min(15, 1 + (distance / edgeThreshold) * 5);
47
48 if (lastClientY < rect.top + edgeThreshold) {
49 const distance = rect.top + edgeThreshold - lastClientY;
50 listRef.scrollTop -= scrollAmount(distance);
51 } else if (lastClientY > rect.bottom - edgeThreshold) {
52 const distance = lastClientY - (rect.bottom - edgeThreshold);
53 listRef.scrollTop += scrollAmount(distance);
54 }
55
56 scheduleUpdate(lastClientY);
57 }
58 scrollInterval = setInterval(step, 10);
59 }
60
61 function stopAutoScrollLoop() {
62 clearInterval(scrollInterval);
63 scrollInterval = undefined;
64 }
65
66 function moveDraggedItem() {
67 const items = props.items();
68 const movedItem = draggingItem();
69 const startIdx = dragStartIndex();
70 const dropIdx = dragOverIndex();
71
72 if (movedItem && startIdx != null && dropIdx != null && startIdx !== dropIdx) {
73 const newItems = [...items];
74 newItems.splice(startIdx, 1);
75 const adjustedIndex = startIdx < dropIdx ? dropIdx - 1 : dropIdx;
76 newItems.splice(adjustedIndex, 0, movedItem);
77 props.setItems(newItems);
78 }
79 }
80
81 function endDrag(move: boolean) {
82 if (rafId) cancelAnimationFrame(rafId);
83 rafId = null;
84 stopAutoScrollLoop();
85 if (move) moveDraggedItem();
86 setDraggingItem(null);
87 setDragStartIndex(null);
88 setDragOverIndex(null);
89 }
90
91 function updateDragOverIndex(clientY: number) {
92 const items = Array.from(listRef.querySelectorAll(".draggable-item")) as HTMLElement[];
93 for (let i = 0; i < items.length; i++) {
94 const rect = items[i].getBoundingClientRect();
95 if (clientY < rect.top + rect.height / 2) {
96 setDragOverIndex(i);
97 return;
98 }
99 }
100 setDragOverIndex(items.length);
101 }
102
103 function handleDragStart(item: T, index: number, dragElement: HTMLElement | null, e: DragEvent) {
104 if (!dragElement) return;
105 setDraggingItem(() => item);
106 setDragStartIndex(index);
107 if (!e.dataTransfer) return;
108 e.dataTransfer.effectAllowed = "move";
109 e.dataTransfer.setDragImage(dragElement, dragElement.offsetWidth / 20, dragElement.offsetHeight / 2);
110 console.log(e.dataTransfer);
111 }
112
113 function handleTouchStart(item: T, index: number, e: TouchEvent) {
114 setDraggingItem(() => item);
115 setDragStartIndex(index);
116 lastClientY = e.touches[0].clientY;
117 }
118
119 function handleTouchMove(e: TouchEvent) {
120 if (!draggingItem()) return;
121 if (!scrollInterval) startAutoScrollLoop();
122 e.preventDefault();
123 scheduleUpdate(e.touches[0].clientY);
124 }
125
126 function handleDragOver(e: DragEvent) {
127 e.preventDefault();
128 scheduleUpdate(e.clientY);
129 if (!e.dataTransfer) return;
130 e.dataTransfer.dropEffect = "move";
131 }
132
133 function handleDrop(e: DragEvent) {
134 e.preventDefault();
135 endDrag(true);
136 }
137
138 onMount(() => {
139 const cancelDrag = () => endDrag(false);
140 const doDrag = () => endDrag(true);
141
142 listRef.addEventListener("touchmove", handleTouchMove, { passive: false });
143 listRef.addEventListener("touchend", doDrag);
144 listRef.addEventListener("touchcancel", cancelDrag);
145
146 onCleanup(() => {
147 listRef?.removeEventListener("touchmove", handleTouchMove);
148 listRef?.removeEventListener("touchend", doDrag);
149 listRef?.removeEventListener("touchcancel", cancelDrag);
150 stopAutoScrollLoop();
151 });
152 });
153
154 return (
155 <div
156 class="draggable-list"
157 ref={listRef}
158 onDragOver={handleDragOver}
159 onDrop={handleDrop}
160 onDragEnd={() => endDrag(true)}
161 >
162 <For each={props.items()}>
163 {(item, index) => {
164 const isDragging = createMemo(() => draggingItem === item);
165 const dropPosition = createMemo(() => {
166 const overIdx = dragOverIndex();
167 return overIdx === index() ? "before" : overIdx === index() + 1 ? "after" : null;
168 });
169
170 return props.children(item, index(), {
171 dragHandlers: {
172 draggable: true,
173 onDragStart: (e) => handleDragStart(item, index(), (e.currentTarget as HTMLElement)?.parentElement, e),
174 onTouchStart: (e) => handleTouchStart(item, index(), e),
175 },
176 isDragging: isDragging,
177 dropPosition: dropPosition,
178 });
179 }}
180 </For>
181 <div class="drop-area-end" />
182 </div>
183 );
184}
185