diff --git a/.changeset/mobile-board-pointercancel-settle.md b/.changeset/mobile-board-pointercancel-settle.md index a5d382bbd1..2f3300e7e4 100644 --- a/.changeset/mobile-board-pointercancel-settle.md +++ b/.changeset/mobile-board-pointercancel-settle.md @@ -2,6 +2,6 @@ "@runfusion/fusion": patch --- -summary: Fix the mobile board resting between columns after a drag and edge-column snap-back glitches during slow scrolls. +summary: Fix mobile board drags resting between columns, edge-column snap-back glitches, and fling overshoot past the mostly-visible column. category: fix -dev: useColumnScrollSnap now ignores pointercancel while the touch stream is still live (native scroll takeover); touchend remains the real finger lift, so gestures are neither orphaned nor idle-settled mid-drag. +dev: useColumnScrollSnap now ignores pointercancel while the touch stream is still live (native scroll takeover), and settle targets the nearest column clamped to one column of progress from the gesture origin (resolveSettleTargetIndex) instead of always paging past it. diff --git a/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts b/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts index a9d95f659c..468cc6665a 100644 --- a/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { isColumnCentered, resolvePanDirection, - resolveTargetIndexInScrollDirection, + resolveSettleTargetIndex, useColumnScrollSnap, } from "../useColumnScrollSnap"; import { isMobileViewport } from "../useViewportMode"; @@ -123,38 +123,55 @@ describe("isColumnCentered", () => { }); }); -describe("resolveTargetIndexInScrollDirection", () => { - it("forward from column 0 always goes right to column 1", () => { - const scroller = createScroller(3, 0); - expect(resolveTargetIndexInScrollDirection(scroller, [...scroller.children] as HTMLElement[], 1)).toBe(1); +describe("resolveSettleTargetIndex", () => { + it("forward short swipe from column 0 commits to column 1", () => { + const scroller = createScroller(3, 8); + expect(resolveSettleTargetIndex(scroller, [...scroller.children] as HTMLElement[], 1, 0)).toBe(1); }); - it("forward past column 0 center still goes right, never left", () => { + it("forward just past column 0 center still commits to column 1, never back", () => { const scroller = createScroller(3, 40); - // nearest may be 0; past its center → 1 - expect(resolveTargetIndexInScrollDirection(scroller, [...scroller.children] as HTMLElement[], 1)).toBe(1); + expect(resolveSettleTargetIndex(scroller, [...scroller.children] as HTMLElement[], 1, 0)).toBe(1); }); - it("forward when nearest is already column 1 stays on 1 if still approaching its center", () => { + /* + FNXC:BoardNavigation 2026-07-22-21:05: + Overshoot regression: a fling that decelerates with column 1 mostly on screen (viewport + center just past its center) must land on column 1 — the prior pager forced column 2. + */ + it("forward fling that decelerated onto column 1 lands on column 1, not one further", () => { + const scroller = createScroller(3, 120); + expect(resolveSettleTargetIndex(scroller, [...scroller.children] as HTMLElement[], 1, 0)).toBe(1); + }); + + it("forward fling that carried to column 2 lands on column 2 (nearest wins)", () => { + const scroller = createScroller(3, 180); + expect(resolveSettleTargetIndex(scroller, [...scroller.children] as HTMLElement[], 1, 0)).toBe(2); + }); + + it("back short swipe from column 1 commits to column 0", () => { + const scroller = createScroller(3, COLUMN_WIDTH - 8); + expect(resolveSettleTargetIndex(scroller, [...scroller.children] as HTMLElement[], -1, 1)).toBe(0); + }); + + it("backward fling that decelerated onto column 1 lands on column 1, not one further", () => { const scroller = createScroller(3, 80); - const columns = [...scroller.children] as HTMLElement[]; - const index = resolveTargetIndexInScrollDirection(scroller, columns, 1); - expect(index).toBeGreaterThanOrEqual(1); - expect(index).toBeLessThanOrEqual(2); + expect(resolveSettleTargetIndex(scroller, [...scroller.children] as HTMLElement[], -1, 2)).toBe(1); }); - it("back from column 1 always goes left to column 0", () => { - const scroller = createScroller(3, COLUMN_WIDTH); - expect(resolveTargetIndexInScrollDirection(scroller, [...scroller.children] as HTMLElement[], -1)).toBe(0); + it("never settles against the locked direction from the origin column", () => { + // Rubber-band pulled the rest point back onto the origin column: still advance one. + const forward = createScroller(4, COLUMN_WIDTH); + expect(resolveSettleTargetIndex(forward, [...forward.children] as HTMLElement[], 1, 1)).toBe(2); + const backward = createScroller(4, COLUMN_WIDTH); + expect(resolveSettleTargetIndex(backward, [...backward.children] as HTMLElement[], -1, 1)).toBe(0); }); - it("never returns a column against scroll direction from nearest", () => { - const scroller = createScroller(4, COLUMN_WIDTH); - const columns = [...scroller.children] as HTMLElement[]; - // At col 1, scroll right → not 0 - expect(resolveTargetIndexInScrollDirection(scroller, columns, 1)).toBeGreaterThanOrEqual(1); - // At col 1, scroll left → not 2+ - expect(resolveTargetIndexInScrollDirection(scroller, columns, -1)).toBeLessThanOrEqual(1); + it("clamps at the board edges", () => { + const last = createScroller(3, COLUMN_WIDTH * 2); + expect(resolveSettleTargetIndex(last, [...last.children] as HTMLElement[], 1, 2)).toBe(2); + const first = createScroller(3, 0); + expect(resolveSettleTargetIndex(first, [...first.children] as HTMLElement[], -1, 0)).toBe(0); }); }); @@ -262,6 +279,26 @@ describe("useColumnScrollSnap", () => { expect(scroller.scrollLeft).toBe(COLUMN_WIDTH); }); + it("does not overshoot a fling that decelerates with the next column mostly on screen", () => { + const scroller = createScroller(3, 0); + renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); + + act(() => { + dispatchPointerEvent(scroller, "pointerdown", 200); + dispatchPointerEvent(scroller, "pointermove", 150); + scroller.scrollLeft = 60; + scroller.dispatchEvent(new Event("scroll")); + dispatchPointerEvent(scroller, "pointerup", 150); + // Momentum carries just past column 1's center — column 1 is mostly on screen. + scroller.scrollLeft = 120; + scroller.dispatchEvent(new Event("scroll")); + }); + settleAfterMomentum(); + + // Regression: the directional pager previously pushed on to column 2 (scrollLeft 200). + expect(scroller.scrollLeft).toBe(COLUMN_WIDTH); + }); + it("pins after settle so residual fling cannot move the board", () => { const scroller = createScroller(); renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); diff --git a/packages/dashboard/app/hooks/useColumnScrollSnap.ts b/packages/dashboard/app/hooks/useColumnScrollSnap.ts index b73123f8fd..e14001737a 100644 --- a/packages/dashboard/app/hooks/useColumnScrollSnap.ts +++ b/packages/dashboard/app/hooks/useColumnScrollSnap.ts @@ -126,42 +126,39 @@ export function resolvePanDirection(options: { return 0; } +/* +FNXC:BoardNavigation 2026-07-22-21:05: +The prior directional pager targeted "one past nearest" whenever the viewport center had +crossed the nearest column's center, so a fling that decelerated with a column mostly on +screen still got pushed a further column — a visible overshoot. Settle now uses the classic +paging rule: land on the NEAREST (mostly-on-screen) column, but guarantee at least one +column of progress from the gesture's ORIGIN column in the locked direction, so a short +deliberate swipe still commits to the next column and the settle never moves against travel. +*/ /** - * Pick the column to land on given locked scroll direction and current viewport. - * Always in the scroll direction — never the opposite column. + * Pick the column to land on at settle time. * - * Moving right (dir +1): if still approaching nearest from the left, land on nearest; - * otherwise land on nearest+1 (the next column on the right). - * Moving left (dir -1): mirror. + * Nearest column wins (it is the one mostly on screen as momentum ends), clamped so a + * directional gesture always advances at least one column from `originIndex` and never + * settles against the locked scroll direction. */ -export function resolveTargetIndexInScrollDirection( +export function resolveSettleTargetIndex( scroller: HTMLElement, columns: HTMLElement[], direction: number, + originIndex: number, ): number { if (columns.length <= 1) return 0; const nearest = nearestColumnIndex(scroller, columns); if (direction === 0) return nearest; - const scrollerRect = scroller.getBoundingClientRect(); - const viewportWidth = scroller.clientWidth || scrollerRect.width; - const viewportCenter = scrollerRect.left + viewportWidth / 2; - const nearestRect = columns[nearest].getBoundingClientRect(); - const nearestCenter = nearestRect.left + nearestRect.width / 2; - + const origin = Math.min(Math.max(originIndex, 0), columns.length - 1); if (direction > 0) { - // Content scrolling right: next column on the right of travel. - if (viewportCenter + CENTER_TOLERANCE_PX < nearestCenter) { - return nearest; - } - return Math.min(columns.length - 1, nearest + 1); + // Content scrolling right: at least origin+1, otherwise wherever momentum landed. + return Math.max(nearest, Math.min(origin + 1, columns.length - 1)); } - - // Content scrolling left: next column on the left of travel. - if (viewportCenter - CENTER_TOLERANCE_PX > nearestCenter) { - return nearest; - } - return Math.max(0, nearest - 1); + // Content scrolling left: mirror. + return Math.min(nearest, Math.max(origin - 1, 0)); } /** @@ -195,8 +192,12 @@ function hardJumpScrollLeft(scroller: HTMLElement, targetLeft: number): void { * Mobile board: free-scroll + momentum, then hard-page only in the scroll direction. * * FNXC:BoardNavigation 2026-07-22-18:00: - * Lock settle direction at finger-up from net gesture deltas. Target via - * resolveTargetIndexInScrollDirection so snap never goes against scroll. Pin until next touch. + * Lock settle direction at finger-up from net gesture deltas. Pin until next touch. + * + * FNXC:BoardNavigation 2026-07-22-21:05: + * Target via resolveSettleTargetIndex: nearest (mostly-on-screen) column, clamped to at least + * one column of progress from the gesture's origin column — commits short swipes without + * overshooting a fling that already decelerated onto a column. */ export function useColumnScrollSnap( scroller: HTMLElement | null, @@ -246,6 +247,8 @@ export function useColumnScrollSnap( */ let touchSequenceActive = false; let gestureStartScrollLeft = scroller.scrollLeft; + /** Column the viewport rested on when the gesture began — the paging baseline. */ + let gestureStartColumnIndex = 0; let lastScrollLeft = scroller.scrollLeft; let gestureStartClientX: number | null = null; let lastClientX: number | null = null; @@ -432,7 +435,7 @@ export function useColumnScrollSnap( const targetIndex = direction === 0 ? nearestColumnIndex(scroller, columns) - : resolveTargetIndexInScrollDirection(scroller, columns, direction); + : resolveSettleTargetIndex(scroller, columns, direction, gestureStartColumnIndex); const targetLeft = scrollLeftToCenterColumn(scroller, columns[targetIndex]); applySnapTo(targetLeft); }; @@ -459,6 +462,7 @@ export function useColumnScrollSnap( lockedDirection = 0; sawHorizontalMovement = false; gestureStartScrollLeft = scroller.scrollLeft; + gestureStartColumnIndex = nearestColumnIndex(scroller, getSnapColumns(scroller)); lastScrollLeft = scroller.scrollLeft; const clientX = getClientX(event); gestureStartClientX = clientX; @@ -487,6 +491,7 @@ export function useColumnScrollSnap( sawHorizontalMovement = false; lockedDirection = 0; gestureStartScrollLeft = scroller.scrollLeft; + gestureStartColumnIndex = nearestColumnIndex(scroller, getSnapColumns(scroller)); lastScrollLeft = scroller.scrollLeft; const clientX = getClientX(event); gestureStartClientX = clientX;