refactor(web): extract timeline touch helpers

This commit is contained in:
2026-04-01 09:48:04 +02:00
parent 167eec31de
commit 3abb3bc865
4 changed files with 151 additions and 30 deletions
@@ -40,13 +40,17 @@ function createSession(element: HTMLElement): LivePreviewSession {
} }
function renderScheduledPreview(session: LivePreviewSession) { function renderScheduledPreview(session: LivePreviewSession) {
let frameCallback: FrameRequestCallback | null = null; const frameCallbacks: unknown[] = [];
vi.stubGlobal("requestAnimationFrame", vi.fn((callback: FrameRequestCallback) => { vi.stubGlobal(
frameCallback = callback; "requestAnimationFrame",
return 1; vi.fn((callback: unknown) => {
})); frameCallbacks.push(callback);
return 1;
}),
);
scheduleLivePreview(session); scheduleLivePreview(session);
const frameCallback = frameCallbacks[0] as ((timestamp: number) => void) | undefined;
frameCallback?.(0); frameCallback?.(0);
} }
+75
View File
@@ -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,
});
});
});
+50
View File
@@ -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<TouchLike>;
changedTouches: ArrayLike<TouchLike>;
};
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,
};
}
+17 -25
View File
@@ -12,6 +12,7 @@ import {
scheduleLivePreview, scheduleLivePreview,
type LivePreviewSession, type LivePreviewSession,
} from "./timelineLivePreview.js"; } from "./timelineLivePreview.js";
import { getTouchPoint, resolveTouchDragDecision } from "./timelineTouch.js";
const DRAG_CLICK_THRESHOLD_PX = 5; const DRAG_CLICK_THRESHOLD_PX = 5;
@@ -1033,11 +1034,6 @@ export function useTimelineDrag({
// ── Touch support ─────────────────────────────────────────────────────────── // ── 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( const onProjectBarTouchStart = useCallback(
( (
e: React.TouchEvent, e: React.TouchEvent,
@@ -1049,10 +1045,11 @@ export function useTimelineDrag({
}, },
) => { ) => {
e.preventDefault(); 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( onProjectBarMouseDown(
{ {
clientX: toClientX(e), clientX: point.clientX,
preventDefault: () => {}, preventDefault: () => {},
stopPropagation: () => {}, stopPropagation: () => {},
} as unknown as React.MouseEvent, } as unknown as React.MouseEvent,
@@ -1080,10 +1077,11 @@ export function useTimelineDrag({
}, },
) => { ) => {
e.preventDefault(); 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( onAllocMouseDown(
{ {
clientX: toClientX(e), clientX: point.clientX,
preventDefault: () => {}, preventDefault: () => {},
stopPropagation: () => {}, stopPropagation: () => {},
} as unknown as React.MouseEvent, } as unknown as React.MouseEvent,
@@ -1103,10 +1101,11 @@ export function useTimelineDrag({
}, },
) => { ) => {
e.preventDefault(); 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( onRowMouseDown(
{ {
clientX: toClientX(e), clientX: point.clientX,
preventDefault: () => {}, preventDefault: () => {},
stopPropagation: () => {}, stopPropagation: () => {},
} as unknown as React.MouseEvent, } as unknown as React.MouseEvent,
@@ -1118,30 +1117,23 @@ export function useTimelineDrag({
const onCanvasTouchMove = useCallback( const onCanvasTouchMove = useCallback(
(e: React.TouchEvent) => { (e: React.TouchEvent) => {
const touch = e.touches[0]; const point = getTouchPoint(e);
if (!touch) return;
// Scroll vs drag disambiguation: once decided, stick with the decision // Scroll vs drag disambiguation: once decided, stick with the decision
if (!touchStartRef.current.decided) { const decision = resolveTouchDragDecision(touchStartRef.current, point);
const dx = Math.abs(touch.clientX - touchStartRef.current.x); touchStartRef.current = decision.nextState;
const dy = Math.abs(touch.clientY - touchStartRef.current.y); if (!decision.shouldHandleDrag) {
if (dx > 8 || dy > 8) { return;
touchStartRef.current.decided = true;
if (dy > dx) return; // vertical scroll wins — don't intercept
} else {
return; // haven't moved enough to decide yet
}
} }
onCanvasMouseMove({ clientX: touch.clientX } as React.MouseEvent); onCanvasMouseMove({ clientX: point.clientX } as React.MouseEvent);
}, },
[onCanvasMouseMove], [onCanvasMouseMove],
); );
const onCanvasTouchEnd = useCallback( const onCanvasTouchEnd = useCallback(
async (e: React.TouchEvent) => { async (e: React.TouchEvent) => {
const clientX = e.changedTouches[0]?.clientX ?? 0; const { clientX, clientY } = getTouchPoint(e);
const clientY = e.changedTouches[0]?.clientY ?? 0;
await onCanvasMouseUp({ clientX, clientY } as React.MouseEvent); await onCanvasMouseUp({ clientX, clientY } as React.MouseEvent);
}, },
[onCanvasMouseUp], [onCanvasMouseUp],