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