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));
|
||||
}
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
const startDrag = useCallback(
|
||||
(clientX: number, clientY: number, index: number) => {
|
||||
const el = itemRefs.current[index];
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
setDragIndex(index);
|
||||
setInsertAt(index);
|
||||
setMousePos({ x: e.clientX, y: e.clientY });
|
||||
setMousePos({ x: clientX, y: clientY });
|
||||
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(() => {
|
||||
if (dragIndex === null) return;
|
||||
|
||||
document.body.style.userSelect = "none";
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
setMousePos({ x: e.clientX, y: e.clientY });
|
||||
|
||||
@@ -99,6 +139,7 @@ export function ArrayEditor<T>({
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
return () => {
|
||||
document.body.style.userSelect = "";
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
@@ -110,12 +151,13 @@ export function ArrayEditor<T>({
|
||||
<div
|
||||
key={i}
|
||||
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"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-3">
|
||||
<div
|
||||
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} />
|
||||
</div>
|
||||
@@ -160,12 +202,13 @@ export function ArrayEditor<T>({
|
||||
<div
|
||||
key={i}
|
||||
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"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-3">
|
||||
<div
|
||||
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} />
|
||||
</div>
|
||||
|
||||
@@ -49,24 +49,62 @@ export default function TeamEditorPage() {
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
const startDrag = useCallback(
|
||||
(clientX: number, clientY: number, index: number) => {
|
||||
const el = itemRefs.current[index];
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
setDragIndex(index);
|
||||
setInsertAt(index);
|
||||
setMousePos({ x: e.clientX, y: e.clientY });
|
||||
setMousePos({ x: clientX, y: clientY });
|
||||
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(() => {
|
||||
if (dragIndex === null) return;
|
||||
|
||||
document.body.style.userSelect = "none";
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
setMousePos({ x: e.clientX, y: e.clientY });
|
||||
|
||||
@@ -107,6 +145,7 @@ export default function TeamEditorPage() {
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
return () => {
|
||||
document.body.style.userSelect = "";
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
@@ -137,11 +176,12 @@ export default function TeamEditorPage() {
|
||||
<div
|
||||
key={member.id}
|
||||
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"
|
||||
>
|
||||
<div
|
||||
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} />
|
||||
</div>
|
||||
@@ -196,11 +236,12 @@ export default function TeamEditorPage() {
|
||||
<div
|
||||
key={member.id}
|
||||
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"
|
||||
>
|
||||
<div
|
||||
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} />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user