diff --git a/apps/web/src/hooks/timelineMultiSelect.test.ts b/apps/web/src/hooks/timelineMultiSelect.test.ts new file mode 100644 index 0000000..8f41e2d --- /dev/null +++ b/apps/web/src/hooks/timelineMultiSelect.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { + createMultiSelectState, + finalizeMultiSelectDraft, + updateMultiSelectDraft, +} from "./timelineMultiSelect.js"; + +type TestMultiSelectState = { + isSelecting: boolean; + startX: number; + startY: number; + currentX: number; + currentY: number; + selectedAllocationIds: string[]; + selectedResourceIds: string[]; + dateRange: { start: Date; end: Date } | null; + multiDragDaysDelta: number; + isMultiDragging: boolean; + multiDragMode: "move" | "resize-start" | "resize-end"; +}; + +describe("timelineMultiSelect", () => { + it("creates a fresh drag-selection state with the provided origin", () => { + expect( + createMultiSelectState(120, 240, { + selectedAllocationIds: [], + selectedResourceIds: [], + dateRange: null, + multiDragDaysDelta: 0, + isMultiDragging: false, + multiDragMode: "move", + }), + ).toEqual({ + isSelecting: true, + startX: 120, + startY: 240, + currentX: 120, + currentY: 240, + selectedAllocationIds: [], + selectedResourceIds: [], + dateRange: null, + multiDragDaysDelta: 0, + isMultiDragging: false, + multiDragMode: "move", + }); + }); + + it("updates only the current rectangle coordinates while preserving selection metadata", () => { + const state = createMultiSelectState(120, 240, { + selectedAllocationIds: ["alloc-1"], + selectedResourceIds: ["res-1"], + dateRange: { + start: new Date("2025-01-01T00:00:00.000Z"), + end: new Date("2025-01-02T00:00:00.000Z"), + }, + multiDragDaysDelta: 3, + isMultiDragging: true, + multiDragMode: "resize-end", + }); + + expect(updateMultiSelectDraft(state, 155, 275)).toEqual({ + ...state, + currentX: 155, + currentY: 275, + }); + }); + + it("resets minimal right-click movement instead of producing a drag rectangle", () => { + const state = createMultiSelectState(120, 240, { + selectedAllocationIds: [], + selectedResourceIds: [], + dateRange: null, + multiDragDaysDelta: 0, + isMultiDragging: false, + multiDragMode: "move", + }); + + expect(finalizeMultiSelectDraft(state, 123, 243)).toBeNull(); + }); + + it("finalizes a real drag by freezing the rectangle and clearing only isSelecting", () => { + const state = createMultiSelectState(120, 240, { + selectedAllocationIds: ["alloc-1"], + selectedResourceIds: ["res-1"], + dateRange: { + start: new Date("2025-01-01T00:00:00.000Z"), + end: new Date("2025-01-02T00:00:00.000Z"), + }, + multiDragDaysDelta: 2, + isMultiDragging: true, + multiDragMode: "resize-start", + }); + + expect(finalizeMultiSelectDraft(state, 150, 295)).toEqual({ + ...state, + isSelecting: false, + currentX: 150, + currentY: 295, + }); + }); +}); diff --git a/apps/web/src/hooks/timelineMultiSelect.ts b/apps/web/src/hooks/timelineMultiSelect.ts new file mode 100644 index 0000000..6a064e2 --- /dev/null +++ b/apps/web/src/hooks/timelineMultiSelect.ts @@ -0,0 +1,67 @@ +type MultiSelectStateLike = { + isSelecting: boolean; + startX: number; + startY: number; + currentX: number; + currentY: number; + selectedAllocationIds: string[]; + selectedResourceIds: string[]; + dateRange: { start: Date; end: Date } | null; + multiDragDaysDelta: number; + isMultiDragging: boolean; + multiDragMode: string; +}; + +export function createMultiSelectState( + currentX: number, + currentY: number, + defaults: Pick< + TState, + | "selectedAllocationIds" + | "selectedResourceIds" + | "dateRange" + | "multiDragDaysDelta" + | "isMultiDragging" + | "multiDragMode" + >, +): TState { + return { + isSelecting: true, + startX: currentX, + startY: currentY, + currentX, + currentY, + ...defaults, + } as TState; +} + +export function updateMultiSelectDraft( + state: TState, + currentX: number, + currentY: number, +): TState { + return { + ...state, + currentX, + currentY, + }; +} + +export function finalizeMultiSelectDraft( + state: TState, + currentX: number, + currentY: number, + minDistancePx = 5, +): TState | null { + const distance = Math.hypot(currentX - state.startX, currentY - state.startY); + if (distance < minDistancePx) { + return null; + } + + return { + ...state, + isSelecting: false, + currentX, + currentY, + }; +} diff --git a/apps/web/src/hooks/useTimelineDrag.ts b/apps/web/src/hooks/useTimelineDrag.ts index 059edec..0ff5c4d 100644 --- a/apps/web/src/hooks/useTimelineDrag.ts +++ b/apps/web/src/hooks/useTimelineDrag.ts @@ -12,6 +12,11 @@ import { scheduleLivePreview, type LivePreviewSession, } from "./timelineLivePreview.js"; +import { + createMultiSelectState, + finalizeMultiSelectDraft, + updateMultiSelectDraft, +} from "./timelineMultiSelect.js"; import { getTouchPoint, resolveTouchDragDecision } from "./timelineTouch.js"; const DRAG_CLICK_THRESHOLD_PX = 5; @@ -958,19 +963,14 @@ export function useTimelineDrag({ if (e.button !== 2) return; e.preventDefault(); - const initial: MultiSelectState = { - isSelecting: true, - startX: e.clientX, - startY: e.clientY, - currentX: e.clientX, - currentY: e.clientY, + const initial = createMultiSelectState(e.clientX, e.clientY, { selectedAllocationIds: [], selectedResourceIds: [], dateRange: null, multiDragDaysDelta: 0, isMultiDragging: false, multiDragMode: "move", - }; + }); multiSelectRef.current = initial; setMultiSelectState(initial); multiSelectCleanupRef.current?.(); @@ -979,11 +979,7 @@ export function useTimelineDrag({ const ms = multiSelectRef.current; if (!ms.isSelecting) return; - const updated: MultiSelectState = { - ...ms, - currentX: ev.clientX, - currentY: ev.clientY, - }; + const updated = updateMultiSelectDraft(ms, ev.clientX, ev.clientY); multiSelectRef.current = updated; setMultiSelectState(updated); } @@ -995,9 +991,8 @@ export function useTimelineDrag({ const ms = multiSelectRef.current; if (!ms.isSelecting) return; - const distance = Math.hypot(ev.clientX - ms.startX, ev.clientY - ms.startY); - - if (distance < 5) { + const finished = finalizeMultiSelectDraft(ms, ev.clientX, ev.clientY); + if (!finished) { // Minimal movement → not a drag selection, reset. // Let existing onContextMenu handlers on allocation blocks handle right-click. multiSelectRef.current = INITIAL_MULTI_SELECT; @@ -1009,12 +1004,6 @@ export function useTimelineDrag({ // isSelecting is set to false to indicate the drag is done, but the // rectangle data (startX/Y, currentX/Y) is preserved so TimelineView // can resolve which allocations/resources fall within the selection. - const finished: MultiSelectState = { - ...ms, - isSelecting: false, - currentX: ev.clientX, - currentY: ev.clientY, - }; multiSelectRef.current = finished; setMultiSelectState(finished); }