"use client"; import { useState, useRef, useCallback, useEffect } from "react"; import { createPortal } from "react-dom"; import { Plus, Trash2, GripVertical, ChevronDown, ChevronsUpDown } from "lucide-react"; import { ConfirmDialog } from "./ConfirmDialog"; 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; collapsible?: boolean; getItemTitle?: (item: T, index: number) => string; getItemBadge?: (item: T, index: number) => React.ReactNode; hiddenItems?: Set; addPosition?: "top" | "bottom"; } export function ArrayEditor({ items, onChange, renderItem, createItem, label, addLabel = "Добавить", collapsible = false, getItemTitle, getItemBadge, hiddenItems, addPosition = "bottom", }: ArrayEditorProps) { const [confirmDelete, setConfirmDelete] = useState(null); 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); const [newItemIndex, setNewItemIndex] = useState(null); const [droppedIndex, setDroppedIndex] = useState(null); const [collapsed, setCollapsed] = useState>(() => collapsible ? new Set(items.map((_, i) => i)) : new Set()); function toggleCollapse(index: number) { setCollapsed(prev => { const next = new Set(prev); if (next.has(index)) next.delete(index); else next.add(index); return next; }); } useEffect(() => { setMounted(true); }, []); // Scroll to newly added item useEffect(() => { if (newItemIndex !== null && itemRefs.current[newItemIndex]) { itemRefs.current[newItemIndex]?.scrollIntoView({ behavior: "smooth", block: "center" }); setNewItemIndex(null); } }, [newItemIndex, items]); 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] ); 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); setDroppedIndex(targetIndex); setTimeout(() => setDroppedIndex(null), 1500); } } }); } 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) => { const isCollapsed = collapsible && collapsed.has(i) && newItemIndex !== i; const isHidden = hiddenItems?.has(i) ?? false; const title = getItemTitle?.(item, i) || `#${i + 1}`; return (
{ itemRefs.current[i] = el; }} className={`rounded-lg border bg-neutral-900/50 mb-3 hover:border-white/25 hover:bg-neutral-800/50 focus-within:border-gold/50 focus-within:bg-neutral-800 transition-all ${ newItemIndex === i || droppedIndex === i ? "border-gold/40 ring-1 ring-gold/20" : "border-white/10" } ${isHidden ? "hidden" : ""}`} >
handleGripMouseDown(e, i)} aria-label="Перетащить для сортировки" role="button" >
{collapsible && ( )}
{collapsible ? (
{renderItem(item, i, (updated) => updateItem(i, updated))}
) : (
{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]; const dragTitle = getItemTitle?.(item, i) || `#${i + 1}`; elements.push(
{ itemRefs.current[i] = el; }} className="rounded-lg border border-white/10 bg-neutral-900/50 mb-3 transition-colors" > {collapsible ? (
{dragTitle} {getItemBadge?.(item, i)}
) : ( <>
handleGripMouseDown(e, i)} aria-label="Перетащить для сортировки" role="button" >
{renderItem(item, i, (updated) => updateItem(i, updated))}
)}
); visualIndex++; } if (visualIndex === placeholderPos) { elements.push(
); } return elements; } return (
{(label || (collapsible && items.length > 1)) && (
{label ?

{label}

:
} {collapsible && items.length > 1 && (() => { const allCollapsed = collapsed.size >= items.length; return ( ); })()}
)} {addPosition === "top" && ( )}
{renderList()}
{addPosition === "bottom" && ( )} {/* Floating clone following cursor */} {mounted && dragIndex !== null && createPortal(
{collapsible && dragIndex !== null ? (getItemTitle?.(items[dragIndex], dragIndex) || "Перемещение...") : "Перемещение элемента..."}
, document.body )} { if (confirmDelete !== null) removeItem(confirmDelete); setConfirmDelete(null); }} onCancel={() => setConfirmDelete(null)} />
); }