|
| 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