diff --git a/apps/web/src/hooks/timelineLivePreview.test.ts b/apps/web/src/hooks/timelineLivePreview.test.ts index d4a602a..b8ab2e0 100644 --- a/apps/web/src/hooks/timelineLivePreview.test.ts +++ b/apps/web/src/hooks/timelineLivePreview.test.ts @@ -40,13 +40,17 @@ function createSession(element: HTMLElement): LivePreviewSession { } function renderScheduledPreview(session: LivePreviewSession) { - let frameCallback: FrameRequestCallback | null = null; - vi.stubGlobal("requestAnimationFrame", vi.fn((callback: FrameRequestCallback) => { - frameCallback = callback; - return 1; - })); + const frameCallbacks: unknown[] = []; + vi.stubGlobal( + "requestAnimationFrame", + vi.fn((callback: unknown) => { + frameCallbacks.push(callback); + return 1; + }), + ); scheduleLivePreview(session); + const frameCallback = frameCallbacks[0] as ((timestamp: number) => void) | undefined; frameCallback?.(0); } diff --git a/apps/web/src/hooks/timelineTouch.test.ts b/apps/web/src/hooks/timelineTouch.test.ts new file mode 100644 index 0000000..f864345 --- /dev/null +++ b/apps/web/src/hooks/timelineTouch.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { getTouchPoint, resolveTouchDragDecision } from "./timelineTouch.js"; + +describe("timelineTouch", () => { + it("falls back from active touches to changedTouches and then zeroes", () => { + expect( + getTouchPoint({ + touches: [{ clientX: 12, clientY: 18 }], + changedTouches: [{ clientX: 40, clientY: 55 }], + }), + ).toEqual({ clientX: 12, clientY: 18 }); + + expect( + getTouchPoint({ + touches: [], + changedTouches: [{ clientX: 40, clientY: 55 }], + }), + ).toEqual({ clientX: 40, clientY: 55 }); + + expect( + getTouchPoint({ + touches: [], + changedTouches: [], + }), + ).toEqual({ clientX: 0, clientY: 0 }); + }); + + it("stays undecided and ignores tiny movements below the threshold", () => { + expect( + resolveTouchDragDecision( + { x: 100, y: 100, decided: false }, + { clientX: 106, clientY: 107 }, + ), + ).toEqual({ + nextState: { x: 100, y: 100, decided: false }, + shouldHandleDrag: false, + }); + }); + + it("lets vertical scrolling win once movement is mostly vertical", () => { + expect( + resolveTouchDragDecision( + { x: 100, y: 100, decided: false }, + { clientX: 106, clientY: 118 }, + ), + ).toEqual({ + nextState: { x: 100, y: 100, decided: true }, + shouldHandleDrag: false, + }); + }); + + it("switches to drag handling once horizontal movement wins", () => { + expect( + resolveTouchDragDecision( + { x: 100, y: 100, decided: false }, + { clientX: 118, clientY: 106 }, + ), + ).toEqual({ + nextState: { x: 100, y: 100, decided: true }, + shouldHandleDrag: true, + }); + }); + + it("keeps handling drag after the decision was already made", () => { + expect( + resolveTouchDragDecision( + { x: 100, y: 100, decided: true }, + { clientX: 101, clientY: 140 }, + ), + ).toEqual({ + nextState: { x: 100, y: 100, decided: true }, + shouldHandleDrag: true, + }); + }); +}); diff --git a/apps/web/src/hooks/timelineTouch.ts b/apps/web/src/hooks/timelineTouch.ts new file mode 100644 index 0000000..48beee5 --- /dev/null +++ b/apps/web/src/hooks/timelineTouch.ts @@ -0,0 +1,50 @@ +export type TouchPoint = { + clientX: number; + clientY: number; +}; + +export type TouchDecisionState = { + x: number; + y: number; + decided: boolean; +}; + +type TouchLike = { + clientX: number; + clientY: number; +}; + +type TouchEventLike = { + touches: ArrayLike; + changedTouches: ArrayLike; +}; + +export function getTouchPoint(event: TouchEventLike): TouchPoint { + const touch = event.touches[0] ?? event.changedTouches[0]; + return { + clientX: touch?.clientX ?? 0, + clientY: touch?.clientY ?? 0, + }; +} + +export function resolveTouchDragDecision( + state: TouchDecisionState, + point: TouchPoint, + thresholdPx = 8, +): { nextState: TouchDecisionState; shouldHandleDrag: boolean } { + if (state.decided) { + return { nextState: state, shouldHandleDrag: true }; + } + + const dx = Math.abs(point.clientX - state.x); + const dy = Math.abs(point.clientY - state.y); + if (dx <= thresholdPx && dy <= thresholdPx) { + return { nextState: state, shouldHandleDrag: false }; + } + + const nextState = { ...state, decided: true }; + return { + nextState, + shouldHandleDrag: dx >= dy, + }; +} diff --git a/apps/web/src/hooks/useTimelineDrag.ts b/apps/web/src/hooks/useTimelineDrag.ts index 73f239e..059edec 100644 --- a/apps/web/src/hooks/useTimelineDrag.ts +++ b/apps/web/src/hooks/useTimelineDrag.ts @@ -12,6 +12,7 @@ import { scheduleLivePreview, type LivePreviewSession, } from "./timelineLivePreview.js"; +import { getTouchPoint, resolveTouchDragDecision } from "./timelineTouch.js"; const DRAG_CLICK_THRESHOLD_PX = 5; @@ -1033,11 +1034,6 @@ export function useTimelineDrag({ // ── Touch support ─────────────────────────────────────────────────────────── - // Helper: extract clientX from a touch event (first active touch, then changedTouches as fallback) - function toClientX(e: React.TouchEvent): number { - return e.touches[0]?.clientX ?? e.changedTouches[0]?.clientX ?? 0; - } - const onProjectBarTouchStart = useCallback( ( e: React.TouchEvent, @@ -1049,10 +1045,11 @@ export function useTimelineDrag({ }, ) => { e.preventDefault(); - touchStartRef.current = { x: toClientX(e), y: e.touches[0]?.clientY ?? 0, decided: true }; + const point = getTouchPoint(e); + touchStartRef.current = { x: point.clientX, y: point.clientY, decided: true }; onProjectBarMouseDown( { - clientX: toClientX(e), + clientX: point.clientX, preventDefault: () => {}, stopPropagation: () => {}, } as unknown as React.MouseEvent, @@ -1080,10 +1077,11 @@ export function useTimelineDrag({ }, ) => { e.preventDefault(); - touchStartRef.current = { x: toClientX(e), y: e.touches[0]?.clientY ?? 0, decided: true }; + const point = getTouchPoint(e); + touchStartRef.current = { x: point.clientX, y: point.clientY, decided: true }; onAllocMouseDown( { - clientX: toClientX(e), + clientX: point.clientX, preventDefault: () => {}, stopPropagation: () => {}, } as unknown as React.MouseEvent, @@ -1103,10 +1101,11 @@ export function useTimelineDrag({ }, ) => { e.preventDefault(); - touchStartRef.current = { x: toClientX(e), y: e.touches[0]?.clientY ?? 0, decided: false }; + const point = getTouchPoint(e); + touchStartRef.current = { x: point.clientX, y: point.clientY, decided: false }; onRowMouseDown( { - clientX: toClientX(e), + clientX: point.clientX, preventDefault: () => {}, stopPropagation: () => {}, } as unknown as React.MouseEvent, @@ -1118,30 +1117,23 @@ export function useTimelineDrag({ const onCanvasTouchMove = useCallback( (e: React.TouchEvent) => { - const touch = e.touches[0]; - if (!touch) return; + const point = getTouchPoint(e); // Scroll vs drag disambiguation: once decided, stick with the decision - if (!touchStartRef.current.decided) { - const dx = Math.abs(touch.clientX - touchStartRef.current.x); - const dy = Math.abs(touch.clientY - touchStartRef.current.y); - if (dx > 8 || dy > 8) { - touchStartRef.current.decided = true; - if (dy > dx) return; // vertical scroll wins — don't intercept - } else { - return; // haven't moved enough to decide yet - } + const decision = resolveTouchDragDecision(touchStartRef.current, point); + touchStartRef.current = decision.nextState; + if (!decision.shouldHandleDrag) { + return; } - onCanvasMouseMove({ clientX: touch.clientX } as React.MouseEvent); + onCanvasMouseMove({ clientX: point.clientX } as React.MouseEvent); }, [onCanvasMouseMove], ); const onCanvasTouchEnd = useCallback( async (e: React.TouchEvent) => { - const clientX = e.changedTouches[0]?.clientX ?? 0; - const clientY = e.changedTouches[0]?.clientY ?? 0; + const { clientX, clientY } = getTouchPoint(e); await onCanvasMouseUp({ clientX, clientY } as React.MouseEvent); }, [onCanvasMouseUp],