"use client"; import { useState, useRef, useCallback, useEffect } from "react"; import { createPortal } from "react-dom"; import { Plus, Trash2, GripVertical } from "lucide-react"; interface ArrayEditorProps { 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({ items, onChange, renderItem, createItem, label, addLabel = "Добавить", }: ArrayEditorProps) { const [dragIndex, setDragIndex] = useState(null); const [insertAt, setInsertAt] = useState(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) => (
{ 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" >
handleGripMouseDown(e, i)} >
{renderItem(item, i, (updated) => updateItem(i, updated))}
)); } 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(
{ itemRefs.current[i] = el; }} className="hidden" /> ); continue; } if (visualIndex === placeholderPos) { elements.push(
); } const item = items[i]; elements.push(
{ 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" >
handleGripMouseDown(e, i)} >
{renderItem(item, i, (updated) => updateItem(i, updated))}
); visualIndex++; } if (visualIndex === placeholderPos) { elements.push(
); } return elements; } return (
{label && (

{label}

)}
{renderList()}
{/* Floating clone following cursor */} {mounted && dragIndex !== null && createPortal(
Перемещение элемента...
, document.body )}
); }