From ac43a5a73fa905cdac905bc956763b6093d6738e Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 15 Apr 2026 05:43:29 +0200 Subject: feat: move/resize multiple elements --- .../timeline/element/use-element-interaction.ts | 299 +++++++----- .../hooks/timeline/element/use-element-resize.ts | 503 --------------------- apps/web/src/hooks/timeline/use-timeline-resize.ts | 322 +++++++++++++ 3 files changed, 514 insertions(+), 610 deletions(-) delete mode 100644 apps/web/src/hooks/timeline/element/use-element-resize.ts create mode 100644 apps/web/src/hooks/timeline/use-timeline-resize.ts (limited to 'apps/web/src/hooks') 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 854c4f4e..98d21d9b 100644 --- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts +++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts @@ -9,6 +9,13 @@ import { import { useEditor } from "@/hooks/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; +import { + buildMoveGroup, + resolveGroupMove, + snapGroupEdges, + type GroupMoveResult, + type MoveGroup, +} from "@/lib/timeline/group-move"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; import { TICKS_PER_SECOND } from "@/lib/wasm"; import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction"; @@ -16,10 +23,11 @@ import { roundToFrame } from "opencut-wasm"; import { computeDropTarget } from "@/components/editor/panels/timeline/drop-target"; import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils"; import { generateUUID } from "@/utils/id"; -import { snapElementEdge, type SnapPoint } from "@/lib/timeline/snap-utils"; +import type { SnapPoint } from "@/lib/timeline/snap-utils"; import { registerCanceller } from "@/lib/cancel-interaction"; import type { DropTarget, + ElementRef, ElementDragState, SceneTracks, TimelineElement, @@ -55,6 +63,7 @@ const initialDragState: ElementDragState = { interface PendingDragState { elementId: string; trackId: string; + selectedElements: ElementRef[]; startMouseX: number; startMouseY: number; startElementTime: number; @@ -116,11 +125,9 @@ function getDragDropTarget({ const scrollContainer = tracksScrollRef.current; if (!containerRect || !scrollContainer) return null; - const sourceTrack = [ - ...tracks.overlay, - tracks.main, - ...tracks.audio, - ].find(({ id }) => id === trackId); + const sourceTrack = [...tracks.overlay, tracks.main, ...tracks.audio].find( + ({ id }) => id === trackId, + ); const movingElement = sourceTrack?.elements.find( ({ id }) => id === elementId, ); @@ -153,7 +160,7 @@ function getDragDropTarget({ interface StartDragParams extends Omit< ElementDragState, - "isDragging" | "currentTime" | "currentMouseY" | "dragElementIds" | "dragTimeOffsets" + "isDragging" | "currentTime" | "currentMouseY" > { initialCurrentTime: number; initialCurrentMouseY: number; @@ -171,11 +178,6 @@ export function useElementInteraction({ const editor = useEditor(); const isShiftHeldRef = useShiftKey(); const sceneTracks = editor.scenes.getActiveScene().tracks; - const tracks = [ - ...sceneTracks.overlay, - sceneTracks.main, - ...sceneTracks.audio, - ]; const { selectedElements, isElementSelected, @@ -188,12 +190,16 @@ export function useElementInteraction({ const [dragDropTarget, setDragDropTarget] = useState(null); const [isPendingDrag, setIsPendingDrag] = useState(false); const pendingDragRef = useRef(null); + const moveGroupRef = useRef(null); + const groupMoveResultRef = useRef(null); const lastMouseXRef = useRef(0); const mouseDownLocationRef = useRef<{ x: number; y: number } | null>(null); const startDrag = useCallback( ({ elementId, + dragElementIds, + dragTimeOffsets, trackId, startMouseX, startMouseY, @@ -205,8 +211,8 @@ export function useElementInteraction({ setDragState({ isDragging: true, elementId, - dragElementIds: elementId ? [elementId] : [], - dragTimeOffsets: {}, + dragElementIds, + dragTimeOffsets, trackId, startMouseX, startMouseY, @@ -220,6 +226,8 @@ export function useElementInteraction({ ); const endDrag = useCallback(() => { + moveGroupRef.current = null; + groupMoveResultRef.current = null; setDragState(initialDragState); setDragDropTarget(null); }, []); @@ -232,6 +240,70 @@ export function useElementInteraction({ onSnapPointChange?.(null); }, [endDrag, onSnapPointChange]); + const resolveGroupDragMove = useCallback( + ({ + group, + snappedTime, + dropTarget, + }: { + group: MoveGroup; + snappedTime: number; + dropTarget: DropTarget | null; + }): GroupMoveResult | null => { + if (!dropTarget) { + return null; + } + + if (dropTarget.isNewTrack) { + return resolveGroupMove({ + group, + tracks: sceneTracks, + anchorStartTime: snappedTime, + target: { + kind: "newTracks", + anchorInsertIndex: dropTarget.trackIndex, + newTrackIds: group.members.map(() => generateUUID()), + }, + }); + } + + const orderedTracks = [ + ...sceneTracks.overlay, + sceneTracks.main, + ...sceneTracks.audio, + ]; + const targetTrack = orderedTracks[dropTarget.trackIndex]; + if (!targetTrack) { + return null; + } + + const existingTrackResult = resolveGroupMove({ + group, + tracks: sceneTracks, + anchorStartTime: snappedTime, + target: { + kind: "existingTrack", + anchorTargetTrackId: targetTrack.id, + }, + }); + if (existingTrackResult) { + return existingTrackResult; + } + + return resolveGroupMove({ + group, + tracks: sceneTracks, + anchorStartTime: snappedTime, + target: { + kind: "newTracks", + anchorInsertIndex: dropTarget.trackIndex, + newTrackIds: group.members.map(() => generateUUID()), + }, + }); + }, + [sceneTracks], + ); + useEffect(() => { if (!dragState.isDragging && !isPendingDrag) return; @@ -241,51 +313,29 @@ export function useElementInteraction({ const getDragSnapResult = useCallback( ({ frameSnappedTime, - movingElement, + group, }: { frameSnappedTime: number; - movingElement: TimelineElement | null | undefined; + group: MoveGroup | null; }) => { - const shouldSnap = snappingEnabled && !isShiftHeldRef.current; - if (!shouldSnap || !movingElement) { + if (!group || !snappingEnabled || isShiftHeldRef.current) { return { snappedTime: frameSnappedTime, snapPoint: null }; } - const elementDuration = movingElement.duration; - const playheadTime = editor.playback.getCurrentTime(); - - const startSnap = snapElementEdge({ - targetTime: frameSnappedTime, - elementDuration, + const groupSnap = snapGroupEdges({ + group, + anchorStartTime: frameSnappedTime, tracks: sceneTracks, - playheadTime, + playheadTime: editor.playback.getCurrentTime(), zoomLevel, - excludeElementId: movingElement.id, - snapToStart: true, }); - const endSnap = snapElementEdge({ - targetTime: frameSnappedTime, - elementDuration, - tracks: sceneTracks, - 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, + snappedTime: groupSnap.snappedAnchorStartTime, + snapPoint: groupSnap.snapPoint, }; }, - [snappingEnabled, editor.playback, tracks, zoomLevel, isShiftHeldRef], + [snappingEnabled, editor.playback, sceneTracks, zoomLevel, isShiftHeldRef], ); useEffect(() => { @@ -314,17 +364,38 @@ export function useElementInteraction({ zoomLevel, scrollLeft, }); - const adjustedTime = Math.max( - 0, - mouseTime - pendingDragRef.current.clickOffsetTime, - ); - const snappedTime = roundToFrame({ - time: adjustedTime, - rate: activeProject.settings.fps, - }) ?? adjustedTime; - startDrag({ - ...pendingDragRef.current, - initialCurrentTime: snappedTime, + const adjustedTime = Math.max( + 0, + mouseTime - pendingDragRef.current.clickOffsetTime, + ); + const snappedTime = + roundToFrame({ + time: adjustedTime, + rate: activeProject.settings.fps, + }) ?? adjustedTime; + const moveGroup = buildMoveGroup({ + anchorRef: { + trackId: pendingDragRef.current.trackId, + elementId: pendingDragRef.current.elementId, + }, + selectedElements: pendingDragRef.current.selectedElements, + tracks: sceneTracks, + }); + if (!moveGroup) { + return; + } + + moveGroupRef.current = moveGroup; + groupMoveResultRef.current = null; + const dragTimeOffsets: Record = {}; + for (const member of moveGroup.members) { + dragTimeOffsets[member.elementId] = member.timeOffset; + } + startDrag({ + ...pendingDragRef.current, + dragElementIds: moveGroup.members.map((member) => member.elementId), + dragTimeOffsets, + initialCurrentTime: snappedTime, initialCurrentMouseY: clientY, }); startedDragThisEvent = true; @@ -364,15 +435,13 @@ export function useElementInteraction({ }); const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); const fps = activeProject.settings.fps; - const frameSnappedTime = roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime; + const frameSnappedTime = + roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime; - const sourceTrack = tracks.find(({ id }) => id === dragState.trackId); - const movingElement = sourceTrack?.elements.find( - ({ id }) => id === dragState.elementId, - ); + const moveGroup = moveGroupRef.current; const { snappedTime, snapPoint } = getDragSnapResult({ frameSnappedTime, - movingElement, + group: moveGroup, }); setDragState((previousDragState) => ({ ...previousDragState, @@ -386,7 +455,7 @@ export function useElementInteraction({ startMouseY: dragState.startMouseY, currentMouseY: clientY, }); - const dropTarget = getDragDropTarget({ + const anchorDropTarget = getDragDropTarget({ clientX, clientY, elementId: dragState.elementId, @@ -399,7 +468,24 @@ export function useElementInteraction({ snappedTime, verticalDragDirection, }); - setDragDropTarget(dropTarget?.isNewTrack ? dropTarget : null); + const nextGroupMoveResult = + moveGroup && anchorDropTarget + ? resolveGroupDragMove({ + group: moveGroup, + snappedTime, + dropTarget: anchorDropTarget, + }) + : null; + groupMoveResultRef.current = nextGroupMoveResult; + setDragDropTarget( + anchorDropTarget && + (anchorDropTarget.isNewTrack || !nextGroupMoveResult) + ? { + ...anchorDropTarget, + isNewTrack: true, + } + : null, + ); } }; @@ -419,10 +505,11 @@ export function useElementInteraction({ tracksScrollRef, tracksContainerRef, headerRef, - tracks, isPendingDrag, startDrag, getDragSnapResult, + resolveGroupDragMove, + sceneTracks, onSnapPointChange, ]); @@ -468,55 +555,45 @@ export function useElementInteraction({ return; } const snappedTime = dragState.currentTime; - - const sourceTrack = tracks.find(({ id }) => id === dragState.trackId); - if (!sourceTrack) { + const moveGroup = moveGroupRef.current; + if (!moveGroup) { endDrag(); onSnapPointChange?.(null); return; } - const movingElement = - sourceTrack.elements.find(({ id }) => id === dragState.elementId) ?? null; - if ( - movingElement && - !dropTarget.isNewTrack && - tracks[dropTarget.trackIndex]?.id === dragState.trackId && - snappedTime === movingElement.startTime - ) { + + const groupMoveResult = resolveGroupDragMove({ + group: moveGroup, + snappedTime, + dropTarget, + }); + if (!groupMoveResult) { 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 }, - }); - 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, - }); - if (targetTrack.id !== dragState.trackId) { - selectElement({ - trackId: targetTrack.id, - elementId: dragState.elementId, - }); - } - } + const didMove = groupMoveResult.moves.some((move) => { + const currentMember = moveGroup.members.find( + (member) => member.elementId === move.elementId, + ); + const originalStartTime = + dragState.startElementTime + (currentMember?.timeOffset ?? 0); + return ( + currentMember?.trackId !== move.targetTrackId || + originalStartTime !== move.newStartTime + ); + }); + if (!didMove && groupMoveResult.createTracks.length === 0) { + endDrag(); + onSnapPointChange?.(null); + return; } + editor.timeline.moveElements({ + moves: groupMoveResult.moves, + createTracks: groupMoveResult.createTracks, + }); endDrag(); onSnapPointChange?.(null); }; @@ -531,14 +608,14 @@ export function useElementInteraction({ dragState.trackId, dragState.currentTime, zoomLevel, - tracks, endDrag, onSnapPointChange, editor.timeline, tracksContainerRef, tracksScrollRef, headerRef, - selectElement, + resolveGroupDragMove, + sceneTracks, ]); useEffect(() => { @@ -600,9 +677,17 @@ export function useElementInteraction({ elementRect: event.currentTarget.getBoundingClientRect(), zoomLevel, }); + const elementRef = { + trackId: track.id, + elementId: element.id, + }; + const pendingSelectedElements = isElementSelected(elementRef) + ? selectedElements + : [elementRef]; pendingDragRef.current = { elementId: element.id, trackId: track.id, + selectedElements: pendingSelectedElements, startMouseX: event.clientX, startMouseY: event.clientY, startElementTime: element.startTime, @@ -610,7 +695,7 @@ export function useElementInteraction({ }; setIsPendingDrag(true); }, - [zoomLevel, isElementSelected, handleSelectionClick], + [zoomLevel, isElementSelected, handleSelectionClick, selectedElements], ); const handleElementClick = useCallback( diff --git a/apps/web/src/hooks/timeline/element/use-element-resize.ts b/apps/web/src/hooks/timeline/element/use-element-resize.ts deleted file mode 100644 index bb0d880c..00000000 --- a/apps/web/src/hooks/timeline/element/use-element-resize.ts +++ /dev/null @@ -1,503 +0,0 @@ -import { useState, useEffect, useRef, useCallback } from "react"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; -import { TICKS_PER_SECOND } from "@/lib/wasm"; -import { roundToFrame } from "opencut-wasm"; -import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; -import { useEditor } from "@/hooks/use-editor"; -import { useShiftKey } from "@/hooks/use-shift-key"; -import { - findSnapPoints, - 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; - 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 = useEditor(); - const isShiftHeldRef = useShiftKey(); - const snappingEnabled = useTimelineStore((state) => state.snappingEnabled); - - const [resizing, setResizing] = useState(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 = ({ - event, - elementId, - side, - }: { - event: React.MouseEvent; - elementId: string; - side: "left" | "right"; - }) => { - event.stopPropagation(); - event.preventDefault(); - - setResizing({ - elementId, - side, - startX: event.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(() => { - 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; - - const deltaX = clientX - resizing.startX; - let deltaTime = Math.round( - (deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel)) * TICKS_PER_SECOND, - ); - let resizeSnapPoint: SnapPoint | null = null; - - const projectFps = editor.project.getActive().settings.fps; - const minDuration = Math.round(TICKS_PER_SECOND * projectFps.denominator / projectFps.numerator); - const shouldSnap = snappingEnabled && !isShiftHeldRef.current; - if (shouldSnap) { - const tracks = editor.scenes.getActiveScene().tracks; - 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); - - 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, - ) - : Infinity; - - const leftNeighborBound = - resizing.side === "left" - ? otherElements - .filter( - ({ startTime, duration }) => - startTime + duration <= resizing.initialStartTime, - ) - .reduce( - (max, { startTime, duration }) => - Math.max(max, startTime + duration), - -Infinity, - ) - : -Infinity; - - if (resizing.side === "left") { - const sourceDuration = getSourceDuration({ - trimStart: resizing.initialTrimStart, - duration: resizing.initialDuration, - trimEnd: resizing.initialTrimEnd, - }); - const minTrimStartForNeighbor = Number.isFinite(leftNeighborBound) - ? Math.max( - 0, - resizing.initialTrimStart + - getSourceDeltaForClipDelta( - leftNeighborBound - resizing.initialStartTime, - ), - ) - : 0; - const maxAllowed = - sourceDuration - - resizing.initialTrimEnd - - getVisibleSourceSpanForDuration(minDuration); - const calculated = - resizing.initialTrimStart + getSourceDeltaForClipDelta(deltaTime); - - if (calculated >= 0 && calculated <= maxAllowed) { - const newTrimStart = roundToFrame({ time: Math.min(maxAllowed, Math.max(minTrimStartForNeighbor, calculated)), rate: projectFps }) ?? Math.min(maxAllowed, Math.max(minTrimStartForNeighbor, calculated)); - const visibleSourceSpan = Math.max( - 0, - sourceDuration - newTrimStart - resizing.initialTrimEnd, - ); - const newDuration = roundToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), rate: projectFps }) ?? getDurationForVisibleSourceSpan(visibleSourceSpan); - const trimDelta = resizing.initialDuration - newDuration; - const newStartTime = roundToFrame({ time: resizing.initialStartTime + trimDelta, rate: projectFps }) ?? resizing.initialStartTime + trimDelta; - - 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.max( - 0, - Number.isFinite(leftNeighborBound) - ? Math.min( - extensionAmount, - maxExtension, - resizing.initialStartTime - leftNeighborBound, - ) - : Math.min(extensionAmount, maxExtension), - ); - const newStartTime = roundToFrame({ time: resizing.initialStartTime - actualExtension, rate: projectFps }) ?? resizing.initialStartTime - actualExtension; - const newDuration = roundToFrame({ time: resizing.initialDuration + actualExtension, rate: projectFps }) ?? resizing.initialDuration + actualExtension; - - setCurrentTrimStart(0); - setCurrentStartTime(newStartTime); - setCurrentDuration(newDuration); - currentTrimStartRef.current = 0; - currentStartTimeRef.current = newStartTime; - currentDurationRef.current = newDuration; - } else { - const leftBound = Number.isFinite(leftNeighborBound) - ? leftNeighborBound - : 0; - const trimDeltaFromTrimStart = - minTrimStartForNeighbor - resizing.initialTrimStart; - 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 = roundToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), rate: projectFps }) ?? getDurationForVisibleSourceSpan(visibleSourceSpan); - const newStartTime = roundToFrame({ time: resizing.initialStartTime + (resizing.initialDuration - newDuration), rate: projectFps }) ?? resizing.initialStartTime + (resizing.initialDuration - newDuration); - - setCurrentTrimStart(newTrimStart); - setCurrentStartTime(newStartTime); - setCurrentDuration(newDuration); - currentTrimStartRef.current = newTrimStart; - currentStartTimeRef.current = newStartTime; - currentDurationRef.current = newDuration; - } - } - } else { - 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; - - if (newTrimEnd < 0) { - if (canExtendElementDuration()) { - const extensionNeeded = Math.abs(newTrimEnd); - const baseDuration = - resizing.initialDuration + resizing.initialTrimEnd; - const newDuration = roundToFrame({ time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration), rate: projectFps }) ?? Math.min(baseDuration + extensionNeeded, maxAllowedDuration); - - setCurrentDuration(newDuration); - setCurrentTrimEnd(0); - currentDurationRef.current = newDuration; - currentTrimEndRef.current = 0; - } else { - const unclampedDuration = getDurationForVisibleSourceSpan( - Math.max(0, sourceDuration - resizing.initialTrimStart), - ); - const newDuration = roundToFrame({ time: Math.min(unclampedDuration, maxAllowedDuration), rate: projectFps }) ?? Math.min(unclampedDuration, maxAllowedDuration); - - setCurrentDuration(newDuration); - setCurrentTrimEnd(0); - currentDurationRef.current = newDuration; - currentTrimEndRef.current = 0; - } - } else { - const minTrimEndForNeighbor = Number.isFinite(maxAllowedDuration) - ? Math.max( - 0, - sourceDuration - - resizing.initialTrimStart - - getVisibleSourceSpanForDuration(maxAllowedDuration), - ) - : 0; - const maxTrimEnd = - sourceDuration - - resizing.initialTrimStart - - getVisibleSourceSpanForDuration(minDuration); - const clampedTrimEnd = Math.min( - maxTrimEnd, - Math.max(minTrimEndForNeighbor, newTrimEnd), - ); - const finalTrimEnd = roundToFrame({ time: clampedTrimEnd, rate: projectFps }) ?? clampedTrimEnd; - const visibleSourceSpan = Math.max( - 0, - sourceDuration - resizing.initialTrimStart - finalTrimEnd, - ); - const newDuration = roundToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), rate: projectFps }) ?? getDurationForVisibleSourceSpan(visibleSourceSpan); - - setCurrentTrimEnd(finalTrimEnd); - setCurrentDuration(newDuration); - currentTrimEndRef.current = finalTrimEnd; - currentDurationRef.current = newDuration; - } - } - }, - [ - resizing, - zoomLevel, - snappingEnabled, - editor, - element.id, - track.elements, - onSnapPointChange, - canExtendElementDuration, - getDurationForVisibleSourceSpan, - getSourceDeltaForClipDelta, - getSourceDuration, - getVisibleSourceSpanForDuration, - isShiftHeldRef, - ], - ); - - 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 || - startTimeChanged || - durationChanged - ) { - editor.timeline.updateElementTrim({ - elementId: element.id, - trimStart: finalTrimStart, - trimEnd: finalTrimEnd, - startTime: startTimeChanged ? finalStartTime : undefined, - duration: durationChanged ? finalDuration : undefined, - }); - } - - setResizing(null); - onResizeStateChange?.({ isResizing: false }); - onSnapPointChange?.(null); - }, [ - resizing, - editor.timeline, - element.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/use-timeline-resize.ts b/apps/web/src/hooks/timeline/use-timeline-resize.ts new file mode 100644 index 00000000..f246261a --- /dev/null +++ b/apps/web/src/hooks/timeline/use-timeline-resize.ts @@ -0,0 +1,322 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { EditorCore } from "@/core"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; +import { TICKS_PER_SECOND } from "@/lib/wasm"; +import { useEditor } from "@/hooks/use-editor"; +import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; +import { useShiftKey } from "@/hooks/use-shift-key"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { + computeGroupResize, + type GroupResizeMember, + type GroupResizeResult, + type ResizeSide, +} from "@/lib/timeline/group-resize"; +import { + findSnapPoints, + snapToNearestPoint, + type SnapPoint, +} from "@/lib/timeline/snap-utils"; +import type { + SceneTracks, + TimelineElement, + TimelineTrack, +} from "@/lib/timeline"; +import { isRetimableElement } from "@/lib/timeline"; +import { registerCanceller } from "@/lib/cancel-interaction"; + +interface ResizeInteractionState { + side: ResizeSide; + startX: number; + members: GroupResizeMember[]; +} + +export function useTimelineResize({ + zoomLevel, + onSnapPointChange, +}: { + zoomLevel: number; + onSnapPointChange?: (snapPoint: SnapPoint | null) => void; +}) { + const editor = useEditor(); + const isShiftHeldRef = useShiftKey(); + const snappingEnabled = useTimelineStore((state) => state.snappingEnabled); + const { selectedElements } = useElementSelection(); + const [resizeState, setResizeState] = useState( + null, + ); + const latestResultRef = useRef(null); + + const cancelResize = useCallback(() => { + editor.timeline.discardPreview(); + setResizeState(null); + latestResultRef.current = null; + onSnapPointChange?.(null); + }, [editor.timeline, onSnapPointChange]); + + useEffect(() => { + if (!resizeState) { + return; + } + + return registerCanceller({ fn: cancelResize }); + }, [resizeState, cancelResize]); + + const handleResizeStart = useCallback( + ({ + event, + element, + track, + side, + }: { + event: React.MouseEvent; + element: TimelineElement; + track: TimelineTrack; + side: ResizeSide; + }) => { + event.stopPropagation(); + event.preventDefault(); + + const elementRef = { + trackId: track.id, + elementId: element.id, + }; + const activeSelection = selectedElements.some( + (selectedElement) => + selectedElement.trackId === track.id && + selectedElement.elementId === element.id, + ) + ? selectedElements + : [elementRef]; + const members = buildResizeMembers({ + tracks: editor.scenes.getActiveScene().tracks, + selectedElements: activeSelection, + }); + if (members.length === 0) { + return; + } + + editor.timeline.discardPreview(); + latestResultRef.current = null; + setResizeState({ + side, + startX: event.clientX, + members, + }); + }, + [selectedElements, editor], + ); + + useEffect(() => { + if (!resizeState) { + return; + } + + const handleMouseMove = ({ clientX }: MouseEvent) => { + const deltaX = clientX - resizeState.startX; + const rawDeltaTime = Math.round( + (deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel)) * + TICKS_PER_SECOND, + ); + const snappedDeltaTime = getSnappedResizeDelta({ + editor, + resizeState, + rawDeltaTime, + zoomLevel, + shouldSnap: snappingEnabled && !isShiftHeldRef.current, + onSnapPointChange, + }); + const fps = editor.project.getActive().settings.fps; + const result = computeGroupResize({ + members: resizeState.members, + side: resizeState.side, + deltaTime: snappedDeltaTime.deltaTime, + fps, + }); + + latestResultRef.current = result; + editor.timeline.previewElements({ + updates: result.updates.map(({ trackId, elementId, patch }) => ({ + trackId, + elementId, + updates: patch, + })), + }); + }; + + const handleMouseUp = () => { + const result = latestResultRef.current; + editor.timeline.discardPreview(); + if ( + result && + hasResizeChanges({ members: resizeState.members, result }) + ) { + editor.timeline.updateElements({ + updates: result.updates.map(({ trackId, elementId, patch }) => ({ + trackId, + elementId, + patch, + })), + }); + } + + setResizeState(null); + latestResultRef.current = null; + onSnapPointChange?.(null); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [ + resizeState, + zoomLevel, + snappingEnabled, + isShiftHeldRef, + editor, + onSnapPointChange, + ]); + + return { + isResizing: resizeState !== null, + handleResizeStart, + }; +} + +function buildResizeMembers({ + tracks, + selectedElements, +}: { + tracks: SceneTracks; + selectedElements: Array<{ trackId: string; elementId: string }>; +}): GroupResizeMember[] { + const selectedElementIds = new Set( + selectedElements.map((selectedElement) => selectedElement.elementId), + ); + const trackMap = new Map( + [...tracks.overlay, tracks.main, ...tracks.audio].map((track) => [ + track.id, + track, + ]), + ); + + return selectedElements.flatMap(({ trackId, elementId }) => { + const track = trackMap.get(trackId); + const element = track?.elements.find( + (trackElement) => trackElement.id === elementId, + ); + if (!track || !element) { + return []; + } + + const otherElements = track.elements.filter( + (trackElement) => !selectedElementIds.has(trackElement.id), + ); + const leftNeighborBound = otherElements + .filter( + (trackElement) => + trackElement.startTime + trackElement.duration <= element.startTime, + ) + .reduce((bound, trackElement) => { + return Math.max(bound, trackElement.startTime + trackElement.duration); + }, -Infinity); + const rightNeighborBound = otherElements + .filter( + (trackElement) => + trackElement.startTime >= element.startTime + element.duration, + ) + .reduce((bound, trackElement) => { + return Math.min(bound, trackElement.startTime); + }, Infinity); + + return [ + { + trackId, + elementId, + startTime: element.startTime, + duration: element.duration, + trimStart: element.trimStart, + trimEnd: element.trimEnd, + sourceDuration: element.sourceDuration, + retime: isRetimableElement(element) ? element.retime : undefined, + leftNeighborBound, + rightNeighborBound, + }, + ]; + }); +} + +function getSnappedResizeDelta({ + editor, + resizeState, + rawDeltaTime, + zoomLevel, + shouldSnap, + onSnapPointChange, +}: { + editor: EditorCore; + resizeState: ResizeInteractionState; + rawDeltaTime: number; + zoomLevel: number; + shouldSnap: boolean; + onSnapPointChange?: (snapPoint: SnapPoint | null) => void; +}): { deltaTime: number } { + if (!shouldSnap) { + onSnapPointChange?.(null); + return { deltaTime: rawDeltaTime }; + } + + const snapPoints = findSnapPoints({ + tracks: editor.scenes.getActiveScene().tracks, + playheadTime: editor.playback.getCurrentTime(), + excludeElementIds: new Set( + resizeState.members.map((member) => member.elementId), + ), + }); + let closestSnapPoint: SnapPoint | null = null; + let closestSnapDistance = Infinity; + let deltaTime = rawDeltaTime; + + for (const member of resizeState.members) { + const baseEdgeTime = + resizeState.side === "left" + ? member.startTime + : member.startTime + member.duration; + const snapResult = snapToNearestPoint({ + targetTime: baseEdgeTime + rawDeltaTime, + snapPoints, + zoomLevel, + }); + if (snapResult.snapPoint && snapResult.snapDistance < closestSnapDistance) { + closestSnapDistance = snapResult.snapDistance; + closestSnapPoint = snapResult.snapPoint; + deltaTime = snapResult.snappedTime - baseEdgeTime; + } + } + + onSnapPointChange?.(closestSnapPoint); + return { deltaTime }; +} + +function hasResizeChanges({ + members, + result, +}: { + members: GroupResizeMember[]; + result: GroupResizeResult; +}): boolean { + return result.updates.some((update) => { + const member = members.find( + (candidateMember) => candidateMember.elementId === update.elementId, + ); + return ( + member?.trimStart !== update.patch.trimStart || + member?.trimEnd !== update.patch.trimEnd || + member?.startTime !== update.patch.startTime || + member?.duration !== update.patch.duration + ); + }); +} -- cgit v1.3.1