FN-067: restore desktop Board drag navigation

Restore safe Board panning for desktop and tablet without changing mobile touch behavior.

- Restore primary-mouse dragging from safe noninteractive Board surfaces, including empty-column text.
- Keep cards, controls, editable content, native draggables, and mobile scrolling native.
- Preserve no edge auto-scroll behavior and document the updated navigation contract.

Files changed:
 .changeset/fn-067-board-safe-surface-pan.md        |   7 +
 docs/dashboard-guide.md                            |   4 +-
 packages/dashboard/app/components/Board.css        |   6 +-
 packages/dashboard/app/components/Board.tsx        |  12 +-
 .../app/components/__tests__/Board.test.tsx        |  73 +++++----
 .../app/components/__tests__/board-mobile.test.tsx |   4 +-
 .../app/hooks/__tests__/useBoardMousePan.test.tsx  | 163 +++++++++++----------
 packages/dashboard/app/hooks/useBoardMousePan.ts   |  37 +++--
 8 files changed, 172 insertions(+), 134 deletions(-)

Fusion-Task-Id: FN-067

Fusion-Task-Lineage: 410d3401-1d41-40f1-b025-26248ecd37ff

Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-20 05:18:29 +00:00
parent dfa25335d0
commit d9a4d36c1a
8 changed files with 181 additions and 143 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Restore desktop Board dragging from safe empty-column surfaces.
category: fix
dev: Keeps mobile Board scrolling and column snapping unchanged while retaining no edge auto-scroll.

View File

@@ -326,8 +326,8 @@ Features:
<!-- FNXC:TaskCardMovement 2026-08-19-18:52: Board and List task relocation is intentionally menu-only, so native card dragging cannot bypass the same explicit Move to choice on desktop, keyboard, and touch surfaces. -->
- Move a task from its context menu: right-click on desktop, use the overflow button or keyboard Context Menu/Shift+F10, or long-press on touch. When several legal destinations exist, open the grouped **Move to** submenu; a single destination remains a direct action. This changes task movement only—subtasks still use drag-to-reorder.
<!-- FNXC:BoardNavigation 2026-08-20-02:44: Desktop Board background dragging is intentional only when the primary mouse starts directly on the bare overflowing Board root; text, cards, and controls stay native, and edge proximity can never start or continue scrolling. -->
- On desktop, click-hold and drag the bare Board background to pan columns by actual pointer movement. Text, task cards, and controls retain their normal selection and interaction behavior; approaching an edge never auto-scrolls. Native wheel, trackpad, scrollbar, and keyboard scrolling remain available. Phones continue to use native touch scrolling and mobile-only column snapping.
<!-- FNXC:BoardNavigation 2026-08-20-04:52: Desktop and tablet Board panning restores safe noninteractive descendants, including empty-column text, while cards and controls remain native. Edge proximity never starts or continues scrolling, and phones retain their separate native touch and column-snap ownership. -->
- On desktop and tablet, click-hold and drag a safe Board surface, including empty-column text, to pan columns by actual pointer movement. Task cards, controls, editable content, and native-draggable targets retain their normal behavior; approaching an edge never auto-scrolls. Native wheel, trackpad, scrollbar, and keyboard scrolling remain available. Phones continue to use unchanged native touch scrolling and mobile-only 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

View File

