diff options
Diffstat (limited to 'apps/web/src/hooks/timeline')
12 files changed, 596 insertions, 515 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 index ea01c718..fae8c527 100644 --- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts +++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts @@ -18,16 +18,14 @@ import { snapTimeToFrame } from "@/lib/time"; import { computeDropTarget } from "@/lib/timeline/drop-utils"; import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils"; import { generateUUID } from "@/utils/id"; -import { - snapElementEdge, - type SnapPoint, -} from "@/lib/timeline/snap-utils"; +import { snapElementEdge, type SnapPoint } from "@/lib/timeline/snap-utils"; +import { registerCanceller } from "@/lib/cancel-interaction"; import type { DropTarget, ElementDragState, TimelineElement, TimelineTrack, -} from "@/types/timeline"; +} from "@/lib/timeline"; interface UseElementInteractionProps { zoomLevel: number; @@ -213,6 +211,20 @@ export function useElementInteraction({ setDragDropTarget(null); }, []); + const cancelCurrentDrag = useCallback(() => { + pendingDragRef.current = null; + mouseDownLocationRef.current = null; + setIsPendingDrag(false); + endDrag(); + onSnapPointChange?.(null); + }, [endDrag, onSnapPointChange]); + + useEffect(() => { + if (!dragState.isDragging && !isPendingDrag) return; + + return registerCanceller({ fn: cancelCurrentDrag }); + }, [dragState.isDragging, isPendingDrag, cancelCurrentDrag]); + const getDragSnapResult = useCallback( ({ frameSnappedTime, @@ -445,33 +457,36 @@ export function useElementInteraction({ return; } - if (dropTarget.isNewTrack) { - const newTrackId = generateUUID(); + 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 }, - rippleEnabled: rippleEditingEnabled, - }); - selectElement({ trackId: newTrackId, elementId: dragState.elementId }); - } else { - const targetTrack = tracks[dropTarget.trackIndex]; - if (targetTrack) { editor.timeline.moveElement({ sourceTrackId: dragState.trackId, - targetTrackId: targetTrack.id, + targetTrackId: newTrackId, elementId: dragState.elementId, newStartTime: snappedTime, + createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex }, rippleEnabled: rippleEditingEnabled, }); - if (targetTrack.id !== dragState.trackId) { - selectElement({ trackId: targetTrack.id, elementId: dragState.elementId }); + selectElement({ trackId: newTrackId, elementId: dragState.elementId }); + } else { + const targetTrack = tracks[dropTarget.trackIndex]; + if (targetTrack) { + editor.timeline.moveElement({ + sourceTrackId: dragState.trackId, + targetTrackId: targetTrack.id, + elementId: dragState.elementId, + newStartTime: snappedTime, + rippleEnabled: rippleEditingEnabled, + }); + if (targetTrack.id !== dragState.trackId) { + selectElement({ + trackId: targetTrack.id, + elementId: dragState.elementId, + }); + } } } - } endDrag(); onSnapPointChange?.(null); @@ -520,10 +535,10 @@ export function useElementInteraction({ element: TimelineElement; track: TimelineTrack; }) => { - const isRightClick = event.button === MOUSE_BUTTON_RIGHT; + const isRightClick = event.button === MOUSE_BUTTON_RIGHT; - // right-click: don't stop propagation so ContextMenu can open - if (isRightClick) { + // right-click: don't stop propagation so ContextMenu can open + if (isRightClick) { const alreadySelected = isElementSelected({ trackId: track.id, elementId: element.id, @@ -538,12 +553,12 @@ export function useElementInteraction({ return; } - event.stopPropagation(); - mouseDownLocationRef.current = { x: event.clientX, y: event.clientY }; + event.stopPropagation(); + mouseDownLocationRef.current = { x: event.clientX, y: event.clientY }; - const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey; + const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey; - if (isMultiSelect) { + if (isMultiSelect) { handleSelectionClick({ trackId: track.id, elementId: element.id, @@ -551,7 +566,7 @@ export function useElementInteraction({ }); } - const clickOffsetTime = getClickOffsetTime({ + const clickOffsetTime = getClickOffsetTime({ clientX: event.clientX, elementRect: event.currentTarget.getBoundingClientRect(), zoomLevel, @@ -579,9 +594,9 @@ export function useElementInteraction({ element: TimelineElement; track: TimelineTrack; }) => { - event.stopPropagation(); + event.stopPropagation(); - if (mouseDownLocationRef.current) { + 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) { @@ -593,7 +608,7 @@ export function useElementInteraction({ // modifier keys already handled in mousedown if (event.metaKey || event.ctrlKey || event.shiftKey) return; - const alreadySelected = isElementSelected({ + const alreadySelected = isElementSelected({ trackId: track.id, elementId: element.id, }); diff --git a/apps/web/src/hooks/timeline/element/use-element-resize.ts b/apps/web/src/hooks/timeline/element/use-element-resize.ts index 3f49e798..b41f82f9 100644 --- a/apps/web/src/hooks/timeline/element/use-element-resize.ts +++ b/apps/web/src/hooks/timeline/element/use-element-resize.ts @@ -1,7 +1,7 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { snapTimeToFrame } from "@/lib/time"; -import type { TimelineElement, TimelineTrack } from "@/types/timeline"; +import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; import { useEditor } from "@/hooks/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; import { @@ -9,7 +9,13 @@ import { snapToNearestPoint, type SnapPoint, } from "@/lib/timeline/snap-utils"; +import { isRetimableElement } from "@/lib/timeline"; +import { + getSourceSpanAtClipTime, + getTimelineDurationForSourceSpan, +} from "@/lib/retime"; import { useTimelineStore } from "@/stores/timeline-store"; +import { registerCanceller } from "@/lib/cancel-interaction"; export interface ResizeState { elementId: string; @@ -37,7 +43,6 @@ export function useTimelineElementResize({ onResizeStateChange, }: UseTimelineElementResizeProps) { const editor = useEditor(); - const activeProject = editor.project.getActive(); const isShiftHeldRef = useShiftKey(); const snappingEnabled = useTimelineStore((state) => state.snappingEnabled); const rippleEditingEnabled = useTimelineStore( @@ -91,6 +96,94 @@ export function useTimelineElementResize({ return element.sourceDuration == null; }, [element.sourceDuration]); + const getSourceDeltaForClipDelta = useCallback( + (clipDelta: number) => { + if (!isRetimableElement(element)) { + return clipDelta; + } + + return clipDelta >= 0 + ? getSourceSpanAtClipTime({ + clipTime: clipDelta, + retime: element.retime, + }) + : -getSourceSpanAtClipTime({ + clipTime: Math.abs(clipDelta), + retime: element.retime, + }); + }, + [element], + ); + + const getVisibleSourceSpanForDuration = useCallback( + (duration: number) => { + if (!isRetimableElement(element)) { + return duration; + } + + return getSourceSpanAtClipTime({ + clipTime: duration, + retime: element.retime, + }); + }, + [element], + ); + + const getDurationForVisibleSourceSpan = useCallback( + (sourceSpan: number) => { + if (!isRetimableElement(element)) { + return sourceSpan; + } + + return getTimelineDurationForSourceSpan({ + sourceSpan, + retime: element.retime, + }); + }, + [element], + ); + + const getSourceDuration = useCallback( + ({ + trimStart, + duration, + trimEnd, + }: { + trimStart: number; + duration: number; + trimEnd: number; + }) => { + if (typeof element.sourceDuration === "number") { + return element.sourceDuration; + } + + return trimStart + getVisibleSourceSpanForDuration(duration) + trimEnd; + }, + [element.sourceDuration, getVisibleSourceSpanForDuration], + ); + + const cancelResize = useCallback(() => { + if (!resizing) return; + + setCurrentTrimStart(resizing.initialTrimStart); + setCurrentTrimEnd(resizing.initialTrimEnd); + setCurrentStartTime(resizing.initialStartTime); + setCurrentDuration(resizing.initialDuration); + currentTrimStartRef.current = resizing.initialTrimStart; + currentTrimEndRef.current = resizing.initialTrimEnd; + currentStartTimeRef.current = resizing.initialStartTime; + currentDurationRef.current = resizing.initialDuration; + setResizing(null); + onResizeStateChange?.({ isResizing: false }); + onSnapPointChange?.(null); + }, [resizing, onResizeStateChange, onSnapPointChange]); + + useEffect(() => { + if (!resizing) return; + + return registerCanceller({ fn: cancelResize }); + }, [resizing, cancelResize]); + const updateTrimFromMouseMove = useCallback( ({ clientX }: { clientX: number }) => { if (!resizing) return; @@ -100,7 +193,7 @@ export function useTimelineElementResize({ deltaX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel); let resizeSnapPoint: SnapPoint | null = null; - const projectFps = activeProject.settings.fps; + const projectFps = editor.project.getActive().settings.fps; const minDurationSeconds = 1 / projectFps; const shouldSnap = snappingEnabled && !isShiftHeldRef.current; if (shouldSnap) { @@ -139,14 +232,20 @@ export function useTimelineElementResize({ } onSnapPointChange?.(resizeSnapPoint); - const otherElements = track.elements.filter(({ id }) => id !== element.id); - const initialEndTime = resizing.initialStartTime + resizing.initialDuration; + const otherElements = track.elements.filter( + ({ id }) => id !== element.id, + ); + const initialEndTime = + resizing.initialStartTime + resizing.initialDuration; const rightNeighborBound = resizing.side === "right" ? otherElements .filter(({ startTime }) => startTime >= initialEndTime) - .reduce((min, { startTime }) => Math.min(min, startTime), Infinity) + .reduce( + (min, { startTime }) => Math.min(min, startTime), + Infinity, + ) : Infinity; const leftNeighborBound = @@ -164,20 +263,26 @@ export function useTimelineElementResize({ : -Infinity; if (resizing.side === "left") { - const sourceDuration = - resizing.initialTrimStart + - resizing.initialDuration + - resizing.initialTrimEnd; + const sourceDuration = getSourceDuration({ + trimStart: resizing.initialTrimStart, + duration: resizing.initialDuration, + trimEnd: resizing.initialTrimEnd, + }); const minTrimStartForNeighbor = Number.isFinite(leftNeighborBound) ? Math.max( 0, resizing.initialTrimStart + - (leftNeighborBound - resizing.initialStartTime), + getSourceDeltaForClipDelta( + leftNeighborBound - resizing.initialStartTime, + ), ) : 0; const maxAllowed = - sourceDuration - resizing.initialTrimEnd - minDurationSeconds; - const calculated = resizing.initialTrimStart + deltaTime; + sourceDuration - + resizing.initialTrimEnd - + getVisibleSourceSpanForDuration(minDurationSeconds); + const calculated = + resizing.initialTrimStart + getSourceDeltaForClipDelta(deltaTime); if (calculated >= 0 && calculated <= maxAllowed) { const newTrimStart = snapTimeToFrame({ @@ -187,13 +292,17 @@ export function useTimelineElementResize({ ), fps: projectFps, }); - const trimDelta = newTrimStart - resizing.initialTrimStart; - const newStartTime = snapTimeToFrame({ - time: resizing.initialStartTime + trimDelta, + const visibleSourceSpan = Math.max( + 0, + sourceDuration - newTrimStart - resizing.initialTrimEnd, + ); + const newDuration = snapTimeToFrame({ + time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps, }); - const newDuration = snapTimeToFrame({ - time: resizing.initialDuration - trimDelta, + const trimDelta = resizing.initialDuration - newDuration; + const newStartTime = snapTimeToFrame({ + time: resizing.initialStartTime + trimDelta, fps: projectFps, }); @@ -233,31 +342,50 @@ export function useTimelineElementResize({ currentStartTimeRef.current = newStartTime; currentDurationRef.current = newDuration; } else { - const trimDelta = + const leftBound = Number.isFinite(leftNeighborBound) + ? leftNeighborBound + : 0; + const trimDeltaFromTrimStart = minTrimStartForNeighbor - resizing.initialTrimStart; - const newStartTime = snapTimeToFrame({ - time: resizing.initialStartTime + trimDelta, + const trimDeltaFromStartTime = getSourceDeltaForClipDelta( + leftBound - resizing.initialStartTime, + ); + const trimDelta = Math.max( + trimDeltaFromTrimStart, + trimDeltaFromStartTime, + ); + const newTrimStart = resizing.initialTrimStart + trimDelta; + const visibleSourceSpan = Math.max( + 0, + sourceDuration - newTrimStart - resizing.initialTrimEnd, + ); + const newDuration = snapTimeToFrame({ + time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps, }); - const newDuration = snapTimeToFrame({ - time: resizing.initialDuration - trimDelta, + const newStartTime = snapTimeToFrame({ + time: + resizing.initialStartTime + + (resizing.initialDuration - newDuration), fps: projectFps, }); - setCurrentTrimStart(minTrimStartForNeighbor); + setCurrentTrimStart(newTrimStart); setCurrentStartTime(newStartTime); setCurrentDuration(newDuration); - currentTrimStartRef.current = minTrimStartForNeighbor; + currentTrimStartRef.current = newTrimStart; currentStartTimeRef.current = newStartTime; currentDurationRef.current = newDuration; } } } else { - const sourceDuration = - resizing.initialTrimStart + - resizing.initialDuration + - resizing.initialTrimEnd; - const newTrimEnd = resizing.initialTrimEnd - deltaTime; + const sourceDuration = getSourceDuration({ + trimStart: resizing.initialTrimStart, + duration: resizing.initialDuration, + trimEnd: resizing.initialTrimEnd, + }); + const newTrimEnd = + resizing.initialTrimEnd - getSourceDeltaForClipDelta(deltaTime); const maxAllowedDuration = Number.isFinite(rightNeighborBound) ? rightNeighborBound - resizing.initialStartTime : Infinity; @@ -268,7 +396,10 @@ export function useTimelineElementResize({ const baseDuration = resizing.initialDuration + resizing.initialTrimEnd; const newDuration = snapTimeToFrame({ - time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration), + time: Math.min( + baseDuration + extensionNeeded, + maxAllowedDuration, + ), fps: projectFps, }); @@ -277,12 +408,11 @@ export function useTimelineElementResize({ currentDurationRef.current = newDuration; currentTrimEndRef.current = 0; } else { - const extensionToLimit = resizing.initialTrimEnd; + const unclampedDuration = getDurationForVisibleSourceSpan( + Math.max(0, sourceDuration - resizing.initialTrimStart), + ); const newDuration = snapTimeToFrame({ - time: Math.min( - resizing.initialDuration + extensionToLimit, - maxAllowedDuration, - ), + time: Math.min(unclampedDuration, maxAllowedDuration), fps: projectFps, }); @@ -295,13 +425,15 @@ export function useTimelineElementResize({ const minTrimEndForNeighbor = Number.isFinite(maxAllowedDuration) ? Math.max( 0, - resizing.initialDuration + - resizing.initialTrimEnd - - maxAllowedDuration, + sourceDuration - + resizing.initialTrimStart - + getVisibleSourceSpanForDuration(maxAllowedDuration), ) : 0; const maxTrimEnd = - sourceDuration - resizing.initialTrimStart - minDurationSeconds; + sourceDuration - + resizing.initialTrimStart - + getVisibleSourceSpanForDuration(minDurationSeconds); const clampedTrimEnd = Math.min( maxTrimEnd, Math.max(minTrimEndForNeighbor, newTrimEnd), @@ -310,9 +442,12 @@ export function useTimelineElementResize({ time: clampedTrimEnd, fps: projectFps, }); - const trimDelta = finalTrimEnd - resizing.initialTrimEnd; + const visibleSourceSpan = Math.max( + 0, + sourceDuration - resizing.initialTrimStart - finalTrimEnd, + ); const newDuration = snapTimeToFrame({ - time: resizing.initialDuration - trimDelta, + time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps, }); @@ -326,13 +461,16 @@ export function useTimelineElementResize({ [ resizing, zoomLevel, - activeProject.settings.fps, snappingEnabled, editor, element.id, track.elements, onSnapPointChange, canExtendElementDuration, + getDurationForVisibleSourceSpan, + getSourceDeltaForClipDelta, + getSourceDuration, + getVisibleSourceSpanForDuration, isShiftHeldRef, ], ); @@ -349,7 +487,12 @@ export function useTimelineElementResize({ const startTimeChanged = finalStartTime !== resizing.initialStartTime; const durationChanged = finalDuration !== resizing.initialDuration; - if (trimStartChanged || trimEndChanged || startTimeChanged || durationChanged) { + if ( + trimStartChanged || + trimEndChanged || + startTimeChanged || + durationChanged + ) { editor.timeline.updateElementTrim({ elementId: element.id, trimStart: finalTrimStart, diff --git a/apps/web/src/hooks/timeline/element/use-element-selection.ts b/apps/web/src/hooks/timeline/element/use-element-selection.ts index c1e5b53f..dcfb5720 100644 --- a/apps/web/src/hooks/timeline/element/use-element-selection.ts +++ b/apps/web/src/hooks/timeline/element/use-element-selection.ts @@ -1,14 +1,10 @@ -import { useCallback, useSyncExternalStore } from "react"; +import { useCallback } from "react"; import { useEditor } from "@/hooks/use-editor"; - -type ElementRef = { trackId: string; elementId: string }; +import type { ElementRef } from "@/lib/timeline/types"; export function useElementSelection() { const editor = useEditor(); - const selectedElements = useSyncExternalStore( - (listener) => editor.selection.subscribe(listener), - () => editor.selection.getSelectedElements(), - ); + const selectedElements = useEditor((e) => e.selection.getSelectedElements()); const isElementSelected = useCallback( ({ trackId, elementId }: ElementRef) => @@ -82,6 +78,30 @@ export function useElementSelection() { [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 @@ -107,6 +127,7 @@ export function useElementSelection() { isElementSelected, selectElement, setElementSelection, + mergeElementsIntoSelection, addElementToSelection, removeElementFromSelection, toggleElementSelection, diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts index b18db6eb..4f961a0a 100644 --- a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts +++ b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts @@ -15,9 +15,10 @@ import { } from "@/constants/timeline-constants"; import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe"; import { BatchCommand } from "@/lib/commands"; -import type { SelectedKeyframeRef } from "@/types/animation"; -import type { TimelineElement } from "@/types/timeline"; +import type { SelectedKeyframeRef } from "@/lib/animation/types"; +import type { TimelineElement } from "@/lib/timeline"; import type { Command } from "@/lib/commands/base-command"; +import { registerCanceller } from "@/lib/cancel-interaction"; export interface KeyframeDragState { isDragging: boolean; draggingKeyframeIds: Set<string>; @@ -69,6 +70,13 @@ export function useKeyframeDrag({ setDragState(initialDragState); }, []); + const cancelDrag = useCallback(() => { + pendingDragRef.current = null; + mouseDownXRef.current = null; + setIsPendingDrag(false); + endDrag(); + }, [endDrag]); + const commitDrag = useCallback( ({ keyframeRefs, @@ -110,6 +118,12 @@ export function useKeyframeDrag({ 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); diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts index 0ab05fe3..8634cb48 100644 --- a/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts +++ b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts @@ -1,6 +1,6 @@ import { useCallback, useSyncExternalStore } from "react"; import { useEditor } from "@/hooks/use-editor"; -import type { SelectedKeyframeRef } from "@/types/animation"; +import type { SelectedKeyframeRef } from "@/lib/animation/types"; function getSelectedKeyframeId({ keyframe, diff --git a/apps/web/src/hooks/timeline/use-bookmark-drag.ts b/apps/web/src/hooks/timeline/use-bookmark-drag.ts index 0e7fabd5..512db821 100644 --- a/apps/web/src/hooks/timeline/use-bookmark-drag.ts +++ b/apps/web/src/hooks/timeline/use-bookmark-drag.ts @@ -15,7 +15,7 @@ import { snapToNearestPoint, type SnapPoint, } from "@/lib/timeline/snap-utils"; -import type { Bookmark } from "@/types/timeline"; +import type { Bookmark } from "@/lib/timeline"; export interface BookmarkDragState { isDragging: boolean; diff --git a/apps/web/src/hooks/timeline/use-initial-scroll-bottom.ts b/apps/web/src/hooks/timeline/use-initial-scroll-bottom.ts new file mode 100644 index 00000000..50833f14 --- /dev/null +++ b/apps/web/src/hooks/timeline/use-initial-scroll-bottom.ts @@ -0,0 +1,42 @@ +import { type RefObject, useLayoutEffect, useRef } from "react"; + +interface UseInitialScrollBottomProps { + tracksScrollRef: RefObject<HTMLDivElement | null>; + trackLabelsScrollRef: RefObject<HTMLDivElement | null>; + 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/hooks/timeline/use-selection-box.ts b/apps/web/src/hooks/timeline/use-selection-box.ts deleted file mode 100644 index c4f8d6ce..00000000 --- a/apps/web/src/hooks/timeline/use-selection-box.ts +++ /dev/null @@ -1,255 +0,0 @@ -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 | null>; - headerRef: React.RefObject<HTMLElement | null>; - onSelectionComplete: ( - elements: { trackId: string; elementId: string }[], - ) => void; - isEnabled?: boolean; - tracksScrollRef: React.RefObject<HTMLDivElement | null>; - 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, - headerRef, - 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 timelineHeaderHeight = - headerRef.current?.getBoundingClientRect().height ?? 0; - 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 = - timelineHeaderHeight + TIMELINE_CONSTANTS.PADDING_TOP_PX + trackTop; - const elementBottom = elementTop + 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, - headerRef, - 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 index f3e63894..1a8f3c5d 100644 --- a/apps/web/src/hooks/timeline/use-snap-indicator-position.ts +++ b/apps/web/src/hooks/timeline/use-snap-indicator-position.ts @@ -1,13 +1,10 @@ import { useEffect, useState } from "react"; import { timelineTimeToSnappedPixels } from "@/lib/timeline"; -import type { TimelineTrack } from "@/types/timeline"; - +import { TRACK_LABELS_WIDTH_PX } from "@/constants/timeline-constants"; interface UseSnapIndicatorPositionParams { snapPoint: { time: number } | null; zoomLevel: number; - tracks: TimelineTrack[]; timelineRef: React.RefObject<HTMLDivElement | null>; - trackLabelsRef?: React.RefObject<HTMLDivElement | null>; tracksScrollRef: React.RefObject<HTMLDivElement | null>; } @@ -20,9 +17,7 @@ interface SnapIndicatorPosition { export function useSnapIndicatorPosition({ snapPoint, zoomLevel, - tracks, timelineRef, - trackLabelsRef, tracksScrollRef, }: UseSnapIndicatorPositionParams): SnapIndicatorPosition { const [scrollLeft, setScrollLeft] = useState(0); @@ -45,16 +40,11 @@ export function useSnapIndicatorPosition({ 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 = timelineTimeToSnappedPixels({ time: snapPoint?.time ?? 0, zoomLevel, }); - const leftPosition = trackLabelsWidth + timelinePosition - scrollLeft; + const leftPosition = TRACK_LABELS_WIDTH_PX + timelinePosition - scrollLeft; return { leftPosition, diff --git a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts index 8a30785e..e8f6e49d 100644 --- a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts +++ b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts @@ -2,26 +2,28 @@ import { useState, useCallback, type RefObject } from "react"; import { useEditor } from "@/hooks/use-editor"; import { processMediaAssets } from "@/lib/media/processing"; import { toast } from "sonner"; +import { showMediaUploadToast } from "@/lib/media/upload-toast"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { snapTimeToFrame } from "@/lib/time"; import { buildTextElement, + buildGraphicElement, buildStickerElement, buildElementFromMedia, buildEffectElement, } from "@/lib/timeline/element-utils"; -import type { Command } from "@/lib/commands/base-command"; -import { AddMediaAssetCommand } from "@/lib/commands/media"; import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline"; import { BatchCommand } from "@/lib/commands"; import { computeDropTarget } from "@/lib/timeline/drop-utils"; import { getDragData, hasDragData } from "@/lib/drag-data"; -import type { TrackType, DropTarget, ElementType } from "@/types/timeline"; +import { isMainTrack } from "@/lib/timeline/track-utils"; +import type { TrackType, DropTarget, ElementType } from "@/lib/timeline"; import type { MediaDragData, + GraphicDragData, StickerDragData, EffectDragData, -} from "@/types/drag"; +} from "@/lib/timeline/drag"; interface UseTimelineDragDropProps { containerRef: RefObject<HTMLDivElement | null>; @@ -41,17 +43,12 @@ export function useTimelineDragDrop({ 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; + const projectFps = editor.project.getActive().settings.fps; return snapTimeToFrame({ time, fps: projectFps }); }, - [activeProject.settings.fps], + [editor], ); const getElementType = useCallback( @@ -60,6 +57,7 @@ export function useTimelineDragDrop({ 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") { @@ -80,18 +78,20 @@ export function useTimelineDragDrop({ }): number => { if ( elementType === "text" || + elementType === "graphic" || elementType === "sticker" || elementType === "effect" ) { return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; } if (mediaId) { + const mediaAssets = editor.media.getAssets(); const media = mediaAssets.find((m) => m.id === mediaId); return media?.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; } return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; }, - [mediaAssets], + [editor], ); const handleDragEnter = useCallback((e: React.DragEvent) => { @@ -148,6 +148,8 @@ export function useTimelineDragDrop({ ? (dragData as MediaDragData).targetElementTypes : undefined; + const tracks = editor.timeline.getTracks(); + const currentTime = editor.playback.getCurrentTime(); const target = computeDropTarget({ elementType, mouseX, @@ -170,12 +172,11 @@ export function useTimelineDragDrop({ containerRef, headerRef, tracksScrollRef, - tracks, - currentTime, zoomLevel, getElementType, getElementDuration, getSnappedTime, + editor, ], ); @@ -228,6 +229,7 @@ export function useTimelineDragDrop({ return; } + const tracks = editor.timeline.getTracks(); const track = tracks[target.trackIndex]; if (!track) return; editor.timeline.insertElement({ @@ -235,7 +237,7 @@ export function useTimelineDragDrop({ element, }); }, - [editor.command, editor.timeline, tracks], + [editor], ); const executeStickerDrop = useCallback( @@ -253,7 +255,45 @@ export function useTimelineDragDrop({ }); if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand("sticker", target.trackIndex); + 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.timeline.getTracks(); + 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() }, @@ -264,6 +304,7 @@ export function useTimelineDragDrop({ return; } + const tracks = editor.timeline.getTracks(); const track = tracks[target.trackIndex]; if (!track) return; editor.timeline.insertElement({ @@ -271,7 +312,7 @@ export function useTimelineDragDrop({ element, }); }, - [editor.command, editor.timeline, tracks], + [editor], ); const executeMediaDrop = useCallback( @@ -281,6 +322,7 @@ export function useTimelineDragDrop({ return; } + const mediaAssets = editor.media.getAssets(); const mediaAsset = mediaAssets.find((m) => m.id === dragData.id); if (!mediaAsset) return; @@ -309,6 +351,7 @@ export function useTimelineDragDrop({ return; } + const tracks = editor.timeline.getTracks(); const track = tracks[target.trackIndex]; if (!track) return; editor.timeline.insertElement({ @@ -316,7 +359,7 @@ export function useTimelineDragDrop({ element, }); }, - [editor.command, editor.timeline, mediaAssets, tracks], + [editor], ); const executeEffectDrop = useCallback( @@ -336,6 +379,7 @@ export function useTimelineDragDrop({ return; } + const tracks = editor.timeline.getTracks(); const effectTrack = tracks.find((t) => t.type === "effect"); let trackId: string; @@ -370,7 +414,7 @@ export function useTimelineDragDrop({ element, }); }, - [editor.command, editor.timeline, tracks], + [editor], ); const executeFileDrop = useCallback( @@ -383,70 +427,98 @@ export function useTimelineDragDrop({ mouseX: number; mouseY: number; }) => { + const activeProject = editor.project.getActiveOrNull(); if (!activeProject) return; - const processedAssets = await processMediaAssets({ files }); - const projectId = activeProject.metadata.id; + await showMediaUploadToast({ + filesCount: files.length, + promise: async () => { + const processedAssets = await processMediaAssets({ files }); + const projectId = activeProject.metadata.id; - for (const asset of processedAssets) { - const duration = - asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; - const currentTracks = editor.timeline.getTracks(); - const dropTarget = computeDropTarget({ - elementType: asset.type, - mouseX, - mouseY, - tracks: currentTracks, - playheadTime: currentTime, - isExternalDrop: true, - elementDuration: duration, - pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND, - zoomLevel, - }); + for (const asset of processedAssets) { + const createdAsset = await editor.media.addMediaAsset({ + projectId, + asset, + }); + if (!createdAsset) continue; - const trackType: TrackType = asset.type === "audio" ? "audio" : "video"; - const addMediaCmd = new AddMediaAssetCommand(projectId, asset); - const assetId = addMediaCmd.getAssetId(); + const duration = + createdAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; + const currentTracks = editor.timeline.getTracks(); + const currentTime = editor.playback.getCurrentTime(); + const onlyTrack = currentTracks[0]; + const reuseMainTrackId = + createdAsset.type !== "audio" && + currentTracks.length === 1 && + !!onlyTrack && + isMainTrack(onlyTrack) && + onlyTrack.elements.length === 0 + ? onlyTrack.id + : null; + const dropTarget = reuseMainTrackId + ? null + : computeDropTarget({ + elementType: createdAsset.type, + mouseX, + mouseY, + tracks: currentTracks, + playheadTime: currentTime, + isExternalDrop: true, + elementDuration: duration, + pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND, + zoomLevel, + }); - const commands: Command[] = [addMediaCmd]; + const trackType: TrackType = + createdAsset.type === "audio" ? "audio" : "video"; - let trackId: string | undefined; - if (dropTarget.isNewTrack) { - const addTrackCmd = new AddTrackCommand( - trackType, - dropTarget.trackIndex, - ); - trackId = addTrackCmd.getTrackId(); - commands.unshift(addTrackCmd); - } else { - trackId = currentTracks[dropTarget.trackIndex]?.id; - } + let trackId: string | undefined; + if (reuseMainTrackId) { + trackId = reuseMainTrackId; + } else { + if (!dropTarget) continue; + if (dropTarget.isNewTrack) { + const addTrackCmd = new AddTrackCommand( + trackType, + dropTarget.trackIndex, + ); + trackId = addTrackCmd.getTrackId(); + editor.command.execute({ command: addTrackCmd }); + } else { + trackId = currentTracks[dropTarget.trackIndex]?.id; + } + } - if (!trackId) return; + if (!trackId) continue; - const element = buildElementFromMedia({ - mediaId: assetId, - mediaType: asset.type, - name: asset.name, - duration, - startTime: dropTarget.xPosition, - buffer: - asset.type === "audio" - ? new AudioBuffer({ length: 1, sampleRate: 44100 }) - : undefined, - }); + const element = buildElementFromMedia({ + mediaId: createdAsset.id, + mediaType: createdAsset.type, + name: createdAsset.name, + duration, + startTime: dropTarget?.xPosition ?? currentTime, + buffer: + createdAsset.type === "audio" + ? new AudioBuffer({ length: 1, sampleRate: 44100 }) + : undefined, + }); - const insertCmd = new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId }, - }); - commands.push(insertCmd); + const insertCmd = new InsertElementCommand({ + element, + placement: { mode: "explicit", trackId }, + }); + editor.command.execute({ command: insertCmd }); + } - const batchCmd = new BatchCommand(commands); - editor.command.execute({ command: batchCmd }); - } + return { + uploadedCount: processedAssets.length, + assetNames: processedAssets.map((asset) => asset.name), + }; + }, + }); }, - [activeProject, editor.command, editor.timeline, currentTime, zoomLevel], + [editor, zoomLevel], ); const handleDrop = useCallback( @@ -471,6 +543,11 @@ export function useTimelineDragDrop({ 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") { @@ -502,12 +579,12 @@ export function useTimelineDragDrop({ } } catch (err) { console.error("Failed to process drop:", err); - toast.error("Failed to process drop"); } }, [ dropTarget, executeTextDrop, + executeGraphicDrop, executeStickerDrop, executeMediaDrop, executeEffectDrop, diff --git a/apps/web/src/hooks/timeline/use-timeline-playhead.ts b/apps/web/src/hooks/timeline/use-timeline-playhead.ts index 1426a003..e6204f23 100644 --- a/apps/web/src/hooks/timeline/use-timeline-playhead.ts +++ b/apps/web/src/hooks/timeline/use-timeline-playhead.ts @@ -1,12 +1,13 @@ import { getSnappedSeekTime } from "@/lib/time"; -import { useState, useEffect, useCallback, useRef } from "react"; +import { useEffect, useCallback, useRef } from "react"; import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; import { useEditor } from "../use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; +import { findSnapPoints, snapToNearestPoint } from "@/lib/timeline/snap-utils"; import { - findSnapPoints, - snapToNearestPoint, -} from "@/lib/timeline/snap-utils"; + getCenteredLineLeft, + timelineTimeToSnappedPixels, +} from "@/lib/timeline"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; interface UseTimelinePlayheadProps { @@ -25,27 +26,33 @@ export function useTimelinePlayhead({ playheadRef, }: UseTimelinePlayheadProps) { const editor = useEditor(); + const isScrubbing = useEditor((e) => e.playback.getIsScrubbing()); const activeProject = editor.project.getActive(); - const currentTime = editor.playback.getCurrentTime(); const duration = editor.timeline.getTotalDuration(); - const isPlaying = editor.playback.getIsPlaying(); - const isScrubbing = editor.playback.getIsScrubbing(); 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 [scrubTime, setScrubTime] = useState<number | null>(null); - - const [isDraggingRuler, setIsDraggingRuler] = useState(false); - const [hasDraggedRuler, setHasDraggedRuler] = useState(false); + const scrubTimeRef = useRef<number | null>(null); + const isDraggingRulerRef = useRef(false); + const hasDraggedRulerRef = useRef(false); const lastMouseXRef = useRef<number>(0); - const playheadPosition = - isScrubbing && scrubTime !== null ? scrubTime : currentTime; - const handleScrub = useCallback( ({ event, @@ -86,8 +93,7 @@ export function useTimelinePlayhead({ const time = (() => { if (!shouldSnap) return frameTime; const tracks = editor.timeline.getTracks(); - const bookmarks = - editor.scenes.getActiveScene()?.bookmarks ?? []; + const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? []; const snapPoints = findSnapPoints({ tracks, playheadTime: frameTime, @@ -102,7 +108,7 @@ export function useTimelinePlayhead({ return snapResult.snapPoint ? snapResult.snappedTime : frameTime; })(); - setScrubTime(time); + scrubTimeRef.current = time; seek({ time }); lastMouseXRef.current = event.clientX; @@ -132,17 +138,16 @@ export function useTimelinePlayhead({ 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); + isDraggingRulerRef.current = true; + hasDraggedRulerRef.current = false; - editor.playback.setScrubbing({ isScrubbing: true }); - handleScrub({ event, snappingEnabled: false }); - }, - [handleScrub, playheadRef, editor.playback], + editor.playback.setScrubbing({ isScrubbing: true }); + handleScrub({ event, snappingEnabled: false }); + }, + [handleScrub, playheadRef, editor.playback], ); const handlePlayheadMouseDownEvent = useCallback( @@ -168,31 +173,32 @@ export function useTimelinePlayhead({ const handleMouseMove = ({ event }: { event: MouseEvent }) => { handleScrub({ event }); - if (isDraggingRuler) { - setHasDraggedRuler(true); + if (isDraggingRulerRef.current) { + hasDraggedRulerRef.current = true; } }; const handleMouseUp = ({ event }: { event: MouseEvent }) => { editor.playback.setScrubbing({ isScrubbing: false }); - if (scrubTime !== null) { - seek({ time: scrubTime }); + const finalTime = scrubTimeRef.current; + if (finalTime !== null) { + seek({ time: finalTime }); editor.project.setTimelineViewState({ viewState: { zoomLevel, scrollLeft: tracksScrollRef.current?.scrollLeft ?? 0, - playheadTime: scrubTime, + playheadTime: finalTime, }, }); } - setScrubTime(null); + scrubTimeRef.current = null; - if (isDraggingRuler) { - setIsDraggingRuler(false); - if (!hasDraggedRuler) { + if (isDraggingRulerRef.current) { + isDraggingRulerRef.current = false; + if (!hasDraggedRulerRef.current) { handleScrub({ event, snappingEnabled: false }); } - setHasDraggedRuler(false); + hasDraggedRulerRef.current = false; } }; @@ -206,55 +212,79 @@ export function useTimelinePlayhead({ window.removeEventListener("mousemove", onMouseMove); window.removeEventListener("mouseup", onMouseUp); }; - }, [ - isScrubbing, - scrubTime, - seek, - handleScrub, - isDraggingRuler, - hasDraggedRuler, - editor, - tracksScrollRef, - zoomLevel, - ]); + }, [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(() => { - if (!isPlaying || isScrubbing) return; + const scrollEl = rulerScrollRef.current; + if (!scrollEl) return; - const rulerViewport = rulerScrollRef.current; - const tracksViewport = tracksScrollRef.current; - if (!rulerViewport || !tracksViewport) return; + const handleScroll = () => { + updatePlayheadLeft(editor.playback.getCurrentTime()); + }; - const playheadPixels = - playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - const viewportWidth = rulerViewport.clientWidth; - const scrollMinimum = 0; - const scrollMaximum = rulerViewport.scrollWidth - viewportWidth; + scrollEl.addEventListener("scroll", handleScroll, { passive: true }); + return () => scrollEl.removeEventListener("scroll", handleScroll); + }, [editor.playback, rulerScrollRef, updatePlayheadLeft]); - const needsScroll = - playheadPixels < rulerViewport.scrollLeft || - playheadPixels > rulerViewport.scrollLeft + viewportWidth; + useEffect(() => { + const handlePlaybackUpdate = (e: Event) => { + const time = (e as CustomEvent<{ time: number }>).detail.time; + updatePlayheadLeft(time); - if (needsScroll) { - const desiredScroll = Math.max( - scrollMinimum, - Math.min(scrollMaximum, playheadPixels - viewportWidth / 2), - ); - rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll; - } - }, [ - playheadPosition, - zoomLevel, - rulerScrollRef, - tracksScrollRef, - isScrubbing, - isPlaying, - ]); + if (!isPlayingRef.current || isScrubbingRef.current) return; + const rulerViewport = rulerScrollRef.current; + const tracksViewport = tracksScrollRef.current; + if (!rulerViewport || !tracksViewport) return; + + const playheadPixels = + time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * 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 { - playheadPosition, handlePlayheadMouseDown: handlePlayheadMouseDownEvent, handleRulerMouseDown: handleRulerMouseDownEvent, - isDraggingRuler, }; } diff --git a/apps/web/src/hooks/timeline/use-timeline-zoom.ts b/apps/web/src/hooks/timeline/use-timeline-zoom.ts index 095c64dc..64a0fb58 100644 --- a/apps/web/src/hooks/timeline/use-timeline-zoom.ts +++ b/apps/web/src/hooks/timeline/use-timeline-zoom.ts @@ -81,18 +81,22 @@ export function useTimelineZoom({ 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; - }); - 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_CONSTANTS.ZOOM_MAX, prev * zoomFactor), + ); + return nextZoom; + }); + return; + } }, [minZoom, setZoomLevel], ); |
