Заглушка модели в useBoardSource стабильна между рендерами, readTheme не возвращает null. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
186 lines
10 KiB
TypeScript
186 lines
10 KiB
TypeScript
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<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;
|
||
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<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);
|
||
|
||
// раскладка как 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");
|
||
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<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]);
|
||
|
||
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}
|
||
onPointerDown={onCardDown} onAutoSize={onAutoSize} />
|
||
))}
|
||
<Edges links={vis.links} placed={placed} />
|
||
<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>
|
||
</>
|
||
);
|
||
}
|