refactor(web): extract timeline multi-select helpers
This commit is contained in:
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -12,6 +12,11 @@ import {
|
|||||||
scheduleLivePreview,
|
scheduleLivePreview,
|
||||||
type LivePreviewSession,
|
type LivePreviewSession,
|
||||||
} from "./timelineLivePreview.js";
|
} from "./timelineLivePreview.js";
|
||||||
|
import {
|
||||||
|
createMultiSelectState,
|
||||||
|
finalizeMultiSelectDraft,
|
||||||
|
updateMultiSelectDraft,
|
||||||
|
} from "./timelineMultiSelect.js";
|
||||||
import { getTouchPoint, resolveTouchDragDecision } from "./timelineTouch.js";
|
import { getTouchPoint, resolveTouchDragDecision } from "./timelineTouch.js";
|
||||||
|
|
||||||
const DRAG_CLICK_THRESHOLD_PX = 5;
|
const DRAG_CLICK_THRESHOLD_PX = 5;
|
||||||
@@ -958,19 +963,14 @@ export function useTimelineDrag({
|
|||||||
if (e.button !== 2) return;
|
if (e.button !== 2) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const initial: MultiSelectState = {
|
const initial = createMultiSelectState<MultiSelectState>(e.clientX, e.clientY, {
|
||||||
isSelecting: true,
|
|
||||||
startX: e.clientX,
|
|
||||||
startY: e.clientY,
|
|
||||||
currentX: e.clientX,
|
|
||||||
currentY: e.clientY,
|
|
||||||
selectedAllocationIds: [],
|
selectedAllocationIds: [],
|
||||||
selectedResourceIds: [],
|
selectedResourceIds: [],
|
||||||
dateRange: null,
|
dateRange: null,
|
||||||
multiDragDaysDelta: 0,
|
multiDragDaysDelta: 0,
|
||||||
isMultiDragging: false,
|
isMultiDragging: false,
|
||||||
multiDragMode: "move",
|
multiDragMode: "move",
|
||||||
};
|
});
|
||||||
multiSelectRef.current = initial;
|
multiSelectRef.current = initial;
|
||||||
setMultiSelectState(initial);
|
setMultiSelectState(initial);
|
||||||
multiSelectCleanupRef.current?.();
|
multiSelectCleanupRef.current?.();
|
||||||
@@ -979,11 +979,7 @@ export function useTimelineDrag({
|
|||||||
const ms = multiSelectRef.current;
|
const ms = multiSelectRef.current;
|
||||||
if (!ms.isSelecting) return;
|
if (!ms.isSelecting) return;
|
||||||
|
|
||||||
const updated: MultiSelectState = {
|
const updated = updateMultiSelectDraft(ms, ev.clientX, ev.clientY);
|
||||||
...ms,
|
|
||||||
currentX: ev.clientX,
|
|
||||||
currentY: ev.clientY,
|
|
||||||
};
|
|
||||||
multiSelectRef.current = updated;
|
multiSelectRef.current = updated;
|
||||||
setMultiSelectState(updated);
|
setMultiSelectState(updated);
|
||||||
}
|
}
|
||||||
@@ -995,9 +991,8 @@ export function useTimelineDrag({
|
|||||||
const ms = multiSelectRef.current;
|
const ms = multiSelectRef.current;
|
||||||
if (!ms.isSelecting) return;
|
if (!ms.isSelecting) return;
|
||||||
|
|
||||||
const distance = Math.hypot(ev.clientX - ms.startX, ev.clientY - ms.startY);
|
const finished = finalizeMultiSelectDraft(ms, ev.clientX, ev.clientY);
|
||||||
|
if (!finished) {
|
||||||
if (distance < 5) {
|
|
||||||
// Minimal movement → not a drag selection, reset.
|
// Minimal movement → not a drag selection, reset.
|
||||||
// Let existing onContextMenu handlers on allocation blocks handle right-click.
|
// Let existing onContextMenu handlers on allocation blocks handle right-click.
|
||||||
multiSelectRef.current = INITIAL_MULTI_SELECT;
|
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
|
// isSelecting is set to false to indicate the drag is done, but the
|
||||||
// rectangle data (startX/Y, currentX/Y) is preserved so TimelineView
|
// rectangle data (startX/Y, currentX/Y) is preserved so TimelineView
|
||||||
// can resolve which allocations/resources fall within the selection.
|
// can resolve which allocations/resources fall within the selection.
|
||||||
const finished: MultiSelectState = {
|
|
||||||
...ms,
|
|
||||||
isSelecting: false,
|
|
||||||
currentX: ev.clientX,
|
|
||||||
currentY: ev.clientY,
|
|
||||||
};
|
|
||||||
multiSelectRef.current = finished;
|
multiSelectRef.current = finished;
|
||||||
setMultiSelectState(finished);
|
setMultiSelectState(finished);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user