FN-009: add desktop Board mouse-drag navigation
Add safe desktop click-drag panning to the Board while preserving existing card and touch interactions. - Add the mouse-pan hook with pointer capture, intent threshold, and click suppression. - Wire panning and cursor states into Board surfaces with regression coverage. - Document the interaction and publish the feature changeset. Files changed: .changeset/fn-009-board-mouse-pan.md | 7 + docs/dashboard-guide.md | 2 + packages/dashboard/app/components/Board.css | 11 ++ packages/dashboard/app/components/Board.tsx | 19 ++- .../app/components/__tests__/Board.test.tsx | 78 +++++++++- .../app/hooks/__tests__/useBoardMousePan.test.tsx | 164 +++++++++++++++++++++ packages/dashboard/app/hooks/useBoardMousePan.ts | 151 +++++++++++++++++++ 7 files changed, 429 insertions(+), 3 deletions(-) Fusion-Task-Id: FN-009 Fusion-Task-Lineage: 0f0bee56-cbb8-4e8d-b866-51d439ab9655 Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-009-board-mouse-pan.md
Normal file
7
.changeset/fn-009-board-mouse-pan.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Add desktop click-drag panning across Board workflow columns.
|
||||
category: feature
|
||||
dev: Safe Board surfaces pan horizontally with the native scroll position; task-card drag-and-drop and mobile touch paging remain unchanged.
|
||||
@@ -294,6 +294,8 @@ Board view is the kanban surface for day-to-day operation.
|
||||
Features:
|
||||
|
||||
- Drag-and-drop between lifecycle columns
|
||||
<!-- FNXC:BoardNavigation 2026-08-18-18:18: Desktop operators can traverse the live workflow columns by dragging a safe Board surface horizontally; card drag-and-drop, card/control clicks, and mobile touch paging remain separate interactions. -->
|
||||
- On desktop, click-drag an empty or otherwise safe Board surface to pan horizontally: drag right to reveal earlier columns and drag left to reveal later columns. Task cards and their controls remain reserved for native drag-and-drop, clicks, and context menus; phones continue to use native touch scrolling and column snapping.
|
||||
- Search/filter tasks (including working-branch and base-branch dropdown filters with explicit **No working branch** / **No base branch** options)
|
||||
- Working-branch and base-branch filter selections are persisted per project and restored across refresh/navigation
|
||||
- Column visibility controls
|
||||
|
||||
@@ -44,6 +44,17 @@ The board needs a neutral first-paint shell whenever workflow lanes are enabled
|
||||
inline-size: 75%;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:BoardNavigation 2026-08-18-18:18:
|
||||
A real desktop board pan gets non-semantic feedback on the existing scroll surface only. The active
|
||||
state disables text selection while dragging, but does not add a control, change touch behavior, or
|
||||
alter the desktop free-pan/mobile snap CSS boundary.
|
||||
*/
|
||||
.board.board-workflow-columns.is-mouse-panning {
|
||||
cursor: grabbing;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@keyframes board-workflows-skeleton-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.55;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createPortal } from "react-dom";
|
||||
import { promoteTask, type ModelInfo, type BoardWorkflowsPayload, type BoardWorkflowColumn, type RevertTaskOptions, type RevertTaskResult } from "../api";
|
||||
import { useBlockerFanout, type BlockerFanoutColumnFlags } from "../hooks/useBlockerFanout";
|
||||
import { useColumnScrollSnap } from "../hooks/useColumnScrollSnap";
|
||||
import { useBoardMousePan } from "../hooks/useBoardMousePan";
|
||||
import { MOBILE_MEDIA_QUERY, useViewportMode } from "../hooks/useViewportMode";
|
||||
import { recordResumeEvent } from "../utils/resumeInstrumentation";
|
||||
import { getBoardCanDropTaskRejection } from "./boardCanDropTask";
|
||||
@@ -199,6 +200,14 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
||||
setBoardElement((current) => current === element ? current : element);
|
||||
}, []);
|
||||
useColumnScrollSnap(boardElement, { mobileOnly: true });
|
||||
/*
|
||||
FNXC:BoardNavigation 2026-08-18-18:18:
|
||||
Both live workflow Board variants share this callback-ref lifecycle so desktop mouse panning is
|
||||
reachable on selected and All-workflows views without binding the loading skeleton or changing
|
||||
the existing mobile-only column snap hook.
|
||||
*/
|
||||
const { isPanning: isBoardMousePanning, ...boardMousePanHandlers } = useBoardMousePan(boardElement);
|
||||
const boardMousePanClassName = `board board-workflow-columns${isBoardMousePanning ? " is-mouse-panning" : ""}`;
|
||||
const [headerWorkflowSlot, setHeaderWorkflowSlot] = useState<HTMLElement | null>(() => {
|
||||
if (typeof document === "undefined") return null;
|
||||
return document.getElementById("header-workflow-slot");
|
||||
@@ -955,7 +964,12 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
||||
return (
|
||||
<div className="board-workflow-view">
|
||||
{renderedWorkflowToolbar}
|
||||
<main className="board board-workflow-columns" id="board" ref={setBoardRef}>
|
||||
<main
|
||||
className={boardMousePanClassName}
|
||||
id="board"
|
||||
ref={setBoardRef}
|
||||
{...boardMousePanHandlers}
|
||||
>
|
||||
{aggregateRenderedBoardColumns.map((columnDef) => {
|
||||
const isCreateColumn = aggregateQuickCreateTarget?.columnId === columnDef.id;
|
||||
const isDoneLikeColumn = columnDef.flags.complete === true && columnDef.flags.archived !== true;
|
||||
@@ -1043,9 +1057,10 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
||||
<div className="board-workflow-view">
|
||||
{renderedWorkflowToolbar}
|
||||
<main
|
||||
className="board board-workflow-columns"
|
||||
className={boardMousePanClassName}
|
||||
id="board"
|
||||
ref={setBoardRef}
|
||||
{...boardMousePanHandlers}
|
||||
onDragStart={(e) => {
|
||||
const id = (e.target as HTMLElement)?.closest?.("[data-id]")?.getAttribute("data-id");
|
||||
if (id) draggingTaskIdRef.current = id;
|
||||
|
||||
@@ -118,7 +118,7 @@ vi.mock("../Column", () => ({
|
||||
</button>
|
||||
) : null}
|
||||
{tasks.map((task) => (
|
||||
<article key={task.id} data-testid={`board-task-card-${task.id}`}>
|
||||
<article key={task.id} data-id={task.id} draggable data-testid={`board-task-card-${task.id}`}>
|
||||
{task.title ?? task.description ?? task.id}
|
||||
</article>
|
||||
))}
|
||||
@@ -241,6 +241,17 @@ function renderBoard(props = {}) {
|
||||
return render(<Board {...createBoardProps(props)} />);
|
||||
}
|
||||
|
||||
function makeBoardHorizontallyScrollable(board: HTMLElement, scrollLeft = 100) {
|
||||
Object.defineProperty(board, "clientWidth", { configurable: true, value: 200 });
|
||||
Object.defineProperty(board, "scrollWidth", { configurable: true, value: 600 });
|
||||
board.scrollLeft = scrollLeft;
|
||||
}
|
||||
|
||||
function dragBoardSurface(board: HTMLElement, pointerId = 1, pointerType = "mouse") {
|
||||
fireEvent.pointerDown(board, { button: 0, clientX: 100, clientY: 50, pointerId, pointerType });
|
||||
fireEvent.pointerMove(board, { clientX: 140, clientY: 50, pointerId, pointerType });
|
||||
}
|
||||
|
||||
function installMobileBoardStabilizationHarness() {
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||
@@ -1988,6 +1999,71 @@ describe("Board", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("pans both selected and All-workflows boards through their live main surfaces", async () => {
|
||||
enableFlag(
|
||||
{ "FN-1": "builtin:coding", "FN-2": "wf-custom" },
|
||||
[DEFAULT_WORKFLOW, CUSTOM_WORKFLOW],
|
||||
);
|
||||
renderBoard({ tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-2", column: "intake" })] });
|
||||
|
||||
const selectedBoard = screen.getByRole("main") as HTMLElement;
|
||||
makeBoardHorizontallyScrollable(selectedBoard);
|
||||
dragBoardSurface(selectedBoard);
|
||||
expect(selectedBoard.scrollLeft).toBe(60);
|
||||
expect(selectedBoard).toHaveClass("is-mouse-panning");
|
||||
fireEvent.pointerUp(selectedBoard, { pointerId: 1, pointerType: "mouse" });
|
||||
expect(selectedBoard).not.toHaveClass("is-mouse-panning");
|
||||
|
||||
await selectWorkflow(ALL_WORKFLOWS_BOARD_VIEW_ID);
|
||||
const aggregateBoard = screen.getByRole("main") as HTMLElement;
|
||||
makeBoardHorizontallyScrollable(aggregateBoard);
|
||||
dragBoardSurface(aggregateBoard, 2);
|
||||
expect(aggregateBoard.scrollLeft).toBe(60);
|
||||
fireEvent.pointerUp(aggregateBoard, { pointerId: 2, pointerType: "mouse" });
|
||||
});
|
||||
|
||||
it("keeps touch and task-card interactions outside desktop board panning", async () => {
|
||||
const onQuickCreate = vi.fn().mockResolvedValue({});
|
||||
enableFlag({ "FN-1": "builtin:coding" });
|
||||
renderBoard({ tasks: [mkTask({ id: "FN-1" })], onQuickCreate });
|
||||
|
||||
const board = screen.getByRole("main") as HTMLElement;
|
||||
makeBoardHorizontallyScrollable(board);
|
||||
fireEvent.pointerDown(board, { button: 0, clientX: 100, clientY: 50, pointerId: 1, pointerType: "touch" });
|
||||
fireEvent.pointerMove(board, { clientX: 40, clientY: 50, pointerId: 1, pointerType: "touch" });
|
||||
fireEvent.pointerUp(board, { pointerId: 1, pointerType: "touch" });
|
||||
expect(board.scrollLeft).toBe(100);
|
||||
expect(board).not.toHaveClass("is-mouse-panning");
|
||||
|
||||
const card = screen.getByTestId("board-task-card-FN-1");
|
||||
expect(card).toHaveAttribute("draggable", "true");
|
||||
fireEvent.pointerDown(card, { button: 0, clientX: 100, clientY: 50, pointerId: 2, pointerType: "mouse" });
|
||||
fireEvent.pointerMove(card, { clientX: 40, clientY: 50, pointerId: 2, pointerType: "mouse" });
|
||||
fireEvent.pointerUp(card, { pointerId: 2, pointerType: "mouse" });
|
||||
expect(board.scrollLeft).toBe(100);
|
||||
|
||||
const quickCreate = screen.getByTestId("mock-quick-create-triage");
|
||||
fireEvent.pointerDown(quickCreate, { button: 0, clientX: 100, clientY: 50, pointerId: 3, pointerType: "mouse" });
|
||||
fireEvent.pointerMove(quickCreate, { clientX: 40, clientY: 50, pointerId: 3, pointerType: "mouse" });
|
||||
fireEvent.pointerUp(quickCreate, { pointerId: 3, pointerType: "mouse" });
|
||||
fireEvent.click(quickCreate);
|
||||
expect(onQuickCreate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not swallow a no-threshold board click", () => {
|
||||
const onQuickCreate = vi.fn().mockResolvedValue({});
|
||||
enableFlag({});
|
||||
renderBoard({ onQuickCreate });
|
||||
const board = screen.getByRole("main") as HTMLElement;
|
||||
makeBoardHorizontallyScrollable(board);
|
||||
fireEvent.pointerDown(board, { button: 0, clientX: 100, clientY: 50, pointerId: 1, pointerType: "mouse" });
|
||||
fireEvent.pointerMove(board, { clientX: 102, clientY: 50, pointerId: 1, pointerType: "mouse" });
|
||||
fireEvent.pointerUp(board, { pointerId: 1, pointerType: "mouse" });
|
||||
fireEvent.click(screen.getByTestId("mock-quick-create-triage"));
|
||||
expect(onQuickCreate).toHaveBeenCalledTimes(1);
|
||||
expect(board).not.toHaveClass("is-mouse-panning");
|
||||
});
|
||||
|
||||
it("preserves all-workflows board scroll during mobile visualViewport refresh stabilization", async () => {
|
||||
const harness = installMobileBoardStabilizationHarness();
|
||||
try {
|
||||
|
||||
164
packages/dashboard/app/hooks/__tests__/useBoardMousePan.test.tsx
Normal file
164
packages/dashboard/app/hooks/__tests__/useBoardMousePan.test.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { useState } from "react";
|
||||
import { fireEvent, render } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useBoardMousePan } from "../useBoardMousePan";
|
||||
|
||||
function PanHarness({ onClick = vi.fn() }: { onClick?: () => void }) {
|
||||
const [boardElement, setBoardElement] = useState<HTMLElement | null>(null);
|
||||
const { isPanning, ...bindings } = useBoardMousePan(boardElement);
|
||||
return (
|
||||
<main
|
||||
ref={(element) => setBoardElement(element)}
|
||||
className={isPanning ? "is-mouse-panning" : ""}
|
||||
data-panning={isPanning ? "true" : "false"}
|
||||
data-testid="board"
|
||||
onClick={onClick}
|
||||
{...bindings}
|
||||
>
|
||||
<button type="button" data-testid="button">Button</button>
|
||||
<input aria-label="Editable" data-testid="input" />
|
||||
<div data-id="FN-1" draggable="true" data-testid="card">Card</div>
|
||||
<div data-testid="surface">Safe surface</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function renderPanHarness(onClick = vi.fn()) {
|
||||
const result = render(<PanHarness onClick={onClick} />);
|
||||
const board = result.getByTestId("board");
|
||||
Object.defineProperty(board, "clientWidth", { configurable: true, value: 200 });
|
||||
Object.defineProperty(board, "scrollWidth", { configurable: true, value: 600 });
|
||||
return { ...result, board, safeSurface: result.getByTestId("surface") };
|
||||
}
|
||||
|
||||
function pointerDown(target: HTMLElement, clientX = 100, clientY = 50, pointerId = 1, pointerType = "mouse") {
|
||||
fireEvent.pointerDown(target, { button: 0, clientX, clientY, pointerId, pointerType });
|
||||
}
|
||||
|
||||
function pointerMove(target: HTMLElement, clientX: number, clientY = 50, pointerId = 1, pointerType = "mouse") {
|
||||
fireEvent.pointerMove(target, { clientX, clientY, pointerId, pointerType });
|
||||
}
|
||||
|
||||
function pointerUp(target: HTMLElement, pointerId = 1, pointerType = "mouse") {
|
||||
fireEvent.pointerUp(target, { button: 0, clientX: 100, clientY: 50, pointerId, pointerType });
|
||||
}
|
||||
|
||||
describe("useBoardMousePan", () => {
|
||||
it("pans horizontally by the inverse mouse delta in either direction", () => {
|
||||
const { board, safeSurface } = renderPanHarness();
|
||||
board.scrollLeft = 100;
|
||||
|
||||
pointerDown(safeSurface);
|
||||
pointerMove(safeSurface, 140);
|
||||
expect(board.scrollLeft).toBe(60);
|
||||
expect(board).toHaveAttribute("data-panning", "true");
|
||||
|
||||
pointerUp(safeSurface);
|
||||
board.scrollLeft = 100;
|
||||
pointerDown(safeSurface, 100, 50, 2);
|
||||
pointerMove(safeSurface, 70, 50, 2);
|
||||
expect(board.scrollLeft).toBe(130);
|
||||
});
|
||||
|
||||
it("keeps taps and non-overflow surfaces from becoming pans or consuming clicks", () => {
|
||||
const onClick = vi.fn();
|
||||
const { board, safeSurface } = renderPanHarness(onClick);
|
||||
|
||||
pointerDown(safeSurface);
|
||||
pointerUp(safeSurface);
|
||||
fireEvent.click(safeSurface);
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
expect(board).toHaveAttribute("data-panning", "false");
|
||||
|
||||
board.scrollLeft = 100;
|
||||
Object.defineProperty(board, "scrollWidth", { configurable: true, value: 200 });
|
||||
pointerDown(safeSurface, 100, 50, 2);
|
||||
pointerMove(safeSurface, 140, 50, 2);
|
||||
pointerUp(safeSurface, 2);
|
||||
fireEvent.click(safeSurface);
|
||||
expect(board.scrollLeft).toBe(100);
|
||||
expect(onClick).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("ignores touch, pen, and non-primary mouse input", () => {
|
||||
const onClick = vi.fn();
|
||||
const { board, safeSurface } = renderPanHarness(onClick);
|
||||
board.scrollLeft = 100;
|
||||
|
||||
for (const [pointerType, pointerId] of [["touch", 1], ["pen", 2]] as const) {
|
||||
fireEvent.pointerDown(safeSurface, { button: 0, clientX: 100, clientY: 50, pointerId, pointerType });
|
||||
pointerMove(safeSurface, 40, 50, pointerId, pointerType);
|
||||
pointerUp(safeSurface, pointerId, pointerType);
|
||||
}
|
||||
fireEvent.pointerDown(safeSurface, { button: 2, clientX: 100, clientY: 50, pointerId: 3, pointerType: "mouse" });
|
||||
pointerMove(safeSurface, 40, 50, 3);
|
||||
pointerUp(safeSurface, 3);
|
||||
|
||||
expect(board.scrollLeft).toBe(100);
|
||||
expect(board).toHaveAttribute("data-panning", "false");
|
||||
fireEvent.click(safeSurface);
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not capture interactive, editable, or native-draggable card targets", () => {
|
||||
const { board, getByTestId } = renderPanHarness();
|
||||
board.scrollLeft = 100;
|
||||
|
||||
for (const target of [getByTestId("button"), getByTestId("input"), getByTestId("card")]) {
|
||||
pointerDown(target);
|
||||
pointerMove(target, 40);
|
||||
pointerUp(target);
|
||||
}
|
||||
|
||||
expect(board.scrollLeft).toBe(100);
|
||||
expect(board).toHaveAttribute("data-panning", "false");
|
||||
});
|
||||
|
||||
it("suppresses one compatibility click after a true pan, then allows later clicks", () => {
|
||||
const onClick = vi.fn();
|
||||
const { safeSurface } = renderPanHarness(onClick);
|
||||
|
||||
pointerDown(safeSurface);
|
||||
pointerMove(safeSurface, 140);
|
||||
pointerUp(safeSurface);
|
||||
fireEvent.click(safeSurface);
|
||||
fireEvent.click(safeSurface);
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ends panning on cancel and lost capture without leaving a stale click guard", () => {
|
||||
const onClick = vi.fn();
|
||||
const { board, safeSurface } = renderPanHarness(onClick);
|
||||
|
||||
pointerDown(safeSurface);
|
||||
pointerMove(safeSurface, 140);
|
||||
fireEvent.pointerCancel(safeSurface, { pointerId: 1 });
|
||||
expect(board).toHaveAttribute("data-panning", "false");
|
||||
fireEvent.click(safeSurface);
|
||||
|
||||
pointerDown(safeSurface, 100, 50, 2);
|
||||
pointerMove(safeSurface, 140, 50, 2);
|
||||
fireEvent.lostPointerCapture(safeSurface, { pointerId: 2 });
|
||||
expect(board).toHaveAttribute("data-panning", "false");
|
||||
fireEvent.click(safeSurface);
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("releases pointer capture during unmount cleanup", () => {
|
||||
const { board, safeSurface, unmount } = renderPanHarness();
|
||||
const setPointerCapture = vi.fn();
|
||||
const hasPointerCapture = vi.fn(() => true);
|
||||
const releasePointerCapture = vi.fn();
|
||||
Object.defineProperty(board, "setPointerCapture", { configurable: true, value: setPointerCapture });
|
||||
Object.defineProperty(board, "hasPointerCapture", { configurable: true, value: hasPointerCapture });
|
||||
Object.defineProperty(board, "releasePointerCapture", { configurable: true, value: releasePointerCapture });
|
||||
|
||||
pointerDown(safeSurface);
|
||||
unmount();
|
||||
|
||||
expect(setPointerCapture).toHaveBeenCalledWith(1);
|
||||
expect(releasePointerCapture).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
151
packages/dashboard/app/hooks/useBoardMousePan.ts
Normal file
151
packages/dashboard/app/hooks/useBoardMousePan.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
|
||||
const BOARD_MOUSE_PAN_THRESHOLD = 4;
|
||||
|
||||
type BoardMousePanSession = {
|
||||
element: HTMLElement;
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
startScrollLeft: number;
|
||||
isPanning: boolean;
|
||||
};
|
||||
|
||||
export interface BoardMousePanBindings {
|
||||
isPanning: boolean;
|
||||
onPointerDown: (event: ReactPointerEvent<HTMLElement>) => void;
|
||||
onPointerMove: (event: ReactPointerEvent<HTMLElement>) => void;
|
||||
onPointerUp: (event: ReactPointerEvent<HTMLElement>) => void;
|
||||
onPointerCancel: (event: ReactPointerEvent<HTMLElement>) => void;
|
||||
onLostPointerCapture: (event: ReactPointerEvent<HTMLElement>) => void;
|
||||
onClickCapture: (event: ReactMouseEvent<HTMLElement>) => void;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:BoardNavigation 2026-08-18-18:18:
|
||||
Desktop Board operators need a primary mouse click-drag on the existing scroll surface to reveal
|
||||
workflow columns without Shift+Scroll. This seam owns only horizontal mouse panning; touch and pen
|
||||
remain native mobile gestures, and task-card/native-draggable or interactive descendants keep their
|
||||
existing click, context-menu, and drag behavior.
|
||||
*/
|
||||
function isExcludedBoardPanTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof Element)) return true;
|
||||
return Boolean(
|
||||
target.closest(
|
||||
"button, a, input, textarea, select, option, label, summary, [contenteditable='true'], [draggable='true'], [data-id], [role='button'], [role='link'], [role='textbox'], [role='menuitem'], [role='checkbox'], [role='combobox'], [role='radio'], [role='slider'], [role='switch']",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function releasePointerCapture(session: BoardMousePanSession): void {
|
||||
const { element, pointerId } = session;
|
||||
try {
|
||||
if (element.hasPointerCapture?.(pointerId)) {
|
||||
element.releasePointerCapture?.(pointerId);
|
||||
}
|
||||
} catch {
|
||||
/* FNXC:BoardNavigation 2026-08-18-18:18: Browser teardown can release pointer capture before the hook cleanup runs; cleanup must remain idempotent. */
|
||||
}
|
||||
}
|
||||
|
||||
export function useBoardMousePan(boardElement: HTMLElement | null): BoardMousePanBindings {
|
||||
const sessionRef = useRef<BoardMousePanSession | null>(null);
|
||||
const didPanRef = useRef(false);
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
|
||||
const endSession = useCallback((event: ReactPointerEvent<HTMLElement>, clearClickGuard: boolean) => {
|
||||
const session = sessionRef.current;
|
||||
if (!session || session.pointerId !== event.pointerId) return;
|
||||
releasePointerCapture(session);
|
||||
sessionRef.current = null;
|
||||
setIsPanning(false);
|
||||
if (clearClickGuard) didPanRef.current = false;
|
||||
}, []);
|
||||
|
||||
const onPointerDown = useCallback((event: ReactPointerEvent<HTMLElement>) => {
|
||||
if (event.pointerType !== "mouse" || event.button !== 0 || isExcludedBoardPanTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = event.currentTarget;
|
||||
didPanRef.current = false;
|
||||
sessionRef.current = {
|
||||
element,
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
startScrollLeft: element.scrollLeft,
|
||||
isPanning: false,
|
||||
};
|
||||
element.setPointerCapture?.(event.pointerId);
|
||||
}, []);
|
||||
|
||||
const onPointerMove = useCallback((event: ReactPointerEvent<HTMLElement>) => {
|
||||
const session = sessionRef.current;
|
||||
if (!session || session.pointerId !== event.pointerId) return;
|
||||
|
||||
const deltaX = event.clientX - session.startX;
|
||||
const deltaY = event.clientY - session.startY;
|
||||
if (!session.isPanning) {
|
||||
const horizontalIntent = Math.abs(deltaX) > Math.abs(deltaY);
|
||||
if (
|
||||
!horizontalIntent
|
||||
|| Math.abs(deltaX) < BOARD_MOUSE_PAN_THRESHOLD
|
||||
|| session.element.scrollWidth <= session.element.clientWidth
|
||||
) {
|
||||
return;
|
||||
}
|
||||
session.isPanning = true;
|
||||
didPanRef.current = true;
|
||||
setIsPanning(true);
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
session.element.scrollLeft = session.startScrollLeft - deltaX;
|
||||
}, []);
|
||||
|
||||
const onPointerUp = useCallback((event: ReactPointerEvent<HTMLElement>) => {
|
||||
endSession(event, false);
|
||||
}, [endSession]);
|
||||
|
||||
const onPointerCancel = useCallback((event: ReactPointerEvent<HTMLElement>) => {
|
||||
endSession(event, true);
|
||||
}, [endSession]);
|
||||
|
||||
const onLostPointerCapture = useCallback((event: ReactPointerEvent<HTMLElement>) => {
|
||||
endSession(event, true);
|
||||
}, [endSession]);
|
||||
|
||||
const onClickCapture = useCallback((event: ReactMouseEvent<HTMLElement>) => {
|
||||
if (!didPanRef.current) return;
|
||||
didPanRef.current = false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const session = sessionRef.current;
|
||||
if (session) releasePointerCapture(session);
|
||||
sessionRef.current = null;
|
||||
didPanRef.current = false;
|
||||
};
|
||||
}, [boardElement]);
|
||||
|
||||
return {
|
||||
isPanning,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
onPointerCancel,
|
||||
onLostPointerCapture,
|
||||
onClickCapture,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user