feat: improved drag-and-drop UX + long-press card drag + no text selection
- Drag from grip icon (instant) or card body (8px movement threshold) - Floating clone + placeholder at drop position - Disable text selection during drag - Auto-resize textareas, hidden scrollbar/resize handle - Dark admin scrollbar styles Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -41,24 +41,64 @@ export function ArrayEditor<T>({
|
|||||||
onChange(items.filter((_, i) => i !== index));
|
onChange(items.filter((_, i) => i !== index));
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMouseDown = useCallback(
|
const startDrag = useCallback(
|
||||||
(e: React.MouseEvent, index: number) => {
|
(clientX: number, clientY: number, index: number) => {
|
||||||
e.preventDefault();
|
|
||||||
const el = itemRefs.current[index];
|
const el = itemRefs.current[index];
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
setDragIndex(index);
|
setDragIndex(index);
|
||||||
setInsertAt(index);
|
setInsertAt(index);
|
||||||
setMousePos({ x: e.clientX, y: e.clientY });
|
setMousePos({ x: clientX, y: clientY });
|
||||||
setDragSize({ w: rect.width, h: rect.height });
|
setDragSize({ w: rect.width, h: rect.height });
|
||||||
setGrabOffset({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
setGrabOffset({ x: clientX - rect.left, y: clientY - rect.top });
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleGripMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent, index: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
startDrag(e.clientX, e.clientY, index);
|
||||||
|
},
|
||||||
|
[startDrag]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCardMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent, index: number) => {
|
||||||
|
// Don't drag from interactive elements
|
||||||
|
const tag = (e.target as HTMLElement).closest("input, textarea, select, button, a, [role='switch']");
|
||||||
|
if (tag) return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const x = e.clientX;
|
||||||
|
const y = e.clientY;
|
||||||
|
const pendingIndex = index;
|
||||||
|
|
||||||
|
function onMove(ev: MouseEvent) {
|
||||||
|
const dx = ev.clientX - x;
|
||||||
|
const dy = ev.clientY - y;
|
||||||
|
// Start drag after 8px movement
|
||||||
|
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) {
|
||||||
|
cleanup();
|
||||||
|
startDrag(ev.clientX, ev.clientY, pendingIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onUp() { cleanup(); }
|
||||||
|
function cleanup() {
|
||||||
|
window.removeEventListener("mousemove", onMove);
|
||||||
|
window.removeEventListener("mouseup", onUp);
|
||||||
|
}
|
||||||
|
window.addEventListener("mousemove", onMove);
|
||||||
|
window.addEventListener("mouseup", onUp);
|
||||||
|
},
|
||||||
|
[startDrag]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dragIndex === null) return;
|
if (dragIndex === null) return;
|
||||||
|
|
||||||
|
document.body.style.userSelect = "none";
|
||||||
|
|
||||||
function onMouseMove(e: MouseEvent) {
|
function onMouseMove(e: MouseEvent) {
|
||||||
setMousePos({ x: e.clientX, y: e.clientY });
|
setMousePos({ x: e.clientX, y: e.clientY });
|
||||||
|
|
||||||
@@ -99,6 +139,7 @@ export function ArrayEditor<T>({
|
|||||||
window.addEventListener("mousemove", onMouseMove);
|
window.addEventListener("mousemove", onMouseMove);
|
||||||
window.addEventListener("mouseup", onMouseUp);
|
window.addEventListener("mouseup", onMouseUp);
|
||||||
return () => {
|
return () => {
|
||||||
|
document.body.style.userSelect = "";
|
||||||
window.removeEventListener("mousemove", onMouseMove);
|
window.removeEventListener("mousemove", onMouseMove);
|
||||||
window.removeEventListener("mouseup", onMouseUp);
|
window.removeEventListener("mouseup", onMouseUp);
|
||||||
};
|
};
|
||||||
@@ -110,12 +151,13 @@ export function ArrayEditor<T>({
|
|||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
ref={(el) => { itemRefs.current[i] = el; }}
|
ref={(el) => { itemRefs.current[i] = el; }}
|
||||||
|
onMouseDown={(e) => handleCardMouseDown(e, i)}
|
||||||
className="rounded-lg border border-white/10 bg-neutral-900/50 p-4 mb-3"
|
className="rounded-lg border border-white/10 bg-neutral-900/50 p-4 mb-3"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2 mb-3">
|
<div className="flex items-start justify-between gap-2 mb-3">
|
||||||
<div
|
<div
|
||||||
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
|
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
|
||||||
onMouseDown={(e) => handleMouseDown(e, i)}
|
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||||
>
|
>
|
||||||
<GripVertical size={16} />
|
<GripVertical size={16} />
|
||||||
</div>
|
</div>
|
||||||
@@ -160,12 +202,13 @@ export function ArrayEditor<T>({
|
|||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
ref={(el) => { itemRefs.current[i] = el; }}
|
ref={(el) => { itemRefs.current[i] = el; }}
|
||||||
|
onMouseDown={(e) => handleCardMouseDown(e, i)}
|
||||||
className="rounded-lg border border-white/10 bg-neutral-900/50 p-4 mb-3"
|
className="rounded-lg border border-white/10 bg-neutral-900/50 p-4 mb-3"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2 mb-3">
|
<div className="flex items-start justify-between gap-2 mb-3">
|
||||||
<div
|
<div
|
||||||
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
|
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
|
||||||
onMouseDown={(e) => handleMouseDown(e, i)}
|
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||||
>
|
>
|
||||||
<GripVertical size={16} />
|
<GripVertical size={16} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,24 +49,62 @@ export default function TeamEditorPage() {
|
|||||||
setTimeout(() => setSaved(false), 2000);
|
setTimeout(() => setSaved(false), 2000);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleMouseDown = useCallback(
|
const startDrag = useCallback(
|
||||||
(e: React.MouseEvent, index: number) => {
|
(clientX: number, clientY: number, index: number) => {
|
||||||
e.preventDefault();
|
|
||||||
const el = itemRefs.current[index];
|
const el = itemRefs.current[index];
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
setDragIndex(index);
|
setDragIndex(index);
|
||||||
setInsertAt(index);
|
setInsertAt(index);
|
||||||
setMousePos({ x: e.clientX, y: e.clientY });
|
setMousePos({ x: clientX, y: clientY });
|
||||||
setDragSize({ w: rect.width, h: rect.height });
|
setDragSize({ w: rect.width, h: rect.height });
|
||||||
setGrabOffset({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
setGrabOffset({ x: clientX - rect.left, y: clientY - rect.top });
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleGripMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent, index: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
startDrag(e.clientX, e.clientY, index);
|
||||||
|
},
|
||||||
|
[startDrag]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCardMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent, index: number) => {
|
||||||
|
const tag = (e.target as HTMLElement).closest("input, textarea, select, button, a, [role='switch']");
|
||||||
|
if (tag) return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const x = e.clientX;
|
||||||
|
const y = e.clientY;
|
||||||
|
const pendingIndex = index;
|
||||||
|
|
||||||
|
function onMove(ev: MouseEvent) {
|
||||||
|
const dx = ev.clientX - x;
|
||||||
|
const dy = ev.clientY - y;
|
||||||
|
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) {
|
||||||
|
cleanup();
|
||||||
|
startDrag(ev.clientX, ev.clientY, pendingIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onUp() { cleanup(); }
|
||||||
|
function cleanup() {
|
||||||
|
window.removeEventListener("mousemove", onMove);
|
||||||
|
window.removeEventListener("mouseup", onUp);
|
||||||
|
}
|
||||||
|
window.addEventListener("mousemove", onMove);
|
||||||
|
window.addEventListener("mouseup", onUp);
|
||||||
|
},
|
||||||
|
[startDrag]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dragIndex === null) return;
|
if (dragIndex === null) return;
|
||||||
|
|
||||||
|
document.body.style.userSelect = "none";
|
||||||
|
|
||||||
function onMouseMove(e: MouseEvent) {
|
function onMouseMove(e: MouseEvent) {
|
||||||
setMousePos({ x: e.clientX, y: e.clientY });
|
setMousePos({ x: e.clientX, y: e.clientY });
|
||||||
|
|
||||||
@@ -107,6 +145,7 @@ export default function TeamEditorPage() {
|
|||||||
window.addEventListener("mousemove", onMouseMove);
|
window.addEventListener("mousemove", onMouseMove);
|
||||||
window.addEventListener("mouseup", onMouseUp);
|
window.addEventListener("mouseup", onMouseUp);
|
||||||
return () => {
|
return () => {
|
||||||
|
document.body.style.userSelect = "";
|
||||||
window.removeEventListener("mousemove", onMouseMove);
|
window.removeEventListener("mousemove", onMouseMove);
|
||||||
window.removeEventListener("mouseup", onMouseUp);
|
window.removeEventListener("mouseup", onMouseUp);
|
||||||
};
|
};
|
||||||
@@ -137,11 +176,12 @@ export default function TeamEditorPage() {
|
|||||||
<div
|
<div
|
||||||
key={member.id}
|
key={member.id}
|
||||||
ref={(el) => { itemRefs.current[i] = el; }}
|
ref={(el) => { itemRefs.current[i] = el; }}
|
||||||
|
onMouseDown={(e) => handleCardMouseDown(e, i)}
|
||||||
className="flex items-center gap-4 rounded-lg border border-white/10 bg-neutral-900/50 p-3 mb-2"
|
className="flex items-center gap-4 rounded-lg border border-white/10 bg-neutral-900/50 p-3 mb-2"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="cursor-grab active:cursor-grabbing text-neutral-500 hover:text-white transition-colors select-none"
|
className="cursor-grab active:cursor-grabbing text-neutral-500 hover:text-white transition-colors select-none"
|
||||||
onMouseDown={(e) => handleMouseDown(e, i)}
|
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||||
>
|
>
|
||||||
<GripVertical size={18} />
|
<GripVertical size={18} />
|
||||||
</div>
|
</div>
|
||||||
@@ -196,11 +236,12 @@ export default function TeamEditorPage() {
|
|||||||
<div
|
<div
|
||||||
key={member.id}
|
key={member.id}
|
||||||
ref={(el) => { itemRefs.current[i] = el; }}
|
ref={(el) => { itemRefs.current[i] = el; }}
|
||||||
|
onMouseDown={(e) => handleCardMouseDown(e, i)}
|
||||||
className="flex items-center gap-4 rounded-lg border border-white/10 bg-neutral-900/50 p-3 mb-2"
|
className="flex items-center gap-4 rounded-lg border border-white/10 bg-neutral-900/50 p-3 mb-2"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="cursor-grab active:cursor-grabbing text-neutral-500 hover:text-white transition-colors select-none"
|
className="cursor-grab active:cursor-grabbing text-neutral-500 hover:text-white transition-colors select-none"
|
||||||
onMouseDown={(e) => handleMouseDown(e, i)}
|
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||||
>
|
>
|
||||||
<GripVertical size={18} />
|
<GripVertical size={18} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user