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:
2026-03-11 19:02:01 +03:00
parent e6c7bcf7f4
commit b145d5416a
2 changed files with 98 additions and 14 deletions

View File

@@ -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>

View File

@@ -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>