From 56ca09692a308b22455420d11376ff8d41244c47 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 26 Apr 2026 17:31:28 +0200 Subject: refactor: thinner hooks --- .../hooks/element/use-element-interaction.ts | 799 ++------------------- .../timeline/hooks/element/use-keyframe-drag.ts | 352 +-------- .../src/timeline/hooks/use-timeline-drag-drop.ts | 659 +---------------- .../src/timeline/hooks/use-timeline-playhead.ts | 339 ++------- apps/web/src/timeline/hooks/use-timeline-resize.ts | 354 ++------- apps/web/src/timeline/hooks/use-timeline-seek.ts | 196 +---- apps/web/src/timeline/hooks/use-timeline-zoom.ts | 283 ++------ 7 files changed, 332 insertions(+), 2650 deletions(-) (limited to 'apps/web/src/timeline/hooks') 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 9caca28a..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,49 +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 { - 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"; + 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; @@ -51,133 +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: 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, @@ -186,597 +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: 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, - }, - }); + 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: 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, - }; + 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 = - 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); + 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 afead4e9..ea03cb5c 100644 --- a/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts +++ b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts @@ -1,48 +1,16 @@ -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 { registerCanceller } from "@/editor/cancel-interaction"; import { - addMediaTime, - type MediaTime, - maxMediaTime, - mediaTime, - minMediaTime, - TICKS_PER_SECOND, - ZERO_MEDIA_TIME, -} 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"; + KeyframeDragController, + type KeyframeDragConfig, + type KeyframeDragState, +} from "@/timeline/controllers/keyframe-drag-controller"; 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: MediaTime; -} - -const initialDragState: KeyframeDragState = { - isDragging: false, - draggingKeyframeIds: new Set(), - deltaTime: ZERO_MEDIA_TIME, -}; +import type { MediaTime } from "@/wasm"; -interface PendingKeyframeDrag { - keyframeRefs: SelectedKeyframeRef[]; - startMouseX: number; -} +export type { KeyframeDragState }; export function useKeyframeDrag({ zoomLevel, @@ -62,286 +30,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: MediaTime; - }) => { - const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => { - const keyframe = getKeyframeById({ - animations: element.animations, - propertyPath: keyframeRef.propertyPath, - keyframeId: keyframeRef.keyframeId, - }); - if (!keyframe) return []; - const nextTime = maxMediaTime({ - a: ZERO_MEDIA_TIME, - b: minMediaTime({ - a: element.duration, - b: addMediaTime({ a: keyframe.time, b: 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: ZERO_MEDIA_TIME, - }); - return; - } - - if (!dragState.isDragging) return; - - const startX = mouseDownXRef.current ?? clientX; - const rawDelta = mediaTime({ - ticks: Math.round( - ((clientX - startX) / pixelsPerSecond) * TICKS_PER_SECOND, - ), - }); - const snappedDelta = ( - roundToFrame({ time: rawDelta, rate: fps }) ?? rawDelta - ) as MediaTime; - - 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: MediaTime; - }) => { - 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: ({ time }) => editor.playback.seek({ time }), + getTotalDuration: () => editor.timeline.getTotalDuration(), + }; - if (wasDrag) return; + const configRef = useRef(config); + configRef.current = config; - const duration = editor.timeline.getTotalDuration(); - const absoluteIndicatorTime = addMediaTime({ - a: displayedStartTime, - b: indicatorTime, - }); - const seekTime = ( - snappedSeekTime({ - time: absoluteIndicatorTime, - duration, - rate: fps, - }) ?? absoluteIndicatorTime - ) as MediaTime; - 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, }; } diff --git a/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts index 5a892ccf..4ec2b80b 100644 --- a/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts +++ b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts @@ -1,30 +1,9 @@ -import { useState, useCallback, type RefObject } from "react"; +import { useEffect, useReducer, useRef, type RefObject } from "react"; import { useEditor } from "@/editor/use-editor"; -import { processMediaAssets } from "@/media/processing"; -import { toast } from "sonner"; -import { showMediaUploadToast } from "@/media/upload-toast"; -import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation"; -import { mediaTimeFromSeconds, type MediaTime } from "@/wasm"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { roundToFrame } from "opencut-wasm"; import { - buildTextElement, - buildGraphicElement, - buildStickerElement, - buildElementFromMedia, - buildEffectElement, -} from "@/timeline/element-utils"; -import { AddTrackCommand, InsertElementCommand } from "@/commands/timeline"; -import { BatchCommand } from "@/commands"; -import { computeDropTarget } from "@/timeline/components/drop-target"; -import { getDragData, hasDragData } from "@/timeline/drag-data"; -import type { TrackType, DropTarget, ElementType } from "@/timeline"; -import type { - MediaDragData, - GraphicDragData, - StickerDragData, - EffectDragData, -} from "@/timeline/drag"; + DragDropController, + type DragDropConfig, +} from "@/timeline/controllers/drag-drop-controller"; interface UseTimelineDragDropProps { containerRef: RefObject; @@ -40,609 +19,47 @@ export function useTimelineDragDrop({ zoomLevel, }: UseTimelineDragDropProps) { const editor = useEditor(); - const [isDragOver, setIsDragOver] = useState(false); - const [dropTarget, setDropTarget] = useState(null); - const [dragElementType, setElementType] = useState(null); - const getSnappedTime = useCallback( - ({ time }: { time: MediaTime }) => { - const projectFps = editor.project.getActive().settings.fps; - return (roundToFrame({ time, rate: projectFps }) ?? time) as MediaTime; - }, - [editor], - ); - - const getElementType = useCallback( - ({ dataTransfer }: { dataTransfer: DataTransfer }): ElementType | null => { - const dragData = getDragData({ dataTransfer }); - if (!dragData) return null; - - if (dragData.type === "text") return "text"; - if (dragData.type === "graphic") return "graphic"; - if (dragData.type === "sticker") return "sticker"; - if (dragData.type === "effect") return "effect"; - if (dragData.type === "media") { - return dragData.mediaType; - } - return null; - }, - [], - ); - - const getElementDuration = useCallback( - ({ - elementType, - mediaId, - }: { - elementType: ElementType; - mediaId?: string; - }): MediaTime => { - if ( - elementType === "text" || - elementType === "graphic" || - elementType === "sticker" || - elementType === "effect" - ) { - return DEFAULT_NEW_ELEMENT_DURATION; - } - if (mediaId) { - const mediaAssets = editor.media.getAssets(); - const media = mediaAssets.find((m) => m.id === mediaId); - return media?.duration != null - ? mediaTimeFromSeconds({ seconds: media.duration }) - : DEFAULT_NEW_ELEMENT_DURATION; - } - return DEFAULT_NEW_ELEMENT_DURATION; - }, - [editor], - ); - - const handleDragEnter = useCallback((e: React.DragEvent) => { - e.preventDefault(); - const hasAsset = hasDragData({ dataTransfer: e.dataTransfer }); - const hasFiles = e.dataTransfer.types.includes("Files"); - if (!hasAsset && !hasFiles) return; - setIsDragOver(true); - }, []); - - const handleDragOver = useCallback( - (e: React.DragEvent) => { - e.preventDefault(); - - const scrollContainer = tracksScrollRef?.current; - const referenceRect = - scrollContainer?.getBoundingClientRect() ?? - containerRef.current?.getBoundingClientRect(); - if (!referenceRect) return; - - const headerHeight = - headerRef?.current?.getBoundingClientRect().height ?? 0; - const scrollLeft = scrollContainer?.scrollLeft ?? 0; - const scrollTop = scrollContainer?.scrollTop ?? 0; - const hasFiles = e.dataTransfer.types.includes("Files"); - const isExternal = - hasFiles && !hasDragData({ dataTransfer: e.dataTransfer }); - - const elementType = getElementType({ dataTransfer: e.dataTransfer }); - - if (!elementType && hasFiles && isExternal) { - setDropTarget(null); - setElementType(null); - return; - } - - if (!elementType) return; - - setElementType(elementType); - - const dragData = getDragData({ dataTransfer: e.dataTransfer }); - const duration = getElementDuration({ - elementType, - mediaId: dragData?.type === "media" ? dragData.id : undefined, - }); - - const mouseX = e.clientX - referenceRect.left + scrollLeft; - const mouseY = e.clientY - referenceRect.top + scrollTop - headerHeight; - - const targetElementTypes = - dragData?.type === "effect" - ? (dragData as EffectDragData).targetElementTypes - : dragData?.type === "media" - ? (dragData as MediaDragData).targetElementTypes - : undefined; - - const sceneTracks = editor.scenes.getActiveScene().tracks; - const currentTime = editor.playback.getCurrentTime(); - const target = computeDropTarget({ - elementType, - mouseX, - mouseY, - tracks: sceneTracks, - playheadTime: currentTime, - isExternalDrop: isExternal, - elementDuration: duration, - pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, - zoomLevel, - targetElementTypes, - }); - - target.xPosition = getSnappedTime({ time: target.xPosition }); - - setDropTarget(target); - e.dataTransfer.dropEffect = "copy"; - }, - [ - containerRef, - headerRef, - tracksScrollRef, - zoomLevel, - getElementType, - getElementDuration, - getSnappedTime, - editor, - ], - ); - - const handleDragLeave = useCallback( - (e: React.DragEvent) => { - e.preventDefault(); - const rect = containerRef.current?.getBoundingClientRect(); - if (rect) { - const { clientX, clientY } = e; - if ( - clientX < rect.left || - clientX > rect.right || - clientY < rect.top || - clientY > rect.bottom - ) { - setIsDragOver(false); - setDropTarget(null); - setElementType(null); - } - } - }, - [containerRef], - ); - - const executeTextDrop = useCallback( - ({ - target, - dragData, - }: { - target: DropTarget; - dragData: { name?: string; content?: string }; - }) => { - const element = buildTextElement({ - raw: { - name: dragData.name ?? "", - content: dragData.content ?? "", - }, - startTime: target.xPosition, - }); - - if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand("text", target.trackIndex); - const insertCmd = new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, - }); - editor.command.execute({ - command: new BatchCommand([addTrackCmd, insertCmd]), - }); - return; - } - - const tracks = [ - ...editor.scenes.getActiveScene().tracks.overlay, - editor.scenes.getActiveScene().tracks.main, - ...editor.scenes.getActiveScene().tracks.audio, - ]; - const track = tracks[target.trackIndex]; - if (!track) return; - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId: track.id }, - element, - }); - }, - [editor], - ); - - const executeStickerDrop = useCallback( - ({ - target, - dragData, - }: { - target: DropTarget; - dragData: StickerDragData; - }) => { - const element = buildStickerElement({ - stickerId: dragData.stickerId, - name: dragData.name, - startTime: target.xPosition, - }); - - if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex); - const insertCmd = new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, - }); - editor.command.execute({ - command: new BatchCommand([addTrackCmd, insertCmd]), - }); - return; - } - - const tracks = [ - ...editor.scenes.getActiveScene().tracks.overlay, - editor.scenes.getActiveScene().tracks.main, - ...editor.scenes.getActiveScene().tracks.audio, - ]; - const track = tracks[target.trackIndex]; - if (!track) return; - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId: track.id }, - element, - }); - }, - [editor], - ); - - const executeGraphicDrop = useCallback( - ({ - target, - dragData, - }: { - target: DropTarget; - dragData: GraphicDragData; - }) => { - const element = buildGraphicElement({ - definitionId: dragData.definitionId, - name: dragData.name, - startTime: target.xPosition, - params: dragData.params, - }); - - if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex); - const insertCmd = new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, - }); - editor.command.execute({ - command: new BatchCommand([addTrackCmd, insertCmd]), - }); - return; - } - - const tracks = [ - ...editor.scenes.getActiveScene().tracks.overlay, - editor.scenes.getActiveScene().tracks.main, - ...editor.scenes.getActiveScene().tracks.audio, - ]; - const track = tracks[target.trackIndex]; - if (!track) return; - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId: track.id }, - element, - }); - }, - [editor], - ); - - const executeMediaDrop = useCallback( - ({ target, dragData }: { target: DropTarget; dragData: MediaDragData }) => { - if (target.targetElement) { - toast.info("Replace media source is coming soon!"); - return; - } - - const mediaAssets = editor.media.getAssets(); - const mediaAsset = mediaAssets.find((m) => m.id === dragData.id); - if (!mediaAsset) return; - - const trackType: TrackType = - dragData.mediaType === "audio" ? "audio" : "video"; - - const duration = - mediaAsset.duration != null - ? mediaTimeFromSeconds({ seconds: mediaAsset.duration }) - : DEFAULT_NEW_ELEMENT_DURATION; - const element = buildElementFromMedia({ - mediaId: mediaAsset.id, - mediaType: mediaAsset.type, - name: mediaAsset.name, - duration, - startTime: target.xPosition, - }); - - if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex); - const insertCmd = new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, - }); - editor.command.execute({ - command: new BatchCommand([addTrackCmd, insertCmd]), - }); - return; - } - - const tracks = [ - ...editor.scenes.getActiveScene().tracks.overlay, - editor.scenes.getActiveScene().tracks.main, - ...editor.scenes.getActiveScene().tracks.audio, - ]; - const track = tracks[target.trackIndex]; - if (!track) return; - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId: track.id }, - element, - }); - }, - [editor], - ); - - const executeEffectDrop = useCallback( - ({ - target, - dragData, - }: { - target: DropTarget; - dragData: EffectDragData; - }) => { - if (target.targetElement) { - editor.timeline.addClipEffect({ - trackId: target.targetElement.trackId, - elementId: target.targetElement.elementId, - effectType: dragData.effectType, - }); - return; - } - - const tracks = [ - ...editor.scenes.getActiveScene().tracks.overlay, - editor.scenes.getActiveScene().tracks.main, - ...editor.scenes.getActiveScene().tracks.audio, - ]; - const effectTrack = tracks.find((t) => t.type === "effect"); - let trackId: string; - - if (effectTrack) { - trackId = effectTrack.id; - } else if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand("effect", target.trackIndex); - const insertCmd = new InsertElementCommand({ - element: buildEffectElement({ - effectType: dragData.effectType, - startTime: target.xPosition, - }), - placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, - }); - editor.command.execute({ - command: new BatchCommand([addTrackCmd, insertCmd]), - }); - return; - } else { - const track = tracks[target.trackIndex]; - if (!track || track.type !== "effect") return; - trackId = track.id; - } - - const element = buildEffectElement({ - effectType: dragData.effectType, - startTime: target.xPosition, - }); - - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId }, - element, - }); - }, - [editor], - ); - - const executeFileDrop = useCallback( - async ({ - files, - mouseX, - mouseY, - }: { - files: File[]; - mouseX: number; - mouseY: number; - }) => { - const activeProject = editor.project.getActiveOrNull(); - if (!activeProject) return; - - await showMediaUploadToast({ - filesCount: files.length, - promise: async () => { - const processedAssets = await processMediaAssets({ files }); - const projectId = activeProject.metadata.id; - - for (const asset of processedAssets) { - const createdAsset = await editor.media.addMediaAsset({ - projectId, - asset, - }); - if (!createdAsset) continue; - - const duration = - createdAsset.duration != null - ? mediaTimeFromSeconds({ seconds: createdAsset.duration }) - : DEFAULT_NEW_ELEMENT_DURATION; - const sceneTracks = editor.scenes.getActiveScene().tracks; - const currentTime = editor.playback.getCurrentTime(); - const reuseMainTrackId = - createdAsset.type !== "audio" && - sceneTracks.overlay.length === 0 && - sceneTracks.audio.length === 0 && - sceneTracks.main.elements.length === 0 - ? sceneTracks.main.id - : null; - const dropTarget = reuseMainTrackId - ? null - : computeDropTarget({ - elementType: createdAsset.type, - mouseX, - mouseY, - tracks: sceneTracks, - playheadTime: currentTime, - isExternalDrop: true, - elementDuration: duration, - pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, - zoomLevel, - }); - - const trackType: TrackType = - createdAsset.type === "audio" ? "audio" : "video"; - - const startTime = dropTarget?.xPosition ?? currentTime; - const element = buildElementFromMedia({ - mediaId: createdAsset.id, - mediaType: createdAsset.type, - name: createdAsset.name, - duration, - startTime, - }); - - if (reuseMainTrackId) { - editor.command.execute({ - command: new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId: reuseMainTrackId }, - }), - }); - } else { - if (!dropTarget) continue; - if (dropTarget.isNewTrack) { - const addTrackCmd = new AddTrackCommand( - trackType, - dropTarget.trackIndex, - ); - editor.command.execute({ - command: new BatchCommand([ - addTrackCmd, - new InsertElementCommand({ - element, - placement: { - mode: "explicit", - trackId: addTrackCmd.getTrackId(), - }, - }), - ]), - }); - } else { - const trackId = [ - ...sceneTracks.overlay, - sceneTracks.main, - ...sceneTracks.audio, - ][dropTarget.trackIndex]?.id; - if (!trackId) continue; - editor.command.execute({ - command: new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId }, - }), - }); - } - } - } - - return { - uploadedCount: processedAssets.length, - assetNames: processedAssets.map((asset) => asset.name), - }; - }, - }); - }, - [editor, zoomLevel], - ); - - const handleDrop = useCallback( - async (e: React.DragEvent) => { - e.preventDefault(); - - const hasAsset = hasDragData({ dataTransfer: e.dataTransfer }); - const hasFiles = e.dataTransfer.files?.length > 0; - - if (!hasAsset && !hasFiles) return; + const config: DragDropConfig = { + zoomLevel, + getContainerEl: () => containerRef.current, + getHeaderEl: () => headerRef?.current ?? null, + getTracksScrollEl: () => tracksScrollRef?.current ?? null, + getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null, + getActiveProjectId: () => + editor.project.getActiveOrNull()?.metadata.id ?? null, + getSceneTracks: () => editor.scenes.getActiveScene().tracks, + getCurrentPlayheadTime: () => editor.playback.getCurrentTime(), + getMediaAssets: () => editor.media.getAssets(), + dragSource: editor.timeline.dragSource, + addMediaAsset: (args) => editor.media.addMediaAsset(args), + executeCommand: (command) => editor.command.execute({ command }), + insertElement: (args) => editor.timeline.insertElement(args), + addClipEffect: (args) => editor.timeline.addClipEffect(args), + }; - const currentTarget = dropTarget; - setIsDragOver(false); - setDropTarget(null); - setElementType(null); + const configRef = useRef(config); + configRef.current = config; - try { - if (hasAsset) { - if (!currentTarget) return; - const dragData = getDragData({ dataTransfer: e.dataTransfer }); - if (!dragData) return; + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new DragDropController({ configRef }); + } + const controller = controllerRef.current; - if (dragData.type === "text") { - executeTextDrop({ target: currentTarget, dragData }); - } else if (dragData.type === "graphic") { - executeGraphicDrop({ - target: currentTarget, - dragData: dragData as GraphicDragData, - }); - } else if (dragData.type === "sticker") { - executeStickerDrop({ target: currentTarget, dragData }); - } else if (dragData.type === "effect") { - executeEffectDrop({ - target: currentTarget, - dragData: dragData as EffectDragData, - }); - } else { - executeMediaDrop({ target: currentTarget, dragData }); - } - } else if (hasFiles) { - const scrollContainer = tracksScrollRef?.current; - const referenceRect = - scrollContainer?.getBoundingClientRect() ?? - containerRef.current?.getBoundingClientRect(); - if (!referenceRect) return; - const scrollLeft = scrollContainer?.scrollLeft ?? 0; - const scrollTop = scrollContainer?.scrollTop ?? 0; - const mouseX = e.clientX - referenceRect.left + scrollLeft; - const headerHeight = - headerRef?.current?.getBoundingClientRect().height ?? 0; - const mouseY = - e.clientY - referenceRect.top + scrollTop - headerHeight; - await executeFileDrop({ - files: Array.from(e.dataTransfer.files), - mouseX, - mouseY, - }); - } - } catch (err) { - console.error("Failed to process drop:", err); - } - }, - [ - dropTarget, - executeTextDrop, - executeGraphicDrop, - executeStickerDrop, - executeMediaDrop, - executeEffectDrop, - executeFileDrop, - containerRef, - headerRef, - tracksScrollRef, - ], - ); + const [, rerender] = useReducer((n: number) => n + 1, 0); + useEffect(() => controller.subscribe(rerender), [controller]); + useEffect(() => () => controller.destroy(), [controller]); return { - isDragOver, - dropTarget, - dragElementType, + isDragOver: controller.isDragOver, + dropTarget: controller.dropTarget, + dragElementType: controller.dragElementType, dragProps: { - onDragEnter: handleDragEnter, - onDragOver: handleDragOver, - onDragLeave: handleDragLeave, - onDrop: handleDrop, + onDragEnter: controller.onDragEnter, + onDragOver: controller.onDragOver, + onDragLeave: controller.onDragLeave, + onDrop: controller.onDrop, }, }; } diff --git a/apps/web/src/timeline/hooks/use-timeline-playhead.ts b/apps/web/src/timeline/hooks/use-timeline-playhead.ts index 0ec39f69..15ef3575 100644 --- a/apps/web/src/timeline/hooks/use-timeline-playhead.ts +++ b/apps/web/src/timeline/hooks/use-timeline-playhead.ts @@ -1,23 +1,13 @@ -import { snappedSeekTime } from "opencut-wasm"; -import { mediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm"; -import { useEffect, useCallback, useRef } from "react"; -import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll"; +import { useEffect, useRef } from "react"; import { useEditor } from "@/editor/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; +import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll"; +import { timelineTimeToPixels } from "@/timeline"; import { - buildTimelineSnapPoints, - getTimelineSnapThresholdInTicks, - resolveTimelineSnap, -} from "@/timeline/snapping"; -import { getBookmarkSnapPoints } from "@/timeline/bookmarks/index"; -import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; -import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; -import { - getCenteredLineLeft, - timelineTimeToPixels, - timelineTimeToSnappedPixels, -} from "@/timeline"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; + PlayheadController, + type PlayheadConfig, +} from "@/timeline/controllers/playhead-controller"; +import type { MediaTime } from "@/wasm"; interface UseTimelinePlayheadProps { zoomLevel: number; @@ -35,272 +25,81 @@ export function useTimelinePlayhead({ playheadRef, }: UseTimelinePlayheadProps) { const editor = useEditor(); - const isScrubbing = useEditor((e) => e.playback.getIsScrubbing()); - const activeProject = editor.project.getActive(); - const duration = editor.timeline.getTotalDuration(); const isShiftHeldRef = useShiftKey(); + // isScrubbing drives useEdgeAutoScroll — the controller sets it on the editor, + // so this reactive read naturally reflects whether scrubbing is active. + const isScrubbing = useEditor((e) => e.playback.getIsScrubbing()); - const zoomLevelRef = useRef(zoomLevel); - const durationRef = useRef(duration); - const isScrubbingRef = useRef(isScrubbing); - const isPlayingRef = useRef(false); - - useEffect(() => { - zoomLevelRef.current = zoomLevel; - durationRef.current = duration; - isScrubbingRef.current = isScrubbing; - isPlayingRef.current = editor.playback.getIsPlaying(); - }, [zoomLevel, duration, isScrubbing, editor.playback]); - - const seek = useCallback( - ({ time }: { time: MediaTime }) => editor.playback.seek({ time }), - [editor.playback], - ); - - const scrubTimeRef = useRef(null); - const isDraggingRulerRef = useRef(false); - const hasDraggedRulerRef = useRef(false); - const lastMouseXRef = useRef(0); - - const handleScrub = useCallback( - ({ - event, - snappingEnabled = true, - }: { - event: MouseEvent | React.MouseEvent; - snappingEnabled?: boolean; - }) => { - const ruler = rulerRef.current; - if (!ruler) return; - const rulerRect = ruler.getBoundingClientRect(); - const relativeMouseX = event.clientX - rulerRect.left; - - const timelineContentWidth = timelineTimeToPixels({ - time: duration, - zoomLevel, - }); - - const clampedMouseX = Math.max( - 0, - Math.min(timelineContentWidth, relativeMouseX), - ); - - const rawTimeSeconds = Math.max( - 0, - Math.min( - duration / TICKS_PER_SECOND, - clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), - ), - ); - const rawTime = mediaTime({ - ticks: Math.round(rawTimeSeconds * TICKS_PER_SECOND), - }); - - const rate = activeProject.settings.fps; - const frameTime = ( - snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime - ) as MediaTime; - - const shouldSnap = snappingEnabled && !isShiftHeldRef.current; - const time: MediaTime = (() => { - if (!shouldSnap) return frameTime; - const tracks = editor.scenes.getActiveScene().tracks; - const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? []; - const snapPoints = buildTimelineSnapPoints({ - sources: [ - () => getElementEdgeSnapPoints({ tracks }), - () => getBookmarkSnapPoints({ bookmarks }), - () => getAnimationKeyframeSnapPointsForTimeline({ tracks }), - ], - }); - const snapResult = resolveTimelineSnap({ - targetTime: frameTime, - snapPoints, - maxSnapDistance: getTimelineSnapThresholdInTicks({ zoomLevel }), - }); - return snapResult.snapPoint - ? (snapResult.snappedTime as MediaTime) - : frameTime; - })(); - - scrubTimeRef.current = time; - seek({ time }); - - lastMouseXRef.current = event.clientX; - }, - [ - duration, - zoomLevel, - seek, - rulerRef, - activeProject.settings.fps, - isShiftHeldRef, - editor.scenes, - ], - ); - - const handlePlayheadMouseDown = useCallback( - ({ event }: { event: React.MouseEvent }) => { - event.preventDefault(); - event.stopPropagation(); - editor.playback.setScrubbing({ isScrubbing: true }); - handleScrub({ event }); - }, - [handleScrub, editor.playback], - ); - - const handleRulerMouseDown = useCallback( - ({ event }: { event: React.MouseEvent }) => { - if (event.button !== 0) return; - if (playheadRef?.current?.contains(event.target as Node)) return; - - event.preventDefault(); - isDraggingRulerRef.current = true; - hasDraggedRulerRef.current = false; - - editor.playback.setScrubbing({ isScrubbing: true }); - handleScrub({ event, snappingEnabled: false }); - }, - [handleScrub, playheadRef, editor.playback], - ); - - const handlePlayheadMouseDownEvent = useCallback( - (event: React.MouseEvent) => handlePlayheadMouseDown({ event }), - [handlePlayheadMouseDown], - ); - - const handleRulerMouseDownEvent = useCallback( - (event: React.MouseEvent) => handleRulerMouseDown({ event }), - [handleRulerMouseDown], - ); - - useEdgeAutoScroll({ - isActive: isScrubbing, - getMouseClientX: () => lastMouseXRef.current, - rulerScrollRef, - tracksScrollRef, - contentWidth: timelineTimeToPixels({ time: duration, zoomLevel }), - }); - - useEffect(() => { - if (!isScrubbing) return; - - const handleMouseMove = ({ event }: { event: MouseEvent }) => { - handleScrub({ event }); - if (isDraggingRulerRef.current) { - hasDraggedRulerRef.current = true; - } - }; - - const handleMouseUp = ({ event }: { event: MouseEvent }) => { - editor.playback.setScrubbing({ isScrubbing: false }); - const finalTime = scrubTimeRef.current; - if (finalTime !== null) { - seek({ time: finalTime }); - editor.project.setTimelineViewState({ - viewState: { - zoomLevel, - scrollLeft: tracksScrollRef.current?.scrollLeft ?? 0, - playheadTime: finalTime, - }, - }); - } - scrubTimeRef.current = null; - - if (isDraggingRulerRef.current) { - isDraggingRulerRef.current = false; - if (!hasDraggedRulerRef.current) { - handleScrub({ event, snappingEnabled: false }); - } - hasDraggedRulerRef.current = false; - } - }; - - const onMouseMove = (event: MouseEvent) => handleMouseMove({ event }); - const onMouseUp = (event: MouseEvent) => handleMouseUp({ event }); - - window.addEventListener("mousemove", onMouseMove); - window.addEventListener("mouseup", onMouseUp); - - return () => { - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("mouseup", onMouseUp); - }; - }, [isScrubbing, seek, handleScrub, editor, tracksScrollRef, zoomLevel]); + const config: PlayheadConfig = { + zoomLevel, + duration: editor.timeline.getTotalDuration(), + getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null, + isShiftHeld: () => isShiftHeldRef.current, + getIsPlaying: () => editor.playback.getIsPlaying(), + getRulerEl: () => rulerRef.current, + getRulerScrollEl: () => rulerScrollRef.current, + getTracksScrollEl: () => tracksScrollRef.current, + getPlayheadEl: () => playheadRef?.current ?? null, + getSceneTracks: () => editor.scenes.getActiveScene().tracks, + getSceneBookmarks: () => editor.scenes.getActiveScene()?.bookmarks ?? [], + seek: (time) => editor.playback.seek({ time }), + setScrubbing: (scrubbing) => + editor.playback.setScrubbing({ isScrubbing: scrubbing }), + setTimelineViewState: ({ zoomLevel, scrollLeft, playheadTime }) => + editor.project.setTimelineViewState({ + viewState: { + zoomLevel, + scrollLeft, + playheadTime, + }, + }), + }; + const configRef = useRef(config); + configRef.current = config; - const updatePlayheadLeft = useCallback( - (time: MediaTime) => { - const playheadEl = playheadRef?.current; - if (!playheadEl) return; - const centerPosition = timelineTimeToSnappedPixels({ - time, - zoomLevel: zoomLevelRef.current, - }); - const leftPosition = getCenteredLineLeft({ centerPixel: centerPosition }); - const scrollLeft = rulerScrollRef.current?.scrollLeft ?? 0; - playheadEl.style.left = `${leftPosition - scrollLeft}px`; - }, - [playheadRef, rulerScrollRef], - ); + const ctrlRef = useRef(null); + if (!ctrlRef.current) { + ctrlRef.current = new PlayheadController({ configRef }); + } + const ctrl = ctrlRef.current; + // Scroll → keep playhead position in sync with scroll offset. useEffect(() => { const scrollEl = rulerScrollRef.current; if (!scrollEl) return; + const handler = () => + ctrl.updatePlayheadLeft(editor.playback.getCurrentTime()); + scrollEl.addEventListener("scroll", handler, { passive: true }); + return () => scrollEl.removeEventListener("scroll", handler); + }, [ctrl, editor.playback, rulerScrollRef]); - const handleScroll = () => { - updatePlayheadLeft(editor.playback.getCurrentTime()); - }; - - scrollEl.addEventListener("scroll", handleScroll, { passive: true }); - return () => scrollEl.removeEventListener("scroll", handleScroll); - }, [editor.playback, rulerScrollRef, updatePlayheadLeft]); - + // Playback events → update playhead position and auto-scroll during playback. useEffect(() => { - const handlePlaybackTime = (time: MediaTime) => { - updatePlayheadLeft(time); - - if (!isPlayingRef.current || isScrubbingRef.current) return; - const rulerViewport = rulerScrollRef.current; - const tracksViewport = tracksScrollRef.current; - if (!rulerViewport || !tracksViewport) return; - - const playheadPixels = timelineTimeToPixels({ - time, - zoomLevel: zoomLevelRef.current, - }); - const viewportWidth = rulerViewport.clientWidth; - const scrollMinimum = 0; - const scrollMaximum = rulerViewport.scrollWidth - viewportWidth; - - const needsScroll = - playheadPixels < rulerViewport.scrollLeft || - playheadPixels > rulerViewport.scrollLeft + viewportWidth; - - if (needsScroll) { - const desiredScroll = Math.max( - scrollMinimum, - Math.min(scrollMaximum, playheadPixels - viewportWidth / 2), - ); - rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll; - } - }; - const handlePlaybackUpdate = (e: Event) => { - handlePlaybackTime((e as CustomEvent<{ time: MediaTime }>).detail.time); + const handler = (time: MediaTime) => ctrl.handlePlaybackUpdate(time); + ctrl.updatePlayheadLeft(editor.playback.getCurrentTime()); + const unsubscribeUpdate = editor.playback.onUpdate(handler); + const unsubscribeSeek = editor.playback.onSeek(handler); + return () => { + unsubscribeUpdate(); + unsubscribeSeek(); }; + }, [ctrl, editor.playback]); - const initialTime = editor.playback.getCurrentTime(); - handlePlaybackTime(initialTime); + useEdgeAutoScroll({ + isActive: isScrubbing, + getMouseClientX: () => ctrl.getLastMouseClientX(), + rulerScrollRef, + tracksScrollRef, + contentWidth: timelineTimeToPixels({ + time: editor.timeline.getTotalDuration(), + zoomLevel, + }), + }); - window.addEventListener("playback-update", handlePlaybackUpdate); - window.addEventListener("playback-seek", handlePlaybackUpdate); - return () => { - window.removeEventListener("playback-update", handlePlaybackUpdate); - window.removeEventListener("playback-seek", handlePlaybackUpdate); - }; - }, [editor.playback, rulerScrollRef, tracksScrollRef, updatePlayheadLeft]); + useEffect(() => () => ctrl.destroy(), [ctrl]); return { - handlePlayheadMouseDown: handlePlayheadMouseDownEvent, - handleRulerMouseDown: handleRulerMouseDownEvent, + handlePlayheadMouseDown: ctrl.onPlayheadMouseDown, + handleRulerMouseDown: ctrl.onRulerMouseDown, }; } diff --git a/apps/web/src/timeline/hooks/use-timeline-resize.ts b/apps/web/src/timeline/hooks/use-timeline-resize.ts index accda4a1..3579bd75 100644 --- a/apps/web/src/timeline/hooks/use-timeline-resize.ts +++ b/apps/web/src/timeline/hooks/use-timeline-resize.ts @@ -1,332 +1,82 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import type { EditorCore } from "@/core"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { useEffect, useReducer, useRef } from "react"; import { useEditor } from "@/editor/use-editor"; -import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; import { useShiftKey } from "@/hooks/use-shift-key"; +import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; import { useTimelineStore } from "@/timeline/timeline-store"; -import { - computeGroupResize, - type GroupResizeMember, - type GroupResizeResult, - type ResizeSide, -} from "@/timeline/group-resize"; -import { - buildTimelineSnapPoints, - getTimelineSnapThresholdInTicks, - resolveTimelineSnap, - type SnapPoint, -} from "@/timeline/snapping"; -import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; -import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source"; -import type { SceneTracks, TimelineElement, TimelineTrack } from "@/timeline"; -import { isRetimableElement } from "@/timeline"; import { registerCanceller } from "@/editor/cancel-interaction"; -import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; +import { + ResizeController, + type ResizeConfig, +} from "@/timeline/controllers/resize-controller"; +import type { ResizeSide } from "@/timeline/group-resize"; +import type { SnapPoint } from "@/timeline/snapping"; +import type { TimelineElement } from "@/timeline"; + +export type { ResizeSide }; -interface ResizeInteractionState { - side: ResizeSide; - startX: number; - members: GroupResizeMember[]; +interface UseTimelineResizeProps { + zoomLevel: number; + onSnapPointChange?: (snapPoint: SnapPoint | null) => void; } export function useTimelineResize({ zoomLevel, onSnapPointChange, -}: { - zoomLevel: number; - onSnapPointChange?: (snapPoint: SnapPoint | null) => void; -}) { +}: UseTimelineResizeProps) { const editor = useEditor(); const isShiftHeldRef = useShiftKey(); const snappingEnabled = useTimelineStore((state) => state.snappingEnabled); const { selectedElements } = useElementSelection(); - const [resizeState, setResizeState] = useState( - null, - ); - const latestResultRef = useRef(null); - - const cancelResize = useCallback(() => { - editor.timeline.discardPreview(); - setResizeState(null); - latestResultRef.current = null; - onSnapPointChange?.(null); - }, [editor.timeline, onSnapPointChange]); - useEffect(() => { - if (!resizeState) { - return; - } - - return registerCanceller({ fn: cancelResize }); - }, [resizeState, cancelResize]); - - const handleResizeStart = useCallback( - ({ - event, - element, - track, - side, - }: { - event: React.MouseEvent; - element: TimelineElement; - track: TimelineTrack; - side: ResizeSide; - }) => { - event.stopPropagation(); - event.preventDefault(); - - const elementRef = { - trackId: track.id, - elementId: element.id, - }; - const activeSelection = selectedElements.some( - (selectedElement) => - selectedElement.trackId === track.id && - selectedElement.elementId === element.id, - ) - ? selectedElements - : [elementRef]; - const members = buildResizeMembers({ - tracks: editor.scenes.getActiveScene().tracks, - selectedElements: activeSelection, - }); - if (members.length === 0) { - return; - } - - editor.timeline.discardPreview(); - latestResultRef.current = null; - setResizeState({ - side, - startX: event.clientX, - members, - }); - }, - [selectedElements, editor], - ); - - useEffect(() => { - if (!resizeState) { - return; - } - - const handleMouseMove = ({ clientX }: MouseEvent) => { - const deltaX = clientX - resizeState.startX; - const rawDeltaTime = Math.round( - (deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel)) * - TICKS_PER_SECOND, - ); - const snappedDeltaTime = getSnappedResizeDelta({ - editor, - resizeState, - rawDeltaTime, - zoomLevel, - shouldSnap: snappingEnabled && !isShiftHeldRef.current, - onSnapPointChange, - }); - const fps = editor.project.getActive().settings.fps; - const result = computeGroupResize({ - members: resizeState.members, - side: resizeState.side, - deltaTime: snappedDeltaTime.deltaTime, - fps, - }); - - latestResultRef.current = result; + const config: ResizeConfig = { + zoomLevel, + snappingEnabled, + isShiftHeld: () => isShiftHeldRef.current, + getSceneTracks: () => editor.scenes.getActiveScene().tracks, + getCurrentPlayheadTime: () => editor.playback.getCurrentTime(), + getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null, + selectedElements, + discardPreview: () => editor.timeline.discardPreview(), + previewElements: (updates) => editor.timeline.previewElements({ - updates: result.updates.map(({ trackId, elementId, patch }) => ({ + updates: updates.map(({ trackId, elementId, patch }) => ({ trackId, elementId, updates: patch as Partial, })), - }); - }; - - const handleMouseUp = () => { - const result = latestResultRef.current; - editor.timeline.discardPreview(); - if ( - result && - hasResizeChanges({ members: resizeState.members, result }) - ) { - editor.timeline.updateElements({ - updates: result.updates.map(({ trackId, elementId, patch }) => ({ - trackId, - elementId, - patch: patch as Partial, - })), - }); - } - - setResizeState(null); - latestResultRef.current = null; - onSnapPointChange?.(null); - }; - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - return () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - }; - }, [ - resizeState, - zoomLevel, - snappingEnabled, - isShiftHeldRef, - editor, + }), + commitElements: (updates) => + editor.timeline.updateElements({ + updates: updates.map(({ trackId, elementId, patch }) => ({ + trackId, + elementId, + patch: patch as Partial, + })), + }), onSnapPointChange, - ]); - - return { - isResizing: resizeState !== null, - handleResizeStart, }; -} - -function buildResizeMembers({ - tracks, - selectedElements, -}: { - tracks: SceneTracks; - selectedElements: Array<{ trackId: string; elementId: string }>; -}): GroupResizeMember[] { - const selectedElementIds = new Set( - selectedElements.map((selectedElement) => selectedElement.elementId), - ); - const trackMap = new Map( - [...tracks.overlay, tracks.main, ...tracks.audio].map((track) => [ - track.id, - track, - ]), - ); - - return selectedElements.flatMap(({ trackId, elementId }) => { - const track = trackMap.get(trackId); - const element = track?.elements.find( - (trackElement) => trackElement.id === elementId, - ); - if (!track || !element) { - return []; - } - const otherElements = track.elements.filter( - (trackElement) => !selectedElementIds.has(trackElement.id), - ); - const leftNeighborBound = otherElements - .filter( - (trackElement) => - trackElement.startTime + trackElement.duration <= element.startTime, - ) - .reduce((bound, trackElement) => { - return Math.max(bound, trackElement.startTime + trackElement.duration); - }, -Infinity); - const rightNeighborBound = otherElements - .filter( - (trackElement) => - trackElement.startTime >= element.startTime + element.duration, - ) - .reduce((bound, trackElement) => { - return Math.min(bound, trackElement.startTime); - }, Infinity); + const configRef = useRef(config); + configRef.current = config; - return [ - { - trackId, - elementId, - startTime: element.startTime, - duration: element.duration, - trimStart: element.trimStart, - trimEnd: element.trimEnd, - sourceDuration: element.sourceDuration, - retime: isRetimableElement(element) ? element.retime : undefined, - leftNeighborBound, - rightNeighborBound, - }, - ]; - }); -} - -function getSnappedResizeDelta({ - editor, - resizeState, - rawDeltaTime, - zoomLevel, - shouldSnap, - onSnapPointChange, -}: { - editor: EditorCore; - resizeState: ResizeInteractionState; - rawDeltaTime: number; - zoomLevel: number; - shouldSnap: boolean; - onSnapPointChange?: (snapPoint: SnapPoint | null) => void; -}): { deltaTime: number } { - if (!shouldSnap) { - onSnapPointChange?.(null); - return { deltaTime: rawDeltaTime }; + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new ResizeController({ configRef }); } + const controller = controllerRef.current; - const tracks = editor.scenes.getActiveScene().tracks; - const playheadTime = editor.playback.getCurrentTime(); - const excludeElementIds = new Set( - resizeState.members.map((member) => member.elementId), - ); - const snapPoints = buildTimelineSnapPoints({ - sources: [ - () => getElementEdgeSnapPoints({ tracks, excludeElementIds }), - () => getPlayheadSnapPoints({ playheadTime }), - () => - getAnimationKeyframeSnapPointsForTimeline({ - tracks, - excludeElementIds, - }), - ], - }); - const maxSnapDistance = getTimelineSnapThresholdInTicks({ zoomLevel }); - let closestSnapPoint: SnapPoint | null = null; - let closestSnapDistance = Infinity; - let deltaTime = rawDeltaTime; + const [, rerender] = useReducer((n: number) => n + 1, 0); + useEffect(() => controller.subscribe(rerender), [controller]); - for (const member of resizeState.members) { - const baseEdgeTime = - resizeState.side === "left" - ? member.startTime - : member.startTime + member.duration; - const snapResult = resolveTimelineSnap({ - targetTime: baseEdgeTime + rawDeltaTime, - snapPoints, - maxSnapDistance, - }); - if (snapResult.snapPoint && snapResult.snapDistance < closestSnapDistance) { - closestSnapDistance = snapResult.snapDistance; - closestSnapPoint = snapResult.snapPoint; - deltaTime = snapResult.snappedTime - baseEdgeTime; - } - } + useEffect(() => { + if (!controller.isResizing) return; + return registerCanceller({ fn: () => controller.cancel() }); + }, [controller.isResizing, controller]); - onSnapPointChange?.(closestSnapPoint); - return { deltaTime }; -} + useEffect(() => () => controller.destroy(), [controller]); -function hasResizeChanges({ - members, - result, -}: { - members: GroupResizeMember[]; - result: GroupResizeResult; -}): boolean { - return result.updates.some((update) => { - const member = members.find( - (candidateMember) => candidateMember.elementId === update.elementId, - ); - return ( - member?.trimStart !== update.patch.trimStart || - member?.trimEnd !== update.patch.trimEnd || - member?.startTime !== update.patch.startTime || - member?.duration !== update.patch.duration - ); - }); + return { + isResizing: controller.isResizing, + handleResizeStart: controller.onResizeStart, + }; } diff --git a/apps/web/src/timeline/hooks/use-timeline-seek.ts b/apps/web/src/timeline/hooks/use-timeline-seek.ts index 88a8268d..bf033dae 100644 --- a/apps/web/src/timeline/hooks/use-timeline-seek.ts +++ b/apps/web/src/timeline/hooks/use-timeline-seek.ts @@ -1,9 +1,10 @@ -import { useCallback, useRef } from "react"; -import type { MutableRefObject, RefObject } from "react"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { snappedSeekTime } from "opencut-wasm"; -import { mediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm"; +import { useEffect, useRef, type RefObject } from "react"; import { useEditor } from "@/editor/use-editor"; +import { + SeekController, + type SeekConfig, +} from "@/timeline/controllers/seek-controller"; +import type { MediaTime } from "@/wasm"; interface UseTimelineSeekProps { playheadRef: RefObject; @@ -17,44 +18,6 @@ interface UseTimelineSeekProps { seek: (time: MediaTime) => void; } -function resetMouseTracking({ - mouseTrackingRef, -}: { - mouseTrackingRef: MutableRefObject<{ - isMouseDown: boolean; - downX: number; - downY: number; - downTime: number; - }>; -}) { - mouseTrackingRef.current = { - isMouseDown: false, - downX: 0, - downY: 0, - downTime: 0, - }; -} - -function setMouseTracking({ - mouseTrackingRef, - event, -}: { - mouseTrackingRef: MutableRefObject<{ - isMouseDown: boolean; - downX: number; - downY: number; - downTime: number; - }>; - event: React.MouseEvent; -}) { - mouseTrackingRef.current = { - isMouseDown: true, - downX: event.clientX, - downY: event.clientY, - downTime: event.timeStamp, - }; -} - export function useTimelineSeek({ playheadRef, trackLabelsRef, @@ -67,133 +30,42 @@ export function useTimelineSeek({ seek, }: UseTimelineSeekProps) { const editor = useEditor(); - const activeProject = editor.project.getActive(); - - const mouseTrackingRef = useRef({ - isMouseDown: false, - downX: 0, - downY: 0, - downTime: 0, - }); - - const handleTracksMouseDown = useCallback((event: React.MouseEvent) => { - if (event.button !== 0) return; - setMouseTracking({ mouseTrackingRef, event }); - }, []); - - const handleRulerMouseDown = useCallback((event: React.MouseEvent) => { - if (event.button !== 0) return; - setMouseTracking({ mouseTrackingRef, event }); - }, []); - - const shouldProcessTimelineClick = useCallback( - ({ event }: { event: React.MouseEvent }) => { - const target = event.target as HTMLElement; - const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current; - const deltaX = Math.abs(event.clientX - downX); - const deltaY = Math.abs(event.clientY - downY); - const deltaTime = event.timeStamp - downTime; - const isPlayhead = !!playheadRef.current?.contains(target); - const isTrackLabels = !!trackLabelsRef.current?.contains(target); - const shouldBlockForDrag = deltaX > 5 || deltaY > 5 || deltaTime > 500; - - if (!isMouseDown) return false; - if (shouldBlockForDrag) return false; - if (isSelecting) return false; - if (isPlayhead) return false; - if (isTrackLabels) { - clearSelectedElements(); - return false; - } - - return true; - }, - [isSelecting, clearSelectedElements, playheadRef, trackLabelsRef], - ); - - const handleTimelineSeek = useCallback( - ({ - event, - source, - }: { - event: React.MouseEvent; - source: "ruler" | "tracks"; - }) => { - const scrollContainer = - source === "ruler" ? rulerScrollRef.current : tracksScrollRef.current; - - if (!scrollContainer) return; - - const rect = scrollContainer.getBoundingClientRect(); - const mouseX = event.clientX - rect.left; - const scrollLeft = scrollContainer.scrollLeft; - - const rawTimeSeconds = Math.max( - 0, - Math.min( - duration / TICKS_PER_SECOND, - (mouseX + scrollLeft) / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), - ), - ); - const rawTime = mediaTime({ - ticks: Math.round(rawTimeSeconds * TICKS_PER_SECOND), - }); - - const rate = activeProject?.settings.fps; - const time = rate - ? ((snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime) as MediaTime) - : rawTime; - seek(time); + const config: SeekConfig = { + zoomLevel, + duration, + isSelecting, + getPlayheadEl: () => playheadRef.current, + getTrackLabelsEl: () => trackLabelsRef.current, + getRulerScrollEl: () => rulerScrollRef.current, + getTracksScrollEl: () => tracksScrollRef.current, + getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null, + clearSelectedElements, + seek, + setTimelineViewState: ({ zoomLevel, scrollLeft, playheadTime }) => editor.project.setTimelineViewState({ viewState: { zoomLevel, - scrollLeft: scrollContainer.scrollLeft, - playheadTime: time, + scrollLeft, + playheadTime, }, - }); - }, - [ - duration, - zoomLevel, - rulerScrollRef, - tracksScrollRef, - seek, - editor, - activeProject?.settings.fps.numerator, - activeProject?.settings.fps.denominator, - ], - ); - - const handleTracksClick = useCallback( - (event: React.MouseEvent) => { - const shouldProcess = shouldProcessTimelineClick({ event }); - resetMouseTracking({ mouseTrackingRef }); + }), + }; - if (shouldProcess) { - clearSelectedElements(); - handleTimelineSeek({ event, source: "tracks" }); - } - }, - [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements], - ); + const configRef = useRef(config); + configRef.current = config; - const handleRulerClick = useCallback( - (event: React.MouseEvent) => { - const shouldProcess = shouldProcessTimelineClick({ event }); - resetMouseTracking({ mouseTrackingRef }); + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new SeekController({ configRef }); + } + const controller = controllerRef.current; - if (shouldProcess) { - clearSelectedElements(); - handleTimelineSeek({ event, source: "ruler" }); - } - }, - [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements], - ); + useEffect(() => () => controller.destroy(), [controller]); return { - handleTracksMouseDown, - handleTracksClick, - handleRulerMouseDown, - handleRulerClick, + handleTracksMouseDown: controller.onTracksMouseDown, + handleTracksClick: controller.onTracksClick, + handleRulerMouseDown: controller.onRulerMouseDown, + handleRulerClick: controller.onRulerClick, }; } diff --git a/apps/web/src/timeline/hooks/use-timeline-zoom.ts b/apps/web/src/timeline/hooks/use-timeline-zoom.ts index 0f75face..3020504f 100644 --- a/apps/web/src/timeline/hooks/use-timeline-zoom.ts +++ b/apps/web/src/timeline/hooks/use-timeline-zoom.ts @@ -1,17 +1,17 @@ import { type WheelEvent as ReactWheelEvent, type RefObject, - useCallback, useEffect, useLayoutEffect, + useReducer, useRef, - useState, } from "react"; -import { TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD } from "@/timeline/components/interaction"; -import { TIMELINE_ZOOM_MAX, TIMELINE_ZOOM_MIN } from "@/timeline/scale"; -import { timelineTimeToPixels } from "@/timeline/pixel-utils"; import { useEditor } from "@/editor/use-editor"; -import { zoomToSlider } from "@/timeline/zoom-utils"; +import { TIMELINE_ZOOM_MIN } from "@/timeline/scale"; +import { + ZoomController, + type ZoomConfig, +} from "@/timeline/controllers/zoom-controller"; import type { MediaTime } from "@/wasm"; interface UseTimelineZoomProps { @@ -41,252 +41,59 @@ export function useTimelineZoom({ rulerScrollRef, }: UseTimelineZoomProps): UseTimelineZoomReturn { const editor = useEditor(); - const hasInitializedRef = useRef(false); - const hasRestoredPlayheadRef = useRef(false); - const scrollSaveTimeoutRef = useRef | null>( - null, - ); - - const [zoomLevel, setZoomLevelRaw] = useState(() => { - if (initialZoom !== undefined) { - hasInitializedRef.current = true; - return Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom)); - } - return minZoom; - }); - const previousZoomRef = useRef(zoomLevel); - const hasRestoredScrollRef = useRef(false); - const preZoomScrollLeftRef = useRef(0); - const prePlayheadAnchorScrollLeftRef = useRef(0); - const isInPlayheadAnchorModeRef = useRef(false); - - const setZoomLevel = useCallback( - (updater: number | ((prev: number) => number)) => { - const scrollElement = tracksScrollRef.current; - if (scrollElement) { - preZoomScrollLeftRef.current = scrollElement.scrollLeft; - } - setZoomLevelRaw(updater); - }, - [tracksScrollRef], - ); - - const handleWheel = useCallback( - (event: ReactWheelEvent) => { - const isZoomGesture = event.ctrlKey || event.metaKey; - const isHorizontalScrollGesture = - event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY); + const config: ZoomConfig = { + minZoom, + getContainerEl: () => containerRef.current, + getTracksScrollEl: () => tracksScrollRef.current, + getRulerScrollEl: () => rulerScrollRef.current, + getCurrentPlayheadTime: () => editor.playback.getCurrentTime(), + seek: (time) => editor.playback.seek({ time }), + setTimelineViewState: ({ zoomLevel, scrollLeft, playheadTime }) => + editor.project.setTimelineViewState({ + viewState: { + zoomLevel, + scrollLeft, + playheadTime, + }, + }), + }; + const configRef = useRef(config); + configRef.current = config; - if (isHorizontalScrollGesture) { - return; - } + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new ZoomController({ configRef, initialZoom }); + } + const controller = controllerRef.current; + const zoomLevel = controller.zoomLevel; - // pinch-zoom (ctrl/meta + wheel) - if (isZoomGesture) { - const normalizedDelta = - event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY; - const cappedDelta = - Math.sign(normalizedDelta) * Math.min(Math.abs(normalizedDelta), 30); - const zoomFactor = Math.exp(-cappedDelta / 300); - setZoomLevel((prev) => { - const nextZoom = Math.max( - minZoom, - Math.min(TIMELINE_ZOOM_MAX, prev * zoomFactor), - ); - return nextZoom; - }); - return; - } - }, - [minZoom, setZoomLevel], - ); + const [, rerender] = useReducer((n: number) => n + 1, 0); + useEffect(() => controller.subscribe(rerender), [controller]); useEffect(() => { - if (initialZoom !== undefined && !hasInitializedRef.current) { - hasInitializedRef.current = true; - setZoomLevel(Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom))); - return; - } - setZoomLevel((prev: number) => { - if (prev < minZoom) { - return minZoom; - } - return prev; - }); - }, [minZoom, initialZoom, setZoomLevel]); - - const wrappedSetZoomLevel = useCallback( - (zoomLevelOrUpdater: number | ((prev: number) => number)) => { - setZoomLevel((prev: number) => { - const nextZoom = - typeof zoomLevelOrUpdater === "function" - ? zoomLevelOrUpdater(prev) - : zoomLevelOrUpdater; - const clampedZoom = Math.max( - minZoom, - Math.min(TIMELINE_ZOOM_MAX, nextZoom), - ); - return clampedZoom; - }); - }, - [minZoom, setZoomLevel], - ); + controller.reconcileInitialAndMinZoom(minZoom, initialZoom); + }, [controller, minZoom, initialZoom]); useLayoutEffect(() => { - const previousZoom = previousZoomRef.current; - if (previousZoom === zoomLevel) return; - - const scrollElement = tracksScrollRef.current; - if (!scrollElement) { - previousZoomRef.current = zoomLevel; - return; - } - - const currentScrollLeft = preZoomScrollLeftRef.current; - const playheadTime = editor.playback.getCurrentTime(); - const sliderPercent = zoomToSlider({ zoomLevel, minZoom }); - const previousSliderPercent = zoomToSlider({ - zoomLevel: previousZoom, - minZoom, - }); - const isCrossingThresholdUp = - previousSliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD && - sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD; - const isCrossingThresholdDown = - previousSliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD && - sliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD; - - const syncScroll = (scrollLeft: number) => { - scrollElement.scrollLeft = scrollLeft; - if (rulerScrollRef.current) { - rulerScrollRef.current.scrollLeft = scrollLeft; - } - }; - - const clampScrollLeft = (scrollLeft: number) => { - const maxScrollLeft = - scrollElement.scrollWidth - scrollElement.clientWidth; - return Math.max(0, Math.min(maxScrollLeft, scrollLeft)); - }; - - if (isCrossingThresholdUp) { - prePlayheadAnchorScrollLeftRef.current = currentScrollLeft; - isInPlayheadAnchorModeRef.current = true; - } - - if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) { - const playheadPixelsBefore = timelineTimeToPixels({ - time: playheadTime, - zoomLevel: previousZoom, - }); - const playheadPixelsAfter = timelineTimeToPixels({ - time: playheadTime, - zoomLevel, - }); - - const viewportOffset = playheadPixelsBefore - currentScrollLeft; - const newScrollLeft = playheadPixelsAfter - viewportOffset; - - syncScroll(clampScrollLeft(newScrollLeft)); - } else if (isCrossingThresholdDown && isInPlayheadAnchorModeRef.current) { - syncScroll(clampScrollLeft(prePlayheadAnchorScrollLeftRef.current)); - isInPlayheadAnchorModeRef.current = false; - } - - previousZoomRef.current = zoomLevel; - - editor.project.setTimelineViewState({ - viewState: { - zoomLevel, - scrollLeft: scrollElement.scrollLeft, - playheadTime, - }, - }); - }, [zoomLevel, editor, tracksScrollRef, rulerScrollRef, minZoom]); - - // biome-ignore lint/correctness/useExhaustiveDependencies: tracksScrollRef is a stable ref - const saveScrollPosition = useCallback(() => { - if (scrollSaveTimeoutRef.current) { - clearTimeout(scrollSaveTimeoutRef.current); - } - scrollSaveTimeoutRef.current = setTimeout(() => { - const scrollElement = tracksScrollRef.current; - if (scrollElement) { - editor.project.setTimelineViewState({ - viewState: { - zoomLevel, - scrollLeft: scrollElement.scrollLeft, - playheadTime: editor.playback.getCurrentTime(), - }, - }); - } - }, 300); - }, [zoomLevel, editor]); - - // biome-ignore lint/correctness/useExhaustiveDependencies: refs are stable - useEffect(() => { - if (initialScrollLeft === undefined) return; - if (hasRestoredScrollRef.current) return; - const scrollElement = tracksScrollRef.current; - if (!scrollElement) return; - - const restoreScroll = () => { - scrollElement.scrollLeft = initialScrollLeft; - if (rulerScrollRef.current) { - rulerScrollRef.current.scrollLeft = initialScrollLeft; - } - hasRestoredScrollRef.current = true; - }; - - if (scrollElement.scrollWidth > 0) { - restoreScroll(); - } else { - const observer = new ResizeObserver(() => { - if (scrollElement.scrollWidth > 0) { - restoreScroll(); - observer.disconnect(); - } - }); - observer.observe(scrollElement); - return () => observer.disconnect(); - } - }, [initialScrollLeft]); + controller.applyZoomLayout(zoomLevel); + }, [controller, zoomLevel]); useEffect(() => { - if (initialPlayheadTime !== undefined && !hasRestoredPlayheadRef.current) { - hasRestoredPlayheadRef.current = true; - editor.playback.seek({ time: initialPlayheadTime }); - } - }, [initialPlayheadTime, editor]); + return controller.restoreInitialScrollIfNeeded(initialScrollLeft); + }, [controller, initialScrollLeft]); - // prevent browser zoom in the timeline useEffect(() => { - const preventZoom = (event: WheelEvent) => { - const isZoomKeyPressed = event.ctrlKey || event.metaKey; - const isInContainer = containerRef.current?.contains( - event.target as Node, - ); - // only check isInContainer, not isInTimeline state - the state check - // causes race conditions where the closure captures stale state - if (isZoomKeyPressed && isInContainer) { - event.preventDefault(); - } - }; + controller.restoreInitialPlayheadIfNeeded(initialPlayheadTime); + }, [controller, initialPlayheadTime]); - document.addEventListener("wheel", preventZoom, { - passive: false, - capture: true, - }); + useEffect(() => controller.bindPreventBrowserZoom(), [controller]); - return () => { - document.removeEventListener("wheel", preventZoom, { capture: true }); - }; - }, [containerRef]); + useEffect(() => () => controller.destroy(), [controller]); return { zoomLevel, - setZoomLevel: wrappedSetZoomLevel, - handleWheel, - saveScrollPosition, + setZoomLevel: controller.setZoomLevel, + handleWheel: controller.handleWheel, + saveScrollPosition: controller.saveScrollPosition, }; } -- cgit v1.3.1