refactor(web): extract project drag session

This commit is contained in:
2026-04-01 11:16:15 +02:00
parent 0181f2b304
commit 3fe3a5fb2a
5 changed files with 164 additions and 17 deletions
@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from "vitest";
import { beginProjectDragSession } from "./timelineProjectDragSession.js";
describe("timelineProjectDragSession", () => {
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 = { projectId: "project-1", isDragging: true };
const cleanupRef = { current: previousCleanup as (() => void) | null };
const stateRef = { current: { projectId: null, isDragging: false } };
const handlers: {
move?: (event: MouseEvent) => void;
up?: (event: MouseEvent) => void;
} = {};
const attachDrag = vi.fn((_: Document, onMove: (event: MouseEvent) => void, onUp: (event: MouseEvent) => void) => {
handlers.move = onMove;
handlers.up = onUp;
return vi.fn();
});
beginProjectDragSession({
state,
cleanupRef,
stateRef,
setState,
documentTarget: {} as Document,
attachDrag,
updatePosition,
finalize,
});
expect(previousCleanup).toHaveBeenCalledOnce();
expect(stateRef.current).toBe(state);
expect(setState).toHaveBeenCalledWith(state);
handlers.move?.({ clientX: 42 } as MouseEvent);
expect(updatePosition).toHaveBeenCalledWith(42);
const preventDefault = vi.fn();
const attachedCleanup = cleanupRef.current;
handlers.up?.({ clientX: 54, preventDefault } as unknown as MouseEvent);
expect(attachedCleanup).toHaveBeenCalledOnce();
expect(cleanupRef.current).toBeNull();
expect(finalize).toHaveBeenCalledWith(54);
expect(preventDefault).toHaveBeenCalledOnce();
});
it("works when no prior cleanup is registered", () => {
const setState = vi.fn();
const updatePosition = vi.fn();
const finalize = vi.fn().mockResolvedValue(undefined);
const cleanupRef = { current: null as (() => void) | null };
const stateRef = { current: { projectId: null, isDragging: false } };
let upHandler: ((event: MouseEvent) => void) | undefined;
beginProjectDragSession({
state: { projectId: "project-2", isDragging: true },
cleanupRef,
stateRef,
setState,
documentTarget: {} as Document,
attachDrag: (_documentTarget, _onMove, onUp) => {
upHandler = onUp;
return vi.fn();
},
updatePosition,
finalize,
});
upHandler?.({ clientX: 12, preventDefault() {} } as MouseEvent);
expect(updatePosition).not.toHaveBeenCalled();
expect(finalize).toHaveBeenCalledWith(12);
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 BeginProjectDragSessionParams<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 beginProjectDragSession<TState>({
state,
cleanupRef,
stateRef,
setState,
documentTarget,
attachDrag,
updatePosition,
finalize,
}: BeginProjectDragSessionParams<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);
}
+13 -17
View File
@@ -23,6 +23,7 @@ import { createAllocationDragState } from "./timelineAllocationDragState.js";
import { cleanupTimelineDragState } from "./timelineDragCleanup.js";
import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js";
import { buildProjectShiftMutationInput, createProjectDragState } from "./timelineProjectDrag.js";
import { beginProjectDragSession } from "./timelineProjectDragSession.js";
import {
completeMultiSelectDraft,
createMultiSelectState,
@@ -550,24 +551,19 @@ export function useTimelineDrag({
endDate: opts.endDate,
startMouseX: e.clientX,
});
dragStateRef.current = state;
setDragState(state);
setProjectPreviewTargets(opts.projectId, e.currentTarget);
projectDragCleanupRef.current?.();
function handleMove(ev: MouseEvent) {
updateProjectDragPosition(ev.clientX);
}
function handleUp(ev: MouseEvent) {
projectDragCleanupRef.current?.();
projectDragCleanupRef.current = null;
void finalizeProjectDrag(ev.clientX);
ev.preventDefault();
}
projectDragCleanupRef.current = attachDocumentMouseDrag(document, handleMove, handleUp);
beginProjectDragSession({
state,
cleanupRef: projectDragCleanupRef,
stateRef: dragStateRef,
setState: setDragState,
documentTarget: document,
attachDrag: attachDocumentMouseDrag,
updatePosition: updateProjectDragPosition,
finalize: (clientX) => {
void finalizeProjectDrag(clientX);
},
});
},
[finalizeProjectDrag, setProjectPreviewTargets, updateProjectDragPosition],
);
+15
View File
@@ -329,6 +329,17 @@ export const rules = [
],
forbidden: [],
},
{
file: "apps/web/src/hooks/timelineProjectDragSession.ts",
maxLines: 70,
required: [
{
pattern: /\bexport function beginProjectDragSession\b/,
message: "timeline project drag session helpers must keep document drag lifecycle centralized",
},
],
forbidden: [],
},
{
file: "apps/web/src/hooks/useTimelineDrag.ts",
required: [
@@ -388,6 +399,10 @@ export const rules = [
pattern: /from "\.\/timelineProjectDrag\.js"/,
message: "timeline drag must keep project drag bootstrap and mutation gating delegated to the extracted helper module",
},
{
pattern: /from "\.\/timelineProjectDragSession\.js"/,
message: "timeline drag must keep project drag document session wiring delegated to the extracted helper module",
},
],
forbidden: [
{
@@ -85,6 +85,7 @@ describe("architecture guardrails", () => {
const documentDragRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineDocumentDrag.ts");
const allocationDragStateRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineAllocationDragState.ts");
const projectDragRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineProjectDrag.ts");
const projectDragSessionRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineProjectDragSession.ts");
assert.ok(dragRule);
assert.ok(livePreviewRule);
@@ -102,6 +103,7 @@ describe("architecture guardrails", () => {
assert.ok(documentDragRule);
assert.ok(allocationDragStateRule);
assert.ok(projectDragRule);
assert.ok(projectDragSessionRule);
assert.deepEqual(evaluateRule(dragRule, "function clearLivePreview() {}\n"), [
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep live preview behavior delegated to the extracted helper module",
@@ -118,6 +120,7 @@ describe("architecture guardrails", () => {
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation multi-drag rules delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation drag bootstrap delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project drag bootstrap and mutation gating delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project drag document session wiring delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: forbidden pattern matched: timeline drag must not re-inline live preview helper implementations",
]);
@@ -189,6 +192,10 @@ describe("architecture guardrails", () => {
"apps/web/src/hooks/timelineProjectDrag.ts: missing guardrail anchor: timeline project drag helpers must keep no-op project-shift mutation gating centralized",
]);
assert.deepEqual(evaluateRule(projectDragSessionRule, ""), [
"apps/web/src/hooks/timelineProjectDragSession.ts: missing guardrail anchor: timeline project drag session helpers must keep document drag lifecycle centralized",
]);
assert.deepEqual(
evaluateRule(
dragRule,
@@ -208,6 +215,7 @@ describe("architecture guardrails", () => {
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation multi-drag rules delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation drag bootstrap delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project drag bootstrap and mutation gating delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project drag document session wiring delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: forbidden pattern matched: timeline drag must not re-inline synthetic touch pointer adapters",
],
);