diff --git a/apps/web/src/hooks/timelineTouchEvents.test.ts b/apps/web/src/hooks/timelineTouchEvents.test.ts new file mode 100644 index 0000000..51ac943 --- /dev/null +++ b/apps/web/src/hooks/timelineTouchEvents.test.ts @@ -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( + target: TCurrentTarget, + point: { clientX: number; clientY: number }, +) { + return { + currentTarget: target, + preventDefault: vi.fn(), + touches: [point], + changedTouches: [point], + } as unknown as TouchEvent; +} + +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 })); + }); +}); diff --git a/apps/web/src/hooks/timelineTouchEvents.ts b/apps/web/src/hooks/timelineTouchEvents.ts new file mode 100644 index 0000000..6e5971d --- /dev/null +++ b/apps/web/src/hooks/timelineTouchEvents.ts @@ -0,0 +1,51 @@ +import type { TouchEvent } from "react"; +import { createTouchCanvasPointerEvent, createTouchMouseDownEvent } from "./timelineTouchAdapters.js"; +import { getTouchPoint, resolveTouchDragDecision } from "./timelineTouch.js"; + +type MutableCurrent = { current: T }; +type TouchDecisionState = { x: number; y: number; decided: boolean }; + +export function forwardTouchStartAsMouseDown({ + event, + touchStartRef, + decided, + onMouseDown, + opts, +}: { + event: TouchEvent; + touchStartRef: MutableCurrent; + decided: boolean; + onMouseDown: (event: ReturnType, 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({ + event, + touchStartRef, + onCanvasMouseMove, +}: { + event: TouchEvent; + touchStartRef: MutableCurrent; + onCanvasMouseMove: (event: ReturnType) => 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({ + event, + onCanvasMouseUp, +}: { + event: TouchEvent; + onCanvasMouseUp: (event: ReturnType) => Promise | void; +}) { + return onCanvasMouseUp(createTouchCanvasPointerEvent(getTouchPoint(event))); +} diff --git a/apps/web/src/hooks/useTimelineDrag.ts b/apps/web/src/hooks/useTimelineDrag.ts index d110b09..bb04861 100644 --- a/apps/web/src/hooks/useTimelineDrag.ts +++ b/apps/web/src/hooks/useTimelineDrag.ts @@ -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], ); diff --git a/scripts/check-architecture-guardrails.mjs b/scripts/check-architecture-guardrails.mjs index ca9bdc2..3ad3bfa 100644 --- a/scripts/check-architecture-guardrails.mjs +++ b/scripts/check-architecture-guardrails.mjs @@ -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/, diff --git a/scripts/check-architecture-guardrails.test.mjs b/scripts/check-architecture-guardrails.test.mjs index a7f5c15..1cfdfb4 100644 --- a/scripts/check-architecture-guardrails.test.mjs +++ b/scripts/check-architecture-guardrails.test.mjs @@ -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", ], ); });