fix(timeline): cancel stranded drag interactions
This commit is contained in:
@@ -1303,6 +1303,61 @@ test.describe("Timeline", () => {
|
|||||||
expect(result.rightEdgeGain).toBeGreaterThan(48);
|
expect(result.rightEdgeGain).toBeGreaterThan(48);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("allocation resize cancels cleanly when the window loses focus mid-drag", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/timeline?startDate=2026-04-01&days=31", {
|
||||||
|
waitUntil: "domcontentloaded",
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
const allocationSegment = await findVisibleAllocationSegmentForResize(
|
||||||
|
page,
|
||||||
|
"[data-timeline-entry-type='allocation'][data-allocation-id]",
|
||||||
|
);
|
||||||
|
expect(allocationSegment).toBeTruthy();
|
||||||
|
|
||||||
|
const allocation = page.locator(allocationSegmentSelector(allocationSegment!));
|
||||||
|
await allocation.scrollIntoViewIfNeeded();
|
||||||
|
|
||||||
|
const initialBox = await readBoundingBox(allocation);
|
||||||
|
const pointerY = initialBox.y + initialBox.height / 2;
|
||||||
|
const startX = initialBox.x + initialBox.width - 3;
|
||||||
|
|
||||||
|
await page.mouse.move(startX, pointerY);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(startX + 72, pointerY, { steps: 6 });
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const box = await allocation.boundingBox();
|
||||||
|
return box ? Math.round(box.width) : null;
|
||||||
|
})
|
||||||
|
.toBeGreaterThan(Math.round(initialBox.width + 36));
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.dispatchEvent(new Event("blur"));
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const box = await allocation.boundingBox();
|
||||||
|
return box ? Math.round(box.width) : null;
|
||||||
|
})
|
||||||
|
.toBe(Math.round(initialBox.width));
|
||||||
|
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
const secondResize = await measureAllocationResizeGap(
|
||||||
|
page,
|
||||||
|
allocationSegmentSelector(allocationSegment!),
|
||||||
|
);
|
||||||
|
expect(secondResize.widthGain).toBeGreaterThan(64);
|
||||||
|
expect(secondResize.rightEdgeGain).toBeGreaterThan(48);
|
||||||
|
});
|
||||||
|
|
||||||
test("allocation start resize shows a live preview before mouseup", async ({
|
test("allocation start resize shows a live preview before mouseup", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
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", () => {
|
describe("timelineDragCleanup", () => {
|
||||||
it("runs registered cleanup callbacks, clears previews, and resets refs", () => {
|
it("runs registered cleanup callbacks, clears previews, and resets refs", () => {
|
||||||
@@ -82,4 +96,81 @@ describe("timelineDragCleanup", () => {
|
|||||||
expect(clearPreview).toHaveBeenNthCalledWith(1, null);
|
expect(clearPreview).toHaveBeenNthCalledWith(1, null);
|
||||||
expect(clearPreview).toHaveBeenNthCalledWith(2, 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;
|
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<
|
type TimelineDragCleanupParams<
|
||||||
DragState,
|
DragState,
|
||||||
AllocDragState,
|
AllocDragState,
|
||||||
@@ -72,3 +86,27 @@ export function cleanupTimelineDragState<
|
|||||||
rangeStateRef.current = initialRangeState;
|
rangeStateRef.current = initialRangeState;
|
||||||
multiSelectRef.current = initialMultiSelectState;
|
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 { createAllocationDragState } from "./timelineAllocationDragState.js";
|
||||||
import { beginAllocationDragSession } from "./timelineAllocationDragSession.js";
|
import { beginAllocationDragSession } from "./timelineAllocationDragSession.js";
|
||||||
import { finalizeAllocationReleaseEffects } from "./timelineAllocationReleaseEffects.js";
|
import { finalizeAllocationReleaseEffects } from "./timelineAllocationReleaseEffects.js";
|
||||||
import { cleanupTimelineDragState } from "./timelineDragCleanup.js";
|
import { cancelTransientMultiSelectState, cleanupTimelineDragState } from "./timelineDragCleanup.js";
|
||||||
import { resolveAllocationDragPosition, resolveProjectDragPosition } from "./timelineDragPosition.js";
|
import { resolveAllocationDragPosition, resolveProjectDragPosition } from "./timelineDragPosition.js";
|
||||||
import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js";
|
import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js";
|
||||||
import { finalizeProjectDrag } from "./timelineProjectDragFinalize.js";
|
import { finalizeProjectDrag } from "./timelineProjectDragFinalize.js";
|
||||||
@@ -350,6 +350,43 @@ export function useTimelineDrag({
|
|||||||
setDragState(INITIAL_DRAG_STATE);
|
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
|
// Project-shift preview
|
||||||
const { data: previewData, isFetching: isPreviewLoading } = trpc.timeline.previewShift.useQuery(
|
const { data: previewData, isFetching: isPreviewLoading } = trpc.timeline.previewShift.useQuery(
|
||||||
{
|
{
|
||||||
@@ -831,7 +868,22 @@ export function useTimelineDrag({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
function handleWindowBlur() {
|
||||||
|
cancelActiveInteractions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVisibilityChange() {
|
||||||
|
if (document.visibilityState === "hidden") {
|
||||||
|
cancelActiveInteractions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("blur", handleWindowBlur);
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
window.removeEventListener("blur", handleWindowBlur);
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
cleanupTimelineDragState({
|
cleanupTimelineDragState({
|
||||||
projectDragCleanupRef,
|
projectDragCleanupRef,
|
||||||
allocDragCleanupRef,
|
allocDragCleanupRef,
|
||||||
@@ -849,7 +901,7 @@ export function useTimelineDrag({
|
|||||||
clearPreview: clearLivePreview,
|
clearPreview: clearLivePreview,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}, []);
|
}, [cancelActiveInteractions]);
|
||||||
|
|
||||||
// ── Derived ─────────────────────────────────────────────────────────────────
|
// ── Derived ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ the timeline is the highest-risk UX surface and still not boringly reliable enou
|
|||||||
Progress note:
|
Progress note:
|
||||||
- 2026-04-01: overlay cleanup on timeline `viewMode` changes and initial-loading transitions landed with targeted e2e regression coverage for allocation popovers across view switches.
|
- 2026-04-01: overlay cleanup on timeline `viewMode` changes and initial-loading transitions landed with targeted e2e regression coverage for allocation popovers across view switches.
|
||||||
- 2026-04-01: viewport-change behavior was tightened so point-anchored timeline popovers close on scroll/resize, while element-anchored hover cards remain repositionable; the viewport regression now passes with a non-happy-path e2e.
|
- 2026-04-01: viewport-change behavior was tightened so point-anchored timeline popovers close on scroll/resize, while element-anchored hover cards remain repositionable; the viewport regression now passes with a non-happy-path e2e.
|
||||||
|
- 2026-04-01: active timeline gestures now cancel on window blur / hidden-tab transitions instead of leaving drag or resize state stranded; regression coverage verifies a mid-resize focus loss reverts the preview and allows the next interaction to proceed cleanly.
|
||||||
|
|
||||||
Slices:
|
Slices:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user