refactor(web): extract drag position helpers

This commit is contained in:
2026-04-01 11:18:31 +02:00
parent 3fe3a5fb2a
commit 5402189158
5 changed files with 250 additions and 56 deletions
@@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import { resolveAllocationDragPosition, resolveProjectDragPosition } from "./timelineDragPosition.js";
describe("timelineDragPosition", () => {
it("ignores project drags that are inactive or missing their original dates", () => {
expect(
resolveProjectDragPosition(
{
isDragging: false,
originalStartDate: new Date("2026-01-10"),
originalEndDate: new Date("2026-01-12"),
currentStartDate: null,
currentEndDate: null,
startMouseX: 100,
pointerDeltaX: 0,
daysDelta: 0,
},
180,
40,
),
).toEqual({ handled: false });
expect(
resolveProjectDragPosition(
{
isDragging: true,
originalStartDate: null,
originalEndDate: new Date("2026-01-12"),
currentStartDate: null,
currentEndDate: null,
startMouseX: 100,
pointerDeltaX: 0,
daysDelta: 0,
},
180,
40,
),
).toEqual({ handled: false });
});
it("updates only the pointer delta when the rounded day delta does not change", () => {
const result = resolveProjectDragPosition(
{
isDragging: true,
originalStartDate: new Date("2026-01-10"),
originalEndDate: new Date("2026-01-12"),
currentStartDate: null,
currentEndDate: null,
startMouseX: 100,
pointerDeltaX: 0,
daysDelta: 0,
},
118,
40,
);
expect(result).toMatchObject({
handled: true,
pointerDeltaX: 18,
daysDelta: 0,
shouldSyncState: false,
nextState: {
pointerDeltaX: 18,
daysDelta: 0,
currentStartDate: null,
currentEndDate: null,
},
});
});
it("recomputes project dates once the drag crosses into a different day bucket", () => {
const result = resolveProjectDragPosition(
{
isDragging: true,
originalStartDate: new Date("2026-01-10"),
originalEndDate: new Date("2026-01-12"),
currentStartDate: null,
currentEndDate: null,
startMouseX: 100,
pointerDeltaX: 0,
daysDelta: 0,
},
195,
40,
);
expect(result).toMatchObject({
handled: true,
pointerDeltaX: 95,
daysDelta: 2,
shouldSyncState: true,
nextState: {
currentStartDate: new Date("2026-01-12"),
currentEndDate: new Date("2026-01-14"),
pointerDeltaX: 95,
daysDelta: 2,
},
});
});
it("clamps allocation resize-end drags so the end date does not cross the start date", () => {
const result = resolveAllocationDragPosition(
{
isActive: true,
mode: "resize-end",
originalStartDate: new Date("2026-01-10"),
originalEndDate: new Date("2026-01-12"),
currentStartDate: null,
currentEndDate: null,
startMouseX: 100,
pointerDeltaX: 0,
daysDelta: 0,
},
-10,
40,
);
expect(result).toMatchObject({
handled: true,
pointerDeltaX: -110,
daysDelta: -3,
shouldSyncState: true,
nextState: {
currentStartDate: new Date("2026-01-10"),
currentEndDate: new Date("2026-01-10"),
pointerDeltaX: -110,
daysDelta: -3,
},
});
});
});
@@ -0,0 +1,74 @@
import { computeDragDates, pixelsToDays } from "~/components/timeline/dragMath.js";
type DragPositionLike = {
originalStartDate: Date | null;
originalEndDate: Date | null;
currentStartDate: Date | null;
currentEndDate: Date | null;
startMouseX: number;
pointerDeltaX: number;
daysDelta: number;
};
type DragMode = "move" | "resize-start" | "resize-end";
export type TimelineDragPositionResult<State> =
| { handled: false }
| { handled: true; nextState: State; pointerDeltaX: number; daysDelta: number; shouldSyncState: boolean };
function resolveDragPosition<State extends DragPositionLike>(
state: State,
clientX: number,
cellWidth: number,
mode: DragMode,
): TimelineDragPositionResult<State> {
const pointerDeltaX = clientX - state.startMouseX;
const daysDelta = pixelsToDays(pointerDeltaX, cellWidth);
if (daysDelta === state.daysDelta) {
return {
handled: true,
nextState: pointerDeltaX === state.pointerDeltaX ? state : { ...state, pointerDeltaX },
pointerDeltaX,
daysDelta,
shouldSyncState: false,
};
}
const { start, end } = computeDragDates(mode, state.originalStartDate!, state.originalEndDate!, daysDelta);
return {
handled: true,
nextState: {
...state,
currentStartDate: start,
currentEndDate: end,
pointerDeltaX,
daysDelta,
},
pointerDeltaX,
daysDelta,
shouldSyncState: true,
};
}
export function resolveProjectDragPosition<State extends DragPositionLike & { isDragging: boolean }>(
drag: State,
clientX: number,
cellWidth: number,
): TimelineDragPositionResult<State> {
if (!drag.isDragging || !drag.originalStartDate || !drag.originalEndDate) {
return { handled: false };
}
return resolveDragPosition(drag, clientX, cellWidth, "move");
}
export function resolveAllocationDragPosition<
State extends DragPositionLike & { isActive: boolean; mode: DragMode },
>(alloc: State, clientX: number, cellWidth: number): TimelineDragPositionResult<State> {
if (!alloc.isActive || !alloc.originalStartDate || !alloc.originalEndDate) {
return { handled: false };
}
return resolveDragPosition(alloc, clientX, cellWidth, alloc.mode);
}
+14 -56
View File
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "./useInvalidatePlanningViews.js";
import { pixelsToDays, computeDragDates } from "~/components/timeline/dragMath.js";
import { pixelsToDays } from "~/components/timeline/dragMath.js";
import {
captureLivePreviewTargets,
clearLivePreview,
@@ -21,6 +21,7 @@ import {
import { resolveAllocationRelease } from "./timelineAllocationRelease.js";
import { createAllocationDragState } from "./timelineAllocationDragState.js";
import { cleanupTimelineDragState } from "./timelineDragCleanup.js";
import { resolveAllocationDragPosition, resolveProjectDragPosition } from "./timelineDragPosition.js";
import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js";
import { buildProjectShiftMutationInput, createProjectDragState } from "./timelineProjectDrag.js";
import { beginProjectDragSession } from "./timelineProjectDragSession.js";
@@ -343,35 +344,14 @@ export function useTimelineDrag({
const updateProjectDragPosition = useCallback(
(clientX: number) => {
const drag = dragStateRef.current;
if (!drag.isDragging || !drag.originalStartDate || !drag.originalEndDate) return false;
const result = resolveProjectDragPosition(dragStateRef.current, clientX, cellWidthRef.current);
if (!result.handled) return false;
const deltaX = clientX - drag.startMouseX;
const daysDelta = pixelsToDays(deltaX, cellWidthRef.current);
updateLivePreview(projectPreviewRef, deltaX, daysDelta);
if (daysDelta === drag.daysDelta) {
if (deltaX !== drag.pointerDeltaX) {
dragStateRef.current = { ...drag, pointerDeltaX: deltaX };
}
return true;
updateLivePreview(projectPreviewRef, result.pointerDeltaX, result.daysDelta);
dragStateRef.current = result.nextState;
if (result.shouldSyncState) {
setDragState(result.nextState);
}
const { start: newStart, end: newEnd } = computeDragDates(
"move",
drag.originalStartDate,
drag.originalEndDate,
daysDelta,
);
const updated: DragState = {
...drag,
currentStartDate: newStart,
currentEndDate: newEnd,
pointerDeltaX: deltaX,
daysDelta,
};
dragStateRef.current = updated;
setDragState(updated);
return true;
},
[updateLivePreview],
@@ -379,36 +359,14 @@ export function useTimelineDrag({
const updateAllocationDragPosition = useCallback(
(clientX: number) => {
const alloc = allocDragRef.current;
if (!alloc.isActive || !alloc.originalStartDate || !alloc.originalEndDate) return false;
const result = resolveAllocationDragPosition(allocDragRef.current, clientX, cellWidthRef.current);
if (!result.handled) return false;
const pointerDeltaX = clientX - alloc.startMouseX;
const daysDelta = pixelsToDays(pointerDeltaX, cellWidthRef.current);
updateLivePreview(allocPreviewRef, pointerDeltaX, daysDelta);
if (daysDelta === alloc.daysDelta) {
if (pointerDeltaX !== alloc.pointerDeltaX) {
allocDragRef.current = { ...alloc, pointerDeltaX };
}
return true;
updateLivePreview(allocPreviewRef, result.pointerDeltaX, result.daysDelta);
allocDragRef.current = result.nextState;
if (result.shouldSyncState) {
setAllocDragState(result.nextState);
}
const { start: newStart, end: newEnd } = computeDragDates(
alloc.mode,
alloc.originalStartDate,
alloc.originalEndDate,
daysDelta,
);
const updated: AllocDragState = {
...alloc,
currentStartDate: newStart,
currentEndDate: newEnd,
pointerDeltaX,
daysDelta,
};
allocDragRef.current = updated;
setAllocDragState(updated);
return true;
},
[updateLivePreview],