feat: move/resize multiple elements

This commit is contained in:
Maze Winther 2026-04-15 05:43:29 +02:00
parent 6312fc5b07
commit ac43a5a73f
21 changed files with 2223 additions and 962 deletions

View File

@ -61,7 +61,6 @@ import { SELECTED_TRACK_ROW_CLASS } from "./theme";
import { import {
computeTrackExpansionHeight, computeTrackExpansionHeight,
getTrackExpandedRows, getTrackExpandedRows,
getExpansionHeight,
getPropertyLabel, getPropertyLabel,
type ExpandedRow, type ExpandedRow,
} from "./expanded-layout"; } from "./expanded-layout";
@ -75,6 +74,7 @@ import { TimelineBookmarksRow } from "./bookmarks";
import { useBookmarkDrag } from "@/hooks/timeline/use-bookmark-drag"; import { useBookmarkDrag } from "@/hooks/timeline/use-bookmark-drag";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import { useInitialScrollBottom } from "@/hooks/timeline/use-initial-scroll-bottom"; import { useInitialScrollBottom } from "@/hooks/timeline/use-initial-scroll-bottom";
import { useTimelineResize } from "@/hooks/timeline/use-timeline-resize";
import { useTimelineStore } from "@/stores/timeline-store"; import { useTimelineStore } from "@/stores/timeline-store";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead"; import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
@ -119,7 +119,9 @@ export function Timeline() {
} = useElementSelection(); } = useElementSelection();
const editor = useEditor(); const editor = useEditor();
const timeline = editor.timeline; const timeline = editor.timeline;
const scene = useEditor((currentEditor) => currentEditor.scenes.getActiveSceneOrNull()); const scene = useEditor((currentEditor) =>
currentEditor.scenes.getActiveSceneOrNull(),
);
const tracks = useMemo<TimelineTrack[]>( const tracks = useMemo<TimelineTrack[]>(
() => () =>
scene scene
@ -140,7 +142,6 @@ export function Timeline() {
const playheadRef = useRef<HTMLDivElement>(null); const playheadRef = useRef<HTMLDivElement>(null);
const trackLabelsScrollRef = useRef<HTMLDivElement>(null); const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
const [isResizing, setIsResizing] = useState(false);
const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>( const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
null, null,
); );
@ -148,15 +149,6 @@ export function Timeline() {
const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => { const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => {
setCurrentSnapPoint(snapPoint); setCurrentSnapPoint(snapPoint);
}, []); }, []);
const handleResizeStateChange = useCallback(
({ isResizing: nextIsResizing }: { isResizing: boolean }) => {
setIsResizing(nextIsResizing);
if (!nextIsResizing) {
setCurrentSnapPoint(null);
}
},
[],
);
const timelineDuration = timeline.getTotalDuration() || 0; const timelineDuration = timeline.getTotalDuration() || 0;
const minZoomLevel = getTimelineZoomMin({ const minZoomLevel = getTimelineZoomMin({
@ -176,6 +168,10 @@ export function Timeline() {
tracksScrollRef, tracksScrollRef,
rulerScrollRef, rulerScrollRef,
}); });
const { isResizing, handleResizeStart } = useTimelineResize({
zoomLevel,
onSnapPointChange: handleSnapPointChange,
});
const expandedElementIds = useTimelineStore((s) => s.expandedElementIds); const expandedElementIds = useTimelineStore((s) => s.expandedElementIds);
@ -366,7 +362,10 @@ export function Timeline() {
const containerWidth = const containerWidth =
tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH; tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH;
const contentWidth = timelineTimeToPixels({ time: timelineDuration, zoomLevel }); const contentWidth = timelineTimeToPixels({
time: timelineDuration,
zoomLevel,
});
const paddingPx = getTimelinePaddingPx({ const paddingPx = getTimelinePaddingPx({
containerWidth, containerWidth,
zoomLevel, zoomLevel,
@ -513,7 +512,10 @@ export function Timeline() {
TRACKS_CONTAINER_HEIGHT.min, TRACKS_CONTAINER_HEIGHT.min,
Math.min( Math.min(
TRACKS_CONTAINER_HEIGHT.max, TRACKS_CONTAINER_HEIGHT.max,
getTotalTracksHeight({ tracks, getExtraHeight: getTrackExpansionHeight }), getTotalTracksHeight({
tracks,
getExtraHeight: getTrackExpansionHeight,
}),
), ),
) + TIMELINE_CONTENT_TOP_PADDING_PX ) + TIMELINE_CONTENT_TOP_PADDING_PX
}px`, }px`,
@ -533,14 +535,13 @@ export function Timeline() {
}} }}
> >
{tracks.length > 0 && ( {tracks.length > 0 && (
<TimelineTrackRows <TimelineTrackRows
mainTrackId={mainTrackId} mainTrackId={mainTrackId}
zoomLevel={zoomLevel} zoomLevel={zoomLevel}
dragState={dragState} dragState={dragState}
tracksScrollRef={tracksScrollRef} tracksScrollRef={tracksScrollRef}
lastMouseXRef={lastMouseXRef} lastMouseXRef={lastMouseXRef}
onSnapPointChange={handleSnapPointChange} onResizeStart={handleResizeStart}
onResizeStateChange={handleResizeStateChange}
onElementMouseDown={handleElementMouseDown} onElementMouseDown={handleElementMouseDown}
onElementClick={handleElementClick} onElementClick={handleElementClick}
onTrackMouseDown={(event) => { onTrackMouseDown={(event) => {
@ -718,8 +719,7 @@ function TimelineTrackRows({
dragState, dragState,
tracksScrollRef, tracksScrollRef,
lastMouseXRef, lastMouseXRef,
onSnapPointChange, onResizeStart,
onResizeStateChange,
onElementMouseDown, onElementMouseDown,
onElementClick, onElementClick,
onTrackMouseDown, onTrackMouseDown,
@ -733,8 +733,9 @@ function TimelineTrackRows({
dragState: ElementDragState; dragState: ElementDragState;
tracksScrollRef: React.RefObject<HTMLDivElement | null>; tracksScrollRef: React.RefObject<HTMLDivElement | null>;
lastMouseXRef: React.RefObject<number>; lastMouseXRef: React.RefObject<number>;
onSnapPointChange: (snapPoint: SnapPoint | null) => void; onResizeStart: React.ComponentProps<
onResizeStateChange: (params: { isResizing: boolean }) => void; typeof TimelineTrackContent
>["onResizeStart"];
onElementMouseDown: React.ComponentProps< onElementMouseDown: React.ComponentProps<
typeof TimelineTrackContent typeof TimelineTrackContent
>["onElementMouseDown"]; >["onElementMouseDown"];
@ -798,8 +799,7 @@ function TimelineTrackRows({
<div <div
className={cn( className={cn(
"absolute right-0 left-0 transition-colors", "absolute right-0 left-0 transition-colors",
tracksWithSelection.has(track.id) && tracksWithSelection.has(track.id) && SELECTED_TRACK_ROW_CLASS,
SELECTED_TRACK_ROW_CLASS,
)} )}
style={{ style={{
top: `${TIMELINE_CONTENT_TOP_PADDING_PX + getCumulativeHeightBefore({ tracks, trackIndex: index, getExtraHeight: getTrackExpansionHeight })}px`, top: `${TIMELINE_CONTENT_TOP_PADDING_PX + getCumulativeHeightBefore({ tracks, trackIndex: index, getExtraHeight: getTrackExpansionHeight })}px`,
@ -813,8 +813,7 @@ function TimelineTrackRows({
rulerScrollRef={tracksScrollRef} rulerScrollRef={tracksScrollRef}
tracksScrollRef={tracksScrollRef} tracksScrollRef={tracksScrollRef}
lastMouseXRef={lastMouseXRef} lastMouseXRef={lastMouseXRef}
onSnapPointChange={onSnapPointChange} onResizeStart={onResizeStart}
onResizeStateChange={onResizeStateChange}
onElementMouseDown={onElementMouseDown} onElementMouseDown={onElementMouseDown}
onElementClick={onElementClick} onElementClick={onElementClick}
onTrackMouseDown={onTrackMouseDown} onTrackMouseDown={onTrackMouseDown}
@ -929,12 +928,10 @@ function TrackToggleIcon({
function PropertyTree({ rows }: { rows: ExpandedRow[] }) { function PropertyTree({ rows }: { rows: ExpandedRow[] }) {
return ( return (
<div className="flex flex-col overflow-hidden"> <div className="flex flex-col overflow-hidden">
{rows.map((row, index) => ( {rows.map((row) => (
<div <div
key={row.propertyPath} key={row.propertyPath}
className={cn( className={cn("flex shrink-0 items-center px-3 bg-muted/50")}
"flex shrink-0 items-center px-3 bg-muted/50",
)}
style={{ height: `${KEYFRAME_LANE_HEIGHT_PX}px` }} style={{ height: `${KEYFRAME_LANE_HEIGHT_PX}px` }}
> >
<span className="text-muted-foreground truncate text-xs leading-none"> <span className="text-muted-foreground truncate text-xs leading-none">

View File

@ -10,7 +10,6 @@ import {
} from "@/hooks/timeline/element/use-keyframe-drag"; } from "@/hooks/timeline/element/use-keyframe-drag";
import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection"; import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection";
import { useKeyframeBoxSelect } from "@/hooks/timeline/element/use-keyframe-box-select"; 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 { SelectionBox } from "@/lib/selection/selection-box";
import { getElementKeyframes } from "@/lib/animation"; import { getElementKeyframes } from "@/lib/animation";
import { import {
@ -86,7 +85,6 @@ import {
getExpansionHeight, getExpansionHeight,
type ExpandedRow, type ExpandedRow,
} from "./expanded-layout"; } from "./expanded-layout";
import type { SnapPoint } from "@/lib/timeline/snap-utils";
const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40; const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40;
const ELEMENT_RING_WIDTH_PX = 1.5; const ELEMENT_RING_WIDTH_PX = 1.5;
@ -197,8 +195,12 @@ interface TimelineElementProps {
track: TimelineTrack; track: TimelineTrack;
zoomLevel: number; zoomLevel: number;
isSelected: boolean; isSelected: boolean;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void; onResizeStart: (params: {
onResizeStateChange?: (params: { isResizing: boolean }) => void; event: React.MouseEvent;
element: TimelineElementType;
track: TimelineTrack;
side: "left" | "right";
}) => void;
onElementMouseDown: ( onElementMouseDown: (
event: React.MouseEvent, event: React.MouseEvent,
element: TimelineElementType, element: TimelineElementType,
@ -216,8 +218,7 @@ export function TimelineElement({
track, track,
zoomLevel, zoomLevel,
isSelected, isSelected,
onSnapPointChange, onResizeStart,
onResizeStateChange,
onElementMouseDown, onElementMouseDown,
onElementClick, onElementClick,
dragState, dragState,
@ -231,18 +232,6 @@ export function TimelineElement({
elementId: element.id, elementId: element.id,
fallback: element, fallback: element,
}); });
const {
currentDuration,
currentStartTime,
handleResizeStart,
isResizing,
} = useTimelineElementResize({
element,
track,
zoomLevel,
onSnapPointChange,
onResizeStateChange,
});
let mediaAsset: MediaAsset | null = null; let mediaAsset: MediaAsset | null = null;
@ -267,13 +256,9 @@ export function TimelineElement({
const elementStartTime = const elementStartTime =
isBeingDragged && dragState.isDragging isBeingDragged && dragState.isDragging
? dragState.currentTime + dragTimeOffset ? dragState.currentTime + dragTimeOffset
: isResizing : renderElement.startTime;
? currentStartTime
: renderElement.startTime;
const displayedStartTime = elementStartTime; const displayedStartTime = elementStartTime;
const displayedDuration = isResizing const displayedDuration = renderElement.duration;
? currentDuration
: renderElement.duration;
const elementWidth = timelineTimeToPixels({ const elementWidth = timelineTimeToPixels({
time: displayedDuration, time: displayedDuration,
zoomLevel, zoomLevel,
@ -282,22 +267,6 @@ export function TimelineElement({
time: displayedStartTime, time: displayedStartTime,
zoomLevel, 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 const keyframeIndicators = isSelected
? getKeyframeIndicators({ ? getKeyframeIndicators({
keyframes: getElementKeyframes({ animations: element.animations }), keyframes: getElementKeyframes({ animations: element.animations }),
@ -329,9 +298,7 @@ export function TimelineElement({
); );
const expandedRows = useMemo( const expandedRows = useMemo(
() => () =>
isExpanded isExpanded ? getExpandedRows({ animations: element.animations }) : [],
? getExpandedRows({ animations: element.animations })
: [],
[isExpanded, element.animations], [isExpanded, element.animations],
); );
@ -422,7 +389,7 @@ export function TimelineElement({
expandedContent={expandedContent} expandedContent={expandedContent}
onElementClick={onElementClick} onElementClick={onElementClick}
onElementMouseDown={onElementMouseDown} onElementMouseDown={onElementMouseDown}
onResizeStart={handleElementResizeStart} onResizeStart={onResizeStart}
isDropTarget={isDropTarget} isDropTarget={isDropTarget}
/> />
{isSelected && ( {isSelected && (
@ -811,8 +778,7 @@ function ExpandedKeyframeLanes({
[...keyframes] [...keyframes]
.sort( .sort(
(a, b) => (a, b) =>
a.time - b.time || a.time - b.time || a.propertyPath.localeCompare(b.propertyPath),
a.propertyPath.localeCompare(b.propertyPath),
) )
.map((kf) => ({ .map((kf) => ({
trackId, trackId,
@ -824,6 +790,8 @@ function ExpandedKeyframeLanes({
); );
return ( 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 <div
ref={containerRef} ref={containerRef}
className="relative flex flex-col" className="relative flex flex-col"
@ -837,9 +805,7 @@ function ExpandedKeyframeLanes({
return ( return (
<div <div
key={row.propertyPath} key={row.propertyPath}
className={cn( className={cn("relative flex items-center bg-muted/50")}
"relative flex items-center bg-muted/50",
)}
style={{ height: `${KEYFRAME_LANE_HEIGHT_PX}px` }} style={{ height: `${KEYFRAME_LANE_HEIGHT_PX}px` }}
> >
{laneKeyframes.map((kf) => { {laneKeyframes.map((kf) => {
@ -849,8 +815,9 @@ function ExpandedKeyframeLanes({
propertyPath: row.propertyPath, propertyPath: row.propertyPath,
keyframeId: kf.id, keyframeId: kf.id,
}; };
const isBeingDragged = const isBeingDragged = keyframeDragState.draggingKeyframeIds.has(
keyframeDragState.draggingKeyframeIds.has(kf.id); kf.id,
);
const kfLeft = timelineTimeToSnappedPixels({ const kfLeft = timelineTimeToSnappedPixels({
time: displayedStartTime + kf.time, time: displayedStartTime + kf.time,
zoomLevel, zoomLevel,
@ -898,9 +865,7 @@ function ExpandedKeyframeLanes({
icon={KeyframeIcon} icon={KeyframeIcon}
className={cn( className={cn(
"size-3.5 text-black mr-1", "size-3.5 text-black mr-1",
isSelected isSelected ? "fill-primary" : "fill-white",
? "fill-primary"
: "fill-white",
)} )}
strokeWidth={1.5} strokeWidth={1.5}
/> />

View File

@ -1,129 +1,132 @@
"use client"; "use client";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { TimelineElement } from "./timeline-element"; import { TimelineElement } from "./timeline-element";
import type { TimelineTrack } from "@/lib/timeline"; import type { TimelineTrack } from "@/lib/timeline";
import type { TimelineElement as TimelineElementType } 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 { TIMELINE_LAYERS } from "./layers"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; import type { ElementDragState } from "@/lib/timeline";
import type { ElementDragState } from "@/lib/timeline"; import { useEditor } from "@/hooks/use-editor";
import { useEditor } from "@/hooks/use-editor";
interface TimelineTrackContentProps {
interface TimelineTrackContentProps { track: TimelineTrack;
track: TimelineTrack; zoomLevel: number;
zoomLevel: number; dragState: ElementDragState;
dragState: ElementDragState; rulerScrollRef: React.RefObject<HTMLDivElement | null>;
rulerScrollRef: React.RefObject<HTMLDivElement | null>; tracksScrollRef: React.RefObject<HTMLDivElement | null>;
tracksScrollRef: React.RefObject<HTMLDivElement | null>; lastMouseXRef: React.RefObject<number>;
lastMouseXRef: React.RefObject<number>; onResizeStart: (params: {
onSnapPointChange?: (snapPoint: SnapPoint | null) => void; event: React.MouseEvent;
onResizeStateChange?: (params: { isResizing: boolean }) => void; element: TimelineElementType;
onElementMouseDown: (params: { track: TimelineTrack;
event: React.MouseEvent; side: "left" | "right";
element: TimelineElementType; }) => void;
track: TimelineTrack; onElementMouseDown: (params: {
}) => void; event: React.MouseEvent;
onElementClick: (params: { element: TimelineElementType;
event: React.MouseEvent; track: TimelineTrack;
element: TimelineElementType; }) => void;
track: TimelineTrack; onElementClick: (params: {
}) => void; event: React.MouseEvent;
onTrackMouseDown?: (event: React.MouseEvent) => void; element: TimelineElementType;
onTrackMouseUp?: (event: React.MouseEvent) => void; track: TimelineTrack;
shouldIgnoreClick?: () => boolean; }) => void;
targetElementId?: string | null; onTrackMouseDown?: (event: React.MouseEvent) => void;
} onTrackMouseUp?: (event: React.MouseEvent) => void;
shouldIgnoreClick?: () => boolean;
export function TimelineTrackContent({ targetElementId?: string | null;
track, }
zoomLevel,
dragState, export function TimelineTrackContent({
rulerScrollRef, track,
tracksScrollRef, zoomLevel,
lastMouseXRef, dragState,
onSnapPointChange, rulerScrollRef,
onResizeStateChange, tracksScrollRef,
onElementMouseDown, lastMouseXRef,
onElementClick, onResizeStart,
onTrackMouseDown, onElementMouseDown,
onTrackMouseUp, onElementClick,
shouldIgnoreClick, onTrackMouseDown,
targetElementId = null, onTrackMouseUp,
}: TimelineTrackContentProps) { shouldIgnoreClick,
const { isElementSelected } = useElementSelection(); targetElementId = null,
const duration = useEditor((e) => e.timeline.getTotalDuration()); }: TimelineTrackContentProps) {
const { isElementSelected } = useElementSelection();
useEdgeAutoScroll({ const duration = useEditor((e) => e.timeline.getTotalDuration());
isActive: dragState.isDragging,
getMouseClientX: () => lastMouseXRef.current ?? 0, useEdgeAutoScroll({
rulerScrollRef, isActive: dragState.isDragging,
tracksScrollRef, getMouseClientX: () => lastMouseXRef.current ?? 0,
contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel, rulerScrollRef,
}); tracksScrollRef,
contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel,
return ( });
<div className="relative size-full">
<button return (
type="button" <div className="relative size-full">
className="absolute inset-0 m-0 size-full appearance-none border-0 bg-transparent p-0" <button
aria-label={`Select ${track.name} track`} type="button"
onMouseUp={(event) => { className="absolute inset-0 m-0 size-full appearance-none border-0 bg-transparent p-0"
if (shouldIgnoreClick?.()) return; aria-label={`Select ${track.name} track`}
onTrackMouseUp?.(event); onMouseUp={(event) => {
}} if (shouldIgnoreClick?.()) return;
onMouseDown={(event) => { onTrackMouseUp?.(event);
event.preventDefault(); }}
onTrackMouseDown?.(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" {/* biome-ignore lint/a11y/noStaticElementInteractions: empty track area is a pointer-only seek surface */}
style={{ zIndex: TIMELINE_LAYERS.trackContent }} <div
onMouseUp={(event) => { className="relative h-full min-w-full"
if (event.target !== event.currentTarget) return; style={{ zIndex: TIMELINE_LAYERS.trackContent }}
if (shouldIgnoreClick?.()) return; onMouseUp={(event) => {
onTrackMouseUp?.(event); if (event.target !== event.currentTarget) return;
}} if (shouldIgnoreClick?.()) return;
onMouseDown={(event) => { onTrackMouseUp?.(event);
if (event.target !== event.currentTarget) return; }}
event.preventDefault(); onMouseDown={(event) => {
onTrackMouseDown?.(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.length === 0 ? (
track.elements.map((element) => { <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" />
const isSelected = isElementSelected({ ) : (
trackId: track.id, track.elements.map((element) => {
elementId: element.id, const isSelected = isElementSelected({
}); trackId: track.id,
elementId: element.id,
return ( });
<TimelineElement
key={element.id} return (
element={element} <TimelineElement
track={track} key={element.id}
zoomLevel={zoomLevel} element={element}
isSelected={isSelected} track={track}
onSnapPointChange={onSnapPointChange} zoomLevel={zoomLevel}
onResizeStateChange={onResizeStateChange} isSelected={isSelected}
onElementMouseDown={(event, element) => onResizeStart={({ event, element, side }) =>
onElementMouseDown({ event, element, track }) onResizeStart({ event, element, track, side })
} }
onElementClick={(event, element) => onElementMouseDown={(event, element) =>
onElementClick({ event, element, track }) onElementMouseDown({ event, element, track })
} }
dragState={dragState} onElementClick={(event, element) =>
isDropTarget={element.id === targetElementId} onElementClick({ event, element, track })
/> }
); dragState={dragState}
}) isDropTarget={element.id === targetElementId}
)} />
</div> );
</div> })
); )}
} </div>
</div>
);
}

View File

@ -8,9 +8,7 @@ import type {
RetimeConfig, RetimeConfig,
} from "@/lib/timeline"; } from "@/lib/timeline";
import { calculateTotalDuration } from "@/lib/timeline"; import { calculateTotalDuration } from "@/lib/timeline";
import { import { findTrackInSceneTracks } from "@/lib/timeline/track-element-update";
findTrackInSceneTracks,
} from "@/lib/timeline/track-element-update";
import { import {
canElementBeHidden, canElementBeHidden,
canElementHaveAudio, canElementHaveAudio,
@ -57,6 +55,10 @@ import {
ToggleSourceAudioSeparationCommand, ToggleSourceAudioSeparationCommand,
} from "@/lib/commands/timeline"; } from "@/lib/commands/timeline";
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element"; import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
import type {
PlannedElementMove,
PlannedTrackCreation,
} from "@/lib/timeline/group-move";
export class TimelineManager { export class TimelineManager {
private listeners = new Set<() => void>(); private listeners = new Set<() => void>();
@ -149,25 +151,20 @@ export class TimelineManager {
}); });
} }
moveElement({ moveElements({
sourceTrackId, moves,
targetTrackId, createTracks,
elementId,
newStartTime,
createTrack,
}: { }: {
sourceTrackId: string; moves: PlannedElementMove[];
targetTrackId: string; createTracks?: PlannedTrackCreation[];
elementId: string;
newStartTime: number;
createTrack?: { type: TrackType; index: number };
}): void { }): void {
if (moves.length === 0) {
return;
}
const command = new MoveElementCommand({ const command = new MoveElementCommand({
sourceTrackId, moves,
targetTrackId, createTracks,
elementId,
newStartTime,
createTrack,
}); });
this.editor.command.execute({ command }); this.editor.command.execute({ command });
} }
@ -580,14 +577,7 @@ export class TimelineManager {
} }
const commands = keyframes.map( const commands = keyframes.map(
({ ({ trackId, elementId, propertyPath, componentKey, keyframeId, patch }) =>
trackId,
elementId,
propertyPath,
componentKey,
keyframeId,
patch,
}) =>
new UpdateScalarKeyframeCurveCommand({ new UpdateScalarKeyframeCurveCommand({
trackId, trackId,
elementId, elementId,
@ -711,7 +701,9 @@ export class TimelineManager {
private applyPreviewOverlay(tracks: SceneTracks): SceneTracks { private applyPreviewOverlay(tracks: SceneTracks): SceneTracks {
if (this.previewOverlay.size === 0) return tracks; 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) => const hasOverlay = track.elements.some((element) =>
this.previewOverlay.has(element.id), this.previewOverlay.has(element.id),
); );
@ -803,7 +795,11 @@ export class TimelineManager {
} }
getPreviewTracks(): SceneTracks | null { 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 { subscribe(listener: () => void): () => void {

View File

@ -9,6 +9,13 @@ import {
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key"; import { useShiftKey } from "@/hooks/use-shift-key";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; 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 { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { TICKS_PER_SECOND } from "@/lib/wasm"; import { TICKS_PER_SECOND } from "@/lib/wasm";
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction"; 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 { computeDropTarget } from "@/components/editor/panels/timeline/drop-target";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils"; import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import { generateUUID } from "@/utils/id"; 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 { registerCanceller } from "@/lib/cancel-interaction";
import type { import type {
DropTarget, DropTarget,
ElementRef,
ElementDragState, ElementDragState,
SceneTracks, SceneTracks,
TimelineElement, TimelineElement,
@ -55,6 +63,7 @@ const initialDragState: ElementDragState = {
interface PendingDragState { interface PendingDragState {
elementId: string; elementId: string;
trackId: string; trackId: string;
selectedElements: ElementRef[];
startMouseX: number; startMouseX: number;
startMouseY: number; startMouseY: number;
startElementTime: number; startElementTime: number;
@ -116,11 +125,9 @@ function getDragDropTarget({
const scrollContainer = tracksScrollRef.current; const scrollContainer = tracksScrollRef.current;
if (!containerRect || !scrollContainer) return null; if (!containerRect || !scrollContainer) return null;
const sourceTrack = [ const sourceTrack = [...tracks.overlay, tracks.main, ...tracks.audio].find(
...tracks.overlay, ({ id }) => id === trackId,
tracks.main, );
...tracks.audio,
].find(({ id }) => id === trackId);
const movingElement = sourceTrack?.elements.find( const movingElement = sourceTrack?.elements.find(
({ id }) => id === elementId, ({ id }) => id === elementId,
); );
@ -153,7 +160,7 @@ function getDragDropTarget({
interface StartDragParams interface StartDragParams
extends Omit< extends Omit<
ElementDragState, ElementDragState,
"isDragging" | "currentTime" | "currentMouseY" | "dragElementIds" | "dragTimeOffsets" "isDragging" | "currentTime" | "currentMouseY"
> { > {
initialCurrentTime: number; initialCurrentTime: number;
initialCurrentMouseY: number; initialCurrentMouseY: number;
@ -171,11 +178,6 @@ export function useElementInteraction({
const editor = useEditor(); const editor = useEditor();
const isShiftHeldRef = useShiftKey(); const isShiftHeldRef = useShiftKey();
const sceneTracks = editor.scenes.getActiveScene().tracks; const sceneTracks = editor.scenes.getActiveScene().tracks;
const tracks = [
...sceneTracks.overlay,
sceneTracks.main,
...sceneTracks.audio,
];
const { const {
selectedElements, selectedElements,
isElementSelected, isElementSelected,
@ -188,12 +190,16 @@ export function useElementInteraction({
const [dragDropTarget, setDragDropTarget] = useState<DropTarget | null>(null); const [dragDropTarget, setDragDropTarget] = useState<DropTarget | null>(null);
const [isPendingDrag, setIsPendingDrag] = useState(false); const [isPendingDrag, setIsPendingDrag] = useState(false);
const pendingDragRef = useRef<PendingDragState | null>(null); const pendingDragRef = useRef<PendingDragState | null>(null);
const moveGroupRef = useRef<MoveGroup | null>(null);
const groupMoveResultRef = useRef<GroupMoveResult | null>(null);
const lastMouseXRef = useRef(0); const lastMouseXRef = useRef(0);
const mouseDownLocationRef = useRef<{ x: number; y: number } | null>(null); const mouseDownLocationRef = useRef<{ x: number; y: number } | null>(null);
const startDrag = useCallback( const startDrag = useCallback(
({ ({
elementId, elementId,
dragElementIds,
dragTimeOffsets,
trackId, trackId,
startMouseX, startMouseX,
startMouseY, startMouseY,
@ -205,8 +211,8 @@ export function useElementInteraction({
setDragState({ setDragState({
isDragging: true, isDragging: true,
elementId, elementId,
dragElementIds: elementId ? [elementId] : [], dragElementIds,
dragTimeOffsets: {}, dragTimeOffsets,
trackId, trackId,
startMouseX, startMouseX,
startMouseY, startMouseY,
@ -220,6 +226,8 @@ export function useElementInteraction({
); );
const endDrag = useCallback(() => { const endDrag = useCallback(() => {
moveGroupRef.current = null;
groupMoveResultRef.current = null;
setDragState(initialDragState); setDragState(initialDragState);
setDragDropTarget(null); setDragDropTarget(null);
}, []); }, []);
@ -232,6 +240,70 @@ export function useElementInteraction({
onSnapPointChange?.(null); onSnapPointChange?.(null);
}, [endDrag, onSnapPointChange]); }, [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(() => { useEffect(() => {
if (!dragState.isDragging && !isPendingDrag) return; if (!dragState.isDragging && !isPendingDrag) return;
@ -241,51 +313,29 @@ export function useElementInteraction({
const getDragSnapResult = useCallback( const getDragSnapResult = useCallback(
({ ({
frameSnappedTime, frameSnappedTime,
movingElement, group,
}: { }: {
frameSnappedTime: number; frameSnappedTime: number;
movingElement: TimelineElement | null | undefined; group: MoveGroup | null;
}) => { }) => {
const shouldSnap = snappingEnabled && !isShiftHeldRef.current; if (!group || !snappingEnabled || isShiftHeldRef.current) {
if (!shouldSnap || !movingElement) {
return { snappedTime: frameSnappedTime, snapPoint: null }; return { snappedTime: frameSnappedTime, snapPoint: null };
} }
const elementDuration = movingElement.duration; const groupSnap = snapGroupEdges({
const playheadTime = editor.playback.getCurrentTime(); group,
anchorStartTime: frameSnappedTime,
const startSnap = snapElementEdge({
targetTime: frameSnappedTime,
elementDuration,
tracks: sceneTracks, tracks: sceneTracks,
playheadTime, playheadTime: editor.playback.getCurrentTime(),
zoomLevel, 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 { return {
snappedTime: snapResult.snappedTime, snappedTime: groupSnap.snappedAnchorStartTime,
snapPoint: snapResult.snapPoint, snapPoint: groupSnap.snapPoint,
}; };
}, },
[snappingEnabled, editor.playback, tracks, zoomLevel, isShiftHeldRef], [snappingEnabled, editor.playback, sceneTracks, zoomLevel, isShiftHeldRef],
); );
useEffect(() => { useEffect(() => {
@ -314,17 +364,38 @@ export function useElementInteraction({
zoomLevel, zoomLevel,
scrollLeft, scrollLeft,
}); });
const adjustedTime = Math.max( const adjustedTime = Math.max(
0, 0,
mouseTime - pendingDragRef.current.clickOffsetTime, mouseTime - pendingDragRef.current.clickOffsetTime,
); );
const snappedTime = roundToFrame({ const snappedTime =
time: adjustedTime, roundToFrame({
rate: activeProject.settings.fps, time: adjustedTime,
}) ?? adjustedTime; rate: activeProject.settings.fps,
startDrag({ }) ?? adjustedTime;
...pendingDragRef.current, const moveGroup = buildMoveGroup({
initialCurrentTime: snappedTime, 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, initialCurrentMouseY: clientY,
}); });
startedDragThisEvent = true; startedDragThisEvent = true;
@ -364,15 +435,13 @@ export function useElementInteraction({
}); });
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
const fps = activeProject.settings.fps; 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 moveGroup = moveGroupRef.current;
const movingElement = sourceTrack?.elements.find(
({ id }) => id === dragState.elementId,
);
const { snappedTime, snapPoint } = getDragSnapResult({ const { snappedTime, snapPoint } = getDragSnapResult({
frameSnappedTime, frameSnappedTime,
movingElement, group: moveGroup,
}); });
setDragState((previousDragState) => ({ setDragState((previousDragState) => ({
...previousDragState, ...previousDragState,
@ -386,7 +455,7 @@ export function useElementInteraction({
startMouseY: dragState.startMouseY, startMouseY: dragState.startMouseY,
currentMouseY: clientY, currentMouseY: clientY,
}); });
const dropTarget = getDragDropTarget({ const anchorDropTarget = getDragDropTarget({
clientX, clientX,
clientY, clientY,
elementId: dragState.elementId, elementId: dragState.elementId,
@ -399,7 +468,24 @@ export function useElementInteraction({
snappedTime, snappedTime,
verticalDragDirection, 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, tracksScrollRef,
tracksContainerRef, tracksContainerRef,
headerRef, headerRef,
tracks,
isPendingDrag, isPendingDrag,
startDrag, startDrag,
getDragSnapResult, getDragSnapResult,
resolveGroupDragMove,
sceneTracks,
onSnapPointChange, onSnapPointChange,
]); ]);
@ -468,55 +555,45 @@ export function useElementInteraction({
return; return;
} }
const snappedTime = dragState.currentTime; const snappedTime = dragState.currentTime;
const moveGroup = moveGroupRef.current;
const sourceTrack = tracks.find(({ id }) => id === dragState.trackId); if (!moveGroup) {
if (!sourceTrack) {
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
) {
endDrag(); endDrag();
onSnapPointChange?.(null); onSnapPointChange?.(null);
return; return;
} }
if (dropTarget.isNewTrack) { const groupMoveResult = resolveGroupDragMove({
const newTrackId = generateUUID(); group: moveGroup,
snappedTime,
editor.timeline.moveElement({ dropTarget,
sourceTrackId: dragState.trackId, });
targetTrackId: newTrackId, if (!groupMoveResult) {
elementId: dragState.elementId, endDrag();
newStartTime: snappedTime, onSnapPointChange?.(null);
createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex }, return;
});
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(); endDrag();
onSnapPointChange?.(null); onSnapPointChange?.(null);
}; };
@ -531,14 +608,14 @@ export function useElementInteraction({
dragState.trackId, dragState.trackId,
dragState.currentTime, dragState.currentTime,
zoomLevel, zoomLevel,
tracks,
endDrag, endDrag,
onSnapPointChange, onSnapPointChange,
editor.timeline, editor.timeline,
tracksContainerRef, tracksContainerRef,
tracksScrollRef, tracksScrollRef,
headerRef, headerRef,
selectElement, resolveGroupDragMove,
sceneTracks,
]); ]);
useEffect(() => { useEffect(() => {
@ -600,9 +677,17 @@ export function useElementInteraction({
elementRect: event.currentTarget.getBoundingClientRect(), elementRect: event.currentTarget.getBoundingClientRect(),
zoomLevel, zoomLevel,
}); });
const elementRef = {
trackId: track.id,
elementId: element.id,
};
const pendingSelectedElements = isElementSelected(elementRef)
? selectedElements
: [elementRef];
pendingDragRef.current = { pendingDragRef.current = {
elementId: element.id, elementId: element.id,
trackId: track.id, trackId: track.id,
selectedElements: pendingSelectedElements,
startMouseX: event.clientX, startMouseX: event.clientX,
startMouseY: event.clientY, startMouseY: event.clientY,
startElementTime: element.startTime, startElementTime: element.startTime,
@ -610,7 +695,7 @@ export function useElementInteraction({
}; };
setIsPendingDrag(true); setIsPendingDrag(true);
}, },
[zoomLevel, isElementSelected, handleSelectionClick], [zoomLevel, isElementSelected, handleSelectionClick, selectedElements],
); );
const handleElementClick = useCallback( const handleElementClick = useCallback(

View File

@ -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,
};
}

View File

@ -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
);
});
}

View File

@ -3,140 +3,123 @@ import { EditorCore } from "@/core";
import type { import type {
SceneTracks, SceneTracks,
TimelineElement, TimelineElement,
TrackType,
TimelineTrack, TimelineTrack,
} from "@/lib/timeline"; } from "@/lib/timeline";
import { import {
buildEmptyTrack, buildEmptyTrack,
validateElementTrackCompatibility, validateElementTrackCompatibility,
enforceMainTrackStart,
} from "@/lib/timeline/placement"; } from "@/lib/timeline/placement";
import { import type {
findTrackInSceneTracks, PlannedElementMove,
updateTrackInSceneTracks, PlannedTrackCreation,
} from "@/lib/timeline/track-element-update"; } from "@/lib/timeline/group-move";
import { findTrackInSceneTracks } from "@/lib/timeline/track-element-update";
export class MoveElementCommand extends Command { export class MoveElementCommand extends Command {
private savedState: SceneTracks | null = null; 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({ constructor({
sourceTrackId, moves,
targetTrackId, createTracks = [],
elementId,
newStartTime,
createTrack,
}: { }: {
sourceTrackId: string; moves: PlannedElementMove[];
targetTrackId: string; createTracks?: PlannedTrackCreation[];
elementId: string;
newStartTime: number;
createTrack?: { type: TrackType; index: number };
}) { }) {
super(); super();
this.sourceTrackId = sourceTrackId; this.moves = moves;
this.targetTrackId = targetTrackId; this.createTracks = createTracks;
this.elementId = elementId;
this.newStartTime = newStartTime;
this.createTrack = createTrack;
} }
private readonly moves: PlannedElementMove[];
private readonly createTracks: PlannedTrackCreation[];
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedState = editor.scenes.getActiveScene().tracks; 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; let tracksToUpdate = this.savedState;
if (!targetTrack && this.createTrack) { for (const createTrack of [...this.createTracks].sort(
const newTrack = buildEmptyTrack({ (firstTrack, secondTrack) => firstTrack.index - secondTrack.index,
id: this.targetTrackId, )) {
type: this.createTrack.type,
});
tracksToUpdate = insertTrackAtDisplayIndex({ tracksToUpdate = insertTrackAtDisplayIndex({
tracks: this.savedState, tracks: tracksToUpdate,
track: newTrack, track: buildEmptyTrack({
insertIndex: this.createTrack.index, id: createTrack.id,
type: createTrack.type,
}),
insertIndex: createTrack.index,
}); });
targetTrack = newTrack;
}
if (!targetTrack) {
throw new Error("Target track not found");
} }
const validation = validateElementTrackCompatibility({ const movedElementsById = new Map<string, TimelineElement>();
element, for (const move of this.moves) {
track: targetTrack, 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");
}
if (!validation.isValid) { const targetTrack = findTrackInSceneTracks({
throw new Error(validation.errorMessage); 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 adjustedStartTime = enforceMainTrackStart({ 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;
}
const nextTargetElements =
movedElementsByTargetTrackId.get(move.targetTrackId) ?? [];
nextTargetElements.push(movedElement);
movedElementsByTargetTrackId.set(move.targetTrackId, nextTargetElements);
}
const updatedTracks = mapSceneTracks({
tracks: tracksToUpdate, tracks: tracksToUpdate,
targetTrackId: this.targetTrackId, update: (track) => ({
requestedStartTime: this.newStartTime, ...track,
excludeElementId: this.elementId, 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); editor.timeline.updateTracks(updatedTracks);
return undefined; return {
select: this.moves.map(({ elementId, targetTrackId }) => ({
trackId: targetTrackId,
elementId,
})),
};
} }
undo(): void { 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({ function insertTrackAtDisplayIndex({
tracks, tracks,
track, track,

View File

@ -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,
},
]);
});
});

View File

@ -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,
};
}

View File

@ -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";

View File

@ -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;
}

View File

@ -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,
};
}

View File

@ -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,
});
}

View File

@ -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[];
}

View File

@ -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
);
}

View File

@ -0,0 +1,9 @@
export { computeGroupResize } from "./compute-resize";
export type {
ComputeGroupResizeArgs,
GroupResizeMember,
GroupResizeResult,
GroupResizeUpdate,
ResizeSide,
} from "./types";

View File

@ -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;
}

View File

@ -329,7 +329,12 @@ describe("resolveTrackPlacement", () => {
id: "graphic-1", id: "graphic-1",
type: "graphic", type: "graphic",
elements: [ elements: [
buildElement({ id: "a", type: "graphic", startTime: 0, duration: 5 }), buildElement({
id: "a",
type: "graphic",
startTime: 0,
duration: 5,
}),
], ],
}), }),
], ],

View File

@ -1,6 +1,10 @@
import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; import type { TimelineElement } from "@/lib/timeline";
import type { PlacementTimeSpan } from "./types"; import type { PlacementTimeSpan } from "./types";
interface TrackWithElements {
elements: TimelineElement[];
}
function wouldElementOverlap({ function wouldElementOverlap({
elements, elements,
startTime, startTime,
@ -26,7 +30,7 @@ export function canPlaceTimeSpansOnTrack({
track, track,
timeSpans, timeSpans,
}: { }: {
track: TimelineTrack; track: TrackWithElements;
timeSpans: PlacementTimeSpan[]; timeSpans: PlacementTimeSpan[];
}): boolean { }): boolean {
return timeSpans.every(({ startTime, duration, excludeElementId }) => { return timeSpans.every(({ startTime, duration, excludeElementId }) => {

View File

@ -22,6 +22,7 @@ export function findSnapPoints({
tracks, tracks,
playheadTime, playheadTime,
excludeElementId, excludeElementId,
excludeElementIds,
bookmarks = [], bookmarks = [],
excludeBookmarkTime, excludeBookmarkTime,
enableElementSnapping = true, enableElementSnapping = true,
@ -32,6 +33,7 @@ export function findSnapPoints({
tracks: SceneTracks; tracks: SceneTracks;
playheadTime: number; playheadTime: number;
excludeElementId?: string; excludeElementId?: string;
excludeElementIds?: Set<string>;
bookmarks?: Array<Bookmark>; bookmarks?: Array<Bookmark>;
excludeBookmarkTime?: number; excludeBookmarkTime?: number;
enableElementSnapping?: boolean; enableElementSnapping?: boolean;
@ -41,10 +43,14 @@ export function findSnapPoints({
}): SnapPoint[] { }): SnapPoint[] {
const snapPoints: SnapPoint[] = []; const snapPoints: SnapPoint[] = [];
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; 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 track of orderedTracks) {
for (const element of track.elements) { for (const element of track.elements) {
if (element.id === excludeElementId) continue; if (blockedElementIds.has(element.id)) continue;
if (enableElementSnapping) { if (enableElementSnapping) {
snapPoints.push( snapPoints.push(
@ -136,6 +142,7 @@ export function snapElementEdge({
playheadTime, playheadTime,
zoomLevel, zoomLevel,
excludeElementId, excludeElementId,
excludeElementIds,
snapToStart = true, snapToStart = true,
bookmarks = [], bookmarks = [],
}: { }: {
@ -145,6 +152,7 @@ export function snapElementEdge({
playheadTime: number; playheadTime: number;
zoomLevel: number; zoomLevel: number;
excludeElementId?: string; excludeElementId?: string;
excludeElementIds?: Set<string>;
snapToStart?: boolean; snapToStart?: boolean;
bookmarks?: Array<Bookmark>; bookmarks?: Array<Bookmark>;
}): SnapResult { }): SnapResult {
@ -152,6 +160,7 @@ export function snapElementEdge({
tracks, tracks,
playheadTime, playheadTime,
excludeElementId, excludeElementId,
excludeElementIds,
bookmarks, bookmarks,
}); });