refactor(web): extract touch event forwarding

This commit is contained in:
2026-04-01 11:39:39 +02:00
parent 37c6e03d23
commit 463caedcfd
5 changed files with 210 additions and 50 deletions
@@ -0,0 +1,81 @@
import type { TouchEvent } from "react";
import { describe, expect, it, vi } from "vitest";
import {
forwardCanvasTouchEnd,
forwardCanvasTouchMove,
forwardTouchStartAsMouseDown,
} from "./timelineTouchEvents.js";
function createTouchEvent<TCurrentTarget>(
target: TCurrentTarget,
point: { clientX: number; clientY: number },
) {
return {
currentTarget: target,
preventDefault: vi.fn(),
touches: [point],
changedTouches: [point],
} as unknown as TouchEvent<TCurrentTarget>;
}
describe("timelineTouchEvents", () => {
it("forwards touch starts as mouse-down events and marks the decision state", () => {
const onMouseDown = vi.fn();
const touchStartRef = { current: { x: 0, y: 0, decided: false } };
const event = createTouchEvent({ id: "row" }, { clientX: 18, clientY: 42 });
forwardTouchStartAsMouseDown({
event,
touchStartRef,
decided: true,
onMouseDown,
opts: { allocationId: "alloc-1" },
});
expect(event.preventDefault).toHaveBeenCalledOnce();
expect(touchStartRef.current).toEqual({ x: 18, y: 42, decided: true });
expect(onMouseDown).toHaveBeenCalledWith(
expect.objectContaining({ clientX: 18, button: 0, currentTarget: { id: "row" } }),
{ allocationId: "alloc-1" },
);
});
it("keeps touch move in scroll mode until drag handling is approved", () => {
const onCanvasMouseMove = vi.fn();
const touchStartRef = { current: { x: 10, y: 10, decided: false } };
forwardCanvasTouchMove({
event: createTouchEvent({ id: "canvas" }, { clientX: 11, clientY: 28 }),
touchStartRef,
onCanvasMouseMove,
});
expect(onCanvasMouseMove).not.toHaveBeenCalled();
expect(touchStartRef.current).toEqual({ x: 10, y: 10, decided: true });
});
it("forwards touch move once the touch policy resolves to drag handling", () => {
const onCanvasMouseMove = vi.fn();
const touchStartRef = { current: { x: 10, y: 10, decided: false } };
forwardCanvasTouchMove({
event: createTouchEvent({ id: "canvas" }, { clientX: 40, clientY: 12 }),
touchStartRef,
onCanvasMouseMove,
});
expect(onCanvasMouseMove).toHaveBeenCalledWith(expect.objectContaining({ clientX: 40, clientY: 12 }));
expect(touchStartRef.current).toEqual({ x: 10, y: 10, decided: true });
});
it("awaits touch-end forwarding through the canvas mouse-up path", async () => {
const onCanvasMouseUp = vi.fn().mockResolvedValue(undefined);
await forwardCanvasTouchEnd({
event: createTouchEvent({ id: "canvas" }, { clientX: 55, clientY: 21 }),
onCanvasMouseUp,
});
expect(onCanvasMouseUp).toHaveBeenCalledWith(expect.objectContaining({ clientX: 55, clientY: 21 }));
});
});
+51
View File
@@ -0,0 +1,51 @@
import type { TouchEvent } from "react";
import { createTouchCanvasPointerEvent, createTouchMouseDownEvent } from "./timelineTouchAdapters.js";
import { getTouchPoint, resolveTouchDragDecision } from "./timelineTouch.js";
type MutableCurrent<T> = { current: T };
type TouchDecisionState = { x: number; y: number; decided: boolean };
export function forwardTouchStartAsMouseDown<TCurrentTarget, TOptions>({
event,
touchStartRef,
decided,
onMouseDown,
opts,
}: {
event: TouchEvent<TCurrentTarget>;
touchStartRef: MutableCurrent<TouchDecisionState>;
decided: boolean;
onMouseDown: (event: ReturnType<typeof createTouchMouseDownEvent>, opts: TOptions) => void;
opts: TOptions;
}) {
event.preventDefault();
const point = getTouchPoint(event);
touchStartRef.current = { x: point.clientX, y: point.clientY, decided };
onMouseDown(createTouchMouseDownEvent(point, event.currentTarget), opts);
}
export function forwardCanvasTouchMove<TCurrentTarget>({
event,
touchStartRef,
onCanvasMouseMove,
}: {
event: TouchEvent<TCurrentTarget>;
touchStartRef: MutableCurrent<TouchDecisionState>;
onCanvasMouseMove: (event: ReturnType<typeof createTouchCanvasPointerEvent>) => void;
}) {
const point = getTouchPoint(event);
const decision = resolveTouchDragDecision(touchStartRef.current, point);
touchStartRef.current = decision.nextState;
if (!decision.shouldHandleDrag) return;
onCanvasMouseMove(createTouchCanvasPointerEvent(point));
}
export function forwardCanvasTouchEnd<TCurrentTarget>({
event,
onCanvasMouseUp,
}: {
event: TouchEvent<TCurrentTarget>;
onCanvasMouseUp: (event: ReturnType<typeof createTouchCanvasPointerEvent>) => Promise<void> | void;
}) {
return onCanvasMouseUp(createTouchCanvasPointerEvent(getTouchPoint(event)));
}
+33 -33
View File
@@ -26,6 +26,11 @@ import { resolveAllocationDragPosition, resolveProjectDragPosition } from "./tim
import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js"; import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js";
import { buildProjectShiftMutationInput, createProjectDragState } from "./timelineProjectDrag.js"; import { buildProjectShiftMutationInput, createProjectDragState } from "./timelineProjectDrag.js";
import { beginProjectDragSession } from "./timelineProjectDragSession.js"; import { beginProjectDragSession } from "./timelineProjectDragSession.js";
import {
forwardCanvasTouchEnd,
forwardCanvasTouchMove,
forwardTouchStartAsMouseDown,
} from "./timelineTouchEvents.js";
import { import {
completeMultiSelectDraft, completeMultiSelectDraft,
createMultiSelectState, createMultiSelectState,
@@ -34,16 +39,7 @@ import {
import { beginCanvasMultiSelectSession } from "./timelineMultiSelectSession.js"; import { beginCanvasMultiSelectSession } from "./timelineMultiSelectSession.js";
import { reconcileOptimisticEntries } from "./timelineOptimisticAllocations.js"; import { reconcileOptimisticEntries } from "./timelineOptimisticAllocations.js";
import { createRangeSelectionState, finalizeRangeSelection, updateRangeSelectionDraft } from "./timelineRangeSelection.js"; import { createRangeSelectionState, finalizeRangeSelection, updateRangeSelectionDraft } from "./timelineRangeSelection.js";
import { import { type TouchCanvasPointerEvent, type TouchMouseDownEvent } from "./timelineTouchAdapters.js";
createTouchCanvasPointerEvent,
createTouchMouseDownEvent,
type TouchCanvasPointerEvent,
type TouchMouseDownEvent,
} from "./timelineTouchAdapters.js";
import {
getTouchPoint,
resolveTouchDragDecision,
} from "./timelineTouch.js";
const DRAG_CLICK_THRESHOLD_PX = 5; const DRAG_CLICK_THRESHOLD_PX = 5;
@@ -796,10 +792,13 @@ export function useTimelineDrag({
endDate: Date; endDate: Date;
}, },
) => { ) => {
e.preventDefault(); forwardTouchStartAsMouseDown({
const point = getTouchPoint(e); event: e,
touchStartRef.current = { x: point.clientX, y: point.clientY, decided: true }; touchStartRef,
onProjectBarMouseDown(createTouchMouseDownEvent(point, e.currentTarget), opts); decided: true,
onMouseDown: onProjectBarMouseDown,
opts,
});
}, },
[onProjectBarMouseDown], [onProjectBarMouseDown],
); );
@@ -821,10 +820,13 @@ export function useTimelineDrag({
scope?: AllocDragScope; scope?: AllocDragScope;
}, },
) => { ) => {
e.preventDefault(); forwardTouchStartAsMouseDown({
const point = getTouchPoint(e); event: e,
touchStartRef.current = { x: point.clientX, y: point.clientY, decided: true }; touchStartRef,
onAllocMouseDown(createTouchMouseDownEvent(point, e.currentTarget), opts); decided: true,
onMouseDown: onAllocMouseDown,
opts,
});
}, },
[onAllocMouseDown], [onAllocMouseDown],
); );
@@ -838,33 +840,31 @@ export function useTimelineDrag({
suggestedProjectId?: string; suggestedProjectId?: string;
}, },
) => { ) => {
e.preventDefault(); forwardTouchStartAsMouseDown({
const point = getTouchPoint(e); event: e,
touchStartRef.current = { x: point.clientX, y: point.clientY, decided: false }; touchStartRef,
onRowMouseDown(createTouchMouseDownEvent(point, e.currentTarget), opts); decided: false,
onMouseDown: onRowMouseDown,
opts,
});
}, },
[onRowMouseDown], [onRowMouseDown],
); );
const onCanvasTouchMove = useCallback( const onCanvasTouchMove = useCallback(
(e: React.TouchEvent) => { (e: React.TouchEvent) => {
const point = getTouchPoint(e); forwardCanvasTouchMove({
event: e,
// Scroll vs drag disambiguation: once decided, stick with the decision touchStartRef,
const decision = resolveTouchDragDecision(touchStartRef.current, point); onCanvasMouseMove,
touchStartRef.current = decision.nextState; });
if (!decision.shouldHandleDrag) {
return;
}
onCanvasMouseMove(createTouchCanvasPointerEvent(point));
}, },
[onCanvasMouseMove], [onCanvasMouseMove],
); );
const onCanvasTouchEnd = useCallback( const onCanvasTouchEnd = useCallback(
async (e: React.TouchEvent) => { async (e: React.TouchEvent) => {
await onCanvasMouseUp(createTouchCanvasPointerEvent(getTouchPoint(e))); await forwardCanvasTouchEnd({ event: e, onCanvasMouseUp });
}, },
[onCanvasMouseUp], [onCanvasMouseUp],
); );
+31 -12
View File
@@ -149,6 +149,33 @@ export const rules = [
], ],
forbidden: [], forbidden: [],
}, },
{
file: "apps/web/src/hooks/timelineTouchEvents.ts",
maxLines: 80,
required: [
{
pattern: /\bexport function forwardTouchStartAsMouseDown\b/,
message: "timeline touch event helpers must keep touch-start forwarding centralized",
},
{
pattern: /\bexport function forwardCanvasTouchMove\b/,
message: "timeline touch event helpers must keep touch-move forwarding centralized",
},
{
pattern: /\bexport function forwardCanvasTouchEnd\b/,
message: "timeline touch event helpers must keep touch-end forwarding centralized",
},
{
pattern: /from "\.\/timelineTouch\.js"/,
message: "timeline touch event helpers must keep touch policy delegated to the extracted helper module",
},
{
pattern: /from "\.\/timelineTouchAdapters\.js"/,
message: "timeline touch event helpers must keep touch adapter wiring delegated to the extracted helper module",
},
],
forbidden: [],
},
{ {
file: "apps/web/src/hooks/timelineMultiSelect.ts", file: "apps/web/src/hooks/timelineMultiSelect.ts",
maxLines: 90, maxLines: 90,
@@ -404,12 +431,8 @@ export const rules = [
message: "timeline drag must keep live preview behavior delegated to the extracted helper module", message: "timeline drag must keep live preview behavior delegated to the extracted helper module",
}, },
{ {
pattern: /from "\.\/timelineTouch\.js"/, pattern: /from "\.\/timelineTouchEvents\.js"/,
message: "timeline drag must keep touch fallback and drag disambiguation delegated to the extracted helper module", message: "timeline drag must keep touch event forwarding delegated to the extracted helper module",
},
{
pattern: /from "\.\/timelineTouchAdapters\.js"/,
message: "timeline drag must keep touch pointer adapter wiring delegated to the extracted helper module",
}, },
{ {
pattern: /from "\.\/timelineMultiSelect\.js"/, pattern: /from "\.\/timelineMultiSelect\.js"/,
@@ -474,12 +497,8 @@ export const rules = [
message: "timeline drag must not re-inline live preview helper implementations", message: "timeline drag must not re-inline live preview helper implementations",
}, },
{ {
pattern: /\bfunction toClientX\b/, pattern: /\b(?:getTouchPoint|resolveTouchDragDecision|createTouchCanvasPointerEvent|createTouchMouseDownEvent)\b/,
message: "timeline drag must not re-inline touch coordinate fallback helpers", message: "timeline drag must not re-inline extracted touch event forwarding dependencies",
},
{
pattern: /as (?:unknown as )?React\.MouseEvent/,
message: "timeline drag must not re-inline synthetic touch pointer adapters",
}, },
{ {
pattern: /\bfunction (?:hasAllocationDateChange|shouldTreatAllocationDragAsClick|requiresAllocationFragmentExtraction|buildAllocationMovedSnapshot|reconcileOptimisticEntries)\b/, pattern: /\bfunction (?:hasAllocationDateChange|shouldTreatAllocationDragAsClick|requiresAllocationFragmentExtraction|buildAllocationMovedSnapshot|reconcileOptimisticEntries)\b/,
+14 -5
View File
@@ -73,6 +73,7 @@ describe("architecture guardrails", () => {
const livePreviewRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineLivePreview.ts"); const livePreviewRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineLivePreview.ts");
const touchRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineTouch.ts"); const touchRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineTouch.ts");
const touchAdaptersRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineTouchAdapters.ts"); const touchAdaptersRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineTouchAdapters.ts");
const touchEventsRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineTouchEvents.ts");
const multiSelectRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineMultiSelect.ts"); const multiSelectRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineMultiSelect.ts");
const multiSelectSessionRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineMultiSelectSession.ts"); const multiSelectSessionRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineMultiSelectSession.ts");
const rangeRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineRangeSelection.ts"); const rangeRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineRangeSelection.ts");
@@ -99,6 +100,7 @@ describe("architecture guardrails", () => {
assert.ok(livePreviewRule); assert.ok(livePreviewRule);
assert.ok(touchRule); assert.ok(touchRule);
assert.ok(touchAdaptersRule); assert.ok(touchAdaptersRule);
assert.ok(touchEventsRule);
assert.ok(multiSelectRule); assert.ok(multiSelectRule);
assert.ok(multiSelectSessionRule); assert.ok(multiSelectSessionRule);
assert.ok(rangeRule); assert.ok(rangeRule);
@@ -119,8 +121,7 @@ describe("architecture guardrails", () => {
assert.deepEqual(evaluateRule(dragRule, "function clearLivePreview() {}\n"), [ assert.deepEqual(evaluateRule(dragRule, "function clearLivePreview() {}\n"), [
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep live preview behavior delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep live preview behavior delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep touch fallback and drag disambiguation delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep touch event forwarding delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep touch pointer adapter wiring delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep multi-select rectangle lifecycle delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep multi-select rectangle lifecycle delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep multi-select document session wiring delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep multi-select document session wiring delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep range preview and finalization delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep range preview and finalization delegated to the extracted helper module",
@@ -152,6 +153,14 @@ describe("architecture guardrails", () => {
"apps/web/src/hooks/timelineTouchAdapters.ts: missing guardrail anchor: timeline touch adapter helpers must keep canvas pointer adapter wiring centralized", "apps/web/src/hooks/timelineTouchAdapters.ts: missing guardrail anchor: timeline touch adapter helpers must keep canvas pointer adapter wiring centralized",
]); ]);
assert.deepEqual(evaluateRule(touchEventsRule, ""), [
"apps/web/src/hooks/timelineTouchEvents.ts: missing guardrail anchor: timeline touch event helpers must keep touch-start forwarding centralized",
"apps/web/src/hooks/timelineTouchEvents.ts: missing guardrail anchor: timeline touch event helpers must keep touch-move forwarding centralized",
"apps/web/src/hooks/timelineTouchEvents.ts: missing guardrail anchor: timeline touch event helpers must keep touch-end forwarding centralized",
"apps/web/src/hooks/timelineTouchEvents.ts: missing guardrail anchor: timeline touch event helpers must keep touch policy delegated to the extracted helper module",
"apps/web/src/hooks/timelineTouchEvents.ts: missing guardrail anchor: timeline touch event helpers must keep touch adapter wiring delegated to the extracted helper module",
]);
assert.deepEqual(evaluateRule(multiSelectRule, "export function createMultiSelectState() {}\n"), [ assert.deepEqual(evaluateRule(multiSelectRule, "export function createMultiSelectState() {}\n"), [
"apps/web/src/hooks/timelineMultiSelect.ts: missing guardrail anchor: timeline multi-select helpers must keep minimal-drag reset logic centralized", "apps/web/src/hooks/timelineMultiSelect.ts: missing guardrail anchor: timeline multi-select helpers must keep minimal-drag reset logic centralized",
"apps/web/src/hooks/timelineMultiSelect.ts: missing guardrail anchor: timeline multi-select helpers must keep right-click release completion centralized", "apps/web/src/hooks/timelineMultiSelect.ts: missing guardrail anchor: timeline multi-select helpers must keep right-click release completion centralized",
@@ -231,11 +240,11 @@ describe("architecture guardrails", () => {
assert.deepEqual( assert.deepEqual(
evaluateRule( evaluateRule(
dragRule, dragRule,
'import { getTouchPoint } from "./timelineTouch.js";\nconst e = {} as unknown as React.MouseEvent;\n', 'import { getTouchPoint } from "./timelineTouch.js";\n',
), ),
[ [
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep live preview behavior delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep live preview behavior delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep touch pointer adapter wiring delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep touch event forwarding delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep multi-select rectangle lifecycle delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep multi-select rectangle lifecycle delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep multi-select document session wiring delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep multi-select document session wiring delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep range preview and finalization delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep range preview and finalization delegated to the extracted helper module",
@@ -250,7 +259,7 @@ describe("architecture guardrails", () => {
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation release side effects delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation release side effects delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project drag bootstrap and mutation gating delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project drag bootstrap and mutation gating delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project drag document session wiring delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project drag document session wiring delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: forbidden pattern matched: timeline drag must not re-inline synthetic touch pointer adapters", "apps/web/src/hooks/useTimelineDrag.ts: forbidden pattern matched: timeline drag must not re-inline extracted touch event forwarding dependencies",
], ],
); );
}); });