Defer onChange call in ArrayEditor drag drop to queueMicrotask to avoid calling parent setState inside React updater. Add hover highlight on draggable cards for better visual feedback. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
288 lines
9.1 KiB
TypeScript
288 lines
9.1 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useRef, useCallback, useEffect } from "react";
|
||
import { createPortal } from "react-dom";
|
||
import { Plus, Trash2, GripVertical } from "lucide-react";
|
||
|
||
interface ArrayEditorProps<T> {
|
||
items: T[];
|
||
onChange: (items: T[]) => void;
|
||
renderItem: (item: T, index: number, update: (item: T) => void) => React.ReactNode;
|
||
createItem: () => T;
|
||
label?: string;
|
||
addLabel?: string;
|
||
}
|
||
|
||
export function ArrayEditor<T>({
|
||
items,
|
||
onChange,
|
||
renderItem,
|
||
createItem,
|
||
label,
|
||
addLabel = "Добавить",
|
||
}: ArrayEditorProps<T>) {
|
||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||
const [insertAt, setInsertAt] = useState<number | null>(null);
|
||
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
|
||
const [dragSize, setDragSize] = useState({ w: 0, h: 0 });
|
||
const [grabOffset, setGrabOffset] = useState({ x: 0, y: 0 });
|
||
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||
const [mounted, setMounted] = useState(false);
|
||
|
||
useEffect(() => { setMounted(true); }, []);
|
||
|
||
function updateItem(index: number, item: T) {
|
||
const updated = [...items];
|
||
updated[index] = item;
|
||
onChange(updated);
|
||
}
|
||
|
||
function removeItem(index: number) {
|
||
onChange(items.filter((_, i) => i !== index));
|
||
}
|
||
|
||
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: clientX, y: clientY });
|
||
setDragSize({ w: rect.width, h: rect.height });
|
||
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 });
|
||
|
||
let newInsert = items.length;
|
||
for (let i = 0; i < items.length; i++) {
|
||
if (i === dragIndex) continue;
|
||
const el = itemRefs.current[i];
|
||
if (!el) continue;
|
||
const rect = el.getBoundingClientRect();
|
||
const midY = rect.top + rect.height / 2;
|
||
if (e.clientY < midY) {
|
||
newInsert = i;
|
||
break;
|
||
}
|
||
}
|
||
setInsertAt(newInsert);
|
||
}
|
||
|
||
function onMouseUp() {
|
||
// Read current values from state updaters but defer onChange to avoid
|
||
// calling parent setState during React's render/updater cycle
|
||
let capturedDrag: number | null = null;
|
||
let capturedInsert: number | null = null;
|
||
|
||
setDragIndex((prev) => { capturedDrag = prev; return null; });
|
||
setInsertAt((prev) => { capturedInsert = prev; return null; });
|
||
|
||
// Defer the reorder to next microtask so React finishes its batch first
|
||
queueMicrotask(() => {
|
||
if (capturedDrag !== null && capturedInsert !== null) {
|
||
let targetIndex = capturedInsert;
|
||
if (capturedDrag < targetIndex) targetIndex -= 1;
|
||
if (capturedDrag !== targetIndex) {
|
||
const updated = [...items];
|
||
const [moved] = updated.splice(capturedDrag, 1);
|
||
updated.splice(targetIndex, 0, moved);
|
||
onChange(updated);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
window.addEventListener("mousemove", onMouseMove);
|
||
window.addEventListener("mouseup", onMouseUp);
|
||
return () => {
|
||
document.body.style.userSelect = "";
|
||
window.removeEventListener("mousemove", onMouseMove);
|
||
window.removeEventListener("mouseup", onMouseUp);
|
||
};
|
||
}, [dragIndex, items, onChange]);
|
||
|
||
function renderList() {
|
||
if (dragIndex === null || insertAt === null) {
|
||
return items.map((item, i) => (
|
||
<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 hover:border-white/25 hover:bg-neutral-800/50 transition-colors"
|
||
>
|
||
<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) => handleGripMouseDown(e, i)}
|
||
>
|
||
<GripVertical size={16} />
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => removeItem(i)}
|
||
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
|
||
>
|
||
<Trash2 size={16} />
|
||
</button>
|
||
</div>
|
||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||
</div>
|
||
));
|
||
}
|
||
|
||
const elements: React.ReactNode[] = [];
|
||
let visualIndex = 0;
|
||
let placeholderPos = insertAt;
|
||
if (insertAt > dragIndex) placeholderPos = insertAt - 1;
|
||
|
||
for (let i = 0; i < items.length; i++) {
|
||
if (i === dragIndex) {
|
||
elements.push(
|
||
<div key={`hidden-${i}`} ref={(el) => { itemRefs.current[i] = el; }} className="hidden" />
|
||
);
|
||
continue;
|
||
}
|
||
|
||
if (visualIndex === placeholderPos) {
|
||
elements.push(
|
||
<div
|
||
key="placeholder"
|
||
className="rounded-lg border-2 border-dashed border-rose-500/50 bg-rose-500/5 mb-3"
|
||
style={{ height: dragSize.h }}
|
||
/>
|
||
);
|
||
}
|
||
|
||
const item = items[i];
|
||
elements.push(
|
||
<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 hover:border-white/25 hover:bg-neutral-800/50 transition-colors"
|
||
>
|
||
<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) => handleGripMouseDown(e, i)}
|
||
>
|
||
<GripVertical size={16} />
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => removeItem(i)}
|
||
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
|
||
>
|
||
<Trash2 size={16} />
|
||
</button>
|
||
</div>
|
||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||
</div>
|
||
);
|
||
visualIndex++;
|
||
}
|
||
|
||
if (visualIndex === placeholderPos) {
|
||
elements.push(
|
||
<div
|
||
key="placeholder"
|
||
className="rounded-lg border-2 border-dashed border-rose-500/50 bg-rose-500/5 mb-3"
|
||
style={{ height: dragSize.h }}
|
||
/>
|
||
);
|
||
}
|
||
|
||
return elements;
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
{label && (
|
||
<h3 className="text-sm font-medium text-neutral-300 mb-3">{label}</h3>
|
||
)}
|
||
|
||
<div>
|
||
{renderList()}
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => onChange([...items, createItem()])}
|
||
className="mt-3 flex items-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-2.5 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors"
|
||
>
|
||
<Plus size={16} />
|
||
{addLabel}
|
||
</button>
|
||
|
||
{/* Floating clone following cursor */}
|
||
{mounted && dragIndex !== null &&
|
||
createPortal(
|
||
<div
|
||
className="fixed z-[9999] pointer-events-none"
|
||
style={{
|
||
left: mousePos.x - grabOffset.x,
|
||
top: mousePos.y - grabOffset.y,
|
||
width: dragSize.w,
|
||
height: dragSize.h,
|
||
}}
|
||
>
|
||
<div className="h-full rounded-lg border-2 border-rose-500 bg-neutral-900/95 shadow-2xl shadow-rose-500/20 flex items-center gap-3 px-4">
|
||
<GripVertical size={16} className="text-rose-400 shrink-0" />
|
||
<span className="text-sm text-neutral-300">Перемещение элемента...</span>
|
||
</div>
|
||
</div>,
|
||
document.body
|
||
)}
|
||
</div>
|
||
);
|
||
}
|