From 3e89d2998588f8e35c524e523ba2ee853f0f6475 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 20 Apr 2026 11:31:17 +0200 Subject: refactor: restructure files to their domains, new preview overlay system, and dep graph --- .../hooks/element/use-element-interaction.ts | 760 +++++++++++++++++++++ .../hooks/element/use-element-selection.ts | 137 ++++ .../hooks/element/use-keyframe-box-select.ts | 217 ++++++ .../timeline/hooks/element/use-keyframe-drag.ts | 328 +++++++++ .../hooks/element/use-keyframe-selection.ts | 229 +++++++ .../web/src/timeline/hooks/use-edge-auto-scroll.ts | 97 +++ apps/web/src/timeline/hooks/use-element-preview.ts | 39 ++ .../timeline/hooks/use-initial-scroll-bottom.ts | 42 ++ apps/web/src/timeline/hooks/use-scroll-position.ts | 52 ++ apps/web/src/timeline/hooks/use-scroll-sync.ts | 173 +++++ .../timeline/hooks/use-snap-indicator-position.ts | 55 ++ .../src/timeline/hooks/use-timeline-drag-drop.ts | 648 ++++++++++++++++++ .../src/timeline/hooks/use-timeline-playhead.ts | 301 ++++++++ apps/web/src/timeline/hooks/use-timeline-resize.ts | 332 +++++++++ apps/web/src/timeline/hooks/use-timeline-seek.ts | 197 ++++++ apps/web/src/timeline/hooks/use-timeline-zoom.ts | 291 ++++++++ 16 files changed, 3898 insertions(+) create mode 100644 apps/web/src/timeline/hooks/element/use-element-interaction.ts create mode 100644 apps/web/src/timeline/hooks/element/use-element-selection.ts create mode 100644 apps/web/src/timeline/hooks/element/use-keyframe-box-select.ts create mode 100644 apps/web/src/timeline/hooks/element/use-keyframe-drag.ts create mode 100644 apps/web/src/timeline/hooks/element/use-keyframe-selection.ts create mode 100644 apps/web/src/timeline/hooks/use-edge-auto-scroll.ts create mode 100644 apps/web/src/timeline/hooks/use-element-preview.ts create mode 100644 apps/web/src/timeline/hooks/use-initial-scroll-bottom.ts create mode 100644 apps/web/src/timeline/hooks/use-scroll-position.ts create mode 100644 apps/web/src/timeline/hooks/use-scroll-sync.ts create mode 100644 apps/web/src/timeline/hooks/use-snap-indicator-position.ts create mode 100644 apps/web/src/timeline/hooks/use-timeline-drag-drop.ts create mode 100644 apps/web/src/timeline/hooks/use-timeline-playhead.ts create mode 100644 apps/web/src/timeline/hooks/use-timeline-resize.ts create mode 100644 apps/web/src/timeline/hooks/use-timeline-seek.ts create mode 100644 apps/web/src/timeline/hooks/use-timeline-zoom.ts (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 new file mode 100644 index 00000000..621259b9 --- /dev/null +++ b/apps/web/src/timeline/hooks/element/use-element-interaction.ts @@ -0,0 +1,760 @@ +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 { TICKS_PER_SECOND } from "@/wasm"; +import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; +import { roundToFrame } from "opencut-wasm"; +import { computeDropTarget } from "@/timeline/components/drop-target"; +import { getMouseTimeFromClientX } from "@/timeline/drag-utils"; +import { generateUUID } from "@/utils/id"; +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: 0, + clickOffsetTime: 0, + currentTime: 0, + currentMouseY: 0, +}; + +interface PendingDragState { + elementId: string; + trackId: string; + selectedElements: ElementRef[]; + startMouseX: number; + startMouseY: number; + startElementTime: number; + clickOffsetTime: number; +} + +function getClickOffsetTime({ + clientX, + elementRect, + zoomLevel, +}: { + clientX: number; + elementRect: DOMRect; + zoomLevel: number; +}): number { + const clickOffsetX = clientX - elementRect.left; + const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel); + return Math.round(seconds * TICKS_PER_SECOND); +} + +function getVerticalDragDirection({ + startMouseY, + currentMouseY, +}: { + startMouseY: number; + currentMouseY: number; +}): "up" | "down" | null { + if (currentMouseY < startMouseY) return "up"; + if (currentMouseY > startMouseY) return "down"; + return null; +} + +function getDragDropTarget({ + clientX, + clientY, + elementId, + trackId, + tracks, + tracksContainerRef, + tracksScrollRef, + headerRef, + zoomLevel, + snappedTime, + verticalDragDirection, +}: { + clientX: number; + clientY: number; + elementId: string; + trackId: string; + tracks: SceneTracks; + tracksContainerRef: RefObject; + tracksScrollRef: RefObject; + headerRef?: RefObject; + zoomLevel: number; + snappedTime: number; + verticalDragDirection?: "up" | "down" | null; +}): DropTarget | null { + const containerRect = tracksContainerRef.current?.getBoundingClientRect(); + const scrollContainer = tracksScrollRef.current; + if (!containerRect || !scrollContainer) return null; + + const sourceTrack = [...tracks.overlay, tracks.main, ...tracks.audio].find( + ({ id }) => id === trackId, + ); + const movingElement = sourceTrack?.elements.find( + ({ id }) => id === elementId, + ); + if (!movingElement) return null; + + const elementDuration = movingElement.duration; + const scrollLeft = scrollContainer.scrollLeft; + const scrollTop = scrollContainer.scrollTop; + const scrollContainerRect = scrollContainer.getBoundingClientRect(); + const headerHeight = headerRef?.current?.getBoundingClientRect().height ?? 0; + const mouseX = clientX - scrollContainerRect.left + scrollLeft; + const mouseY = clientY - scrollContainerRect.top + scrollTop - headerHeight; + + return computeDropTarget({ + elementType: movingElement.type, + mouseX, + mouseY, + tracks, + playheadTime: snappedTime, + isExternalDrop: false, + elementDuration, + pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, + zoomLevel, + startTimeOverride: snappedTime, + excludeElementId: movingElement.id, + verticalDragDirection, + }); +} + +interface StartDragParams + extends Omit< + ElementDragState, + "isDragging" | "currentTime" | "currentMouseY" + > { + initialCurrentTime: number; + initialCurrentMouseY: number; +} + +export function useElementInteraction({ + zoomLevel, + timelineRef, + tracksContainerRef, + tracksScrollRef, + headerRef, + 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: number; + dropTarget: DropTarget | null; + }): GroupMoveResult | null => { + if (!dropTarget) { + return null; + } + + if (dropTarget.isNewTrack) { + return resolveGroupMove({ + group, + tracks: sceneTracks, + anchorStartTime: snappedTime, + target: { + kind: "newTracks", + anchorInsertIndex: dropTarget.trackIndex, + newTrackIds: newTrackIdsRef.current, + }, + }); + } + + const orderedTracks = [ + ...sceneTracks.overlay, + sceneTracks.main, + ...sceneTracks.audio, + ]; + const targetTrack = orderedTracks[dropTarget.trackIndex]; + if (!targetTrack) { + return null; + } + + const existingTrackResult = resolveGroupMove({ + group, + tracks: sceneTracks, + anchorStartTime: snappedTime, + target: { + kind: "existingTrack", + anchorTargetTrackId: targetTrack.id, + }, + }); + if (existingTrackResult) { + return existingTrackResult; + } + + return resolveGroupMove({ + group, + tracks: sceneTracks, + anchorStartTime: snappedTime, + target: { + kind: "newTracks", + anchorInsertIndex: dropTarget.trackIndex, + newTrackIds: newTrackIdsRef.current, + }, + }); + }, + [sceneTracks], + ); + + useEffect(() => { + if (!dragState.isDragging && !isPendingDrag) return; + + return registerCanceller({ fn: cancelCurrentDrag }); + }, [dragState.isDragging, isPendingDrag, cancelCurrentDrag]); + + const getDragSnapResult = useCallback( + ({ + frameSnappedTime, + group, + }: { + frameSnappedTime: number; + group: MoveGroup | null; + }) => { + if (!group || !snappingEnabled || isShiftHeldRef.current) { + return { snappedTime: frameSnappedTime, snapPoint: null }; + } + + const groupSnap = snapGroupEdges({ + group, + anchorStartTime: frameSnappedTime, + tracks: sceneTracks, + playheadTime: editor.playback.getCurrentTime(), + zoomLevel, + }); + + return { + snappedTime: groupSnap.snappedAnchorStartTime, + snapPoint: groupSnap.snapPoint, + }; + }, + [snappingEnabled, editor.playback, sceneTracks, zoomLevel, isShiftHeldRef], + ); + + useEffect(() => { + if (!dragState.isDragging && !isPendingDrag) return; + + const handleMouseMove = ({ clientX, clientY }: MouseEvent) => { + let startedDragThisEvent = false; + const timeline = timelineRef.current; + const scrollContainer = tracksScrollRef.current; + if (!timeline || !scrollContainer) return; + lastMouseXRef.current = clientX; + + if (isPendingDrag && pendingDragRef.current) { + const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX); + const deltaY = Math.abs(clientY - pendingDragRef.current.startMouseY); + if ( + deltaX > TIMELINE_DRAG_THRESHOLD_PX || + deltaY > TIMELINE_DRAG_THRESHOLD_PX + ) { + const activeProject = editor.project.getActive(); + if (!activeProject) return; + const scrollLeft = scrollContainer.scrollLeft; + const mouseTime = getMouseTimeFromClientX({ + clientX, + containerRect: scrollContainer.getBoundingClientRect(), + zoomLevel, + scrollLeft, + }); + const adjustedTime = Math.max( + 0, + mouseTime - pendingDragRef.current.clickOffsetTime, + ); + const snappedTime = + roundToFrame({ + time: adjustedTime, + rate: activeProject.settings.fps, + }) ?? adjustedTime; + const moveGroup = buildMoveGroup({ + anchorRef: { + trackId: pendingDragRef.current.trackId, + elementId: pendingDragRef.current.elementId, + }, + selectedElements: pendingDragRef.current.selectedElements, + tracks: sceneTracks, + }); + if (!moveGroup) { + return; + } + + moveGroupRef.current = moveGroup; + newTrackIdsRef.current = moveGroup.members.map(() => generateUUID()); + const dragTimeOffsets: Record = {}; + for (const member of moveGroup.members) { + dragTimeOffsets[member.elementId] = member.timeOffset; + } + const { + snappedTime: initialSnappedTime, + snapPoint: initialSnapPoint, + } = getDragSnapResult({ + frameSnappedTime: snappedTime, + group: moveGroup, + }); + const verticalDragDirection = getVerticalDragDirection({ + startMouseY: pendingDragRef.current.startMouseY, + currentMouseY: clientY, + }); + const anchorDropTarget = getDragDropTarget({ + clientX, + clientY, + elementId: pendingDragRef.current.elementId, + trackId: pendingDragRef.current.trackId, + tracks: sceneTracks, + tracksContainerRef, + tracksScrollRef, + headerRef, + zoomLevel, + snappedTime: initialSnappedTime, + verticalDragDirection, + }); + const nextGroupMoveResult = + anchorDropTarget != null + ? resolveGroupDragMove({ + group: moveGroup, + snappedTime: initialSnappedTime, + dropTarget: anchorDropTarget, + }) + : null; + groupMoveResultRef.current = nextGroupMoveResult; + setDragDropTarget( + anchorDropTarget && + (anchorDropTarget.isNewTrack || !nextGroupMoveResult) + ? { + ...anchorDropTarget, + isNewTrack: true, + } + : null, + ); + startDrag({ + ...pendingDragRef.current, + dragElementIds: moveGroup.members.map((member) => member.elementId), + dragTimeOffsets, + initialCurrentTime: initialSnappedTime, + initialCurrentMouseY: clientY, + }); + onSnapPointChange?.(initialSnapPoint); + startedDragThisEvent = true; + pendingDragRef.current = null; + setIsPendingDrag(false); + } else { + return; + } + } + + if (startedDragThisEvent) { + return; + } + + if (dragState.elementId && dragState.trackId) { + const alreadySelected = isElementSelected({ + trackId: dragState.trackId, + elementId: dragState.elementId, + }); + if (!alreadySelected) { + selectElement({ + trackId: dragState.trackId, + elementId: dragState.elementId, + }); + } + } + + const activeProject = editor.project.getActive(); + if (!activeProject) return; + + const scrollLeft = scrollContainer.scrollLeft; + const mouseTime = getMouseTimeFromClientX({ + clientX, + containerRect: scrollContainer.getBoundingClientRect(), + zoomLevel, + scrollLeft, + }); + const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); + const fps = activeProject.settings.fps; + const frameSnappedTime = + roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime; + + const moveGroup = moveGroupRef.current; + const { snappedTime, snapPoint } = getDragSnapResult({ + frameSnappedTime, + group: moveGroup, + }); + setDragState((previousDragState) => ({ + ...previousDragState, + currentTime: snappedTime, + currentMouseY: clientY, + })); + onSnapPointChange?.(snapPoint); + + if (dragState.elementId && dragState.trackId) { + const verticalDragDirection = getVerticalDragDirection({ + startMouseY: dragState.startMouseY, + currentMouseY: clientY, + }); + const anchorDropTarget = getDragDropTarget({ + clientX, + clientY, + elementId: dragState.elementId, + trackId: dragState.trackId, + tracks: sceneTracks, + tracksContainerRef, + tracksScrollRef, + headerRef, + zoomLevel, + snappedTime, + verticalDragDirection, + }); + const nextGroupMoveResult = + moveGroup && anchorDropTarget + ? resolveGroupDragMove({ + group: moveGroup, + snappedTime, + dropTarget: anchorDropTarget, + }) + : null; + groupMoveResultRef.current = nextGroupMoveResult; + setDragDropTarget( + anchorDropTarget && + (anchorDropTarget.isNewTrack || !nextGroupMoveResult) + ? { + ...anchorDropTarget, + isNewTrack: true, + } + : null, + ); + } + }; + + document.addEventListener("mousemove", handleMouseMove); + return () => document.removeEventListener("mousemove", handleMouseMove); + }, [ + dragState.isDragging, + dragState.clickOffsetTime, + dragState.elementId, + dragState.startMouseY, + dragState.trackId, + zoomLevel, + isElementSelected, + selectElement, + editor.project, + timelineRef, + tracksScrollRef, + tracksContainerRef, + headerRef, + isPendingDrag, + startDrag, + getDragSnapResult, + resolveGroupDragMove, + sceneTracks, + onSnapPointChange, + ]); + + useEffect(() => { + if (!dragState.isDragging) return; + + const handleMouseUp = ({ clientX, clientY }: MouseEvent) => { + if (!dragState.elementId || !dragState.trackId) return; + + if (mouseDownLocationRef.current) { + const deltaX = Math.abs(clientX - mouseDownLocationRef.current.x); + const deltaY = Math.abs(clientY - mouseDownLocationRef.current.y); + if ( + deltaX <= TIMELINE_DRAG_THRESHOLD_PX && + deltaY <= TIMELINE_DRAG_THRESHOLD_PX + ) { + mouseDownLocationRef.current = null; + endDrag(); + onSnapPointChange?.(null); + return; + } + } + + const moveGroup = moveGroupRef.current; + if (!moveGroup) { + endDrag(); + onSnapPointChange?.(null); + return; + } + + const groupMoveResult = groupMoveResultRef.current; + if (!groupMoveResult) { + endDrag(); + onSnapPointChange?.(null); + return; + } + + const didMove = groupMoveResult.moves.some((move) => { + const currentMember = moveGroup.members.find( + (member) => member.elementId === move.elementId, + ); + const originalStartTime = + dragState.startElementTime + (currentMember?.timeOffset ?? 0); + return ( + currentMember?.trackId !== move.targetTrackId || + originalStartTime !== move.newStartTime + ); + }); + if (!didMove && groupMoveResult.createTracks.length === 0) { + endDrag(); + onSnapPointChange?.(null); + return; + } + + editor.timeline.moveElements({ + moves: groupMoveResult.moves, + createTracks: groupMoveResult.createTracks, + }); + endDrag(); + onSnapPointChange?.(null); + }; + + document.addEventListener("mouseup", handleMouseUp); + return () => document.removeEventListener("mouseup", handleMouseUp); + }, [ + dragState.isDragging, + dragState.elementId, + dragState.startElementTime, + dragState.trackId, + endDrag, + onSnapPointChange, + editor.timeline, + ]); + + useEffect(() => { + if (!isPendingDrag) return; + + const handleMouseUp = () => { + pendingDragRef.current = null; + setIsPendingDrag(false); + onSnapPointChange?.(null); + }; + + document.addEventListener("mouseup", handleMouseUp); + return () => document.removeEventListener("mouseup", handleMouseUp); + }, [isPendingDrag, onSnapPointChange]); + + const handleElementMouseDown = useCallback( + ({ + event, + element, + track, + }: { + event: ReactMouseEvent; + element: TimelineElement; + track: TimelineTrack; + }) => { + const isRightClick = event.button === MOUSE_BUTTON_RIGHT; + + // right-click: don't stop propagation so ContextMenu can open + if (isRightClick) { + const alreadySelected = isElementSelected({ + trackId: track.id, + elementId: element.id, + }); + if (!alreadySelected) { + handleSelectionClick({ + trackId: track.id, + elementId: element.id, + isMultiKey: false, + }); + } + return; + } + + event.stopPropagation(); + mouseDownLocationRef.current = { x: event.clientX, y: event.clientY }; + + const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey; + + if (isMultiSelect) { + handleSelectionClick({ + trackId: track.id, + elementId: element.id, + isMultiKey: true, + }); + } + + const clickOffsetTime = getClickOffsetTime({ + clientX: event.clientX, + elementRect: event.currentTarget.getBoundingClientRect(), + zoomLevel, + }); + const elementRef = { + trackId: track.id, + elementId: element.id, + }; + const pendingSelectedElements = isElementSelected(elementRef) + ? selectedElements + : [elementRef]; + pendingDragRef.current = { + elementId: element.id, + trackId: track.id, + selectedElements: pendingSelectedElements, + startMouseX: event.clientX, + startMouseY: event.clientY, + startElementTime: element.startTime, + clickOffsetTime, + }; + setIsPendingDrag(true); + }, + [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, + }; +} diff --git a/apps/web/src/timeline/hooks/element/use-element-selection.ts b/apps/web/src/timeline/hooks/element/use-element-selection.ts new file mode 100644 index 00000000..19037e10 --- /dev/null +++ b/apps/web/src/timeline/hooks/element/use-element-selection.ts @@ -0,0 +1,137 @@ +import { useCallback } from "react"; +import { useEditor } from "@/editor/use-editor"; +import type { ElementRef } from "@/timeline/types"; + +export function useElementSelection() { + const editor = useEditor(); + const selectedElements = useEditor((e) => e.selection.getSelectedElements()); + + const isElementSelected = useCallback( + ({ trackId, elementId }: ElementRef) => + selectedElements.some( + (element) => + element.trackId === trackId && element.elementId === elementId, + ), + [selectedElements], + ); + + const selectElement = useCallback( + ({ trackId, elementId }: ElementRef) => { + editor.selection.setSelectedElements({ + elements: [{ trackId, elementId }], + }); + }, + [editor], + ); + + const addElementToSelection = useCallback( + ({ trackId, elementId }: ElementRef) => { + const alreadySelected = selectedElements.some( + (element) => + element.trackId === trackId && element.elementId === elementId, + ); + if (alreadySelected) return; + + editor.selection.setSelectedElements({ + elements: [...selectedElements, { trackId, elementId }], + }); + }, + [selectedElements, editor], + ); + + const removeElementFromSelection = useCallback( + ({ trackId, elementId }: ElementRef) => { + editor.selection.setSelectedElements({ + elements: selectedElements.filter( + (element) => + !(element.trackId === trackId && element.elementId === elementId), + ), + }); + }, + [selectedElements, editor], + ); + + const toggleElementSelection = useCallback( + ({ trackId, elementId }: ElementRef) => { + const alreadySelected = selectedElements.some( + (element) => + element.trackId === trackId && element.elementId === elementId, + ); + + if (alreadySelected) { + removeElementFromSelection({ trackId, elementId }); + } else { + addElementToSelection({ trackId, elementId }); + } + }, + [selectedElements, addElementToSelection, removeElementFromSelection], + ); + + const clearElementSelection = useCallback(() => { + editor.selection.clearSelection(); + }, [editor]); + + const setElementSelection = useCallback( + ({ elements }: { elements: ElementRef[] }) => { + editor.selection.setSelectedElements({ elements }); + }, + [editor], + ); + + + /** + * Merges elements into the current selection, deduplicating by identity. + * Used for additive box-select where the pre-drag selection is preserved. + */ + const mergeElementsIntoSelection = useCallback( + ({ elements }: { elements: ElementRef[] }) => { + const merged = [ + ...selectedElements.filter( + (selectedElement) => + !elements.some( + (element) => + element.trackId === selectedElement.trackId && + element.elementId === selectedElement.elementId, + ), + ), + ...elements, + ]; + editor.selection.setSelectedElements({ elements: merged }); + }, + [selectedElements, editor], + ); + + + /** + * Handles click interaction on an element. + * - Regular click: select only this element + * - Multi-key click (Ctrl/Cmd): toggle this element in selection + */ + const handleElementClick = useCallback( + ({ + trackId, + elementId, + isMultiKey, + }: ElementRef & { isMultiKey: boolean }) => { + if (isMultiKey) { + toggleElementSelection({ trackId, elementId }); + } else { + selectElement({ trackId, elementId }); + } + }, + [toggleElementSelection, selectElement], + ); + + return { + selectedElements, + isElementSelected, + selectElement, + setElementSelection, + mergeElementsIntoSelection, + addElementToSelection, + removeElementFromSelection, + toggleElementSelection, + clearElementSelection, + handleElementClick, + }; +} diff --git a/apps/web/src/timeline/hooks/element/use-keyframe-box-select.ts b/apps/web/src/timeline/hooks/element/use-keyframe-box-select.ts new file mode 100644 index 00000000..e471e2d4 --- /dev/null +++ b/apps/web/src/timeline/hooks/element/use-keyframe-box-select.ts @@ -0,0 +1,217 @@ +import { useCallback, useRef, useMemo } from "react"; +import { useBoxSelect } from "@/selection/hooks/use-box-select"; +import { + useKeyframeSelection, + getSelectedKeyframeId, +} from "./use-keyframe-selection"; +import type { SelectedKeyframeRef, ElementKeyframe } from "@/animation/types"; +import type { ExpandedRow } from "@/timeline/components/expanded-layout"; +import { timelineTimeToSnappedPixels } from "@/timeline"; +import { + KEYFRAME_LANE_HEIGHT_PX, + KEYFRAME_DIAMOND_SIZE_PX, +} from "@/timeline/components/layout"; + +export function useKeyframeBoxSelect({ + trackId, + elementId, + rows, + keyframes, + displayedStartTime, + zoomLevel, + elementLeft, +}: { + trackId: string; + elementId: string; + rows: ExpandedRow[]; + keyframes: ElementKeyframe[]; + displayedStartTime: number; + zoomLevel: number; + elementLeft: number; +}) { + const { + selectedKeyframes, + keyframeSelectionAnchor, + setKeyframeSelection, + clearKeyframeSelection, + } = useKeyframeSelection(); + + const containerRef = useRef(null); + const initialKeyframesRef = useRef([]); + + const keyframeEntries = useMemo(() => { + const entries: Array<{ + id: string; + ref: SelectedKeyframeRef; + rowIndex: number; + offsetPx: number; + }> = []; + + for (const kf of keyframes) { + const rowIndex = rows.findIndex( + (r) => r.propertyPath === kf.propertyPath, + ); + if (rowIndex === -1) continue; + + const ref: SelectedKeyframeRef = { + trackId, + elementId, + propertyPath: kf.propertyPath, + keyframeId: kf.id, + }; + + const kfLeft = timelineTimeToSnappedPixels({ + time: displayedStartTime + kf.time, + zoomLevel, + }); + + entries.push({ + id: getSelectedKeyframeId({ keyframe: ref }), + ref, + rowIndex, + offsetPx: kfLeft - elementLeft, + }); + } + + return entries; + }, [ + keyframes, + rows, + trackId, + elementId, + displayedStartTime, + zoomLevel, + elementLeft, + ]); + + const idToRefMap = useMemo(() => { + const map = new Map(); + for (const entry of keyframeEntries) { + map.set(entry.id, entry.ref); + } + return map; + }, [keyframeEntries]); + + const selectedIds = useMemo( + () => + selectedKeyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })), + [selectedKeyframes], + ); + + const anchorId = useMemo( + () => + keyframeSelectionAnchor + ? getSelectedKeyframeId({ keyframe: keyframeSelectionAnchor }) + : null, + [keyframeSelectionAnchor], + ); + + const resolveIntersections = useCallback( + ({ + startPos, + currentPos, + }: { + startPos: { x: number; y: number }; + currentPos: { x: number; y: number }; + }) => { + const container = containerRef.current; + if (!container) return []; + + const containerRect = container.getBoundingClientRect(); + + const sx = startPos.x - containerRect.left; + const sy = startPos.y - containerRect.top; + const cx = currentPos.x - containerRect.left; + const cy = currentPos.y - containerRect.top; + + const selLeft = Math.min(sx, cx); + const selTop = Math.min(sy, cy); + const selRight = Math.max(sx, cx); + const selBottom = Math.max(sy, cy); + + const halfHit = KEYFRAME_DIAMOND_SIZE_PX / 2; + + return keyframeEntries + .filter((entry) => { + const kfX = entry.offsetPx; + const kfY = + entry.rowIndex * KEYFRAME_LANE_HEIGHT_PX + + KEYFRAME_LANE_HEIGHT_PX / 2; + + return !( + kfX + halfHit < selLeft || + kfX - halfHit > selRight || + kfY + halfHit < selTop || + kfY - halfHit > selBottom + ); + }) + .map((entry) => entry.id); + }, + [keyframeEntries], + ); + + const onSelectionChange = useCallback( + ({ + intersectedIds, + isAdditive, + }: { + intersectedIds: string[]; + initialSelectedIds: string[]; + initialAnchorId: string | null; + isAdditive: boolean; + }) => { + const intersectedRefs = intersectedIds + .map((id) => idToRefMap.get(id)) + .filter((ref): ref is SelectedKeyframeRef => ref != null); + + if (isAdditive) { + setKeyframeSelection({ + keyframes: [...initialKeyframesRef.current, ...intersectedRefs], + }); + } else { + setKeyframeSelection({ keyframes: intersectedRefs }); + } + }, + [idToRefMap, setKeyframeSelection], + ); + + const { + selectionBox, + handleMouseDown: boxSelectMouseDown, + isSelecting, + shouldIgnoreClick, + } = useBoxSelect({ + containerRef, + resolveIntersections, + selectedIds, + anchorId, + onSelectionChange, + }); + + const handleExpandedAreaMouseDown = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + initialKeyframesRef.current = selectedKeyframes; + boxSelectMouseDown(event); + }, + [boxSelectMouseDown, selectedKeyframes], + ); + + const handleExpandedAreaClick = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + if (shouldIgnoreClick()) return; + if (event.metaKey || event.ctrlKey || event.shiftKey) return; + clearKeyframeSelection(); + }, + [shouldIgnoreClick, clearKeyframeSelection], + ); + + return { + containerRef, + selectionBox, + isBoxSelecting: isSelecting, + handleExpandedAreaMouseDown, + handleExpandedAreaClick, + }; +} diff --git a/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts new file mode 100644 index 00000000..92b6f0d8 --- /dev/null +++ b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts @@ -0,0 +1,328 @@ +import { + useState, + useCallback, + useEffect, + useRef, + type MouseEvent as ReactMouseEvent, +} from "react"; +import { useEditor } from "@/editor/use-editor"; +import { getKeyframeById } from "@/animation"; +import { useKeyframeSelection } from "./use-keyframe-selection"; +import { roundToFrame, snappedSeekTime } from "opencut-wasm"; +import { timelineTimeToSnappedPixels } from "@/timeline"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { TICKS_PER_SECOND } from "@/wasm"; +import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; +import { RetimeKeyframeCommand } from "@/commands/timeline/element/keyframes/retime-keyframe"; +import { BatchCommand } from "@/commands"; +import type { SelectedKeyframeRef } from "@/animation/types"; +import type { TimelineElement } from "@/timeline"; +import type { Command } from "@/commands/base-command"; +import { registerCanceller } from "@/editor/cancel-interaction"; +export interface KeyframeDragState { + isDragging: boolean; + draggingKeyframeIds: Set; + deltaTime: number; +} + +const initialDragState: KeyframeDragState = { + isDragging: false, + draggingKeyframeIds: new Set(), + deltaTime: 0, +}; + +interface PendingKeyframeDrag { + keyframeRefs: SelectedKeyframeRef[]; + startMouseX: number; +} + +export function useKeyframeDrag({ + zoomLevel, + element, + displayedStartTime, +}: { + zoomLevel: number; + element: TimelineElement; + displayedStartTime: number; +}) { + const editor = useEditor(); + const { + selectedKeyframes, + isKeyframeSelected, + setKeyframeSelection, + toggleKeyframeSelection, + selectKeyframeRange, + } = useKeyframeSelection(); + + const [dragState, setDragState] = + useState(initialDragState); + const [isPendingDrag, setIsPendingDrag] = useState(false); + + const pendingDragRef = useRef(null); + const mouseDownXRef = useRef(null); + + const activeProject = editor.project.getActive(); + const fps = activeProject.settings.fps; + + const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel; + + const endDrag = useCallback(() => { + setDragState(initialDragState); + }, []); + + const cancelDrag = useCallback(() => { + pendingDragRef.current = null; + mouseDownXRef.current = null; + setIsPendingDrag(false); + endDrag(); + }, [endDrag]); + + const commitDrag = useCallback( + ({ + keyframeRefs, + deltaTime, + }: { + keyframeRefs: SelectedKeyframeRef[]; + deltaTime: number; + }) => { + const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => { + const keyframe = getKeyframeById({ + animations: element.animations, + propertyPath: keyframeRef.propertyPath, + keyframeId: keyframeRef.keyframeId, + }); + if (!keyframe) return []; + const nextTime = Math.max( + 0, + Math.min(element.duration, keyframe.time + deltaTime), + ); + return [ + new RetimeKeyframeCommand({ + trackId: keyframeRef.trackId, + elementId: keyframeRef.elementId, + propertyPath: keyframeRef.propertyPath, + keyframeId: keyframeRef.keyframeId, + nextTime, + }), + ]; + }); + + if (commands.length === 1) { + editor.command.execute({ command: commands[0] }); + } else if (commands.length > 1) { + editor.command.execute({ command: new BatchCommand(commands) }); + } + }, + [editor.command, element], + ); + + useEffect(() => { + if (!dragState.isDragging && !isPendingDrag) return; + + return registerCanceller({ fn: cancelDrag }); + }, [dragState.isDragging, isPendingDrag, cancelDrag]); + + useEffect(() => { + if (!dragState.isDragging && !isPendingDrag) return; + + const handleMouseMove = ({ clientX }: MouseEvent) => { + if (isPendingDrag && pendingDragRef.current) { + const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX); + if (deltaX <= TIMELINE_DRAG_THRESHOLD_PX) return; + + const pending = pendingDragRef.current; + pendingDragRef.current = null; + setIsPendingDrag(false); + setDragState({ + isDragging: true, + draggingKeyframeIds: new Set( + pending.keyframeRefs.map((keyframe) => keyframe.keyframeId), + ), + deltaTime: 0, + }); + return; + } + + if (!dragState.isDragging) return; + + const startX = mouseDownXRef.current ?? clientX; + const rawDelta = Math.round( + ((clientX - startX) / pixelsPerSecond) * TICKS_PER_SECOND, + ); + const snappedDelta = + roundToFrame({ time: rawDelta, rate: fps }) ?? rawDelta; + + setDragState((previous) => ({ ...previous, deltaTime: snappedDelta })); + }; + + document.addEventListener("mousemove", handleMouseMove); + return () => document.removeEventListener("mousemove", handleMouseMove); + }, [dragState.isDragging, isPendingDrag, pixelsPerSecond, fps]); + + useEffect(() => { + if (!dragState.isDragging) return; + + const handleMouseUp = () => { + const draggingRefs = selectedKeyframes.filter( + (keyframe) => + keyframe.elementId === element.id && + dragState.draggingKeyframeIds.has(keyframe.keyframeId), + ); + + if (draggingRefs.length > 0 && dragState.deltaTime !== 0) { + commitDrag({ + keyframeRefs: draggingRefs, + deltaTime: dragState.deltaTime, + }); + } + + endDrag(); + }; + + document.addEventListener("mouseup", handleMouseUp); + return () => document.removeEventListener("mouseup", handleMouseUp); + }, [ + dragState.isDragging, + dragState.draggingKeyframeIds, + dragState.deltaTime, + selectedKeyframes, + element.id, + commitDrag, + endDrag, + ]); + + useEffect(() => { + if (!isPendingDrag) return; + + const handleMouseUp = () => { + pendingDragRef.current = null; + setIsPendingDrag(false); + }; + + document.addEventListener("mouseup", handleMouseUp); + return () => document.removeEventListener("mouseup", handleMouseUp); + }, [isPendingDrag]); + + const handleKeyframeMouseDown = useCallback( + ({ + event, + keyframes, + }: { + event: ReactMouseEvent; + keyframes: SelectedKeyframeRef[]; + }) => { + event.preventDefault(); + event.stopPropagation(); + + mouseDownXRef.current = event.clientX; + + const anySelected = keyframes.some((keyframe) => + isKeyframeSelected({ keyframe }), + ); + + const isModifierKey = event.shiftKey || event.metaKey || event.ctrlKey; + if (!anySelected && !isModifierKey) { + setKeyframeSelection({ keyframes }); + } + + const keyframeRefsToTrack = anySelected ? selectedKeyframes : keyframes; + + pendingDragRef.current = { + keyframeRefs: keyframeRefsToTrack, + startMouseX: event.clientX, + }; + setIsPendingDrag(true); + }, + [isKeyframeSelected, selectedKeyframes, setKeyframeSelection], + ); + + const handleKeyframeClick = useCallback( + ({ + event, + keyframes, + orderedKeyframes, + indicatorTime, + }: { + event: ReactMouseEvent; + keyframes: SelectedKeyframeRef[]; + orderedKeyframes: SelectedKeyframeRef[]; + indicatorTime: number; + }) => { + event.stopPropagation(); + + const wasDrag = + mouseDownXRef.current !== null && + Math.abs(event.clientX - mouseDownXRef.current) > + TIMELINE_DRAG_THRESHOLD_PX; + mouseDownXRef.current = null; + + if (wasDrag) return; + + const duration = editor.timeline.getTotalDuration(); + const seekTime = + snappedSeekTime({ + time: displayedStartTime + indicatorTime, + duration, + rate: fps, + }) ?? displayedStartTime + indicatorTime; + editor.playback.seek({ time: seekTime }); + + if (event.shiftKey) { + selectKeyframeRange({ + orderedKeyframes, + targetKeyframes: keyframes, + isAdditive: event.metaKey || event.ctrlKey, + }); + return; + } + + toggleKeyframeSelection({ + keyframes, + isMultiKey: event.metaKey || event.ctrlKey, + }); + }, + [ + toggleKeyframeSelection, + selectKeyframeRange, + editor, + displayedStartTime, + fps, + ], + ); + + 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], + ); + + return { + keyframeDragState: dragState, + handleKeyframeMouseDown, + handleKeyframeClick, + getVisualOffsetPx, + }; +} diff --git a/apps/web/src/timeline/hooks/element/use-keyframe-selection.ts b/apps/web/src/timeline/hooks/element/use-keyframe-selection.ts new file mode 100644 index 00000000..c5d3ffc9 --- /dev/null +++ b/apps/web/src/timeline/hooks/element/use-keyframe-selection.ts @@ -0,0 +1,229 @@ +import { useCallback, useSyncExternalStore } from "react"; +import { useEditor } from "@/editor/use-editor"; +import type { SelectedKeyframeRef } from "@/animation/types"; + +export function getSelectedKeyframeId({ + keyframe, +}: { + keyframe: SelectedKeyframeRef; +}): string { + return `${keyframe.trackId}:${keyframe.elementId}:${keyframe.propertyPath}:${keyframe.keyframeId}`; +} + +function mergeUniqueKeyframes({ + keyframes, +}: { + keyframes: SelectedKeyframeRef[]; +}): SelectedKeyframeRef[] { + const keyframesById = new Map(); + for (const keyframe of keyframes) { + keyframesById.set(getSelectedKeyframeId({ keyframe }), keyframe); + } + return [...keyframesById.values()]; +} + +export function useKeyframeSelection() { + const editor = useEditor(); + const selectedKeyframes = useSyncExternalStore( + (listener) => editor.selection.subscribe(listener), + () => editor.selection.getSelectedKeyframes(), + ); + const keyframeSelectionAnchor = useSyncExternalStore( + (listener) => editor.selection.subscribe(listener), + () => editor.selection.getKeyframeSelectionAnchor(), + ); + + const isKeyframeSelected = useCallback( + ({ keyframe }: { keyframe: SelectedKeyframeRef }) => { + const keyframeId = getSelectedKeyframeId({ keyframe }); + return selectedKeyframes.some( + (selectedKeyframe) => + getSelectedKeyframeId({ keyframe: selectedKeyframe }) === keyframeId, + ); + }, + [selectedKeyframes], + ); + + const setKeyframeSelection = useCallback( + ({ + keyframes, + anchorKeyframe, + }: { + keyframes: SelectedKeyframeRef[]; + anchorKeyframe?: SelectedKeyframeRef; + }) => { + const uniqueKeyframes = mergeUniqueKeyframes({ keyframes }); + editor.selection.setSelectedKeyframes({ + keyframes: uniqueKeyframes, + anchorKeyframe: + anchorKeyframe ?? uniqueKeyframes[uniqueKeyframes.length - 1] ?? null, + }); + }, + [editor], + ); + + const addKeyframesToSelection = useCallback( + ({ + keyframes, + anchorKeyframe, + }: { + keyframes: SelectedKeyframeRef[]; + anchorKeyframe?: SelectedKeyframeRef; + }) => { + const mergedKeyframes = mergeUniqueKeyframes({ + keyframes: [...selectedKeyframes, ...keyframes], + }); + editor.selection.setSelectedKeyframes({ + keyframes: mergedKeyframes, + anchorKeyframe: + anchorKeyframe ?? mergedKeyframes[mergedKeyframes.length - 1] ?? null, + }); + }, + [selectedKeyframes, editor], + ); + + const removeKeyframesFromSelection = useCallback( + ({ + keyframes, + anchorKeyframe, + }: { + keyframes: SelectedKeyframeRef[]; + anchorKeyframe?: SelectedKeyframeRef; + }) => { + const keyframeIdsToRemove = new Set( + keyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })), + ); + const nextKeyframes = selectedKeyframes.filter( + (selectedKeyframe) => + !keyframeIdsToRemove.has( + getSelectedKeyframeId({ keyframe: selectedKeyframe }), + ), + ); + editor.selection.setSelectedKeyframes({ + keyframes: nextKeyframes, + anchorKeyframe: + anchorKeyframe ?? nextKeyframes[nextKeyframes.length - 1] ?? null, + }); + }, + [selectedKeyframes, editor], + ); + + const clearKeyframeSelection = useCallback(() => { + editor.selection.clearKeyframeSelection(); + }, [editor]); + + const toggleKeyframeSelection = useCallback( + ({ + keyframes, + isMultiKey, + }: { + keyframes: SelectedKeyframeRef[]; + isMultiKey: boolean; + }) => { + const anchorKeyframe = keyframes[0]; + const areAllKeyframesSelected = keyframes.every((keyframe) => + isKeyframeSelected({ keyframe }), + ); + if (!isMultiKey) { + setKeyframeSelection({ keyframes, anchorKeyframe }); + return; + } + + if (areAllKeyframesSelected) { + removeKeyframesFromSelection({ keyframes, anchorKeyframe }); + return; + } + + addKeyframesToSelection({ keyframes, anchorKeyframe }); + }, + [ + setKeyframeSelection, + isKeyframeSelected, + removeKeyframesFromSelection, + addKeyframesToSelection, + ], + ); + + const selectKeyframeRange = useCallback( + ({ + orderedKeyframes, + targetKeyframes, + isAdditive, + }: { + orderedKeyframes: SelectedKeyframeRef[]; + targetKeyframes: SelectedKeyframeRef[]; + isAdditive: boolean; + }) => { + if (orderedKeyframes.length === 0 || targetKeyframes.length === 0) { + return; + } + + const anchorKeyframe = + keyframeSelectionAnchor ?? + selectedKeyframes[selectedKeyframes.length - 1] ?? + targetKeyframes[0]; + if (!anchorKeyframe) { + return; + } + + const targetKeyframeIds = new Set( + targetKeyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })), + ); + const anchorId = getSelectedKeyframeId({ keyframe: anchorKeyframe }); + const anchorIndex = orderedKeyframes.findIndex( + (keyframe) => getSelectedKeyframeId({ keyframe }) === anchorId, + ); + if (anchorIndex === -1) { + if (isAdditive) { + addKeyframesToSelection({ + keyframes: targetKeyframes, + anchorKeyframe, + }); + return; + } + setKeyframeSelection({ keyframes: targetKeyframes, anchorKeyframe }); + return; + } + + const targetIndexes = orderedKeyframes + .map((keyframe, index) => ({ + keyframeId: getSelectedKeyframeId({ keyframe }), + index, + })) + .filter(({ keyframeId }) => targetKeyframeIds.has(keyframeId)) + .map(({ index }) => index); + if (targetIndexes.length === 0) { + return; + } + + const rangeStart = Math.min(anchorIndex, ...targetIndexes); + const rangeEnd = Math.max(anchorIndex, ...targetIndexes); + const rangeKeyframes = orderedKeyframes.slice(rangeStart, rangeEnd + 1); + + if (isAdditive) { + addKeyframesToSelection({ keyframes: rangeKeyframes, anchorKeyframe }); + return; + } + + setKeyframeSelection({ keyframes: rangeKeyframes, anchorKeyframe }); + }, + [ + keyframeSelectionAnchor, + selectedKeyframes, + addKeyframesToSelection, + setKeyframeSelection, + ], + ); + + return { + selectedKeyframes, + keyframeSelectionAnchor, + isKeyframeSelected, + setKeyframeSelection, + addKeyframesToSelection, + removeKeyframesFromSelection, + clearKeyframeSelection, + toggleKeyframeSelection, + selectKeyframeRange, + }; +} diff --git a/apps/web/src/timeline/hooks/use-edge-auto-scroll.ts b/apps/web/src/timeline/hooks/use-edge-auto-scroll.ts new file mode 100644 index 00000000..509031bb --- /dev/null +++ b/apps/web/src/timeline/hooks/use-edge-auto-scroll.ts @@ -0,0 +1,97 @@ +import { useEffect, useRef } from "react"; + +interface UseEdgeAutoScrollParams { + isActive: boolean; + getMouseClientX: () => number; + rulerScrollRef: React.RefObject; + tracksScrollRef: React.RefObject; + contentWidth: number; + edgeThreshold?: number; + maxScrollSpeed?: number; +} + +export function useEdgeAutoScroll({ + isActive, + getMouseClientX, + rulerScrollRef, + tracksScrollRef, + contentWidth, + edgeThreshold = 100, + maxScrollSpeed = 15, +}: UseEdgeAutoScrollParams): void { + const rafRef = useRef(null); + + useEffect(() => { + if (!isActive) { + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + return; + } + + const step = () => { + const rulerViewport = rulerScrollRef.current; + const tracksViewport = tracksScrollRef.current; + if (!rulerViewport || !tracksViewport) { + rafRef.current = requestAnimationFrame(step); + return; + } + + const viewportRect = rulerViewport.getBoundingClientRect(); + const mouseX = getMouseClientX(); + const mouseXRelative = mouseX - viewportRect.left; + + const viewportWidth = rulerViewport.clientWidth; + const intrinsicContentWidth = rulerViewport.scrollWidth; + const effectiveContentWidth = Math.max( + contentWidth, + intrinsicContentWidth, + ); + const scrollMax = Math.max(0, effectiveContentWidth - viewportWidth); + + let scrollSpeed = 0; + + if (mouseXRelative < edgeThreshold && rulerViewport.scrollLeft > 0) { + const edgeDistance = Math.max(0, mouseXRelative); + const intensity = 1 - edgeDistance / edgeThreshold; + scrollSpeed = -maxScrollSpeed * intensity; + } else if ( + mouseXRelative > viewportWidth - edgeThreshold && + rulerViewport.scrollLeft < scrollMax + ) { + const edgeDistance = Math.max(0, viewportWidth - mouseXRelative); + const intensity = 1 - edgeDistance / edgeThreshold; + scrollSpeed = maxScrollSpeed * intensity; + } + + if (scrollSpeed !== 0) { + const newScrollLeft = Math.max( + 0, + Math.min(scrollMax, rulerViewport.scrollLeft + scrollSpeed), + ); + rulerViewport.scrollLeft = newScrollLeft; + tracksViewport.scrollLeft = newScrollLeft; + } + + rafRef.current = requestAnimationFrame(step); + }; + + rafRef.current = requestAnimationFrame(step); + + return () => { + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + }; + }, [ + isActive, + getMouseClientX, + rulerScrollRef, + tracksScrollRef, + contentWidth, + edgeThreshold, + maxScrollSpeed, + ]); +} diff --git a/apps/web/src/timeline/hooks/use-element-preview.ts b/apps/web/src/timeline/hooks/use-element-preview.ts new file mode 100644 index 00000000..67213094 --- /dev/null +++ b/apps/web/src/timeline/hooks/use-element-preview.ts @@ -0,0 +1,39 @@ +import { useEditor } from "@/editor/use-editor"; +import { findTrackInSceneTracks, type TimelineElement } from "@/timeline"; + +/** + * Subscribes to render tracks and returns the live (preview-aware) version of + * an element alongside helpers for previewing and committing updates. + * + * Use this wherever property fields need to reflect in-progress preview state + * (e.g. a slider being dragged) rather than the last committed value. + */ +export function useElementPreview({ + trackId, + elementId, + fallback, +}: { + trackId: string; + elementId: string; + fallback: T; +}) { + const editor = useEditor(); + useEditor((e) => e.timeline.getPreviewTracks()); + + const previewTracks = editor.timeline.getPreviewTracks(); + const renderElement = + (findTrackInSceneTracks({ + tracks: previewTracks ?? editor.scenes.getActiveScene().tracks, + trackId, + })?.elements.find((element) => element.id === elementId) as T | undefined) ?? + fallback; + + const previewUpdates = (updates: Partial) => + editor.timeline.previewElements({ + updates: [{ trackId, elementId, updates }], + }); + + const commit = () => editor.timeline.commitPreview(); + + return { renderElement, previewUpdates, commit }; +} diff --git a/apps/web/src/timeline/hooks/use-initial-scroll-bottom.ts b/apps/web/src/timeline/hooks/use-initial-scroll-bottom.ts new file mode 100644 index 00000000..0b1cf92a --- /dev/null +++ b/apps/web/src/timeline/hooks/use-initial-scroll-bottom.ts @@ -0,0 +1,42 @@ +import { type RefObject, useLayoutEffect, useRef } from "react"; + +interface UseInitialScrollBottomProps { + tracksScrollRef: RefObject; + trackLabelsScrollRef: RefObject; + onAfterScroll?: () => void; + /** Defers the scroll until there is at least one track to measure against. */ + isReady: boolean; +} + +/** + * Scrolls the timeline tracks viewport to the bottom once on mount. + * useLayoutEffect runs synchronously after React commits the DOM but before + * the browser paints, so the initial scroll position is never visible. + */ +export function useInitialScrollBottom({ + tracksScrollRef, + trackLabelsScrollRef, + onAfterScroll, + isReady, +}: UseInitialScrollBottomProps): void { + const hasScrolledRef = useRef(false); + + useLayoutEffect(() => { + if (!isReady || hasScrolledRef.current) return; + + const viewport = tracksScrollRef.current; + if (!viewport) return; + + const maxScrollTop = viewport.scrollHeight - viewport.clientHeight; + if (maxScrollTop <= 0) return; + + viewport.scrollTop = maxScrollTop; + + if (trackLabelsScrollRef.current) { + trackLabelsScrollRef.current.scrollTop = maxScrollTop; + } + + onAfterScroll?.(); + hasScrolledRef.current = true; + }, [isReady, tracksScrollRef, trackLabelsScrollRef, onAfterScroll]); +} diff --git a/apps/web/src/timeline/hooks/use-scroll-position.ts b/apps/web/src/timeline/hooks/use-scroll-position.ts new file mode 100644 index 00000000..ad2c6374 --- /dev/null +++ b/apps/web/src/timeline/hooks/use-scroll-position.ts @@ -0,0 +1,52 @@ +import { useEffect, useState, useRef } from "react"; + +interface UseScrollPositionReturn { + scrollLeft: number; + viewportWidth: number; +} + +export function useScrollPosition({ + scrollRef, +}: { + scrollRef: React.RefObject; +}): UseScrollPositionReturn { + const [scrollLeft, setScrollLeft] = useState(0); + const [viewportWidth, setViewportWidth] = useState(0); + const rafIdRef = useRef(null); + + useEffect(() => { + const scrollElement = scrollRef.current; + if (!scrollElement) return; + + const updatePosition = () => { + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current); + } + + rafIdRef.current = requestAnimationFrame(() => { + setScrollLeft(scrollElement.scrollLeft); + setViewportWidth(scrollElement.clientWidth); + rafIdRef.current = null; + }); + }; + + const resizeObserver = new ResizeObserver(() => { + updatePosition(); + }); + + updatePosition(); + + scrollElement.addEventListener("scroll", updatePosition, { passive: true }); + resizeObserver.observe(scrollElement); + + return () => { + scrollElement.removeEventListener("scroll", updatePosition); + resizeObserver.disconnect(); + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current); + } + }; + }, [scrollRef]); + + return { scrollLeft, viewportWidth }; +} diff --git a/apps/web/src/timeline/hooks/use-scroll-sync.ts b/apps/web/src/timeline/hooks/use-scroll-sync.ts new file mode 100644 index 00000000..e147c106 --- /dev/null +++ b/apps/web/src/timeline/hooks/use-scroll-sync.ts @@ -0,0 +1,173 @@ +import { useEffect, useRef } from "react"; + +interface UseScrollSyncProps { + rulerScrollRef?: React.RefObject; + tracksScrollRef: React.RefObject; + trackLabelsScrollRef?: React.RefObject; + bookmarksScrollRef?: React.RefObject; +} + +export function useScrollSync({ + tracksScrollRef, + rulerScrollRef, + trackLabelsScrollRef, + bookmarksScrollRef, +}: UseScrollSyncProps) { + const isUpdatingRef = useRef(false); + const lastRulerSync = useRef(0); + const lastTracksSync = useRef(0); + const lastVerticalSync = useRef(0); + const lastBookmarksSync = useRef(0); + + useEffect(() => { + const rulerViewport = rulerScrollRef?.current ?? null; + const tracksViewport = tracksScrollRef.current; + const trackLabelsViewport = trackLabelsScrollRef?.current; + const bookmarksViewport = bookmarksScrollRef?.current; + let handleBookmarksScroll: (() => void) | null = null; + + if (!tracksViewport) return; + + const syncScrollLeft = ({ + target, + scrollLeft, + }: { + target: HTMLDivElement | null | undefined; + scrollLeft: number; + }) => { + if (target) { + target.scrollLeft = scrollLeft; + } + }; + + const handleRulerScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastRulerSync.current < 16) return; + lastRulerSync.current = now; + isUpdatingRef.current = true; + tracksViewport.scrollLeft = rulerViewport?.scrollLeft ?? 0; + syncScrollLeft({ + target: bookmarksViewport, + scrollLeft: rulerViewport?.scrollLeft ?? 0, + }); + isUpdatingRef.current = false; + }; + + const handleTracksScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastTracksSync.current < 16) return; + lastTracksSync.current = now; + isUpdatingRef.current = true; + syncScrollLeft({ + target: rulerViewport, + scrollLeft: tracksViewport.scrollLeft, + }); + syncScrollLeft({ + target: bookmarksViewport, + scrollLeft: tracksViewport.scrollLeft, + }); + isUpdatingRef.current = false; + }; + + if (rulerViewport && rulerViewport !== tracksViewport) { + rulerViewport.addEventListener("scroll", handleRulerScroll); + } + if (tracksViewport !== rulerViewport) { + tracksViewport.addEventListener("scroll", handleTracksScroll); + } + + if (bookmarksViewport) { + handleBookmarksScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastBookmarksSync.current < 16) + return; + lastBookmarksSync.current = now; + isUpdatingRef.current = true; + const nextScrollLeft = bookmarksViewport.scrollLeft; + tracksViewport.scrollLeft = nextScrollLeft; + syncScrollLeft({ + target: rulerViewport, + scrollLeft: nextScrollLeft, + }); + isUpdatingRef.current = false; + }; + + if (bookmarksViewport !== tracksViewport) { + bookmarksViewport.addEventListener("scroll", handleBookmarksScroll); + } + } + + if (trackLabelsViewport) { + const handleTrackLabelsScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastVerticalSync.current < 16) + return; + lastVerticalSync.current = now; + isUpdatingRef.current = true; + tracksViewport.scrollTop = trackLabelsViewport.scrollTop; + isUpdatingRef.current = false; + }; + + const handleTracksVerticalScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastVerticalSync.current < 16) + return; + lastVerticalSync.current = now; + isUpdatingRef.current = true; + trackLabelsViewport.scrollTop = tracksViewport.scrollTop; + isUpdatingRef.current = false; + }; + + trackLabelsViewport.addEventListener("scroll", handleTrackLabelsScroll); + tracksViewport.addEventListener("scroll", handleTracksVerticalScroll); + + return () => { + if (rulerViewport && rulerViewport !== tracksViewport) { + rulerViewport.removeEventListener("scroll", handleRulerScroll); + } + if (tracksViewport !== rulerViewport) { + tracksViewport.removeEventListener("scroll", handleTracksScroll); + } + if ( + bookmarksViewport && + bookmarksViewport !== tracksViewport && + handleBookmarksScroll + ) { + bookmarksViewport.removeEventListener( + "scroll", + handleBookmarksScroll, + ); + } + trackLabelsViewport.removeEventListener( + "scroll", + handleTrackLabelsScroll, + ); + tracksViewport.removeEventListener( + "scroll", + handleTracksVerticalScroll, + ); + }; + } + + return () => { + if (rulerViewport && rulerViewport !== tracksViewport) { + rulerViewport.removeEventListener("scroll", handleRulerScroll); + } + if (tracksViewport !== rulerViewport) { + tracksViewport.removeEventListener("scroll", handleTracksScroll); + } + if ( + bookmarksViewport && + bookmarksViewport !== tracksViewport && + handleBookmarksScroll + ) { + bookmarksViewport.removeEventListener("scroll", handleBookmarksScroll); + } + }; + }, [ + rulerScrollRef, + tracksScrollRef, + trackLabelsScrollRef, + bookmarksScrollRef, + ]); +} diff --git a/apps/web/src/timeline/hooks/use-snap-indicator-position.ts b/apps/web/src/timeline/hooks/use-snap-indicator-position.ts new file mode 100644 index 00000000..2081b939 --- /dev/null +++ b/apps/web/src/timeline/hooks/use-snap-indicator-position.ts @@ -0,0 +1,55 @@ +import { useEffect, useState } from "react"; +import { timelineTimeToSnappedPixels } from "@/timeline"; +import { TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX } from "@/timeline/components/layout"; +interface UseSnapIndicatorPositionParams { + snapPoint: { time: number } | null; + zoomLevel: number; + timelineRef: React.RefObject; + tracksScrollRef: React.RefObject; +} + +interface SnapIndicatorPosition { + leftPosition: number; + topPosition: number; + height: number; +} + +export function useSnapIndicatorPosition({ + snapPoint, + zoomLevel, + timelineRef, + tracksScrollRef, +}: UseSnapIndicatorPositionParams): SnapIndicatorPosition { + const [scrollLeft, setScrollLeft] = useState(0); + + useEffect(() => { + const tracksViewport = tracksScrollRef.current; + + if (!tracksViewport) return; + + const handleScroll = () => { + setScrollLeft(tracksViewport.scrollLeft); + }; + + setScrollLeft(tracksViewport.scrollLeft); + + tracksViewport.addEventListener("scroll", handleScroll); + return () => tracksViewport.removeEventListener("scroll", handleScroll); + }, [tracksScrollRef]); + + const timelineContainerHeight = timelineRef.current?.offsetHeight || 400; + const totalHeight = timelineContainerHeight - 8; // 8px padding from edges + + const timelinePosition = timelineTimeToSnappedPixels({ + time: snapPoint?.time ?? 0, + zoomLevel, + }); + const leftPosition = + TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX + timelinePosition - scrollLeft; + + return { + leftPosition, + topPosition: 0, + height: totalHeight, + }; +} diff --git a/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts new file mode 100644 index 00000000..775b7337 --- /dev/null +++ b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts @@ -0,0 +1,648 @@ +import { useState, useCallback, 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 { TICKS_PER_SECOND } 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"; + +interface UseTimelineDragDropProps { + containerRef: RefObject; + headerRef?: RefObject; + tracksScrollRef?: RefObject; + zoomLevel: number; +} + +export function useTimelineDragDrop({ + containerRef, + headerRef, + tracksScrollRef, + 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: number }) => { + const projectFps = editor.project.getActive().settings.fps; + return roundToFrame({ time, rate: projectFps }) ?? time; + }, + [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; + }): number => { + 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 + ? Math.round(media.duration * TICKS_PER_SECOND) + : 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 + ? Math.round(mediaAsset.duration * TICKS_PER_SECOND) + : 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 + ? Math.round(createdAsset.duration * TICKS_PER_SECOND) + : 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 currentTarget = dropTarget; + setIsDragOver(false); + setDropTarget(null); + setElementType(null); + + try { + if (hasAsset) { + if (!currentTarget) return; + const dragData = getDragData({ dataTransfer: e.dataTransfer }); + if (!dragData) return; + + 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, + ], + ); + + return { + isDragOver, + dropTarget, + dragElementType, + dragProps: { + onDragEnter: handleDragEnter, + onDragOver: handleDragOver, + onDragLeave: handleDragLeave, + onDrop: handleDrop, + }, + }; +} diff --git a/apps/web/src/timeline/hooks/use-timeline-playhead.ts b/apps/web/src/timeline/hooks/use-timeline-playhead.ts new file mode 100644 index 00000000..7d9cf447 --- /dev/null +++ b/apps/web/src/timeline/hooks/use-timeline-playhead.ts @@ -0,0 +1,301 @@ +import { snappedSeekTime } from "opencut-wasm"; +import { TICKS_PER_SECOND } from "@/wasm"; +import { useEffect, useCallback, useRef } from "react"; +import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll"; +import { useEditor } from "@/editor/use-editor"; +import { useShiftKey } from "@/hooks/use-shift-key"; +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"; + +interface UseTimelinePlayheadProps { + zoomLevel: number; + rulerRef: React.RefObject; + rulerScrollRef: React.RefObject; + tracksScrollRef: React.RefObject; + playheadRef?: React.RefObject; +} + +export function useTimelinePlayhead({ + zoomLevel, + rulerRef, + rulerScrollRef, + tracksScrollRef, + 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(); + + 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: number }) => 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 = Math.round(rawTimeSeconds * TICKS_PER_SECOND); + + const rate = activeProject.settings.fps; + const frameTime = + snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime; + + const shouldSnap = snappingEnabled && !isShiftHeldRef.current; + const time = (() => { + 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 : 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 updatePlayheadLeft = useCallback( + (time: number) => { + 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], + ); + + useEffect(() => { + const scrollEl = rulerScrollRef.current; + if (!scrollEl) return; + + const handleScroll = () => { + updatePlayheadLeft(editor.playback.getCurrentTime()); + }; + + scrollEl.addEventListener("scroll", handleScroll, { passive: true }); + return () => scrollEl.removeEventListener("scroll", handleScroll); + }, [editor.playback, rulerScrollRef, updatePlayheadLeft]); + + useEffect(() => { + const handlePlaybackUpdate = (e: Event) => { + const time = (e as CustomEvent<{ time: number }>).detail.time; + 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 initialTime = editor.playback.getCurrentTime(); + handlePlaybackUpdate({ + detail: { time: initialTime }, + } as CustomEvent<{ time: number }>); + + 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]); + + return { + handlePlayheadMouseDown: handlePlayheadMouseDownEvent, + handleRulerMouseDown: handleRulerMouseDownEvent, + }; +} diff --git a/apps/web/src/timeline/hooks/use-timeline-resize.ts b/apps/web/src/timeline/hooks/use-timeline-resize.ts new file mode 100644 index 00000000..fb8da291 --- /dev/null +++ b/apps/web/src/timeline/hooks/use-timeline-resize.ts @@ -0,0 +1,332 @@ +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 { useEditor } from "@/editor/use-editor"; +import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; +import { useShiftKey } from "@/hooks/use-shift-key"; +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"; + +interface ResizeInteractionState { + side: ResizeSide; + startX: number; + members: GroupResizeMember[]; +} + +export function useTimelineResize({ + zoomLevel, + onSnapPointChange, +}: { + zoomLevel: number; + onSnapPointChange?: (snapPoint: SnapPoint | null) => void; +}) { + 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; + editor.timeline.previewElements({ + updates: result.updates.map(({ trackId, elementId, patch }) => ({ + trackId, + elementId, + updates: patch, + })), + }); + }; + + 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, + })), + }); + } + + 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, + 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); + + 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 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; + + 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; + } + } + + onSnapPointChange?.(closestSnapPoint); + return { deltaTime }; +} + +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 + ); + }); +} diff --git a/apps/web/src/timeline/hooks/use-timeline-seek.ts b/apps/web/src/timeline/hooks/use-timeline-seek.ts new file mode 100644 index 00000000..889177c9 --- /dev/null +++ b/apps/web/src/timeline/hooks/use-timeline-seek.ts @@ -0,0 +1,197 @@ +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 { TICKS_PER_SECOND } from "@/wasm"; +import { useEditor } from "@/editor/use-editor"; + +interface UseTimelineSeekProps { + playheadRef: RefObject; + trackLabelsRef: RefObject; + rulerScrollRef: RefObject; + tracksScrollRef: RefObject; + zoomLevel: number; + duration: number; + isSelecting: boolean; + clearSelectedElements: () => void; + seek: (time: number) => 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, + rulerScrollRef, + tracksScrollRef, + zoomLevel, + duration, + isSelecting, + clearSelectedElements, + 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 = Math.round(rawTimeSeconds * TICKS_PER_SECOND); + + const rate = activeProject?.settings.fps; + const time = rate + ? (snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime) + : rawTime; + seek(time); + editor.project.setTimelineViewState({ + viewState: { + zoomLevel, + scrollLeft: scrollContainer.scrollLeft, + playheadTime: time, + }, + }); + }, + [ + 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 handleRulerClick = useCallback( + (event: React.MouseEvent) => { + const shouldProcess = shouldProcessTimelineClick({ event }); + resetMouseTracking({ mouseTrackingRef }); + + if (shouldProcess) { + clearSelectedElements(); + handleTimelineSeek({ event, source: "ruler" }); + } + }, + [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements], + ); + + return { + handleTracksMouseDown, + handleTracksClick, + handleRulerMouseDown, + handleRulerClick, + }; +} diff --git a/apps/web/src/timeline/hooks/use-timeline-zoom.ts b/apps/web/src/timeline/hooks/use-timeline-zoom.ts new file mode 100644 index 00000000..7929d495 --- /dev/null +++ b/apps/web/src/timeline/hooks/use-timeline-zoom.ts @@ -0,0 +1,291 @@ +import { + type WheelEvent as ReactWheelEvent, + type RefObject, + useCallback, + useEffect, + useLayoutEffect, + 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"; + +interface UseTimelineZoomProps { + containerRef: RefObject; + minZoom?: number; + initialZoom?: number; + initialScrollLeft?: number; + initialPlayheadTime?: number; + tracksScrollRef: RefObject; + rulerScrollRef: RefObject; +} + +interface UseTimelineZoomReturn { + zoomLevel: number; + setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void; + handleWheel: (event: ReactWheelEvent) => void; + saveScrollPosition: () => void; +} + +export function useTimelineZoom({ + containerRef, + minZoom = TIMELINE_ZOOM_MIN, + initialZoom, + initialScrollLeft, + initialPlayheadTime, + tracksScrollRef, + 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); + + if (isHorizontalScrollGesture) { + return; + } + + // 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], + ); + + useEffect(() => { + if (initialZoom !== undefined && !hasInitializedRef.current) { + hasInitializedRef.current = true; + setZoomLevel(Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom))); + return; + } + setZoomLevel((prev) => { + if (prev < minZoom) { + return minZoom; + } + return prev; + }); + }, [minZoom, initialZoom, setZoomLevel]); + + const wrappedSetZoomLevel = useCallback( + (zoomLevelOrUpdater: number | ((prev: number) => number)) => { + setZoomLevel((prev) => { + const nextZoom = + typeof zoomLevelOrUpdater === "function" + ? zoomLevelOrUpdater(prev) + : zoomLevelOrUpdater; + const clampedZoom = Math.max( + minZoom, + Math.min(TIMELINE_ZOOM_MAX, nextZoom), + ); + return clampedZoom; + }); + }, + [minZoom, setZoomLevel], + ); + + 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]); + + useEffect(() => { + if (initialPlayheadTime !== undefined && !hasRestoredPlayheadRef.current) { + hasRestoredPlayheadRef.current = true; + editor.playback.seek({ time: initialPlayheadTime }); + } + }, [initialPlayheadTime, editor]); + + // 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(); + } + }; + + document.addEventListener("wheel", preventZoom, { + passive: false, + capture: true, + }); + + return () => { + document.removeEventListener("wheel", preventZoom, { capture: true }); + }; + }, [containerRef]); + + return { + zoomLevel, + setZoomLevel: wrappedSetZoomLevel, + handleWheel, + saveScrollPosition, + }; +} -- cgit v1.3.1