@@ -45,9 +45,9 @@ The board needs a neutral first-paint shell whenever workflow lanes are enabled
}
/*
FNXC:BoardNavigation 2026-08-20-02:44:
Feedback appears only after a qualifying direct-root desktop drag crosses horizontal intent. It
cannot suppress selection for text, cards, controls, the loading skeleton, or mobile touch.
FNXC:BoardNavigation 2026-08-20-04:47:
Feedback begins only after an eligible desktop/tablet safe-surface drag crosses horizontal intent.
It never activates for excluded cards or controls, the unbound skeleton, or the disabled mobile path.
*/
.board.board-workflow-columns.is-mouse-panning {
cursor: grabbing;

View File

@@ -225,20 +225,20 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
boardRef.current = element;
setBoardElement((current) => current === element ? current : element);
}, []);
const viewportMode = useViewportMode();
useColumnScrollSnap(boardElement, { mobileOnly: true });
/*
FNXC:BoardNavigation 2026-08-20-02:44:
Selected and All-workflows Board roots share one bounded mouse-pan hook, while the loading
skeleton remains unbound and mobile touch remains owned by the separate snap hook. Direct-root
drags alone can pan; this wiring must not turn cards, text, or controls into navigation surfaces.
FNXC:BoardNavigation 2026-08-20-04:47:
Selected and All-workflows live roots share safe-surface primary-mouse panning outside mobile;
empty-column text may pan, while cards and controls remain native. The skeleton stays unbound,
and the separate mobile-only snap owner plus mobile styles remain unchanged.
*/
const { isPanning: isBoardMousePanning, ...boardMousePanBindings } = useBoardMousePan(boardElement);
const { isPanning: isBoardMousePanning, ...boardMousePanBindings } = useBoardMousePan(boardElement, viewportMode !== "mobile");
const boardClassName = `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");
});
const viewportMode = useViewportMode();
// Normalized search-active signal: trimmed and non-empty
const isSearchActive = searchQuery.trim() !== "";
useEffect(() => {

View File

@@ -2041,7 +2041,7 @@ describe("Board", () => {
]);
});
it("pans selected and All-workflows Board roots only while the pointer moves", async () => {
it("restores safe empty-column descendant panning in selected and All-workflows Boards", async () => {
enableFlag(
{ "FN-1": "builtin:coding", "FN-2": "wf-custom" },
[DEFAULT_WORKFLOW, CUSTOM_WORKFLOW],
@@ -2050,45 +2050,41 @@ describe("Board", () => {
const selectedBoard = screen.getByRole("main") as HTMLElement;
makeBoardHorizontallyScrollable(selectedBoard);
fireEvent.pointerDown(selectedBoard, { button: 0, clientX: 100, clientY: 50, pointerId: 1, pointerType: "mouse" });
fireEvent.pointerMove(selectedBoard, { clientX: 40, clientY: 50, pointerId: 1, pointerType: "mouse" });
const selectedEmptyText = within(selectedBoard).getAllByText("No tasks")[0];
fireEvent.pointerDown(selectedEmptyText, { button: 0, clientX: 100, clientY: 50, pointerId: 1, pointerType: "mouse" });
fireEvent.pointerMove(selectedEmptyText, { clientX: 40, clientY: 50, pointerId: 1, pointerType: "mouse" });
expect(selectedBoard.scrollLeft).toBe(160);
expect(selectedBoard).toHaveClass("is-mouse-panning");
fireEvent.pointerUp(selectedBoard, { pointerId: 1, pointerType: "mouse" });
fireEvent.pointerUp(selectedEmptyText, { pointerId: 1, pointerType: "mouse" });
expect(selectedBoard).not.toHaveClass("is-mouse-panning");
expect(selectedBoard.scrollLeft).toBe(160);
await selectWorkflow(ALL_WORKFLOWS_BOARD_VIEW_ID);
const aggregateBoard = screen.getByRole("main") as HTMLElement;
makeBoardHorizontallyScrollable(aggregateBoard);
fireEvent.pointerDown(aggregateBoard, { button: 0, clientX: 100, clientY: 50, pointerId: 2, pointerType: "mouse" });
fireEvent.pointerMove(aggregateBoard, { clientX: 40, clientY: 50, pointerId: 2, pointerType: "mouse" });
expect(aggregateBoard.scrollLeft).toBe(160);
fireEvent.pointerUp(aggregateBoard, { pointerId: 2, pointerType: "mouse" });
const aggregateEmptyText = within(aggregateBoard).getAllByText("No tasks")[0];
fireEvent.pointerDown(aggregateEmptyText, { button: 0, clientX: 100, clientY: 50, pointerId: 2, pointerType: "mouse" });
fireEvent.pointerMove(aggregateEmptyText, { clientX: 40, clientY: 50, pointerId: 2, pointerType: "mouse" });
expect(aggregateBoard.scrollLeft).toBe(160);
fireEvent.pointerUp(aggregateEmptyText, { pointerId: 2, pointerType: "mouse" });
expect(aggregateBoard).not.toHaveClass("is-mouse-panning");
});
it("keeps empty text and non-overflow Board surfaces outside mouse panning", 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" })] });
it("keeps non-overflow Boards and stationary edgeward pointers outside continued panning", () => {
enableFlag({});
renderBoard();
const board = screen.getByRole("main") as HTMLElement;
makeBoardHorizontallyScrollable(board);
const selectedBoard = screen.getByRole("main") as HTMLElement;
makeBoardHorizontallyScrollable(selectedBoard);
dispatchMouseDrag(within(selectedBoard).getAllByText("No tasks")[0]);
expect(selectedBoard.scrollLeft).toBe(100);
fireEvent.pointerDown(within(board).getAllByText("No tasks")[0], { button: 0, clientX: 100, clientY: 50, pointerId: 1, pointerType: "mouse" });
fireEvent.pointerMove(board, { clientX: 190, clientY: 50, pointerId: 1, pointerType: "mouse" });
const scrollAfterPointerMove = board.scrollLeft;
fireEvent.pointerUp(board, { pointerId: 1, pointerType: "mouse" });
expect(board.scrollLeft).toBe(scrollAfterPointerMove);
Object.defineProperty(selectedBoard, "scrollWidth", { configurable: true, value: 200 });
dispatchMouseDrag(selectedBoard, 2);
expect(selectedBoard.scrollLeft).toBe(100);
await selectWorkflow(ALL_WORKFLOWS_BOARD_VIEW_ID);
const aggregateBoard = screen.getByRole("main") as HTMLElement;
makeBoardHorizontallyScrollable(aggregateBoard);
dispatchMouseDrag(within(aggregateBoard).getAllByText("No tasks")[0], 3);
expect(aggregateBoard.scrollLeft).toBe(100);
Object.defineProperty(board, "scrollWidth", { configurable: true, value: 200 });
dispatchMouseDrag(board, 2);
expect(board.scrollLeft).toBe(scrollAfterPointerMove);
});
it("keeps touch and task-card interactions native", async () => {
@@ -2123,6 +2119,29 @@ describe("Board", () => {
expect(onQuickCreate).toHaveBeenCalledTimes(1);
});
it("disables desktop mouse panning at the mobile viewport without changing touch ownership", () => {
const harness = installMobileBoardStabilizationHarness();
try {
enableFlag({});
renderBoard();
const board = screen.getByRole("main") as HTMLElement;
makeBoardHorizontallyScrollable(board);
const emptyText = within(board).getAllByText("No tasks")[0];
fireEvent.pointerDown(emptyText, { button: 0, clientX: 100, clientY: 50, pointerId: 1, pointerType: "mouse" });
fireEvent.pointerMove(emptyText, { clientX: 40, clientY: 50, pointerId: 1, pointerType: "mouse" });
fireEvent.pointerUp(emptyText, { pointerId: 1, pointerType: "mouse" });
fireEvent.pointerDown(emptyText, { button: 0, clientX: 100, clientY: 50, pointerId: 2, pointerType: "touch" });
fireEvent.pointerMove(emptyText, { clientX: 40, clientY: 50, pointerId: 2, pointerType: "touch" });
fireEvent.pointerUp(emptyText, { pointerId: 2, pointerType: "touch" });
expect(board.scrollLeft).toBe(100);
expect(board).not.toHaveClass("is-mouse-panning");
} finally {
harness.restore();
}
});
it("preserves all-workflows board scroll during mobile visualViewport refresh stabilization", async () => {
const harness = installMobileBoardStabilizationHarness();
try {

View File

@@ -98,13 +98,13 @@ describe("mobile board magnetic column snap wiring (FN-8235)", () => {
remain are the selected and aggregate workflow-column renders, and the point of this guard is that
BOTH share the one scroll-snap hook, which still holds.
*/
it("shares mobile snap and bounded mouse-pan wiring across the selected and aggregate live boards", () => {
it("shares mobile snap and explicitly disabled mouse-pan wiring across the selected and aggregate live boards", () => {
const boardSource = readAppFile("components/Board.tsx");
expect(boardSource).toContain('import { useColumnScrollSnap } from "../hooks/useColumnScrollSnap";');
expect(boardSource).toContain('import { useBoardMousePan } from "../hooks/useBoardMousePan";');
expect(boardSource).toContain("useColumnScrollSnap(boardElement, { mobileOnly: true });");
expect(boardSource).toContain("useBoardMousePan(boardElement)");
expect(boardSource).toContain('useBoardMousePan(boardElement, viewportMode !== "mobile")');
expect(boardSource.match(/ref=\{setBoardRef\}/g)).toHaveLength(2);
expect(boardSource.match(/\{\.\.\.boardMousePanBindings\}/g)).toHaveLength(2);
expect(boardSource.match(/className=\{boardClassName\}/g)).toHaveLength(2);

View File

@@ -3,31 +3,29 @@ 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 }) {
function PanHarness({ enabled = true, onClick = vi.fn() }: { enabled?: boolean; onClick?: () => void }) {
const [boardElement, setBoardElement] = useState<HTMLElement | null>(null);
const { isPanning, ...bindings } = useBoardMousePan(boardElement);
const { isPanning, ...bindings } = useBoardMousePan(boardElement, enabled);
return (
<main
ref={setBoardElement}
className={isPanning ? "is-mouse-panning" : ""}
data-panning={isPanning ? "true" : "false"}
data-testid="board"
onClick={onClick}
{...bindings}
>
<main ref={setBoardElement} className={isPanning ? "is-mouse-panning" : ""} data-panning={String(isPanning)} data-testid="board" onClick={onClick} {...bindings}>
<p data-testid="empty-text">No tasks</p>
<div data-testid="surface">Safe surface</div>
<button type="button" data-testid="button">Button</button>
<input aria-label="Editable" data-testid="input" />
<article data-testid="card">Card</article>
<div contentEditable data-testid="contenteditable">Editable content</div>
<div draggable data-testid="draggable">Draggable</div>
<article data-id="FN-1" data-testid="card">Card</article>
</main>
);
}
function renderPanHarness(onClick = vi.fn()) {
const result = render(<PanHarness onClick={onClick} />);
function renderPanHarness(enabled = true, onClick = vi.fn()) {
const result = render(<PanHarness enabled={enabled} onClick={onClick} />);
const board = result.getByTestId("board");
Object.defineProperty(board, "clientWidth", { configurable: true, value: 200 });
Object.defineProperty(board, "scrollWidth", { configurable: true, value: 600 });
Object.defineProperties(board, {
clientWidth: { configurable: true, value: 200 },
scrollWidth: { configurable: true, value: 600 },
});
return { ...result, board };
}
@@ -44,15 +42,15 @@ function pointerUp(target: HTMLElement, pointerId = 1, pointerType = "mouse") {
}
describe("useBoardMousePan", () => {
it("pans direct Board-root primary mouse drags by inverse horizontal delta in both directions", () => {
const { board } = renderPanHarness();
it("pans safe descendants and the root by inverse horizontal delta in both directions", () => {
const { board, getByTestId } = renderPanHarness();
board.scrollLeft = 100;
pointerDown(board);
pointerMove(board, 140);
pointerDown(getByTestId("empty-text"));
pointerMove(getByTestId("empty-text"), 140);
expect(board.scrollLeft).toBe(60);
expect(board).toHaveAttribute("data-panning", "true");
pointerUp(board);
pointerUp(getByTestId("empty-text"));
board.scrollLeft = 100;
pointerDown(board, 100, 50, 2);
@@ -60,79 +58,75 @@ describe("useBoardMousePan", () => {
expect(board.scrollLeft).toBe(130);
});
it("does not pan before horizontal intent, vertically, or without overflow", () => {
const { board } = renderPanHarness();
board.scrollLeft = 100;
pointerDown(board);
pointerMove(board, 103);
pointerMove(board, 104, 110);
pointerUp(board);
expect(board.scrollLeft).toBe(100);
Object.defineProperty(board, "scrollWidth", { configurable: true, value: 200 });
pointerDown(board, 100, 50, 2);
pointerMove(board, 140, 50, 2);
pointerUp(board, 2);
expect(board.scrollLeft).toBe(100);
});
it("never starts from text, cards, or controls", () => {
it("leaves excluded interactive, editable, native-draggable, and task surfaces native", () => {
const { board, getByTestId } = renderPanHarness();
board.scrollLeft = 100;
for (const [index, target] of [
getByTestId("empty-text"),
getByTestId("card"),
getByTestId("button"),
getByTestId("input"),
].entries()) {
const pointerId = index + 1;
pointerDown(target, 100, 50, pointerId);
pointerMove(target, 40, 50, pointerId);
pointerUp(target, pointerId);
for (const [index, target] of ["button", "input", "contenteditable", "draggable", "card"].map(getByTestId).entries()) {
pointerDown(target, 100, 50, index + 1);
pointerMove(target, 40, 50, index + 1);
pointerUp(target, index + 1);
}
expect(board.scrollLeft).toBe(100);
expect(board).toHaveAttribute("data-panning", "false");
});
it("ignores touch, pen, and non-primary mouse input", () => {
const { board } = renderPanHarness();
board.scrollLeft = 100;
for (const [pointerType, pointerId] of [["touch", 1], ["pen", 2]] as const) {
pointerDown(board, 100, 50, pointerId, pointerType);
pointerMove(board, 40, 50, pointerId, pointerType);
pointerUp(board, pointerId, pointerType);
}
pointerDown(board, 100, 50, 3, "mouse", 2);
pointerMove(board, 40, 50, 3);
pointerUp(board, 3);
expect(board.scrollLeft).toBe(100);
expect(board).toHaveAttribute("data-panning", "false");
});
it("only changes scrollLeft during pointer moves and suppresses one compatibility click after panning", () => {
it("is inert when disabled for mobile and for touch, pen, or non-primary input", () => {
const onClick = vi.fn();
const { board } = renderPanHarness(onClick);
const { board, getByTestId, rerender } = renderPanHarness(false, onClick);
board.scrollLeft = 100;
const surface = getByTestId("surface");
pointerDown(board);
pointerMove(board, 190);
const scrollAfterMove = board.scrollLeft;
pointerUp(board);
expect(board.scrollLeft).toBe(scrollAfterMove);
fireEvent.click(board);
fireEvent.click(board);
pointerDown(surface);
pointerMove(surface, 40);
pointerUp(surface);
fireEvent.click(surface);
expect(board.scrollLeft).toBe(100);
expect(board).toHaveAttribute("data-panning", "false");
expect(onClick).toHaveBeenCalledTimes(1);
rerender(<PanHarness />);
board.scrollLeft = 100;
for (const [pointerType, pointerId] of [["touch", 1], ["pen", 2]] as const) {
pointerDown(getByTestId("surface"), 100, 50, pointerId, pointerType);
pointerMove(getByTestId("surface"), 40, 50, pointerId, pointerType);
pointerUp(getByTestId("surface"), pointerId, pointerType);
}
pointerDown(getByTestId("surface"), 100, 50, 3, "mouse", 2);
pointerMove(getByTestId("surface"), 40, 50, 3);
expect(board.scrollLeft).toBe(100);
});
it("cleans active feedback and click guards on cancellation, lost capture, and unmount", () => {
it("does not pan before horizontal intent, without overflow, or after a stationary edgeward pointer", () => {
const { board, getByTestId } = renderPanHarness();
const surface = getByTestId("surface");
board.scrollLeft = 100;
pointerDown(surface);
pointerMove(surface, 103);
pointerMove(surface, 104, 110);
pointerUp(surface);
expect(board.scrollLeft).toBe(100);
Object.defineProperty(board, "scrollWidth", { configurable: true, value: 200 });
pointerDown(surface, 100, 50, 2);
pointerMove(surface, 190, 50, 2);
pointerUp(surface, 2);
expect(board.scrollLeft).toBe(100);
Object.defineProperty(board, "scrollWidth", { configurable: true, value: 600 });
pointerDown(surface, 100, 50, 3);
pointerMove(surface, 190, 50, 3);
const scrollAfterMove = board.scrollLeft;
pointerUp(surface, 3);
expect(board.scrollLeft).toBe(scrollAfterMove);
});
it("suppresses one compatibility click after a pan and cleans up cancellation, lost capture, and unmount", () => {
const onClick = vi.fn();
const { board, unmount } = renderPanHarness(onClick);
const { board, getByTestId, unmount } = renderPanHarness(true, onClick);
const surface = getByTestId("surface");
const setPointerCapture = vi.fn();
const hasPointerCapture = vi.fn(() => true);
const releasePointerCapture = vi.fn();
@@ -142,22 +136,29 @@ describe("useBoardMousePan", () => {
releasePointerCapture: { configurable: true, value: releasePointerCapture },
});
pointerDown(board);
pointerMove(board, 140);
fireEvent.pointerCancel(board, { pointerId: 1 });
expect(board).toHaveAttribute("data-panning", "false");
fireEvent.click(board);
pointerDown(surface);
pointerMove(surface, 140);
pointerUp(surface);
fireEvent.click(surface);
fireEvent.click(surface);
expect(onClick).toHaveBeenCalledTimes(1);
pointerDown(board, 100, 50, 2);
pointerMove(board, 140, 50, 2);
fireEvent.lostPointerCapture(board, { pointerId: 2 });
pointerDown(surface, 100, 50, 2);
pointerMove(surface, 140, 50, 2);
fireEvent.pointerCancel(surface, { pointerId: 2 });
expect(board).toHaveAttribute("data-panning", "false");
fireEvent.click(board);
fireEvent.click(surface);
pointerDown(board, 100, 50, 3);
pointerDown(surface, 100, 50, 3);
pointerMove(surface, 140, 50, 3);
fireEvent.lostPointerCapture(surface, { pointerId: 3 });
expect(board).toHaveAttribute("data-panning", "false");
fireEvent.click(surface);
pointerDown(surface, 100, 50, 4);
unmount();
expect(setPointerCapture).toHaveBeenCalledWith(3);
expect(releasePointerCapture).toHaveBeenCalledWith(3);
expect(onClick).toHaveBeenCalledTimes(2);
expect(setPointerCapture).toHaveBeenCalledWith(4);
expect(releasePointerCapture).toHaveBeenCalledWith(4);
expect(onClick).toHaveBeenCalledTimes(3);
});
});

View File

@@ -29,12 +29,21 @@ export interface BoardMousePanBindings {
}
/*
FNXC:BoardNavigation 2026-08-20-02:44:
Desktop Board navigation intentionally pans only from a direct primary-mouse drag on the bare
overflowing Board root. Descendant text and controls remain native, edge proximity can never
continue scrolling after pointer movement stops, and mobile touch remains owned by its separate
column-snap hook.
FNXC:BoardNavigation 2026-08-20-04:47:
Desktop and tablet Board navigation restores primary-mouse click-drag panning from safe,
noninteractive descendants such as empty-column text. Interactive, editable, native-draggable,
and task-card descendants keep their native behavior. Mobile owns touch scrolling and column snap,
so this hook is disabled there; edge proximity and stationary pointers never continue scrolling.
*/
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 {
@@ -46,7 +55,7 @@ function releasePointerCapture(session: BoardMousePanSession): void {
}
}
export function useBoardMousePan(boardElement: HTMLElement | null): BoardMousePanBindings {
export function useBoardMousePan(boardElement: HTMLElement | null, enabled: boolean): BoardMousePanBindings {
const sessionRef = useRef<BoardMousePanSession | null>(null);
const didPanRef = useRef(false);
const [isPanning, setIsPanning] = useState(false);
@@ -63,9 +72,10 @@ export function useBoardMousePan(boardElement: HTMLElement | null): BoardMousePa
const onPointerDown = useCallback((event: ReactPointerEvent<HTMLElement>) => {
const element = event.currentTarget;
if (
event.pointerType !== "mouse"
!enabled
|| event.pointerType !== "mouse"
|| event.button !== 0
|| event.target !== element
|| isExcludedBoardPanTarget(event.target)
|| element.scrollWidth <= element.clientWidth
) {
return;
@@ -81,9 +91,10 @@ export function useBoardMousePan(boardElement: HTMLElement | null): BoardMousePa
isPanning: false,
};
element.setPointerCapture?.(event.pointerId);
}, []);
}, [enabled]);
const onPointerMove = useCallback((event: ReactPointerEvent<HTMLElement>) => {
if (!enabled) return;
const session = sessionRef.current;
if (!session || session.pointerId !== event.pointerId) return;
@@ -100,7 +111,7 @@ export function useBoardMousePan(boardElement: HTMLElement | null): BoardMousePa
event.preventDefault();
session.element.scrollLeft = session.startScrollLeft - deltaX;
}, []);
}, [enabled]);
const onPointerUp = useCallback((event: ReactPointerEvent<HTMLElement>) => {
endSession(event.pointerId, false);
@@ -115,11 +126,11 @@ export function useBoardMousePan(boardElement: HTMLElement | null): BoardMousePa
}, [endSession]);
const onClickCapture = useCallback((event: ReactMouseEvent<HTMLElement>) => {
if (!didPanRef.current) return;
if (!enabled || !didPanRef.current) return;
didPanRef.current = false;
event.preventDefault();
event.stopPropagation();
}, []);
}, [enabled]);
useEffect(() => () => {
const session = sessionRef.current;
@@ -127,7 +138,7 @@ export function useBoardMousePan(boardElement: HTMLElement | null): BoardMousePa
sessionRef.current = null;
didPanRef.current = false;
setIsPanning(false);
}, [boardElement]);
}, [boardElement, enabled]);
return {
isPanning,