fix(timeline): cancel stranded drag interactions

This commit is contained in:
2026-04-01 14:57:56 +02:00
parent a71bbeb640
commit d4652b7a42
5 changed files with 240 additions and 3 deletions
+55
View File
@@ -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,
}) => { }) => {
+92 -1
View File
@@ -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);
});
}); });
+38
View File
@@ -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,
};
}
+54 -2
View File
@@ -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 ─────────────────────────────────────────────────────────────────
+1
View File
@@ -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: