import { Scissors } from "lucide-react"; import { useRef } from "react"; import { Avatar } from "./Avatar"; import { MAX_COUNT, parseCount } from "../domain/matchCounts"; export interface PlacePlayer { user_id: number; nickname: string; faction_name: string; avatar_url?: string | null; } export type CountField = "objectives" | "worlds"; /** Цели и миры на конец партии по user_id; отсутствие ключа или null — не указано. */ export type Counts = Record; type Target = | { type: "merge"; idx: number } | { type: "insert"; idx: number } | { type: "elim" }; interface DragState { blockIdx: number | null; // null — тянем блок из зоны выбывших elimId: number | null; el: HTMLElement; startY: number; rects: { idx: number; el: HTMLElement; rect: DOMRect }[]; elimRect: DOMRect | null; } /** * Редактор мест перетаскиванием (макет A2): вертикальный список блоков игроков, * верхний — 1-е место. Бросок между блоками — порядок, на середину чужого блока — * слияние в ничью (общая рамка и место, ✂ выносит обратно), в пунктирную зону — * выбывший. В каждом блоке — строка комментария об игроке и (если переданы counts) * цели и миры на конец партии; у выбывшего миров нет — поле заблокировано нулём. * * Контролируемый: blocks (упорядоченные группы user_id, длина >1 = ничья) и * eliminated живут у родителя. Во время drag DOM двигается напрямую (transform, * классы-подсветки) — setState только на отпускании, перерендер сбрасывает transform. */ export function PlaceEditor({ players, blocks, eliminated, comments, onChange, onComment, counts, onCount, }: { players: PlacePlayer[]; blocks: number[][]; eliminated: number[]; comments: Record; onChange: (blocks: number[][], eliminated: number[]) => void; onComment: (userId: number, text: string) => void; counts?: Counts; onCount?: (userId: number, field: CountField, value: number | null) => void; }) { const listRef = useRef(null); const elimRef = useRef(null); const indRef = useRef(null); const dragRef = useRef(null); const hintRef = useRef(null); const byId = new Map(players.map((p) => [p.user_id, p])); const placeOf = (i: number) => 1 + blocks.slice(0, i).reduce((sum, b) => sum + b.length, 0); // ─── Drag ─── const computeTarget = (pointerY: number, d: DragState): Target => { if (d.elimRect && pointerY > d.elimRect.top && pointerY < d.elimRect.bottom) { return { type: "elim" }; } for (const r of d.rects) { if (r.el === d.el) continue; const h = r.rect.height; if (pointerY > r.rect.top + h * 0.3 && pointerY < r.rect.bottom - h * 0.3) { return { type: "merge", idx: r.idx }; } } let idx = 0; for (const r of d.rects) { if (r.el === d.el) continue; if (pointerY > r.rect.top + r.rect.height / 2) idx = r.idx + 1; } return { type: "insert", idx }; }; const clearHints = () => { hintRef.current = null; if (indRef.current) indRef.current.style.display = "none"; listRef.current ?.querySelectorAll(".merge-target") .forEach((n) => n.classList.remove("merge-target")); elimRef.current?.classList.remove("highlight"); }; const showHint = (t: Target, d: DragState) => { // pointermove срабатывает десятки раз в секунду, а цель меняется намного реже: // без этой проверки на каждое движение шёл querySelectorAll и перестановка классов. const key = `${t.type}:${t.type === "elim" ? "" : t.idx}`; if (key === hintRef.current) return; clearHints(); hintRef.current = key; if (t.type === "elim") { elimRef.current?.classList.add("highlight"); return; } if (t.type === "merge") { d.rects.find((r) => r.idx === t.idx)?.el.classList.add("merge-target"); return; } const ind = indRef.current; const list = listRef.current; if (!ind || !list || d.rects.length === 0) return; const listTop = list.getBoundingClientRect().top; const others = d.rects.filter((r) => r.el !== d.el); if (others.length === 0) return; const y = t.idx === 0 ? others[0].rect.top - listTop - 5 : (d.rects.find((r) => r.idx === t.idx - 1)?.rect.bottom ?? 0) - listTop + 2; ind.style.top = `${y}px`; ind.style.display = "block"; }; const startDrag = ( e: React.PointerEvent, opts: { blockIdx?: number; elimId?: number }, ) => { e.preventDefault(); const grip = e.currentTarget; const el = (grip.closest("[data-idx]") ?? grip.closest(".rank-block")) as HTMLElement | null; const list = listRef.current; if (!el || !list) return; dragRef.current = { blockIdx: opts.blockIdx ?? null, elimId: opts.elimId ?? null, el, startY: e.clientY, rects: [...list.querySelectorAll(":scope > [data-idx]")].map((n) => ({ idx: Number(n.dataset.idx), el: n, rect: n.getBoundingClientRect(), })), elimRect: elimRef.current?.getBoundingClientRect() ?? null, }; el.classList.add("dragging"); grip.setPointerCapture(e.pointerId); }; const moveDrag = (e: React.PointerEvent) => { const d = dragRef.current; if (!d) return; d.el.style.transform = `translateY(${e.clientY - d.startY}px)`; showHint(computeTarget(e.clientY, d), d); }; const endDrag = (e: React.PointerEvent) => { const d = dragRef.current; if (!d) return; dragRef.current = null; clearHints(); d.el.classList.remove("dragging"); d.el.style.transform = ""; const t = computeTarget(e.clientY, d); const moved = d.elimId != null ? [d.elimId] : [...blocks[d.blockIdx!]]; const nextBlocks = blocks.map((b) => [...b]); let nextElim = [...eliminated]; const takeMoved = () => { if (d.elimId != null) nextElim = nextElim.filter((x) => x !== d.elimId); else nextBlocks.splice(d.blockIdx!, 1); }; if (t.type === "elim") { if (d.elimId != null) return; // уже там takeMoved(); nextElim.push(...moved); } else if (t.type === "merge") { const target = nextBlocks[t.idx]; // ссылка ДО удаления: индексы сместятся takeMoved(); target.push(...moved); } else { let idx = t.idx; if (d.elimId == null && d.blockIdx! < idx) idx -= 1; takeMoved(); nextBlocks.splice(idx, 0, moved); } onChange(nextBlocks, nextElim); }; const gripProps = (opts: { blockIdx?: number; elimId?: number }) => ({ className: "rank-grip", onPointerDown: (e: React.PointerEvent) => startDrag(e, opts), onPointerMove: moveDrag, onPointerUp: endDrag, // Браузер отменяет указатель (перехват прокрутки, входящий звонок) — без этого // блок остаётся с классом dragging и сдвигом: они выставлены в обход React, // и перерендер их не снимет. onPointerCancel: endDrag, }); // ✂ — вынуть игрока из ничьей отдельным блоком сразу после неё. const unlink = (userId: number) => { const bi = blocks.findIndex((b) => b.includes(userId)); if (bi < 0) return; const rest = blocks[bi].filter((x) => x !== userId); const next = blocks.map((b) => [...b]); next[bi] = rest; next.splice(bi + 1, 0, [userId]); if (rest.length === 0) next.splice(bi, 1); onChange(next, eliminated); }; // ─── Разметка ─── const playerRow = (userId: number, lead?: React.ReactNode, extra?: React.ReactNode) => { const p = byId.get(userId); if (!p) return null; return (
{lead}
{p.nickname}
{p.faction_name}
{extra}
); }; const countInput = (userId: number, field: CountField, label: string, locked = false) => ( ); const commentInput = (userId: number) => { const comment = ( onComment(userId, e.target.value)} /> ); if (!counts) return comment; return (
{comment} {countInput(userId, "objectives", "цели")} {countInput(userId, "worlds", "миры", eliminated.includes(userId))}
); }; return (
{blocks.map((ids, i) => { const place = placeOf(i); const badge = ( {place} ); if (ids.length === 1) { return (
{playerRow(ids[0],
⠿
, badge)} {commentInput(ids[0])}
); } return (
⠿
{ids.map((id) => (
{playerRow( id, undefined, , )} {commentInput(id)}
))}
ничья {badge}
); })}
Выбыли (перетащите сюда) — считаются последним местом
{eliminated.map((id) => (
{playerRow( id,
⠿
, ✕, )} {commentInput(id)}
))}
); }