import { useState, useCallback, useEffect, useRef, type MouseEvent as ReactMouseEvent, 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 { buildMoveGroup, resolveGroupMove, snapGroupEdges, type GroupMoveResult, type MoveGroup, } from "@/timeline/group-move"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; import { addMediaTime, type MediaTime, mediaTime, subMediaTime, TICKS_PER_SECOND, ZERO_MEDIA_TIME, } 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"; 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; snappingEnabled: boolean; 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: ZERO_MEDIA_TIME, clickOffsetTime: ZERO_MEDIA_TIME, currentTime: ZERO_MEDIA_TIME, currentMouseY: 0, }; interface PendingDragState { elementId: string; trackId: string; selectedElements: ElementRef[]; startMouseX: number; startMouseY: number; startElementTime: MediaTime; clickOffsetTime: MediaTime; } function getClickOffsetTime({ clientX, elementRect, zoomLevel, }: { clientX: number; elementRect: DOMRect; zoomLevel: number; }): MediaTime { const clickOffsetX = clientX - elementRect.left; const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel); return mediaTime({ ticks: 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: MediaTime; 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: MediaTime; initialCurrentMouseY: number; } export function useElementInteraction({ zoomLevel, timelineRef, tracksContainerRef, tracksScrollRef, headerRef, snappingEnabled, onSnapPointChange, }: 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 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: MediaTime; 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, }, }); }, [sceneTracks], ); useEffect(() => { if (!dragState.isDragging && !isPendingDrag) return; return registerCanceller({ fn: cancelCurrentDrag }); }, [dragState.isDragging, isPendingDrag, cancelCurrentDrag]); const getDragSnapResult = useCallback( ({ frameSnappedTime, group, }: { frameSnappedTime: MediaTime; 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, }; }, [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 = mouseTime > pendingDragRef.current.clickOffsetTime ? subMediaTime({ a: mouseTime, b: pendingDragRef.current.clickOffsetTime, }) : ZERO_MEDIA_TIME; const snappedTime = ( roundToFrame({ time: adjustedTime, rate: activeProject.settings.fps, }) ?? adjustedTime ) as MediaTime; 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 = mouseTime > dragState.clickOffsetTime ? subMediaTime({ a: mouseTime, b: dragState.clickOffsetTime, }) : ZERO_MEDIA_TIME; const fps = activeProject.settings.fps; const frameSnappedTime = ( roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime ) as MediaTime; 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 = addMediaTime({ a: dragState.startElementTime, b: currentMember?.timeOffset ?? ZERO_MEDIA_TIME, }); 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); }, [zoomLevel, isElementSelected, handleSelectionClick, selectedElements], ); const handleElementClick = useCallback( ({ event, element, track, }: { event: ReactMouseEvent; element: TimelineElement; track: TimelineTrack; }) => { event.stopPropagation(); 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; } } // modifier keys already handled in mousedown if (event.metaKey || event.ctrlKey || event.shiftKey) return; const alreadySelected = isElementSelected({ trackId: track.id, elementId: element.id, }); if (!alreadySelected || selectedElements.length > 1) { selectElement({ trackId: track.id, elementId: element.id }); return; } editor.selection.clearKeyframeSelection(); }, [editor.selection, isElementSelected, selectElement, selectedElements], ); return { dragState, dragDropTarget, handleElementMouseDown, handleElementClick, lastMouseXRef, }; }