Files
CapaKraken/apps/web/src/hooks/useTimelineDrag.ts
T
Hartmut f3fa902773 fix(web): make invalidation hooks async with Promise.all and fix cross-view staleness
- useInvalidateTimeline and useInvalidatePlanningViews now return
  Promise.all instead of fire-and-forget void calls
- Timeline mutations now use useInvalidatePlanningViews to also
  invalidate allocation list views, preventing stale data
- AllocationsClient sequential awaits replaced with single
  invalidatePlanningViews() call (parallel invalidation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 08:24:33 +02:00

1016 lines
32 KiB
TypeScript

"use client";
import {
useCallback,
useDeferredValue,
useEffect,
useRef,
useState,
type MutableRefObject,
} from "react";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidatePlanningViews } from "./useInvalidatePlanningViews.js";
import { pixelsToDays } from "~/components/timeline/dragMath.js";
import {
clearLivePreview,
preserveLivePreview,
scheduleLivePreview,
type LivePreviewSession,
} from "./timelineLivePreview.js";
import {
finalizeAllocationMultiDrag,
isAllocationMultiSelected,
startAllocationMultiDrag,
updateAllocationMultiDrag,
} from "./timelineAllocationMultiDrag.js";
import { beginAllocationMultiDragSession } from "./timelineAllocationMultiDragSession.js";
import { createAllocationDragState } from "./timelineAllocationDragState.js";
import { beginAllocationDragSession } from "./timelineAllocationDragSession.js";
import { finalizeAllocationReleaseEffects } from "./timelineAllocationReleaseEffects.js";
import {
cancelTransientMultiSelectState,
cleanupTimelineDragState,
} from "./timelineDragCleanup.js";
import {
resolveAllocationDragPosition,
resolveProjectDragPosition,
} from "./timelineDragPosition.js";
import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js";
import { finalizeProjectDrag } from "./timelineProjectDragFinalize.js";
import { createProjectDragState } from "./timelineProjectDrag.js";
import { beginProjectDragSession } from "./timelineProjectDragSession.js";
import {
forwardCanvasTouchEnd,
forwardCanvasTouchMove,
forwardTouchStartAsMouseDown,
} from "./timelineTouchEvents.js";
import {
completeMultiSelectDraft,
createMultiSelectState,
updateMultiSelectDraft,
} from "./timelineMultiSelect.js";
import { beginCanvasMultiSelectSession } from "./timelineMultiSelectSession.js";
import { reconcileOptimisticEntries } from "./timelineOptimisticAllocations.js";
import {
createAllocationPreviewSession,
createProjectPreviewSession,
} from "./timelinePreviewSession.js";
import {
resolveRangeSelectionCancel,
resolveRangeSelectionRelease,
} from "./timelineRangeRelease.js";
import { createRangeSelectionState, updateRangeSelectionDraft } from "./timelineRangeSelection.js";
import { type TouchCanvasPointerEvent, type TouchMouseDownEvent } from "./timelineTouchAdapters.js";
const DRAG_CLICK_THRESHOLD_PX = 5;
// ─── Project-shift drag state ───────────────────────────────────────────────
export interface DragState {
isDragging: boolean;
projectId: string | null;
projectName: string | null;
allocationId: string | null;
originalStartDate: Date | null;
originalEndDate: Date | null;
currentStartDate: Date | null;
currentEndDate: Date | null;
startMouseX: number;
pointerDeltaX: number;
originalLeft: number;
blockWidth: number;
daysDelta: number;
}
export interface BlockClickInfo {
allocationId: string;
projectId: string;
projectName: string;
startDate: Date;
endDate: Date;
}
export interface ShiftPreviewData {
valid: boolean;
deltaCents: number;
wouldExceedBudget: boolean;
budgetUtilizationAfter: number;
conflictCount: number;
errors: string[];
warnings: string[];
}
const INITIAL_DRAG_STATE: DragState = {
isDragging: false,
projectId: null,
projectName: null,
allocationId: null,
originalStartDate: null,
originalEndDate: null,
currentStartDate: null,
currentEndDate: null,
startMouseX: 0,
pointerDeltaX: 0,
originalLeft: 0,
blockWidth: 0,
daysDelta: 0,
};
// ─── Per-allocation drag state ──────────────────────────────────────────────
export type AllocDragMode = "move" | "resize-start" | "resize-end";
export type AllocDragScope = "allocation" | "segment";
export interface AllocDragState {
isActive: boolean;
mode: AllocDragMode;
scope: AllocDragScope;
allocationId: string | null;
mutationAllocationId: string | null;
projectId: string | null;
projectName: string | null;
resourceId: string | null;
allocationStartDate: Date | null;
allocationEndDate: Date | null;
originalStartDate: Date | null;
originalEndDate: Date | null;
currentStartDate: Date | null;
currentEndDate: Date | null;
startMouseX: number;
pointerDeltaX: number;
daysDelta: number;
}
const INITIAL_ALLOC_DRAG: AllocDragState = {
isActive: false,
mode: "move",
scope: "allocation",
allocationId: null,
mutationAllocationId: null,
projectId: null,
projectName: null,
resourceId: null,
allocationStartDate: null,
allocationEndDate: null,
originalStartDate: null,
originalEndDate: null,
currentStartDate: null,
currentEndDate: null,
startMouseX: 0,
pointerDeltaX: 0,
daysDelta: 0,
};
// ─── Range-select state ─────────────────────────────────────────────────────
export interface RangeState {
isSelecting: boolean;
resourceId: string | null;
startDate: Date | null;
currentDate: Date | null;
suggestedProjectId: string | null;
startClientX: number;
}
export interface RangeSelectedInfo {
resourceId: string;
startDate: Date;
endDate: Date;
suggestedProjectId: string | null;
anchorX: number;
anchorY: number;
}
const INITIAL_RANGE_STATE: RangeState = {
isSelecting: false,
resourceId: null,
startDate: null,
currentDate: null,
suggestedProjectId: null,
startClientX: 0,
};
// ─── Multi-select state ────────────────────────────────────────────────────
export interface MultiSelectState {
isSelecting: boolean;
startX: number;
startY: number;
currentX: number;
currentY: number;
selectedAllocationIds: string[];
selectedResourceIds: string[];
dateRange: { start: Date; end: Date } | null;
/** When multi-dragging, the number of days all selected allocations are shifted */
multiDragDaysDelta: number;
/** Whether a multi-drag is currently in progress */
isMultiDragging: boolean;
/** The drag mode during multi-drag (move, resize-start, resize-end) */
multiDragMode: AllocDragMode;
}
const INITIAL_MULTI_SELECT: MultiSelectState = {
isSelecting: false,
startX: 0,
startY: 0,
currentX: 0,
currentY: 0,
selectedAllocationIds: [],
selectedResourceIds: [],
dateRange: null,
multiDragDaysDelta: 0,
isMultiDragging: false,
multiDragMode: "move",
};
// ─── Hook ───────────────────────────────────────────────────────────────────
export interface AllocationMovedSnapshot {
allocationId: string;
mutationAllocationId: string;
projectName: string;
before: { startDate: Date; endDate: Date };
after: { startDate: Date; endDate: Date };
}
export interface OptimisticTimelineEntry {
id: string;
startDate: Date | string;
endDate: Date | string;
}
export interface OptimisticTimelineOverride {
startDate: Date;
endDate: Date;
}
export function useTimelineDrag({
cellWidthRef,
onShiftApplied,
onBlockClick,
onRangeSelected,
onAllocationMoved,
onShiftClickAlloc,
onMultiDragComplete,
onMutationError,
}: {
cellWidthRef: MutableRefObject<number>;
onShiftApplied?: (projectId: string) => void;
onBlockClick?: (info: BlockClickInfo) => void;
onRangeSelected?: (info: RangeSelectedInfo) => void;
onAllocationMoved?: (snapshot: AllocationMovedSnapshot) => void;
onShiftClickAlloc?: (allocationId: string) => void;
onMultiDragComplete?: (daysDelta: number, mode: AllocDragMode, selectedIds?: string[]) => void;
onMutationError?: (message: string) => void;
}) {
const [dragState, setDragState] = useState<DragState>(INITIAL_DRAG_STATE);
const [allocDragState, setAllocDragState] = useState<AllocDragState>(INITIAL_ALLOC_DRAG);
const [rangeState, setRangeState] = useState<RangeState>(INITIAL_RANGE_STATE);
const [multiSelectState, setMultiSelectState] = useState<MultiSelectState>(INITIAL_MULTI_SELECT);
const dragStateRef = useRef<DragState>(INITIAL_DRAG_STATE);
const allocDragRef = useRef<AllocDragState>(INITIAL_ALLOC_DRAG);
const rangeStateRef = useRef<RangeState>(INITIAL_RANGE_STATE);
const multiSelectRef = useRef<MultiSelectState>(INITIAL_MULTI_SELECT);
const projectPreviewRef = useRef<LivePreviewSession | null>(null);
const allocPreviewRef = useRef<LivePreviewSession | null>(null);
const projectDragCleanupRef = useRef<(() => void) | null>(null);
const allocDragCleanupRef = useRef<(() => void) | null>(null);
const multiSelectCleanupRef = useRef<(() => void) | null>(null);
// Keep ref in sync with state so document-level handlers read the latest selection
multiSelectRef.current = multiSelectState;
// Touch disambiguation: track initial touch position to distinguish horizontal drag from vertical scroll
const touchStartRef = useRef<{ x: number; y: number; decided: boolean }>({
x: 0,
y: 0,
decided: false,
});
const onBlockClickRef = useRef(onBlockClick);
onBlockClickRef.current = onBlockClick;
const onAllocationMovedRef = useRef(onAllocationMoved);
onAllocationMovedRef.current = onAllocationMoved;
const onShiftClickAllocRef = useRef(onShiftClickAlloc);
onShiftClickAllocRef.current = onShiftClickAlloc;
const onMultiDragCompleteRef = useRef(onMultiDragComplete);
onMultiDragCompleteRef.current = onMultiDragComplete;
const onMutationErrorRef = useRef(onMutationError);
onMutationErrorRef.current = onMutationError;
const utils = trpc.useUtils();
const invalidatePlanningViews = useInvalidatePlanningViews();
const setProjectPreviewTargets = useCallback(
(projectId: string, currentTarget?: EventTarget | null) => {
clearLivePreview(projectPreviewRef.current);
projectPreviewRef.current = createProjectPreviewSession({
projectId,
currentTarget,
cellWidth: cellWidthRef.current,
});
},
[],
);
const setAllocationPreviewTarget = useCallback(
(currentTarget?: EventTarget | null, mode: AllocDragMode = "move") => {
clearLivePreview(allocPreviewRef.current);
allocPreviewRef.current = createAllocationPreviewSession({
currentTarget,
mode,
cellWidth: cellWidthRef.current,
});
},
[],
);
const updateLivePreview = useCallback(
(
previewRef: MutableRefObject<LivePreviewSession | null>,
pointerDeltaX: number,
daysDelta: number,
) => {
const preview = previewRef.current;
if (!preview) return;
preview.cellWidth = cellWidthRef.current;
preview.pointerDeltaX = pointerDeltaX;
preview.daysDelta = daysDelta;
scheduleLivePreview(preview);
},
[],
);
const updateProjectDragPosition = useCallback(
(clientX: number) => {
const result = resolveProjectDragPosition(
dragStateRef.current,
clientX,
cellWidthRef.current,
);
if (!result.handled) return false;
updateLivePreview(projectPreviewRef, result.pointerDeltaX, result.daysDelta);
dragStateRef.current = result.nextState;
if (result.shouldSyncState) {
setDragState(result.nextState);
}
return true;
},
[updateLivePreview],
);
const updateAllocationDragPosition = useCallback(
(clientX: number) => {
const result = resolveAllocationDragPosition(
allocDragRef.current,
clientX,
cellWidthRef.current,
);
if (!result.handled) return false;
updateLivePreview(allocPreviewRef, result.pointerDeltaX, result.daysDelta);
allocDragRef.current = result.nextState;
if (result.shouldSyncState) {
setAllocDragState(result.nextState);
}
return true;
},
[updateLivePreview],
);
const clearProjectDragSession = useCallback(() => {
projectDragCleanupRef.current?.();
projectDragCleanupRef.current = null;
clearLivePreview(projectPreviewRef.current);
projectPreviewRef.current = null;
dragStateRef.current = 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);
}, []);
// Defer daysDelta to avoid firing the preview query every pixel during drag
const deferredDaysDelta = useDeferredValue(dragState.daysDelta);
// Project-shift preview
const { data: previewData, isFetching: isPreviewLoading } = trpc.timeline.previewShift.useQuery(
{
projectId: dragState.projectId ?? "",
newStartDate: dragState.currentStartDate ?? new Date(),
newEndDate: dragState.currentEndDate ?? new Date(),
},
{
enabled:
dragState.isDragging &&
dragState.projectId !== null &&
deferredDaysDelta !== 0 &&
dragState.currentStartDate !== null,
staleTime: 0,
},
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const applyShiftMutation = (trpc.timeline.applyShift.useMutation as any)({
onSuccess: (data: { project: { id: string } }) => {
void invalidatePlanningViews();
void utils.project.list.invalidate();
onShiftApplied?.(data.project.id);
},
}) as {
isPending: boolean;
mutate: (...args: unknown[]) => void;
mutateAsync: (...args: unknown[]) => Promise<unknown>;
};
const finalizeActiveProjectDrag = useCallback(
(clientX: number, mode: "mutate" | "mutateAsync" = "mutate") =>
finalizeProjectDrag({
clientX,
mode,
dragRef: dragStateRef,
previewRef: projectPreviewRef,
updatePosition: updateProjectDragPosition,
clearSession: clearProjectDragSession,
mutate: applyShiftMutation.mutate,
mutateAsync: applyShiftMutation.mutateAsync,
}),
[applyShiftMutation, clearProjectDragSession, updateProjectDragPosition],
);
const pendingSnapshotRef = useRef<AllocationMovedSnapshot | null>(null);
const pendingOptimisticAllocationIdRef = useRef<string | null>(null);
const [optimisticAllocations, setOptimisticAllocations] = useState<
Map<string, OptimisticTimelineOverride>
>(() => new Map());
const updateAllocMutation = trpc.timeline.updateAllocationInline.useMutation({
onSuccess: () => {
void invalidatePlanningViews();
const snap = pendingSnapshotRef.current;
if (snap) {
onAllocationMovedRef.current?.(snap);
pendingSnapshotRef.current = null;
}
},
onError: (error) => {
console.error("[timeline] updateAllocationInline failed:", error);
const message =
(error as { message?: string }).message ?? "Zuweisung konnte nicht verschoben werden.";
onMutationErrorRef.current?.(message);
clearPendingOptimisticAllocation();
},
});
const extractAllocFragmentMutation = trpc.timeline.extractAllocationFragment.useMutation({
onSuccess: () => {
void invalidatePlanningViews();
},
});
const clearPendingOptimisticAllocation = useCallback((allocationId?: string | null) => {
pendingSnapshotRef.current = null;
const optimisticAllocationId = allocationId ?? pendingOptimisticAllocationIdRef.current;
if (!optimisticAllocationId) {
pendingOptimisticAllocationIdRef.current = null;
return;
}
setOptimisticAllocations((prev) => {
if (!prev.has(optimisticAllocationId)) return prev;
const next = new Map(prev);
next.delete(optimisticAllocationId);
return next;
});
pendingOptimisticAllocationIdRef.current = null;
}, []);
const reconcileOptimisticAllocations = useCallback(
(entries: readonly OptimisticTimelineEntry[]) => {
setOptimisticAllocations((prev) => {
const result = reconcileOptimisticEntries(
prev,
entries,
pendingOptimisticAllocationIdRef.current,
);
pendingOptimisticAllocationIdRef.current = result.pendingOptimisticAllocationId;
return result.changed ? result.optimisticAllocations : prev;
});
},
[],
);
// ── Project-bar drag (shifts all allocations) ──────────────────────────────
const onProjectBarMouseDown = useCallback(
(
e: TouchMouseDownEvent,
opts: {
projectId: string;
projectName: string;
startDate: Date;
endDate: Date;
},
) => {
if (e.button !== 0) return;
multiSelectRef.current = INITIAL_MULTI_SELECT;
setMultiSelectState(INITIAL_MULTI_SELECT);
e.preventDefault();
e.stopPropagation();
const state = createProjectDragState<DragState>({
projectId: opts.projectId,
projectName: opts.projectName,
startDate: opts.startDate,
endDate: opts.endDate,
startMouseX: e.clientX,
});
setProjectPreviewTargets(opts.projectId, e.currentTarget);
beginProjectDragSession({
state,
cleanupRef: projectDragCleanupRef,
stateRef: dragStateRef,
setState: setDragState,
documentTarget: document,
attachDrag: attachDocumentMouseDrag,
updatePosition: updateProjectDragPosition,
finalize: (clientX) => {
void finalizeActiveProjectDrag(clientX);
},
});
},
[finalizeActiveProjectDrag, setProjectPreviewTargets, updateProjectDragPosition],
);
// Legacy — kept for backward compat (triggers project shift from allocation block)
const onBlockMouseDown = useCallback(
(
e: TouchMouseDownEvent,
opts: {
projectId: string;
projectName: string;
allocationId?: string;
startDate: Date;
endDate: Date;
blockLeft: number;
blockWidth: number;
},
) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const state = createProjectDragState<DragState>({
projectId: opts.projectId,
projectName: opts.projectName,
allocationId: opts.allocationId ?? null,
startDate: opts.startDate,
endDate: opts.endDate,
startMouseX: e.clientX,
originalLeft: opts.blockLeft,
blockWidth: opts.blockWidth,
});
dragStateRef.current = state;
setDragState(state);
},
[],
);
// ── Per-allocation drag — document-level listeners ─────────────────────────
//
// Uses document.addEventListener instead of React canvas events so the drag
// works reliably even when the cursor leaves the canvas boundary (e.g. while
// moving quickly or scrolling into the sticky header area).
const onAllocMouseDown = useCallback(
(
e: TouchMouseDownEvent,
opts: {
mode: AllocDragMode;
allocationId: string;
mutationAllocationId?: string;
projectId: string;
projectName: string;
resourceId: string | null;
startDate: Date;
endDate: Date;
allocationStartDate?: Date;
allocationEndDate?: Date;
scope?: AllocDragScope;
},
) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const wasShift = e.shiftKey;
// Check if this allocation is part of a multi-selection → multi-drag mode
const ms = multiSelectRef.current;
const isMultiSelected = isAllocationMultiSelected(ms, opts.allocationId);
if (isMultiSelected) {
beginAllocationMultiDragSession({
startMouseX: e.clientX,
dragMode: opts.mode,
documentTarget: document,
cleanupRef: multiSelectCleanupRef,
stateRef: multiSelectRef,
setState: setMultiSelectState,
startState: startAllocationMultiDrag,
updateState: updateAllocationMultiDrag,
finalizeState: finalizeAllocationMultiDrag,
toDaysDelta: (deltaX) => pixelsToDays(deltaX, cellWidthRef.current),
onComplete: (daysDelta, finalized) => {
onMultiDragCompleteRef.current?.(daysDelta, opts.mode, finalized.selectedAllocationIds);
},
attachDrag: attachDocumentMouseDrag,
});
return;
}
// ── Single allocation drag ────────────────────────────────────────────
multiSelectRef.current = INITIAL_MULTI_SELECT;
setMultiSelectState(INITIAL_MULTI_SELECT);
const initial = createAllocationDragState<AllocDragState, AllocDragMode, AllocDragScope>({
mode: opts.mode,
scope: opts.scope,
allocationId: opts.allocationId,
mutationAllocationId: opts.mutationAllocationId,
projectId: opts.projectId,
projectName: opts.projectName,
resourceId: opts.resourceId,
allocationStartDate: opts.allocationStartDate,
allocationEndDate: opts.allocationEndDate,
startDate: opts.startDate,
endDate: opts.endDate,
startMouseX: e.clientX,
});
setAllocationPreviewTarget(e.currentTarget, opts.mode);
beginAllocationDragSession({
state: initial,
cleanupRef: allocDragCleanupRef,
stateRef: allocDragRef,
setState: setAllocDragState,
documentTarget: document,
attachDrag: attachDocumentMouseDrag,
updatePosition: updateAllocationDragPosition,
finalize: (clientX) => {
void finalizeAllocationReleaseEffects({
clientX,
allocRef: allocDragRef,
previewRef: allocPreviewRef,
updatePosition: updateAllocationDragPosition,
clickThresholdPx: DRAG_CLICK_THRESHOLD_PX,
wasShift,
onShiftClick: onShiftClickAllocRef.current,
onBlockClick: onBlockClickRef.current,
pendingSnapshotRef,
pendingOptimisticAllocationIdRef,
setOptimisticAllocations,
extractAllocationFragment: extractAllocFragmentMutation.mutateAsync,
updateAllocation: updateAllocMutation.mutate,
clearPendingOptimisticAllocation,
onError: (msg) => onMutationErrorRef.current?.(msg),
});
allocDragRef.current = INITIAL_ALLOC_DRAG;
setAllocDragState(INITIAL_ALLOC_DRAG);
},
});
},
[
clearPendingOptimisticAllocation,
extractAllocFragmentMutation,
setAllocationPreviewTarget,
updateAllocationDragPosition,
updateAllocMutation,
],
);
// ── Range-select ────────────────────────────────────────────────────────────
const onRowMouseDown = useCallback(
(
e: TouchMouseDownEvent,
opts: {
resourceId: string;
startDate: Date;
suggestedProjectId?: string;
},
) => {
if (dragStateRef.current.isDragging || allocDragRef.current.isActive) return;
if (e.button !== 0) return;
e.preventDefault();
const state = createRangeSelectionState<RangeState>(
opts.resourceId,
opts.startDate,
e.clientX,
opts.suggestedProjectId,
);
rangeStateRef.current = state;
setRangeState(state);
},
[],
);
// ── Canvas-level handlers (project shift + range select only) ──────────────
const onCanvasMouseMove = useCallback(
(e: TouchCanvasPointerEvent) => {
if (updateProjectDragPosition(e.clientX)) {
return;
}
// Range select
const range = rangeStateRef.current;
const updated = updateRangeSelectionDraft(range, e.clientX, cellWidthRef.current);
if (updated) {
rangeStateRef.current = updated;
setRangeState(updated);
}
},
[updateProjectDragPosition],
);
const onCanvasMouseUp = useCallback(
async (e: TouchCanvasPointerEvent) => {
// Project shift
const drag = dragStateRef.current;
if (drag.isDragging) {
try {
await finalizeActiveProjectDrag(e.clientX, "mutateAsync");
} catch {
// Validation error — revert visually
}
return;
}
// Range select
const release = resolveRangeSelectionRelease(
rangeStateRef.current,
e.clientX,
e.clientY,
INITIAL_RANGE_STATE,
);
if (release.kind !== "complete") return;
onRangeSelected?.(release.selection);
rangeStateRef.current = release.nextState;
setRangeState(release.nextState);
},
[finalizeActiveProjectDrag, onRangeSelected],
);
const onCanvasMouseLeave = useCallback(() => {
// Only cancel project-shift and range-select on canvas leave.
// Alloc drag is managed by document-level listeners and must NOT be cancelled here.
const cancellation = resolveRangeSelectionCancel(rangeStateRef.current, INITIAL_RANGE_STATE);
if (!cancellation.didReset) return;
rangeStateRef.current = cancellation.nextState;
setRangeState(cancellation.nextState);
}, []);
// ── Multi-select (right-click drag) ─────────────────────────────────────────
const onCanvasRightMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 2) return;
e.preventDefault();
beginCanvasMultiSelectSession({
clientX: e.clientX,
clientY: e.clientY,
documentTarget: document,
cleanupRef: multiSelectCleanupRef,
stateRef: multiSelectRef,
setState: setMultiSelectState,
createInitialState: (clientX, clientY) =>
createMultiSelectState<MultiSelectState>(clientX, clientY, {
selectedAllocationIds: [],
selectedResourceIds: [],
dateRange: null,
multiDragDaysDelta: 0,
isMultiDragging: false,
multiDragMode: "move",
}),
updateState: updateMultiSelectDraft,
completeState: completeMultiSelectDraft,
initialState: INITIAL_MULTI_SELECT,
attachDrag: attachDocumentMouseDrag,
});
}, []);
const clearMultiSelect = useCallback(() => {
multiSelectRef.current = INITIAL_MULTI_SELECT;
setMultiSelectState(INITIAL_MULTI_SELECT);
}, []);
// ── Touch support ───────────────────────────────────────────────────────────
const onProjectBarTouchStart = useCallback(
(
e: React.TouchEvent,
opts: {
projectId: string;
projectName: string;
startDate: Date;
endDate: Date;
},
) => {
forwardTouchStartAsMouseDown({
event: e,
touchStartRef,
decided: true,
onMouseDown: onProjectBarMouseDown,
opts,
});
},
[onProjectBarMouseDown],
);
const onAllocTouchStart = useCallback(
(
e: React.TouchEvent,
opts: {
mode: AllocDragMode;
allocationId: string;
mutationAllocationId?: string;
projectId: string;
projectName: string;
resourceId: string | null;
startDate: Date;
endDate: Date;
allocationStartDate?: Date;
allocationEndDate?: Date;
scope?: AllocDragScope;
},
) => {
forwardTouchStartAsMouseDown({
event: e,
touchStartRef,
decided: true,
onMouseDown: onAllocMouseDown,
opts,
});
},
[onAllocMouseDown],
);
const onRowTouchStart = useCallback(
(
e: React.TouchEvent,
opts: {
resourceId: string;
startDate: Date;
suggestedProjectId?: string;
},
) => {
forwardTouchStartAsMouseDown({
event: e,
touchStartRef,
decided: false,
onMouseDown: onRowMouseDown,
opts,
});
},
[onRowMouseDown],
);
const onCanvasTouchMove = useCallback(
(e: React.TouchEvent) => {
forwardCanvasTouchMove({
event: e,
touchStartRef,
onCanvasMouseMove,
});
},
[onCanvasMouseMove],
);
const onCanvasTouchEnd = useCallback(
async (e: React.TouchEvent) => {
await forwardCanvasTouchEnd({ event: e, onCanvasMouseUp });
},
[onCanvasMouseUp],
);
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,
multiSelectCleanupRef,
projectPreviewRef,
allocPreviewRef,
dragStateRef,
allocDragRef,
rangeStateRef,
multiSelectRef,
initialDragState: INITIAL_DRAG_STATE,
initialAllocDragState: INITIAL_ALLOC_DRAG,
initialRangeState: INITIAL_RANGE_STATE,
initialMultiSelectState: INITIAL_MULTI_SELECT,
clearPreview: clearLivePreview,
});
};
}, [cancelActiveInteractions]);
// ── Derived ─────────────────────────────────────────────────────────────────
const shiftPreview: ShiftPreviewData | null =
dragState.isDragging && dragState.daysDelta !== 0 && previewData
? {
valid: previewData.valid,
deltaCents: previewData.costImpact.deltaCents,
wouldExceedBudget: previewData.costImpact.wouldExceedBudget,
budgetUtilizationAfter: previewData.costImpact.budgetUtilizationAfter,
conflictCount: previewData.conflictDetails.length,
errors: previewData.errors.map((e) => e.message),
warnings: previewData.warnings.map((w) => w.message),
}
: null;
return {
dragState,
allocDragState,
rangeState,
multiSelectState,
setMultiSelectState,
optimisticAllocations,
reconcileOptimisticAllocations,
shiftPreview,
isPreviewLoading,
isApplying: applyShiftMutation.isPending,
isAllocSaving: updateAllocMutation.isPending,
onProjectBarMouseDown,
onBlockMouseDown,
onAllocMouseDown,
onRowMouseDown,
onCanvasMouseMove,
onCanvasMouseUp,
onCanvasMouseLeave,
onCanvasRightMouseDown,
clearMultiSelect,
// Touch equivalents
onProjectBarTouchStart,
onAllocTouchStart,
onRowTouchStart,
onCanvasTouchMove,
onCanvasTouchEnd,
};
}