Skip to content

Commit 6958e61

Browse files
committed
refactor(studio): isolate clip drag lifecycle
1 parent 287a998 commit 6958e61

3 files changed

Lines changed: 384 additions & 216 deletions

File tree

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// @vitest-environment happy-dom
2+
3+
import type { SetStateAction } from "react";
4+
import { afterEach, describe, expect, it, vi } from "vitest";
5+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
6+
import { mountTimelineClipDragGestureLifecycle } from "./timelineClipDragGestureLifecycle";
7+
import type { DraggedClipState, ResizingClipState } from "./timelineClipDragTypes";
8+
9+
afterEach(() => {
10+
document.body.replaceChildren();
11+
usePlayerStore.getState().reset();
12+
});
13+
14+
describe("timeline clip drag gesture lifecycle", () => {
15+
it("finishes a drag after virtualization unmounts its source row", () => {
16+
const element: TimelineElement = {
17+
id: "clip-1",
18+
tag: "div",
19+
start: 0,
20+
duration: 2,
21+
track: 0,
22+
};
23+
const drag: DraggedClipState = {
24+
element,
25+
originClientX: 0,
26+
originClientY: 0,
27+
originScrollLeft: 0,
28+
originScrollTop: 0,
29+
pointerClientX: 0,
30+
pointerClientY: 0,
31+
pointerOffsetX: 0,
32+
pointerOffsetY: 0,
33+
previewStart: 1,
34+
previewTrack: 0,
35+
desiredTrack: 0,
36+
insertRow: null,
37+
snapTime: null,
38+
snapType: null,
39+
started: true,
40+
};
41+
const draggedClipRef = { current: drag as DraggedClipState | null };
42+
const resizingClipRef = { current: null as ResizingClipState | null };
43+
const setDraggedClip = (next: SetStateAction<DraggedClipState | null>) => {
44+
draggedClipRef.current = typeof next === "function" ? next(draggedClipRef.current) : next;
45+
};
46+
const updateDraggedClipPreview = vi.fn((previous: DraggedClipState) => ({
47+
...previous,
48+
previewStart: 3,
49+
}));
50+
const onMoveElement = vi.fn();
51+
const stopAutoScroll = vi.fn();
52+
const dispose = mountTimelineClipDragGestureLifecycle({
53+
draggedClipRef,
54+
resizingClipRef,
55+
blockedClipRef: { current: null },
56+
groupResizeRef: { current: null },
57+
suppressClickRef: { current: false },
58+
elementsRef: { current: [element] },
59+
trackOrderRef: { current: [0] },
60+
setDraggedClip,
61+
setResizingClip: () => {},
62+
setShowPopover: () => {},
63+
setRangeSelectionRef: { current: null },
64+
applyResizePointerRef: { current: () => {} },
65+
syncClipDragAutoScrollRef: { current: () => {} },
66+
stopClipDragAutoScrollRef: { current: stopAutoScroll },
67+
updateDraggedClipPreviewRef: { current: updateDraggedClipPreview },
68+
restoreGroupResizeMembers: () => {},
69+
updateElement: vi.fn(),
70+
onMoveElementRef: { current: onMoveElement },
71+
onMoveElementsRef: { current: undefined },
72+
onResizeElementRef: { current: undefined },
73+
onResizeElementsRef: { current: undefined },
74+
onBlockedEditAttemptRef: { current: undefined },
75+
readZIndexRef: { current: undefined },
76+
onStackingPatchesRef: { current: undefined },
77+
refreshAfterLaneMoveRef: { current: undefined },
78+
});
79+
80+
const sourceRow = document.createElement("div");
81+
sourceRow.dataset.timelineRow = "";
82+
sourceRow.append(document.createElement("div"));
83+
document.body.append(sourceRow);
84+
sourceRow.remove();
85+
86+
window.dispatchEvent(new MouseEvent("pointermove", { clientX: 20, clientY: 10 }));
87+
expect(updateDraggedClipPreview).toHaveBeenCalledTimes(1);
88+
expect(draggedClipRef.current?.previewStart).toBe(3);
89+
90+
window.dispatchEvent(new MouseEvent("pointerup"));
91+
expect(onMoveElement).toHaveBeenCalledWith(element, { start: 3, track: 0 });
92+
expect(draggedClipRef.current).toBeNull();
93+
expect(stopAutoScroll).toHaveBeenCalledTimes(1);
94+
95+
dispose();
96+
});
97+
});
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import type { Dispatch, RefObject, SetStateAction } from "react";
2+
import { resolveTimelineDragEscape } from "./timelineEditing";
3+
import { commitDraggedClipMove } from "./timelineClipDragCommit";
4+
import type {
5+
BlockedClipState,
6+
DraggedClipState,
7+
ResizingClipState,
8+
} from "./timelineClipDragTypes";
9+
import type { TimelineGroupResizeSession } from "./timelineGroupEditing";
10+
import { commitTimelineGroupResize } from "./timelineGroupResizeCommit";
11+
import {
12+
beginTimelineOptimisticGesture,
13+
rollbackLatestTimelineOptimisticGesture,
14+
} from "./timelineOptimisticRevision";
15+
import type { TimelineEditCallbacks } from "./timelineCallbacks";
16+
import type { StackingPatch } from "./timelineStackingSync";
17+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
18+
19+
type UpdateElement = ReturnType<typeof usePlayerStore.getState>["updateElement"];
20+
21+
interface TimelineClipDragGestureLifecycleInput {
22+
draggedClipRef: RefObject<DraggedClipState | null>;
23+
resizingClipRef: RefObject<ResizingClipState | null>;
24+
blockedClipRef: RefObject<BlockedClipState | null>;
25+
groupResizeRef: RefObject<TimelineGroupResizeSession | null>;
26+
suppressClickRef: RefObject<boolean>;
27+
elementsRef: RefObject<TimelineElement[]>;
28+
trackOrderRef: RefObject<number[]>;
29+
setDraggedClip: Dispatch<SetStateAction<DraggedClipState | null>>;
30+
setResizingClip: Dispatch<SetStateAction<ResizingClipState | null>>;
31+
setShowPopover: (show: boolean) => void;
32+
setRangeSelectionRef: RefObject<((selection: null) => void) | null>;
33+
applyResizePointerRef: RefObject<(resize: ResizingClipState, clientX: number) => void>;
34+
syncClipDragAutoScrollRef: RefObject<(clientX: number, clientY: number) => void>;
35+
stopClipDragAutoScrollRef: RefObject<() => void>;
36+
updateDraggedClipPreviewRef: RefObject<
37+
(drag: DraggedClipState, clientX: number, clientY: number) => DraggedClipState
38+
>;
39+
restoreGroupResizeMembers: (session: TimelineGroupResizeSession, all?: boolean) => void;
40+
updateElement: UpdateElement;
41+
onMoveElementRef: RefObject<TimelineEditCallbacks["onMoveElement"]>;
42+
onMoveElementsRef: RefObject<TimelineEditCallbacks["onMoveElements"]>;
43+
onResizeElementRef: RefObject<TimelineEditCallbacks["onResizeElement"]>;
44+
onResizeElementsRef: RefObject<TimelineEditCallbacks["onResizeElements"]>;
45+
onBlockedEditAttemptRef: RefObject<
46+
((element: TimelineElement, intent: BlockedClipState["intent"]) => void) | undefined
47+
>;
48+
readZIndexRef: RefObject<((element: TimelineElement) => number) | undefined>;
49+
onStackingPatchesRef: RefObject<
50+
((patches: StackingPatch[]) => Promise<unknown> | void) | undefined
51+
>;
52+
refreshAfterLaneMoveRef: RefObject<(() => void) | undefined>;
53+
}
54+
55+
export function mountTimelineClipDragGestureLifecycle({
56+
draggedClipRef,
57+
resizingClipRef,
58+
blockedClipRef,
59+
groupResizeRef,
60+
suppressClickRef,
61+
elementsRef,
62+
trackOrderRef,
63+
setDraggedClip,
64+
setResizingClip,
65+
setShowPopover,
66+
setRangeSelectionRef,
67+
applyResizePointerRef,
68+
syncClipDragAutoScrollRef,
69+
stopClipDragAutoScrollRef,
70+
updateDraggedClipPreviewRef,
71+
restoreGroupResizeMembers,
72+
updateElement,
73+
onMoveElementRef,
74+
onMoveElementsRef,
75+
onResizeElementRef,
76+
onResizeElementsRef,
77+
onBlockedEditAttemptRef,
78+
readZIndexRef,
79+
onStackingPatchesRef,
80+
refreshAfterLaneMoveRef,
81+
}: TimelineClipDragGestureLifecycleInput): () => void {
82+
const clearSuppressedClick = () => {
83+
requestAnimationFrame(() => {
84+
suppressClickRef.current = false;
85+
});
86+
};
87+
88+
const handleResizePointerMove = (event: PointerEvent, resize: ResizingClipState) => {
89+
const distance = Math.abs(event.clientX - resize.originClientX);
90+
if (!resize.started && distance < 2) return;
91+
setShowPopover(false);
92+
setRangeSelectionRef.current?.(null);
93+
applyResizePointerRef.current(resize, event.clientX);
94+
syncClipDragAutoScrollRef.current(event.clientX, event.clientY);
95+
};
96+
97+
const handleBlockedPointerMove = (event: PointerEvent, blocked: BlockedClipState) => {
98+
const distance = Math.hypot(
99+
event.clientX - blocked.originClientX,
100+
event.clientY - blocked.originClientY,
101+
);
102+
const threshold = blocked.intent === "move" ? 4 : 2;
103+
if (!blocked.started && distance < threshold) return;
104+
if (!blocked.started) {
105+
blocked.started = true;
106+
blockedClipRef.current = blocked;
107+
suppressClickRef.current = true;
108+
setShowPopover(false);
109+
setRangeSelectionRef.current?.(null);
110+
onBlockedEditAttemptRef.current?.(blocked.element, blocked.intent);
111+
}
112+
};
113+
114+
const handleDragPointerMove = (event: PointerEvent, drag: DraggedClipState) => {
115+
const distance = Math.hypot(
116+
event.clientX - drag.originClientX,
117+
event.clientY - drag.originClientY,
118+
);
119+
if (!drag.started && distance < 4) return;
120+
setShowPopover(false);
121+
setRangeSelectionRef.current?.(null);
122+
setDraggedClip((previous) =>
123+
previous
124+
? updateDraggedClipPreviewRef.current(previous, event.clientX, event.clientY)
125+
: previous,
126+
);
127+
syncClipDragAutoScrollRef.current(event.clientX, event.clientY);
128+
};
129+
130+
const handleWindowPointerMove = (event: PointerEvent) => {
131+
const resize = resizingClipRef.current;
132+
if (resize) return handleResizePointerMove(event, resize);
133+
const blocked = blockedClipRef.current;
134+
if (blocked) return handleBlockedPointerMove(event, blocked);
135+
const drag = draggedClipRef.current;
136+
if (drag) handleDragPointerMove(event, drag);
137+
};
138+
139+
const commitResizePointerUp = (resize: ResizingClipState) => {
140+
resizingClipRef.current = null;
141+
setResizingClip(null);
142+
const groupSession = groupResizeRef.current;
143+
groupResizeRef.current = null;
144+
if (!resize.started) {
145+
if (groupSession) restoreGroupResizeMembers(groupSession);
146+
return;
147+
}
148+
suppressClickRef.current = true;
149+
clearSuppressedClick();
150+
if (groupSession) {
151+
commitTimelineGroupResize(groupSession, updateElement, onResizeElementsRef.current);
152+
return;
153+
}
154+
const hasChanged =
155+
resize.previewStart !== resize.element.start ||
156+
resize.previewDuration !== resize.element.duration ||
157+
resize.previewPlaybackStart !== resize.element.playbackStart;
158+
if (!hasChanged) return;
159+
160+
const resizeKey = resize.element.key ?? resize.element.id;
161+
const revision = beginTimelineOptimisticGesture(updateElement, [resizeKey]);
162+
updateElement(resizeKey, {
163+
start: resize.previewStart,
164+
duration: resize.previewDuration,
165+
playbackStart: resize.previewPlaybackStart,
166+
});
167+
Promise.resolve(
168+
onResizeElementRef.current?.(resize.element, {
169+
start: resize.previewStart,
170+
duration: resize.previewDuration,
171+
playbackStart: resize.previewPlaybackStart,
172+
}),
173+
).catch((error) => {
174+
rollbackLatestTimelineOptimisticGesture(updateElement, revision, [
175+
{
176+
key: resizeKey,
177+
updates: {
178+
start: resize.element.start,
179+
duration: resize.element.duration,
180+
playbackStart: resize.element.playbackStart,
181+
},
182+
},
183+
]);
184+
console.error("[Timeline] Failed to persist clip resize", error);
185+
});
186+
};
187+
188+
const finishBlockedPointerUp = (blocked: BlockedClipState) => {
189+
blockedClipRef.current = null;
190+
if (blocked.started) clearSuppressedClick();
191+
};
192+
193+
const commitDragPointerUp = (drag: DraggedClipState) => {
194+
draggedClipRef.current = null;
195+
setDraggedClip(null);
196+
if (!drag.started) return;
197+
suppressClickRef.current = true;
198+
clearSuppressedClick();
199+
commitDraggedClipMove(drag, {
200+
elements: elementsRef.current,
201+
trackOrder: trackOrderRef.current,
202+
updateElement,
203+
onMoveElement: onMoveElementRef.current,
204+
onMoveElements: onMoveElementsRef.current,
205+
selectedKeys: usePlayerStore.getState().selectedElementIds,
206+
readZIndex: readZIndexRef.current,
207+
onStackingPatches: onStackingPatchesRef.current,
208+
refreshAfterLaneMove: refreshAfterLaneMoveRef.current,
209+
});
210+
};
211+
212+
const handleWindowPointerUp = () => {
213+
stopClipDragAutoScrollRef.current();
214+
const resize = resizingClipRef.current;
215+
if (resize) return commitResizePointerUp(resize);
216+
const blocked = blockedClipRef.current;
217+
if (blocked) return finishBlockedPointerUp(blocked);
218+
const drag = draggedClipRef.current;
219+
if (!drag) {
220+
if (suppressClickRef.current) clearSuppressedClick();
221+
return;
222+
}
223+
commitDragPointerUp(drag);
224+
};
225+
226+
const handleWindowKeyDown = (event: KeyboardEvent) => {
227+
const decision = resolveTimelineDragEscape({
228+
key: event.key,
229+
drag: draggedClipRef.current,
230+
resize: resizingClipRef.current,
231+
blocked: blockedClipRef.current,
232+
});
233+
if (!decision.cancel) return;
234+
event.preventDefault();
235+
event.stopPropagation();
236+
stopClipDragAutoScrollRef.current();
237+
draggedClipRef.current = null;
238+
setDraggedClip(null);
239+
resizingClipRef.current = null;
240+
setResizingClip(null);
241+
const groupSession = groupResizeRef.current;
242+
groupResizeRef.current = null;
243+
if (groupSession) restoreGroupResizeMembers(groupSession);
244+
blockedClipRef.current = null;
245+
if (decision.suppressClick) suppressClickRef.current = true;
246+
};
247+
248+
window.addEventListener("pointermove", handleWindowPointerMove);
249+
window.addEventListener("pointerup", handleWindowPointerUp);
250+
window.addEventListener("pointercancel", handleWindowPointerUp);
251+
window.addEventListener("keydown", handleWindowKeyDown, true);
252+
return () => {
253+
stopClipDragAutoScrollRef.current();
254+
window.removeEventListener("pointermove", handleWindowPointerMove);
255+
window.removeEventListener("pointerup", handleWindowPointerUp);
256+
window.removeEventListener("pointercancel", handleWindowPointerUp);
257+
window.removeEventListener("keydown", handleWindowKeyDown, true);
258+
};
259+
}

0 commit comments

Comments
 (0)