refactor(web): extract timeline multi-select helpers

This commit is contained in:
2026-04-01 09:50:03 +02:00
parent 3abb3bc865
commit 43f04d66c8
3 changed files with 178 additions and 21 deletions
@@ -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<TestMultiSelectState>(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<TestMultiSelectState>(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<TestMultiSelectState>(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<TestMultiSelectState>(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,
});
});
});
+67
View File
@@ -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<TState extends MultiSelectStateLike>(
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<TState extends MultiSelectStateLike>(
state: TState,
currentX: number,
currentY: number,
): TState {
return {
...state,
currentX,
currentY,
};
}
export function finalizeMultiSelectDraft<TState extends MultiSelectStateLike>(
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,
};
}
+10 -21
View File
@@ -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<MultiSelectState>(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);
}