refactor(web): extract timeline touch helpers
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user