sdlc-hub/web/src/board/components/BoardCanvas.tsx
Andrey Epifancev 80cff5bcfb Доска: закрытые задачи и их связи приглушены (#57)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:46:22 +04:00

214 lines
13 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.

import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type PointerEvent, type ReactNode } from "react";
import { isClosed } from "../../api/closed.ts";
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 { readBoardScroll, saveBoardScroll } from "../scrollMemory.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<string>; colWidths: Record<string, number>; version: number }
export interface BoardCanvasProps {
vis: Visible; byDate: boolean; snap: boolean; today: string; store: LayoutStore; sprintSel: Set<string>;
emptyText: string;
closedStatuses?: Set<string>; // статусы Jira закрытых задач: их карточки и стрелки приглушены (hide-closed), на модель не влияют
saveRef?: { current: (() => void) | null };
onToggleLane(name: string): void;
onMoveLane(name: string, to: number): void;
onColWidth(date: string, w: number | null): void;
children?: ReactNode;
}
const NONE = new Set<string>();
export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyText, closedStatuses = NONE, onToggleLane, onMoveLane, onColWidth, saveRef, children }: BoardCanvasProps) {
const boardRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLDivElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const hscrollRef = useRef<HTMLDivElement>(null);
const datesInRef = useRef<HTMLDivElement>(null);
const [layout, setLayout] = useState<Layout | null>(null);
const [rel, setRel] = useState<Record<string, RelBox>>({});
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);
const closedKeys = useMemo(() => new Set(vis.nodes.filter((n) => isClosed(n.status, closedStatuses)).map((n) => n.key)), [vis.nodes, closedStatuses]);
// раскладка как render() links.html: при новой выборке, режиме и ширинах колонок — заново, с сохранённой из хранилища
useLayoutEffect(() => {
if (!vis.nodes.length) { setLayout(null); setRel({}); return; }
const els = new Map<string, HTMLElement>();
measureRef.current?.querySelectorAll<HTMLElement>(".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<HTMLElement>(".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<Placed | null>(() => (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<HTMLDivElement>, 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");
t.setPointerCapture(ev.pointerId); // захват на нажатом элементе, а не на карточке: иначе dblclick по уголку уходит на карточку (Chromium)
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<HTMLElement>(`.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]);
// M-3: прокрутка доски переживает переход в карточку задачи и обратно (R-1); восстанавливаем, когда карточки расставлены
const restore = useRef(readBoardScroll());
useLayoutEffect(() => {
const s = restore.current;
if (!s || !placed) return;
restore.current = null;
window.scrollTo(s.x, s.y);
if (wrapRef.current) wrapRef.current.scrollLeft = s.left;
syncScroll();
}, [placed, syncScroll]);
// запоминаем по событиям прокрутки, а не при размонтировании: к моменту ухода браузер может уже сжать прокрутку
// под короткую страницу карточки
useEffect(() => {
const w = wrapRef.current;
// после ухода со страницы браузер зажимает прокрутку короткой страницы в 0 и шлёт scroll: доску уже убрали из DOM —
// такое событие не должно затирать запомненное
const save = () => { if (w && !w.isConnected) return; saveBoardScroll({ x: window.scrollX, y: window.scrollY, left: w?.scrollLeft ?? 0 }); };
window.addEventListener("scroll", save, { passive: true });
w?.addEventListener("scroll", save, { passive: true });
return () => { window.removeEventListener("scroll", save); w?.removeEventListener("scroll", save); };
}, []);
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 && (
<DatesBar cols={layout.dateCols} width={placed.width} scrollLeft={scroll.left} wrapW={scroll.wrapW} inRef={datesInRef} onColWidth={onColWidth} />
)}
<div className={"stage" + (byDate && !empty ? " bydate" : "")}>
<div className="wrap" ref={wrapRef} onScroll={syncScroll}>
<div className={"board" + (byDate ? " bydate" : "")} ref={boardRef}
style={placed && !empty ? { width: placed.width, height: placed.height } : { width: "100%", height: "auto" }}>
{empty && <div className="empty">{emptyText}</div>}
{placed && !empty && (
<>
{layout?.dateCols?.map((c) => (
<div key={c.date} className={"dcol" + (c.cls ? " " + c.cls : "")} style={{ left: c.x0, width: c.w, height: placed.height }} />
))}
{placed.lanes.map((g, i) => (
<Lane key={g.name} g={g} index={i} last={placed.lanes.length - 1} count={counts[g.name] ?? 0} lanes={placed.lanes}
boardTop={boardTop} onToggle={onToggleLane} onMove={onMoveLane}
targetClass={dragTarget && dragTarget.to === i ? (i < dragTarget.from ? "target" : 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]) => (
<Card key={k} node={byKey[k]} box={box} rel={rel[k]} multiSprint={multiSprint} sprintSel={sprintSel}
closed={closedKeys.has(k)} onPointerDown={onCardDown} onAutoSize={onAutoSize} />
))}
<Edges links={vis.links} placed={placed} closedKeys={closedKeys} />
<Guides x={guides?.x ?? null} y={guides?.y ?? null} width={placed.width} height={placed.height} />
</>
)}
</div>
</div>
{children}
</div>
<div className="hscroll" ref={hscrollRef} hidden={!scroll.wide}
style={{ width: scroll.wrapW }}
onScroll={(e) => { const w = wrapRef.current; if (w && Math.abs(e.currentTarget.scrollLeft - w.scrollLeft) > 1) w.scrollLeft = e.currentTarget.scrollLeft; }}>
<div style={{ width: scroll.scrollW, height: 1 }} />
</div>
<div className="measure" ref={measureRef} aria-hidden="true">
{vis.nodes.map((n) => <Card key={n.key} node={n} multiSprint={measureMulti} sprintSel={sprintSel} />)}
</div>
</>
);
}