diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-15 05:43:29 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-15 05:43:29 +0200 |
| commit | ac43a5a73fa905cdac905bc956763b6093d6738e (patch) | |
| tree | 8d9ff8c3a9ae55bac8860215bec69cc5b6094a1c /apps | |
| parent | 6312fc5b078689bc82c52fda9b7f92ee0a21d311 (diff) | |
| download | opencut-ac43a5a73fa905cdac905bc956763b6093d6738e.tar.gz | |
feat: move/resize multiple elements
Diffstat (limited to 'apps')
21 files changed, 2218 insertions, 957 deletions
diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx index ae9e19c9..20e9faf1 100644 --- a/apps/web/src/components/editor/panels/timeline/index.tsx +++ b/apps/web/src/components/editor/panels/timeline/index.tsx @@ -61,7 +61,6 @@ import { SELECTED_TRACK_ROW_CLASS } from "./theme"; import { computeTrackExpansionHeight, getTrackExpandedRows, - getExpansionHeight, getPropertyLabel, type ExpandedRow, } from "./expanded-layout"; @@ -75,6 +74,7 @@ import { TimelineBookmarksRow } from "./bookmarks"; import { useBookmarkDrag } from "@/hooks/timeline/use-bookmark-drag"; import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; import { useInitialScrollBottom } from "@/hooks/timeline/use-initial-scroll-bottom"; +import { useTimelineResize } from "@/hooks/timeline/use-timeline-resize"; import { useTimelineStore } from "@/stores/timeline-store"; import { useEditor } from "@/hooks/use-editor"; import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead"; @@ -119,7 +119,9 @@ export function Timeline() { } = useElementSelection(); const editor = useEditor(); const timeline = editor.timeline; - const scene = useEditor((currentEditor) => currentEditor.scenes.getActiveSceneOrNull()); + const scene = useEditor((currentEditor) => + currentEditor.scenes.getActiveSceneOrNull(), + ); const tracks = useMemo<TimelineTrack[]>( () => scene @@ -140,7 +142,6 @@ export function Timeline() { const playheadRef = useRef<HTMLDivElement>(null); const trackLabelsScrollRef = useRef<HTMLDivElement>(null); - const [isResizing, setIsResizing] = useState(false); const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>( null, ); @@ -148,15 +149,6 @@ export function Timeline() { const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => { setCurrentSnapPoint(snapPoint); }, []); - const handleResizeStateChange = useCallback( - ({ isResizing: nextIsResizing }: { isResizing: boolean }) => { - setIsResizing(nextIsResizing); - if (!nextIsResizing) { - setCurrentSnapPoint(null); - } - }, - [], - ); const timelineDuration = timeline.getTotalDuration() || 0; const minZoomLevel = getTimelineZoomMin({ @@ -176,6 +168,10 @@ export function Timeline() { tracksScrollRef, rulerScrollRef, }); + const { isResizing, handleResizeStart } = useTimelineResize({ + zoomLevel, + onSnapPointChange: handleSnapPointChange, + }); const expandedElementIds = useTimelineStore((s) => s.expandedElementIds); @@ -366,7 +362,10 @@ export function Timeline() { const containerWidth = tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH; - const contentWidth = timelineTimeToPixels({ time: timelineDuration, zoomLevel }); + const contentWidth = timelineTimeToPixels({ + time: timelineDuration, + zoomLevel, + }); const paddingPx = getTimelinePaddingPx({ containerWidth, zoomLevel, @@ -513,7 +512,10 @@ export function Timeline() { TRACKS_CONTAINER_HEIGHT.min, Math.min( TRACKS_CONTAINER_HEIGHT.max, - getTotalTracksHeight({ tracks, getExtraHeight: getTrackExpansionHeight }), + getTotalTracksHeight({ + tracks, + getExtraHeight: getTrackExpansionHeight, + }), ), ) + TIMELINE_CONTENT_TOP_PADDING_PX }px`, @@ -533,14 +535,13 @@ export function Timeline() { }} > {tracks.length > 0 && ( - <TimelineTrackRows - mainTrackId={mainTrackId} + <TimelineTrackRows + mainTrackId={mainTrackId} zoomLevel={zoomLevel} dragState={dragState} tracksScrollRef={tracksScrollRef} lastMouseXRef={lastMouseXRef} - onSnapPointChange={handleSnapPointChange} - onResizeStateChange={handleResizeStateChange} + onResizeStart={handleResizeStart} onElementMouseDown={handleElementMouseDown} onElementClick={handleElementClick} onTrackMouseDown={(event) => { @@ -718,8 +719,7 @@ function TimelineTrackRows({ dragState, tracksScrollRef, lastMouseXRef, - onSnapPointChange, - onResizeStateChange, + onResizeStart, onElementMouseDown, onElementClick, onTrackMouseDown, @@ -733,8 +733,9 @@ function TimelineTrackRows({ dragState: ElementDragState; tracksScrollRef: React.RefObject<HTMLDivElement | null>; lastMouseXRef: React.RefObject<number>; - onSnapPointChange: (snapPoint: SnapPoint | null) => void; - onResizeStateChange: (params: { isResizing: boolean }) => void; + onResizeStart: React.ComponentProps< + typeof TimelineTrackContent + >["onResizeStart"]; onElementMouseDown: React.ComponentProps< typeof TimelineTrackContent >["onElementMouseDown"]; @@ -798,8 +799,7 @@ function TimelineTrackRows({ <div className={cn( "absolute right-0 left-0 transition-colors", - tracksWithSelection.has(track.id) && - SELECTED_TRACK_ROW_CLASS, + tracksWithSelection.has(track.id) && SELECTED_TRACK_ROW_CLASS, )} style={{ top: `${TIMELINE_CONTENT_TOP_PADDING_PX + getCumulativeHeightBefore({ tracks, trackIndex: index, getExtraHeight: getTrackExpansionHeight })}px`, @@ -813,8 +813,7 @@ function TimelineTrackRows({ rulerScrollRef={tracksScrollRef} tracksScrollRef={tracksScrollRef} lastMouseXRef={lastMouseXRef} - onSnapPointChange={onSnapPointChange} - onResizeStateChange={onResizeStateChange} + onResizeStart={onResizeStart} onElementMouseDown={onElementMouseDown} onElementClick={onElementClick} onTrackMouseDown={onTrackMouseDown} @@ -929,12 +928,10 @@ function TrackToggleIcon({ function PropertyTree({ rows }: { rows: ExpandedRow[] }) { return ( <div className="flex flex-col overflow-hidden"> - {rows.map((row, index) => ( + {rows.map((row) => ( <div key={row.propertyPath} - className={cn( - "flex shrink-0 items-center px-3 bg-muted/50", - )} + className={cn("flex shrink-0 items-center px-3 bg-muted/50")} style={{ height: `${KEYFRAME_LANE_HEIGHT_PX}px` }} > <span className="text-muted-foreground truncate text-xs leading-none"> diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx index a3555e94..3ebd3efa 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx @@ -10,7 +10,6 @@ import { } from "@/hooks/timeline/element/use-keyframe-drag"; import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection"; import { useKeyframeBoxSelect } from "@/hooks/timeline/element/use-keyframe-box-select"; -import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize"; import { SelectionBox } from "@/lib/selection/selection-box"; import { getElementKeyframes } from "@/lib/animation"; import { @@ -86,7 +85,6 @@ import { getExpansionHeight, type ExpandedRow, } from "./expanded-layout"; -import type { SnapPoint } from "@/lib/timeline/snap-utils"; const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40; const ELEMENT_RING_WIDTH_PX = 1.5; @@ -197,8 +195,12 @@ interface TimelineElementProps { track: TimelineTrack; zoomLevel: number; isSelected: boolean; - onSnapPointChange?: (snapPoint: SnapPoint | null) => void; - onResizeStateChange?: (params: { isResizing: boolean }) => void; + onResizeStart: (params: { + event: React.MouseEvent; + element: TimelineElementType; + track: TimelineTrack; + side: "left" | "right"; + }) => void; onElementMouseDown: ( event: React.MouseEvent, element: TimelineElementType, @@ -216,8 +218,7 @@ export function TimelineElement({ track, zoomLevel, isSelected, - onSnapPointChange, - onResizeStateChange, + onResizeStart, onElementMouseDown, onElementClick, dragState, @@ -231,18 +232,6 @@ export function TimelineElement({ elementId: element.id, fallback: element, }); - const { - currentDuration, - currentStartTime, - handleResizeStart, - isResizing, - } = useTimelineElementResize({ - element, - track, - zoomLevel, - onSnapPointChange, - onResizeStateChange, - }); let mediaAsset: MediaAsset | null = null; @@ -267,13 +256,9 @@ export function TimelineElement({ const elementStartTime = isBeingDragged && dragState.isDragging ? dragState.currentTime + dragTimeOffset - : isResizing - ? currentStartTime - : renderElement.startTime; + : renderElement.startTime; const displayedStartTime = elementStartTime; - const displayedDuration = isResizing - ? currentDuration - : renderElement.duration; + const displayedDuration = renderElement.duration; const elementWidth = timelineTimeToPixels({ time: displayedDuration, zoomLevel, @@ -282,22 +267,6 @@ export function TimelineElement({ time: displayedStartTime, zoomLevel, }); - const handleElementResizeStart = ({ - event, - element, - side, - }: { - event: React.MouseEvent; - element: TimelineElementType; - track: TimelineTrack; - side: "left" | "right"; - }) => { - handleResizeStart({ - event, - elementId: element.id, - side, - }); - }; const keyframeIndicators = isSelected ? getKeyframeIndicators({ keyframes: getElementKeyframes({ animations: element.animations }), @@ -329,9 +298,7 @@ export function TimelineElement({ ); const expandedRows = useMemo( () => - isExpanded - ? getExpandedRows({ animations: element.animations }) - : [], + isExpanded ? getExpandedRows({ animations: element.animations }) : [], [isExpanded, element.animations], ); @@ -422,7 +389,7 @@ export function TimelineElement({ expandedContent={expandedContent} onElementClick={onElementClick} onElementMouseDown={onElementMouseDown} - onResizeStart={handleElementResizeStart} + onResizeStart={onResizeStart} isDropTarget={isDropTarget} /> {isSelected && ( @@ -811,8 +778,7 @@ function ExpandedKeyframeLanes({ [...keyframes] .sort( (a, b) => - a.time - b.time || - a.propertyPath.localeCompare(b.propertyPath), + a.time - b.time || a.propertyPath.localeCompare(b.propertyPath), ) .map((kf) => ({ trackId, @@ -824,6 +790,8 @@ function ExpandedKeyframeLanes({ ); return ( + // biome-ignore lint/a11y/noStaticElementInteractions: expanded keyframe lanes are a pointer-only editing surface + // biome-ignore lint/a11y/useKeyWithClickEvents: expanded keyframe lanes are a pointer-only editing surface <div ref={containerRef} className="relative flex flex-col" @@ -837,9 +805,7 @@ function ExpandedKeyframeLanes({ return ( <div key={row.propertyPath} - className={cn( - "relative flex items-center bg-muted/50", - )} + className={cn("relative flex items-center bg-muted/50")} style={{ height: `${KEYFRAME_LANE_HEIGHT_PX}px` }} > {laneKeyframes.map((kf) => { @@ -849,8 +815,9 @@ function ExpandedKeyframeLanes({ propertyPath: row.propertyPath, keyframeId: kf.id, }; - const isBeingDragged = - keyframeDragState.draggingKeyframeIds.has(kf.id); + const isBeingDragged = keyframeDragState.draggingKeyframeIds.has( + kf.id, + ); const kfLeft = timelineTimeToSnappedPixels({ time: displayedStartTime + kf.time, zoomLevel, @@ -898,9 +865,7 @@ function ExpandedKeyframeLanes({ icon={KeyframeIcon} className={cn( "size-3.5 text-black mr-1", - isSelected - ? "fill-primary" - : "fill-white", + isSelected ? "fill-primary" : "fill-white", )} strokeWidth={1.5} /> diff --git a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx index 888e1496..32b1b93d 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx @@ -1,129 +1,132 @@ -"use client";
-
-import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
-import { TimelineElement } from "./timeline-element";
-import type { TimelineTrack } from "@/lib/timeline";
-import type { TimelineElement as TimelineElementType } from "@/lib/timeline";
-import type { SnapPoint } from "@/lib/timeline/snap-utils";
-import { TIMELINE_LAYERS } from "./layers";
-import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
-import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
-import type { ElementDragState } from "@/lib/timeline";
-import { useEditor } from "@/hooks/use-editor";
-
-interface TimelineTrackContentProps {
- track: TimelineTrack;
- zoomLevel: number;
- dragState: ElementDragState;
- rulerScrollRef: React.RefObject<HTMLDivElement | null>;
- tracksScrollRef: React.RefObject<HTMLDivElement | null>;
- lastMouseXRef: React.RefObject<number>;
- onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
- onResizeStateChange?: (params: { isResizing: boolean }) => void;
- onElementMouseDown: (params: {
- event: React.MouseEvent;
- element: TimelineElementType;
- track: TimelineTrack;
- }) => void;
- onElementClick: (params: {
- event: React.MouseEvent;
- element: TimelineElementType;
- track: TimelineTrack;
- }) => void;
- onTrackMouseDown?: (event: React.MouseEvent) => void;
- onTrackMouseUp?: (event: React.MouseEvent) => void;
- shouldIgnoreClick?: () => boolean;
- targetElementId?: string | null;
-}
-
-export function TimelineTrackContent({
- track,
- zoomLevel,
- dragState,
- rulerScrollRef,
- tracksScrollRef,
- lastMouseXRef,
- onSnapPointChange,
- onResizeStateChange,
- onElementMouseDown,
- onElementClick,
- onTrackMouseDown,
- onTrackMouseUp,
- shouldIgnoreClick,
- targetElementId = null,
-}: TimelineTrackContentProps) {
- const { isElementSelected } = useElementSelection();
- const duration = useEditor((e) => e.timeline.getTotalDuration());
-
- useEdgeAutoScroll({
- isActive: dragState.isDragging,
- getMouseClientX: () => lastMouseXRef.current ?? 0,
- rulerScrollRef,
- tracksScrollRef,
- contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel,
- });
-
- return (
- <div className="relative size-full">
- <button
- type="button"
- className="absolute inset-0 m-0 size-full appearance-none border-0 bg-transparent p-0"
- aria-label={`Select ${track.name} track`}
- onMouseUp={(event) => {
- if (shouldIgnoreClick?.()) return;
- onTrackMouseUp?.(event);
- }}
- onMouseDown={(event) => {
- event.preventDefault();
- onTrackMouseDown?.(event);
- }}
- />
- {/* biome-ignore lint/a11y/noStaticElementInteractions: empty track area is a pointer-only seek surface */}
- <div
- className="relative h-full min-w-full"
- style={{ zIndex: TIMELINE_LAYERS.trackContent }}
- onMouseUp={(event) => {
- if (event.target !== event.currentTarget) return;
- if (shouldIgnoreClick?.()) return;
- onTrackMouseUp?.(event);
- }}
- onMouseDown={(event) => {
- if (event.target !== event.currentTarget) return;
- event.preventDefault();
- onTrackMouseDown?.(event);
- }}
- >
- {track.elements.length === 0 ? (
- <div className="text-muted-foreground border-muted/30 pointer-events-none flex size-full items-center justify-center rounded-sm border-2 border-dashed text-xs" />
- ) : (
- track.elements.map((element) => {
- const isSelected = isElementSelected({
- trackId: track.id,
- elementId: element.id,
- });
-
- return (
- <TimelineElement
- key={element.id}
- element={element}
- track={track}
- zoomLevel={zoomLevel}
- isSelected={isSelected}
- onSnapPointChange={onSnapPointChange}
- onResizeStateChange={onResizeStateChange}
- onElementMouseDown={(event, element) =>
- onElementMouseDown({ event, element, track })
- }
- onElementClick={(event, element) =>
- onElementClick({ event, element, track })
- }
- dragState={dragState}
- isDropTarget={element.id === targetElementId}
- />
- );
- })
- )}
- </div>
- </div>
- );
-}
+"use client"; + +import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; +import { TimelineElement } from "./timeline-element"; +import type { TimelineTrack } from "@/lib/timeline"; +import type { TimelineElement as TimelineElementType } from "@/lib/timeline"; +import { TIMELINE_LAYERS } from "./layers"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; +import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; +import type { ElementDragState } from "@/lib/timeline"; +import { useEditor } from "@/hooks/use-editor"; + +interface TimelineTrackContentProps { + track: TimelineTrack; + zoomLevel: number; + dragState: ElementDragState; + rulerScrollRef: React.RefObject<HTMLDivElement | null>; + tracksScrollRef: React.RefObject<HTMLDivElement | null>; + lastMouseXRef: React.RefObject<number>; + onResizeStart: (params: { + event: React.MouseEvent; + element: TimelineElementType; + track: TimelineTrack; + side: "left" | "right"; + }) => void; + onElementMouseDown: (params: { + event: React.MouseEvent; + element: TimelineElementType; + track: TimelineTrack; + }) => void; + onElementClick: (params: { + event: React.MouseEvent; + element: TimelineElementType; + track: TimelineTrack; + }) => void; + onTrackMouseDown?: (event: React.MouseEvent) => void; + onTrackMouseUp?: (event: React.MouseEvent) => void; + shouldIgnoreClick?: () => boolean; + targetElementId?: string | null; +} + +export function TimelineTrackContent({ + track, + zoomLevel, + dragState, + rulerScrollRef, + tracksScrollRef, + lastMouseXRef, + onResizeStart, + onElementMouseDown, + onElementClick, + onTrackMouseDown, + onTrackMouseUp, + shouldIgnoreClick, + targetElementId = null, +}: TimelineTrackContentProps) { + const { isElementSelected } = useElementSelection(); + const duration = useEditor((e) => e.timeline.getTotalDuration()); + + useEdgeAutoScroll({ + isActive: dragState.isDragging, + getMouseClientX: () => lastMouseXRef.current ?? 0, + rulerScrollRef, + tracksScrollRef, + contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel, + }); + + return ( + <div className="relative size-full"> + <button + type="button" + className="absolute inset-0 m-0 size-full appearance-none border-0 bg-transparent p-0" + aria-label={`Select ${track.name} track`} + onMouseUp={(event) => { + if (shouldIgnoreClick?.()) return; + onTrackMouseUp?.(event); + }} + onMouseDown={(event) => { + event.preventDefault(); + onTrackMouseDown?.(event); + }} + /> + {/* biome-ignore lint/a11y/noStaticElementInteractions: empty track area is a pointer-only seek surface */} + <div + className="relative h-full min-w-full" + style={{ zIndex: TIMELINE_LAYERS.trackContent }} + onMouseUp={(event) => { + if (event.target !== event.currentTarget) return; + if (shouldIgnoreClick?.()) return; + onTrackMouseUp?.(event); + }} + onMouseDown={(event) => { + if (event.target !== event.currentTarget) return; + event.preventDefault(); + onTrackMouseDown?.(event); + }} + > + {track.elements.length === 0 ? ( + <div className="text-muted-foreground border-muted/30 pointer-events-none flex size-full items-center justify-center rounded-sm border-2 border-dashed text-xs" /> + ) : ( + track.elements.map((element) => { + const isSelected = isElementSelected({ + trackId: track.id, + elementId: element.id, + }); + + return ( + <TimelineElement + key={element.id} + element={element} + track={track} + zoomLevel={zoomLevel} + isSelected={isSelected} + onResizeStart={({ event, element, side }) => + onResizeStart({ event, element, track, side }) + } + onElementMouseDown={(event, element) => + onElementMouseDown({ event, element, track }) + } + onElementClick={(event, element) => + onElementClick({ event, element, track }) + } + dragState={dragState} + isDropTarget={element.id === targetElementId} + /> + ); + }) + )} + </div> + </div> + ); +} diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts index 70cd0439..c1398e8b 100644 --- a/apps/web/src/core/managers/timeline-manager.ts +++ b/apps/web/src/core/managers/timeline-manager.ts @@ -8,9 +8,7 @@ import type { RetimeConfig, } from "@/lib/timeline"; import { calculateTotalDuration } from "@/lib/timeline"; -import { - findTrackInSceneTracks, -} from "@/lib/timeline/track-element-update"; +import { findTrackInSceneTracks } from "@/lib/timeline/track-element-update"; import { canElementBeHidden, canElementHaveAudio, @@ -57,6 +55,10 @@ import { ToggleSourceAudioSeparationCommand, } from "@/lib/commands/timeline"; import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element"; +import type { + PlannedElementMove, + PlannedTrackCreation, +} from "@/lib/timeline/group-move"; export class TimelineManager { private listeners = new Set<() => void>(); @@ -149,25 +151,20 @@ export class TimelineManager { }); } - moveElement({ - sourceTrackId, - targetTrackId, - elementId, - newStartTime, - createTrack, + moveElements({ + moves, + createTracks, }: { - sourceTrackId: string; - targetTrackId: string; - elementId: string; - newStartTime: number; - createTrack?: { type: TrackType; index: number }; + moves: PlannedElementMove[]; + createTracks?: PlannedTrackCreation[]; }): void { + if (moves.length === 0) { + return; + } + const command = new MoveElementCommand({ - sourceTrackId, - targetTrackId, - elementId, - newStartTime, - createTrack, + moves, + createTracks, }); this.editor.command.execute({ command }); } @@ -580,14 +577,7 @@ export class TimelineManager { } const commands = keyframes.map( - ({ - trackId, - elementId, - propertyPath, - componentKey, - keyframeId, - patch, - }) => + ({ trackId, elementId, propertyPath, componentKey, keyframeId, patch }) => new UpdateScalarKeyframeCurveCommand({ trackId, elementId, @@ -711,7 +701,9 @@ export class TimelineManager { private applyPreviewOverlay(tracks: SceneTracks): SceneTracks { if (this.previewOverlay.size === 0) return tracks; - const applyTrackOverlay = <TTrack extends TimelineTrack>(track: TTrack): TTrack => { + const applyTrackOverlay = <TTrack extends TimelineTrack>( + track: TTrack, + ): TTrack => { const hasOverlay = track.elements.some((element) => this.previewOverlay.has(element.id), ); @@ -803,7 +795,11 @@ export class TimelineManager { } getPreviewTracks(): SceneTracks | null { - return this.previewTracks ?? this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null; + return ( + this.previewTracks ?? + this.editor.scenes.getActiveSceneOrNull()?.tracks ?? + null + ); } subscribe(listener: () => void): () => void { 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<DropTarget | null>(null); const [isPendingDrag, setIsPendingDrag] = useState(false); const pendingDragRef = useRef<PendingDragState | null>(null); + const moveGroupRef = useRef<MoveGroup | null>(null); + const groupMoveResultRef = useRef<GroupMoveResult | null>(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<string, number> = {}; + 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<ResizeState | null>(null); - const [currentTrimStart, setCurrentTrimStart] = useState(element.trimStart); - const [currentTrimEnd, setCurrentTrimEnd] = useState(element.trimEnd); - const [currentStartTime, setCurrentStartTime] = useState(element.startTime); - const [currentDuration, setCurrentDuration] = useState(element.duration); - const currentTrimStartRef = useRef(element.trimStart); - const currentTrimEndRef = useRef(element.trimEnd); - const currentStartTimeRef = useRef(element.startTime); - const currentDurationRef = useRef(element.duration); - - const handleResizeStart = ({ - 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<ResizeInteractionState | null>( + null, + ); + const latestResultRef = useRef<GroupResizeResult | null>(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 + ); + }); +} diff --git a/apps/web/src/lib/commands/timeline/element/move-elements.ts b/apps/web/src/lib/commands/timeline/element/move-elements.ts index c1fa6897..533c85b4 100644 --- a/apps/web/src/lib/commands/timeline/element/move-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/move-elements.ts @@ -3,140 +3,123 @@ import { EditorCore } from "@/core"; import type { SceneTracks, TimelineElement, - TrackType, TimelineTrack, } from "@/lib/timeline"; import { buildEmptyTrack, validateElementTrackCompatibility, - enforceMainTrackStart, } from "@/lib/timeline/placement"; -import { - findTrackInSceneTracks, - updateTrackInSceneTracks, -} from "@/lib/timeline/track-element-update"; +import type { + PlannedElementMove, + PlannedTrackCreation, +} from "@/lib/timeline/group-move"; +import { findTrackInSceneTracks } from "@/lib/timeline/track-element-update"; export class MoveElementCommand extends Command { private savedState: SceneTracks | null = null; - private readonly sourceTrackId: string; - private readonly targetTrackId: string; - private readonly elementId: string; - private readonly newStartTime: number; - private readonly createTrack: { type: TrackType; index: number } | undefined; constructor({ - sourceTrackId, - targetTrackId, - elementId, - newStartTime, - createTrack, + moves, + createTracks = [], }: { - sourceTrackId: string; - targetTrackId: string; - elementId: string; - newStartTime: number; - createTrack?: { type: TrackType; index: number }; + moves: PlannedElementMove[]; + createTracks?: PlannedTrackCreation[]; }) { super(); - this.sourceTrackId = sourceTrackId; - this.targetTrackId = targetTrackId; - this.elementId = elementId; - this.newStartTime = newStartTime; - this.createTrack = createTrack; + this.moves = moves; + this.createTracks = createTracks; } + private readonly moves: PlannedElementMove[]; + private readonly createTracks: PlannedTrackCreation[]; + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); this.savedState = editor.scenes.getActiveScene().tracks; - const sourceTrack = findTrackInSceneTracks({ - tracks: this.savedState, - trackId: this.sourceTrackId, - }); - const element = sourceTrack?.elements.find( - (trackElement) => trackElement.id === this.elementId, - ); - - if (!sourceTrack || !element) { - throw new Error("Source track or element not found"); - } - - let targetTrack = findTrackInSceneTracks({ - tracks: this.savedState, - trackId: this.targetTrackId, - }); let tracksToUpdate = this.savedState; - if (!targetTrack && this.createTrack) { - const newTrack = buildEmptyTrack({ - id: this.targetTrackId, - type: this.createTrack.type, - }); + for (const createTrack of [...this.createTracks].sort( + (firstTrack, secondTrack) => firstTrack.index - secondTrack.index, + )) { tracksToUpdate = insertTrackAtDisplayIndex({ - tracks: this.savedState, - track: newTrack, - insertIndex: this.createTrack.index, + tracks: tracksToUpdate, + track: buildEmptyTrack({ + id: createTrack.id, + type: createTrack.type, + }), + insertIndex: createTrack.index, }); - targetTrack = newTrack; } - if (!targetTrack) { - throw new Error("Target track not found"); + + const movedElementsById = new Map<string, TimelineElement>(); + for (const move of this.moves) { + const sourceTrack = findTrackInSceneTracks({ + tracks: this.savedState, + trackId: move.sourceTrackId, + }); + const sourceElement = sourceTrack?.elements.find( + (trackElement) => trackElement.id === move.elementId, + ); + if (!sourceTrack || !sourceElement) { + throw new Error("Source track or element not found"); + } + + const targetTrack = findTrackInSceneTracks({ + tracks: tracksToUpdate, + trackId: move.targetTrackId, + }); + if (!targetTrack) { + throw new Error("Target track not found"); + } + + const validation = validateElementTrackCompatibility({ + element: sourceElement, + track: targetTrack, + }); + if (!validation.isValid) { + throw new Error(validation.errorMessage); + } + + movedElementsById.set(move.elementId, { + ...sourceElement, + startTime: move.newStartTime, + }); } - const validation = validateElementTrackCompatibility({ - element, - track: targetTrack, - }); + const movedElementIds = new Set(this.moves.map((move) => move.elementId)); + const movedElementsByTargetTrackId = new Map<string, TimelineElement[]>(); + for (const move of this.moves) { + const movedElement = movedElementsById.get(move.elementId); + if (!movedElement) { + continue; + } - if (!validation.isValid) { - throw new Error(validation.errorMessage); + const nextTargetElements = + movedElementsByTargetTrackId.get(move.targetTrackId) ?? []; + nextTargetElements.push(movedElement); + movedElementsByTargetTrackId.set(move.targetTrackId, nextTargetElements); } - const adjustedStartTime = enforceMainTrackStart({ + const updatedTracks = mapSceneTracks({ tracks: tracksToUpdate, - targetTrackId: this.targetTrackId, - requestedStartTime: this.newStartTime, - excludeElementId: this.elementId, + update: (track) => ({ + ...track, + elements: [ + ...track.elements.filter( + (element) => !movedElementIds.has(element.id), + ), + ...(movedElementsByTargetTrackId.get(track.id) ?? []), + ], + }), }); - // keyframe times remain clip-local, so moving only changes element startTime. - const movedElement: TimelineElement = { - ...element, - startTime: adjustedStartTime, - }; - - const isSameTrack = this.sourceTrackId === this.targetTrackId; - - const updatedTracks = isSameTrack - ? updateTrackInSceneTracks({ - tracks: tracksToUpdate, - trackId: this.sourceTrackId, - update: (track) => ({ - ...track, - elements: track.elements.map((trackElement) => - trackElement.id === this.elementId ? movedElement : trackElement, - ), - }), - }) - : updateTrackInSceneTracks({ - tracks: updateTrackInSceneTracks({ - tracks: tracksToUpdate, - trackId: this.sourceTrackId, - update: (track) => ({ - ...track, - elements: track.elements.filter( - (trackElement) => trackElement.id !== this.elementId, - ), - }), - }), - trackId: this.targetTrackId, - update: (track) => ({ - ...track, - elements: [...track.elements, movedElement], - }), - }); - editor.timeline.updateTracks(updatedTracks); - return undefined; + return { + select: this.moves.map(({ elementId, targetTrackId }) => ({ + trackId: targetTrackId, + elementId, + })), + }; } undo(): void { @@ -147,6 +130,20 @@ export class MoveElementCommand extends Command { } } +function mapSceneTracks({ + tracks, + update, +}: { + tracks: SceneTracks; + update: <TTrack extends TimelineTrack>(track: TTrack) => TTrack; +}): SceneTracks { + return { + overlay: tracks.overlay.map((track) => update(track)), + main: update(tracks.main), + audio: tracks.audio.map((track) => update(track)), + }; +} + function insertTrackAtDisplayIndex({ tracks, track, diff --git a/apps/web/src/lib/timeline/group-move/__tests__/group-move.test.ts b/apps/web/src/lib/timeline/group-move/__tests__/group-move.test.ts new file mode 100644 index 00000000..e6c86e41 --- /dev/null +++ b/apps/web/src/lib/timeline/group-move/__tests__/group-move.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, test } from "bun:test"; +import type { + AudioTrack, + OverlayTrack, + SceneTracks, + TextElement, + TextTrack, + VideoElement, + VideoTrack, +} from "@/lib/timeline/types"; +import type { Transform } from "@/lib/rendering"; +import { buildMoveGroup } from "@/lib/timeline/group-move/build-group"; +import { resolveGroupMove } from "@/lib/timeline/group-move/resolve-move"; + +function buildTransform(): Transform { + return { + scaleX: 1, + scaleY: 1, + position: { x: 0, y: 0 }, + rotate: 0, + }; +} + +function buildVideoElement({ + id, + startTime, + duration, +}: { + id: string; + startTime: number; + duration: number; +}): VideoElement { + return { + id, + type: "video", + name: id, + startTime, + duration, + trimStart: 0, + trimEnd: 0, + mediaId: `${id}-media`, + transform: buildTransform(), + opacity: 1, + }; +} + +function buildTextElement({ + id, + startTime, + duration, +}: { + id: string; + startTime: number; + duration: number; +}): TextElement { + return { + id, + type: "text", + name: id, + startTime, + duration, + trimStart: 0, + trimEnd: 0, + content: id, + fontSize: 32, + fontFamily: "Inter", + color: "#ffffff", + background: { + enabled: false, + color: "#000000", + }, + textAlign: "left", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", + transform: buildTransform(), + opacity: 1, + }; +} + +function buildVideoTrack({ + id, + elements = [], +}: { + id: string; + elements?: VideoTrack["elements"]; +}): VideoTrack { + return { + id, + type: "video", + name: id, + elements, + muted: false, + hidden: false, + }; +} + +function buildTextTrack({ + id, + elements = [], +}: { + id: string; + elements?: TextTrack["elements"]; +}): TextTrack { + return { + id, + type: "text", + name: id, + elements, + hidden: false, + }; +} + +function buildTracks({ + overlay, + main, + audio = [], +}: { + overlay: OverlayTrack[]; + main: VideoTrack; + audio?: AudioTrack[]; +}): SceneTracks { + return { + overlay, + main, + audio, + }; +} + +describe("group move", () => { + test("buildMoveGroup keeps rigid time offsets", () => { + const tracks = buildTracks({ + overlay: [ + buildVideoTrack({ + id: "overlay-video", + elements: [ + buildVideoElement({ id: "video-1", startTime: 5, duration: 4 }), + ], + }), + buildTextTrack({ + id: "overlay-text", + elements: [ + buildTextElement({ id: "text-1", startTime: 8, duration: 3 }), + ], + }), + ], + main: buildVideoTrack({ id: "main" }), + }); + + const group = buildMoveGroup({ + anchorRef: { trackId: "overlay-video", elementId: "video-1" }, + selectedElements: [ + { trackId: "overlay-video", elementId: "video-1" }, + { trackId: "overlay-text", elementId: "text-1" }, + ], + tracks, + }); + + expect(group).not.toBeNull(); + expect(group?.members.map((member) => member.timeOffset)).toEqual([0, 3]); + }); + + test("resolveGroupMove preserves order and clamps rigidly at time zero", () => { + const tracks = buildTracks({ + overlay: [ + buildTextTrack({ + id: "overlay-text", + elements: [ + buildTextElement({ id: "text-1", startTime: 0, duration: 3 }), + ], + }), + buildVideoTrack({ + id: "overlay-video", + elements: [ + buildVideoElement({ id: "video-1", startTime: 5, duration: 4 }), + ], + }), + ], + main: buildVideoTrack({ id: "main" }), + }); + const group = buildMoveGroup({ + anchorRef: { trackId: "overlay-video", elementId: "video-1" }, + selectedElements: [ + { trackId: "overlay-video", elementId: "video-1" }, + { trackId: "overlay-text", elementId: "text-1" }, + ], + tracks, + }); + if (!group) { + throw new Error("Expected group"); + } + + const result = resolveGroupMove({ + group, + tracks, + anchorStartTime: 7, + target: { + kind: "existingTrack", + anchorTargetTrackId: "main", + }, + }); + + expect(result?.moves).toEqual([ + { + sourceTrackId: "overlay-video", + elementId: "video-1", + targetTrackId: "main", + newStartTime: 5, + }, + { + sourceTrackId: "overlay-text", + elementId: "text-1", + targetTrackId: "overlay-text", + newStartTime: 0, + }, + ]); + }); + + test("resolveGroupMove falls back to new overlay tracks when the group cannot cross main", () => { + const tracks = buildTracks({ + overlay: [ + buildVideoTrack({ + id: "overlay-video", + elements: [ + buildVideoElement({ id: "video-1", startTime: 5, duration: 4 }), + ], + }), + buildTextTrack({ + id: "overlay-text", + elements: [ + buildTextElement({ id: "text-1", startTime: 5, duration: 3 }), + ], + }), + ], + main: buildVideoTrack({ id: "main" }), + }); + const group = buildMoveGroup({ + anchorRef: { trackId: "overlay-video", elementId: "video-1" }, + selectedElements: [ + { trackId: "overlay-video", elementId: "video-1" }, + { trackId: "overlay-text", elementId: "text-1" }, + ], + tracks, + }); + if (!group) { + throw new Error("Expected group"); + } + + const result = resolveGroupMove({ + group, + tracks, + anchorStartTime: 6, + target: { + kind: "newTracks", + anchorInsertIndex: 2, + newTrackIds: ["new-video-track", "new-text-track"], + }, + }); + + expect(result?.createTracks).toEqual([ + { + id: "new-video-track", + type: "video", + index: 2, + }, + { + id: "new-text-track", + type: "text", + index: 3, + }, + ]); + expect(result?.moves).toEqual([ + { + sourceTrackId: "overlay-video", + elementId: "video-1", + targetTrackId: "new-video-track", + newStartTime: 6, + }, + { + sourceTrackId: "overlay-text", + elementId: "text-1", + targetTrackId: "new-text-track", + newStartTime: 6, + }, + ]); + }); +}); diff --git a/apps/web/src/lib/timeline/group-move/build-group.ts b/apps/web/src/lib/timeline/group-move/build-group.ts new file mode 100644 index 00000000..16d720eb --- /dev/null +++ b/apps/web/src/lib/timeline/group-move/build-group.ts @@ -0,0 +1,87 @@ +import type { ElementRef, SceneTracks } from "@/lib/timeline"; +import { findTrackInSceneTracks } from "@/lib/timeline/track-element-update"; +import type { GroupMember, MoveGroup } from "./types"; +import { getTrackPlacementById } from "./track-placement"; + +export function buildMoveGroup({ + anchorRef, + selectedElements, + tracks, +}: { + anchorRef: ElementRef; + selectedElements: ElementRef[]; + tracks: SceneTracks; +}): MoveGroup | null { + const anchorTrack = findTrackInSceneTracks({ + tracks, + trackId: anchorRef.trackId, + }); + const anchorElement = anchorTrack?.elements.find( + (element) => element.id === anchorRef.elementId, + ); + const anchorPlacement = getTrackPlacementById({ + tracks, + trackId: anchorRef.trackId, + }); + if (!anchorTrack || !anchorElement || !anchorPlacement) { + return null; + } + + const seen = new Set<string>(); + const orderedRefs = [anchorRef, ...selectedElements].filter((elementRef) => { + if (seen.has(elementRef.elementId)) { + return false; + } + + seen.add(elementRef.elementId); + return true; + }); + + const members = orderedRefs.flatMap((elementRef): GroupMember[] => { + const track = findTrackInSceneTracks({ + tracks, + trackId: elementRef.trackId, + }); + const element = track?.elements.find( + (trackElement) => trackElement.id === elementRef.elementId, + ); + const placement = getTrackPlacementById({ + tracks, + trackId: elementRef.trackId, + }); + if (!track || !element || !placement) { + return []; + } + + return [ + { + trackId: track.id, + elementId: element.id, + elementType: element.type, + duration: element.duration, + timeOffset: element.startTime - anchorElement.startTime, + trackSection: placement.section, + sectionIndex: placement.sectionIndex, + displayIndex: placement.displayIndex, + }, + ]; + }); + + if (members.length === 0) { + return null; + } + + const anchor = members.find( + (member) => + member.trackId === anchorRef.trackId && + member.elementId === anchorRef.elementId, + ); + if (!anchor) { + return null; + } + + return { + anchor, + members, + }; +} diff --git a/apps/web/src/lib/timeline/group-move/index.ts b/apps/web/src/lib/timeline/group-move/index.ts new file mode 100644 index 00000000..82bd3895 --- /dev/null +++ b/apps/web/src/lib/timeline/group-move/index.ts @@ -0,0 +1,12 @@ +export { buildMoveGroup } from "./build-group"; +export { resolveGroupMove } from "./resolve-move"; +export { snapGroupEdges } from "./snap"; + +export type { + GroupMember, + GroupMoveResult, + GroupTrackSection, + MoveGroup, + PlannedElementMove, + PlannedTrackCreation, +} from "./types"; diff --git a/apps/web/src/lib/timeline/group-move/resolve-move.ts b/apps/web/src/lib/timeline/group-move/resolve-move.ts new file mode 100644 index 00000000..fbe4cbda --- /dev/null +++ b/apps/web/src/lib/timeline/group-move/resolve-move.ts @@ -0,0 +1,470 @@ +import type { SceneTracks } from "@/lib/timeline"; +import { getTrackTypeForElementType } from "@/lib/timeline/placement/compatibility"; +import { canPlaceTimeSpansOnTrack } from "@/lib/timeline/placement/overlap"; +import type { + GroupMoveResult, + MoveGroup, + PlannedElementMove, + PlannedTrackCreation, +} from "./types"; +import { + getDisplayTracks, + getTrackPlacementByDisplayIndex, + getTrackPlacementById, +} from "./track-placement"; + +type GroupMoveTarget = + | { + kind: "existingTrack"; + anchorTargetTrackId: string; + } + | { + kind: "newTracks"; + anchorInsertIndex: number; + newTrackIds: string[]; + }; + +export function resolveGroupMove({ + group, + tracks, + anchorStartTime, + target, +}: { + group: MoveGroup; + tracks: SceneTracks; + anchorStartTime: number; + target: GroupMoveTarget; +}): GroupMoveResult | null { + if (target.kind === "newTracks") { + return resolveNewTrackMove({ + group, + tracks, + anchorStartTime, + anchorInsertIndex: target.anchorInsertIndex, + newTrackIds: target.newTrackIds, + }); + } + + return resolveExistingTrackMove({ + group, + tracks, + anchorStartTime, + anchorTargetTrackId: target.anchorTargetTrackId, + }); +} + +function resolveExistingTrackMove({ + group, + tracks, + anchorStartTime, + anchorTargetTrackId, +}: { + group: MoveGroup; + tracks: SceneTracks; + anchorStartTime: number; + anchorTargetTrackId: string; +}): GroupMoveResult | null { + const anchorTargetPlacement = getTrackPlacementById({ + tracks, + trackId: anchorTargetTrackId, + }); + if (!anchorTargetPlacement) { + return null; + } + + const targetTrackIdsByElementId = resolveExistingTrackIdsByElementId({ + group, + tracks, + anchorTargetDisplayIndex: anchorTargetPlacement.displayIndex, + }); + if (!targetTrackIdsByElementId) { + return null; + } + + const clampedAnchorStartTime = clampAnchorStartTime({ + group, + tracks, + anchorStartTime, + targetTrackIdsByElementId, + }); + + const moves = group.members.map((member) => ({ + sourceTrackId: member.trackId, + targetTrackId: + targetTrackIdsByElementId.get(member.elementId) ?? member.trackId, + elementId: member.elementId, + newStartTime: clampedAnchorStartTime + member.timeOffset, + })); + + if (!canApplyMovesToExistingTracks({ tracks, moves })) { + return null; + } + + return { + moves, + createTracks: [], + targetSelection: moves.map(({ elementId, targetTrackId }) => ({ + trackId: targetTrackId, + elementId, + })), + }; +} + +function resolveNewTrackMove({ + group, + tracks, + anchorStartTime, + anchorInsertIndex, + newTrackIds, +}: { + group: MoveGroup; + tracks: SceneTracks; + anchorStartTime: number; + anchorInsertIndex: number; + newTrackIds: string[]; +}): GroupMoveResult | null { + const sortedMembers = [...group.members].sort( + (leftMember, rightMember) => + leftMember.displayIndex - rightMember.displayIndex, + ); + const anchorMemberIndex = sortedMembers.findIndex( + (member) => member.elementId === group.anchor.elementId, + ); + if (anchorMemberIndex < 0 || newTrackIds.length < sortedMembers.length) { + return null; + } + + const hasAudioMember = sortedMembers.some( + (member) => member.trackSection === "audio", + ); + const hasNonAudioMember = sortedMembers.some( + (member) => member.trackSection !== "audio", + ); + if (hasAudioMember && hasNonAudioMember) { + return null; + } + + const clampedAnchorStartTime = clampAnchorStartTime({ + group, + tracks, + anchorStartTime, + targetTrackIdsByElementId: new Map(), + }); + const blockStartIndex = hasAudioMember + ? clampAudioInsertIndex({ + tracks, + insertIndex: anchorInsertIndex - anchorMemberIndex, + }) + : Math.max( + 0, + Math.min(anchorInsertIndex - anchorMemberIndex, tracks.overlay.length), + ); + + const createTracks: PlannedTrackCreation[] = sortedMembers.map( + (member, memberIndex) => ({ + id: newTrackIds[memberIndex], + type: getTrackTypeForElementType({ + elementType: member.elementType, + }), + index: blockStartIndex + memberIndex, + }), + ); + const moves = sortedMembers.map((member, memberIndex) => ({ + sourceTrackId: member.trackId, + targetTrackId: newTrackIds[memberIndex], + elementId: member.elementId, + newStartTime: clampedAnchorStartTime + member.timeOffset, + })); + + return { + moves, + createTracks, + targetSelection: moves.map(({ elementId, targetTrackId }) => ({ + trackId: targetTrackId, + elementId, + })), + }; +} + +function clampAudioInsertIndex({ + tracks, + insertIndex, +}: { + tracks: SceneTracks; + insertIndex: number; +}): number { + const minimumAudioInsertIndex = tracks.overlay.length + 1; + return Math.max( + minimumAudioInsertIndex, + Math.min(insertIndex, minimumAudioInsertIndex + tracks.audio.length), + ); +} + +function resolveExistingTrackIdsByElementId({ + group, + tracks, + anchorTargetDisplayIndex, +}: { + group: MoveGroup; + tracks: SceneTracks; + anchorTargetDisplayIndex: number; +}): Map<string, string> | null { + const sortedMembers = [...group.members].sort( + (leftMember, rightMember) => + leftMember.displayIndex - rightMember.displayIndex, + ); + const anchorMemberIndex = sortedMembers.findIndex( + (member) => member.elementId === group.anchor.elementId, + ); + if (anchorMemberIndex < 0) { + return null; + } + + const targetTrackIdsByElementId = new Map<string, string>(); + const usedTrackIds = new Set<string>(); + const anchorPlacement = getTrackPlacementByDisplayIndex({ + tracks, + displayIndex: anchorTargetDisplayIndex, + }); + if (!anchorPlacement) { + return null; + } + + targetTrackIdsByElementId.set( + group.anchor.elementId, + anchorPlacement.trackId, + ); + usedTrackIds.add(anchorPlacement.trackId); + + let upperBoundaryIndex = anchorTargetDisplayIndex; + for ( + let memberIndex = anchorMemberIndex - 1; + memberIndex >= 0; + memberIndex -= 1 + ) { + const member = sortedMembers[memberIndex]; + const targetPlacement = findCompatibleTrackPlacement({ + tracks, + requiredTrackType: getTrackTypeForElementType({ + elementType: member.elementType, + }), + startDisplayIndex: upperBoundaryIndex - 1, + step: -1, + usedTrackIds, + }); + if (!targetPlacement) { + return null; + } + + targetTrackIdsByElementId.set(member.elementId, targetPlacement.trackId); + usedTrackIds.add(targetPlacement.trackId); + upperBoundaryIndex = targetPlacement.displayIndex; + } + + let lowerBoundaryIndex = anchorTargetDisplayIndex; + for ( + let memberIndex = anchorMemberIndex + 1; + memberIndex < sortedMembers.length; + memberIndex += 1 + ) { + const member = sortedMembers[memberIndex]; + const targetPlacement = findCompatibleTrackPlacement({ + tracks, + requiredTrackType: getTrackTypeForElementType({ + elementType: member.elementType, + }), + startDisplayIndex: lowerBoundaryIndex + 1, + step: 1, + usedTrackIds, + }); + if (!targetPlacement) { + return null; + } + + targetTrackIdsByElementId.set(member.elementId, targetPlacement.trackId); + usedTrackIds.add(targetPlacement.trackId); + lowerBoundaryIndex = targetPlacement.displayIndex; + } + + return targetTrackIdsByElementId; +} + +function findCompatibleTrackPlacement({ + tracks, + requiredTrackType, + startDisplayIndex, + step, + usedTrackIds, +}: { + tracks: SceneTracks; + requiredTrackType: ReturnType<typeof getTrackTypeForElementType>; + startDisplayIndex: number; + step: -1 | 1; + usedTrackIds: Set<string>; +}) { + for ( + let displayIndex = startDisplayIndex; + displayIndex >= 0 && + displayIndex < tracks.overlay.length + 1 + tracks.audio.length; + displayIndex += step + ) { + const placement = getTrackPlacementByDisplayIndex({ + tracks, + displayIndex, + }); + if (!placement) { + continue; + } + + if ( + placement.trackType === requiredTrackType && + !usedTrackIds.has(placement.trackId) + ) { + return placement; + } + } + + return null; +} + +function clampAnchorStartTime({ + group, + tracks, + anchorStartTime, + targetTrackIdsByElementId, +}: { + group: MoveGroup; + tracks: SceneTracks; + anchorStartTime: number; + targetTrackIdsByElementId: Map<string, string>; +}): number { + const minimumAnchorStartTime = Math.max( + 0, + ...group.members.map((member) => -member.timeOffset), + ); + let clampedAnchorStartTime = Math.max( + minimumAnchorStartTime, + anchorStartTime, + ); + + const memberOnMainTrack = group.members.find( + (member) => + targetTrackIdsByElementId.get(member.elementId) === tracks.main.id, + ); + if (!memberOnMainTrack) { + return clampedAnchorStartTime; + } + + const movingElementIds = new Set( + group.members.map((member) => member.elementId), + ); + const requestedMainStartTime = + clampedAnchorStartTime + memberOnMainTrack.timeOffset; + const earliestStationaryMainStartTime = tracks.main.elements + .filter((element) => !movingElementIds.has(element.id)) + .reduce<number | null>((earliestStartTime, element) => { + if (earliestStartTime == null || element.startTime < earliestStartTime) { + return element.startTime; + } + + return earliestStartTime; + }, null); + if ( + earliestStationaryMainStartTime == null || + requestedMainStartTime <= earliestStationaryMainStartTime + ) { + clampedAnchorStartTime = Math.max( + minimumAnchorStartTime, + -memberOnMainTrack.timeOffset, + ); + } + + return clampedAnchorStartTime; +} + +function canApplyMovesToExistingTracks({ + tracks, + moves, +}: { + tracks: SceneTracks; + moves: PlannedElementMove[]; +}): boolean { + const movingElementIds = new Set(moves.map((move) => move.elementId)); + const sourceElements = new Map( + getDisplayTracks({ tracks }).flatMap((track) => + track.elements.map((element) => [element.id, element] as const), + ), + ); + const movesByTargetTrackId = new Map<string, PlannedElementMove[]>(); + for (const move of moves) { + const targetMoves = movesByTargetTrackId.get(move.targetTrackId) ?? []; + targetMoves.push(move); + movesByTargetTrackId.set(move.targetTrackId, targetMoves); + } + + for (const [targetTrackId, targetMoves] of movesByTargetTrackId) { + const targetPlacement = getTrackPlacementById({ + tracks, + trackId: targetTrackId, + }); + if (!targetPlacement) { + return false; + } + + const targetTrack = getDisplayTracks({ tracks })[ + targetPlacement.displayIndex + ]; + if (!targetTrack) { + return false; + } + + const timeSpans = targetMoves.map((move) => { + const sourceElement = sourceElements.get(move.elementId); + return { + startTime: move.newStartTime, + duration: sourceElement?.duration ?? 0, + }; + }); + if (hasOverlappingTimeSpans({ timeSpans })) { + return false; + } + + if ( + !canPlaceTimeSpansOnTrack({ + track: { + elements: targetTrack.elements.filter( + (element) => !movingElementIds.has(element.id), + ), + }, + timeSpans, + }) + ) { + return false; + } + } + + return true; +} + +function hasOverlappingTimeSpans({ + timeSpans, +}: { + timeSpans: Array<{ startTime: number; duration: number }>; +}): boolean { + const sortedSpans = [...timeSpans].sort( + (leftSpan, rightSpan) => leftSpan.startTime - rightSpan.startTime, + ); + + for (let spanIndex = 1; spanIndex < sortedSpans.length; spanIndex += 1) { + const previousSpan = sortedSpans[spanIndex - 1]; + const currentSpan = sortedSpans[spanIndex]; + if ( + previousSpan.startTime + previousSpan.duration > + currentSpan.startTime + ) { + return true; + } + } + + return false; +} diff --git a/apps/web/src/lib/timeline/group-move/snap.ts b/apps/web/src/lib/timeline/group-move/snap.ts new file mode 100644 index 00000000..3502ae30 --- /dev/null +++ b/apps/web/src/lib/timeline/group-move/snap.ts @@ -0,0 +1,71 @@ +import type { SceneTracks } from "@/lib/timeline"; +import { + findSnapPoints, + snapToNearestPoint, + type SnapPoint, +} from "@/lib/timeline/snap-utils"; +import type { MoveGroup } from "./types"; + +export function snapGroupEdges({ + group, + anchorStartTime, + tracks, + playheadTime, + zoomLevel, +}: { + group: MoveGroup; + anchorStartTime: number; + tracks: SceneTracks; + playheadTime: number; + zoomLevel: number; +}): { + snappedAnchorStartTime: number; + snapPoint: SnapPoint | null; +} { + const snapPoints = findSnapPoints({ + tracks, + playheadTime, + excludeElementIds: new Set(group.members.map((member) => member.elementId)), + }); + + let closestSnapDistance = Infinity; + let snappedAnchorStartTime = anchorStartTime; + let snapPoint: SnapPoint | null = null; + + for (const member of group.members) { + const memberStartTime = anchorStartTime + member.timeOffset; + const memberStartSnap = snapToNearestPoint({ + targetTime: memberStartTime, + snapPoints, + zoomLevel, + }); + if ( + memberStartSnap.snapPoint && + memberStartSnap.snapDistance < closestSnapDistance + ) { + closestSnapDistance = memberStartSnap.snapDistance; + snappedAnchorStartTime = memberStartSnap.snappedTime - member.timeOffset; + snapPoint = memberStartSnap.snapPoint; + } + + const memberEndSnap = snapToNearestPoint({ + targetTime: memberStartTime + member.duration, + snapPoints, + zoomLevel, + }); + if ( + memberEndSnap.snapPoint && + memberEndSnap.snapDistance < closestSnapDistance + ) { + closestSnapDistance = memberEndSnap.snapDistance; + snappedAnchorStartTime = + memberEndSnap.snappedTime - member.duration - member.timeOffset; + snapPoint = memberEndSnap.snapPoint; + } + } + + return { + snappedAnchorStartTime, + snapPoint, + }; +} diff --git a/apps/web/src/lib/timeline/group-move/track-placement.ts b/apps/web/src/lib/timeline/group-move/track-placement.ts new file mode 100644 index 00000000..d301e5ef --- /dev/null +++ b/apps/web/src/lib/timeline/group-move/track-placement.ts @@ -0,0 +1,83 @@ +import type { SceneTracks, TimelineTrack } from "@/lib/timeline"; +import type { GroupTrackSection } from "./types"; + +export interface TrackPlacement { + trackId: string; + trackType: TimelineTrack["type"]; + section: GroupTrackSection; + sectionIndex: number; + displayIndex: number; +} + +export function getDisplayTracks({ + tracks, +}: { + tracks: SceneTracks; +}): TimelineTrack[] { + return [...tracks.overlay, tracks.main, ...tracks.audio]; +} + +export function getTrackPlacementById({ + tracks, + trackId, +}: { + tracks: SceneTracks; + trackId: string; +}): TrackPlacement | null { + if (tracks.main.id === trackId) { + return { + trackId, + trackType: tracks.main.type, + section: "main", + sectionIndex: -1, + displayIndex: tracks.overlay.length, + }; + } + + const overlayTrackIndex = tracks.overlay.findIndex( + (track) => track.id === trackId, + ); + if (overlayTrackIndex >= 0) { + return { + trackId, + trackType: tracks.overlay[overlayTrackIndex].type, + section: "overlay", + sectionIndex: overlayTrackIndex, + displayIndex: overlayTrackIndex, + }; + } + + const audioTrackIndex = tracks.audio.findIndex( + (track) => track.id === trackId, + ); + if (audioTrackIndex >= 0) { + return { + trackId, + trackType: tracks.audio[audioTrackIndex].type, + section: "audio", + sectionIndex: audioTrackIndex, + displayIndex: tracks.overlay.length + 1 + audioTrackIndex, + }; + } + + return null; +} + +export function getTrackPlacementByDisplayIndex({ + tracks, + displayIndex, +}: { + tracks: SceneTracks; + displayIndex: number; +}): TrackPlacement | null { + const displayTracks = getDisplayTracks({ tracks }); + const track = displayTracks[displayIndex]; + if (!track) { + return null; + } + + return getTrackPlacementById({ + tracks, + trackId: track.id, + }); +} diff --git a/apps/web/src/lib/timeline/group-move/types.ts b/apps/web/src/lib/timeline/group-move/types.ts new file mode 100644 index 00000000..5ac1e207 --- /dev/null +++ b/apps/web/src/lib/timeline/group-move/types.ts @@ -0,0 +1,36 @@ +import type { ElementRef, ElementType, TrackType } from "@/lib/timeline"; + +export type GroupTrackSection = "overlay" | "main" | "audio"; + +export interface GroupMember extends ElementRef { + elementType: ElementType; + duration: number; + timeOffset: number; + trackSection: GroupTrackSection; + sectionIndex: number; + displayIndex: number; +} + +export interface MoveGroup { + anchor: GroupMember; + members: GroupMember[]; +} + +export interface PlannedTrackCreation { + id: string; + type: TrackType; + index: number; +} + +export interface PlannedElementMove { + sourceTrackId: string; + targetTrackId: string; + elementId: string; + newStartTime: number; +} + +export interface GroupMoveResult { + moves: PlannedElementMove[]; + createTracks: PlannedTrackCreation[]; + targetSelection: ElementRef[]; +} diff --git a/apps/web/src/lib/timeline/group-resize/compute-resize.ts b/apps/web/src/lib/timeline/group-resize/compute-resize.ts new file mode 100644 index 00000000..8d7d9b2d --- /dev/null +++ b/apps/web/src/lib/timeline/group-resize/compute-resize.ts @@ -0,0 +1,290 @@ +import { roundToFrame } from "opencut-wasm"; +import { + getSourceSpanAtClipTime, + getTimelineDurationForSourceSpan, +} from "@/lib/retime"; +import { TICKS_PER_SECOND } from "@/lib/wasm"; +import type { + ComputeGroupResizeArgs, + GroupResizeMember, + GroupResizeResult, + GroupResizeUpdate, + ResizeSide, +} from "./types"; + +export function computeGroupResize({ + members, + side, + deltaTime, + fps, +}: ComputeGroupResizeArgs): GroupResizeResult { + const minDuration = Math.round( + (TICKS_PER_SECOND * fps.denominator) / fps.numerator, + ); + const minimumDeltaTime = Math.max( + ...members.map((member) => + getMinimumAllowedDeltaTime({ + member, + side, + minDuration, + }), + ), + ); + const maximumDeltaTime = Math.min( + ...members.map((member) => + getMaximumAllowedDeltaTime({ + member, + side, + minDuration, + }), + ), + ); + const clampedDeltaTime = + minimumDeltaTime > maximumDeltaTime + ? minimumDeltaTime + : Math.min(maximumDeltaTime, Math.max(minimumDeltaTime, deltaTime)); + + return { + deltaTime: Object.is(clampedDeltaTime, -0) ? 0 : clampedDeltaTime, + updates: members.map((member) => + buildResizeUpdate({ + member, + side, + deltaTime: clampedDeltaTime, + fps, + }), + ), + }; +} + +function buildResizeUpdate({ + member, + side, + deltaTime, + fps, +}: { + member: GroupResizeMember; + side: ResizeSide; + deltaTime: number; + fps: ComputeGroupResizeArgs["fps"]; +}): GroupResizeUpdate { + const totalSourceDuration = getSourceDuration({ member }); + const sourceDelta = getSourceDeltaForClipDelta({ + member, + clipDelta: deltaTime, + }); + const visibleSourceSpan = getVisibleSourceSpanForDuration({ + member, + duration: member.duration, + }); + + if (side === "left") { + if (deltaTime < 0 && member.sourceDuration == null) { + const rawStartTime = member.startTime + deltaTime; + const rawDuration = member.duration - deltaTime; + return { + trackId: member.trackId, + elementId: member.elementId, + patch: { + trimStart: Math.max(0, member.trimStart + sourceDelta), + trimEnd: member.trimEnd, + startTime: + roundToFrame({ time: rawStartTime, rate: fps }) ?? rawStartTime, + duration: + roundToFrame({ time: rawDuration, rate: fps }) ?? rawDuration, + }, + }; + } + + const nextTrimStart = Math.max(0, member.trimStart + sourceDelta); + const nextVisibleSourceSpan = Math.max( + 0, + totalSourceDuration - nextTrimStart - member.trimEnd, + ); + const rawDuration = getDurationForVisibleSourceSpan({ + member, + sourceSpan: nextVisibleSourceSpan, + }); + const nextDuration = + roundToFrame({ time: rawDuration, rate: fps }) ?? rawDuration; + const rawStartTime = member.startTime + (member.duration - nextDuration); + return { + trackId: member.trackId, + elementId: member.elementId, + patch: { + trimStart: + roundToFrame({ time: nextTrimStart, rate: fps }) ?? nextTrimStart, + trimEnd: member.trimEnd, + startTime: + roundToFrame({ time: rawStartTime, rate: fps }) ?? rawStartTime, + duration: nextDuration, + }, + }; + } + + const nextVisibleSourceSpan = Math.max(0, visibleSourceSpan + sourceDelta); + if (deltaTime > 0 && member.sourceDuration == null) { + const rawDuration = member.duration + deltaTime; + return { + trackId: member.trackId, + elementId: member.elementId, + patch: { + trimStart: member.trimStart, + trimEnd: Math.max(0, member.trimEnd - sourceDelta), + startTime: member.startTime, + duration: roundToFrame({ time: rawDuration, rate: fps }) ?? rawDuration, + }, + }; + } + + const nextTrimEnd = Math.max( + 0, + totalSourceDuration - member.trimStart - nextVisibleSourceSpan, + ); + const rawDuration = getDurationForVisibleSourceSpan({ + member, + sourceSpan: nextVisibleSourceSpan, + }); + return { + trackId: member.trackId, + elementId: member.elementId, + patch: { + trimStart: member.trimStart, + trimEnd: roundToFrame({ time: nextTrimEnd, rate: fps }) ?? nextTrimEnd, + startTime: member.startTime, + duration: roundToFrame({ time: rawDuration, rate: fps }) ?? rawDuration, + }, + }; +} + +function getMinimumAllowedDeltaTime({ + member, + side, + minDuration, +}: { + member: GroupResizeMember; + side: ResizeSide; + minDuration: number; +}): number { + if (side === "right") { + return minDuration - member.duration; + } + + const leftNeighborFloor = Number.isFinite(member.leftNeighborBound) + ? member.leftNeighborBound - member.startTime + : -member.startTime; + if (member.sourceDuration == null) { + return leftNeighborFloor; + } + + const maximumSourceExtension = + getDurationForVisibleSourceSpan({ + member, + sourceSpan: + getVisibleSourceSpanForDuration({ + member, + duration: member.duration, + }) + member.trimStart, + }) - member.duration; + return Math.max(leftNeighborFloor, -maximumSourceExtension); +} + +function getMaximumAllowedDeltaTime({ + member, + side, + minDuration, +}: { + member: GroupResizeMember; + side: ResizeSide; + minDuration: number; +}): number { + if (side === "left") { + return member.duration - minDuration; + } + + const rightNeighborCeiling = Number.isFinite(member.rightNeighborBound) + ? member.rightNeighborBound - (member.startTime + member.duration) + : Infinity; + if (member.sourceDuration == null) { + return rightNeighborCeiling; + } + + const maximumVisibleSourceSpan = + getSourceDuration({ member }) - member.trimStart; + const maximumDuration = getDurationForVisibleSourceSpan({ + member, + sourceSpan: maximumVisibleSourceSpan, + }); + return Math.min(rightNeighborCeiling, maximumDuration - member.duration); +} + +function getSourceDeltaForClipDelta({ + member, + clipDelta, +}: { + member: GroupResizeMember; + clipDelta: number; +}): number { + if (!member.retime) { + return clipDelta; + } + + return clipDelta >= 0 + ? getSourceSpanAtClipTime({ + clipTime: clipDelta, + retime: member.retime, + }) + : -getSourceSpanAtClipTime({ + clipTime: Math.abs(clipDelta), + retime: member.retime, + }); +} + +function getVisibleSourceSpanForDuration({ + member, + duration, +}: { + member: GroupResizeMember; + duration: number; +}): number { + if (!member.retime) { + return duration; + } + + return getSourceSpanAtClipTime({ + clipTime: duration, + retime: member.retime, + }); +} + +function getDurationForVisibleSourceSpan({ + member, + sourceSpan, +}: { + member: GroupResizeMember; + sourceSpan: number; +}): number { + if (!member.retime) { + return sourceSpan; + } + + return getTimelineDurationForSourceSpan({ + sourceSpan, + retime: member.retime, + }); +} + +function getSourceDuration({ member }: { member: GroupResizeMember }): number { + if (typeof member.sourceDuration === "number") { + return member.sourceDuration; + } + + return ( + member.trimStart + + getVisibleSourceSpanForDuration({ + member, + duration: member.duration, + }) + + member.trimEnd + ); +} diff --git a/apps/web/src/lib/timeline/group-resize/index.ts b/apps/web/src/lib/timeline/group-resize/index.ts new file mode 100644 index 00000000..bc0a7b35 --- /dev/null +++ b/apps/web/src/lib/timeline/group-resize/index.ts @@ -0,0 +1,9 @@ +export { computeGroupResize } from "./compute-resize"; + +export type { + ComputeGroupResizeArgs, + GroupResizeMember, + GroupResizeResult, + GroupResizeUpdate, + ResizeSide, +} from "./types"; diff --git a/apps/web/src/lib/timeline/group-resize/types.ts b/apps/web/src/lib/timeline/group-resize/types.ts new file mode 100644 index 00000000..d5f00c1b --- /dev/null +++ b/apps/web/src/lib/timeline/group-resize/types.ts @@ -0,0 +1,36 @@ +import type { FrameRate } from "opencut-wasm"; +import type { ElementRef, RetimeConfig } from "@/lib/timeline/types"; + +export type ResizeSide = "left" | "right"; + +export interface GroupResizeMember extends ElementRef { + startTime: number; + duration: number; + trimStart: number; + trimEnd: number; + sourceDuration?: number; + retime?: RetimeConfig; + leftNeighborBound: number; + rightNeighborBound: number; +} + +export interface GroupResizeUpdate extends ElementRef { + patch: { + trimStart: number; + trimEnd: number; + startTime: number; + duration: number; + }; +} + +export interface GroupResizeResult { + deltaTime: number; + updates: GroupResizeUpdate[]; +} + +export interface ComputeGroupResizeArgs { + members: GroupResizeMember[]; + side: ResizeSide; + deltaTime: number; + fps: FrameRate; +} diff --git a/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts b/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts index 4d39f5d8..7cadb9b7 100644 --- a/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts +++ b/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts @@ -329,7 +329,12 @@ describe("resolveTrackPlacement", () => { id: "graphic-1", type: "graphic", elements: [ - buildElement({ id: "a", type: "graphic", startTime: 0, duration: 5 }), + buildElement({ + id: "a", + type: "graphic", + startTime: 0, + duration: 5, + }), ], }), ], diff --git a/apps/web/src/lib/timeline/placement/overlap.ts b/apps/web/src/lib/timeline/placement/overlap.ts index e5f95fa9..5e37c052 100644 --- a/apps/web/src/lib/timeline/placement/overlap.ts +++ b/apps/web/src/lib/timeline/placement/overlap.ts @@ -1,6 +1,10 @@ -import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; +import type { TimelineElement } from "@/lib/timeline"; import type { PlacementTimeSpan } from "./types"; +interface TrackWithElements { + elements: TimelineElement[]; +} + function wouldElementOverlap({ elements, startTime, @@ -26,7 +30,7 @@ export function canPlaceTimeSpansOnTrack({ track, timeSpans, }: { - track: TimelineTrack; + track: TrackWithElements; timeSpans: PlacementTimeSpan[]; }): boolean { return timeSpans.every(({ startTime, duration, excludeElementId }) => { diff --git a/apps/web/src/lib/timeline/snap-utils.ts b/apps/web/src/lib/timeline/snap-utils.ts index c1768deb..b2be95a9 100644 --- a/apps/web/src/lib/timeline/snap-utils.ts +++ b/apps/web/src/lib/timeline/snap-utils.ts @@ -22,6 +22,7 @@ export function findSnapPoints({ tracks, playheadTime, excludeElementId, + excludeElementIds, bookmarks = [], excludeBookmarkTime, enableElementSnapping = true, @@ -32,6 +33,7 @@ export function findSnapPoints({ tracks: SceneTracks; playheadTime: number; excludeElementId?: string; + excludeElementIds?: Set<string>; bookmarks?: Array<Bookmark>; excludeBookmarkTime?: number; enableElementSnapping?: boolean; @@ -41,10 +43,14 @@ export function findSnapPoints({ }): SnapPoint[] { const snapPoints: SnapPoint[] = []; const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; + const blockedElementIds = new Set(excludeElementIds); + if (excludeElementId) { + blockedElementIds.add(excludeElementId); + } for (const track of orderedTracks) { for (const element of track.elements) { - if (element.id === excludeElementId) continue; + if (blockedElementIds.has(element.id)) continue; if (enableElementSnapping) { snapPoints.push( @@ -136,6 +142,7 @@ export function snapElementEdge({ playheadTime, zoomLevel, excludeElementId, + excludeElementIds, snapToStart = true, bookmarks = [], }: { @@ -145,6 +152,7 @@ export function snapElementEdge({ playheadTime: number; zoomLevel: number; excludeElementId?: string; + excludeElementIds?: Set<string>; snapToStart?: boolean; bookmarks?: Array<Bookmark>; }): SnapResult { @@ -152,6 +160,7 @@ export function snapElementEdge({ tracks, playheadTime, excludeElementId, + excludeElementIds, bookmarks, }); |
