From 6a22a3ecbdff2d0e6a4c24c8db40bb86bc01b692 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sat, 25 Apr 2026 03:56:22 +0200 Subject: refactor: centralize editor interaction state Move timeline and preview gestures into dedicated controllers so hooks stay thin around editor state and DOM events. This also routes drag and playback synchronization through manager APIs and reuses persistent wasm surfaces so scrubbing, seeking, and preview rendering stay in sync. Made-with: Cursor --- .../hooks/element/use-element-interaction.ts | 777 ++------------------- .../timeline/hooks/element/use-keyframe-drag.ts | 334 ++------- 2 files changed, 94 insertions(+), 1017 deletions(-) (limited to 'apps/web/src/timeline/hooks/element') diff --git a/apps/web/src/timeline/hooks/element/use-element-interaction.ts b/apps/web/src/timeline/hooks/element/use-element-interaction.ts index 621259b9..26a21f85 100644 --- a/apps/web/src/timeline/hooks/element/use-element-interaction.ts +++ b/apps/web/src/timeline/hooks/element/use-element-interaction.ts @@ -1,42 +1,17 @@ -import { - useState, - useCallback, - useEffect, - useRef, - type MouseEvent as ReactMouseEvent, - type RefObject, -} from "react"; +import { useEffect, useReducer, useRef, type RefObject } from "react"; import { useEditor } from "@/editor/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; +import { registerCanceller } from "@/editor/cancel-interaction"; import { - buildMoveGroup, - resolveGroupMove, - snapGroupEdges, - type GroupMoveResult, - type MoveGroup, -} from "@/timeline/group-move"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { TICKS_PER_SECOND } from "@/wasm"; -import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; -import { roundToFrame } from "opencut-wasm"; -import { computeDropTarget } from "@/timeline/components/drop-target"; -import { getMouseTimeFromClientX } from "@/timeline/drag-utils"; -import { generateUUID } from "@/utils/id"; + ElementInteractionController, + type ElementInteractionDeps, + type ElementInteractionDepsRef, +} from "@/timeline/controllers/element-interaction-controller"; import type { SnapPoint } from "@/timeline/snapping"; -import { registerCanceller } from "@/editor/cancel-interaction"; -import type { - DropTarget, - ElementRef, - ElementDragState, - SceneTracks, - TimelineElement, - TimelineTrack, -} from "@/timeline"; interface UseElementInteractionProps { zoomLevel: number; - timelineRef: RefObject; tracksContainerRef: RefObject; tracksScrollRef: RefObject; headerRef?: RefObject; @@ -44,131 +19,8 @@ interface UseElementInteractionProps { onSnapPointChange?: (snapPoint: SnapPoint | null) => void; } -const MOUSE_BUTTON_RIGHT = 2; - -const initialDragState: ElementDragState = { - isDragging: false, - elementId: null, - dragElementIds: [], - dragTimeOffsets: {}, - trackId: null, - startMouseX: 0, - startMouseY: 0, - startElementTime: 0, - clickOffsetTime: 0, - currentTime: 0, - currentMouseY: 0, -}; - -interface PendingDragState { - elementId: string; - trackId: string; - selectedElements: ElementRef[]; - startMouseX: number; - startMouseY: number; - startElementTime: number; - clickOffsetTime: number; -} - -function getClickOffsetTime({ - clientX, - elementRect, - zoomLevel, -}: { - clientX: number; - elementRect: DOMRect; - zoomLevel: number; -}): number { - const clickOffsetX = clientX - elementRect.left; - const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel); - return Math.round(seconds * TICKS_PER_SECOND); -} - -function getVerticalDragDirection({ - startMouseY, - currentMouseY, -}: { - startMouseY: number; - currentMouseY: number; -}): "up" | "down" | null { - if (currentMouseY < startMouseY) return "up"; - if (currentMouseY > startMouseY) return "down"; - return null; -} - -function getDragDropTarget({ - clientX, - clientY, - elementId, - trackId, - tracks, - tracksContainerRef, - tracksScrollRef, - headerRef, - zoomLevel, - snappedTime, - verticalDragDirection, -}: { - clientX: number; - clientY: number; - elementId: string; - trackId: string; - tracks: SceneTracks; - tracksContainerRef: RefObject; - tracksScrollRef: RefObject; - headerRef?: RefObject; - zoomLevel: number; - snappedTime: number; - verticalDragDirection?: "up" | "down" | null; -}): DropTarget | null { - const containerRect = tracksContainerRef.current?.getBoundingClientRect(); - const scrollContainer = tracksScrollRef.current; - if (!containerRect || !scrollContainer) return null; - - const sourceTrack = [...tracks.overlay, tracks.main, ...tracks.audio].find( - ({ id }) => id === trackId, - ); - const movingElement = sourceTrack?.elements.find( - ({ id }) => id === elementId, - ); - if (!movingElement) return null; - - const elementDuration = movingElement.duration; - const scrollLeft = scrollContainer.scrollLeft; - const scrollTop = scrollContainer.scrollTop; - const scrollContainerRect = scrollContainer.getBoundingClientRect(); - const headerHeight = headerRef?.current?.getBoundingClientRect().height ?? 0; - const mouseX = clientX - scrollContainerRect.left + scrollLeft; - const mouseY = clientY - scrollContainerRect.top + scrollTop - headerHeight; - - return computeDropTarget({ - elementType: movingElement.type, - mouseX, - mouseY, - tracks, - playheadTime: snappedTime, - isExternalDrop: false, - elementDuration, - pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, - zoomLevel, - startTimeOverride: snappedTime, - excludeElementId: movingElement.id, - verticalDragDirection, - }); -} - -interface StartDragParams - extends Omit< - ElementDragState, - "isDragging" | "currentTime" | "currentMouseY" - > { - initialCurrentTime: number; - initialCurrentMouseY: number; -} - export function useElementInteraction({ zoomLevel, - timelineRef, tracksContainerRef, tracksScrollRef, headerRef, @@ -177,584 +29,65 @@ export function useElementInteraction({ }: UseElementInteractionProps) { const editor = useEditor(); const isShiftHeldRef = useShiftKey(); - const sceneTracks = editor.scenes.getActiveScene().tracks; - const { - selectedElements, - isElementSelected, - selectElement, - handleElementClick: handleSelectionClick, - } = useElementSelection(); - - const [dragState, setDragState] = - useState(initialDragState); - const [dragDropTarget, setDragDropTarget] = useState(null); - const [isPendingDrag, setIsPendingDrag] = useState(false); - const pendingDragRef = useRef(null); - const moveGroupRef = useRef(null); - const newTrackIdsRef = useRef([]); - const groupMoveResultRef = useRef(null); - const lastMouseXRef = useRef(0); - const mouseDownLocationRef = useRef<{ x: number; y: number } | null>(null); - - const startDrag = useCallback( - ({ - elementId, - dragElementIds, - dragTimeOffsets, - trackId, - startMouseX, - startMouseY, - startElementTime, - clickOffsetTime, - initialCurrentTime, - initialCurrentMouseY, - }: StartDragParams) => { - setDragState({ - isDragging: true, - elementId, - dragElementIds, - dragTimeOffsets, - trackId, - startMouseX, - startMouseY, - startElementTime, - clickOffsetTime, - currentTime: initialCurrentTime, - currentMouseY: initialCurrentMouseY, - }); + const selection = useElementSelection(); + + const deps: ElementInteractionDeps = { + viewport: { + getZoomLevel: () => zoomLevel, + getTracksScrollEl: () => tracksScrollRef.current, + getTracksContainerEl: () => tracksContainerRef.current, + getHeaderEl: () => headerRef?.current ?? null, }, - [], - ); - - const endDrag = useCallback(() => { - moveGroupRef.current = null; - newTrackIdsRef.current = []; - groupMoveResultRef.current = null; - setDragState(initialDragState); - setDragDropTarget(null); - }, []); - - const cancelCurrentDrag = useCallback(() => { - pendingDragRef.current = null; - mouseDownLocationRef.current = null; - setIsPendingDrag(false); - endDrag(); - onSnapPointChange?.(null); - }, [endDrag, onSnapPointChange]); - - const resolveGroupDragMove = useCallback( - ({ - group, - snappedTime, - dropTarget, - }: { - group: MoveGroup; - snappedTime: number; - dropTarget: DropTarget | null; - }): GroupMoveResult | null => { - if (!dropTarget) { - return null; - } - - if (dropTarget.isNewTrack) { - return resolveGroupMove({ - group, - tracks: sceneTracks, - anchorStartTime: snappedTime, - target: { - kind: "newTracks", - anchorInsertIndex: dropTarget.trackIndex, - newTrackIds: newTrackIdsRef.current, - }, - }); - } - - const orderedTracks = [ - ...sceneTracks.overlay, - sceneTracks.main, - ...sceneTracks.audio, - ]; - const targetTrack = orderedTracks[dropTarget.trackIndex]; - if (!targetTrack) { - return null; - } - - const existingTrackResult = resolveGroupMove({ - group, - tracks: sceneTracks, - anchorStartTime: snappedTime, - target: { - kind: "existingTrack", - anchorTargetTrackId: targetTrack.id, - }, - }); - if (existingTrackResult) { - return existingTrackResult; - } - - return resolveGroupMove({ - group, - tracks: sceneTracks, - anchorStartTime: snappedTime, - target: { - kind: "newTracks", - anchorInsertIndex: dropTarget.trackIndex, - newTrackIds: newTrackIdsRef.current, - }, - }); + input: { + isShiftHeld: () => isShiftHeldRef.current, }, - [sceneTracks], - ); - - useEffect(() => { - if (!dragState.isDragging && !isPendingDrag) return; - - return registerCanceller({ fn: cancelCurrentDrag }); - }, [dragState.isDragging, isPendingDrag, cancelCurrentDrag]); - - const getDragSnapResult = useCallback( - ({ - frameSnappedTime, - group, - }: { - frameSnappedTime: number; - group: MoveGroup | null; - }) => { - if (!group || !snappingEnabled || isShiftHeldRef.current) { - return { snappedTime: frameSnappedTime, snapPoint: null }; - } - - const groupSnap = snapGroupEdges({ - group, - anchorStartTime: frameSnappedTime, - tracks: sceneTracks, - playheadTime: editor.playback.getCurrentTime(), - zoomLevel, - }); - - return { - snappedTime: groupSnap.snappedAnchorStartTime, - snapPoint: groupSnap.snapPoint, - }; + scene: { + getTracks: () => editor.scenes.getActiveScene().tracks, + getActiveFps: () => editor.project.getActive()?.settings.fps ?? null, }, - [snappingEnabled, editor.playback, sceneTracks, zoomLevel, isShiftHeldRef], - ); - - useEffect(() => { - if (!dragState.isDragging && !isPendingDrag) return; - - const handleMouseMove = ({ clientX, clientY }: MouseEvent) => { - let startedDragThisEvent = false; - const timeline = timelineRef.current; - const scrollContainer = tracksScrollRef.current; - if (!timeline || !scrollContainer) return; - lastMouseXRef.current = clientX; - - if (isPendingDrag && pendingDragRef.current) { - const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX); - const deltaY = Math.abs(clientY - pendingDragRef.current.startMouseY); - if ( - deltaX > TIMELINE_DRAG_THRESHOLD_PX || - deltaY > TIMELINE_DRAG_THRESHOLD_PX - ) { - const activeProject = editor.project.getActive(); - if (!activeProject) return; - const scrollLeft = scrollContainer.scrollLeft; - const mouseTime = getMouseTimeFromClientX({ - clientX, - containerRect: scrollContainer.getBoundingClientRect(), - zoomLevel, - scrollLeft, - }); - const adjustedTime = Math.max( - 0, - mouseTime - pendingDragRef.current.clickOffsetTime, - ); - const snappedTime = - roundToFrame({ - time: adjustedTime, - rate: activeProject.settings.fps, - }) ?? adjustedTime; - const moveGroup = buildMoveGroup({ - anchorRef: { - trackId: pendingDragRef.current.trackId, - elementId: pendingDragRef.current.elementId, - }, - selectedElements: pendingDragRef.current.selectedElements, - tracks: sceneTracks, - }); - if (!moveGroup) { - return; - } - - moveGroupRef.current = moveGroup; - newTrackIdsRef.current = moveGroup.members.map(() => generateUUID()); - const dragTimeOffsets: Record = {}; - for (const member of moveGroup.members) { - dragTimeOffsets[member.elementId] = member.timeOffset; - } - const { - snappedTime: initialSnappedTime, - snapPoint: initialSnapPoint, - } = getDragSnapResult({ - frameSnappedTime: snappedTime, - group: moveGroup, - }); - const verticalDragDirection = getVerticalDragDirection({ - startMouseY: pendingDragRef.current.startMouseY, - currentMouseY: clientY, - }); - const anchorDropTarget = getDragDropTarget({ - clientX, - clientY, - elementId: pendingDragRef.current.elementId, - trackId: pendingDragRef.current.trackId, - tracks: sceneTracks, - tracksContainerRef, - tracksScrollRef, - headerRef, - zoomLevel, - snappedTime: initialSnappedTime, - verticalDragDirection, - }); - const nextGroupMoveResult = - anchorDropTarget != null - ? resolveGroupDragMove({ - group: moveGroup, - snappedTime: initialSnappedTime, - dropTarget: anchorDropTarget, - }) - : null; - groupMoveResultRef.current = nextGroupMoveResult; - setDragDropTarget( - anchorDropTarget && - (anchorDropTarget.isNewTrack || !nextGroupMoveResult) - ? { - ...anchorDropTarget, - isNewTrack: true, - } - : null, - ); - startDrag({ - ...pendingDragRef.current, - dragElementIds: moveGroup.members.map((member) => member.elementId), - dragTimeOffsets, - initialCurrentTime: initialSnappedTime, - initialCurrentMouseY: clientY, - }); - onSnapPointChange?.(initialSnapPoint); - startedDragThisEvent = true; - pendingDragRef.current = null; - setIsPendingDrag(false); - } else { - return; - } - } - - if (startedDragThisEvent) { - return; - } - - if (dragState.elementId && dragState.trackId) { - const alreadySelected = isElementSelected({ - trackId: dragState.trackId, - elementId: dragState.elementId, - }); - if (!alreadySelected) { - selectElement({ - trackId: dragState.trackId, - elementId: dragState.elementId, - }); - } - } - - const activeProject = editor.project.getActive(); - if (!activeProject) return; - - const scrollLeft = scrollContainer.scrollLeft; - const mouseTime = getMouseTimeFromClientX({ - clientX, - containerRect: scrollContainer.getBoundingClientRect(), - zoomLevel, - scrollLeft, - }); - const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); - const fps = activeProject.settings.fps; - const frameSnappedTime = - roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime; - - const moveGroup = moveGroupRef.current; - const { snappedTime, snapPoint } = getDragSnapResult({ - frameSnappedTime, - group: moveGroup, - }); - setDragState((previousDragState) => ({ - ...previousDragState, - currentTime: snappedTime, - currentMouseY: clientY, - })); - onSnapPointChange?.(snapPoint); - - if (dragState.elementId && dragState.trackId) { - const verticalDragDirection = getVerticalDragDirection({ - startMouseY: dragState.startMouseY, - currentMouseY: clientY, - }); - const anchorDropTarget = getDragDropTarget({ - clientX, - clientY, - elementId: dragState.elementId, - trackId: dragState.trackId, - tracks: sceneTracks, - tracksContainerRef, - tracksScrollRef, - headerRef, - zoomLevel, - snappedTime, - verticalDragDirection, - }); - const nextGroupMoveResult = - moveGroup && anchorDropTarget - ? resolveGroupDragMove({ - group: moveGroup, - snappedTime, - dropTarget: anchorDropTarget, - }) - : null; - groupMoveResultRef.current = nextGroupMoveResult; - setDragDropTarget( - anchorDropTarget && - (anchorDropTarget.isNewTrack || !nextGroupMoveResult) - ? { - ...anchorDropTarget, - isNewTrack: true, - } - : null, - ); - } - }; - - document.addEventListener("mousemove", handleMouseMove); - return () => document.removeEventListener("mousemove", handleMouseMove); - }, [ - dragState.isDragging, - dragState.clickOffsetTime, - dragState.elementId, - dragState.startMouseY, - dragState.trackId, - zoomLevel, - isElementSelected, - selectElement, - editor.project, - timelineRef, - tracksScrollRef, - tracksContainerRef, - headerRef, - isPendingDrag, - startDrag, - getDragSnapResult, - resolveGroupDragMove, - sceneTracks, - onSnapPointChange, - ]); - - useEffect(() => { - if (!dragState.isDragging) return; - - const handleMouseUp = ({ clientX, clientY }: MouseEvent) => { - if (!dragState.elementId || !dragState.trackId) return; - - if (mouseDownLocationRef.current) { - const deltaX = Math.abs(clientX - mouseDownLocationRef.current.x); - const deltaY = Math.abs(clientY - mouseDownLocationRef.current.y); - if ( - deltaX <= TIMELINE_DRAG_THRESHOLD_PX && - deltaY <= TIMELINE_DRAG_THRESHOLD_PX - ) { - mouseDownLocationRef.current = null; - endDrag(); - onSnapPointChange?.(null); - return; - } - } - - const moveGroup = moveGroupRef.current; - if (!moveGroup) { - endDrag(); - onSnapPointChange?.(null); - return; - } - - const groupMoveResult = groupMoveResultRef.current; - if (!groupMoveResult) { - endDrag(); - onSnapPointChange?.(null); - return; - } - - const didMove = groupMoveResult.moves.some((move) => { - const currentMember = moveGroup.members.find( - (member) => member.elementId === move.elementId, - ); - const originalStartTime = - dragState.startElementTime + (currentMember?.timeOffset ?? 0); - return ( - currentMember?.trackId !== move.targetTrackId || - originalStartTime !== move.newStartTime - ); - }); - if (!didMove && groupMoveResult.createTracks.length === 0) { - endDrag(); - onSnapPointChange?.(null); - return; - } - - editor.timeline.moveElements({ - moves: groupMoveResult.moves, - createTracks: groupMoveResult.createTracks, - }); - endDrag(); - onSnapPointChange?.(null); - }; - - document.addEventListener("mouseup", handleMouseUp); - return () => document.removeEventListener("mouseup", handleMouseUp); - }, [ - dragState.isDragging, - dragState.elementId, - dragState.startElementTime, - dragState.trackId, - endDrag, - onSnapPointChange, - editor.timeline, - ]); - - useEffect(() => { - if (!isPendingDrag) return; - - const handleMouseUp = () => { - pendingDragRef.current = null; - setIsPendingDrag(false); - onSnapPointChange?.(null); - }; - - document.addEventListener("mouseup", handleMouseUp); - return () => document.removeEventListener("mouseup", handleMouseUp); - }, [isPendingDrag, onSnapPointChange]); - - const handleElementMouseDown = useCallback( - ({ - event, - element, - track, - }: { - event: ReactMouseEvent; - element: TimelineElement; - track: TimelineTrack; - }) => { - const isRightClick = event.button === MOUSE_BUTTON_RIGHT; - - // right-click: don't stop propagation so ContextMenu can open - if (isRightClick) { - const alreadySelected = isElementSelected({ - trackId: track.id, - elementId: element.id, - }); - if (!alreadySelected) { - handleSelectionClick({ - trackId: track.id, - elementId: element.id, - isMultiKey: false, - }); - } - return; - } - - event.stopPropagation(); - mouseDownLocationRef.current = { x: event.clientX, y: event.clientY }; - - const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey; - - if (isMultiSelect) { - handleSelectionClick({ - trackId: track.id, - elementId: element.id, - isMultiKey: true, - }); - } - - const clickOffsetTime = getClickOffsetTime({ - clientX: event.clientX, - elementRect: event.currentTarget.getBoundingClientRect(), - zoomLevel, - }); - const elementRef = { - trackId: track.id, - elementId: element.id, - }; - const pendingSelectedElements = isElementSelected(elementRef) - ? selectedElements - : [elementRef]; - pendingDragRef.current = { - elementId: element.id, - trackId: track.id, - selectedElements: pendingSelectedElements, - startMouseX: event.clientX, - startMouseY: event.clientY, - startElementTime: element.startTime, - clickOffsetTime, - }; - setIsPendingDrag(true); + selection: { + getSelected: () => selection.selectedElements, + isSelected: selection.isElementSelected, + select: selection.selectElement, + handleClick: selection.handleElementClick, + clearKeyframeSelection: () => editor.selection.clearKeyframeSelection(), + }, + playback: { + getCurrentTime: () => editor.playback.getCurrentTime(), }, - [zoomLevel, isElementSelected, handleSelectionClick, selectedElements], - ); + timeline: { + moveElements: (args) => editor.timeline.moveElements(args), + }, + snap: { + isEnabled: () => snappingEnabled, + onChange: onSnapPointChange, + }, + }; - const handleElementClick = useCallback( - ({ - event, - element, - track, - }: { - event: ReactMouseEvent; - element: TimelineElement; - track: TimelineTrack; - }) => { - event.stopPropagation(); + const depsRef = useRef(deps); + depsRef.current = deps; - if (mouseDownLocationRef.current) { - const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x); - const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y); - if ( - deltaX > TIMELINE_DRAG_THRESHOLD_PX || - deltaY > TIMELINE_DRAG_THRESHOLD_PX - ) { - mouseDownLocationRef.current = null; - return; - } - } + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new ElementInteractionController({ + depsRef: depsRef as ElementInteractionDepsRef, + }); + } + const controller = controllerRef.current; - // modifier keys already handled in mousedown - if (event.metaKey || event.ctrlKey || event.shiftKey) return; + const [, rerender] = useReducer((n: number) => n + 1, 0); + useEffect(() => controller.subscribe(rerender), [controller]); - const alreadySelected = isElementSelected({ - trackId: track.id, - elementId: element.id, - }); - if (!alreadySelected || selectedElements.length > 1) { - selectElement({ trackId: track.id, elementId: element.id }); - return; - } + useEffect(() => { + if (!controller.isActive) return; + return registerCanceller({ fn: () => controller.cancel() }); + }, [controller.isActive, controller]); - editor.selection.clearKeyframeSelection(); - }, - [editor.selection, isElementSelected, selectElement, selectedElements], - ); + useEffect(() => () => controller.destroy(), [controller]); return { - dragState, - dragDropTarget, - handleElementMouseDown, - handleElementClick, - lastMouseXRef, + dragView: controller.view, + handleElementMouseDown: controller.onElementMouseDown, + handleElementClick: controller.onElementClick, }; } diff --git a/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts index 92b6f0d8..5a1ff1f0 100644 --- a/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts +++ b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts @@ -1,40 +1,15 @@ -import { - useState, - useCallback, - useEffect, - useRef, - type MouseEvent as ReactMouseEvent, -} from "react"; +import { useEffect, useReducer, useRef } from "react"; import { useEditor } from "@/editor/use-editor"; -import { getKeyframeById } from "@/animation"; import { useKeyframeSelection } from "./use-keyframe-selection"; -import { roundToFrame, snappedSeekTime } from "opencut-wasm"; -import { timelineTimeToSnappedPixels } from "@/timeline"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { TICKS_PER_SECOND } from "@/wasm"; -import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; -import { RetimeKeyframeCommand } from "@/commands/timeline/element/keyframes/retime-keyframe"; -import { BatchCommand } from "@/commands"; -import type { SelectedKeyframeRef } from "@/animation/types"; -import type { TimelineElement } from "@/timeline"; -import type { Command } from "@/commands/base-command"; import { registerCanceller } from "@/editor/cancel-interaction"; -export interface KeyframeDragState { - isDragging: boolean; - draggingKeyframeIds: Set; - deltaTime: number; -} - -const initialDragState: KeyframeDragState = { - isDragging: false, - draggingKeyframeIds: new Set(), - deltaTime: 0, -}; +import { + KeyframeDragController, + type KeyframeDragConfig, + type KeyframeDragState, +} from "@/timeline/controllers/keyframe-drag-controller"; +import type { TimelineElement } from "@/timeline"; -interface PendingKeyframeDrag { - keyframeRefs: SelectedKeyframeRef[]; - startMouseX: number; -} +export type { KeyframeDragState }; export function useKeyframeDrag({ zoomLevel, @@ -54,275 +29,44 @@ export function useKeyframeDrag({ selectKeyframeRange, } = useKeyframeSelection(); - const [dragState, setDragState] = - useState(initialDragState); - const [isPendingDrag, setIsPendingDrag] = useState(false); - - const pendingDragRef = useRef(null); - const mouseDownXRef = useRef(null); - - const activeProject = editor.project.getActive(); - const fps = activeProject.settings.fps; - - const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel; - - const endDrag = useCallback(() => { - setDragState(initialDragState); - }, []); - - const cancelDrag = useCallback(() => { - pendingDragRef.current = null; - mouseDownXRef.current = null; - setIsPendingDrag(false); - endDrag(); - }, [endDrag]); - - const commitDrag = useCallback( - ({ - keyframeRefs, - deltaTime, - }: { - keyframeRefs: SelectedKeyframeRef[]; - deltaTime: number; - }) => { - const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => { - const keyframe = getKeyframeById({ - animations: element.animations, - propertyPath: keyframeRef.propertyPath, - keyframeId: keyframeRef.keyframeId, - }); - if (!keyframe) return []; - const nextTime = Math.max( - 0, - Math.min(element.duration, keyframe.time + deltaTime), - ); - return [ - new RetimeKeyframeCommand({ - trackId: keyframeRef.trackId, - elementId: keyframeRef.elementId, - propertyPath: keyframeRef.propertyPath, - keyframeId: keyframeRef.keyframeId, - nextTime, - }), - ]; - }); - - if (commands.length === 1) { - editor.command.execute({ command: commands[0] }); - } else if (commands.length > 1) { - editor.command.execute({ command: new BatchCommand(commands) }); - } - }, - [editor.command, element], - ); - - useEffect(() => { - if (!dragState.isDragging && !isPendingDrag) return; - - return registerCanceller({ fn: cancelDrag }); - }, [dragState.isDragging, isPendingDrag, cancelDrag]); - - useEffect(() => { - if (!dragState.isDragging && !isPendingDrag) return; - - const handleMouseMove = ({ clientX }: MouseEvent) => { - if (isPendingDrag && pendingDragRef.current) { - const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX); - if (deltaX <= TIMELINE_DRAG_THRESHOLD_PX) return; - - const pending = pendingDragRef.current; - pendingDragRef.current = null; - setIsPendingDrag(false); - setDragState({ - isDragging: true, - draggingKeyframeIds: new Set( - pending.keyframeRefs.map((keyframe) => keyframe.keyframeId), - ), - deltaTime: 0, - }); - return; - } - - if (!dragState.isDragging) return; - - const startX = mouseDownXRef.current ?? clientX; - const rawDelta = Math.round( - ((clientX - startX) / pixelsPerSecond) * TICKS_PER_SECOND, - ); - const snappedDelta = - roundToFrame({ time: rawDelta, rate: fps }) ?? rawDelta; - - setDragState((previous) => ({ ...previous, deltaTime: snappedDelta })); - }; - - document.addEventListener("mousemove", handleMouseMove); - return () => document.removeEventListener("mousemove", handleMouseMove); - }, [dragState.isDragging, isPendingDrag, pixelsPerSecond, fps]); - - useEffect(() => { - if (!dragState.isDragging) return; - - const handleMouseUp = () => { - const draggingRefs = selectedKeyframes.filter( - (keyframe) => - keyframe.elementId === element.id && - dragState.draggingKeyframeIds.has(keyframe.keyframeId), - ); - - if (draggingRefs.length > 0 && dragState.deltaTime !== 0) { - commitDrag({ - keyframeRefs: draggingRefs, - deltaTime: dragState.deltaTime, - }); - } - - endDrag(); - }; - - document.addEventListener("mouseup", handleMouseUp); - return () => document.removeEventListener("mouseup", handleMouseUp); - }, [ - dragState.isDragging, - dragState.draggingKeyframeIds, - dragState.deltaTime, + const config: KeyframeDragConfig = { + zoomLevel, + element, + displayedStartTime, + getFps: () => editor.project.getActive()?.settings.fps ?? null, selectedKeyframes, - element.id, - commitDrag, - endDrag, - ]); - - useEffect(() => { - if (!isPendingDrag) return; - - const handleMouseUp = () => { - pendingDragRef.current = null; - setIsPendingDrag(false); - }; - - document.addEventListener("mouseup", handleMouseUp); - return () => document.removeEventListener("mouseup", handleMouseUp); - }, [isPendingDrag]); - - const handleKeyframeMouseDown = useCallback( - ({ - event, - keyframes, - }: { - event: ReactMouseEvent; - keyframes: SelectedKeyframeRef[]; - }) => { - event.preventDefault(); - event.stopPropagation(); - - mouseDownXRef.current = event.clientX; - - const anySelected = keyframes.some((keyframe) => - isKeyframeSelected({ keyframe }), - ); - - const isModifierKey = event.shiftKey || event.metaKey || event.ctrlKey; - if (!anySelected && !isModifierKey) { - setKeyframeSelection({ keyframes }); - } - - const keyframeRefsToTrack = anySelected ? selectedKeyframes : keyframes; - - pendingDragRef.current = { - keyframeRefs: keyframeRefsToTrack, - startMouseX: event.clientX, - }; - setIsPendingDrag(true); - }, - [isKeyframeSelected, selectedKeyframes, setKeyframeSelection], - ); - - const handleKeyframeClick = useCallback( - ({ - event, - keyframes, - orderedKeyframes, - indicatorTime, - }: { - event: ReactMouseEvent; - keyframes: SelectedKeyframeRef[]; - orderedKeyframes: SelectedKeyframeRef[]; - indicatorTime: number; - }) => { - event.stopPropagation(); - - const wasDrag = - mouseDownXRef.current !== null && - Math.abs(event.clientX - mouseDownXRef.current) > - TIMELINE_DRAG_THRESHOLD_PX; - mouseDownXRef.current = null; + isKeyframeSelected, + setKeyframeSelection, + toggleKeyframeSelection, + selectKeyframeRange, + executeCommand: (command) => editor.command.execute({ command }), + seek: (args) => editor.playback.seek(args), + getTotalDuration: () => editor.timeline.getTotalDuration(), + }; - if (wasDrag) return; + const configRef = useRef(config); + configRef.current = config; - const duration = editor.timeline.getTotalDuration(); - const seekTime = - snappedSeekTime({ - time: displayedStartTime + indicatorTime, - duration, - rate: fps, - }) ?? displayedStartTime + indicatorTime; - editor.playback.seek({ time: seekTime }); + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new KeyframeDragController({ configRef }); + } + const controller = controllerRef.current; - if (event.shiftKey) { - selectKeyframeRange({ - orderedKeyframes, - targetKeyframes: keyframes, - isAdditive: event.metaKey || event.ctrlKey, - }); - return; - } + const [, rerender] = useReducer((n: number) => n + 1, 0); + useEffect(() => controller.subscribe(rerender), [controller]); - toggleKeyframeSelection({ - keyframes, - isMultiKey: event.metaKey || event.ctrlKey, - }); - }, - [ - toggleKeyframeSelection, - selectKeyframeRange, - editor, - displayedStartTime, - fps, - ], - ); + useEffect(() => { + if (!controller.isActive) return; + return registerCanceller({ fn: () => controller.cancel() }); + }, [controller.isActive, controller]); - const getVisualOffsetPx = useCallback( - ({ - indicatorTime, - indicatorOffsetPx, - isBeingDragged, - displayedStartTime, - elementLeft, - }: { - indicatorTime: number; - indicatorOffsetPx: number; - isBeingDragged: boolean; - displayedStartTime: number; - elementLeft: number; - }): number => { - if (!isBeingDragged) return indicatorOffsetPx; - const clampedTime = Math.max( - 0, - Math.min(element.duration, indicatorTime + dragState.deltaTime), - ); - return ( - timelineTimeToSnappedPixels({ - time: displayedStartTime + clampedTime, - zoomLevel, - }) - elementLeft - ); - }, - [dragState.deltaTime, element.duration, zoomLevel], - ); + useEffect(() => () => controller.destroy(), [controller]); return { - keyframeDragState: dragState, - handleKeyframeMouseDown, - handleKeyframeClick, - getVisualOffsetPx, + keyframeDragState: controller.keyframeDragState, + handleKeyframeMouseDown: controller.onKeyframeMouseDown, + handleKeyframeClick: controller.onKeyframeClick, + getVisualOffsetPx: controller.getVisualOffsetPx, }; } -- cgit v1.3.1