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 { buildProjectShiftMutationInput, createProjectDragState } from "./timelineProjectDrag.js";
import { beginProjectDragSession } from "./timelineProjectDragSession.js";
import {
forwardCanvasTouchEnd,
forwardCanvasTouchMove,
forwardTouchStartAsMouseDown,
} from "./timelineTouchEvents.js";
import {
completeMultiSelectDraft,
createMultiSelectState,
@@ -34,16 +39,7 @@ import {
import { beginCanvasMultiSelectSession } from "./timelineMultiSelectSession.js";
import { reconcileOptimisticEntries } from "./timelineOptimisticAllocations.js";
import { createRangeSelectionState, finalizeRangeSelection, updateRangeSelectionDraft } from "./timelineRangeSelection.js";
import {
createTouchCanvasPointerEvent,
createTouchMouseDownEvent,
type TouchCanvasPointerEvent,
type TouchMouseDownEvent,
} from "./timelineTouchAdapters.js";
import {
getTouchPoint,
resolveTouchDragDecision,
} from "./timelineTouch.js";
import { type TouchCanvasPointerEvent, type TouchMouseDownEvent } from "./timelineTouchAdapters.js";
const DRAG_CLICK_THRESHOLD_PX = 5;
@@ -796,10 +792,13 @@ export function useTimelineDrag({
endDate: Date;
},
) => {
e.preventDefault();
const point = getTouchPoint(e);
touchStartRef.current = { x: point.clientX, y: point.clientY, decided: true };
onProjectBarMouseDown(createTouchMouseDownEvent(point, e.currentTarget), opts);
forwardTouchStartAsMouseDown({
event: e,
touchStartRef,
decided: true,
onMouseDown: onProjectBarMouseDown,
opts,
});
},
[onProjectBarMouseDown],
);
@@ -821,10 +820,13 @@ export function useTimelineDrag({
scope?: AllocDragScope;
},
) => {
e.preventDefault();
const point = getTouchPoint(e);
touchStartRef.current = { x: point.clientX, y: point.clientY, decided: true };
onAllocMouseDown(createTouchMouseDownEvent(point, e.currentTarget), opts);
forwardTouchStartAsMouseDown({
event: e,
touchStartRef,
decided: true,
onMouseDown: onAllocMouseDown,
opts,
});
},
[onAllocMouseDown],
);
@@ -838,33 +840,31 @@ export function useTimelineDrag({
suggestedProjectId?: string;
},
) => {
e.preventDefault();
const point = getTouchPoint(e);
touchStartRef.current = { x: point.clientX, y: point.clientY, decided: false };
onRowMouseDown(createTouchMouseDownEvent(point, e.currentTarget), opts);
forwardTouchStartAsMouseDown({
event: e,
touchStartRef,
decided: false,
onMouseDown: onRowMouseDown,
opts,
});
},
[onRowMouseDown],
);
const onCanvasTouchMove = useCallback(
(e: React.TouchEvent) => {
const point = getTouchPoint(e);
// Scroll vs drag disambiguation: once decided, stick with the decision
const decision = resolveTouchDragDecision(touchStartRef.current, point);
touchStartRef.current = decision.nextState;
if (!decision.shouldHandleDrag) {
return;
}
onCanvasMouseMove(createTouchCanvasPointerEvent(point));
forwardCanvasTouchMove({
event: e,
touchStartRef,
onCanvasMouseMove,
});
},
[onCanvasMouseMove],
);
const onCanvasTouchEnd = useCallback(
async (e: React.TouchEvent) => {
await onCanvasMouseUp(createTouchCanvasPointerEvent(getTouchPoint(e)));
await forwardCanvasTouchEnd({ event: e, onCanvasMouseUp });
},
[onCanvasMouseUp],
);
+31 -12
View File
@@ -149,6 +149,33 @@ export const rules = [
],
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",
maxLines: 90,
@@ -404,12 +431,8 @@ export const rules = [
message: "timeline drag must keep live preview behavior delegated to the extracted helper module",
},
{
pattern: /from "\.\/timelineTouch\.js"/,
message: "timeline drag must keep touch fallback and drag disambiguation 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 "\.\/timelineTouchEvents\.js"/,
message: "timeline drag must keep touch event forwarding delegated to the extracted helper module",
},
{
pattern: /from "\.\/timelineMultiSelect\.js"/,
@@ -474,12 +497,8 @@ export const rules = [
message: "timeline drag must not re-inline live preview helper implementations",
},
{
pattern: /\bfunction toClientX\b/,
message: "timeline drag must not re-inline touch coordinate fallback helpers",
},
{
pattern: /as (?:unknown as )?React\.MouseEvent/,
message: "timeline drag must not re-inline synthetic touch pointer adapters",
pattern: /\b(?:getTouchPoint|resolveTouchDragDecision|createTouchCanvasPointerEvent|createTouchMouseDownEvent)\b/,
message: "timeline drag must not re-inline extracted touch event forwarding dependencies",
},
{
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 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 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 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");
@@ -99,6 +100,7 @@ describe("architecture guardrails", () => {
assert.ok(livePreviewRule);
assert.ok(touchRule);
assert.ok(touchAdaptersRule);
assert.ok(touchEventsRule);
assert.ok(multiSelectRule);
assert.ok(multiSelectSessionRule);
assert.ok(rangeRule);
@@ -119,8 +121,7 @@ describe("architecture guardrails", () => {
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 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 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 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",
@@ -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",
]);
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"), [
"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",
@@ -231,11 +240,11 @@ describe("architecture guardrails", () => {
assert.deepEqual(
evaluateRule(
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 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 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",
@@ -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 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: 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",
],
);
});