refactor(web): extract range release resolution
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveRangeSelectionCancel, resolveRangeSelectionRelease } from "./timelineRangeRelease.js";
|
||||
|
||||
type TestRangeState = {
|
||||
isSelecting: boolean;
|
||||
resourceId: string | null;
|
||||
startDate: Date | null;
|
||||
currentDate: Date | null;
|
||||
suggestedProjectId: string | null;
|
||||
startClientX: number;
|
||||
};
|
||||
|
||||
const INITIAL_RANGE_STATE: TestRangeState = {
|
||||
isSelecting: false,
|
||||
resourceId: null,
|
||||
startDate: null,
|
||||
currentDate: null,
|
||||
suggestedProjectId: null,
|
||||
startClientX: 0,
|
||||
};
|
||||
|
||||
describe("timelineRangeRelease", () => {
|
||||
it("keeps state unchanged when release happens outside an active range selection", () => {
|
||||
const result = resolveRangeSelectionRelease(INITIAL_RANGE_STATE, 12, 34, INITIAL_RANGE_STATE);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "noop",
|
||||
nextState: INITIAL_RANGE_STATE,
|
||||
selection: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps state unchanged when the active range is structurally invalid on release", () => {
|
||||
const invalidState: TestRangeState = {
|
||||
...INITIAL_RANGE_STATE,
|
||||
isSelecting: true,
|
||||
resourceId: null,
|
||||
startDate: new Date("2026-04-01T00:00:00.000Z"),
|
||||
currentDate: new Date("2026-04-03T00:00:00.000Z"),
|
||||
};
|
||||
|
||||
const result = resolveRangeSelectionRelease(invalidState, 40, 50, INITIAL_RANGE_STATE);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "noop",
|
||||
nextState: invalidState,
|
||||
selection: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("resets to the provided initial state when release completes a backwards drag", () => {
|
||||
const selectingState: TestRangeState = {
|
||||
isSelecting: true,
|
||||
resourceId: "res_1",
|
||||
startDate: new Date("2026-04-05T00:00:00.000Z"),
|
||||
currentDate: new Date("2026-04-02T00:00:00.000Z"),
|
||||
suggestedProjectId: "proj_1",
|
||||
startClientX: 120,
|
||||
};
|
||||
|
||||
const result = resolveRangeSelectionRelease(selectingState, 200, 300, INITIAL_RANGE_STATE);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "complete",
|
||||
nextState: INITIAL_RANGE_STATE,
|
||||
selection: {
|
||||
resourceId: "res_1",
|
||||
startDate: new Date("2026-04-02T00:00:00.000Z"),
|
||||
endDate: new Date("2026-04-05T00:00:00.000Z"),
|
||||
suggestedProjectId: "proj_1",
|
||||
anchorX: 200,
|
||||
anchorY: 300,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not reset on cancel when there is no active range selection", () => {
|
||||
const result = resolveRangeSelectionCancel(INITIAL_RANGE_STATE, INITIAL_RANGE_STATE);
|
||||
|
||||
expect(result).toEqual({
|
||||
didReset: false,
|
||||
nextState: INITIAL_RANGE_STATE,
|
||||
});
|
||||
});
|
||||
|
||||
it("resets on cancel when a range selection is active", () => {
|
||||
const selectingState: TestRangeState = {
|
||||
isSelecting: true,
|
||||
resourceId: "res_1",
|
||||
startDate: new Date("2026-04-01T00:00:00.000Z"),
|
||||
currentDate: new Date("2026-04-03T00:00:00.000Z"),
|
||||
suggestedProjectId: null,
|
||||
startClientX: 100,
|
||||
};
|
||||
|
||||
const result = resolveRangeSelectionCancel(selectingState, INITIAL_RANGE_STATE);
|
||||
|
||||
expect(result).toEqual({
|
||||
didReset: true,
|
||||
nextState: INITIAL_RANGE_STATE,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { finalizeRangeSelection, type RangeSelectionResult } from "./timelineRangeSelection.js";
|
||||
|
||||
type RangeStateLike = {
|
||||
isSelecting: boolean;
|
||||
resourceId: string | null;
|
||||
startDate: Date | null;
|
||||
currentDate: Date | null;
|
||||
suggestedProjectId: string | null;
|
||||
startClientX: number;
|
||||
};
|
||||
|
||||
export type RangeReleaseResolution<TState> =
|
||||
| { kind: "noop"; nextState: TState; selection: null }
|
||||
| { kind: "complete"; nextState: TState; selection: RangeSelectionResult };
|
||||
|
||||
export function resolveRangeSelectionRelease<TState extends RangeStateLike>(
|
||||
state: TState,
|
||||
anchorX: number,
|
||||
anchorY: number,
|
||||
initialState: TState,
|
||||
): RangeReleaseResolution<TState> {
|
||||
const selection = finalizeRangeSelection(state, anchorX, anchorY);
|
||||
if (!selection) {
|
||||
return {
|
||||
kind: "noop",
|
||||
nextState: state,
|
||||
selection: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "complete",
|
||||
nextState: initialState,
|
||||
selection,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveRangeSelectionCancel<TState extends RangeStateLike>(
|
||||
state: TState,
|
||||
initialState: TState,
|
||||
): { didReset: boolean; nextState: TState } {
|
||||
if (!state.isSelecting) {
|
||||
return {
|
||||
didReset: false,
|
||||
nextState: state,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
didReset: true,
|
||||
nextState: initialState,
|
||||
};
|
||||
}
|
||||
@@ -39,7 +39,8 @@ import {
|
||||
} from "./timelineMultiSelect.js";
|
||||
import { beginCanvasMultiSelectSession } from "./timelineMultiSelectSession.js";
|
||||
import { reconcileOptimisticEntries } from "./timelineOptimisticAllocations.js";
|
||||
import { createRangeSelectionState, finalizeRangeSelection, updateRangeSelectionDraft } from "./timelineRangeSelection.js";
|
||||
import { resolveRangeSelectionCancel, resolveRangeSelectionRelease } from "./timelineRangeRelease.js";
|
||||
import { createRangeSelectionState, updateRangeSelectionDraft } from "./timelineRangeSelection.js";
|
||||
import { type TouchCanvasPointerEvent, type TouchMouseDownEvent } from "./timelineTouchAdapters.js";
|
||||
|
||||
const DRAG_CLICK_THRESHOLD_PX = 5;
|
||||
@@ -716,14 +717,12 @@ export function useTimelineDrag({
|
||||
}
|
||||
|
||||
// Range select
|
||||
const range = rangeStateRef.current;
|
||||
const selection = finalizeRangeSelection(range, e.clientX, e.clientY);
|
||||
if (selection) {
|
||||
onRangeSelected?.(selection);
|
||||
const release = resolveRangeSelectionRelease(rangeStateRef.current, e.clientX, e.clientY, INITIAL_RANGE_STATE);
|
||||
if (release.kind !== "complete") return;
|
||||
|
||||
rangeStateRef.current = INITIAL_RANGE_STATE;
|
||||
setRangeState(INITIAL_RANGE_STATE);
|
||||
}
|
||||
onRangeSelected?.(release.selection);
|
||||
rangeStateRef.current = release.nextState;
|
||||
setRangeState(release.nextState);
|
||||
},
|
||||
[finalizeActiveProjectDrag, onRangeSelected],
|
||||
);
|
||||
@@ -731,10 +730,11 @@ export function useTimelineDrag({
|
||||
const onCanvasMouseLeave = useCallback(() => {
|
||||
// Only cancel project-shift and range-select on canvas leave.
|
||||
// Alloc drag is managed by document-level listeners and must NOT be cancelled here.
|
||||
if (rangeStateRef.current.isSelecting) {
|
||||
rangeStateRef.current = INITIAL_RANGE_STATE;
|
||||
setRangeState(INITIAL_RANGE_STATE);
|
||||
}
|
||||
const cancellation = resolveRangeSelectionCancel(rangeStateRef.current, INITIAL_RANGE_STATE);
|
||||
if (!cancellation.didReset) return;
|
||||
|
||||
rangeStateRef.current = cancellation.nextState;
|
||||
setRangeState(cancellation.nextState);
|
||||
}, []);
|
||||
|
||||
// ── Multi-select (right-click drag) ─────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user