refactor(web): extract allocation drag session

This commit is contained in:
2026-04-01 11:27:03 +02:00
parent 510459fbff
commit f4e9831dea
5 changed files with 228 additions and 78 deletions
@@ -0,0 +1,78 @@
import { describe, expect, it, vi } from "vitest";
import { beginAllocationDragSession } from "./timelineAllocationDragSession.js";
describe("timelineAllocationDragSession", () => {
it("starts the session, forwards movement, and finalizes on mouseup", () => {
const previousCleanup = vi.fn();
const setState = vi.fn();
const updatePosition = vi.fn();
const finalize = vi.fn();
const state = { allocationId: "alloc-1", isActive: true };
const cleanupRef = { current: previousCleanup as (() => void) | null };
const stateRef = { current: { allocationId: null, isActive: false } };
const handlers: {
move?: (event: MouseEvent) => void;
up?: (event: MouseEvent) => void;
} = {};
beginAllocationDragSession({
state,
cleanupRef,
stateRef,
setState,
documentTarget: {} as Document,
attachDrag: (_documentTarget, onMove, onUp) => {
handlers.move = onMove;
handlers.up = onUp;
return vi.fn();
},
updatePosition,
finalize,
});
expect(previousCleanup).toHaveBeenCalledOnce();
expect(stateRef.current).toBe(state);
expect(setState).toHaveBeenCalledWith(state);
handlers.move?.({ clientX: 44 } as MouseEvent);
expect(updatePosition).toHaveBeenCalledWith(44);
const preventDefault = vi.fn();
const attachedCleanup = cleanupRef.current;
handlers.up?.({ clientX: 65, preventDefault } as unknown as MouseEvent);
expect(attachedCleanup).toHaveBeenCalledOnce();
expect(cleanupRef.current).toBeNull();
expect(finalize).toHaveBeenCalledWith(65);
expect(preventDefault).toHaveBeenCalledOnce();
});
it("works when the session starts without a prior cleanup", () => {
const setState = vi.fn();
const updatePosition = vi.fn();
const finalize = vi.fn().mockResolvedValue(undefined);
const cleanupRef = { current: null as (() => void) | null };
const stateRef = { current: { allocationId: null, isActive: false } };
let upHandler: ((event: MouseEvent) => void) | undefined;
beginAllocationDragSession({
state: { allocationId: "alloc-2", isActive: true },
cleanupRef,
stateRef,
setState,
documentTarget: {} as Document,
attachDrag: (_documentTarget, _onMove, onUp) => {
upHandler = onUp;
return vi.fn();
},
updatePosition,
finalize,
});
upHandler?.({ clientX: 18, preventDefault() {} } as MouseEvent);
expect(updatePosition).not.toHaveBeenCalled();
expect(finalize).toHaveBeenCalledWith(18);
expect(cleanupRef.current).toBeNull();
});
});
@@ -0,0 +1,48 @@
type MutableCurrent<T> = {
current: T;
};
type AttachDocumentMouseDrag = (
documentTarget: Document,
onMove: (event: MouseEvent) => void,
onUp: (event: MouseEvent) => void,
) => () => void;
type BeginAllocationDragSessionParams<TState> = {
state: TState;
cleanupRef: MutableCurrent<(() => void) | null>;
stateRef: MutableCurrent<TState>;
setState: (state: TState) => void;
documentTarget: Document;
attachDrag: AttachDocumentMouseDrag;
updatePosition: (clientX: number) => void;
finalize: (clientX: number) => Promise<void> | void;
};
export function beginAllocationDragSession<TState>({
state,
cleanupRef,
stateRef,
setState,
documentTarget,
attachDrag,
updatePosition,
finalize,
}: BeginAllocationDragSessionParams<TState>) {
stateRef.current = state;
setState(state);
cleanupRef.current?.();
function handleMove(event: MouseEvent) {
updatePosition(event.clientX);
}
function handleUp(event: MouseEvent) {
cleanupRef.current?.();
cleanupRef.current = null;
void finalize(event.clientX);
event.preventDefault();
}
cleanupRef.current = attachDrag(documentTarget, handleMove, handleUp);
}
+75 -78
View File
@@ -21,6 +21,7 @@ import {
import { beginAllocationMultiDragSession } from "./timelineAllocationMultiDragSession.js";
import { resolveAllocationRelease } from "./timelineAllocationRelease.js";
import { createAllocationDragState } from "./timelineAllocationDragState.js";
import { beginAllocationDragSession } from "./timelineAllocationDragSession.js";
import { cleanupTimelineDragState } from "./timelineDragCleanup.js";
import { resolveAllocationDragPosition, resolveProjectDragPosition } from "./timelineDragPosition.js";
import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js";
@@ -629,94 +630,90 @@ export function useTimelineDrag({
endDate: opts.endDate,
startMouseX: e.clientX,
});
allocDragRef.current = initial;
setAllocDragState(initial);
setAllocationPreviewTarget(e.currentTarget, opts.mode);
allocDragCleanupRef.current?.();
// ── document handlers ────────────────────────────────────────────────
function handleMove(ev: MouseEvent) {
updateAllocationDragPosition(ev.clientX);
}
function handleUp(ev: MouseEvent) {
allocDragCleanupRef.current?.();
allocDragCleanupRef.current = null;
updateAllocationDragPosition(ev.clientX);
const alloc = allocDragRef.current;
const release = resolveAllocationRelease(alloc, {
clickThresholdPx: DRAG_CLICK_THRESHOLD_PX,
wasShift,
});
if (release.kind === "ignore") return;
if (release.preservePreview) {
preserveLivePreview(allocPreviewRef.current);
}
clearLivePreview(allocPreviewRef.current);
allocPreviewRef.current = null;
if (release.kind === "shift-click") {
onShiftClickAllocRef.current?.(release.allocationId);
} else if (release.kind === "click") {
onBlockClickRef.current?.(release.clickInfo);
} else if (release.kind === "mutation") {
const { mutationPlan } = release;
const {
activeAllocationId,
currentStartDate,
currentEndDate,
baseMutationAllocationId,
requiresExtraction,
pendingSnapshot,
} = mutationPlan;
pendingSnapshotRef.current = pendingSnapshot;
pendingOptimisticAllocationIdRef.current = activeAllocationId;
setOptimisticAllocations((prev) => {
const next = new Map(prev);
next.set(activeAllocationId, {
startDate: currentStartDate,
endDate: currentEndDate,
});
return next;
beginAllocationDragSession({
state: initial,
cleanupRef: allocDragCleanupRef,
stateRef: allocDragRef,
setState: setAllocDragState,
documentTarget: document,
attachDrag: attachDocumentMouseDrag,
updatePosition: updateAllocationDragPosition,
finalize: (clientX) => {
updateAllocationDragPosition(clientX);
const alloc = allocDragRef.current;
const release = resolveAllocationRelease(alloc, {
clickThresholdPx: DRAG_CLICK_THRESHOLD_PX,
wasShift,
});
void (async () => {
try {
let mutationAllocationId = baseMutationAllocationId;
if (release.kind === "ignore") return;
if (requiresExtraction) {
const extracted = await extractAllocFragmentMutation.mutateAsync({
allocationId: mutationAllocationId,
startDate: alloc.originalStartDate!,
endDate: alloc.originalEndDate!,
});
mutationAllocationId = extracted.extractedAllocationId;
}
if (release.preservePreview) {
preserveLivePreview(allocPreviewRef.current);
}
clearLivePreview(allocPreviewRef.current);
allocPreviewRef.current = null;
pendingSnapshotRef.current = pendingSnapshotRef.current
? {
...pendingSnapshotRef.current,
mutationAllocationId,
}
: null;
if (release.kind === "shift-click") {
onShiftClickAllocRef.current?.(release.allocationId);
} else if (release.kind === "click") {
onBlockClickRef.current?.(release.clickInfo);
} else if (release.kind === "mutation") {
const { mutationPlan } = release;
const {
activeAllocationId,
currentStartDate,
currentEndDate,
baseMutationAllocationId,
requiresExtraction,
pendingSnapshot,
} = mutationPlan;
updateAllocMutation.mutate({
allocationId: mutationAllocationId,
pendingSnapshotRef.current = pendingSnapshot;
pendingOptimisticAllocationIdRef.current = activeAllocationId;
setOptimisticAllocations((prev) => {
const next = new Map(prev);
next.set(activeAllocationId, {
startDate: currentStartDate,
endDate: currentEndDate,
});
} catch {
clearPendingOptimisticAllocation(activeAllocationId);
}
})();
}
return next;
});
void (async () => {
try {
let mutationAllocationId = baseMutationAllocationId;
allocDragRef.current = INITIAL_ALLOC_DRAG;
setAllocDragState(INITIAL_ALLOC_DRAG);
}
if (requiresExtraction) {
const extracted = await extractAllocFragmentMutation.mutateAsync({
allocationId: mutationAllocationId,
startDate: alloc.originalStartDate!,
endDate: alloc.originalEndDate!,
});
mutationAllocationId = extracted.extractedAllocationId;
}
allocDragCleanupRef.current = attachDocumentMouseDrag(document, handleMove, handleUp);
pendingSnapshotRef.current = pendingSnapshotRef.current
? {
...pendingSnapshotRef.current,
mutationAllocationId,
}
: null;
updateAllocMutation.mutate({
allocationId: mutationAllocationId,
startDate: currentStartDate,
endDate: currentEndDate,
});
} catch {
clearPendingOptimisticAllocation(activeAllocationId);
}
})();
}
allocDragRef.current = INITIAL_ALLOC_DRAG;
setAllocDragState(INITIAL_ALLOC_DRAG);
},
});
},
[
clearPendingOptimisticAllocation,