214 lines
13 KiB
TypeScript
214 lines
13 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
}
|