diff options
| author | Maze <167211895+mazeincoding@users.noreply.github.com> | 2026-01-31 00:20:04 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-31 00:20:04 +0100 |
| commit | 7bf0984698e43b9aa2dd879f65951318ed410390 (patch) | |
| tree | ccf2cd590a47d550152afc07129cb55c71a9ec35 /apps/web/src/hooks/timeline | |
| parent | 0173db99448d1c4c6415dcf15272e17551b48b04 (diff) | |
| download | opencut-7bf0984698e43b9aa2dd879f65951318ed410390.tar.gz | |
codebase overhaul (#697)
Diffstat (limited to 'apps/web/src/hooks/timeline')
| -rw-r--r-- | apps/web/src/hooks/timeline/element/use-element-interaction.ts | 619 | ||||
| -rw-r--r-- | apps/web/src/hooks/timeline/element/use-element-resize.ts | 331 | ||||
| -rw-r--r-- | apps/web/src/hooks/timeline/element/use-element-selection.ts | 114 | ||||
| -rw-r--r-- | apps/web/src/hooks/timeline/use-edge-auto-scroll.ts | 100 | ||||
| -rw-r--r-- | apps/web/src/hooks/timeline/use-scroll-sync.ts | 173 | ||||
| -rw-r--r-- | apps/web/src/hooks/timeline/use-selection-box.ts | 243 | ||||
| -rw-r--r-- | apps/web/src/hooks/timeline/use-snap-indicator-position.ts | 62 | ||||
| -rw-r--r-- | apps/web/src/hooks/timeline/use-timeline-drag-drop.ts | 460 | ||||
| -rw-r--r-- | apps/web/src/hooks/timeline/use-timeline-playhead.ts | 218 | ||||
| -rw-r--r-- | apps/web/src/hooks/timeline/use-timeline-seek.ts | 197 | ||||
| -rw-r--r-- | apps/web/src/hooks/timeline/use-timeline-snapping.ts | 159 | ||||
| -rw-r--r-- | apps/web/src/hooks/timeline/use-timeline-zoom.ts | 224 |
12 files changed, 2900 insertions, 0 deletions
diff --git a/apps/web/src/hooks/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts new file mode 100644 index 00000000..c92d6722 --- /dev/null +++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts @@ -0,0 +1,619 @@ +import { + useState, + useCallback, + useEffect, + useRef, + type MouseEvent as ReactMouseEvent, + type RefObject, +} from "react"; +import { useEditor } from "@/hooks/use-editor"; +import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { snapTimeToFrame } from "@/lib/time"; +import { computeDropTarget } from "@/lib/timeline/drop-utils"; +import { generateUUID } from "@/utils/id"; +import { useTimelineSnapping } from "@/hooks/timeline/use-timeline-snapping"; +import type { + DropTarget, + ElementDragState, + TimelineElement, + TimelineTrack, +} from "@/types/timeline"; +import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; + +const DRAG_THRESHOLD_PX = 5; + +interface UseElementInteractionProps { + zoomLevel: number; + timelineRef: RefObject<HTMLDivElement | null>; + tracksContainerRef: RefObject<HTMLDivElement | null>; + tracksScrollRef: RefObject<HTMLDivElement | null>; + headerRef?: RefObject<HTMLElement | null>; + snappingEnabled: boolean; + onSnapPointChange?: (snapPoint: SnapPoint | null) => void; +} + +const initialDragState: ElementDragState = { + isDragging: false, + elementId: null, + trackId: null, + startMouseX: 0, + startMouseY: 0, + startElementTime: 0, + clickOffsetTime: 0, + currentTime: 0, + currentMouseY: 0, +}; + +interface PendingDragState { + elementId: string; + trackId: string; + startMouseX: number; + startMouseY: number; + startElementTime: number; + clickOffsetTime: number; +} + +function getMouseTimeFromClientX({ + clientX, + containerRect, + zoomLevel, + scrollLeft, +}: { + clientX: number; + containerRect: DOMRect; + zoomLevel: number; + scrollLeft: number; +}): number { + const mouseX = clientX - containerRect.left + scrollLeft; + return Math.max( + 0, + mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel), + ); +} + +function getClickOffsetTime({ + clientX, + elementRect, + zoomLevel, +}: { + clientX: number; + elementRect: DOMRect; + zoomLevel: number; +}): number { + const clickOffsetX = clientX - elementRect.left; + return clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel); +} + +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: TimelineTrack[]; + tracksContainerRef: RefObject<HTMLDivElement | null>; + tracksScrollRef: RefObject<HTMLDivElement | null>; + headerRef?: RefObject<HTMLElement | null>; + 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.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: TIMELINE_CONSTANTS.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 tracks = editor.timeline.getTracks(); + const { snapElementEdge } = useTimelineSnapping(); + const { + isElementSelected, + selectElement, + handleElementClick: handleSelectionClick, + } = useElementSelection(); + + const [dragState, setDragState] = + useState<ElementDragState>(initialDragState); + const [dragDropTarget, setDragDropTarget] = useState<DropTarget | null>(null); + const [isPendingDrag, setIsPendingDrag] = useState(false); + const pendingDragRef = useRef<PendingDragState | null>(null); + const lastMouseXRef = useRef(0); + const mouseDownLocationRef = useRef<{ x: number; y: number } | null>(null); + + const startDrag = useCallback( + ({ + elementId, + trackId, + startMouseX, + startMouseY, + startElementTime, + clickOffsetTime, + initialCurrentTime, + initialCurrentMouseY, + }: StartDragParams) => { + setDragState({ + isDragging: true, + elementId, + trackId, + startMouseX, + startMouseY, + startElementTime, + clickOffsetTime, + currentTime: initialCurrentTime, + currentMouseY: initialCurrentMouseY, + }); + }, + [], + ); + + const endDrag = useCallback(() => { + setDragState(initialDragState); + setDragDropTarget(null); + }, []); + + const getDragSnapResult = useCallback( + ({ + frameSnappedTime, + movingElement, + }: { + frameSnappedTime: number; + movingElement: TimelineElement | null | undefined; + }) => { + if (!snappingEnabled || !movingElement) { + return { snappedTime: frameSnappedTime, snapPoint: null }; + } + + const elementDuration = movingElement.duration; + const playheadTime = editor.playback.getCurrentTime(); + + const startSnap = snapElementEdge({ + targetTime: frameSnappedTime, + elementDuration, + tracks, + playheadTime, + zoomLevel, + excludeElementId: movingElement.id, + snapToStart: true, + }); + + const endSnap = snapElementEdge({ + targetTime: frameSnappedTime, + elementDuration, + tracks, + playheadTime, + zoomLevel, + excludeElementId: movingElement.id, + snapToStart: false, + }); + + const snapResult = + startSnap.snapDistance <= endSnap.snapDistance ? startSnap : endSnap; + if (!snapResult.snapPoint) { + return { snappedTime: frameSnappedTime, snapPoint: null }; + } + + return { + snappedTime: snapResult.snappedTime, + snapPoint: snapResult.snapPoint, + }; + }, + [snappingEnabled, editor.playback, snapElementEdge, tracks, zoomLevel], + ); + + 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 > DRAG_THRESHOLD_PX || deltaY > 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 = snapTimeToFrame({ + time: adjustedTime, + fps: activeProject.settings.fps, + }); + startDrag({ + ...pendingDragRef.current, + initialCurrentTime: snappedTime, + initialCurrentMouseY: clientY, + }); + 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 = snapTimeToFrame({ time: adjustedTime, fps }); + + const sourceTrack = tracks.find(({ id }) => id === dragState.trackId); + const movingElement = sourceTrack?.elements.find( + ({ id }) => id === dragState.elementId, + ); + const { snappedTime, snapPoint } = getDragSnapResult({ + frameSnappedTime, + movingElement, + }); + setDragState((previousDragState) => ({ + ...previousDragState, + currentTime: snappedTime, + currentMouseY: clientY, + })); + onSnapPointChange?.(snapPoint); + + if (dragState.elementId && dragState.trackId) { + const verticalDragDirection = getVerticalDragDirection({ + startMouseY: dragState.startMouseY, + currentMouseY: clientY, + }); + const dropTarget = getDragDropTarget({ + clientX, + clientY, + elementId: dragState.elementId, + trackId: dragState.trackId, + tracks, + tracksContainerRef, + tracksScrollRef, + headerRef, + zoomLevel, + snappedTime, + verticalDragDirection, + }); + setDragDropTarget(dropTarget?.isNewTrack ? dropTarget : 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, + tracks, + isPendingDrag, + startDrag, + getDragSnapResult, + 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 <= DRAG_THRESHOLD_PX && deltaY <= DRAG_THRESHOLD_PX) { + mouseDownLocationRef.current = null; + endDrag(); + onSnapPointChange?.(null); + return; + } + } + + const dropTarget = getDragDropTarget({ + clientX, + clientY, + elementId: dragState.elementId, + trackId: dragState.trackId, + tracks, + tracksContainerRef, + tracksScrollRef, + headerRef, + zoomLevel, + snappedTime: dragState.currentTime, + verticalDragDirection: getVerticalDragDirection({ + startMouseY: dragState.startMouseY, + currentMouseY: clientY, + }), + }); + if (!dropTarget) { + endDrag(); + onSnapPointChange?.(null); + return; + } + const snappedTime = dragState.currentTime; + + const sourceTrack = tracks.find(({ id }) => id === dragState.trackId); + if (!sourceTrack) { + endDrag(); + onSnapPointChange?.(null); + return; + } + + if (dropTarget.isNewTrack) { + const newTrackId = generateUUID(); + + editor.timeline.moveElement({ + sourceTrackId: dragState.trackId, + targetTrackId: newTrackId, + elementId: dragState.elementId, + newStartTime: snappedTime, + createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex }, + }); + } else { + const targetTrack = tracks[dropTarget.trackIndex]; + if (targetTrack) { + editor.timeline.moveElement({ + sourceTrackId: dragState.trackId, + targetTrackId: targetTrack.id, + elementId: dragState.elementId, + newStartTime: snappedTime, + }); + } + } + + endDrag(); + onSnapPointChange?.(null); + }; + + document.addEventListener("mouseup", handleMouseUp); + return () => document.removeEventListener("mouseup", handleMouseUp); + }, [ + dragState.isDragging, + dragState.elementId, + dragState.startMouseY, + dragState.trackId, + dragState.currentTime, + zoomLevel, + tracks, + endDrag, + onSnapPointChange, + editor.timeline, + tracksContainerRef, + tracksScrollRef, + headerRef, + ]); + + 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 === 2; + + // 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; + } + + // left-click: stop propagation for drag operations + event.stopPropagation(); + mouseDownLocationRef.current = { x: event.clientX, y: event.clientY }; + + const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey; + + // multi-select: toggle selection + if (isMultiSelect) { + handleSelectionClick({ + trackId: track.id, + elementId: element.id, + isMultiKey: true, + }); + } + + // start drag + const clickOffsetTime = getClickOffsetTime({ + clientX: event.clientX, + elementRect: event.currentTarget.getBoundingClientRect(), + zoomLevel, + }); + pendingDragRef.current = { + elementId: element.id, + trackId: track.id, + startMouseX: event.clientX, + startMouseY: event.clientY, + startElementTime: element.startTime, + clickOffsetTime, + }; + setIsPendingDrag(true); + }, + [zoomLevel, isElementSelected, handleSelectionClick], + ); + + const handleElementClick = useCallback( + ({ + event, + element, + track, + }: { + event: ReactMouseEvent; + element: TimelineElement; + track: TimelineTrack; + }) => { + event.stopPropagation(); + + // was it a drag or a click? + if (mouseDownLocationRef.current) { + const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x); + const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y); + if (deltaX > DRAG_THRESHOLD_PX || deltaY > DRAG_THRESHOLD_PX) { + mouseDownLocationRef.current = null; + return; + } + } + + // modifier keys already handled in mousedown + if (event.metaKey || event.ctrlKey || event.shiftKey) return; + + // single click: select if not selected + const alreadySelected = isElementSelected({ + trackId: track.id, + elementId: element.id, + }); + if (!alreadySelected) { + selectElement({ trackId: track.id, elementId: element.id }); + } + }, + [isElementSelected, selectElement], + ); + + return { + dragState, + dragDropTarget, + handleElementMouseDown, + handleElementClick, + lastMouseXRef, + }; +} diff --git a/apps/web/src/hooks/timeline/element/use-element-resize.ts b/apps/web/src/hooks/timeline/element/use-element-resize.ts new file mode 100644 index 00000000..803d9788 --- /dev/null +++ b/apps/web/src/hooks/timeline/element/use-element-resize.ts @@ -0,0 +1,331 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import type { TimelineElement, TimelineTrack } from "@/types/timeline"; +import { snapTimeToFrame } from "@/lib/time"; +import { EditorCore } from "@/core"; +import { + useTimelineSnapping, + type SnapPoint, +} from "@/hooks/timeline/use-timeline-snapping"; +import { useTimelineStore } from "@/stores/timeline-store"; + +export interface ResizeState { + elementId: string; + side: "left" | "right"; + startX: number; + initialTrimStart: number; + initialTrimEnd: number; + initialStartTime: number; + initialDuration: number; +} + +interface UseTimelineElementResizeProps { + element: TimelineElement; + track: TimelineTrack; + zoomLevel: number; + onSnapPointChange?: (snapPoint: SnapPoint | null) => void; + onResizeStateChange?: (params: { isResizing: boolean }) => void; +} + +export function useTimelineElementResize({ + element, + track, + zoomLevel, + onSnapPointChange, + onResizeStateChange, +}: UseTimelineElementResizeProps) { + const editor = EditorCore.getInstance(); + const activeProject = editor.project.getActive(); + const snappingEnabled = useTimelineStore((state) => state.snappingEnabled); + const { findSnapPoints, snapToNearestPoint } = useTimelineSnapping(); + + const [resizing, setResizing] = useState<ResizeState | null>(null); + const [currentTrimStart, setCurrentTrimStart] = useState(element.trimStart); + const [currentTrimEnd, setCurrentTrimEnd] = useState(element.trimEnd); + const [currentStartTime, setCurrentStartTime] = useState(element.startTime); + const [currentDuration, setCurrentDuration] = useState(element.duration); + const currentTrimStartRef = useRef(element.trimStart); + const currentTrimEndRef = useRef(element.trimEnd); + const currentStartTimeRef = useRef(element.startTime); + const currentDurationRef = useRef(element.duration); + + const handleResizeStart = ({ + e, + elementId, + side, + }: { + e: React.MouseEvent; + elementId: string; + side: "left" | "right"; + }) => { + e.stopPropagation(); + e.preventDefault(); + + setResizing({ + elementId, + side, + startX: e.clientX, + initialTrimStart: element.trimStart, + initialTrimEnd: element.trimEnd, + initialStartTime: element.startTime, + initialDuration: element.duration, + }); + + setCurrentTrimStart(element.trimStart); + setCurrentTrimEnd(element.trimEnd); + setCurrentStartTime(element.startTime); + setCurrentDuration(element.duration); + currentTrimStartRef.current = element.trimStart; + currentTrimEndRef.current = element.trimEnd; + currentStartTimeRef.current = element.startTime; + currentDurationRef.current = element.duration; + onResizeStateChange?.({ isResizing: true }); + }; + + const canExtendElementDuration = useCallback(() => { + if (element.type === "text" || element.type === "image") { + return true; + } + + return false; + }, [element.type]); + + const updateTrimFromMouseMove = useCallback(({ clientX }: { clientX: number }) => { + if (!resizing) return; + + const deltaX = clientX - resizing.startX; + let deltaTime = deltaX / (50 * zoomLevel); + let resizeSnapPoint: SnapPoint | null = null; + + const projectFps = activeProject.settings.fps; + const minDurationSeconds = 1 / projectFps; + const canSnap = snappingEnabled; + if (canSnap) { + const tracks = editor.timeline.getTracks(); + const playheadTime = editor.playback.getCurrentTime(); + const snapPoints = findSnapPoints({ + tracks, + playheadTime, + excludeElementId: element.id, + }); + if (resizing.side === "left") { + const targetStartTime = resizing.initialStartTime + deltaTime; + const snapResult = snapToNearestPoint({ + targetTime: targetStartTime, + snapPoints, + zoomLevel, + }); + resizeSnapPoint = snapResult.snapPoint; + if (snapResult.snapPoint) { + deltaTime = snapResult.snappedTime - resizing.initialStartTime; + } + } else { + const baseEndTime = + resizing.initialStartTime + resizing.initialDuration; + const targetEndTime = baseEndTime + deltaTime; + const snapResult = snapToNearestPoint({ + targetTime: targetEndTime, + snapPoints, + zoomLevel, + }); + resizeSnapPoint = snapResult.snapPoint; + if (snapResult.snapPoint) { + deltaTime = snapResult.snappedTime - baseEndTime; + } + } + } + onSnapPointChange?.(resizeSnapPoint); + + if (resizing.side === "left") { + const sourceDuration = + resizing.initialTrimStart + + resizing.initialDuration + + resizing.initialTrimEnd; + const maxAllowed = + sourceDuration - resizing.initialTrimEnd - minDurationSeconds; + const calculated = resizing.initialTrimStart + deltaTime; + + if (calculated >= 0 && calculated <= maxAllowed) { + const newTrimStart = snapTimeToFrame({ + time: Math.min(maxAllowed, calculated), + fps: projectFps, + }); + const trimDelta = newTrimStart - resizing.initialTrimStart; + const newStartTime = snapTimeToFrame({ + time: resizing.initialStartTime + trimDelta, + fps: projectFps, + }); + const newDuration = snapTimeToFrame({ + time: resizing.initialDuration - trimDelta, + fps: projectFps, + }); + + setCurrentTrimStart(newTrimStart); + setCurrentStartTime(newStartTime); + setCurrentDuration(newDuration); + currentTrimStartRef.current = newTrimStart; + currentStartTimeRef.current = newStartTime; + currentDurationRef.current = newDuration; + } else if (calculated < 0) { + if (canExtendElementDuration()) { + const extensionAmount = Math.abs(calculated); + const maxExtension = resizing.initialStartTime; + const actualExtension = Math.min(extensionAmount, maxExtension); + const newStartTime = snapTimeToFrame({ + time: resizing.initialStartTime - actualExtension, + fps: projectFps, + }); + const newDuration = snapTimeToFrame({ + time: resizing.initialDuration + actualExtension, + fps: projectFps, + }); + + setCurrentTrimStart(0); + setCurrentStartTime(newStartTime); + setCurrentDuration(newDuration); + currentTrimStartRef.current = 0; + currentStartTimeRef.current = newStartTime; + currentDurationRef.current = newDuration; + } else { + const trimDelta = 0 - resizing.initialTrimStart; + const newStartTime = snapTimeToFrame({ + time: resizing.initialStartTime + trimDelta, + fps: projectFps, + }); + const newDuration = snapTimeToFrame({ + time: resizing.initialDuration - trimDelta, + fps: projectFps, + }); + + setCurrentTrimStart(0); + setCurrentStartTime(newStartTime); + setCurrentDuration(newDuration); + currentTrimStartRef.current = 0; + currentStartTimeRef.current = newStartTime; + currentDurationRef.current = newDuration; + } + } + } else { + const sourceDuration = + resizing.initialTrimStart + + resizing.initialDuration + + resizing.initialTrimEnd; + const newTrimEnd = resizing.initialTrimEnd - deltaTime; + + if (newTrimEnd < 0) { + if (canExtendElementDuration()) { + const extensionNeeded = Math.abs(newTrimEnd); + const baseDuration = + resizing.initialDuration + resizing.initialTrimEnd; + const newDuration = snapTimeToFrame({ + time: baseDuration + extensionNeeded, + fps: projectFps, + }); + + setCurrentDuration(newDuration); + setCurrentTrimEnd(0); + currentDurationRef.current = newDuration; + currentTrimEndRef.current = 0; + } else { + const extensionToLimit = resizing.initialTrimEnd; + const newDuration = snapTimeToFrame({ + time: resizing.initialDuration + extensionToLimit, + fps: projectFps, + }); + + setCurrentDuration(newDuration); + setCurrentTrimEnd(0); + currentDurationRef.current = newDuration; + currentTrimEndRef.current = 0; + } + } else { + const maxTrimEnd = + sourceDuration - resizing.initialTrimStart - minDurationSeconds; + const clampedTrimEnd = Math.min(maxTrimEnd, Math.max(0, newTrimEnd)); + const finalTrimEnd = snapTimeToFrame({ + time: clampedTrimEnd, + fps: projectFps, + }); + const trimDelta = finalTrimEnd - resizing.initialTrimEnd; + const newDuration = snapTimeToFrame({ + time: resizing.initialDuration - trimDelta, + fps: projectFps, + }); + + setCurrentTrimEnd(finalTrimEnd); + setCurrentDuration(newDuration); + currentTrimEndRef.current = finalTrimEnd; + currentDurationRef.current = newDuration; + } + } + }, [resizing, zoomLevel, activeProject.settings.fps, snappingEnabled, editor, findSnapPoints, snapToNearestPoint, element.id, onSnapPointChange, canExtendElementDuration]); + + const handleResizeEnd = useCallback(() => { + if (!resizing) return; + + const finalTrimStart = currentTrimStartRef.current; + const finalTrimEnd = currentTrimEndRef.current; + const finalStartTime = currentStartTimeRef.current; + const finalDuration = currentDurationRef.current; + const trimStartChanged = finalTrimStart !== resizing.initialTrimStart; + const trimEndChanged = finalTrimEnd !== resizing.initialTrimEnd; + const startTimeChanged = finalStartTime !== resizing.initialStartTime; + const durationChanged = finalDuration !== resizing.initialDuration; + + if (trimStartChanged || trimEndChanged) { + editor.timeline.updateElementTrim({ + elementId: element.id, + trimStart: finalTrimStart, + trimEnd: finalTrimEnd, + }); + } + + if (startTimeChanged) { + editor.timeline.updateElementStartTime({ + elements: [{ trackId: track.id, elementId: element.id }], + startTime: finalStartTime, + }); + } + + if (durationChanged) { + editor.timeline.updateElementDuration({ + trackId: track.id, + elementId: element.id, + duration: finalDuration, + }); + } + + setResizing(null); + onResizeStateChange?.({ isResizing: false }); + onSnapPointChange?.(null); + }, [resizing, editor.timeline, element.id, track.id, onResizeStateChange, onSnapPointChange]); + + useEffect(() => { + if (!resizing) return; + + const handleDocumentMouseMove = ({ clientX }: MouseEvent) => { + updateTrimFromMouseMove({ clientX }); + }; + + const handleDocumentMouseUp = () => { + handleResizeEnd(); + }; + + document.addEventListener("mousemove", handleDocumentMouseMove); + document.addEventListener("mouseup", handleDocumentMouseUp); + + return () => { + document.removeEventListener("mousemove", handleDocumentMouseMove); + document.removeEventListener("mouseup", handleDocumentMouseUp); + }; + }, [resizing, handleResizeEnd, updateTrimFromMouseMove]); + + return { + resizing, + isResizing: resizing !== null, + handleResizeStart, + currentTrimStart, + currentTrimEnd, + currentStartTime, + currentDuration, + }; +} diff --git a/apps/web/src/hooks/timeline/element/use-element-selection.ts b/apps/web/src/hooks/timeline/element/use-element-selection.ts new file mode 100644 index 00000000..38694c8b --- /dev/null +++ b/apps/web/src/hooks/timeline/element/use-element-selection.ts @@ -0,0 +1,114 @@ +import { useCallback, useSyncExternalStore } from "react"; +import { useEditor } from "@/hooks/use-editor"; + +type ElementRef = { trackId: string; elementId: string }; + +export function useElementSelection() { + const editor = useEditor(); + const selectedElements = useSyncExternalStore( + (listener) => editor.selection.subscribe(listener), + () => editor.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], + ); + + /** + * 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, + addElementToSelection, + removeElementFromSelection, + toggleElementSelection, + clearElementSelection, + handleElementClick, + }; +} diff --git a/apps/web/src/hooks/timeline/use-edge-auto-scroll.ts b/apps/web/src/hooks/timeline/use-edge-auto-scroll.ts new file mode 100644 index 00000000..0ddab654 --- /dev/null +++ b/apps/web/src/hooks/timeline/use-edge-auto-scroll.ts @@ -0,0 +1,100 @@ +import { useEffect, useRef } from "react"; + +interface UseEdgeAutoScrollParams { + isActive: boolean; + getMouseClientX: () => number; + rulerScrollRef: React.RefObject<HTMLDivElement>; + tracksScrollRef: React.RefObject<HTMLDivElement>; + contentWidth: number; + edgeThreshold?: number; + maxScrollSpeed?: number; +} + +export function useEdgeAutoScroll({ + isActive, + getMouseClientX, + rulerScrollRef, + tracksScrollRef, + contentWidth, + edgeThreshold = 100, + maxScrollSpeed = 15, +}: UseEdgeAutoScrollParams): void { + const rafRef = useRef<number | null>(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 - edgeThreshold - 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/hooks/timeline/use-scroll-sync.ts b/apps/web/src/hooks/timeline/use-scroll-sync.ts new file mode 100644 index 00000000..cdd915b0 --- /dev/null +++ b/apps/web/src/hooks/timeline/use-scroll-sync.ts @@ -0,0 +1,173 @@ +import { useEffect, useRef } from "react"; + +interface UseScrollSyncProps { + rulerScrollRef?: React.RefObject<HTMLDivElement>; + tracksScrollRef: React.RefObject<HTMLDivElement>; + trackLabelsScrollRef?: React.RefObject<HTMLDivElement>; + bookmarksScrollRef?: React.RefObject<HTMLDivElement>; +} + +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/hooks/timeline/use-selection-box.ts b/apps/web/src/hooks/timeline/use-selection-box.ts new file mode 100644 index 00000000..702abad9 --- /dev/null +++ b/apps/web/src/hooks/timeline/use-selection-box.ts @@ -0,0 +1,243 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { getCumulativeHeightBefore, getTrackHeight } from "@/lib/timeline"; +import { useEditor } from "../use-editor"; + +interface UseSelectionBoxProps { + containerRef: React.RefObject<HTMLElement>; + onSelectionComplete: ( + elements: { trackId: string; elementId: string }[], + ) => void; + isEnabled?: boolean; + tracksScrollRef: React.RefObject<HTMLDivElement>; + zoomLevel: number; +} + +interface SelectionBoxState { + startPos: { x: number; y: number }; + currentPos: { x: number; y: number }; + isActive: boolean; +} + +interface SelectionRectangle { + left: number; + top: number; + right: number; + bottom: number; +} + +function getNormalizedRectangle({ + startPos, + endPos, +}: { + startPos: { x: number; y: number }; + endPos: { x: number; y: number }; +}): SelectionRectangle { + return { + left: Math.min(startPos.x, endPos.x), + top: Math.min(startPos.y, endPos.y), + right: Math.max(startPos.x, endPos.x), + bottom: Math.max(startPos.y, endPos.y), + }; +} + +function getSelectionRectangleInContent({ + container, + scrollContainer, + startPos, + endPos, +}: { + container: HTMLElement; + scrollContainer: HTMLDivElement | null; + startPos: { x: number; y: number }; + endPos: { x: number; y: number }; +}): SelectionRectangle { + const containerRect = container.getBoundingClientRect(); + const scrollLeft = scrollContainer?.scrollLeft ?? 0; + const scrollTop = scrollContainer?.scrollTop ?? 0; + + const adjustedStart = { + x: startPos.x - containerRect.left + scrollLeft, + y: startPos.y - containerRect.top + scrollTop, + }; + const adjustedEnd = { + x: endPos.x - containerRect.left + scrollLeft, + y: endPos.y - containerRect.top + scrollTop, + }; + + return getNormalizedRectangle({ + startPos: adjustedStart, + endPos: adjustedEnd, + }); +} + +function isRectangleIntersecting({ + elementRectangle, + selectionRectangle, +}: { + elementRectangle: SelectionRectangle; + selectionRectangle: SelectionRectangle; +}): boolean { + return !( + elementRectangle.right < selectionRectangle.left || + elementRectangle.left > selectionRectangle.right || + elementRectangle.bottom < selectionRectangle.top || + elementRectangle.top > selectionRectangle.bottom + ); +} + +export function useSelectionBox({ + containerRef, + onSelectionComplete, + isEnabled = true, + tracksScrollRef, + zoomLevel, +}: UseSelectionBoxProps) { + const editor = useEditor(); + const tracks = editor.timeline.getTracks(); + const [selectionBox, setSelectionBox] = useState<SelectionBoxState | null>( + null, + ); + const justFinishedSelectingRef = useRef(false); + + const handleMouseDown = useCallback( + ({ clientX, clientY }: React.MouseEvent) => { + if (!isEnabled) return; + + setSelectionBox({ + startPos: { x: clientX, y: clientY }, + currentPos: { x: clientX, y: clientY }, + isActive: false, + }); + }, + [isEnabled], + ); + + const selectElementsInBox = useCallback( + ({ + startPos, + endPos, + }: { + startPos: { x: number; y: number }; + endPos: { x: number; y: number }; + }) => { + if (!containerRef.current) return; + + const container = containerRef.current; + const selectionRectangle = getSelectionRectangleInContent({ + container, + scrollContainer: tracksScrollRef.current, + startPos, + endPos, + }); + const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const selectedElements: { trackId: string; elementId: string }[] = []; + + for (const [trackIndex, track] of tracks.entries()) { + const trackTop = getCumulativeHeightBefore({ + tracks, + trackIndex, + }); + const trackHeight = getTrackHeight({ type: track.type }); + const elementTop = trackTop; + const elementBottom = trackTop + trackHeight; + + for (const element of track.elements) { + const elementLeft = element.startTime * pixelsPerSecond; + const elementRight = elementLeft + element.duration * pixelsPerSecond; + + const elementRectangle = { + left: elementLeft, + top: elementTop, + right: elementRight, + bottom: elementBottom, + }; + + const intersects = isRectangleIntersecting({ + elementRectangle, + selectionRectangle, + }); + + if (intersects) { + selectedElements.push({ + trackId: track.id, + elementId: element.id, + }); + } + } + } + onSelectionComplete(selectedElements); + }, + [containerRef, onSelectionComplete, tracks, tracksScrollRef, zoomLevel], + ); + + useEffect(() => { + if (!selectionBox) return; + + const handleMouseMove = ({ clientX, clientY }: MouseEvent) => { + const deltaX = Math.abs(clientX - selectionBox.startPos.x); + const deltaY = Math.abs(clientY - selectionBox.startPos.y); + const shouldActivate = deltaX > 5 || deltaY > 5; + + const newSelectionBox = { + ...selectionBox, + currentPos: { x: clientX, y: clientY }, + isActive: shouldActivate || selectionBox.isActive, + }; + + setSelectionBox(newSelectionBox); + + if (newSelectionBox.isActive) { + selectElementsInBox({ + startPos: newSelectionBox.startPos, + endPos: newSelectionBox.currentPos, + }); + } + }; + + const handleMouseUp = () => { + if (selectionBox?.isActive) { + justFinishedSelectingRef.current = true; + requestAnimationFrame(() => { + justFinishedSelectingRef.current = false; + }); + } + setSelectionBox(null); + }; + + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, [selectionBox, selectElementsInBox]); + + useEffect(() => { + if (!selectionBox) return; + + const previousBodyUserSelect = document.body.style.userSelect; + const container = containerRef.current; + const previousContainerUserSelect = container?.style.userSelect ?? ""; + + document.body.style.userSelect = "none"; + if (container) container.style.userSelect = "none"; + + return () => { + document.body.style.userSelect = previousBodyUserSelect; + if (container) container.style.userSelect = previousContainerUserSelect; + }; + }, [selectionBox, containerRef]); + + const shouldIgnoreClick = useCallback(() => { + return justFinishedSelectingRef.current; + }, []); + + return { + selectionBox, + handleMouseDown, + isSelecting: selectionBox?.isActive || false, + shouldIgnoreClick, + }; +} diff --git a/apps/web/src/hooks/timeline/use-snap-indicator-position.ts b/apps/web/src/hooks/timeline/use-snap-indicator-position.ts new file mode 100644 index 00000000..c6ecbf7c --- /dev/null +++ b/apps/web/src/hooks/timeline/use-snap-indicator-position.ts @@ -0,0 +1,62 @@ +import { useEffect, useState } from "react"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import type { TimelineTrack } from "@/types/timeline"; + +interface UseSnapIndicatorPositionParams { + snapPoint: { time: number } | null; + zoomLevel: number; + tracks: TimelineTrack[]; + timelineRef: React.RefObject<HTMLDivElement>; + trackLabelsRef?: React.RefObject<HTMLDivElement>; + tracksScrollRef: React.RefObject<HTMLDivElement>; +} + +interface SnapIndicatorPosition { + leftPosition: number; + topPosition: number; + height: number; +} + +export function useSnapIndicatorPosition({ + snapPoint, + zoomLevel, + tracks, + timelineRef, + trackLabelsRef, + 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 trackLabelsWidth = + tracks.length > 0 && trackLabelsRef?.current + ? trackLabelsRef.current.offsetWidth + : 0; + + const timelinePosition = + (snapPoint?.time || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const leftPosition = trackLabelsWidth + timelinePosition - scrollLeft; + + return { + leftPosition, + topPosition: 0, + height: totalHeight, + }; +} diff --git a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts new file mode 100644 index 00000000..01af9362 --- /dev/null +++ b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts @@ -0,0 +1,460 @@ +import { useState, useCallback, type RefObject } from "react"; +import { useEditor } from "@/hooks/use-editor"; +import { processMediaAssets } from "@/lib/media/processing"; +import { toast } from "sonner"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { snapTimeToFrame } from "@/lib/time"; +import { + buildTextElement, + buildStickerElement, + buildUploadAudioElement, + buildVideoElement, + buildImageElement, +} from "@/lib/timeline/element-utils"; +import { computeDropTarget } from "@/lib/timeline/drop-utils"; +import { getDragData, hasDragData } from "@/lib/drag-data"; +import type { TrackType, DropTarget, ElementType } from "@/types/timeline"; +import type { MediaDragData, StickerDragData } from "@/types/drag"; + +interface UseTimelineDragDropProps { + containerRef: RefObject<HTMLDivElement | null>; + headerRef?: RefObject<HTMLElement | null>; + zoomLevel: number; +} + +export function useTimelineDragDrop({ + containerRef, + headerRef, + zoomLevel, +}: UseTimelineDragDropProps) { + const editor = useEditor(); + const [isDragOver, setIsDragOver] = useState(false); + const [dropTarget, setDropTarget] = useState<DropTarget | null>(null); + const [dragElementType, setElementType] = useState<ElementType | null>(null); + + const tracks = editor.timeline.getTracks(); + const currentTime = editor.playback.getCurrentTime(); + const mediaAssets = editor.media.getAssets(); + const activeProject = editor.project.getActive(); + + const getSnappedTime = useCallback( + ({ time }: { time: number }) => { + const projectFps = activeProject.settings.fps; + return snapTimeToFrame({ time, fps: projectFps }); + }, + [activeProject.settings.fps], + ); + + 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 === "sticker") return "sticker"; + if (dragData.type === "media") { + return dragData.mediaType; + } + return null; + }, + [], + ); + + const getElementDuration = useCallback( + ({ + elementType, + mediaId, + }: { + elementType: ElementType; + mediaId?: string; + }): number => { + if (elementType === "text" || elementType === "sticker") { + return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; + } + if (mediaId) { + const media = mediaAssets.find((m) => m.id === mediaId); + return media?.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; + } + return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; + }, + [mediaAssets], + ); + + 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 rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + + const headerHeight = + headerRef?.current?.getBoundingClientRect().height ?? 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 - rect.left; + const mouseY = Math.max(0, e.clientY - rect.top - headerHeight); + + const target = computeDropTarget({ + elementType, + mouseX, + mouseY, + tracks, + playheadTime: currentTime, + isExternalDrop: isExternal, + elementDuration: duration, + pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND, + zoomLevel, + }); + + target.xPosition = getSnappedTime({ time: target.xPosition }); + + setDropTarget(target); + e.dataTransfer.dropEffect = "copy"; + }, + [ + containerRef, + headerRef, + tracks, + currentTime, + zoomLevel, + getElementType, + getElementDuration, + getSnappedTime, + ], + ); + + 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 }; + }) => { + let trackId: string; + + if (target.isNewTrack) { + trackId = editor.timeline.addTrack({ + type: "text", + index: target.trackIndex, + }); + } else { + const track = tracks[target.trackIndex]; + if (!track) return; + trackId = track.id; + } + + const element = buildTextElement({ + raw: { + name: dragData.name ?? "", + content: dragData.content ?? "", + }, + startTime: target.xPosition, + }); + + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId }, + element, + }); + }, + [editor.timeline, tracks], + ); + + const executeStickerDrop = useCallback( + ({ + target, + dragData, + }: { + target: DropTarget; + dragData: StickerDragData; + }) => { + let trackId: string; + + if (target.isNewTrack) { + trackId = editor.timeline.addTrack({ + type: "sticker", + index: target.trackIndex, + }); + } else { + const track = tracks[target.trackIndex]; + if (!track) return; + trackId = track.id; + } + + const element = buildStickerElement({ + iconName: dragData.iconName, + startTime: target.xPosition, + }); + + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId }, + element, + }); + }, + [editor.timeline, tracks], + ); + + const executeMediaDrop = useCallback( + ({ target, dragData }: { target: DropTarget; dragData: MediaDragData }) => { + const mediaAsset = mediaAssets.find((m) => m.id === dragData.id); + if (!mediaAsset) return; + + const trackType: TrackType = + dragData.mediaType === "audio" ? "audio" : "video"; + let trackId: string; + + if (target.isNewTrack) { + trackId = editor.timeline.addTrack({ + type: trackType, + index: target.trackIndex, + }); + } else { + const track = tracks[target.trackIndex]; + if (!track) return; + trackId = track.id; + } + + const duration = + mediaAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; + + if (dragData.mediaType === "audio") { + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId }, + element: buildUploadAudioElement({ + mediaId: mediaAsset.id, + name: mediaAsset.name, + duration, + startTime: target.xPosition, + }), + }); + } else if (dragData.mediaType === "video") { + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId }, + element: buildVideoElement({ + mediaId: mediaAsset.id, + name: mediaAsset.name, + duration, + startTime: target.xPosition, + }), + }); + } else { + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId }, + element: buildImageElement({ + mediaId: mediaAsset.id, + name: mediaAsset.name, + duration, + startTime: target.xPosition, + }), + }); + } + }, + [editor.timeline, mediaAssets, tracks], + ); + + const executeFileDrop = useCallback( + async ({ + files, + mouseX, + mouseY, + }: { + files: File[]; + mouseX: number; + mouseY: number; + }) => { + if (!activeProject) return; + + const processedAssets = await processMediaAssets({ files }); + + for (const asset of processedAssets) { + await editor.media.addMediaAsset({ + projectId: activeProject.metadata.id, + asset, + }); + + const added = editor.media + .getAssets() + .find((m) => m.name === asset.name && m.url === asset.url); + + if (added) { + const duration = + added.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; + const currentTracks = editor.timeline.getTracks(); + const dropTarget = computeDropTarget({ + elementType: added.type, + mouseX, + mouseY, + tracks: currentTracks, + playheadTime: currentTime, + isExternalDrop: true, + elementDuration: duration, + pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND, + zoomLevel, + }); + + const trackType: TrackType = + added.type === "audio" ? "audio" : "video"; + const trackId = dropTarget.isNewTrack + ? editor.timeline.addTrack({ + type: trackType, + index: dropTarget.trackIndex, + }) + : currentTracks[dropTarget.trackIndex]?.id; + + if (!trackId) return; + + if (added.type === "audio") { + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId }, + element: buildUploadAudioElement({ + mediaId: added.id, + name: added.name, + duration, + startTime: dropTarget.xPosition, + buffer: new AudioBuffer({ length: 1, sampleRate: 44100 }), + }), + }); + } else if (added.type === "video") { + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId }, + element: buildVideoElement({ + mediaId: added.id, + name: added.name, + duration, + startTime: dropTarget.xPosition, + }), + }); + } else { + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId }, + element: buildImageElement({ + mediaId: added.id, + name: added.name, + duration, + startTime: dropTarget.xPosition, + }), + }); + } + } + } + }, + [activeProject, editor.media, editor.timeline, currentTime, 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 === "sticker") { + executeStickerDrop({ target: currentTarget, dragData }); + } else { + executeMediaDrop({ target: currentTarget, dragData }); + } + } else if (hasFiles) { + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + const mouseX = e.clientX - rect.left; + const headerHeight = + headerRef?.current?.getBoundingClientRect().height ?? 0; + const mouseY = Math.max(0, e.clientY - rect.top - headerHeight); + await executeFileDrop({ + files: Array.from(e.dataTransfer.files), + mouseX, + mouseY, + }); + } + } catch (err) { + console.error("Failed to process drop:", err); + toast.error("Failed to process drop"); + } + }, + [ + dropTarget, + executeTextDrop, + executeStickerDrop, + executeMediaDrop, + executeFileDrop, + containerRef, + headerRef, + ], + ); + + return { + isDragOver, + dropTarget, + dragElementType, + dragProps: { + onDragEnter: handleDragEnter, + onDragOver: handleDragOver, + onDragLeave: handleDragLeave, + onDrop: handleDrop, + }, + }; +} diff --git a/apps/web/src/hooks/timeline/use-timeline-playhead.ts b/apps/web/src/hooks/timeline/use-timeline-playhead.ts new file mode 100644 index 00000000..55e04a68 --- /dev/null +++ b/apps/web/src/hooks/timeline/use-timeline-playhead.ts @@ -0,0 +1,218 @@ +import { getSnappedSeekTime } from "@/lib/time"; +import { useState, useEffect, useCallback, useRef } from "react"; +import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; +import { useEditor } from "../use-editor"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; + +interface UseTimelinePlayheadProps { + zoomLevel: number; + rulerRef: React.RefObject<HTMLDivElement>; + rulerScrollRef: React.RefObject<HTMLDivElement>; + tracksScrollRef: React.RefObject<HTMLDivElement>; + playheadRef?: React.RefObject<HTMLDivElement>; +} + +export function useTimelinePlayhead({ + zoomLevel, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, +}: UseTimelinePlayheadProps) { + const editor = useEditor(); + const activeProject = editor.project.getActive(); + const currentTime = editor.playback.getCurrentTime(); + const duration = editor.timeline.getTotalDuration(); + const isPlaying = editor.playback.getIsPlaying(); + + const seek = useCallback( + ({ time }: { time: number }) => editor.playback.seek({ time }), + [editor.playback], + ); + + const [isScrubbing, setIsScrubbing] = useState(false); + const [scrubTime, setScrubTime] = useState<number | null>(null); + + const [isDraggingRuler, setIsDraggingRuler] = useState(false); + const [hasDraggedRuler, setHasDraggedRuler] = useState(false); + const lastMouseXRef = useRef<number>(0); + + const playheadPosition = + isScrubbing && scrubTime !== null ? scrubTime : currentTime; + + const handleScrub = useCallback( + ({ event }: { event: MouseEvent | React.MouseEvent }) => { + const ruler = rulerRef.current; + if (!ruler) return; + const rulerRect = ruler.getBoundingClientRect(); + const relativeMouseX = event.clientX - rulerRect.left; + + const timelineContentWidth = + duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + + const clampedMouseX = Math.max( + 0, + Math.min(timelineContentWidth, relativeMouseX), + ); + + const rawTime = Math.max( + 0, + Math.min( + duration, + clampedMouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel), + ), + ); + const framesPerSecond = activeProject.settings.fps; + const time = getSnappedSeekTime({ + rawTime, + duration, + fps: framesPerSecond, + }); + + setScrubTime(time); + seek({ time }); + + lastMouseXRef.current = event.clientX; + }, + [duration, zoomLevel, seek, rulerRef, activeProject.settings.fps], + ); + + const handlePlayheadMouseDown = useCallback( + ({ event }: { event: React.MouseEvent }) => { + event.preventDefault(); + event.stopPropagation(); + setIsScrubbing(true); + handleScrub({ event }); + }, + [handleScrub], + ); + + const handleRulerMouseDown = useCallback( + ({ event }: { event: React.MouseEvent }) => { + if (event.button !== 0) return; + + if (playheadRef?.current?.contains(event.target as Node)) return; + + event.preventDefault(); + setIsDraggingRuler(true); + setHasDraggedRuler(false); + + setIsScrubbing(true); + handleScrub({ event }); + }, + [handleScrub, playheadRef], + ); + + 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: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, + }); + + useEffect(() => { + if (!isScrubbing) return; + + const handleMouseMove = ({ event }: { event: MouseEvent }) => { + handleScrub({ event }); + if (isDraggingRuler) { + setHasDraggedRuler(true); + } + }; + + const handleMouseUp = ({ event }: { event: MouseEvent }) => { + setIsScrubbing(false); + if (scrubTime !== null) { + seek({ time: scrubTime }); + editor.project.setTimelineViewState({ + viewState: { + zoomLevel, + scrollLeft: tracksScrollRef.current?.scrollLeft ?? 0, + playheadTime: scrubTime, + }, + }); + } + setScrubTime(null); + + if (isDraggingRuler) { + setIsDraggingRuler(false); + if (!hasDraggedRuler) { + handleScrub({ event }); + } + setHasDraggedRuler(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, + scrubTime, + seek, + handleScrub, + isDraggingRuler, + hasDraggedRuler, + editor, + tracksScrollRef, + zoomLevel, + ]); + + useEffect(() => { + if (!isPlaying || isScrubbing) return; + + const rulerViewport = rulerScrollRef.current; + const tracksViewport = tracksScrollRef.current; + if (!rulerViewport || !tracksViewport) return; + + const playheadPixels = + playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + 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; + } + }, [ + playheadPosition, + zoomLevel, + rulerScrollRef, + tracksScrollRef, + isScrubbing, + isPlaying, + ]); + + return { + playheadPosition, + handlePlayheadMouseDown: handlePlayheadMouseDownEvent, + handleRulerMouseDown: handleRulerMouseDownEvent, + isDraggingRuler, + }; +} diff --git a/apps/web/src/hooks/timeline/use-timeline-seek.ts b/apps/web/src/hooks/timeline/use-timeline-seek.ts new file mode 100644 index 00000000..f5dc6ddb --- /dev/null +++ b/apps/web/src/hooks/timeline/use-timeline-seek.ts @@ -0,0 +1,197 @@ +import { useCallback, useRef } from "react"; +import type { MutableRefObject, RefObject } from "react"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { getSnappedSeekTime } from "@/lib/time"; +import { useEditor } from "../use-editor"; + +interface UseTimelineSeekProps { + playheadRef: RefObject<HTMLDivElement>; + trackLabelsRef: RefObject<HTMLDivElement>; + rulerScrollRef: RefObject<HTMLDivElement>; + tracksScrollRef: RefObject<HTMLDivElement>; + 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 rawTime = Math.max( + 0, + Math.min( + duration, + (mouseX + scrollLeft) / + (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel), + ), + ); + + const projectFps = activeProject?.settings.fps || 30; + const time = getSnappedSeekTime({ + rawTime, + duration, + fps: projectFps, + }); + seek(time); + editor.project.setTimelineViewState({ + viewState: { + zoomLevel, + scrollLeft: scrollContainer.scrollLeft, + playheadTime: time, + }, + }); + }, + [ + duration, + zoomLevel, + rulerScrollRef, + tracksScrollRef, + seek, + editor, + activeProject?.settings.fps, + ], + ); + + 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/hooks/timeline/use-timeline-snapping.ts b/apps/web/src/hooks/timeline/use-timeline-snapping.ts new file mode 100644 index 00000000..4cd6f775 --- /dev/null +++ b/apps/web/src/hooks/timeline/use-timeline-snapping.ts @@ -0,0 +1,159 @@ +import { useCallback } from "react"; +import type { TimelineTrack } from "@/types/timeline"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; + +export interface SnapPoint { + time: number; + type: "element-start" | "element-end" | "playhead"; + elementId?: string; + trackId?: string; +} + +export interface SnapResult { + snappedTime: number; + snapPoint: SnapPoint | null; + snapDistance: number; +} + +export interface UseTimelineSnappingOptions { + snapThreshold?: number; + enableElementSnapping?: boolean; + enablePlayheadSnapping?: boolean; +} + +export function useTimelineSnapping({ + snapThreshold = 10, + enableElementSnapping = true, + enablePlayheadSnapping = true, +}: UseTimelineSnappingOptions = {}) { + const findSnapPoints = useCallback( + ({ + tracks, + playheadTime, + excludeElementId, + }: { + tracks: Array<TimelineTrack>; + playheadTime: number; + excludeElementId?: string; + }): SnapPoint[] => { + const snapPoints: SnapPoint[] = []; + + if (enableElementSnapping) { + for (const track of tracks) { + for (const element of track.elements) { + if (element.id === excludeElementId) continue; + + const elementStart = element.startTime; + const elementEnd = element.startTime + element.duration; + + snapPoints.push( + { + time: elementStart, + type: "element-start", + elementId: element.id, + trackId: track.id, + }, + { + time: elementEnd, + type: "element-end", + elementId: element.id, + trackId: track.id, + }, + ); + } + } + } + + if (enablePlayheadSnapping) { + snapPoints.push({ + time: playheadTime, + type: "playhead", + }); + } + + return snapPoints; + }, + [enableElementSnapping, enablePlayheadSnapping], + ); + + const snapToNearestPoint = useCallback( + ({ + targetTime, + snapPoints, + zoomLevel, + }: { + targetTime: number; + snapPoints: Array<SnapPoint>; + zoomLevel: number; + }): SnapResult => { + const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const thresholdInSeconds = snapThreshold / pixelsPerSecond; + + let closestSnapPoint: SnapPoint | null = null; + let closestDistance = Infinity; + + for (const snapPoint of snapPoints) { + const distance = Math.abs(targetTime - snapPoint.time); + if (distance < thresholdInSeconds && distance < closestDistance) { + closestDistance = distance; + closestSnapPoint = snapPoint; + } + } + + return { + snappedTime: closestSnapPoint ? closestSnapPoint.time : targetTime, + snapPoint: closestSnapPoint, + snapDistance: closestDistance, + }; + }, + [snapThreshold], + ); + + const snapElementEdge = useCallback( + ({ + targetTime, + elementDuration, + tracks, + playheadTime, + zoomLevel, + excludeElementId, + snapToStart = true, + }: { + targetTime: number; + elementDuration: number; + tracks: Array<TimelineTrack>; + playheadTime: number; + zoomLevel: number; + excludeElementId?: string; + snapToStart?: boolean; + }): SnapResult => { + const snapPoints = findSnapPoints({ + tracks, + playheadTime, + excludeElementId, + }); + + const effectiveTargetTime = snapToStart + ? targetTime + : targetTime + elementDuration; + const snapResult = snapToNearestPoint({ + targetTime: effectiveTargetTime, + snapPoints, + zoomLevel, + }); + + if (!snapToStart && snapResult.snapPoint) { + snapResult.snappedTime = snapResult.snappedTime - elementDuration; + } + + return snapResult; + }, + [findSnapPoints, snapToNearestPoint], + ); + + return { + snapElementEdge, + findSnapPoints, + snapToNearestPoint, + }; +} diff --git a/apps/web/src/hooks/timeline/use-timeline-zoom.ts b/apps/web/src/hooks/timeline/use-timeline-zoom.ts new file mode 100644 index 00000000..96a4be43 --- /dev/null +++ b/apps/web/src/hooks/timeline/use-timeline-zoom.ts @@ -0,0 +1,224 @@ +import { + type WheelEvent as ReactWheelEvent, + type RefObject, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { useEditor } from "@/hooks/use-editor"; + +interface UseTimelineZoomProps { + containerRef: RefObject<HTMLDivElement>; + minZoom?: number; + initialZoom?: number; + initialScrollLeft?: number; + initialPlayheadTime?: number; + tracksScrollRef: RefObject<HTMLDivElement>; + rulerScrollRef: RefObject<HTMLDivElement>; +} + +interface UseTimelineZoomReturn { + zoomLevel: number; + setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void; + handleWheel: (event: ReactWheelEvent) => void; + saveScrollPosition: () => void; +} + +export function useTimelineZoom({ + containerRef, + minZoom = TIMELINE_CONSTANTS.ZOOM_MIN, + initialZoom, + initialScrollLeft, + initialPlayheadTime, + tracksScrollRef, + rulerScrollRef, +}: UseTimelineZoomProps): UseTimelineZoomReturn { + const editor = useEditor(); + const hasInitializedRef = useRef(false); + const hasRestoredPlayheadRef = useRef(false); + const scrollSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>( + null, + ); + + const [zoomLevel, setZoomLevel] = useState(() => { + if (initialZoom !== undefined) { + hasInitializedRef.current = true; + return Math.max( + minZoom, + Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, initialZoom), + ); + } + return minZoom; + }); + const previousZoomRef = useRef(zoomLevel); + const hasRestoredScrollRef = useRef(false); + + 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 zoomMultiplier = event.deltaY > 0 ? 1 / 1.1 : 1.1; + setZoomLevel((prev) => { + const nextZoom = Math.max( + minZoom, + Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, prev * zoomMultiplier), + ); + return nextZoom; + }); + // for horizontal scrolling (when shift is held or horizontal wheel movement), + // let the event bubble up to allow ScrollArea to handle it + return; + } + }, + [minZoom], + ); + + useEffect(() => { + if (initialZoom !== undefined && !hasInitializedRef.current) { + hasInitializedRef.current = true; + setZoomLevel( + Math.max(minZoom, Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, initialZoom)), + ); + return; + } + setZoomLevel((prev) => { + if (prev < minZoom) { + return minZoom; + } + return prev; + }); + }, [minZoom, initialZoom]); + + 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_CONSTANTS.ZOOM_MAX, nextZoom), + ); + return clampedZoom; + }); + }, + [minZoom], + ); + + useLayoutEffect(() => { + const previousZoom = previousZoomRef.current; + if (previousZoom === zoomLevel) return; + + const scrollElement = tracksScrollRef.current; + if (scrollElement) { + editor.project.setTimelineViewState({ + viewState: { + zoomLevel, + scrollLeft: scrollElement.scrollLeft, + playheadTime: editor.playback.getCurrentTime(), + }, + }); + } + + previousZoomRef.current = zoomLevel; + }, [zoomLevel, editor, tracksScrollRef]); + + // 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(); + hasRestoredScrollRef.current = true; + 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, + }; +} |
