refactor(web): extract timeline range selection helpers
This commit is contained in:
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { finalizeRangeSelection, updateRangeSelectionDraft } from "./timelineRangeSelection.js";
|
||||||
|
|
||||||
|
type TestRangeState = {
|
||||||
|
isSelecting: boolean;
|
||||||
|
resourceId: string | null;
|
||||||
|
startDate: Date | null;
|
||||||
|
currentDate: Date | null;
|
||||||
|
suggestedProjectId: string | null;
|
||||||
|
startClientX: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("timelineRangeSelection", () => {
|
||||||
|
it("ignores updates when no full day boundary was crossed", () => {
|
||||||
|
const state: TestRangeState = {
|
||||||
|
isSelecting: true,
|
||||||
|
resourceId: "res-1",
|
||||||
|
startDate: new Date("2025-01-15T12:00:00.000Z"),
|
||||||
|
currentDate: new Date("2025-01-15T12:00:00.000Z"),
|
||||||
|
suggestedProjectId: "proj-1",
|
||||||
|
startClientX: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(updateRangeSelectionDraft(state, 115, 32)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates the preview date when drag distance crosses into another day", () => {
|
||||||
|
const state: TestRangeState = {
|
||||||
|
isSelecting: true,
|
||||||
|
resourceId: "res-1",
|
||||||
|
startDate: new Date("2025-01-15T12:00:00.000Z"),
|
||||||
|
currentDate: new Date("2025-01-15T12:00:00.000Z"),
|
||||||
|
suggestedProjectId: "proj-1",
|
||||||
|
startClientX: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
const updated = updateRangeSelectionDraft(state, 168, 32);
|
||||||
|
|
||||||
|
expect(updated).toMatchObject({
|
||||||
|
...state,
|
||||||
|
currentDate: new Date("2025-01-17T12:00:00.000Z"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when finalizing an incomplete range selection", () => {
|
||||||
|
expect(
|
||||||
|
finalizeRangeSelection(
|
||||||
|
{
|
||||||
|
isSelecting: true,
|
||||||
|
resourceId: null,
|
||||||
|
startDate: new Date("2025-01-15T12:00:00.000Z"),
|
||||||
|
currentDate: null,
|
||||||
|
suggestedProjectId: null,
|
||||||
|
startClientX: 100,
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
300,
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders the final range chronologically even when the user dragged backwards", () => {
|
||||||
|
expect(
|
||||||
|
finalizeRangeSelection(
|
||||||
|
{
|
||||||
|
isSelecting: true,
|
||||||
|
resourceId: "res-1",
|
||||||
|
startDate: new Date("2025-01-15T12:00:00.000Z"),
|
||||||
|
currentDate: new Date("2025-01-12T12:00:00.000Z"),
|
||||||
|
suggestedProjectId: "proj-1",
|
||||||
|
startClientX: 100,
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
300,
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
resourceId: "res-1",
|
||||||
|
startDate: new Date("2025-01-12T12:00:00.000Z"),
|
||||||
|
endDate: new Date("2025-01-15T12:00:00.000Z"),
|
||||||
|
suggestedProjectId: "proj-1",
|
||||||
|
anchorX: 200,
|
||||||
|
anchorY: 300,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { pixelsToDays } from "~/components/timeline/dragMath.js";
|
||||||
|
|
||||||
|
type RangeStateLike = {
|
||||||
|
isSelecting: boolean;
|
||||||
|
resourceId: string | null;
|
||||||
|
startDate: Date | null;
|
||||||
|
currentDate: Date | null;
|
||||||
|
suggestedProjectId: string | null;
|
||||||
|
startClientX: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RangeSelectionResult = {
|
||||||
|
resourceId: string;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
suggestedProjectId: string | null;
|
||||||
|
anchorX: number;
|
||||||
|
anchorY: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function updateRangeSelectionDraft<TState extends RangeStateLike>(
|
||||||
|
state: TState,
|
||||||
|
currentClientX: number,
|
||||||
|
cellWidth: number,
|
||||||
|
): TState | null {
|
||||||
|
if (!state.isSelecting || !state.startDate) return null;
|
||||||
|
|
||||||
|
const daysDelta = pixelsToDays(currentClientX - state.startClientX, cellWidth);
|
||||||
|
const currentDate = new Date(state.startDate);
|
||||||
|
currentDate.setDate(currentDate.getDate() + daysDelta);
|
||||||
|
|
||||||
|
const prevDelta = state.currentDate
|
||||||
|
? Math.round((state.currentDate.getTime() - state.startDate.getTime()) / 86400000)
|
||||||
|
: 0;
|
||||||
|
if (daysDelta === prevDelta) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
currentDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function finalizeRangeSelection<TState extends RangeStateLike>(
|
||||||
|
state: TState,
|
||||||
|
anchorX: number,
|
||||||
|
anchorY: number,
|
||||||
|
): RangeSelectionResult | null {
|
||||||
|
if (!state.isSelecting || !state.resourceId || !state.startDate) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endDate = state.currentDate ?? state.startDate;
|
||||||
|
const [startDate, finalEnd] =
|
||||||
|
state.startDate <= endDate ? [state.startDate, endDate] : [endDate, state.startDate];
|
||||||
|
|
||||||
|
return {
|
||||||
|
resourceId: state.resourceId,
|
||||||
|
startDate,
|
||||||
|
endDate: finalEnd,
|
||||||
|
suggestedProjectId: state.suggestedProjectId,
|
||||||
|
anchorX,
|
||||||
|
anchorY,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
finalizeMultiSelectDraft,
|
finalizeMultiSelectDraft,
|
||||||
updateMultiSelectDraft,
|
updateMultiSelectDraft,
|
||||||
} from "./timelineMultiSelect.js";
|
} from "./timelineMultiSelect.js";
|
||||||
|
import { finalizeRangeSelection, updateRangeSelectionDraft } from "./timelineRangeSelection.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;
|
||||||
@@ -893,18 +894,8 @@ export function useTimelineDrag({
|
|||||||
|
|
||||||
// Range select
|
// Range select
|
||||||
const range = rangeStateRef.current;
|
const range = rangeStateRef.current;
|
||||||
if (range.isSelecting && range.startDate) {
|
const updated = updateRangeSelectionDraft(range, e.clientX, cellWidthRef.current);
|
||||||
const deltaX = e.clientX - range.startClientX;
|
if (updated) {
|
||||||
const daysDelta = pixelsToDays(deltaX, cellWidthRef.current);
|
|
||||||
const currentDate = new Date(range.startDate);
|
|
||||||
currentDate.setDate(currentDate.getDate() + daysDelta);
|
|
||||||
|
|
||||||
const prevDelta = range.currentDate
|
|
||||||
? Math.round((range.currentDate.getTime() - range.startDate.getTime()) / 86400000)
|
|
||||||
: 0;
|
|
||||||
if (daysDelta === prevDelta) return;
|
|
||||||
|
|
||||||
const updated: RangeState = { ...range, currentDate };
|
|
||||||
rangeStateRef.current = updated;
|
rangeStateRef.current = updated;
|
||||||
setRangeState(updated);
|
setRangeState(updated);
|
||||||
}
|
}
|
||||||
@@ -927,19 +918,9 @@ export function useTimelineDrag({
|
|||||||
|
|
||||||
// Range select
|
// Range select
|
||||||
const range = rangeStateRef.current;
|
const range = rangeStateRef.current;
|
||||||
if (range.isSelecting && range.resourceId && range.startDate) {
|
const selection = finalizeRangeSelection(range, e.clientX, e.clientY);
|
||||||
const endDate = range.currentDate ?? range.startDate;
|
if (selection) {
|
||||||
const [startDate, finalEnd] =
|
onRangeSelected?.(selection);
|
||||||
range.startDate <= endDate ? [range.startDate, endDate] : [endDate, range.startDate];
|
|
||||||
|
|
||||||
onRangeSelected?.({
|
|
||||||
resourceId: range.resourceId,
|
|
||||||
startDate,
|
|
||||||
endDate: finalEnd,
|
|
||||||
suggestedProjectId: range.suggestedProjectId,
|
|
||||||
anchorX: e.clientX,
|
|
||||||
anchorY: e.clientY,
|
|
||||||
});
|
|
||||||
|
|
||||||
rangeStateRef.current = INITIAL_RANGE_STATE;
|
rangeStateRef.current = INITIAL_RANGE_STATE;
|
||||||
setRangeState(INITIAL_RANGE_STATE);
|
setRangeState(INITIAL_RANGE_STATE);
|
||||||
|
|||||||
Reference in New Issue
Block a user