import { useCallback, useLayoutEffect, useMemo, useRef, useState, type PointerEvent, type ReactNode } from "react"; import { CW, GRID, MINH, MINW, TOP } from "../constants.ts"; import { alignEdges } from "../layout/align.ts"; import { clampToCol, computeLayout, measuredHeight, place, type Layout, type Placed, type RelBox } from "../layout/layout.ts"; import { saveLayout, savedLayout } from "../storage.ts"; import type { Visible } from "../types.ts"; import { Card } from "./Card.tsx"; import { DatesBar } from "./DatesBar.tsx"; import { Edges, Guides } from "./Edges.tsx"; import { Lane } from "./Lane.tsx"; import "../board.css"; export interface LayoutStore { laneOrder: string[]; folded: Set; colWidths: Record; version: number } export interface BoardCanvasProps { vis: Visible; byDate: boolean; snap: boolean; today: string; store: LayoutStore; sprintSel: Set; emptyText: string; saveRef?: { current: (() => void) | null }; onToggleLane(name: string): void; onMoveLane(name: string, to: number): void; onColWidth(date: string, w: number | null): void; children?: ReactNode; } export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyText, onToggleLane, onMoveLane, onColWidth, saveRef, children }: BoardCanvasProps) { const boardRef = useRef(null); const measureRef = useRef(null); const wrapRef = useRef(null); const hscrollRef = useRef(null); const datesInRef = useRef(null); const [layout, setLayout] = useState(null); const [rel, setRel] = useState>({}); const [guides, setGuides] = useState<{ x: number | null; y: number | null } | null>(null); const [scroll, setScroll] = useState({ left: 0, wide: false, wrapW: 0, scrollW: 0 }); const [dragTarget, setDragTarget] = useState<{ from: number; to: number } | null>(null); // раскладка как render() links.html: при новой выборке, режиме и ширинах колонок — заново, с сохранённой из хранилища useLayoutEffect(() => { if (!vis.nodes.length) { setLayout(null); setRel({}); return; } const els = new Map(); measureRef.current?.querySelectorAll(".card[data-key]").forEach((el) => els.set(el.dataset.key!, el)); const measure = (key: string) => { const el = els.get(key), oh = el?.offsetHeight ?? 0; return { h: measuredHeight(oh), other: oh - (el?.querySelector(".title")?.offsetHeight ?? 0) }; }; const lay = computeLayout(vis, { byDate, colWidths: store.colWidths, saved: savedLayout(byDate), today, measure }); setLayout(lay); setRel(lay.rel); }, [vis, byDate, store.colWidths, store.version, today]); const placed = useMemo(() => (layout ? place(layout, rel, store.folded) : null), [layout, rel, store.folded]); const live = useRef({ placed, rel, layout }); useLayoutEffect(() => { live.current = { placed, rel, layout }; }); // экспорт JSON сохраняет текущую раскладку (в том числе автоматическую) перед выгрузкой useLayoutEffect(() => { if (!saveRef) return; saveRef.current = () => { const { rel: r, layout: lay } = live.current; if (lay) saveLayout(r, lay.colBounds, byDate); }; return () => { saveRef.current = null; }; }, [saveRef, byDate]); // перетаскивание за рамку и растяжка за уголок (строки 753–778): направляющие, сетка, не выше своей дорожки const onCardDown = useCallback((ev: PointerEvent, key: string) => { const t = ev.target as HTMLElement; if (t.closest("a")) return; const resizing = !!t.closest(".rsz"); if (!resizing && !t.closest(".grip")) return; // остальное — текст, его можно выделять const { placed: pl, layout: lay } = live.current; if (!pl || !lay || !pl.pos[key]) return; ev.preventDefault(); const el = ev.currentTarget; el.classList.add("drag"); el.setPointerCapture(ev.pointerId); const q0 = { ...pl.pos[key] }, start = { x: ev.clientX, y: ev.clientY }; let cur = live.current.rel; const move = (e: globalThis.PointerEvent) => { const dx = e.clientX - start.x, dy = e.clientY - start.y; const others = live.current.placed!.pos, r0 = cur[key]; let next: RelBox; if (resizing) { const g = alignEdges(others, key, { x: q0.x, y: q0.y, w: Math.max(MINW, q0.w + dx), h: Math.max(MINH, q0.h + dy) }, true); let { w, h } = g; if (snap) { w = Math.round(w / GRID) * GRID; h = Math.round(h / GRID) * GRID; } const cc = clampToCol(lay.colBounds, key, r0.x, Math.max(MINW, w)); next = { ...r0, w: cc.w, h: Math.max(MINH, h), auto: false }; setGuides({ x: g.guideX, y: g.guideY }); } else { const g = alignEdges(others, key, { x: Math.max(0, q0.x + dx), y: Math.max(0, q0.y + dy), w: q0.w, h: q0.h }, false); let { x, y } = g; if (snap && !g.snappedX) x = Math.round(x / GRID) * GRID; if (snap && !g.snappedY) y = Math.round(y / GRID) * GRID; const laneY = live.current.placed!.lanes[lay.laneOf[key]].y; next = { ...r0, x: clampToCol(lay.colBounds, key, Math.max(0, x), r0.w).x, ry: Math.max(TOP, y - laneY) }; setGuides({ x: g.guideX, y: g.guideY }); } cur = { ...cur, [key]: next }; setRel(cur); }; const stop = () => { el.classList.remove("drag"); el.removeEventListener("pointermove", move); el.removeEventListener("pointerup", stop); el.removeEventListener("pointercancel", stop); setGuides(null); saveLayout(cur, lay.colBounds, byDate); }; el.addEventListener("pointermove", move); el.addEventListener("pointerup", stop); el.addEventListener("pointercancel", stop); }, [snap, byDate]); // двойной щелчок по уголку: ширина CW (в пределах колонки даты), высота по содержимому (строка 750) const onAutoSize = useCallback((key: string) => { const { layout: lay, rel: r } = live.current; if (!lay) return; const el = measureRef.current?.querySelector(`.card[data-key="${CSS.escape(key)}"]`); const cc = clampToCol(lay.colBounds, key, r[key].x, CW); const next = { ...r, [key]: { ...r[key], x: cc.x, w: cc.w, auto: true, h: measuredHeight(el?.offsetHeight ?? 0) } }; setRel(next); saveLayout(next, lay.colBounds, byDate); }, [byDate]); // полоса дат и нижняя полоса прокрутки повторяют горизонтальную прокрутку доски (строки 705–714) const syncScroll = useCallback(() => { const w = wrapRef.current; if (!w) return; setScroll({ left: w.scrollLeft, wide: w.scrollWidth > w.clientWidth + 1, wrapW: w.offsetWidth, scrollW: w.scrollWidth }); const h = hscrollRef.current; if (h && Math.abs(h.scrollLeft - w.scrollLeft) > 1) h.scrollLeft = w.scrollLeft; }, []); useLayoutEffect(() => { syncScroll(); const w = wrapRef.current; if (!w || typeof ResizeObserver === "undefined") return; const ro = new ResizeObserver(syncScroll); ro.observe(w); return () => ro.disconnect(); }, [syncScroll, placed]); const empty = !vis.nodes.length; const multiSprint = layout?.multiSprint ?? false; // слой измерения рисует метку спринта так же, как доска: она влияет на высоту карточки (строка 661) const measureMulti = new Set(vis.nodes.filter((x) => !x.stub).map((x) => x.sprint)).size > 1; const boardTop = () => boardRef.current?.getBoundingClientRect().top ?? 0; const counts = Object.fromEntries(vis.lanes.map((l) => [l.name, l.keys.length])); const byKey = Object.fromEntries(vis.nodes.map((n) => [n.key, n])); return ( <> {byDate && !empty && layout?.dateCols && placed && ( )}
{empty &&
{emptyText}
} {placed && !empty && ( <> {layout?.dateCols?.map((c) => (
))} {placed.lanes.map((g, i) => ( dragTarget.from ? "target-after" : "") : ""} onDragTarget={(to) => setDragTarget(to === null ? null : { from: i, to })} /> ))} {Object.entries(placed.pos).filter(([k]) => byKey[k]).map(([k, box]) => ( ))} )}
{children}