diff --git a/packages/studio/src/player/components/timelineClipDragGestureLifecycle.ts b/packages/studio/src/player/components/timelineClipDragGestureLifecycle.ts new file mode 100644 index 0000000000..f9432dd1d2 --- /dev/null +++ b/packages/studio/src/player/components/timelineClipDragGestureLifecycle.ts @@ -0,0 +1,259 @@ +import type { Dispatch, RefObject, SetStateAction } from "react"; +import { resolveTimelineDragEscape } from "./timelineEditing"; +import { commitDraggedClipMove } from "./timelineClipDragCommit"; +import type { + BlockedClipState, + DraggedClipState, + ResizingClipState, +} from "./timelineClipDragTypes"; +import type { TimelineGroupResizeSession } from "./timelineGroupEditing"; +import { commitTimelineGroupResize } from "./timelineGroupResizeCommit"; +import { + beginTimelineOptimisticGesture, + rollbackLatestTimelineOptimisticGesture, +} from "./timelineOptimisticRevision"; +import type { TimelineEditCallbacks } from "./timelineCallbacks"; +import type { StackingPatch } from "./timelineStackingSync"; +import { usePlayerStore, type TimelineElement } from "../store/playerStore"; + +type UpdateElement = ReturnType["updateElement"]; + +interface TimelineClipDragGestureLifecycleInput { + draggedClipRef: RefObject; + resizingClipRef: RefObject; + blockedClipRef: RefObject; + groupResizeRef: RefObject; + suppressClickRef: RefObject; + elementsRef: RefObject; + trackOrderRef: RefObject; + setDraggedClip: Dispatch>; + setResizingClip: Dispatch>; + setShowPopover: (show: boolean) => void; + setRangeSelectionRef: RefObject<((selection: null) => void) | null>; + applyResizePointerRef: RefObject<(resize: ResizingClipState, clientX: number) => void>; + syncClipDragAutoScrollRef: RefObject<(clientX: number, clientY: number) => void>; + stopClipDragAutoScrollRef: RefObject<() => void>; + updateDraggedClipPreviewRef: RefObject< + (drag: DraggedClipState, clientX: number, clientY: number) => DraggedClipState + >; + restoreGroupResizeMembers: (session: TimelineGroupResizeSession, all?: boolean) => void; + updateElement: UpdateElement; + onMoveElementRef: RefObject; + onMoveElementsRef: RefObject; + onResizeElementRef: RefObject; + onResizeElementsRef: RefObject; + onBlockedEditAttemptRef: RefObject< + ((element: TimelineElement, intent: BlockedClipState["intent"]) => void) | undefined + >; + readZIndexRef: RefObject<((element: TimelineElement) => number) | undefined>; + onStackingPatchesRef: RefObject< + ((patches: StackingPatch[]) => Promise | void) | undefined + >; + refreshAfterLaneMoveRef: RefObject<(() => void) | undefined>; +} + +export function mountTimelineClipDragGestureLifecycle({ + draggedClipRef, + resizingClipRef, + blockedClipRef, + groupResizeRef, + suppressClickRef, + elementsRef, + trackOrderRef, + setDraggedClip, + setResizingClip, + setShowPopover, + setRangeSelectionRef, + applyResizePointerRef, + syncClipDragAutoScrollRef, + stopClipDragAutoScrollRef, + updateDraggedClipPreviewRef, + restoreGroupResizeMembers, + updateElement, + onMoveElementRef, + onMoveElementsRef, + onResizeElementRef, + onResizeElementsRef, + onBlockedEditAttemptRef, + readZIndexRef, + onStackingPatchesRef, + refreshAfterLaneMoveRef, +}: TimelineClipDragGestureLifecycleInput): () => void { + const clearSuppressedClick = () => { + requestAnimationFrame(() => { + suppressClickRef.current = false; + }); + }; + + const handleResizePointerMove = (event: PointerEvent, resize: ResizingClipState) => { + const distance = Math.abs(event.clientX - resize.originClientX); + if (!resize.started && distance < 2) return; + setShowPopover(false); + setRangeSelectionRef.current?.(null); + applyResizePointerRef.current(resize, event.clientX); + syncClipDragAutoScrollRef.current(event.clientX, event.clientY); + }; + + const handleBlockedPointerMove = (event: PointerEvent, blocked: BlockedClipState) => { + const distance = Math.hypot( + event.clientX - blocked.originClientX, + event.clientY - blocked.originClientY, + ); + const threshold = blocked.intent === "move" ? 4 : 2; + if (!blocked.started && distance < threshold) return; + if (!blocked.started) { + blocked.started = true; + blockedClipRef.current = blocked; + suppressClickRef.current = true; + setShowPopover(false); + setRangeSelectionRef.current?.(null); + onBlockedEditAttemptRef.current?.(blocked.element, blocked.intent); + } + }; + + const handleDragPointerMove = (event: PointerEvent, drag: DraggedClipState) => { + const distance = Math.hypot( + event.clientX - drag.originClientX, + event.clientY - drag.originClientY, + ); + if (!drag.started && distance < 4) return; + setShowPopover(false); + setRangeSelectionRef.current?.(null); + setDraggedClip((previous) => + previous + ? updateDraggedClipPreviewRef.current(previous, event.clientX, event.clientY) + : previous, + ); + syncClipDragAutoScrollRef.current(event.clientX, event.clientY); + }; + + const handleWindowPointerMove = (event: PointerEvent) => { + const resize = resizingClipRef.current; + if (resize) return handleResizePointerMove(event, resize); + const blocked = blockedClipRef.current; + if (blocked) return handleBlockedPointerMove(event, blocked); + const drag = draggedClipRef.current; + if (drag) handleDragPointerMove(event, drag); + }; + + const commitResizePointerUp = (resize: ResizingClipState) => { + resizingClipRef.current = null; + setResizingClip(null); + const groupSession = groupResizeRef.current; + groupResizeRef.current = null; + if (!resize.started) { + if (groupSession) restoreGroupResizeMembers(groupSession); + return; + } + suppressClickRef.current = true; + clearSuppressedClick(); + if (groupSession) { + commitTimelineGroupResize(groupSession, updateElement, onResizeElementsRef.current); + return; + } + const hasChanged = + resize.previewStart !== resize.element.start || + resize.previewDuration !== resize.element.duration || + resize.previewPlaybackStart !== resize.element.playbackStart; + if (!hasChanged) return; + + const resizeKey = resize.element.key ?? resize.element.id; + const revision = beginTimelineOptimisticGesture(updateElement, [resizeKey]); + updateElement(resizeKey, { + start: resize.previewStart, + duration: resize.previewDuration, + playbackStart: resize.previewPlaybackStart, + }); + Promise.resolve( + onResizeElementRef.current?.(resize.element, { + start: resize.previewStart, + duration: resize.previewDuration, + playbackStart: resize.previewPlaybackStart, + }), + ).catch((error) => { + rollbackLatestTimelineOptimisticGesture(updateElement, revision, [ + { + key: resizeKey, + updates: { + start: resize.element.start, + duration: resize.element.duration, + playbackStart: resize.element.playbackStart, + }, + }, + ]); + console.error("[Timeline] Failed to persist clip resize", error); + }); + }; + + const finishBlockedPointerUp = (blocked: BlockedClipState) => { + blockedClipRef.current = null; + if (blocked.started) clearSuppressedClick(); + }; + + const commitDragPointerUp = (drag: DraggedClipState) => { + draggedClipRef.current = null; + setDraggedClip(null); + if (!drag.started) return; + suppressClickRef.current = true; + clearSuppressedClick(); + commitDraggedClipMove(drag, { + elements: elementsRef.current, + trackOrder: trackOrderRef.current, + updateElement, + onMoveElement: onMoveElementRef.current, + onMoveElements: onMoveElementsRef.current, + selectedKeys: usePlayerStore.getState().selectedElementIds, + readZIndex: readZIndexRef.current, + onStackingPatches: onStackingPatchesRef.current, + refreshAfterLaneMove: refreshAfterLaneMoveRef.current, + }); + }; + + const handleWindowPointerUp = () => { + stopClipDragAutoScrollRef.current(); + const resize = resizingClipRef.current; + if (resize) return commitResizePointerUp(resize); + const blocked = blockedClipRef.current; + if (blocked) return finishBlockedPointerUp(blocked); + const drag = draggedClipRef.current; + if (!drag) { + if (suppressClickRef.current) clearSuppressedClick(); + return; + } + commitDragPointerUp(drag); + }; + + const handleWindowKeyDown = (event: KeyboardEvent) => { + const decision = resolveTimelineDragEscape({ + key: event.key, + drag: draggedClipRef.current, + resize: resizingClipRef.current, + blocked: blockedClipRef.current, + }); + if (!decision.cancel) return; + event.preventDefault(); + event.stopPropagation(); + stopClipDragAutoScrollRef.current(); + draggedClipRef.current = null; + setDraggedClip(null); + resizingClipRef.current = null; + setResizingClip(null); + const groupSession = groupResizeRef.current; + groupResizeRef.current = null; + if (groupSession) restoreGroupResizeMembers(groupSession); + blockedClipRef.current = null; + if (decision.suppressClick) suppressClickRef.current = true; + }; + + window.addEventListener("pointermove", handleWindowPointerMove); + window.addEventListener("pointerup", handleWindowPointerUp); + window.addEventListener("pointercancel", handleWindowPointerUp); + window.addEventListener("keydown", handleWindowKeyDown, true); + return () => { + stopClipDragAutoScrollRef.current(); + window.removeEventListener("pointermove", handleWindowPointerMove); + window.removeEventListener("pointerup", handleWindowPointerUp); + window.removeEventListener("pointercancel", handleWindowPointerUp); + window.removeEventListener("keydown", handleWindowKeyDown, true); + }; +} diff --git a/packages/studio/src/player/components/useTimelineClipDrag.ts b/packages/studio/src/player/components/useTimelineClipDrag.ts index 90d4100813..da366e6bdb 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.ts +++ b/packages/studio/src/player/components/useTimelineClipDrag.ts @@ -3,7 +3,6 @@ import { useMountEffect } from "../../hooks/useMountEffect"; import { applyTimelineAutoScrollStep, resolveTimelineAutoScrollLoopAction, - resolveTimelineDragEscape, } from "./timelineEditing"; import { usePlayerStore } from "../store/playerStore"; import type { TimelineElement } from "../store/playerStore"; @@ -14,7 +13,6 @@ import { type TimelineGroupResizeSession, } from "./timelineGroupEditing"; import { collectTimelineSnapTargets, type TimelineSnapTarget } from "./timelineSnapping"; -import { commitDraggedClipMove } from "./timelineClipDragCommit"; import type { StackingPatch } from "./timelineStackingSync"; import type { TimelineEditCallbacks } from "./timelineCallbacks"; import { @@ -28,11 +26,7 @@ import type { ResizingClipState, BlockedClipState, } from "./timelineClipDragTypes"; -import { - beginTimelineOptimisticGesture, - rollbackLatestTimelineOptimisticGesture, -} from "./timelineOptimisticRevision"; -import { commitTimelineGroupResize } from "./timelineGroupResizeCommit"; +import { mountTimelineClipDragGestureLifecycle } from "./timelineClipDragGestureLifecycle"; export type { DraggedClipState, @@ -363,215 +357,33 @@ export function useTimelineClipDrag({ stopClipDragAutoScrollRef.current = stopClipDragAutoScroll; useMountEffect(() => { - const clearSuppressedClick = () => { - requestAnimationFrame(() => { - suppressClickRef.current = false; - }); - }; - - /* ── pointermove branch handlers (dispatched by drag/resize/blocked) ── */ - const handleResizePointerMove = (e: PointerEvent, resize: ResizingClipState) => { - const distance = Math.abs(e.clientX - resize.originClientX); - if (!resize.started && distance < 2) return; - - setShowPopover(false); - setRangeSelectionRef.current?.(null); - - applyResizePointerRef.current(resize, e.clientX); - // Edge auto-scroll during a trim, exactly like the move branch — lets a - // right-edge trim keep extending past the current viewport (the stepper - // re-runs the scroll-compensated preview each frame). - syncClipDragAutoScrollRef.current(e.clientX, e.clientY); - }; - - const handleBlockedPointerMove = (e: PointerEvent, blocked: BlockedClipState) => { - const distance = Math.hypot( - e.clientX - blocked.originClientX, - e.clientY - blocked.originClientY, - ); - const threshold = blocked.intent === "move" ? 4 : 2; - if (!blocked.started && distance < threshold) return; - if (!blocked.started) { - blocked.started = true; - blockedClipRef.current = blocked; - suppressClickRef.current = true; - setShowPopover(false); - setRangeSelectionRef.current?.(null); - onBlockedEditAttemptRef.current?.(blocked.element, blocked.intent); - } - }; - - const handleDragPointerMove = (e: PointerEvent, drag: DraggedClipState) => { - const distance = Math.hypot(e.clientX - drag.originClientX, e.clientY - drag.originClientY); - if (!drag.started && distance < 4) return; - - setShowPopover(false); - setRangeSelectionRef.current?.(null); - - setDraggedClip((prev) => - prev ? updateDraggedClipPreviewRef.current(prev, e.clientX, e.clientY) : prev, - ); - syncClipDragAutoScrollRef.current(e.clientX, e.clientY); - }; - - const handleWindowPointerMove = (e: PointerEvent) => { - const resize = resizingClipRef.current; - if (resize) return handleResizePointerMove(e, resize); - const blocked = blockedClipRef.current; - if (blocked) return handleBlockedPointerMove(e, blocked); - const drag = draggedClipRef.current; - if (drag) handleDragPointerMove(e, drag); - }; - - /* ── pointerup commit handlers (dispatched by drag/resize/blocked) ──── */ - const commitResizePointerUp = (resize: ResizingClipState) => { - resizingClipRef.current = null; - setResizingClip(null); - const groupSession = groupResizeRef.current; - groupResizeRef.current = null; - if (!resize.started) { - // No preview ran, so no group store-mutation to undo; guard is defensive. - if (groupSession) restoreGroupResizeMembers(groupSession); - return; - } - - suppressClickRef.current = true; - clearSuppressedClick(); - - if (groupSession) { - commitTimelineGroupResize(groupSession, updateElement, onResizeElementsRef.current); - return; - } - - const hasChanged = - resize.previewStart !== resize.element.start || - resize.previewDuration !== resize.element.duration || - resize.previewPlaybackStart !== resize.element.playbackStart; - if (!hasChanged) return; - - const resizeKey = resize.element.key ?? resize.element.id; - const revision = beginTimelineOptimisticGesture(updateElement, [resizeKey]); - updateElement(resizeKey, { - start: resize.previewStart, - duration: resize.previewDuration, - playbackStart: resize.previewPlaybackStart, - }); - - Promise.resolve( - onResizeElementRef.current?.(resize.element, { - start: resize.previewStart, - duration: resize.previewDuration, - playbackStart: resize.previewPlaybackStart, - }), - ).catch((error) => { - rollbackLatestTimelineOptimisticGesture(updateElement, revision, [ - { - key: resizeKey, - updates: { - start: resize.element.start, - duration: resize.element.duration, - playbackStart: resize.element.playbackStart, - }, - }, - ]); - console.error("[Timeline] Failed to persist clip resize", error); - }); - }; - - const finishBlockedPointerUp = (blocked: BlockedClipState) => { - blockedClipRef.current = null; - if (!blocked.started) return; - clearSuppressedClick(); - }; - - const commitDragPointerUp = (drag: DraggedClipState) => { - draggedClipRef.current = null; - setDraggedClip(null); - if (!drag.started) return; - - suppressClickRef.current = true; - clearSuppressedClick(); - - // Commit the drag — insert (new track), main-track ripple (reflow contiguous), - // a plain single-clip move, or a multi-selection move (every selected clip - // shifts by the dragged clip's time delta). See timelineClipDragCommit. - commitDraggedClipMove(drag, { - elements: elementsRef.current, - trackOrder: trackOrderRef.current, - updateElement, - onMoveElement: onMoveElementRef.current, - onMoveElements: onMoveElementsRef.current, - selectedKeys: usePlayerStore.getState().selectedElementIds, - // Lane ↔ stacking: engages only when the timeline layer provisions both - // deps (Timeline.tsx). Absent → commitDraggedClipMove skips the z-sync. - readZIndex: readZIndexRef.current, - onStackingPatches: onStackingPatchesRef.current, - refreshAfterLaneMove: refreshAfterLaneMoveRef.current, - }); - }; - - const handleWindowPointerUp = () => { - stopClipDragAutoScrollRef.current(); - - const resize = resizingClipRef.current; - if (resize) return commitResizePointerUp(resize); - - const blocked = blockedClipRef.current; - if (blocked) return finishBlockedPointerUp(blocked); - - const drag = draggedClipRef.current; - if (!drag) { - // Escape-cancel leaves the click suppressor armed so the click this - // pointerup generates can't act on the clip; disarm it right after. - if (suppressClickRef.current) clearSuppressedClick(); - return; - } - commitDragPointerUp(drag); - }; - - // Escape cancels the in-progress gesture: no commit, no undo entry. The - // previews live only in the drag/resize state (the store is untouched - // until the pointerup commit), so clearing them restores the pre-drag - // timeline. Clip drags never take pointer capture (all tracking runs on - // these window listeners), so there is no capture to release; the null - // refs make the remaining pointermove/pointerup a no-op. - const handleWindowKeyDown = (e: KeyboardEvent) => { - const decision = resolveTimelineDragEscape({ - key: e.key, - drag: draggedClipRef.current, - resize: resizingClipRef.current, - blocked: blockedClipRef.current, - }); - if (!decision.cancel) return; - e.preventDefault(); - e.stopPropagation(); - stopClipDragAutoScrollRef.current(); - draggedClipRef.current = null; - setDraggedClip(null); - resizingClipRef.current = null; - setResizingClip(null); - // Undo any group-resize preview store-mutation (non-grabbed members) so the - // cancelled gesture restores the pre-drag timeline, like the single-clip path. - const groupSession = groupResizeRef.current; - groupResizeRef.current = null; - if (groupSession) restoreGroupResizeMembers(groupSession); - blockedClipRef.current = null; - // The pointer is usually still down; keep the suppressor armed until the - // eventual pointerup (which disarms it) so its click can't reselect. - if (decision.suppressClick) suppressClickRef.current = true; - }; - - window.addEventListener("pointermove", handleWindowPointerMove); - window.addEventListener("pointerup", handleWindowPointerUp); - window.addEventListener("pointercancel", handleWindowPointerUp); - window.addEventListener("keydown", handleWindowKeyDown, true); - return () => { - stopClipDragAutoScrollRef.current(); - window.removeEventListener("pointermove", handleWindowPointerMove); - window.removeEventListener("pointerup", handleWindowPointerUp); - window.removeEventListener("pointercancel", handleWindowPointerUp); - window.removeEventListener("keydown", handleWindowKeyDown, true); - }; + return mountTimelineClipDragGestureLifecycle({ + draggedClipRef, + resizingClipRef, + blockedClipRef, + groupResizeRef, + suppressClickRef, + elementsRef, + trackOrderRef, + setDraggedClip, + setResizingClip, + setShowPopover, + setRangeSelectionRef, + applyResizePointerRef, + syncClipDragAutoScrollRef, + stopClipDragAutoScrollRef, + updateDraggedClipPreviewRef, + restoreGroupResizeMembers, + updateElement, + onMoveElementRef, + onMoveElementsRef, + onResizeElementRef, + onResizeElementsRef, + onBlockedEditAttemptRef, + readZIndexRef, + onStackingPatchesRef, + refreshAfterLaneMoveRef, + }); }); return {