fix(timeline): cancel stranded drag interactions
This commit is contained in:
@@ -1,5 +1,19 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { cleanupTimelineDragState } from "./timelineDragCleanup.js";
|
||||
import { cancelTransientMultiSelectState, cleanupTimelineDragState } from "./timelineDragCleanup.js";
|
||||
|
||||
type MultiSelectStateFixture = {
|
||||
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;
|
||||
};
|
||||
|
||||
describe("timelineDragCleanup", () => {
|
||||
it("runs registered cleanup callbacks, clears previews, and resets refs", () => {
|
||||
@@ -82,4 +96,81 @@ describe("timelineDragCleanup", () => {
|
||||
expect(clearPreview).toHaveBeenNthCalledWith(1, null);
|
||||
expect(clearPreview).toHaveBeenNthCalledWith(2, null);
|
||||
});
|
||||
|
||||
it("keeps completed multi-selection while cancelling transient drag state", () => {
|
||||
const initialState: MultiSelectStateFixture = {
|
||||
isSelecting: false,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
currentX: 0,
|
||||
currentY: 0,
|
||||
selectedAllocationIds: [],
|
||||
selectedResourceIds: [],
|
||||
dateRange: null,
|
||||
multiDragDaysDelta: 0,
|
||||
isMultiDragging: false,
|
||||
multiDragMode: "move",
|
||||
};
|
||||
|
||||
const nextState = cancelTransientMultiSelectState(
|
||||
{
|
||||
isSelecting: false,
|
||||
startX: 140,
|
||||
startY: 32,
|
||||
currentX: 244,
|
||||
currentY: 96,
|
||||
selectedAllocationIds: ["alloc-1", "alloc-2"],
|
||||
selectedResourceIds: ["res-1"],
|
||||
dateRange: { start: new Date("2026-04-02"), end: new Date("2026-04-05") },
|
||||
multiDragDaysDelta: 3,
|
||||
isMultiDragging: true,
|
||||
multiDragMode: "resize-end",
|
||||
},
|
||||
initialState,
|
||||
);
|
||||
|
||||
expect(nextState).toEqual({
|
||||
isSelecting: false,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
currentX: 0,
|
||||
currentY: 0,
|
||||
selectedAllocationIds: ["alloc-1", "alloc-2"],
|
||||
selectedResourceIds: ["res-1"],
|
||||
dateRange: { start: new Date("2026-04-02"), end: new Date("2026-04-05") },
|
||||
multiDragDaysDelta: 0,
|
||||
isMultiDragging: false,
|
||||
multiDragMode: "move",
|
||||
});
|
||||
});
|
||||
|
||||
it("drops incomplete marquee state when nothing was selected yet", () => {
|
||||
const initialState: MultiSelectStateFixture = {
|
||||
isSelecting: false,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
currentX: 0,
|
||||
currentY: 0,
|
||||
selectedAllocationIds: [],
|
||||
selectedResourceIds: [],
|
||||
dateRange: null,
|
||||
multiDragDaysDelta: 0,
|
||||
isMultiDragging: false,
|
||||
multiDragMode: "move",
|
||||
};
|
||||
|
||||
const nextState = cancelTransientMultiSelectState(
|
||||
{
|
||||
...initialState,
|
||||
isSelecting: true,
|
||||
startX: 180,
|
||||
startY: 24,
|
||||
currentX: 220,
|
||||
currentY: 68,
|
||||
},
|
||||
initialState,
|
||||
);
|
||||
|
||||
expect(nextState).toBe(initialState);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,20 @@ type MutableCurrent<T> = {
|
||||
current: T;
|
||||
};
|
||||
|
||||
type MultiSelectInteractionStateLike = {
|
||||
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;
|
||||
};
|
||||
|
||||
type TimelineDragCleanupParams<
|
||||
DragState,
|
||||
AllocDragState,
|
||||
@@ -72,3 +86,27 @@ export function cleanupTimelineDragState<
|
||||
rangeStateRef.current = initialRangeState;
|
||||
multiSelectRef.current = initialMultiSelectState;
|
||||
}
|
||||
|
||||
export function cancelTransientMultiSelectState<TState extends MultiSelectInteractionStateLike>(
|
||||
state: TState,
|
||||
initialState: TState,
|
||||
): TState {
|
||||
const hasCommittedSelection =
|
||||
state.selectedAllocationIds.length > 0 || state.selectedResourceIds.length > 0 || state.dateRange !== null;
|
||||
|
||||
if (!hasCommittedSelection) {
|
||||
return initialState;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
isSelecting: false,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
currentX: 0,
|
||||
currentY: 0,
|
||||
multiDragDaysDelta: 0,
|
||||
isMultiDragging: false,
|
||||
multiDragMode: initialState.multiDragMode,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { beginAllocationMultiDragSession } from "./timelineAllocationMultiDragSe
|
||||
import { createAllocationDragState } from "./timelineAllocationDragState.js";
|
||||
import { beginAllocationDragSession } from "./timelineAllocationDragSession.js";
|
||||
import { finalizeAllocationReleaseEffects } from "./timelineAllocationReleaseEffects.js";
|
||||
import { cleanupTimelineDragState } from "./timelineDragCleanup.js";
|
||||
import { cancelTransientMultiSelectState, cleanupTimelineDragState } from "./timelineDragCleanup.js";
|
||||
import { resolveAllocationDragPosition, resolveProjectDragPosition } from "./timelineDragPosition.js";
|
||||
import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js";
|
||||
import { finalizeProjectDrag } from "./timelineProjectDragFinalize.js";
|
||||
@@ -350,6 +350,43 @@ export function useTimelineDrag({
|
||||
setDragState(INITIAL_DRAG_STATE);
|
||||
}, []);
|
||||
|
||||
const cancelActiveInteractions = useCallback(() => {
|
||||
const hasActiveInteraction =
|
||||
dragStateRef.current.isDragging ||
|
||||
allocDragRef.current.isActive ||
|
||||
rangeStateRef.current.isSelecting ||
|
||||
multiSelectRef.current.isSelecting ||
|
||||
multiSelectRef.current.isMultiDragging;
|
||||
if (!hasActiveInteraction) return;
|
||||
|
||||
const nextMultiSelectState = cancelTransientMultiSelectState(
|
||||
multiSelectRef.current,
|
||||
INITIAL_MULTI_SELECT,
|
||||
);
|
||||
|
||||
cleanupTimelineDragState({
|
||||
projectDragCleanupRef,
|
||||
allocDragCleanupRef,
|
||||
multiSelectCleanupRef,
|
||||
projectPreviewRef,
|
||||
allocPreviewRef,
|
||||
dragStateRef,
|
||||
allocDragRef,
|
||||
rangeStateRef,
|
||||
multiSelectRef,
|
||||
initialDragState: INITIAL_DRAG_STATE,
|
||||
initialAllocDragState: INITIAL_ALLOC_DRAG,
|
||||
initialRangeState: INITIAL_RANGE_STATE,
|
||||
initialMultiSelectState: nextMultiSelectState,
|
||||
clearPreview: clearLivePreview,
|
||||
});
|
||||
|
||||
setDragState(INITIAL_DRAG_STATE);
|
||||
setAllocDragState(INITIAL_ALLOC_DRAG);
|
||||
setRangeState(INITIAL_RANGE_STATE);
|
||||
setMultiSelectState(nextMultiSelectState);
|
||||
}, []);
|
||||
|
||||
// Project-shift preview
|
||||
const { data: previewData, isFetching: isPreviewLoading } = trpc.timeline.previewShift.useQuery(
|
||||
{
|
||||
@@ -831,7 +868,22 @@ export function useTimelineDrag({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
function handleWindowBlur() {
|
||||
cancelActiveInteractions();
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (document.visibilityState === "hidden") {
|
||||
cancelActiveInteractions();
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("blur", handleWindowBlur);
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("blur", handleWindowBlur);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
cleanupTimelineDragState({
|
||||
projectDragCleanupRef,
|
||||
allocDragCleanupRef,
|
||||
@@ -849,7 +901,7 @@ export function useTimelineDrag({
|
||||
clearPreview: clearLivePreview,
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
}, [cancelActiveInteractions]);
|
||||
|
||||
// ── Derived ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user