feat(web): холст доски — дорожки, карточки, стрелки, перетаскивание, колонки дат (#21)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3c2c75e8fe
commit
86f5ccc981
161
web/src/board/board.css
Normal file
161
web/src/board/board.css
Normal file
@ -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}
|
||||
88
web/src/board/components/BoardCanvas.test.tsx
Normal file
88
web/src/board/components/BoardCanvas.test.tsx
Normal file
@ -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<Parameters<typeof BoardCanvas>[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<string>(), emptyText: "пусто",
|
||||
onToggleLane: vi.fn(), onMoveLane: vi.fn(), onColWidth: vi.fn(), ...over };
|
||||
renderWithProviders(<BoardCanvas {...props} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
178
web/src/board/components/BoardCanvas.tsx
Normal file
178
web/src/board/components/BoardCanvas.tsx
Normal file
@ -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<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;
|
||||
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<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 }; });
|
||||
|
||||
// перетаскивание за рамку и растяжка за уголок (строки 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} 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).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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
50
web/src/board/components/Card.tsx
Normal file
50
web/src/board/components/Card.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { memo, type PointerEvent } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { CW, JIRA_URL, KIND, PRIO } from "../constants.ts";
|
||||
import { lineClamp, type Rect, type RelBox } from "../layout/layout.ts";
|
||||
import { fmtDate, shortName, unassigned } from "../model/util.ts";
|
||||
import type { VisNode } from "../types.ts";
|
||||
|
||||
export interface CardProps {
|
||||
node: VisNode;
|
||||
box?: Rect; // нет — карточка слоя измерения (ширина CW, высота по содержимому)
|
||||
rel?: RelBox;
|
||||
multiSprint: boolean;
|
||||
sprintSel: Set<string>;
|
||||
onPointerDown?: (ev: PointerEvent<HTMLDivElement>, key: string) => void;
|
||||
onAutoSize?: (key: string) => void;
|
||||
}
|
||||
|
||||
export const Card = memo(function Card({ node: n, box, rel, multiSprint, sprintSel, onPointerDown, onAutoSize }: CardProps) {
|
||||
const cls = "card" + (n.dim || n.stub ? " out" : "") + (n.crit ? " crit" : "") + (n.focus ? " focus" : "");
|
||||
const style = box ? { left: box.x, top: box.y, width: box.w, height: box.h } : { width: CW };
|
||||
const showTag = !n.stub && (multiSprint || n.dim); // метка спринта — если спринтов на доске больше одного или это сосед
|
||||
return (
|
||||
<div className={cls} title={n.title} style={style} data-key={n.key}
|
||||
onPointerDown={onPointerDown ? (e) => onPointerDown(e, n.key) : undefined}>
|
||||
<div className="head">
|
||||
<a className="key" href={`${JIRA_URL}/browse/${n.key}`} target="_blank" rel="noopener">{n.key}</a>
|
||||
{!n.stub && <Link className="open-card" to={`/issues/${n.key}`} title="Открыть карточку задачи"
|
||||
aria-label={`Открыть карточку ${n.key}`}>ⓘ</Link>}
|
||||
{showTag && (n.sprint
|
||||
? <span className={"sprint-tag" + (sprintSel.has(n.sprint) ? " sel" : "")} title={n.sprint}>{n.sprint.replace(/^AWPCA_/, "")}</span>
|
||||
: <span className="sprint-tag none">без спринта</span>)}
|
||||
</div>
|
||||
<div className="title" style={{ WebkitLineClamp: box && rel ? lineClamp(rel) : "unset" }}>{n.title}</div>
|
||||
<div className="meta">
|
||||
<span className="kind"><i style={{ background: `var(--k-${KIND[n.kind] || "org"})` }} />{n.kind}</span>
|
||||
<span className="prio" style={{ color: `var(--p-${PRIO[n.prio] || "mid"})` }}>{n.prio}</span>
|
||||
</div>
|
||||
{(n.status || n.due) && (
|
||||
<div className="state">
|
||||
{n.status && <span className="status" title={n.status}>{n.status}</span>}
|
||||
{n.due && <span className="due">до {fmtDate(n.due)}</span>}
|
||||
</div>
|
||||
)}
|
||||
{!n.stub && <div className={"who" + (unassigned(n.who) ? " none" : "")} title={n.who}>{shortName(n.who)}</div>}
|
||||
<div className="rsz" title="Потянуть — изменить размер; двойной щелчок — размер по содержимому"
|
||||
onDoubleClick={onAutoSize ? () => onAutoSize(n.key) : undefined} />
|
||||
<div className="grip t" title="Тянуть за рамку" /><div className="grip b" /><div className="grip l" /><div className="grip r" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
44
web/src/board/components/DatesBar.tsx
Normal file
44
web/src/board/components/DatesBar.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { Fragment, useRef, type PointerEvent, type RefObject } from "react";
|
||||
import { COLW, GRID } from "../constants.ts";
|
||||
import { dateText, dateTitle, type DateCol } from "../layout/layout.ts";
|
||||
|
||||
// липкая полоса подписей дат над доской: сдвиг — как у горизонтальной прокрутки доски; ручка границы меняет ширину колонки
|
||||
export function DatesBar({ cols, width, scrollLeft, inRef, onColWidth }: {
|
||||
cols: DateCol[]; width: number; scrollLeft: number; inRef: RefObject<HTMLDivElement | null>;
|
||||
onColWidth(date: string, w: number | null): void;
|
||||
}) {
|
||||
const raf = useRef(0);
|
||||
const down = (ev: PointerEvent<HTMLDivElement>, c: DateCol) => {
|
||||
ev.preventDefault();
|
||||
const handle = ev.currentTarget, x0 = ev.clientX;
|
||||
handle.classList.add("on"); inRef.current?.classList.add("resizing");
|
||||
let last = c.w;
|
||||
const apply = (w: number) => onColWidth(c.date, w <= COLW ? null : Math.round(w / GRID) * GRID);
|
||||
const move = (e: globalThis.PointerEvent) => {
|
||||
last = Math.max(COLW, c.w + e.clientX - x0);
|
||||
if (!raf.current) raf.current = requestAnimationFrame(() => { raf.current = 0; apply(last); });
|
||||
};
|
||||
const stop = () => {
|
||||
window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", stop); window.removeEventListener("pointercancel", stop);
|
||||
handle.classList.remove("on"); inRef.current?.classList.remove("resizing");
|
||||
apply(last);
|
||||
};
|
||||
window.addEventListener("pointermove", move); window.addEventListener("pointerup", stop); window.addEventListener("pointercancel", stop);
|
||||
};
|
||||
const cls = (c: DateCol) => (c.cls ? " " + c.cls : "");
|
||||
return (
|
||||
<div className="dates-bar">
|
||||
<div className="dates-in" ref={inRef}>
|
||||
<div className="dates-track" style={{ width, transform: `translateX(${-scrollLeft}px)` }}>
|
||||
{cols.map((c) => (
|
||||
<Fragment key={c.date}>
|
||||
<span className={"dlabel" + cls(c)} style={{ left: c.x0 + 8 }} title={dateTitle(c)}>{dateText(c)}<span className="n">{c.count}</span></span>
|
||||
<div className="dsplit" style={{ left: c.x0 + c.w }} title="Тянуть — ширина колонки; двойной щелчок — автоматическая"
|
||||
onPointerDown={(e) => down(e, c)} onDoubleClick={() => onColWidth(c.date, null)} />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
web/src/board/components/Edges.tsx
Normal file
38
web/src/board/components/Edges.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import type { Placed } from "../layout/layout.ts";
|
||||
import { edgePath } from "../layout/edges.ts";
|
||||
import { RELATED } from "../constants.ts";
|
||||
import type { VisLink } from "../types.ts";
|
||||
|
||||
export function Edges({ links, placed }: { links: VisLink[]; placed: Placed }) {
|
||||
return (
|
||||
<svg className="edges">
|
||||
<defs>
|
||||
<marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M0 0 L10 5 L0 10 z" fill="var(--edge-strong)" />
|
||||
</marker>
|
||||
<marker id="arrHand" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M0 0 L10 5 L0 10 z" fill="var(--guide)" />
|
||||
</marker>
|
||||
</defs>
|
||||
{links.map((l) => {
|
||||
const a = placed.pos[l.src], b = placed.pos[l.dst];
|
||||
if (!a || !b) return null;
|
||||
const related = l.type === RELATED;
|
||||
return <path key={`${l.src}>${l.dst}${l.type}`} d={edgePath(a, b)} fill="none"
|
||||
stroke={related ? "var(--edge)" : l.hand ? "var(--guide)" : "var(--edge-strong)"}
|
||||
strokeWidth={l.crit ? "4" : related ? "1.4" : "1.8"}
|
||||
strokeDasharray={related ? "5 4" : undefined}
|
||||
markerEnd={related ? undefined : l.hand ? "url(#arrHand)" : "url(#arr)"} />;
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function Guides({ x, y, width, height }: { x: number | null; y: number | null; width: number; height: number }) {
|
||||
return (
|
||||
<svg className="guides">
|
||||
{x !== null && <line x1={x} y1={0} x2={x} y2={height} />}
|
||||
{y !== null && <line x1={0} y1={y} x2={width} y2={y} />}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
52
web/src/board/components/Lane.tsx
Normal file
52
web/src/board/components/Lane.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { useRef, useState, type PointerEvent } from "react";
|
||||
import type { LaneGeom } from "../layout/layout.ts";
|
||||
import { plural } from "../model/util.ts";
|
||||
|
||||
interface LaneProps {
|
||||
g: LaneGeom; index: number; last: number; count: number;
|
||||
lanes: LaneGeom[]; // все дорожки — для выбора места при перетаскивании
|
||||
boardTop: () => number; // верх доски в координатах окна
|
||||
targetClass: string; // "target" / "target-after" — подсветка целевой границы (строка 729)
|
||||
onToggle(name: string): void;
|
||||
onMove(name: string, to: number): void;
|
||||
onDragTarget(to: number | null): void; // цель перетаскивания этой дорожки; null — перетаскивание закончено
|
||||
}
|
||||
|
||||
export function Lane({ g, index, last, count, lanes, boardTop, targetClass, onToggle, onMove, onDragTarget }: LaneProps) {
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const nameRef = useRef<HTMLSpanElement>(null);
|
||||
const where = (clientY: number) => {
|
||||
const y = clientY - boardTop();
|
||||
const i = lanes.findIndex((x) => y < x.y + x.h / 2);
|
||||
return i < 0 ? lanes.length - 1 : i;
|
||||
};
|
||||
const down = (ev: PointerEvent<HTMLSpanElement>) => {
|
||||
ev.preventDefault();
|
||||
const el = nameRef.current!;
|
||||
el.setPointerCapture(ev.pointerId);
|
||||
let target = index;
|
||||
setDragging(true);
|
||||
const move = (e: globalThis.PointerEvent) => { target = where(e.clientY); onDragTarget(target); };
|
||||
const stop = () => {
|
||||
el.removeEventListener("pointermove", move); el.removeEventListener("pointerup", stop); el.removeEventListener("pointercancel", stop);
|
||||
setDragging(false);
|
||||
onDragTarget(null);
|
||||
if (target !== index) onMove(g.name, target);
|
||||
};
|
||||
el.addEventListener("pointermove", move); el.addEventListener("pointerup", stop); el.addEventListener("pointercancel", stop);
|
||||
};
|
||||
const cls = "lane" + (g.folded ? " folded" : "") + (dragging ? " dragging" : "") + (targetClass ? " " + targetClass : "");
|
||||
return (
|
||||
<div className={cls} style={{ top: g.y, height: g.h }}>
|
||||
<div className="lane-head">
|
||||
<button type="button" className="fold" title="Свернуть или развернуть дорожку" onClick={() => onToggle(g.name)} />
|
||||
<span ref={nameRef} className="lane-name" title="Перетащить, чтобы поменять дорожки местами" onPointerDown={down}>{g.name}</span>
|
||||
<span className="lane-count">{plural(count, "задача", "задачи", "задач")}</span>
|
||||
<span className="lane-tools">
|
||||
<button type="button" title="Выше" disabled={index === 0} onClick={() => onMove(g.name, index - 1)}>▲</button>
|
||||
<button type="button" title="Ниже" disabled={index === last} onClick={() => onMove(g.name, index + 1)}>▼</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user