diff options
Diffstat (limited to 'apps/web/src/hooks/timeline/element')
5 files changed, 281 insertions, 88 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, |
