From 86f5ccc9810fa6819d21dfde30750628dcb548cc Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 14 Oct 2026 22:55:12 +0400 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D1=85=D0=BE=D0=BB=D1=81=D1=82=20?= =?UTF-8?q?=D0=B4=D0=BE=D1=81=D0=BA=D0=B8=20=E2=80=94=20=D0=B4=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D0=B6=D0=BA=D0=B8,=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BA=D0=B8,=20=D1=81=D1=82=D1=80=D0=B5=D0=BB=D0=BA?= =?UTF-8?q?=D0=B8,=20=D0=BF=D0=B5=D1=80=D0=B5=D1=82=D0=B0=D1=81=D0=BA?= =?UTF-8?q?=D0=B8=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5,=20=D0=BA=D0=BE=D0=BB?= =?UTF-8?q?=D0=BE=D0=BD=D0=BA=D0=B8=20=D0=B4=D0=B0=D1=82=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/board/board.css | 161 ++++++++++++++++ web/src/board/components/BoardCanvas.test.tsx | 88 +++++++++ web/src/board/components/BoardCanvas.tsx | 178 ++++++++++++++++++ web/src/board/components/Card.tsx | 50 +++++ web/src/board/components/DatesBar.tsx | 44 +++++ web/src/board/components/Edges.tsx | 38 ++++ web/src/board/components/Lane.tsx | 52 +++++ 7 files changed, 611 insertions(+) create mode 100644 web/src/board/board.css create mode 100644 web/src/board/components/BoardCanvas.test.tsx create mode 100644 web/src/board/components/BoardCanvas.tsx create mode 100644 web/src/board/components/Card.tsx create mode 100644 web/src/board/components/DatesBar.tsx create mode 100644 web/src/board/components/Edges.tsx create mode 100644 web/src/board/components/Lane.tsx diff --git a/web/src/board/board.css b/web/src/board/board.css new file mode 100644 index 0000000..454be46 --- /dev/null +++ b/web/src/board/board.css @@ -0,0 +1,161 @@ +/* 3. Закреплённая шапка: название, файл, статистика, кнопка проблем, меню; под ней строка управления и легенда */ +.sticky{position:sticky;top:env(safe-area-inset-top,0px);z-index:10;background:var(--bg);padding-block:12px 8px;margin:0 0 8px;box-shadow:0 1px 0 var(--line)} +.top{display:flex;align-items:center;gap:6px 14px;flex-wrap:wrap;margin:0 0 8px;min-height:34px} +.src{display:inline-flex;align-items:baseline;gap:8px;min-width:0} +.file-name{font-size:14px;color:var(--muted);max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.stats{font-size:14px;color:var(--muted);font-variant-numeric:tabular-nums;white-space:nowrap} +.stats b{color:var(--fg);font-weight:500} +/* 5. Строка управления, предупреждения о файле, легенда-фильтр */ +.bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 8px} +.notice{display:flex;flex-wrap:wrap;gap:4px 16px;font-size:13.5px;color:var(--p-high);margin:0 0 6px} +.legend{display:flex;flex-wrap:wrap;align-items:center;gap:2px 12px;margin:0;font-size:13px;color:var(--muted)} +.legend span{display:inline-flex;align-items:center;gap:6px} +.legend .sep{width:1px;height:14px;background:var(--line)} +.legend .hint{margin-left:auto} +.lg{font:inherit;font-size:13px;color:var(--muted);background:none;border:0;padding:2px 5px;border-radius:4px;cursor:pointer;display:inline-flex;align-items:center;gap:6px} +.lg:hover,.lg:focus-visible{background:var(--soft);color:var(--fg);outline:none} +.lg.off{opacity:.45;text-decoration:line-through} +.sw{width:10px;height:10px;border-radius:2px;display:inline-block} +.ln{width:26px;height:0;border-top:2px solid var(--edge-strong);display:inline-block} +.ln.dash{border-top-style:dashed;border-top-color:var(--edge)} +.ln.hand{border-top-color:var(--guide)} +.ln.crit{border-top-width:4px;border-top-color:var(--guide)} +.out-sw{width:16px;height:11px;border:1.5px dashed var(--muted);border-radius:3px;display:inline-block} + +/* 6. Доска: прокрутка только по горизонтали (вертикальная — у страницы), дорожки по эпикам, карточки абсолютные */ +.stage{display:flex;gap:10px;align-items:stretch} +.wrap{flex:1 1 auto;min-width:0;overflow-x:auto;overflow-y:hidden;border:1px solid var(--line);border-radius:8px;background:var(--card);user-select:none;-webkit-user-select:none} +.board{position:relative;min-width:max(100%,max-content)} +svg.edges,svg.guides{position:absolute;inset:0;width:100%;height:100%;pointer-events:none} +svg.guides{z-index:3} +svg.guides line{stroke:var(--guide);stroke-width:1;stroke-dasharray:4 3} +.empty{padding:40px 16px;color:var(--muted);text-align:center} +.bar>span:empty{display:none} +#hubState{white-space:normal;min-width:0} +.login{max-width:560px;margin:48px auto;padding:20px 24px;background:var(--panel);border:1px solid var(--line);border-radius:8px} +.login h2{margin:0 0 8px;font-size:17px;font-weight:600} +.login p{margin:0 0 12px;font-size:14px;color:var(--muted)} +.login p.err{color:var(--p-crit)} +.login code{font-family:var(--font-mono);font-size:13px;color:var(--fg)} +.login form{display:flex;gap:8px;margin:0 0 12px} +.login input{flex:1;min-width:0;font:inherit;font-size:14px;height:34px;box-sizing:border-box;padding:0 10px;color:var(--fg);background:var(--bg);border:1px solid var(--line);border-radius:6px} +.login input:focus-visible{border-color:var(--accent);outline:none} +.lane{position:absolute;left:0;right:0;border-top:1px solid var(--line)} +.lane:nth-child(even of .lane){background:color-mix(in srgb,var(--soft) 45%,transparent)} +.lane.folded{background:color-mix(in srgb,var(--soft) 80%,transparent)} +.lane.target{box-shadow:inset 0 3px 0 var(--accent)} +.lane.target-after{box-shadow:inset 0 -3px 0 var(--accent)} +.lane.dragging{opacity:.6} +.lane-head{position:absolute;left:8px;top:8px;display:flex;align-items:center;gap:4px;z-index:1} +.fold{width:18px;height:18px;border:0;background:none;color:var(--muted);cursor:pointer;padding:0;border-radius:4px;display:inline-flex;align-items:center;justify-content:center;font-size:0} +.fold::before{content:"";border:5px solid transparent;border-top:6px solid currentColor;border-bottom:0;transition:transform .15s} +.fold:hover{background:var(--soft);color:var(--fg)} +.lane.folded .fold::before{transform:rotate(-90deg)} +.lane-name{font-size:12.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);font-weight:500;cursor:grab;padding:2px 4px;border-radius:4px;user-select:none;touch-action:none} +.lane-name:hover{background:var(--soft);color:var(--fg)} +.lane-count{display:none;font-size:12.5px;color:var(--muted);margin-left:4px} +.lane.folded .lane-count{display:inline} +.lane-tools{display:none;gap:2px} +.lane:hover .lane-tools{display:inline-flex} +.lane-tools button{font:inherit;font-size:12px;line-height:1;color:var(--muted);background:var(--card);border:1px solid var(--line);border-radius:4px;width:22px;height:22px;cursor:pointer;padding:0} +.lane-tools button:hover{border-color:var(--accent);color:var(--accent)} +.lane-tools button:disabled{opacity:.35;cursor:default} +/* режим «по датам»: колонки по плановым датам окончания, шапка колонки над дорожками; карточки двигаются внутри своей колонки */ +.dcol{position:absolute;top:0;border-left:1px solid var(--line);box-sizing:border-box;pointer-events:none} +.dcol:first-of-type{border-left:0} +.dcol.past{background:color-mix(in srgb,var(--p-crit) 5%,transparent)} +.dcol.today{background:color-mix(in srgb,var(--accent) 7%,transparent)} +/* полоса дат над доской: липнет под шапкой страницы, по горизонтали двигается вместе с доской (transform по scrollLeft) */ +.dates-bar{position:sticky;top:calc(var(--head-h,0px) + env(safe-area-inset-top,0px));z-index:9;background:var(--bg)} +.dates-in{overflow:hidden;border:1px solid var(--line);border-bottom:0;border-radius:8px 8px 0 0;background:var(--card);height:36px;box-sizing:border-box} +.dates-track{position:relative;height:100%} +.dlabel{position:absolute;top:9px;font-size:12.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--fg);font-weight:600;white-space:nowrap} +.dlabel .n{font-weight:400;color:var(--muted);margin-left:6px;font-variant-numeric:tabular-nums} +.dlabel.past{color:var(--p-crit)} +.dlabel.today{color:var(--accent)} +.dlabel.none{color:var(--muted);font-weight:500} +.stage.bydate .wrap{border-top-left-radius:0;border-top-right-radius:0} +.dsplit{position:absolute;top:0;bottom:0;width:11px;margin-left:-5px;cursor:col-resize;z-index:1} +.dsplit::after{content:"";position:absolute;left:5px;top:6px;bottom:6px;width:1px;background:var(--line)} +.dsplit:hover::after,.dsplit.on::after{background:var(--accent);width:3px;left:4px;border-radius:2px} +.dates-in.resizing{cursor:col-resize} +/* горизонтальная прокрутка доски, прилипшая к низу окна: полоса всегда видна, скроллбар нарисован явно (на macOS системный прячется) */ +.hscroll{position:sticky;bottom:0;z-index:8;overflow-x:scroll;overflow-y:hidden;height:14px;background:var(--bg);padding-top:2px;box-sizing:border-box;scrollbar-width:thin;scrollbar-color:var(--edge) var(--soft)} +.hscroll>div{height:1px} +.hscroll::-webkit-scrollbar{height:12px} +.hscroll::-webkit-scrollbar-track{background:var(--soft);border-radius:6px} +.hscroll::-webkit-scrollbar-thumb{background:var(--edge);border-radius:6px;border:2px solid var(--soft)} +.hscroll::-webkit-scrollbar-thumb:hover{background:var(--accent)} +/* карточка: текст выделяется, тянуть можно только за рамку (появляется при наведении) и за уголок */ +.card{position:absolute;width:240px;height:120px;box-sizing:border-box;background:var(--card);border:1px solid var(--line);border-radius:6px;padding:12px 14px;display:flex;flex-direction:column;gap:4px;overflow:hidden;cursor:default;touch-action:none} +.card:hover{border-color:var(--accent)} +.card.out{border-style:dashed;opacity:.7} +.card.crit{box-shadow:0 0 0 2px var(--guide)} +.card.focus{box-shadow:0 0 0 2px var(--accent)} +.card.drag{cursor:grabbing;border-color:var(--accent);box-shadow:0 6px 18px rgba(0,0,0,.18);z-index:2} +.card .title,.card .state,.card .who,.card .meta,.card .key,.card .sprint-tag{user-select:text;-webkit-user-select:text;cursor:text;touch-action:auto} +.card .key{cursor:pointer} +.card .meta{cursor:default} +.card .grip{position:absolute;background:var(--accent);opacity:0;cursor:grab;z-index:1;transition:opacity .12s} +.card .grip.t{left:0;right:0;top:0;height:8px;border-radius:5px 5px 0 0} +.card .grip.b{left:0;right:14px;bottom:0;height:8px;border-radius:0 0 0 5px} +.card .grip.l{left:0;top:0;bottom:0;width:8px;border-radius:5px 0 0 5px} +.card .grip.r{right:0;top:0;bottom:14px;width:8px;border-radius:0 5px 0 0} +.card:hover .grip,.card.drag .grip{opacity:.85} +.card.drag .grip{cursor:grabbing} +.card .rsz{position:absolute;right:0;bottom:0;width:14px;height:14px;cursor:nwse-resize;background:linear-gradient(135deg,transparent 50%,var(--line) 50%);border-bottom-right-radius:5px;z-index:2} +.card:hover .rsz{background:linear-gradient(135deg,transparent 50%,var(--accent) 50%)} +.head{display:flex;align-items:center;justify-content:space-between;gap:6px;min-width:0} +.key{font-family:var(--font-mono);font-size:13.5px;font-weight:500;color:var(--accent);text-decoration:none;flex:0 0 auto} +.sprint-tag{font-size:11px;line-height:1;padding:4px 7px;border-radius:10px;background:var(--soft);color:var(--muted);max-width:55%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:0 1 auto} +.sprint-tag.sel{background:var(--accent);color:var(--accent-fg)} +.sprint-tag.none{background:transparent;border:1px dashed var(--line)} +.title{font-size:14.5px;line-height:19px;min-width:0;flex:1 1 auto;overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2} +.meta{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--muted);white-space:nowrap;flex:0 0 auto} +.kind{display:inline-flex;align-items:center;gap:4px} +.kind i{width:8px;height:8px;border-radius:2px;display:inline-block} +.prio{font-weight:600} +/* состояние задачи (статус, срок, исполнитель) — отдельный блок под чертой, основным цветом: это главное после названия */ +.state,.who{font-size:13px;flex:0 0 auto;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--fg)} +.state{display:flex;align-items:center;gap:6px;margin-top:4px;padding-top:7px;border-top:1px solid var(--line)} +.status{font-size:12.5px;line-height:1;padding:4px 7px;border-radius:10px;background:var(--soft);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.due{font-variant-numeric:tabular-nums;font-weight:600;flex:0 0 auto} +.who::before{content:"";display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--accent);margin-right:5px;vertical-align:1px} +.who.none{color:var(--muted);font-style:italic} +.who.none::before{background:transparent;border:1px solid var(--muted)} + +/* 7. Панель проблем справа от доски, липнет под шапкой и прокручивается внутри себя */ +.drawer{flex:0 0 340px;max-width:40%;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:10px 12px;overflow:auto;font-size:14px;box-sizing:border-box;align-self:flex-start;position:sticky;top:calc(var(--head-h,0px) + 8px);max-height:calc(100vh - var(--head-h,0px) - 24px)} +.drawer-head{display:flex;justify-content:space-between;align-items:center;gap:8px} +.drawer-head b{font-weight:600} +.drawer h4{font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);font-weight:500;margin:12px 0 4px} +.drawer ul{margin:0;padding-left:18px} +.drawer li{margin:3px 0} +.drawer .k{font-family:var(--font-mono);font-size:13px;color:var(--accent);text-decoration:none} +.drawer .ok{color:var(--k-test);margin:8px 0 0} +.drawer .why{color:var(--muted)} + +/* 8. Экспорт JSON и таблица связей (включаются из меню «⋯») */ +.export{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:10px 12px;margin:0 0 8px;display:flex;flex-direction:column;gap:8px} +.export textarea{font-family:var(--font-mono);font-size:11.5px;width:100%;box-sizing:border-box;height:180px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--fg);padding:8px;resize:vertical} +details{margin-top:12px} +summary{cursor:pointer;color:var(--muted);font-size:14px} +.tbl{max-width:100%;overflow-x:auto;margin-top:8px} +table{border-collapse:collapse;font-size:14px;min-width:520px} +th,td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--line);vertical-align:top} +th{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);font-weight:500} +td.k{font-family:var(--font-mono);font-size:12px;white-space:nowrap} + +/* 9. Движение и узкие экраны */ +@media (prefers-reduced-motion:no-preference){.card{transition:border-color .15s}} +@media (prefers-reduced-motion:reduce){.card .grip,.fold::before{transition:none}} +@media (max-width:900px){.stage{flex-direction:column}.drawer{flex:none;max-width:100%;max-height:none;position:static}} +@media (max-width:640px){.sticky{position:static}.dates-bar{position:static}.file-name{max-width:100%}} + +/* «Открыть карточку» в заголовке карточки (R-1) */ +.card .open-card{margin-left:6px;color:var(--muted);text-decoration:none;font-size:13px} +.card .open-card:hover,.card .open-card:focus-visible{color:var(--accent);outline:none} +/* слой измерения: карточки шириной CW с высотой по содержимому, невидим */ +.measure{position:absolute;left:-100000px;top:0;visibility:hidden;pointer-events:none} +.measure .card{position:static} +.measure .card .title{-webkit-line-clamp:unset} diff --git a/web/src/board/components/BoardCanvas.test.tsx b/web/src/board/components/BoardCanvas.test.tsx new file mode 100644 index 0000000..909f536 --- /dev/null +++ b/web/src/board/components/BoardCanvas.test.tsx @@ -0,0 +1,88 @@ +import { fireEvent, screen, within } from "@testing-library/react"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import type { BoardSnapshot } from "../../api/types.ts"; +import { renderWithProviders } from "../../test/render.tsx"; +import { computeVisible } from "../analysis/visible.ts"; +import { dictsFromSettings } from "../dicts.ts"; +import settings from "../__fixtures__/settings.json"; +import { fromHub } from "../model/fromHub.ts"; +import { noFilters } from "../types.ts"; +import { BoardCanvas, type LayoutStore } from "./BoardCanvas.tsx"; + +const fx = (name: string) => readFileSync(join(import.meta.dirname, "..", "__fixtures__", name), "utf8"); +const { dicts } = dictsFromSettings(settings); +const model = fromHub(JSON.parse(fx("hub-basic.board.json")) as BoardSnapshot, dicts); +const store: LayoutStore = { laneOrder: [], folded: new Set(), colWidths: {}, version: 0 }; + +function draw(over: Partial[0]> = {}) { + const f = { ...noFilters(), ...(over.byDate ? { byDate: true } : {}) }; + const vis = computeVisible(model, f, [], "2026-10-14"); + const props = { vis, byDate: false, snap: true, today: "2026-10-14", store, sprintSel: new Set(), emptyText: "пусто", + onToggleLane: vi.fn(), onMoveLane: vi.fn(), onColWidth: vi.fn(), ...over }; + renderWithProviders(); + return props; +} +const board = () => document.querySelector(".board") as HTMLElement; + +describe("BoardCanvas", () => { + it("дорожки по эпикам и карточки задач с заглушкой", () => { + draw(); + const lanes = [...board().querySelectorAll(".lane-name")].map((e) => e.textContent); + expect(lanes).toEqual(["Гудвилл", "Тестовый эпик", "AURORA-9999", "Без эпика"]); + expect(board().querySelectorAll(".card[data-key]")).toHaveLength(9); + }); + + it("заглушка соседа вне зеркала — на служебной дорожке с подписью", () => { + const vis = computeVisible(model, { ...noFilters(), who: new Set(["Иванов Иван Иванович"]), neighbors: true }, [], "2026-10-14"); + draw({ vis }); + expect([...board().querySelectorAll(".lane-name")].map((e) => e.textContent)).toContain("Нет в зеркале"); + expect(within(board()).getByText("нет в зеркале")).toBeInTheDocument(); + }); + + it("ключ ведёт в Jira, ⓘ — в карточку приложения; у заглушки ⓘ нет", () => { + draw({ vis: computeVisible(model, { ...noFilters(), who: new Set(["Иванов Иван Иванович"]), neighbors: true }, [], "2026-10-14") }); + const card = board().querySelector('.card[data-key="AURORA-9001"]') as HTMLElement; + expect(within(card).getByRole("link", { name: "AURORA-9001" })).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/browse/AURORA-9001"); + expect(within(card).getByRole("link", { name: "Открыть карточку AURORA-9001" })).toHaveAttribute("href", "/issues/AURORA-9001"); + const stub = board().querySelector('.card[data-key="AURORA-9500"]') as HTMLElement; + expect(within(stub).queryByRole("link", { name: /Открыть карточку/ })).toBeNull(); + }); + + it("стрелки: по одной на видимую связь, «связанные» пунктиром, блокирующие со стрелкой", () => { + draw(); + const paths = [...document.querySelectorAll("svg.edges > path")]; + expect(paths).toHaveLength(6); + expect(paths.filter((p) => p.getAttribute("stroke-dasharray") === "5 4")).toHaveLength(1); + expect(paths.filter((p) => p.getAttribute("marker-end"))).toHaveLength(5); + }); + + it("свёртка и перестановка дорожек — через колбэки", () => { + const p = draw(); + const lane = [...board().querySelectorAll(".lane")].find((l) => l.textContent?.includes("Гудвилл")) as HTMLElement; + fireEvent.click(within(lane).getByTitle("Свернуть или развернуть дорожку")); + expect(p.onToggleLane).toHaveBeenCalledWith("Гудвилл"); + fireEvent.click(within(lane).getByTitle("Ниже")); + expect(p.onMoveLane).toHaveBeenCalledWith("Гудвилл", 1); + }); + + it("свёрнутая дорожка прячет свои карточки", () => { + draw({ store: { ...store, folded: new Set(["Гудвилл"]) } }); + expect(board().querySelector('.card[data-key="AURORA-9004"]')).toBeNull(); + expect(board().querySelector(".lane.folded")).not.toBeNull(); + }); + + it("«по датам»: колонки и подписи дат, двойной щелчок по границе — автоматическая ширина", () => { + const p = draw({ byDate: true }); + expect(board().querySelectorAll(".dcol").length).toBeGreaterThan(0); + expect(screen.getByText("16.10")).toBeInTheDocument(); + fireEvent.doubleClick(document.querySelector(".dsplit") as HTMLElement); + expect(p.onColWidth).toHaveBeenCalledWith(expect.any(String), null); + }); + + it("пустая выборка — текст причины", () => { + draw({ vis: computeVisible(model, { ...noFilters(), who: new Set(["Никто"]) }, [], "2026-10-14"), emptyText: "Фильтры не оставили" }); + expect(screen.getByText("Фильтры не оставили")).toBeInTheDocument(); + }); +}); diff --git a/web/src/board/components/BoardCanvas.tsx b/web/src/board/components/BoardCanvas.tsx new file mode 100644 index 0000000..35d0565 --- /dev/null +++ b/web/src/board/components/BoardCanvas.tsx @@ -0,0 +1,178 @@ +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; + 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, 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 }; }); + + // перетаскивание за рамку и растяжка за уголок (строки 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).map(([k, box]) => ( + + ))} + + + + )} +
+
+ {children} +
+