Skip to content

Commit d4a1901

Browse files
dhkimclaude
andcommitted
fix: replace HTML5 drag with pointer events for menu reorder
HTML5 drag API conflicted with @dnd-kit/core DndContext, preventing menu drag-and-drop from working. Switched to pointer event-based implementation with setPointerCapture for reliable cross-element drag. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2b859f1 commit d4a1901

1 file changed

Lines changed: 59 additions & 24 deletions

File tree

src/components/TitleBar.tsx

Lines changed: 59 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useEffect, useRef } from "react";
1+
import { useState, useEffect, useRef, useCallback } from "react";
22

33
interface TitleBarProps {
44
onArchiveClick: () => void;
@@ -107,10 +107,10 @@ export function TitleBar({ onArchiveClick, onStatsClick, onRecurringClick, theme
107107
}
108108
};
109109

110-
const saveOrder = (order: MenuId[]) => {
110+
const saveOrder = useCallback((order: MenuId[]) => {
111111
setMenuOrder(order);
112112
localStorage.setItem("kanban-menu-order", JSON.stringify(order));
113-
};
113+
}, []);
114114

115115
const handleRestoreClick = () => fileInputRef.current?.click();
116116

@@ -126,6 +126,56 @@ export function TitleBar({ onArchiveClick, onStatsClick, onRecurringClick, theme
126126
e.target.value = "";
127127
};
128128

129+
// Pointer-based menu drag (HTML5 drag conflicts with DndContext)
130+
const menuContainerRef = useRef<HTMLDivElement>(null);
131+
const dragStartX = useRef(0);
132+
const dragActive = useRef(false);
133+
const dragIdxRef = useRef<number | null>(null);
134+
const menuOrderRef = useRef(menuOrder);
135+
menuOrderRef.current = menuOrder;
136+
137+
const handlePointerDown = useCallback((idx: number, e: React.PointerEvent) => {
138+
if (!editing) return;
139+
e.preventDefault();
140+
setDragIdx(idx);
141+
dragIdxRef.current = idx;
142+
dragStartX.current = e.clientX;
143+
dragActive.current = false;
144+
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
145+
}, [editing]);
146+
147+
const handlePointerMove = useCallback((e: React.PointerEvent) => {
148+
if (dragIdxRef.current === null || !editing) return;
149+
e.preventDefault();
150+
if (!dragActive.current && Math.abs(e.clientX - dragStartX.current) > 4) {
151+
dragActive.current = true;
152+
}
153+
if (!dragActive.current) return;
154+
155+
const container = menuContainerRef.current;
156+
if (!container) return;
157+
const children = Array.from(container.children).filter(c => c.hasAttribute('data-menu-idx'));
158+
const currentIdx = dragIdxRef.current;
159+
for (let i = 0; i < children.length; i++) {
160+
const rect = children[i].getBoundingClientRect();
161+
if (e.clientX >= rect.left && e.clientX <= rect.right && i !== currentIdx) {
162+
const newOrder = [...menuOrderRef.current];
163+
const [moved] = newOrder.splice(currentIdx, 1);
164+
newOrder.splice(i, 0, moved);
165+
saveOrder(newOrder);
166+
setDragIdx(i);
167+
dragIdxRef.current = i;
168+
break;
169+
}
170+
}
171+
}, [editing, saveOrder]);
172+
173+
const handlePointerUp = useCallback(() => {
174+
setDragIdx(null);
175+
dragIdxRef.current = null;
176+
dragActive.current = false;
177+
}, []);
178+
129179
const btnClass = "text-xs text-slate-400 hover:text-slate-200 bg-white/[0.06] hover:bg-white/[0.1] border border-white/[0.1] rounded-md px-3 py-1.5 transition-colors";
130180

131181
const menuLabels: Record<MenuId, string> = {
@@ -164,30 +214,15 @@ export function TitleBar({ onArchiveClick, onStatsClick, onRecurringClick, theme
164214
</div>
165215
)}
166216
</div>
167-
<div className="flex items-center gap-2">
217+
<div ref={menuContainerRef} className="flex items-center gap-2">
168218
{menuOrder.map((id, idx) => (
169219
<div
170220
key={id}
171-
draggable={editing}
172-
onDragStart={(e) => {
173-
if (!editing) return;
174-
setDragIdx(idx);
175-
e.dataTransfer.effectAllowed = "move";
176-
}}
177-
onDragOver={(e) => {
178-
if (!editing || dragIdx === null) return;
179-
e.preventDefault();
180-
}}
181-
onDrop={() => {
182-
if (!editing || dragIdx === null || dragIdx === idx) { setDragIdx(null); return; }
183-
const newOrder = [...menuOrder];
184-
const [moved] = newOrder.splice(dragIdx, 1);
185-
newOrder.splice(idx, 0, moved);
186-
saveOrder(newOrder);
187-
setDragIdx(null);
188-
}}
189-
onDragEnd={() => setDragIdx(null)}
190-
className={`${editing ? "cursor-grab active:cursor-grabbing" : ""} ${dragIdx === idx ? "opacity-30" : ""}`}
221+
data-menu-idx={idx}
222+
onPointerDown={(e) => handlePointerDown(idx, e)}
223+
onPointerMove={handlePointerMove}
224+
onPointerUp={handlePointerUp}
225+
className={`${editing ? "cursor-grab active:cursor-grabbing touch-none" : ""} ${dragIdx === idx ? "opacity-30" : ""}`}
191226
>
192227
<span
193228
onClick={editing ? undefined : menuActions[id]}

0 commit comments

Comments
 (0)