refactor(web): centralize multi-select release handling

This commit is contained in:
2026-04-01 10:50:21 +02:00
parent ca947befde
commit a4789d718b
5 changed files with 94 additions and 16 deletions
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
completeMultiSelectDraft,
createMultiSelectState,
finalizeMultiSelectDraft,
updateMultiSelectDraft,
@@ -98,4 +99,67 @@ describe("timelineMultiSelect", () => {
currentY: 295,
});
});
it("returns the provided empty state when the right-click drag never really started", () => {
const state = createMultiSelectState<TestMultiSelectState>(120, 240, {
selectedAllocationIds: ["alloc-1"],
selectedResourceIds: ["res-1"],
dateRange: null,
multiDragDaysDelta: 1,
isMultiDragging: true,
multiDragMode: "move",
});
const emptyState: TestMultiSelectState = {
isSelecting: false,
startX: 0,
startY: 0,
currentX: 0,
currentY: 0,
selectedAllocationIds: [],
selectedResourceIds: [],
dateRange: null,
multiDragDaysDelta: 0,
isMultiDragging: false,
multiDragMode: "move",
};
expect(completeMultiSelectDraft(state, 123, 243, emptyState)).toEqual({
nextState: emptyState,
shouldClear: true,
});
});
it("keeps the finished rectangle when the drag cleared the minimum distance", () => {
const state = createMultiSelectState<TestMultiSelectState>(120, 240, {
selectedAllocationIds: [],
selectedResourceIds: [],
dateRange: null,
multiDragDaysDelta: 0,
isMultiDragging: false,
multiDragMode: "move",
});
const emptyState: TestMultiSelectState = {
isSelecting: false,
startX: 0,
startY: 0,
currentX: 0,
currentY: 0,
selectedAllocationIds: [],
selectedResourceIds: [],
dateRange: null,
multiDragDaysDelta: 0,
isMultiDragging: false,
multiDragMode: "move",
};
expect(completeMultiSelectDraft(state, 150, 295, emptyState)).toEqual({
nextState: {
...state,
isSelecting: false,
currentX: 150,
currentY: 295,
},
shouldClear: false,
});
});
});
+21
View File
@@ -65,3 +65,24 @@ export function finalizeMultiSelectDraft<TState extends MultiSelectStateLike>(
currentY,
};
}
export function completeMultiSelectDraft<TState extends MultiSelectStateLike>(
state: TState,
currentX: number,
currentY: number,
emptyState: TState,
minDistancePx = 5,
): { nextState: TState; shouldClear: boolean } {
const finished = finalizeMultiSelectDraft(state, currentX, currentY, minDistancePx);
if (!finished) {
return {
nextState: emptyState,
shouldClear: true,
};
}
return {
nextState: finished,
shouldClear: false,
};
}
+4 -16
View File
@@ -23,8 +23,8 @@ import { createAllocationDragState } from "./timelineAllocationDragState.js";
import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js";
import { buildProjectShiftMutationInput, createProjectDragState } from "./timelineProjectDrag.js";
import {
completeMultiSelectDraft,
createMultiSelectState,
finalizeMultiSelectDraft,
updateMultiSelectDraft,
} from "./timelineMultiSelect.js";
import { reconcileOptimisticEntries } from "./timelineOptimisticAllocations.js";
@@ -896,21 +896,9 @@ export function useTimelineDrag({
const ms = multiSelectRef.current;
if (!ms.isSelecting) return;
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;
setMultiSelectState(INITIAL_MULTI_SELECT);
return;
}
// Keep the rectangle coordinates for the parent to compute intersection.
// 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.
multiSelectRef.current = finished;
setMultiSelectState(finished);
const result = completeMultiSelectDraft(ms, ev.clientX, ev.clientY, INITIAL_MULTI_SELECT);
multiSelectRef.current = result.nextState;
setMultiSelectState(result.nextState);
}
multiSelectCleanupRef.current = attachDocumentMouseDrag(document, handleMove, handleUp);