diff --git a/apps/web/src/hooks/timelineMultiSelectSession.test.ts b/apps/web/src/hooks/timelineMultiSelectSession.test.ts new file mode 100644 index 0000000..613eecc --- /dev/null +++ b/apps/web/src/hooks/timelineMultiSelectSession.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from "vitest"; +import { beginCanvasMultiSelectSession } from "./timelineMultiSelectSession.js"; + +type TestState = { + isSelecting: boolean; + cursor: string; +}; + +describe("timelineMultiSelectSession", () => { + it("starts a session, forwards move updates, and finalizes on mouseup", () => { + const previousCleanup = vi.fn(); + const setState = vi.fn(); + const createInitialState = vi.fn((clientX: number, clientY: number) => ({ + isSelecting: true, + cursor: `${clientX}:${clientY}`, + })); + const updateState = vi.fn((_: TestState, clientX: number, clientY: number) => ({ + isSelecting: true, + cursor: `${clientX}:${clientY}`, + })); + const completeState = vi.fn((_: TestState, clientX: number, clientY: number, initialState: TestState) => ({ + nextState: { + ...initialState, + isSelecting: false, + cursor: `done:${clientX}:${clientY}`, + }, + })); + + const cleanupRef = { current: previousCleanup as (() => void) | null }; + const stateRef = { current: { isSelecting: false, cursor: "initial" } }; + const initialState = { isSelecting: false, cursor: "idle" }; + const handlers: { + move?: (event: MouseEvent) => void; + up?: (event: MouseEvent) => void; + } = {}; + + const attachDrag = vi.fn((_: Document, onMove: (event: MouseEvent) => void, onUp: (event: MouseEvent) => void) => { + handlers.move = onMove; + handlers.up = onUp; + return vi.fn(); + }); + + beginCanvasMultiSelectSession({ + clientX: 10, + clientY: 20, + documentTarget: {} as Document, + cleanupRef, + stateRef, + setState, + createInitialState, + updateState, + completeState, + initialState, + attachDrag, + }); + + expect(previousCleanup).toHaveBeenCalledOnce(); + expect(setState).toHaveBeenCalledWith({ isSelecting: true, cursor: "10:20" }); + expect(typeof handlers.move).toBe("function"); + expect(typeof handlers.up).toBe("function"); + + handlers.move?.({ clientX: 14, clientY: 24 } as MouseEvent); + expect(updateState).toHaveBeenCalledWith({ isSelecting: true, cursor: "10:20" }, 14, 24); + expect(stateRef.current).toEqual({ isSelecting: true, cursor: "14:24" }); + + const attachedCleanup = cleanupRef.current; + expect(attachedCleanup).not.toBeNull(); + handlers.up?.({ clientX: 18, clientY: 28 } as MouseEvent); + + expect(completeState).toHaveBeenCalledWith({ isSelecting: true, cursor: "14:24" }, 18, 28, initialState); + expect(setState).toHaveBeenLastCalledWith({ isSelecting: false, cursor: "done:18:28" }); + expect(stateRef.current).toEqual({ isSelecting: false, cursor: "done:18:28" }); + expect(cleanupRef.current).toBeNull(); + expect(attachedCleanup).toHaveBeenCalledOnce(); + }); + + it("stops updating when selection is already inactive before move and mouseup", () => { + const setState = vi.fn(); + const updateState = vi.fn(); + const completeState = vi.fn(); + const cleanupRef = { current: null as (() => void) | null }; + const stateRef = { current: { isSelecting: false, cursor: "idle" } }; + const handlers: { + move?: (event: MouseEvent) => void; + up?: (event: MouseEvent) => void; + } = {}; + + beginCanvasMultiSelectSession({ + clientX: 1, + clientY: 2, + documentTarget: {} as Document, + cleanupRef, + stateRef, + setState, + createInitialState: () => ({ isSelecting: true, cursor: "start" }), + updateState, + completeState, + initialState: { isSelecting: false, cursor: "idle" }, + attachDrag: (_documentTarget, onMove, onUp) => { + handlers.move = onMove; + handlers.up = onUp; + return vi.fn(); + }, + }); + + stateRef.current = { isSelecting: false, cursor: "cancelled" }; + + handlers.move?.({ clientX: 3, clientY: 4 } as MouseEvent); + handlers.up?.({ clientX: 5, clientY: 6 } as MouseEvent); + + expect(updateState).not.toHaveBeenCalled(); + expect(completeState).not.toHaveBeenCalled(); + expect(setState).toHaveBeenCalledTimes(1); + expect(cleanupRef.current).toBeNull(); + }); +}); diff --git a/apps/web/src/hooks/timelineMultiSelectSession.ts b/apps/web/src/hooks/timelineMultiSelectSession.ts new file mode 100644 index 0000000..71cf780 --- /dev/null +++ b/apps/web/src/hooks/timelineMultiSelectSession.ts @@ -0,0 +1,78 @@ +type MutableCurrent = { + current: T; +}; + +type AttachDocumentMouseDrag = ( + documentTarget: Document, + onMove: (event: MouseEvent) => void, + onUp: (event: MouseEvent) => void, +) => () => void; + +type MultiSelectSessionState = { + isSelecting: boolean; +}; + +type CompleteMultiSelectResult = { + nextState: TState; +}; + +type BeginCanvasMultiSelectSessionParams = { + clientX: number; + clientY: number; + documentTarget: Document; + cleanupRef: MutableCurrent<(() => void) | null>; + stateRef: MutableCurrent; + setState: (state: TState) => void; + createInitialState: (clientX: number, clientY: number) => TState; + updateState: (state: TState, clientX: number, clientY: number) => TState; + completeState: ( + state: TState, + clientX: number, + clientY: number, + initialState: TState, + ) => CompleteMultiSelectResult; + initialState: TState; + attachDrag: AttachDocumentMouseDrag; +}; + +export function beginCanvasMultiSelectSession({ + clientX, + clientY, + documentTarget, + cleanupRef, + stateRef, + setState, + createInitialState, + updateState, + completeState, + initialState, + attachDrag, +}: BeginCanvasMultiSelectSessionParams) { + const nextState = createInitialState(clientX, clientY); + stateRef.current = nextState; + setState(nextState); + cleanupRef.current?.(); + + function handleMove(event: MouseEvent) { + const currentState = stateRef.current; + if (!currentState.isSelecting) return; + + const updated = updateState(currentState, event.clientX, event.clientY); + stateRef.current = updated; + setState(updated); + } + + function handleUp(event: MouseEvent) { + cleanupRef.current?.(); + cleanupRef.current = null; + + const currentState = stateRef.current; + if (!currentState.isSelecting) return; + + const result = completeState(currentState, event.clientX, event.clientY, initialState); + stateRef.current = result.nextState; + setState(result.nextState); + } + + cleanupRef.current = attachDrag(documentTarget, handleMove, handleUp); +} diff --git a/apps/web/src/hooks/useTimelineDrag.ts b/apps/web/src/hooks/useTimelineDrag.ts index 53253cf..9d89274 100644 --- a/apps/web/src/hooks/useTimelineDrag.ts +++ b/apps/web/src/hooks/useTimelineDrag.ts @@ -28,6 +28,7 @@ import { createMultiSelectState, updateMultiSelectDraft, } from "./timelineMultiSelect.js"; +import { beginCanvasMultiSelectSession } from "./timelineMultiSelectSession.js"; import { reconcileOptimisticEntries } from "./timelineOptimisticAllocations.js"; import { createRangeSelectionState, finalizeRangeSelection, updateRangeSelectionDraft } from "./timelineRangeSelection.js"; import { @@ -878,40 +879,27 @@ export function useTimelineDrag({ if (e.button !== 2) return; e.preventDefault(); - const initial = createMultiSelectState(e.clientX, e.clientY, { - selectedAllocationIds: [], - selectedResourceIds: [], - dateRange: null, - multiDragDaysDelta: 0, - isMultiDragging: false, - multiDragMode: "move", + beginCanvasMultiSelectSession({ + clientX: e.clientX, + clientY: e.clientY, + documentTarget: document, + cleanupRef: multiSelectCleanupRef, + stateRef: multiSelectRef, + setState: setMultiSelectState, + createInitialState: (clientX, clientY) => + createMultiSelectState(clientX, clientY, { + selectedAllocationIds: [], + selectedResourceIds: [], + dateRange: null, + multiDragDaysDelta: 0, + isMultiDragging: false, + multiDragMode: "move", + }), + updateState: updateMultiSelectDraft, + completeState: completeMultiSelectDraft, + initialState: INITIAL_MULTI_SELECT, + attachDrag: attachDocumentMouseDrag, }); - multiSelectRef.current = initial; - setMultiSelectState(initial); - multiSelectCleanupRef.current?.(); - - function handleMove(ev: MouseEvent) { - const ms = multiSelectRef.current; - if (!ms.isSelecting) return; - - const updated = updateMultiSelectDraft(ms, ev.clientX, ev.clientY); - multiSelectRef.current = updated; - setMultiSelectState(updated); - } - - function handleUp(ev: MouseEvent) { - multiSelectCleanupRef.current?.(); - multiSelectCleanupRef.current = null; - - const ms = multiSelectRef.current; - if (!ms.isSelecting) return; - - const result = completeMultiSelectDraft(ms, ev.clientX, ev.clientY, INITIAL_MULTI_SELECT); - multiSelectRef.current = result.nextState; - setMultiSelectState(result.nextState); - } - - multiSelectCleanupRef.current = attachDocumentMouseDrag(document, handleMove, handleUp); }, []); const clearMultiSelect = useCallback(() => { diff --git a/scripts/check-architecture-guardrails.mjs b/scripts/check-architecture-guardrails.mjs index b454423..9060e20 100644 --- a/scripts/check-architecture-guardrails.mjs +++ b/scripts/check-architecture-guardrails.mjs @@ -168,6 +168,17 @@ export const rules = [ ], forbidden: [], }, + { + file: "apps/web/src/hooks/timelineMultiSelectSession.ts", + maxLines: 90, + required: [ + { + pattern: /\bexport function beginCanvasMultiSelectSession\b/, + message: "timeline multi-select session helpers must keep right-click session lifecycle centralized", + }, + ], + forbidden: [], + }, { file: "apps/web/src/hooks/timelineRangeSelection.ts", maxLines: 90, @@ -337,6 +348,10 @@ export const rules = [ pattern: /from "\.\/timelineMultiSelect\.js"/, message: "timeline drag must keep multi-select rectangle lifecycle delegated to the extracted helper module", }, + { + pattern: /from "\.\/timelineMultiSelectSession\.js"/, + message: "timeline drag must keep multi-select document session wiring delegated to the extracted helper module", + }, { pattern: /from "\.\/timelineRangeSelection\.js"/, message: "timeline drag must keep range preview and finalization delegated to the extracted helper module", diff --git a/scripts/check-architecture-guardrails.test.mjs b/scripts/check-architecture-guardrails.test.mjs index 353ce5e..126e58c 100644 --- a/scripts/check-architecture-guardrails.test.mjs +++ b/scripts/check-architecture-guardrails.test.mjs @@ -74,6 +74,7 @@ describe("architecture guardrails", () => { 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 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"); const optimisticRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineOptimisticAllocations.ts"); const allocationFinalizeRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineAllocationFinalize.ts"); @@ -90,6 +91,7 @@ describe("architecture guardrails", () => { assert.ok(touchRule); assert.ok(touchAdaptersRule); assert.ok(multiSelectRule); + assert.ok(multiSelectSessionRule); assert.ok(rangeRule); assert.ok(optimisticRule); assert.ok(allocationFinalizeRule); @@ -106,6 +108,7 @@ describe("architecture guardrails", () => { "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 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", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep optimistic allocation reconciliation delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation drag completion rules delegated to the extracted helper module", @@ -137,6 +140,10 @@ describe("architecture guardrails", () => { "apps/web/src/hooks/timelineMultiSelect.ts: missing guardrail anchor: timeline multi-select helpers must keep right-click release completion centralized", ]); + assert.deepEqual(evaluateRule(multiSelectSessionRule, ""), [ + "apps/web/src/hooks/timelineMultiSelectSession.ts: missing guardrail anchor: timeline multi-select session helpers must keep right-click session lifecycle centralized", + ]); + assert.deepEqual(evaluateRule(rangeRule, "export function updateRangeSelectionDraft() {}\n"), [ "apps/web/src/hooks/timelineRangeSelection.ts: missing guardrail anchor: timeline range helpers must keep selection bootstrap centralized", "apps/web/src/hooks/timelineRangeSelection.ts: missing guardrail anchor: timeline range helpers must keep ordered range finalization centralized", @@ -191,6 +198,7 @@ describe("architecture guardrails", () => { "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 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", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep optimistic allocation reconciliation delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation drag completion rules delegated to the extracted helper module",