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) {
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);
}
+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,
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],