Files
blackheart-website/src/app/admin/_components/ArrayEditor.tsx
diana.dolgolyova 27ef3bd694 fix: setState-during-render error + hover highlight on cards
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>
2026-03-11 19:07:16 +03:00

288 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}