summaryrefslogtreecommitdiff
path: root/apps/web
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-15 05:43:29 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-15 05:43:29 +0200
commitac43a5a73fa905cdac905bc956763b6093d6738e (patch)
tree8d9ff8c3a9ae55bac8860215bec69cc5b6094a1c /apps/web
parent6312fc5b078689bc82c52fda9b7f92ee0a21d311 (diff)
downloadopencut-ac43a5a73fa905cdac905bc956763b6093d6738e.tar.gz
feat: move/resize multiple elements
Diffstat (limited to 'apps/web')
-rw-r--r--apps/web/src/components/editor/panels/timeline/index.tsx57
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-element.tsx73
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-track.tsx261
-rw-r--r--apps/web/src/core/managers/timeline-manager.ts54
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-interaction.ts299
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-resize.ts503
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-resize.ts322
-rw-r--r--apps/web/src/lib/commands/timeline/element/move-elements.ts199
-rw-r--r--apps/web/src/lib/timeline/group-move/__tests__/group-move.test.ts287
-rw-r--r--apps/web/src/lib/timeline/group-move/build-group.ts87
-rw-r--r--apps/web/src/lib/timeline/group-move/index.ts12
-rw-r--r--apps/web/src/lib/timeline/group-move/resolve-move.ts470
-rw-r--r--apps/web/src/lib/timeline/group-move/snap.ts71
-rw-r--r--apps/web/src/lib/timeline/group-move/track-placement.ts83
-rw-r--r--apps/web/src/lib/timeline/group-move/types.ts36
-rw-r--r--apps/web/src/lib/timeline/group-resize/compute-resize.ts290
-rw-r--r--apps/web/src/lib/timeline/group-resize/index.ts9
-rw-r--r--apps/web/src/lib/timeline/group-resize/types.ts36
-rw-r--r--apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts7
-rw-r--r--apps/web/src/lib/timeline/placement/overlap.ts8
-rw-r--r--apps/web/src/lib/timeline/snap-utils.ts11
21 files changed, 2218 insertions, 957 deletions
diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx
index ae9e19c9..20e9faf1 100644
--- a/apps/web/src/components/editor/panels/timeline/index.tsx
+++ b/apps/web/src/components/editor/panels/timeline/index.tsx
@@ -61,7 +61,6 @@ import { SELECTED_TRACK_ROW_CLASS } from "./theme";
import {
computeTrackExpansionHeight,
getTrackExpandedRows,
- getExpansionHeight,
getPropertyLabel,
type ExpandedRow,
} from "./expanded-layout";
@@ -75,6 +74,7 @@ import { TimelineBookmarksRow } from "./bookmarks";
import { useBookmarkDrag } from "@/hooks/timeline/use-bookmark-drag";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import { useInitialScrollBottom } from "@/hooks/timeline/use-initial-scroll-bottom";
+import { useTimelineResize } from "@/hooks/timeline/use-timeline-resize";
import { useTimelineStore } from "@/stores/timeline-store";
import { useEditor } from "@/hooks/use-editor";
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
@@ -119,7 +119,9 @@ export function Timeline() {
} = useElementSelection();
const editor = useEditor();
const timeline = editor.timeline;
- const scene = useEditor((currentEditor) => currentEditor.scenes.getActiveSceneOrNull());
+ const scene = useEditor((currentEditor) =>
+ currentEditor.scenes.getActiveSceneOrNull(),
+ );
const tracks = useMemo<TimelineTrack[]>(
() =>
scene
@@ -140,7 +142,6 @@ export function Timeline() {
const playheadRef = useRef<HTMLDivElement>(null);
const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
- const [isResizing, setIsResizing] = useState(false);
const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
null,
);
@@ -148,15 +149,6 @@ export function Timeline() {
const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => {
setCurrentSnapPoint(snapPoint);
}, []);
- const handleResizeStateChange = useCallback(
- ({ isResizing: nextIsResizing }: { isResizing: boolean }) => {
- setIsResizing(nextIsResizing);
- if (!nextIsResizing) {
- setCurrentSnapPoint(null);
- }
- },
- [],
- );
const timelineDuration = timeline.getTotalDuration() || 0;
const minZoomLevel = getTimelineZoomMin({
@@ -176,6 +168,10 @@ export function Timeline() {
tracksScrollRef,
rulerScrollRef,
});
+ const { isResizing, handleResizeStart } = useTimelineResize({
+ zoomLevel,
+ onSnapPointChange: handleSnapPointChange,
+ });
const expandedElementIds = useTimelineStore((s) => s.expandedElementIds);
@@ -366,7 +362,10 @@ export function Timeline() {
const containerWidth =
tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH;
- const contentWidth = timelineTimeToPixels({ time: timelineDuration, zoomLevel });
+ const contentWidth = timelineTimeToPixels({
+ time: timelineDuration,
+ zoomLevel,
+ });
const paddingPx = getTimelinePaddingPx({
containerWidth,
zoomLevel,
@@ -513,7 +512,10 @@ export function Timeline() {
TRACKS_CONTAINER_HEIGHT.min,
Math.min(
TRACKS_CONTAINER_HEIGHT.max,
- getTotalTracksHeight({ tracks, getExtraHeight: getTrackExpansionHeight }),
+ getTotalTracksHeight({
+ tracks,
+ getExtraHeight: getTrackExpansionHeight,
+ }),
),
) + TIMELINE_CONTENT_TOP_PADDING_PX
}px`,
@@ -533,14 +535,13 @@ export function Timeline() {
}}
>
{tracks.length > 0 && (
- <TimelineTrackRows
- mainTrackId={mainTrackId}
+ <TimelineTrackRows
+ mainTrackId={mainTrackId}
zoomLevel={zoomLevel}
dragState={dragState}
tracksScrollRef={tracksScrollRef}
lastMouseXRef={lastMouseXRef}
- onSnapPointChange={handleSnapPointChange}
- onResizeStateChange={handleResizeStateChange}
+ onResizeStart={handleResizeStart}
onElementMouseDown={handleElementMouseDown}
onElementClick={handleElementClick}
onTrackMouseDown={(event) => {
@@ -718,8 +719,7 @@ function TimelineTrackRows({
dragState,
tracksScrollRef,
lastMouseXRef,
- onSnapPointChange,
- onResizeStateChange,
+ onResizeStart,
onElementMouseDown,
onElementClick,
onTrackMouseDown,
@@ -733,8 +733,9 @@ function TimelineTrackRows({
dragState: ElementDragState;
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
lastMouseXRef: React.RefObject<number>;
- onSnapPointChange: (snapPoint: SnapPoint | null) => void;
- onResizeStateChange: (params: { isResizing: boolean }) => void;
+ onResizeStart: React.ComponentProps<
+ typeof TimelineTrackContent
+ >["onResizeStart"];
onElementMouseDown: React.ComponentProps<
typeof TimelineTrackContent
>["onElementMouseDown"];
@@ -798,8 +799,7 @@ function TimelineTrackRows({
<div
className={cn(
"absolute right-0 left-0 transition-colors",
- tracksWithSelection.has(track.id) &&
- SELECTED_TRACK_ROW_CLASS,
+ tracksWithSelection.has(track.id) && SELECTED_TRACK_ROW_CLASS,
)}
style={{
top: `${TIMELINE_CONTENT_TOP_PADDING_PX + getCumulativeHeightBefore({ tracks, trackIndex: index, getExtraHeight: getTrackExpansionHeight })}px`,
@@ -813,8 +813,7 @@ function TimelineTrackRows({
rulerScrollRef={tracksScrollRef}
tracksScrollRef={tracksScrollRef}
lastMouseXRef={lastMouseXRef}
- onSnapPointChange={onSnapPointChange}
- onResizeStateChange={onResizeStateChange}
+ onResizeStart={onResizeStart}
onElementMouseDown={onElementMouseDown}
onElementClick={onElementClick}
onTrackMouseDown={onTrackMouseDown}
@@ -929,12 +928,10 @@ function TrackToggleIcon({
function PropertyTree({ rows }: { rows: ExpandedRow[] }) {
return (
<div className="flex flex-col overflow-hidden">
- {rows.map((row, index) => (
+ {rows.map((row) => (
<div
key={row.propertyPath}
- className={cn(
- "flex shrink-0 items-center px-3 bg-muted/50",
- )}
+ className={cn("flex shrink-0 items-center px-3 bg-muted/50")}
style={{ height: `${KEYFRAME_LANE_HEIGHT_PX}px` }}
>
<span className="text-muted-foreground truncate text-xs leading-none">
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
index a3555e94..3ebd3efa 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
@@ -10,7 +10,6 @@ import {
} from "@/hooks/timeline/element/use-keyframe-drag";
import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection";
import { useKeyframeBoxSelect } from "@/hooks/timeline/element/use-keyframe-box-select";
-import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize";
import { SelectionBox } from "@/lib/selection/selection-box";
import { getElementKeyframes } from "@/lib/animation";
import {
@@ -86,7 +85,6 @@ import {
getExpansionHeight,
type ExpandedRow,
} from "./expanded-layout";
-import type { SnapPoint } from "@/lib/timeline/snap-utils";
const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40;
const ELEMENT_RING_WIDTH_PX = 1.5;
@@ -197,8 +195,12 @@ interface TimelineElementProps {
track: TimelineTrack;
zoomLevel: number;
isSelected: boolean;
- onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
- onResizeStateChange?: (params: { isResizing: boolean }) => void;
+ onResizeStart: (params: {
+ event: React.MouseEvent;
+ element: TimelineElementType;
+ track: TimelineTrack;
+ side: "left" | "right";
+ }) => void;
onElementMouseDown: (
event: React.MouseEvent,
element: TimelineElementType,
@@ -216,8 +218,7 @@ export function TimelineElement({
track,
zoomLevel,
isSelected,
- onSnapPointChange,
- onResizeStateChange,
+ onResizeStart,
onElementMouseDown,
onElementClick,
dragState,
@@ -231,18 +232,6 @@ export function TimelineElement({
elementId: element.id,
fallback: element,
});
- const {
- currentDuration,
- currentStartTime,
- handleResizeStart,
- isResizing,
- } = useTimelineElementResize({
- element,
- track,
- zoomLevel,
- onSnapPointChange,
- onResizeStateChange,
- });
let mediaAsset: MediaAsset | null = null;
@@ -267,13 +256,9 @@ export function TimelineElement({
const elementStartTime =
isBeingDragged && dragState.isDragging
? dragState.currentTime + dragTimeOffset
- : isResizing
- ? currentStartTime
- : renderElement.startTime;
+ : renderElement.startTime;
const displayedStartTime = elementStartTime;
- const displayedDuration = isResizing
- ? currentDuration
- : renderElement.duration;
+ const displayedDuration = renderElement.duration;
const elementWidth = timelineTimeToPixels({
time: displayedDuration,
zoomLevel,
@@ -282,22 +267,6 @@ export function TimelineElement({
time: displayedStartTime,
zoomLevel,
});
- const handleElementResizeStart = ({
- event,
- element,
- side,
- }: {
- event: React.MouseEvent;
- element: TimelineElementType;
- track: TimelineTrack;
- side: "left" | "right";
- }) => {
- handleResizeStart({
- event,
- elementId: element.id,
- side,
- });
- };
const keyframeIndicators = isSelected
? getKeyframeIndicators({
keyframes: getElementKeyframes({ animations: element.animations }),
@@ -329,9 +298,7 @@ export function TimelineElement({
);
const expandedRows = useMemo(
() =>
- isExpanded
- ? getExpandedRows({ animations: element.animations })
- : [],
+ isExpanded ? getExpandedRows({ animations: element.animations }) : [],
[isExpanded, element.animations],
);
@@ -422,7 +389,7 @@ export function TimelineElement({
expandedContent={expandedContent}
onElementClick={onElementClick}
onElementMouseDown={onElementMouseDown}
- onResizeStart={handleElementResizeStart}
+ onResizeStart={onResizeStart}
isDropTarget={isDropTarget}
/>
{isSelected && (
@@ -811,8 +778,7 @@ function ExpandedKeyframeLanes({
[...keyframes]
.sort(
(a, b) =>
- a.time - b.time ||
- a.propertyPath.localeCompare(b.propertyPath),
+ a.time - b.time || a.propertyPath.localeCompare(b.propertyPath),
)
.map((kf) => ({
trackId,
@@ -824,6 +790,8 @@ function ExpandedKeyframeLanes({
);
return (
+ // biome-ignore lint/a11y/noStaticElementInteractions: expanded keyframe lanes are a pointer-only editing surface
+ // biome-ignore lint/a11y/useKeyWithClickEvents: expanded keyframe lanes are a pointer-only editing surface
<div
ref={containerRef}
className="relative flex flex-col"
@@ -837,9 +805,7 @@ function ExpandedKeyframeLanes({
return (
<div
key={row.propertyPath}
- className={cn(
- "relative flex items-center bg-muted/50",
- )}
+ className={cn("relative flex items-center bg-muted/50")}
style={{ height: `${KEYFRAME_LANE_HEIGHT_PX}px` }}
>
{laneKeyframes.map((kf) => {
@@ -849,8 +815,9 @@ function ExpandedKeyframeLanes({
propertyPath: row.propertyPath,
keyframeId: kf.id,
};
- const isBeingDragged =
- keyframeDragState.draggingKeyframeIds.has(kf.id);
+ const isBeingDragged = keyframeDragState.draggingKeyframeIds.has(
+ kf.id,
+ );
const kfLeft = timelineTimeToSnappedPixels({
time: displayedStartTime + kf.time,
zoomLevel,
@@ -898,9 +865,7 @@ function ExpandedKeyframeLanes({
icon={KeyframeIcon}
className={cn(
"size-3.5 text-black mr-1",
- isSelected
- ? "fill-primary"
- : "fill-white",
+ isSelected ? "fill-primary" : "fill-white",
)}
strokeWidth={1.5}
/>
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx
index 888e1496..32b1b93d 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx
@@ -1,129 +1,132 @@
-"use client";
-
-import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
-import { TimelineElement } from "./timeline-element";
-import type { TimelineTrack } from "@/lib/timeline";
-import type { TimelineElement as TimelineElementType } from "@/lib/timeline";
-import type { SnapPoint } from "@/lib/timeline/snap-utils";
-import { TIMELINE_LAYERS } from "./layers";
-import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
-import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
-import type { ElementDragState } from "@/lib/timeline";
-import { useEditor } from "@/hooks/use-editor";
-
-interface TimelineTrackContentProps {
- track: TimelineTrack;
- zoomLevel: number;
- dragState: ElementDragState;
- rulerScrollRef: React.RefObject<HTMLDivElement | null>;
- tracksScrollRef: React.RefObject<HTMLDivElement | null>;
- lastMouseXRef: React.RefObject<number>;
- onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
- onResizeStateChange?: (params: { isResizing: boolean }) => void;
- onElementMouseDown: (params: {
- event: React.MouseEvent;
- element: TimelineElementType;
- track: TimelineTrack;
- }) => void;
- onElementClick: (params: {
- event: React.MouseEvent;
- element: TimelineElementType;
- track: TimelineTrack;
- }) => void;
- onTrackMouseDown?: (event: React.MouseEvent) => void;
- onTrackMouseUp?: (event: React.MouseEvent) => void;
- shouldIgnoreClick?: () => boolean;
- targetElementId?: string | null;
-}
-
-export function TimelineTrackContent({
- track,
- zoomLevel,
- dragState,
- rulerScrollRef,
- tracksScrollRef,
- lastMouseXRef,
- onSnapPointChange,
- onResizeStateChange,
- onElementMouseDown,
- onElementClick,
- onTrackMouseDown,
- onTrackMouseUp,
- shouldIgnoreClick,
- targetElementId = null,
-}: TimelineTrackContentProps) {
- const { isElementSelected } = useElementSelection();
- const duration = useEditor((e) => e.timeline.getTotalDuration());
-
- useEdgeAutoScroll({
- isActive: dragState.isDragging,
- getMouseClientX: () => lastMouseXRef.current ?? 0,
- rulerScrollRef,
- tracksScrollRef,
- contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel,
- });
-
- return (
- <div className="relative size-full">
- <button
- type="button"
- className="absolute inset-0 m-0 size-full appearance-none border-0 bg-transparent p-0"
- aria-label={`Select ${track.name} track`}
- onMouseUp={(event) => {
- if (shouldIgnoreClick?.()) return;
- onTrackMouseUp?.(event);
- }}
- onMouseDown={(event) => {
- event.preventDefault();
- onTrackMouseDown?.(event);
- }}
- />
- {/* biome-ignore lint/a11y/noStaticElementInteractions: empty track area is a pointer-only seek surface */}
- <div
- className="relative h-full min-w-full"
- style={{ zIndex: TIMELINE_LAYERS.trackContent }}
- onMouseUp={(event) => {
- if (event.target !== event.currentTarget) return;
- if (shouldIgnoreClick?.()) return;
- onTrackMouseUp?.(event);
- }}
- onMouseDown={(event) => {
- if (event.target !== event.currentTarget) return;
- event.preventDefault();
- onTrackMouseDown?.(event);
- }}
- >
- {track.elements.length === 0 ? (
- <div className="text-muted-foreground border-muted/30 pointer-events-none flex size-full items-center justify-center rounded-sm border-2 border-dashed text-xs" />
- ) : (
- track.elements.map((element) => {
- const isSelected = isElementSelected({
- trackId: track.id,
- elementId: element.id,
- });
-
- return (
- <TimelineElement
- key={element.id}
- element={element}
- track={track}
- zoomLevel={zoomLevel}
- isSelected={isSelected}
- onSnapPointChange={onSnapPointChange}
- onResizeStateChange={onResizeStateChange}
- onElementMouseDown={(event, element) =>
- onElementMouseDown({ event, element, track })
- }
- onElementClick={(event, element) =>
- onElementClick({ event, element, track })
- }
- dragState={dragState}
- isDropTarget={element.id === targetElementId}
- />
- );
- })
- )}
- </div>
- </div>
- );
-}
+"use client";
+
+import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
+import { TimelineElement } from "./timeline-element";
+import type { TimelineTrack } from "@/lib/timeline";
+import type { TimelineElement as TimelineElementType } from "@/lib/timeline";
+import { TIMELINE_LAYERS } from "./layers";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
+import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
+import type { ElementDragState } from "@/lib/timeline";
+import { useEditor } from "@/hooks/use-editor";
+
+interface TimelineTrackContentProps {
+ track: TimelineTrack;
+ zoomLevel: number;
+ dragState: ElementDragState;
+ rulerScrollRef: React.RefObject<HTMLDivElement | null>;
+ tracksScrollRef: React.RefObject<HTMLDivElement | null>;
+ lastMouseXRef: React.RefObject<number>;
+ onResizeStart: (params: {
+ event: React.MouseEvent;
+ element: TimelineElementType;
+ track: TimelineTrack;
+ side: "left" | "right";
+ }) => void;
+ onElementMouseDown: (params: {
+ event: React.MouseEvent;
+ element: TimelineElementType;
+ track: TimelineTrack;
+ }) => void;
+ onElementClick: (params: {
+ event: React.MouseEvent;
+ element: TimelineElementType;
+ track: TimelineTrack;
+ }) => void;
+ onTrackMouseDown?: (event: React.MouseEvent) => void;
+ onTrackMouseUp?: (event: React.MouseEvent) => void;
+ shouldIgnoreClick?: () => boolean;
+ targetElementId?: string | null;
+}
+
+export function TimelineTrackContent({
+ track,
+ zoomLevel,
+ dragState,
+ rulerScrollRef,
+ tracksScrollRef,
+ lastMouseXRef,
+ onResizeStart,
+ onElementMouseDown,
+ onElementClick,
+ onTrackMouseDown,
+ onTrackMouseUp,
+ shouldIgnoreClick,
+ targetElementId = null,
+}: TimelineTrackContentProps) {
+ const { isElementSelected } = useElementSelection();
+ const duration = useEditor((e) => e.timeline.getTotalDuration());
+
+ useEdgeAutoScroll({
+ isActive: dragState.isDragging,
+ getMouseClientX: () => lastMouseXRef.current ?? 0,
+ rulerScrollRef,
+ tracksScrollRef,
+ contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel,
+ });
+
+ return (
+ <div className="relative size-full">
+ <button
+ type="button"
+ className="absolute inset-0 m-0 size-full appearance-none border-0 bg-transparent p-0"
+ aria-label={`Select ${track.name} track`}
+ onMouseUp={(event) => {
+ if (shouldIgnoreClick?.()) return;
+ onTrackMouseUp?.(event);
+ }}
+ onMouseDown={(event) => {
+ event.preventDefault();
+ onTrackMouseDown?.(event);
+ }}
+ />
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: empty track area is a pointer-only seek surface */}
+ <div
+ className="relative h-full min-w-full"
+ style={{ zIndex: TIMELINE_LAYERS.trackContent }}
+ onMouseUp={(event) => {
+ if (event.target !== event.currentTarget) return;
+ if (shouldIgnoreClick?.()) return;
+ onTrackMouseUp?.(event);
+ }}
+ onMouseDown={(event) => {
+ if (event.target !== event.currentTarget) return;
+ event.preventDefault();
+ onTrackMouseDown?.(event);
+ }}
+ >
+ {track.elements.length === 0 ? (
+ <div className="text-muted-foreground border-muted/30 pointer-events-none flex size-full items-center justify-center rounded-sm border-2 border-dashed text-xs" />
+ ) : (
+ track.elements.map((element) => {
+ const isSelected = isElementSelected({
+ trackId: track.id,
+ elementId: element.id,
+ });
+
+ return (
+ <TimelineElement
+ key={element.id}
+ element={element}
+ track={track}
+ zoomLevel={zoomLevel}
+ isSelected={isSelected}
+ onResizeStart={({ event, element, side }) =>
+ onResizeStart({ event, element, track, side })
+ }
+ onElementMouseDown={(event, element) =>
+ onElementMouseDown({ event, element, track })
+ }
+ onElementClick={(event, element) =>
+ onElementClick({ event, element, track })
+ }
+ dragState={dragState}
+ isDropTarget={element.id === targetElementId}
+ />
+ );
+ })
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts
index 70cd0439..c1398e8b 100644
--- a/apps/web/src/core/managers/timeline-manager.ts
+++ b/apps/web/src/core/managers/timeline-manager.ts
@@ -8,9 +8,7 @@ import type {
RetimeConfig,
} from "@/lib/timeline";
import { calculateTotalDuration } from "@/lib/timeline";
-import {
- findTrackInSceneTracks,
-} from "@/lib/timeline/track-element-update";
+import { findTrackInSceneTracks } from "@/lib/timeline/track-element-update";
import {
canElementBeHidden,
canElementHaveAudio,
@@ -57,6 +55,10 @@ import {
ToggleSourceAudioSeparationCommand,
} from "@/lib/commands/timeline";
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
+import type {
+ PlannedElementMove,
+ PlannedTrackCreation,
+} from "@/lib/timeline/group-move";
export class TimelineManager {
private listeners = new Set<() => void>();
@@ -149,25 +151,20 @@ export class TimelineManager {
});
}
- moveElement({
- sourceTrackId,
- targetTrackId,
- elementId,
- newStartTime,
- createTrack,
+ moveElements({
+ moves,
+ createTracks,
}: {
- sourceTrackId: string;
- targetTrackId: string;
- elementId: string;
- newStartTime: number;
- createTrack?: { type: TrackType; index: number };
+ moves: PlannedElementMove[];
+ createTracks?: PlannedTrackCreation[];
}): void {
+ if (moves.length === 0) {
+ return;
+ }
+
const command = new MoveElementCommand({
- sourceTrackId,
- targetTrackId,
- elementId,
- newStartTime,
- createTrack,
+ moves,
+ createTracks,
});
this.editor.command.execute({ command });
}
@@ -580,14 +577,7 @@ export class TimelineManager {
}
const commands = keyframes.map(
- ({
- trackId,
- elementId,
- propertyPath,
- componentKey,
- keyframeId,
- patch,
- }) =>
+ ({ trackId, elementId, propertyPath, componentKey, keyframeId, patch }) =>
new UpdateScalarKeyframeCurveCommand({
trackId,
elementId,
@@ -711,7 +701,9 @@ export class TimelineManager {
private applyPreviewOverlay(tracks: SceneTracks): SceneTracks {
if (this.previewOverlay.size === 0) return tracks;
- const applyTrackOverlay = <TTrack extends TimelineTrack>(track: TTrack): TTrack => {
+ const applyTrackOverlay = <TTrack extends TimelineTrack>(
+ track: TTrack,
+ ): TTrack => {
const hasOverlay = track.elements.some((element) =>
this.previewOverlay.has(element.id),
);
@@ -803,7 +795,11 @@ export class TimelineManager {
}
getPreviewTracks(): SceneTracks | null {
- return this.previewTracks ?? this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null;
+ return (
+ this.previewTracks ??
+ this.editor.scenes.getActiveSceneOrNull()?.tracks ??
+ null
+ );
}
subscribe(listener: () => void): () => void {
diff --git a/apps/web/src/hooks/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts
index 854c4f4e..98d21d9b 100644
--- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts
@@ -9,6 +9,13 @@ import {
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
+import {
+ buildMoveGroup,
+ resolveGroupMove,
+ snapGroupEdges,
+ type GroupMoveResult,
+ type MoveGroup,
+} from "@/lib/timeline/group-move";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
@@ -16,10 +23,11 @@ import { roundToFrame } from "opencut-wasm";
import { computeDropTarget } from "@/components/editor/panels/timeline/drop-target";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import { generateUUID } from "@/utils/id";
-import { snapElementEdge, type SnapPoint } from "@/lib/timeline/snap-utils";
+import type { SnapPoint } from "@/lib/timeline/snap-utils";
import { registerCanceller } from "@/lib/cancel-interaction";
import type {
DropTarget,
+ ElementRef,
ElementDragState,
SceneTracks,
TimelineElement,
@@ -55,6 +63,7 @@ const initialDragState: ElementDragState = {
interface PendingDragState {
elementId: string;
trackId: string;
+ selectedElements: ElementRef[];
startMouseX: number;
startMouseY: number;
startElementTime: number;
@@ -116,11 +125,9 @@ function getDragDropTarget({
const scrollContainer = tracksScrollRef.current;
if (!containerRect || !scrollContainer) return null;
- const sourceTrack = [
- ...tracks.overlay,
- tracks.main,
- ...tracks.audio,
- ].find(({ id }) => id === trackId);
+ const sourceTrack = [...tracks.overlay, tracks.main, ...tracks.audio].find(
+ ({ id }) => id === trackId,
+ );
const movingElement = sourceTrack?.elements.find(
({ id }) => id === elementId,
);
@@ -153,7 +160,7 @@ function getDragDropTarget({
interface StartDragParams
extends Omit<
ElementDragState,
- "isDragging" | "currentTime" | "currentMouseY" | "dragElementIds" | "dragTimeOffsets"
+ "isDragging" | "currentTime" | "currentMouseY"
> {
initialCurrentTime: number;
initialCurrentMouseY: number;
@@ -171,11 +178,6 @@ export function useElementInteraction({
const editor = useEditor();
const isShiftHeldRef = useShiftKey();
const sceneTracks = editor.scenes.getActiveScene().tracks;
- const tracks = [
- ...sceneTracks.overlay,
- sceneTracks.main,
- ...sceneTracks.audio,
- ];
const {
selectedElements,
isElementSelected,
@@ -188,12 +190,16 @@ export function useElementInteraction({
const [dragDropTarget, setDragDropTarget] = useState<DropTarget | null>(null);
const [isPendingDrag, setIsPendingDrag] = useState(false);
const pendingDragRef = useRef<PendingDragState | null>(null);
+ const moveGroupRef = useRef<MoveGroup | null>(null);
+ const groupMoveResultRef = useRef<GroupMoveResult | null>(null);
const lastMouseXRef = useRef(0);
const mouseDownLocationRef = useRef<{ x: number; y: number } | null>(null);
const startDrag = useCallback(
({
elementId,
+ dragElementIds,
+ dragTimeOffsets,
trackId,
startMouseX,
startMouseY,
@@ -205,8 +211,8 @@ export function useElementInteraction({
setDragState({
isDragging: true,
elementId,
- dragElementIds: elementId ? [elementId] : [],
- dragTimeOffsets: {},
+ dragElementIds,
+ dragTimeOffsets,
trackId,
startMouseX,
startMouseY,
@@ -220,6 +226,8 @@ export function useElementInteraction({
);
const endDrag = useCallback(() => {
+ moveGroupRef.current = null;
+ groupMoveResultRef.current = null;
setDragState(initialDragState);
setDragDropTarget(null);
}, []);
@@ -232,6 +240,70 @@ export function useElementInteraction({
onSnapPointChange?.(null);
}, [endDrag, onSnapPointChange]);
+ const resolveGroupDragMove = useCallback(
+ ({
+ group,
+ snappedTime,
+ dropTarget,
+ }: {
+ group: MoveGroup;
+ snappedTime: number;
+ dropTarget: DropTarget | null;
+ }): GroupMoveResult | null => {
+ if (!dropTarget) {
+ return null;
+ }
+
+ if (dropTarget.isNewTrack) {
+ return resolveGroupMove({
+ group,
+ tracks: sceneTracks,
+ anchorStartTime: snappedTime,
+ target: {
+ kind: "newTracks",
+ anchorInsertIndex: dropTarget.trackIndex,
+ newTrackIds: group.members.map(() => generateUUID()),
+ },
+ });
+ }
+
+ const orderedTracks = [
+ ...sceneTracks.overlay,
+ sceneTracks.main,
+ ...sceneTracks.audio,
+ ];
+ const targetTrack = orderedTracks[dropTarget.trackIndex];
+ if (!targetTrack) {
+ return null;
+ }
+
+ const existingTrackResult = resolveGroupMove({
+ group,
+ tracks: sceneTracks,
+ anchorStartTime: snappedTime,
+ target: {
+ kind: "existingTrack",
+ anchorTargetTrackId: targetTrack.id,
+ },
+ });
+ if (existingTrackResult) {
+ return existingTrackResult;
+ }
+
+ return resolveGroupMove({
+ group,
+ tracks: sceneTracks,
+ anchorStartTime: snappedTime,
+ target: {
+ kind: "newTracks",
+ anchorInsertIndex: dropTarget.trackIndex,
+ newTrackIds: group.members.map(() => generateUUID()),
+ },
+ });
+ },
+ [sceneTracks],
+ );
+
useEffect(() => {
if (!dragState.isDragging && !isPendingDrag) return;
@@ -241,51 +313,29 @@ export function useElementInteraction({
const getDragSnapResult = useCallback(
({
frameSnappedTime,
- movingElement,
+ group,
}: {
frameSnappedTime: number;
- movingElement: TimelineElement | null | undefined;
+ group: MoveGroup | null;
}) => {
- const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
- if (!shouldSnap || !movingElement) {
+ if (!group || !snappingEnabled || isShiftHeldRef.current) {
return { snappedTime: frameSnappedTime, snapPoint: null };
}
- const elementDuration = movingElement.duration;
- const playheadTime = editor.playback.getCurrentTime();
-
- const startSnap = snapElementEdge({
- targetTime: frameSnappedTime,
- elementDuration,
+ const groupSnap = snapGroupEdges({
+ group,
+ anchorStartTime: frameSnappedTime,
tracks: sceneTracks,
- playheadTime,
+ playheadTime: editor.playback.getCurrentTime(),
zoomLevel,
- excludeElementId: movingElement.id,
- snapToStart: true,
});
- const endSnap = snapElementEdge({
- targetTime: frameSnappedTime,
- elementDuration,
- tracks: sceneTracks,
- playheadTime,
- zoomLevel,
- excludeElementId: movingElement.id,
- snapToStart: false,
- });
-
- const snapResult =
- startSnap.snapDistance <= endSnap.snapDistance ? startSnap : endSnap;
- if (!snapResult.snapPoint) {
- return { snappedTime: frameSnappedTime, snapPoint: null };
- }
-
return {
- snappedTime: snapResult.snappedTime,
- snapPoint: snapResult.snapPoint,
+ snappedTime: groupSnap.snappedAnchorStartTime,
+ snapPoint: groupSnap.snapPoint,
};
},
- [snappingEnabled, editor.playback, tracks, zoomLevel, isShiftHeldRef],
+ [snappingEnabled, editor.playback, sceneTracks, zoomLevel, isShiftHeldRef],
);
useEffect(() => {
@@ -314,17 +364,38 @@ export function useElementInteraction({
zoomLevel,
scrollLeft,
});
- const adjustedTime = Math.max(
- 0,
- mouseTime - pendingDragRef.current.clickOffsetTime,
- );
- const snappedTime = roundToFrame({
- time: adjustedTime,
- rate: activeProject.settings.fps,
- }) ?? adjustedTime;
- startDrag({
- ...pendingDragRef.current,
- initialCurrentTime: snappedTime,
+ const adjustedTime = Math.max(
+ 0,
+ mouseTime - pendingDragRef.current.clickOffsetTime,
+ );
+ const snappedTime =
+ roundToFrame({
+ time: adjustedTime,
+ rate: activeProject.settings.fps,
+ }) ?? adjustedTime;
+ const moveGroup = buildMoveGroup({
+ anchorRef: {
+ trackId: pendingDragRef.current.trackId,
+ elementId: pendingDragRef.current.elementId,
+ },
+ selectedElements: pendingDragRef.current.selectedElements,
+ tracks: sceneTracks,
+ });
+ if (!moveGroup) {
+ return;
+ }
+
+ moveGroupRef.current = moveGroup;
+ groupMoveResultRef.current = null;
+ const dragTimeOffsets: Record<string, number> = {};
+ for (const member of moveGroup.members) {
+ dragTimeOffsets[member.elementId] = member.timeOffset;
+ }
+ startDrag({
+ ...pendingDragRef.current,
+ dragElementIds: moveGroup.members.map((member) => member.elementId),
+ dragTimeOffsets,
+ initialCurrentTime: snappedTime,
initialCurrentMouseY: clientY,
});
startedDragThisEvent = true;
@@ -364,15 +435,13 @@ export function useElementInteraction({
});
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
const fps = activeProject.settings.fps;
- const frameSnappedTime = roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime;
+ const frameSnappedTime =
+ roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime;
- const sourceTrack = tracks.find(({ id }) => id === dragState.trackId);
- const movingElement = sourceTrack?.elements.find(
- ({ id }) => id === dragState.elementId,
- );
+ const moveGroup = moveGroupRef.current;
const { snappedTime, snapPoint } = getDragSnapResult({
frameSnappedTime,
- movingElement,
+ group: moveGroup,
});
setDragState((previousDragState) => ({
...previousDragState,
@@ -386,7 +455,7 @@ export function useElementInteraction({
startMouseY: dragState.startMouseY,
currentMouseY: clientY,
});
- const dropTarget = getDragDropTarget({
+ const anchorDropTarget = getDragDropTarget({
clientX,
clientY,
elementId: dragState.elementId,
@@ -399,7 +468,24 @@ export function useElementInteraction({
snappedTime,
verticalDragDirection,
});
- setDragDropTarget(dropTarget?.isNewTrack ? dropTarget : null);
+ const nextGroupMoveResult =
+ moveGroup && anchorDropTarget
+ ? resolveGroupDragMove({
+ group: moveGroup,
+ snappedTime,
+ dropTarget: anchorDropTarget,
+ })
+ : null;
+ groupMoveResultRef.current = nextGroupMoveResult;
+ setDragDropTarget(
+ anchorDropTarget &&
+ (anchorDropTarget.isNewTrack || !nextGroupMoveResult)
+ ? {
+ ...anchorDropTarget,
+ isNewTrack: true,
+ }
+ : null,
+ );
}
};
@@ -419,10 +505,11 @@ export function useElementInteraction({
tracksScrollRef,
tracksContainerRef,
headerRef,
- tracks,
isPendingDrag,
startDrag,
getDragSnapResult,
+ resolveGroupDragMove,
+ sceneTracks,
onSnapPointChange,
]);
@@ -468,55 +555,45 @@ export function useElementInteraction({
return;
}
const snappedTime = dragState.currentTime;
-
- const sourceTrack = tracks.find(({ id }) => id === dragState.trackId);
- if (!sourceTrack) {
+ const moveGroup = moveGroupRef.current;
+ if (!moveGroup) {
endDrag();
onSnapPointChange?.(null);
return;
}
- const movingElement =
- sourceTrack.elements.find(({ id }) => id === dragState.elementId) ?? null;
- if (
- movingElement &&
- !dropTarget.isNewTrack &&
- tracks[dropTarget.trackIndex]?.id === dragState.trackId &&
- snappedTime === movingElement.startTime
- ) {
+
+ const groupMoveResult = resolveGroupDragMove({
+ group: moveGroup,
+ snappedTime,
+ dropTarget,
+ });
+ if (!groupMoveResult) {
endDrag();
onSnapPointChange?.(null);
return;
}
- if (dropTarget.isNewTrack) {
- const newTrackId = generateUUID();
-
- editor.timeline.moveElement({
- sourceTrackId: dragState.trackId,
- targetTrackId: newTrackId,
- elementId: dragState.elementId,
- newStartTime: snappedTime,
- createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex },
- });
- selectElement({ trackId: newTrackId, elementId: dragState.elementId });
- } else {
- const targetTrack = tracks[dropTarget.trackIndex];
- if (targetTrack) {
- editor.timeline.moveElement({
- sourceTrackId: dragState.trackId,
- targetTrackId: targetTrack.id,
- elementId: dragState.elementId,
- newStartTime: snappedTime,
- });
- if (targetTrack.id !== dragState.trackId) {
- selectElement({
- trackId: targetTrack.id,
- elementId: dragState.elementId,
- });
- }
- }
+ const didMove = groupMoveResult.moves.some((move) => {
+ const currentMember = moveGroup.members.find(
+ (member) => member.elementId === move.elementId,
+ );
+ const originalStartTime =
+ dragState.startElementTime + (currentMember?.timeOffset ?? 0);
+ return (
+ currentMember?.trackId !== move.targetTrackId ||
+ originalStartTime !== move.newStartTime
+ );
+ });
+ if (!didMove && groupMoveResult.createTracks.length === 0) {
+ endDrag();
+ onSnapPointChange?.(null);
+ return;
}
+ editor.timeline.moveElements({
+ moves: groupMoveResult.moves,
+ createTracks: groupMoveResult.createTracks,
+ });
endDrag();
onSnapPointChange?.(null);
};
@@ -531,14 +608,14 @@ export function useElementInteraction({
dragState.trackId,
dragState.currentTime,
zoomLevel,
- tracks,
endDrag,
onSnapPointChange,
editor.timeline,
tracksContainerRef,
tracksScrollRef,
headerRef,
- selectElement,
+ resolveGroupDragMove,
+ sceneTracks,
]);
useEffect(() => {
@@ -600,9 +677,17 @@ export function useElementInteraction({
elementRect: event.currentTarget.getBoundingClientRect(),
zoomLevel,
});
+ const elementRef = {
+ trackId: track.id,
+ elementId: element.id,
+ };
+ const pendingSelectedElements = isElementSelected(elementRef)
+ ? selectedElements
+ : [elementRef];
pendingDragRef.current = {
elementId: element.id,
trackId: track.id,
+ selectedElements: pendingSelectedElements,
startMouseX: event.clientX,
startMouseY: event.clientY,
startElementTime: element.startTime,
@@ -610,7 +695,7 @@ export function useElementInteraction({
};
setIsPendingDrag(true);
},
- [zoomLevel, isElementSelected, handleSelectionClick],
+ [zoomLevel, isElementSelected, handleSelectionClick, selectedElements],
);
const handleElementClick = useCallback(
diff --git a/apps/web/src/hooks/timeline/element/use-element-resize.ts b/apps/web/src/hooks/timeline/element/use-element-resize.ts
deleted file mode 100644
index bb0d880c..00000000
--- a/apps/web/src/hooks/timeline/element/use-element-resize.ts
+++ /dev/null
@@ -1,503 +0,0 @@
-import { useState, useEffect, useRef, useCallback } from "react";
-import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
-import { TICKS_PER_SECOND } from "@/lib/wasm";
-import { roundToFrame } from "opencut-wasm";
-import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
-import { useEditor } from "@/hooks/use-editor";
-import { useShiftKey } from "@/hooks/use-shift-key";
-import {
- findSnapPoints,
- snapToNearestPoint,
- type SnapPoint,
-} from "@/lib/timeline/snap-utils";
-import { isRetimableElement } from "@/lib/timeline";
-import {
- getSourceSpanAtClipTime,
- getTimelineDurationForSourceSpan,
-} from "@/lib/retime";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { registerCanceller } from "@/lib/cancel-interaction";
-
-export interface ResizeState {
- elementId: string;
- side: "left" | "right";
- startX: number;
- initialTrimStart: number;
- initialTrimEnd: number;
- initialStartTime: number;
- initialDuration: number;
-}
-
-interface UseTimelineElementResizeProps {
- element: TimelineElement;
- track: TimelineTrack;
- zoomLevel: number;
- onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
- onResizeStateChange?: (params: { isResizing: boolean }) => void;
-}
-
-export function useTimelineElementResize({
- element,
- track,
- zoomLevel,
- onSnapPointChange,
- onResizeStateChange,
-}: UseTimelineElementResizeProps) {
- const editor = useEditor();
- const isShiftHeldRef = useShiftKey();
- const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
-
- const [resizing, setResizing] = useState<ResizeState | null>(null);
- const [currentTrimStart, setCurrentTrimStart] = useState(element.trimStart);
- const [currentTrimEnd, setCurrentTrimEnd] = useState(element.trimEnd);
- const [currentStartTime, setCurrentStartTime] = useState(element.startTime);
- const [currentDuration, setCurrentDuration] = useState(element.duration);
- const currentTrimStartRef = useRef(element.trimStart);
- const currentTrimEndRef = useRef(element.trimEnd);
- const currentStartTimeRef = useRef(element.startTime);
- const currentDurationRef = useRef(element.duration);
-
- const handleResizeStart = ({
- event,
- elementId,
- side,
- }: {
- event: React.MouseEvent;
- elementId: string;
- side: "left" | "right";
- }) => {
- event.stopPropagation();
- event.preventDefault();
-
- setResizing({
- elementId,
- side,
- startX: event.clientX,
- initialTrimStart: element.trimStart,
- initialTrimEnd: element.trimEnd,
- initialStartTime: element.startTime,
- initialDuration: element.duration,
- });
-
- setCurrentTrimStart(element.trimStart);
- setCurrentTrimEnd(element.trimEnd);
- setCurrentStartTime(element.startTime);
- setCurrentDuration(element.duration);
- currentTrimStartRef.current = element.trimStart;
- currentTrimEndRef.current = element.trimEnd;
- currentStartTimeRef.current = element.startTime;
- currentDurationRef.current = element.duration;
- onResizeStateChange?.({ isResizing: true });
- };
-
- const canExtendElementDuration = useCallback(() => {
- return element.sourceDuration == null;
- }, [element.sourceDuration]);
-
- const getSourceDeltaForClipDelta = useCallback(
- (clipDelta: number) => {
- if (!isRetimableElement(element)) {
- return clipDelta;
- }
-
- return clipDelta >= 0
- ? getSourceSpanAtClipTime({
- clipTime: clipDelta,
- retime: element.retime,
- })
- : -getSourceSpanAtClipTime({
- clipTime: Math.abs(clipDelta),
- retime: element.retime,
- });
- },
- [element],
- );
-
- const getVisibleSourceSpanForDuration = useCallback(
- (duration: number) => {
- if (!isRetimableElement(element)) {
- return duration;
- }
-
- return getSourceSpanAtClipTime({
- clipTime: duration,
- retime: element.retime,
- });
- },
- [element],
- );
-
- const getDurationForVisibleSourceSpan = useCallback(
- (sourceSpan: number) => {
- if (!isRetimableElement(element)) {
- return sourceSpan;
- }
-
- return getTimelineDurationForSourceSpan({
- sourceSpan,
- retime: element.retime,
- });
- },
- [element],
- );
-
- const getSourceDuration = useCallback(
- ({
- trimStart,
- duration,
- trimEnd,
- }: {
- trimStart: number;
- duration: number;
- trimEnd: number;
- }) => {
- if (typeof element.sourceDuration === "number") {
- return element.sourceDuration;
- }
-
- return trimStart + getVisibleSourceSpanForDuration(duration) + trimEnd;
- },
- [element.sourceDuration, getVisibleSourceSpanForDuration],
- );
-
- const cancelResize = useCallback(() => {
- if (!resizing) return;
-
- setCurrentTrimStart(resizing.initialTrimStart);
- setCurrentTrimEnd(resizing.initialTrimEnd);
- setCurrentStartTime(resizing.initialStartTime);
- setCurrentDuration(resizing.initialDuration);
- currentTrimStartRef.current = resizing.initialTrimStart;
- currentTrimEndRef.current = resizing.initialTrimEnd;
- currentStartTimeRef.current = resizing.initialStartTime;
- currentDurationRef.current = resizing.initialDuration;
- setResizing(null);
- onResizeStateChange?.({ isResizing: false });
- onSnapPointChange?.(null);
- }, [resizing, onResizeStateChange, onSnapPointChange]);
-
- useEffect(() => {
- if (!resizing) return;
-
- return registerCanceller({ fn: cancelResize });
- }, [resizing, cancelResize]);
-
- const updateTrimFromMouseMove = useCallback(
- ({ clientX }: { clientX: number }) => {
- if (!resizing) return;
-
- const deltaX = clientX - resizing.startX;
- let deltaTime = Math.round(
- (deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel)) * TICKS_PER_SECOND,
- );
- let resizeSnapPoint: SnapPoint | null = null;
-
- const projectFps = editor.project.getActive().settings.fps;
- const minDuration = Math.round(TICKS_PER_SECOND * projectFps.denominator / projectFps.numerator);
- const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
- if (shouldSnap) {
- const tracks = editor.scenes.getActiveScene().tracks;
- const playheadTime = editor.playback.getCurrentTime();
- const snapPoints = findSnapPoints({
- tracks,
- playheadTime,
- excludeElementId: element.id,
- });
- if (resizing.side === "left") {
- const targetStartTime = resizing.initialStartTime + deltaTime;
- const snapResult = snapToNearestPoint({
- targetTime: targetStartTime,
- snapPoints,
- zoomLevel,
- });
- resizeSnapPoint = snapResult.snapPoint;
- if (snapResult.snapPoint) {
- deltaTime = snapResult.snappedTime - resizing.initialStartTime;
- }
- } else {
- const baseEndTime =
- resizing.initialStartTime + resizing.initialDuration;
- const targetEndTime = baseEndTime + deltaTime;
- const snapResult = snapToNearestPoint({
- targetTime: targetEndTime,
- snapPoints,
- zoomLevel,
- });
- resizeSnapPoint = snapResult.snapPoint;
- if (snapResult.snapPoint) {
- deltaTime = snapResult.snappedTime - baseEndTime;
- }
- }
- }
- onSnapPointChange?.(resizeSnapPoint);
-
- const otherElements = track.elements.filter(
- ({ id }) => id !== element.id,
- );
- const initialEndTime =
- resizing.initialStartTime + resizing.initialDuration;
-
- const rightNeighborBound =
- resizing.side === "right"
- ? otherElements
- .filter(({ startTime }) => startTime >= initialEndTime)
- .reduce(
- (min, { startTime }) => Math.min(min, startTime),
- Infinity,
- )
- : Infinity;
-
- const leftNeighborBound =
- resizing.side === "left"
- ? otherElements
- .filter(
- ({ startTime, duration }) =>
- startTime + duration <= resizing.initialStartTime,
- )
- .reduce(
- (max, { startTime, duration }) =>
- Math.max(max, startTime + duration),
- -Infinity,
- )
- : -Infinity;
-
- if (resizing.side === "left") {
- const sourceDuration = getSourceDuration({
- trimStart: resizing.initialTrimStart,
- duration: resizing.initialDuration,
- trimEnd: resizing.initialTrimEnd,
- });
- const minTrimStartForNeighbor = Number.isFinite(leftNeighborBound)
- ? Math.max(
- 0,
- resizing.initialTrimStart +
- getSourceDeltaForClipDelta(
- leftNeighborBound - resizing.initialStartTime,
- ),
- )
- : 0;
- const maxAllowed =
- sourceDuration -
- resizing.initialTrimEnd -
- getVisibleSourceSpanForDuration(minDuration);
- const calculated =
- resizing.initialTrimStart + getSourceDeltaForClipDelta(deltaTime);
-
- if (calculated >= 0 && calculated <= maxAllowed) {
- const newTrimStart = roundToFrame({ time: Math.min(maxAllowed, Math.max(minTrimStartForNeighbor, calculated)), rate: projectFps }) ?? Math.min(maxAllowed, Math.max(minTrimStartForNeighbor, calculated));
- const visibleSourceSpan = Math.max(
- 0,
- sourceDuration - newTrimStart - resizing.initialTrimEnd,
- );
- const newDuration = roundToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), rate: projectFps }) ?? getDurationForVisibleSourceSpan(visibleSourceSpan);
- const trimDelta = resizing.initialDuration - newDuration;
- const newStartTime = roundToFrame({ time: resizing.initialStartTime + trimDelta, rate: projectFps }) ?? resizing.initialStartTime + trimDelta;
-
- setCurrentTrimStart(newTrimStart);
- setCurrentStartTime(newStartTime);
- setCurrentDuration(newDuration);
- currentTrimStartRef.current = newTrimStart;
- currentStartTimeRef.current = newStartTime;
- currentDurationRef.current = newDuration;
- } else if (calculated < 0) {
- if (canExtendElementDuration()) {
- const extensionAmount = Math.abs(calculated);
- const maxExtension = resizing.initialStartTime;
- const actualExtension = Math.max(
- 0,
- Number.isFinite(leftNeighborBound)
- ? Math.min(
- extensionAmount,
- maxExtension,
- resizing.initialStartTime - leftNeighborBound,
- )
- : Math.min(extensionAmount, maxExtension),
- );
- const newStartTime = roundToFrame({ time: resizing.initialStartTime - actualExtension, rate: projectFps }) ?? resizing.initialStartTime - actualExtension;
- const newDuration = roundToFrame({ time: resizing.initialDuration + actualExtension, rate: projectFps }) ?? resizing.initialDuration + actualExtension;
-
- setCurrentTrimStart(0);
- setCurrentStartTime(newStartTime);
- setCurrentDuration(newDuration);
- currentTrimStartRef.current = 0;
- currentStartTimeRef.current = newStartTime;
- currentDurationRef.current = newDuration;
- } else {
- const leftBound = Number.isFinite(leftNeighborBound)
- ? leftNeighborBound
- : 0;
- const trimDeltaFromTrimStart =
- minTrimStartForNeighbor - resizing.initialTrimStart;
- const trimDeltaFromStartTime = getSourceDeltaForClipDelta(
- leftBound - resizing.initialStartTime,
- );
- const trimDelta = Math.max(
- trimDeltaFromTrimStart,
- trimDeltaFromStartTime,
- );
- const newTrimStart = resizing.initialTrimStart + trimDelta;
- const visibleSourceSpan = Math.max(
- 0,
- sourceDuration - newTrimStart - resizing.initialTrimEnd,
- );
- const newDuration = roundToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), rate: projectFps }) ?? getDurationForVisibleSourceSpan(visibleSourceSpan);
- const newStartTime = roundToFrame({ time: resizing.initialStartTime + (resizing.initialDuration - newDuration), rate: projectFps }) ?? resizing.initialStartTime + (resizing.initialDuration - newDuration);
-
- setCurrentTrimStart(newTrimStart);
- setCurrentStartTime(newStartTime);
- setCurrentDuration(newDuration);
- currentTrimStartRef.current = newTrimStart;
- currentStartTimeRef.current = newStartTime;
- currentDurationRef.current = newDuration;
- }
- }
- } else {
- const sourceDuration = getSourceDuration({
- trimStart: resizing.initialTrimStart,
- duration: resizing.initialDuration,
- trimEnd: resizing.initialTrimEnd,
- });
- const newTrimEnd =
- resizing.initialTrimEnd - getSourceDeltaForClipDelta(deltaTime);
- const maxAllowedDuration = Number.isFinite(rightNeighborBound)
- ? rightNeighborBound - resizing.initialStartTime
- : Infinity;
-
- if (newTrimEnd < 0) {
- if (canExtendElementDuration()) {
- const extensionNeeded = Math.abs(newTrimEnd);
- const baseDuration =
- resizing.initialDuration + resizing.initialTrimEnd;
- const newDuration = roundToFrame({ time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration), rate: projectFps }) ?? Math.min(baseDuration + extensionNeeded, maxAllowedDuration);
-
- setCurrentDuration(newDuration);
- setCurrentTrimEnd(0);
- currentDurationRef.current = newDuration;
- currentTrimEndRef.current = 0;
- } else {
- const unclampedDuration = getDurationForVisibleSourceSpan(
- Math.max(0, sourceDuration - resizing.initialTrimStart),
- );
- const newDuration = roundToFrame({ time: Math.min(unclampedDuration, maxAllowedDuration), rate: projectFps }) ?? Math.min(unclampedDuration, maxAllowedDuration);
-
- setCurrentDuration(newDuration);
- setCurrentTrimEnd(0);
- currentDurationRef.current = newDuration;
- currentTrimEndRef.current = 0;
- }
- } else {
- const minTrimEndForNeighbor = Number.isFinite(maxAllowedDuration)
- ? Math.max(
- 0,
- sourceDuration -
- resizing.initialTrimStart -
- getVisibleSourceSpanForDuration(maxAllowedDuration),
- )
- : 0;
- const maxTrimEnd =
- sourceDuration -
- resizing.initialTrimStart -
- getVisibleSourceSpanForDuration(minDuration);
- const clampedTrimEnd = Math.min(
- maxTrimEnd,
- Math.max(minTrimEndForNeighbor, newTrimEnd),
- );
- const finalTrimEnd = roundToFrame({ time: clampedTrimEnd, rate: projectFps }) ?? clampedTrimEnd;
- const visibleSourceSpan = Math.max(
- 0,
- sourceDuration - resizing.initialTrimStart - finalTrimEnd,
- );
- const newDuration = roundToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), rate: projectFps }) ?? getDurationForVisibleSourceSpan(visibleSourceSpan);
-
- setCurrentTrimEnd(finalTrimEnd);
- setCurrentDuration(newDuration);
- currentTrimEndRef.current = finalTrimEnd;
- currentDurationRef.current = newDuration;
- }
- }
- },
- [
- resizing,
- zoomLevel,
- snappingEnabled,
- editor,
- element.id,
- track.elements,
- onSnapPointChange,
- canExtendElementDuration,
- getDurationForVisibleSourceSpan,
- getSourceDeltaForClipDelta,
- getSourceDuration,
- getVisibleSourceSpanForDuration,
- isShiftHeldRef,
- ],
- );
-
- const handleResizeEnd = useCallback(() => {
- if (!resizing) return;
-
- const finalTrimStart = currentTrimStartRef.current;
- const finalTrimEnd = currentTrimEndRef.current;
- const finalStartTime = currentStartTimeRef.current;
- const finalDuration = currentDurationRef.current;
- const trimStartChanged = finalTrimStart !== resizing.initialTrimStart;
- const trimEndChanged = finalTrimEnd !== resizing.initialTrimEnd;
- const startTimeChanged = finalStartTime !== resizing.initialStartTime;
- const durationChanged = finalDuration !== resizing.initialDuration;
-
- if (
- trimStartChanged ||
- trimEndChanged ||
- startTimeChanged ||
- durationChanged
- ) {
- editor.timeline.updateElementTrim({
- elementId: element.id,
- trimStart: finalTrimStart,
- trimEnd: finalTrimEnd,
- startTime: startTimeChanged ? finalStartTime : undefined,
- duration: durationChanged ? finalDuration : undefined,
- });
- }
-
- setResizing(null);
- onResizeStateChange?.({ isResizing: false });
- onSnapPointChange?.(null);
- }, [
- resizing,
- editor.timeline,
- element.id,
- onResizeStateChange,
- onSnapPointChange,
- ]);
-
- useEffect(() => {
- if (!resizing) return;
-
- const handleDocumentMouseMove = ({ clientX }: MouseEvent) => {
- updateTrimFromMouseMove({ clientX });
- };
-
- const handleDocumentMouseUp = () => {
- handleResizeEnd();
- };
-
- document.addEventListener("mousemove", handleDocumentMouseMove);
- document.addEventListener("mouseup", handleDocumentMouseUp);
-
- return () => {
- document.removeEventListener("mousemove", handleDocumentMouseMove);
- document.removeEventListener("mouseup", handleDocumentMouseUp);
- };
- }, [resizing, handleResizeEnd, updateTrimFromMouseMove]);
-
- return {
- resizing,
- isResizing: resizing !== null,
- handleResizeStart,
- currentTrimStart,
- currentTrimEnd,
- currentStartTime,
- currentDuration,
- };
-}
diff --git a/apps/web/src/hooks/timeline/use-timeline-resize.ts b/apps/web/src/hooks/timeline/use-timeline-resize.ts
new file mode 100644
index 00000000..f246261a
--- /dev/null
+++ b/apps/web/src/hooks/timeline/use-timeline-resize.ts
@@ -0,0 +1,322 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import type { EditorCore } from "@/core";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
+import { TICKS_PER_SECOND } from "@/lib/wasm";
+import { useEditor } from "@/hooks/use-editor";
+import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
+import { useShiftKey } from "@/hooks/use-shift-key";
+import { useTimelineStore } from "@/stores/timeline-store";
+import {
+ computeGroupResize,
+ type GroupResizeMember,
+ type GroupResizeResult,
+ type ResizeSide,
+} from "@/lib/timeline/group-resize";
+import {
+ findSnapPoints,
+ snapToNearestPoint,
+ type SnapPoint,
+} from "@/lib/timeline/snap-utils";
+import type {
+ SceneTracks,
+ TimelineElement,
+ TimelineTrack,
+} from "@/lib/timeline";
+import { isRetimableElement } from "@/lib/timeline";
+import { registerCanceller } from "@/lib/cancel-interaction";
+
+interface ResizeInteractionState {
+ side: ResizeSide;
+ startX: number;
+ members: GroupResizeMember[];
+}
+
+export function useTimelineResize({
+ zoomLevel,
+ onSnapPointChange,
+}: {
+ zoomLevel: number;
+ onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
+}) {
+ const editor = useEditor();
+ const isShiftHeldRef = useShiftKey();
+ const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
+ const { selectedElements } = useElementSelection();
+ const [resizeState, setResizeState] = useState<ResizeInteractionState | null>(
+ null,
+ );
+ const latestResultRef = useRef<GroupResizeResult | null>(null);
+
+ const cancelResize = useCallback(() => {
+ editor.timeline.discardPreview();
+ setResizeState(null);
+ latestResultRef.current = null;
+ onSnapPointChange?.(null);
+ }, [editor.timeline, onSnapPointChange]);
+
+ useEffect(() => {
+ if (!resizeState) {
+ return;
+ }
+
+ return registerCanceller({ fn: cancelResize });
+ }, [resizeState, cancelResize]);
+
+ const handleResizeStart = useCallback(
+ ({
+ event,
+ element,
+ track,
+ side,
+ }: {
+ event: React.MouseEvent;
+ element: TimelineElement;
+ track: TimelineTrack;
+ side: ResizeSide;
+ }) => {
+ event.stopPropagation();
+ event.preventDefault();
+
+ const elementRef = {
+ trackId: track.id,
+ elementId: element.id,
+ };
+ const activeSelection = selectedElements.some(
+ (selectedElement) =>
+ selectedElement.trackId === track.id &&
+ selectedElement.elementId === element.id,
+ )
+ ? selectedElements
+ : [elementRef];
+ const members = buildResizeMembers({
+ tracks: editor.scenes.getActiveScene().tracks,
+ selectedElements: activeSelection,
+ });
+ if (members.length === 0) {
+ return;
+ }
+
+ editor.timeline.discardPreview();
+ latestResultRef.current = null;
+ setResizeState({
+ side,
+ startX: event.clientX,
+ members,
+ });
+ },
+ [selectedElements, editor],
+ );
+
+ useEffect(() => {
+ if (!resizeState) {
+ return;
+ }
+
+ const handleMouseMove = ({ clientX }: MouseEvent) => {
+ const deltaX = clientX - resizeState.startX;
+ const rawDeltaTime = Math.round(
+ (deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel)) *
+ TICKS_PER_SECOND,
+ );
+ const snappedDeltaTime = getSnappedResizeDelta({
+ editor,
+ resizeState,
+ rawDeltaTime,
+ zoomLevel,
+ shouldSnap: snappingEnabled && !isShiftHeldRef.current,
+ onSnapPointChange,
+ });
+ const fps = editor.project.getActive().settings.fps;
+ const result = computeGroupResize({
+ members: resizeState.members,
+ side: resizeState.side,
+ deltaTime: snappedDeltaTime.deltaTime,
+ fps,
+ });
+
+ latestResultRef.current = result;
+ editor.timeline.previewElements({
+ updates: result.updates.map(({ trackId, elementId, patch }) => ({
+ trackId,
+ elementId,
+ updates: patch,
+ })),
+ });
+ };
+
+ const handleMouseUp = () => {
+ const result = latestResultRef.current;
+ editor.timeline.discardPreview();
+ if (
+ result &&
+ hasResizeChanges({ members: resizeState.members, result })
+ ) {
+ editor.timeline.updateElements({
+ updates: result.updates.map(({ trackId, elementId, patch }) => ({
+ trackId,
+ elementId,
+ patch,
+ })),
+ });
+ }
+
+ setResizeState(null);
+ latestResultRef.current = null;
+ onSnapPointChange?.(null);
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+ return () => {
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ };
+ }, [
+ resizeState,
+ zoomLevel,
+ snappingEnabled,
+ isShiftHeldRef,
+ editor,
+ onSnapPointChange,
+ ]);
+
+ return {
+ isResizing: resizeState !== null,
+ handleResizeStart,
+ };
+}
+
+function buildResizeMembers({
+ tracks,
+ selectedElements,
+}: {
+ tracks: SceneTracks;
+ selectedElements: Array<{ trackId: string; elementId: string }>;
+}): GroupResizeMember[] {
+ const selectedElementIds = new Set(
+ selectedElements.map((selectedElement) => selectedElement.elementId),
+ );
+ const trackMap = new Map(
+ [...tracks.overlay, tracks.main, ...tracks.audio].map((track) => [
+ track.id,
+ track,
+ ]),
+ );
+
+ return selectedElements.flatMap(({ trackId, elementId }) => {
+ const track = trackMap.get(trackId);
+ const element = track?.elements.find(
+ (trackElement) => trackElement.id === elementId,
+ );
+ if (!track || !element) {
+ return [];
+ }
+
+ const otherElements = track.elements.filter(
+ (trackElement) => !selectedElementIds.has(trackElement.id),
+ );
+ const leftNeighborBound = otherElements
+ .filter(
+ (trackElement) =>
+ trackElement.startTime + trackElement.duration <= element.startTime,
+ )
+ .reduce((bound, trackElement) => {
+ return Math.max(bound, trackElement.startTime + trackElement.duration);
+ }, -Infinity);
+ const rightNeighborBound = otherElements
+ .filter(
+ (trackElement) =>
+ trackElement.startTime >= element.startTime + element.duration,
+ )
+ .reduce((bound, trackElement) => {
+ return Math.min(bound, trackElement.startTime);
+ }, Infinity);
+
+ return [
+ {
+ trackId,
+ elementId,
+ startTime: element.startTime,
+ duration: element.duration,
+ trimStart: element.trimStart,
+ trimEnd: element.trimEnd,
+ sourceDuration: element.sourceDuration,
+ retime: isRetimableElement(element) ? element.retime : undefined,
+ leftNeighborBound,
+ rightNeighborBound,
+ },
+ ];
+ });
+}
+
+function getSnappedResizeDelta({
+ editor,
+ resizeState,
+ rawDeltaTime,
+ zoomLevel,
+ shouldSnap,
+ onSnapPointChange,
+}: {
+ editor: EditorCore;
+ resizeState: ResizeInteractionState;
+ rawDeltaTime: number;
+ zoomLevel: number;
+ shouldSnap: boolean;
+ onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
+}): { deltaTime: number } {
+ if (!shouldSnap) {
+ onSnapPointChange?.(null);
+ return { deltaTime: rawDeltaTime };
+ }
+
+ const snapPoints = findSnapPoints({
+ tracks: editor.scenes.getActiveScene().tracks,
+ playheadTime: editor.playback.getCurrentTime(),
+ excludeElementIds: new Set(
+ resizeState.members.map((member) => member.elementId),
+ ),
+ });
+ let closestSnapPoint: SnapPoint | null = null;
+ let closestSnapDistance = Infinity;
+ let deltaTime = rawDeltaTime;
+
+ for (const member of resizeState.members) {
+ const baseEdgeTime =
+ resizeState.side === "left"
+ ? member.startTime
+ : member.startTime + member.duration;
+ const snapResult = snapToNearestPoint({
+ targetTime: baseEdgeTime + rawDeltaTime,
+ snapPoints,
+ zoomLevel,
+ });
+ if (snapResult.snapPoint && snapResult.snapDistance < closestSnapDistance) {
+ closestSnapDistance = snapResult.snapDistance;
+ closestSnapPoint = snapResult.snapPoint;
+ deltaTime = snapResult.snappedTime - baseEdgeTime;
+ }
+ }
+
+ onSnapPointChange?.(closestSnapPoint);
+ return { deltaTime };
+}
+
+function hasResizeChanges({
+ members,
+ result,
+}: {
+ members: GroupResizeMember[];
+ result: GroupResizeResult;
+}): boolean {
+ return result.updates.some((update) => {
+ const member = members.find(
+ (candidateMember) => candidateMember.elementId === update.elementId,
+ );
+ return (
+ member?.trimStart !== update.patch.trimStart ||
+ member?.trimEnd !== update.patch.trimEnd ||
+ member?.startTime !== update.patch.startTime ||
+ member?.duration !== update.patch.duration
+ );
+ });
+}
diff --git a/apps/web/src/lib/commands/timeline/element/move-elements.ts b/apps/web/src/lib/commands/timeline/element/move-elements.ts
index c1fa6897..533c85b4 100644
--- a/apps/web/src/lib/commands/timeline/element/move-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/move-elements.ts
@@ -3,140 +3,123 @@ import { EditorCore } from "@/core";
import type {
SceneTracks,
TimelineElement,
- TrackType,
TimelineTrack,
} from "@/lib/timeline";
import {
buildEmptyTrack,
validateElementTrackCompatibility,
- enforceMainTrackStart,
} from "@/lib/timeline/placement";
-import {
- findTrackInSceneTracks,
- updateTrackInSceneTracks,
-} from "@/lib/timeline/track-element-update";
+import type {
+ PlannedElementMove,
+ PlannedTrackCreation,
+} from "@/lib/timeline/group-move";
+import { findTrackInSceneTracks } from "@/lib/timeline/track-element-update";
export class MoveElementCommand extends Command {
private savedState: SceneTracks | null = null;
- private readonly sourceTrackId: string;
- private readonly targetTrackId: string;
- private readonly elementId: string;
- private readonly newStartTime: number;
- private readonly createTrack: { type: TrackType; index: number } | undefined;
constructor({
- sourceTrackId,
- targetTrackId,
- elementId,
- newStartTime,
- createTrack,
+ moves,
+ createTracks = [],
}: {
- sourceTrackId: string;
- targetTrackId: string;
- elementId: string;
- newStartTime: number;
- createTrack?: { type: TrackType; index: number };
+ moves: PlannedElementMove[];
+ createTracks?: PlannedTrackCreation[];
}) {
super();
- this.sourceTrackId = sourceTrackId;
- this.targetTrackId = targetTrackId;
- this.elementId = elementId;
- this.newStartTime = newStartTime;
- this.createTrack = createTrack;
+ this.moves = moves;
+ this.createTracks = createTracks;
}
+ private readonly moves: PlannedElementMove[];
+ private readonly createTracks: PlannedTrackCreation[];
+
execute(): CommandResult | undefined {
const editor = EditorCore.getInstance();
this.savedState = editor.scenes.getActiveScene().tracks;
- const sourceTrack = findTrackInSceneTracks({
- tracks: this.savedState,
- trackId: this.sourceTrackId,
- });
- const element = sourceTrack?.elements.find(
- (trackElement) => trackElement.id === this.elementId,
- );
-
- if (!sourceTrack || !element) {
- throw new Error("Source track or element not found");
- }
-
- let targetTrack = findTrackInSceneTracks({
- tracks: this.savedState,
- trackId: this.targetTrackId,
- });
let tracksToUpdate = this.savedState;
- if (!targetTrack && this.createTrack) {
- const newTrack = buildEmptyTrack({
- id: this.targetTrackId,
- type: this.createTrack.type,
- });
+ for (const createTrack of [...this.createTracks].sort(
+ (firstTrack, secondTrack) => firstTrack.index - secondTrack.index,
+ )) {
tracksToUpdate = insertTrackAtDisplayIndex({
- tracks: this.savedState,
- track: newTrack,
- insertIndex: this.createTrack.index,
+ tracks: tracksToUpdate,
+ track: buildEmptyTrack({
+ id: createTrack.id,
+ type: createTrack.type,
+ }),
+ insertIndex: createTrack.index,
});
- targetTrack = newTrack;
}
- if (!targetTrack) {
- throw new Error("Target track not found");
+
+ const movedElementsById = new Map<string, TimelineElement>();
+ for (const move of this.moves) {
+ const sourceTrack = findTrackInSceneTracks({
+ tracks: this.savedState,
+ trackId: move.sourceTrackId,
+ });
+ const sourceElement = sourceTrack?.elements.find(
+ (trackElement) => trackElement.id === move.elementId,
+ );
+ if (!sourceTrack || !sourceElement) {
+ throw new Error("Source track or element not found");
+ }
+
+ const targetTrack = findTrackInSceneTracks({
+ tracks: tracksToUpdate,
+ trackId: move.targetTrackId,
+ });
+ if (!targetTrack) {
+ throw new Error("Target track not found");
+ }
+
+ const validation = validateElementTrackCompatibility({
+ element: sourceElement,
+ track: targetTrack,
+ });
+ if (!validation.isValid) {
+ throw new Error(validation.errorMessage);
+ }
+
+ movedElementsById.set(move.elementId, {
+ ...sourceElement,
+ startTime: move.newStartTime,
+ });
}
- const validation = validateElementTrackCompatibility({
- element,
- track: targetTrack,
- });
+ const movedElementIds = new Set(this.moves.map((move) => move.elementId));
+ const movedElementsByTargetTrackId = new Map<string, TimelineElement[]>();
+ for (const move of this.moves) {
+ const movedElement = movedElementsById.get(move.elementId);
+ if (!movedElement) {
+ continue;
+ }
- if (!validation.isValid) {
- throw new Error(validation.errorMessage);
+ const nextTargetElements =
+ movedElementsByTargetTrackId.get(move.targetTrackId) ?? [];
+ nextTargetElements.push(movedElement);
+ movedElementsByTargetTrackId.set(move.targetTrackId, nextTargetElements);
}
- const adjustedStartTime = enforceMainTrackStart({
+ const updatedTracks = mapSceneTracks({
tracks: tracksToUpdate,
- targetTrackId: this.targetTrackId,
- requestedStartTime: this.newStartTime,
- excludeElementId: this.elementId,
+ update: (track) => ({
+ ...track,
+ elements: [
+ ...track.elements.filter(
+ (element) => !movedElementIds.has(element.id),
+ ),
+ ...(movedElementsByTargetTrackId.get(track.id) ?? []),
+ ],
+ }),
});
- // keyframe times remain clip-local, so moving only changes element startTime.
- const movedElement: TimelineElement = {
- ...element,
- startTime: adjustedStartTime,
- };
-
- const isSameTrack = this.sourceTrackId === this.targetTrackId;
-
- const updatedTracks = isSameTrack
- ? updateTrackInSceneTracks({
- tracks: tracksToUpdate,
- trackId: this.sourceTrackId,
- update: (track) => ({
- ...track,
- elements: track.elements.map((trackElement) =>
- trackElement.id === this.elementId ? movedElement : trackElement,
- ),
- }),
- })
- : updateTrackInSceneTracks({
- tracks: updateTrackInSceneTracks({
- tracks: tracksToUpdate,
- trackId: this.sourceTrackId,
- update: (track) => ({
- ...track,
- elements: track.elements.filter(
- (trackElement) => trackElement.id !== this.elementId,
- ),
- }),
- }),
- trackId: this.targetTrackId,
- update: (track) => ({
- ...track,
- elements: [...track.elements, movedElement],
- }),
- });
-
editor.timeline.updateTracks(updatedTracks);
- return undefined;
+ return {
+ select: this.moves.map(({ elementId, targetTrackId }) => ({
+ trackId: targetTrackId,
+ elementId,
+ })),
+ };
}
undo(): void {
@@ -147,6 +130,20 @@ export class MoveElementCommand extends Command {
}
}
+function mapSceneTracks({
+ tracks,
+ update,
+}: {
+ tracks: SceneTracks;
+ update: <TTrack extends TimelineTrack>(track: TTrack) => TTrack;
+}): SceneTracks {
+ return {
+ overlay: tracks.overlay.map((track) => update(track)),
+ main: update(tracks.main),
+ audio: tracks.audio.map((track) => update(track)),
+ };
+}
+
function insertTrackAtDisplayIndex({
tracks,
track,
diff --git a/apps/web/src/lib/timeline/group-move/__tests__/group-move.test.ts b/apps/web/src/lib/timeline/group-move/__tests__/group-move.test.ts
new file mode 100644
index 00000000..e6c86e41
--- /dev/null
+++ b/apps/web/src/lib/timeline/group-move/__tests__/group-move.test.ts
@@ -0,0 +1,287 @@
+import { describe, expect, test } from "bun:test";
+import type {
+ AudioTrack,
+ OverlayTrack,
+ SceneTracks,
+ TextElement,
+ TextTrack,
+ VideoElement,
+ VideoTrack,
+} from "@/lib/timeline/types";
+import type { Transform } from "@/lib/rendering";
+import { buildMoveGroup } from "@/lib/timeline/group-move/build-group";
+import { resolveGroupMove } from "@/lib/timeline/group-move/resolve-move";
+
+function buildTransform(): Transform {
+ return {
+ scaleX: 1,
+ scaleY: 1,
+ position: { x: 0, y: 0 },
+ rotate: 0,
+ };
+}
+
+function buildVideoElement({
+ id,
+ startTime,
+ duration,
+}: {
+ id: string;
+ startTime: number;
+ duration: number;
+}): VideoElement {
+ return {
+ id,
+ type: "video",
+ name: id,
+ startTime,
+ duration,
+ trimStart: 0,
+ trimEnd: 0,
+ mediaId: `${id}-media`,
+ transform: buildTransform(),
+ opacity: 1,
+ };
+}
+
+function buildTextElement({
+ id,
+ startTime,
+ duration,
+}: {
+ id: string;
+ startTime: number;
+ duration: number;
+}): TextElement {
+ return {
+ id,
+ type: "text",
+ name: id,
+ startTime,
+ duration,
+ trimStart: 0,
+ trimEnd: 0,
+ content: id,
+ fontSize: 32,
+ fontFamily: "Inter",
+ color: "#ffffff",
+ background: {
+ enabled: false,
+ color: "#000000",
+ },
+ textAlign: "left",
+ fontWeight: "normal",
+ fontStyle: "normal",
+ textDecoration: "none",
+ transform: buildTransform(),
+ opacity: 1,
+ };
+}
+
+function buildVideoTrack({
+ id,
+ elements = [],
+}: {
+ id: string;
+ elements?: VideoTrack["elements"];
+}): VideoTrack {
+ return {
+ id,
+ type: "video",
+ name: id,
+ elements,
+ muted: false,
+ hidden: false,
+ };
+}
+
+function buildTextTrack({
+ id,
+ elements = [],
+}: {
+ id: string;
+ elements?: TextTrack["elements"];
+}): TextTrack {
+ return {
+ id,
+ type: "text",
+ name: id,
+ elements,
+ hidden: false,
+ };
+}
+
+function buildTracks({
+ overlay,
+ main,
+ audio = [],
+}: {
+ overlay: OverlayTrack[];
+ main: VideoTrack;
+ audio?: AudioTrack[];
+}): SceneTracks {
+ return {
+ overlay,
+ main,
+ audio,
+ };
+}
+
+describe("group move", () => {
+ test("buildMoveGroup keeps rigid time offsets", () => {
+ const tracks = buildTracks({
+ overlay: [
+ buildVideoTrack({
+ id: "overlay-video",
+ elements: [
+ buildVideoElement({ id: "video-1", startTime: 5, duration: 4 }),
+ ],
+ }),
+ buildTextTrack({
+ id: "overlay-text",
+ elements: [
+ buildTextElement({ id: "text-1", startTime: 8, duration: 3 }),
+ ],
+ }),
+ ],
+ main: buildVideoTrack({ id: "main" }),
+ });
+
+ const group = buildMoveGroup({
+ anchorRef: { trackId: "overlay-video", elementId: "video-1" },
+ selectedElements: [
+ { trackId: "overlay-video", elementId: "video-1" },
+ { trackId: "overlay-text", elementId: "text-1" },
+ ],
+ tracks,
+ });
+
+ expect(group).not.toBeNull();
+ expect(group?.members.map((member) => member.timeOffset)).toEqual([0, 3]);
+ });
+
+ test("resolveGroupMove preserves order and clamps rigidly at time zero", () => {
+ const tracks = buildTracks({
+ overlay: [
+ buildTextTrack({
+ id: "overlay-text",
+ elements: [
+ buildTextElement({ id: "text-1", startTime: 0, duration: 3 }),
+ ],
+ }),
+ buildVideoTrack({
+ id: "overlay-video",
+ elements: [
+ buildVideoElement({ id: "video-1", startTime: 5, duration: 4 }),
+ ],
+ }),
+ ],
+ main: buildVideoTrack({ id: "main" }),
+ });
+ const group = buildMoveGroup({
+ anchorRef: { trackId: "overlay-video", elementId: "video-1" },
+ selectedElements: [
+ { trackId: "overlay-video", elementId: "video-1" },
+ { trackId: "overlay-text", elementId: "text-1" },
+ ],
+ tracks,
+ });
+ if (!group) {
+ throw new Error("Expected group");
+ }
+
+ const result = resolveGroupMove({
+ group,
+ tracks,
+ anchorStartTime: 7,
+ target: {
+ kind: "existingTrack",
+ anchorTargetTrackId: "main",
+ },
+ });
+
+ expect(result?.moves).toEqual([
+ {
+ sourceTrackId: "overlay-video",
+ elementId: "video-1",
+ targetTrackId: "main",
+ newStartTime: 5,
+ },
+ {
+ sourceTrackId: "overlay-text",
+ elementId: "text-1",
+ targetTrackId: "overlay-text",
+ newStartTime: 0,
+ },
+ ]);
+ });
+
+ test("resolveGroupMove falls back to new overlay tracks when the group cannot cross main", () => {
+ const tracks = buildTracks({
+ overlay: [
+ buildVideoTrack({
+ id: "overlay-video",
+ elements: [
+ buildVideoElement({ id: "video-1", startTime: 5, duration: 4 }),
+ ],
+ }),
+ buildTextTrack({
+ id: "overlay-text",
+ elements: [
+ buildTextElement({ id: "text-1", startTime: 5, duration: 3 }),
+ ],
+ }),
+ ],
+ main: buildVideoTrack({ id: "main" }),
+ });
+ const group = buildMoveGroup({
+ anchorRef: { trackId: "overlay-video", elementId: "video-1" },
+ selectedElements: [
+ { trackId: "overlay-video", elementId: "video-1" },
+ { trackId: "overlay-text", elementId: "text-1" },
+ ],
+ tracks,
+ });
+ if (!group) {
+ throw new Error("Expected group");
+ }
+
+ const result = resolveGroupMove({
+ group,
+ tracks,
+ anchorStartTime: 6,
+ target: {
+ kind: "newTracks",
+ anchorInsertIndex: 2,
+ newTrackIds: ["new-video-track", "new-text-track"],
+ },
+ });
+
+ expect(result?.createTracks).toEqual([
+ {
+ id: "new-video-track",
+ type: "video",
+ index: 2,
+ },
+ {
+ id: "new-text-track",
+ type: "text",
+ index: 3,
+ },
+ ]);
+ expect(result?.moves).toEqual([
+ {
+ sourceTrackId: "overlay-video",
+ elementId: "video-1",
+ targetTrackId: "new-video-track",
+ newStartTime: 6,
+ },
+ {
+ sourceTrackId: "overlay-text",
+ elementId: "text-1",
+ targetTrackId: "new-text-track",
+ newStartTime: 6,
+ },
+ ]);
+ });
+});
diff --git a/apps/web/src/lib/timeline/group-move/build-group.ts b/apps/web/src/lib/timeline/group-move/build-group.ts
new file mode 100644
index 00000000..16d720eb
--- /dev/null
+++ b/apps/web/src/lib/timeline/group-move/build-group.ts
@@ -0,0 +1,87 @@
+import type { ElementRef, SceneTracks } from "@/lib/timeline";
+import { findTrackInSceneTracks } from "@/lib/timeline/track-element-update";
+import type { GroupMember, MoveGroup } from "./types";
+import { getTrackPlacementById } from "./track-placement";
+
+export function buildMoveGroup({
+ anchorRef,
+ selectedElements,
+ tracks,
+}: {
+ anchorRef: ElementRef;
+ selectedElements: ElementRef[];
+ tracks: SceneTracks;
+}): MoveGroup | null {
+ const anchorTrack = findTrackInSceneTracks({
+ tracks,
+ trackId: anchorRef.trackId,
+ });
+ const anchorElement = anchorTrack?.elements.find(
+ (element) => element.id === anchorRef.elementId,
+ );
+ const anchorPlacement = getTrackPlacementById({
+ tracks,
+ trackId: anchorRef.trackId,
+ });
+ if (!anchorTrack || !anchorElement || !anchorPlacement) {
+ return null;
+ }
+
+ const seen = new Set<string>();
+ const orderedRefs = [anchorRef, ...selectedElements].filter((elementRef) => {
+ if (seen.has(elementRef.elementId)) {
+ return false;
+ }
+
+ seen.add(elementRef.elementId);
+ return true;
+ });
+
+ const members = orderedRefs.flatMap((elementRef): GroupMember[] => {
+ const track = findTrackInSceneTracks({
+ tracks,
+ trackId: elementRef.trackId,
+ });
+ const element = track?.elements.find(
+ (trackElement) => trackElement.id === elementRef.elementId,
+ );
+ const placement = getTrackPlacementById({
+ tracks,
+ trackId: elementRef.trackId,
+ });
+ if (!track || !element || !placement) {
+ return [];
+ }
+
+ return [
+ {
+ trackId: track.id,
+ elementId: element.id,
+ elementType: element.type,
+ duration: element.duration,
+ timeOffset: element.startTime - anchorElement.startTime,
+ trackSection: placement.section,
+ sectionIndex: placement.sectionIndex,
+ displayIndex: placement.displayIndex,
+ },
+ ];
+ });
+
+ if (members.length === 0) {
+ return null;
+ }
+
+ const anchor = members.find(
+ (member) =>
+ member.trackId === anchorRef.trackId &&
+ member.elementId === anchorRef.elementId,
+ );
+ if (!anchor) {
+ return null;
+ }
+
+ return {
+ anchor,
+ members,
+ };
+}
diff --git a/apps/web/src/lib/timeline/group-move/index.ts b/apps/web/src/lib/timeline/group-move/index.ts
new file mode 100644
index 00000000..82bd3895
--- /dev/null
+++ b/apps/web/src/lib/timeline/group-move/index.ts
@@ -0,0 +1,12 @@
+export { buildMoveGroup } from "./build-group";
+export { resolveGroupMove } from "./resolve-move";
+export { snapGroupEdges } from "./snap";
+
+export type {
+ GroupMember,
+ GroupMoveResult,
+ GroupTrackSection,
+ MoveGroup,
+ PlannedElementMove,
+ PlannedTrackCreation,
+} from "./types";
diff --git a/apps/web/src/lib/timeline/group-move/resolve-move.ts b/apps/web/src/lib/timeline/group-move/resolve-move.ts
new file mode 100644
index 00000000..fbe4cbda
--- /dev/null
+++ b/apps/web/src/lib/timeline/group-move/resolve-move.ts
@@ -0,0 +1,470 @@
+import type { SceneTracks } from "@/lib/timeline";
+import { getTrackTypeForElementType } from "@/lib/timeline/placement/compatibility";
+import { canPlaceTimeSpansOnTrack } from "@/lib/timeline/placement/overlap";
+import type {
+ GroupMoveResult,
+ MoveGroup,
+ PlannedElementMove,
+ PlannedTrackCreation,
+} from "./types";
+import {
+ getDisplayTracks,
+ getTrackPlacementByDisplayIndex,
+ getTrackPlacementById,
+} from "./track-placement";
+
+type GroupMoveTarget =
+ | {
+ kind: "existingTrack";
+ anchorTargetTrackId: string;
+ }
+ | {
+ kind: "newTracks";
+ anchorInsertIndex: number;
+ newTrackIds: string[];
+ };
+
+export function resolveGroupMove({
+ group,
+ tracks,
+ anchorStartTime,
+ target,
+}: {
+ group: MoveGroup;
+ tracks: SceneTracks;
+ anchorStartTime: number;
+ target: GroupMoveTarget;
+}): GroupMoveResult | null {
+ if (target.kind === "newTracks") {
+ return resolveNewTrackMove({
+ group,
+ tracks,
+ anchorStartTime,
+ anchorInsertIndex: target.anchorInsertIndex,
+ newTrackIds: target.newTrackIds,
+ });
+ }
+
+ return resolveExistingTrackMove({
+ group,
+ tracks,
+ anchorStartTime,
+ anchorTargetTrackId: target.anchorTargetTrackId,
+ });
+}
+
+function resolveExistingTrackMove({
+ group,
+ tracks,
+ anchorStartTime,
+ anchorTargetTrackId,
+}: {
+ group: MoveGroup;
+ tracks: SceneTracks;
+ anchorStartTime: number;
+ anchorTargetTrackId: string;
+}): GroupMoveResult | null {
+ const anchorTargetPlacement = getTrackPlacementById({
+ tracks,
+ trackId: anchorTargetTrackId,
+ });
+ if (!anchorTargetPlacement) {
+ return null;
+ }
+
+ const targetTrackIdsByElementId = resolveExistingTrackIdsByElementId({
+ group,
+ tracks,
+ anchorTargetDisplayIndex: anchorTargetPlacement.displayIndex,
+ });
+ if (!targetTrackIdsByElementId) {
+ return null;
+ }
+
+ const clampedAnchorStartTime = clampAnchorStartTime({
+ group,
+ tracks,
+ anchorStartTime,
+ targetTrackIdsByElementId,
+ });
+
+ const moves = group.members.map((member) => ({
+ sourceTrackId: member.trackId,
+ targetTrackId:
+ targetTrackIdsByElementId.get(member.elementId) ?? member.trackId,
+ elementId: member.elementId,
+ newStartTime: clampedAnchorStartTime + member.timeOffset,
+ }));
+
+ if (!canApplyMovesToExistingTracks({ tracks, moves })) {
+ return null;
+ }
+
+ return {
+ moves,
+ createTracks: [],
+ targetSelection: moves.map(({ elementId, targetTrackId }) => ({
+ trackId: targetTrackId,
+ elementId,
+ })),
+ };
+}
+
+function resolveNewTrackMove({
+ group,
+ tracks,
+ anchorStartTime,
+ anchorInsertIndex,
+ newTrackIds,
+}: {
+ group: MoveGroup;
+ tracks: SceneTracks;
+ anchorStartTime: number;
+ anchorInsertIndex: number;
+ newTrackIds: string[];
+}): GroupMoveResult | null {
+ const sortedMembers = [...group.members].sort(
+ (leftMember, rightMember) =>
+ leftMember.displayIndex - rightMember.displayIndex,
+ );
+ const anchorMemberIndex = sortedMembers.findIndex(
+ (member) => member.elementId === group.anchor.elementId,
+ );
+ if (anchorMemberIndex < 0 || newTrackIds.length < sortedMembers.length) {
+ return null;
+ }
+
+ const hasAudioMember = sortedMembers.some(
+ (member) => member.trackSection === "audio",
+ );
+ const hasNonAudioMember = sortedMembers.some(
+ (member) => member.trackSection !== "audio",
+ );
+ if (hasAudioMember && hasNonAudioMember) {
+ return null;
+ }
+
+ const clampedAnchorStartTime = clampAnchorStartTime({
+ group,
+ tracks,
+ anchorStartTime,
+ targetTrackIdsByElementId: new Map(),
+ });
+ const blockStartIndex = hasAudioMember
+ ? clampAudioInsertIndex({
+ tracks,
+ insertIndex: anchorInsertIndex - anchorMemberIndex,
+ })
+ : Math.max(
+ 0,
+ Math.min(anchorInsertIndex - anchorMemberIndex, tracks.overlay.length),
+ );
+
+ const createTracks: PlannedTrackCreation[] = sortedMembers.map(
+ (member, memberIndex) => ({
+ id: newTrackIds[memberIndex],
+ type: getTrackTypeForElementType({
+ elementType: member.elementType,
+ }),
+ index: blockStartIndex + memberIndex,
+ }),
+ );
+ const moves = sortedMembers.map((member, memberIndex) => ({
+ sourceTrackId: member.trackId,
+ targetTrackId: newTrackIds[memberIndex],
+ elementId: member.elementId,
+ newStartTime: clampedAnchorStartTime + member.timeOffset,
+ }));
+
+ return {
+ moves,
+ createTracks,
+ targetSelection: moves.map(({ elementId, targetTrackId }) => ({
+ trackId: targetTrackId,
+ elementId,
+ })),
+ };
+}
+
+function clampAudioInsertIndex({
+ tracks,
+ insertIndex,
+}: {
+ tracks: SceneTracks;
+ insertIndex: number;
+}): number {
+ const minimumAudioInsertIndex = tracks.overlay.length + 1;
+ return Math.max(
+ minimumAudioInsertIndex,
+ Math.min(insertIndex, minimumAudioInsertIndex + tracks.audio.length),
+ );
+}
+
+function resolveExistingTrackIdsByElementId({
+ group,
+ tracks,
+ anchorTargetDisplayIndex,
+}: {
+ group: MoveGroup;
+ tracks: SceneTracks;
+ anchorTargetDisplayIndex: number;
+}): Map<string, string> | null {
+ const sortedMembers = [...group.members].sort(
+ (leftMember, rightMember) =>
+ leftMember.displayIndex - rightMember.displayIndex,
+ );
+ const anchorMemberIndex = sortedMembers.findIndex(
+ (member) => member.elementId === group.anchor.elementId,
+ );
+ if (anchorMemberIndex < 0) {
+ return null;
+ }
+
+ const targetTrackIdsByElementId = new Map<string, string>();
+ const usedTrackIds = new Set<string>();
+ const anchorPlacement = getTrackPlacementByDisplayIndex({
+ tracks,
+ displayIndex: anchorTargetDisplayIndex,
+ });
+ if (!anchorPlacement) {
+ return null;
+ }
+
+ targetTrackIdsByElementId.set(
+ group.anchor.elementId,
+ anchorPlacement.trackId,
+ );
+ usedTrackIds.add(anchorPlacement.trackId);
+
+ let upperBoundaryIndex = anchorTargetDisplayIndex;
+ for (
+ let memberIndex = anchorMemberIndex - 1;
+ memberIndex >= 0;
+ memberIndex -= 1
+ ) {
+ const member = sortedMembers[memberIndex];
+ const targetPlacement = findCompatibleTrackPlacement({
+ tracks,
+ requiredTrackType: getTrackTypeForElementType({
+ elementType: member.elementType,
+ }),
+ startDisplayIndex: upperBoundaryIndex - 1,
+ step: -1,
+ usedTrackIds,
+ });
+ if (!targetPlacement) {
+ return null;
+ }
+
+ targetTrackIdsByElementId.set(member.elementId, targetPlacement.trackId);
+ usedTrackIds.add(targetPlacement.trackId);
+ upperBoundaryIndex = targetPlacement.displayIndex;
+ }
+
+ let lowerBoundaryIndex = anchorTargetDisplayIndex;
+ for (
+ let memberIndex = anchorMemberIndex + 1;
+ memberIndex < sortedMembers.length;
+ memberIndex += 1
+ ) {
+ const member = sortedMembers[memberIndex];
+ const targetPlacement = findCompatibleTrackPlacement({
+ tracks,
+ requiredTrackType: getTrackTypeForElementType({
+ elementType: member.elementType,
+ }),
+ startDisplayIndex: lowerBoundaryIndex + 1,
+ step: 1,
+ usedTrackIds,
+ });
+ if (!targetPlacement) {
+ return null;
+ }
+
+ targetTrackIdsByElementId.set(member.elementId, targetPlacement.trackId);
+ usedTrackIds.add(targetPlacement.trackId);
+ lowerBoundaryIndex = targetPlacement.displayIndex;
+ }
+
+ return targetTrackIdsByElementId;
+}
+
+function findCompatibleTrackPlacement({
+ tracks,
+ requiredTrackType,
+ startDisplayIndex,
+ step,
+ usedTrackIds,
+}: {
+ tracks: SceneTracks;
+ requiredTrackType: ReturnType<typeof getTrackTypeForElementType>;
+ startDisplayIndex: number;
+ step: -1 | 1;
+ usedTrackIds: Set<string>;
+}) {
+ for (
+ let displayIndex = startDisplayIndex;
+ displayIndex >= 0 &&
+ displayIndex < tracks.overlay.length + 1 + tracks.audio.length;
+ displayIndex += step
+ ) {
+ const placement = getTrackPlacementByDisplayIndex({
+ tracks,
+ displayIndex,
+ });
+ if (!placement) {
+ continue;
+ }
+
+ if (
+ placement.trackType === requiredTrackType &&
+ !usedTrackIds.has(placement.trackId)
+ ) {
+ return placement;
+ }
+ }
+
+ return null;
+}
+
+function clampAnchorStartTime({
+ group,
+ tracks,
+ anchorStartTime,
+ targetTrackIdsByElementId,
+}: {
+ group: MoveGroup;
+ tracks: SceneTracks;
+ anchorStartTime: number;
+ targetTrackIdsByElementId: Map<string, string>;
+}): number {
+ const minimumAnchorStartTime = Math.max(
+ 0,
+ ...group.members.map((member) => -member.timeOffset),
+ );
+ let clampedAnchorStartTime = Math.max(
+ minimumAnchorStartTime,
+ anchorStartTime,
+ );
+
+ const memberOnMainTrack = group.members.find(
+ (member) =>
+ targetTrackIdsByElementId.get(member.elementId) === tracks.main.id,
+ );
+ if (!memberOnMainTrack) {
+ return clampedAnchorStartTime;
+ }
+
+ const movingElementIds = new Set(
+ group.members.map((member) => member.elementId),
+ );
+ const requestedMainStartTime =
+ clampedAnchorStartTime + memberOnMainTrack.timeOffset;
+ const earliestStationaryMainStartTime = tracks.main.elements
+ .filter((element) => !movingElementIds.has(element.id))
+ .reduce<number | null>((earliestStartTime, element) => {
+ if (earliestStartTime == null || element.startTime < earliestStartTime) {
+ return element.startTime;
+ }
+
+ return earliestStartTime;
+ }, null);
+ if (
+ earliestStationaryMainStartTime == null ||
+ requestedMainStartTime <= earliestStationaryMainStartTime
+ ) {
+ clampedAnchorStartTime = Math.max(
+ minimumAnchorStartTime,
+ -memberOnMainTrack.timeOffset,
+ );
+ }
+
+ return clampedAnchorStartTime;
+}
+
+function canApplyMovesToExistingTracks({
+ tracks,
+ moves,
+}: {
+ tracks: SceneTracks;
+ moves: PlannedElementMove[];
+}): boolean {
+ const movingElementIds = new Set(moves.map((move) => move.elementId));
+ const sourceElements = new Map(
+ getDisplayTracks({ tracks }).flatMap((track) =>
+ track.elements.map((element) => [element.id, element] as const),
+ ),
+ );
+ const movesByTargetTrackId = new Map<string, PlannedElementMove[]>();
+ for (const move of moves) {
+ const targetMoves = movesByTargetTrackId.get(move.targetTrackId) ?? [];
+ targetMoves.push(move);
+ movesByTargetTrackId.set(move.targetTrackId, targetMoves);
+ }
+
+ for (const [targetTrackId, targetMoves] of movesByTargetTrackId) {
+ const targetPlacement = getTrackPlacementById({
+ tracks,
+ trackId: targetTrackId,
+ });
+ if (!targetPlacement) {
+ return false;
+ }
+
+ const targetTrack = getDisplayTracks({ tracks })[
+ targetPlacement.displayIndex
+ ];
+ if (!targetTrack) {
+ return false;
+ }
+
+ const timeSpans = targetMoves.map((move) => {
+ const sourceElement = sourceElements.get(move.elementId);
+ return {
+ startTime: move.newStartTime,
+ duration: sourceElement?.duration ?? 0,
+ };
+ });
+ if (hasOverlappingTimeSpans({ timeSpans })) {
+ return false;
+ }
+
+ if (
+ !canPlaceTimeSpansOnTrack({
+ track: {
+ elements: targetTrack.elements.filter(
+ (element) => !movingElementIds.has(element.id),
+ ),
+ },
+ timeSpans,
+ })
+ ) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+function hasOverlappingTimeSpans({
+ timeSpans,
+}: {
+ timeSpans: Array<{ startTime: number; duration: number }>;
+}): boolean {
+ const sortedSpans = [...timeSpans].sort(
+ (leftSpan, rightSpan) => leftSpan.startTime - rightSpan.startTime,
+ );
+
+ for (let spanIndex = 1; spanIndex < sortedSpans.length; spanIndex += 1) {
+ const previousSpan = sortedSpans[spanIndex - 1];
+ const currentSpan = sortedSpans[spanIndex];
+ if (
+ previousSpan.startTime + previousSpan.duration >
+ currentSpan.startTime
+ ) {
+ return true;
+ }
+ }
+
+ return false;
+}
diff --git a/apps/web/src/lib/timeline/group-move/snap.ts b/apps/web/src/lib/timeline/group-move/snap.ts
new file mode 100644
index 00000000..3502ae30
--- /dev/null
+++ b/apps/web/src/lib/timeline/group-move/snap.ts
@@ -0,0 +1,71 @@
+import type { SceneTracks } from "@/lib/timeline";
+import {
+ findSnapPoints,
+ snapToNearestPoint,
+ type SnapPoint,
+} from "@/lib/timeline/snap-utils";
+import type { MoveGroup } from "./types";
+
+export function snapGroupEdges({
+ group,
+ anchorStartTime,
+ tracks,
+ playheadTime,
+ zoomLevel,
+}: {
+ group: MoveGroup;
+ anchorStartTime: number;
+ tracks: SceneTracks;
+ playheadTime: number;
+ zoomLevel: number;
+}): {
+ snappedAnchorStartTime: number;
+ snapPoint: SnapPoint | null;
+} {
+ const snapPoints = findSnapPoints({
+ tracks,
+ playheadTime,
+ excludeElementIds: new Set(group.members.map((member) => member.elementId)),
+ });
+
+ let closestSnapDistance = Infinity;
+ let snappedAnchorStartTime = anchorStartTime;
+ let snapPoint: SnapPoint | null = null;
+
+ for (const member of group.members) {
+ const memberStartTime = anchorStartTime + member.timeOffset;
+ const memberStartSnap = snapToNearestPoint({
+ targetTime: memberStartTime,
+ snapPoints,
+ zoomLevel,
+ });
+ if (
+ memberStartSnap.snapPoint &&
+ memberStartSnap.snapDistance < closestSnapDistance
+ ) {
+ closestSnapDistance = memberStartSnap.snapDistance;
+ snappedAnchorStartTime = memberStartSnap.snappedTime - member.timeOffset;
+ snapPoint = memberStartSnap.snapPoint;
+ }
+
+ const memberEndSnap = snapToNearestPoint({
+ targetTime: memberStartTime + member.duration,
+ snapPoints,
+ zoomLevel,
+ });
+ if (
+ memberEndSnap.snapPoint &&
+ memberEndSnap.snapDistance < closestSnapDistance
+ ) {
+ closestSnapDistance = memberEndSnap.snapDistance;
+ snappedAnchorStartTime =
+ memberEndSnap.snappedTime - member.duration - member.timeOffset;
+ snapPoint = memberEndSnap.snapPoint;
+ }
+ }
+
+ return {
+ snappedAnchorStartTime,
+ snapPoint,
+ };
+}
diff --git a/apps/web/src/lib/timeline/group-move/track-placement.ts b/apps/web/src/lib/timeline/group-move/track-placement.ts
new file mode 100644
index 00000000..d301e5ef
--- /dev/null
+++ b/apps/web/src/lib/timeline/group-move/track-placement.ts
@@ -0,0 +1,83 @@
+import type { SceneTracks, TimelineTrack } from "@/lib/timeline";
+import type { GroupTrackSection } from "./types";
+
+export interface TrackPlacement {
+ trackId: string;
+ trackType: TimelineTrack["type"];
+ section: GroupTrackSection;
+ sectionIndex: number;
+ displayIndex: number;
+}
+
+export function getDisplayTracks({
+ tracks,
+}: {
+ tracks: SceneTracks;
+}): TimelineTrack[] {
+ return [...tracks.overlay, tracks.main, ...tracks.audio];
+}
+
+export function getTrackPlacementById({
+ tracks,
+ trackId,
+}: {
+ tracks: SceneTracks;
+ trackId: string;
+}): TrackPlacement | null {
+ if (tracks.main.id === trackId) {
+ return {
+ trackId,
+ trackType: tracks.main.type,
+ section: "main",
+ sectionIndex: -1,
+ displayIndex: tracks.overlay.length,
+ };
+ }
+
+ const overlayTrackIndex = tracks.overlay.findIndex(
+ (track) => track.id === trackId,
+ );
+ if (overlayTrackIndex >= 0) {
+ return {
+ trackId,
+ trackType: tracks.overlay[overlayTrackIndex].type,
+ section: "overlay",
+ sectionIndex: overlayTrackIndex,
+ displayIndex: overlayTrackIndex,
+ };
+ }
+
+ const audioTrackIndex = tracks.audio.findIndex(
+ (track) => track.id === trackId,
+ );
+ if (audioTrackIndex >= 0) {
+ return {
+ trackId,
+ trackType: tracks.audio[audioTrackIndex].type,
+ section: "audio",
+ sectionIndex: audioTrackIndex,
+ displayIndex: tracks.overlay.length + 1 + audioTrackIndex,
+ };
+ }
+
+ return null;
+}
+
+export function getTrackPlacementByDisplayIndex({
+ tracks,
+ displayIndex,
+}: {
+ tracks: SceneTracks;
+ displayIndex: number;
+}): TrackPlacement | null {
+ const displayTracks = getDisplayTracks({ tracks });
+ const track = displayTracks[displayIndex];
+ if (!track) {
+ return null;
+ }
+
+ return getTrackPlacementById({
+ tracks,
+ trackId: track.id,
+ });
+}
diff --git a/apps/web/src/lib/timeline/group-move/types.ts b/apps/web/src/lib/timeline/group-move/types.ts
new file mode 100644
index 00000000..5ac1e207
--- /dev/null
+++ b/apps/web/src/lib/timeline/group-move/types.ts
@@ -0,0 +1,36 @@
+import type { ElementRef, ElementType, TrackType } from "@/lib/timeline";
+
+export type GroupTrackSection = "overlay" | "main" | "audio";
+
+export interface GroupMember extends ElementRef {
+ elementType: ElementType;
+ duration: number;
+ timeOffset: number;
+ trackSection: GroupTrackSection;
+ sectionIndex: number;
+ displayIndex: number;
+}
+
+export interface MoveGroup {
+ anchor: GroupMember;
+ members: GroupMember[];
+}
+
+export interface PlannedTrackCreation {
+ id: string;
+ type: TrackType;
+ index: number;
+}
+
+export interface PlannedElementMove {
+ sourceTrackId: string;
+ targetTrackId: string;
+ elementId: string;
+ newStartTime: number;
+}
+
+export interface GroupMoveResult {
+ moves: PlannedElementMove[];
+ createTracks: PlannedTrackCreation[];
+ targetSelection: ElementRef[];
+}
diff --git a/apps/web/src/lib/timeline/group-resize/compute-resize.ts b/apps/web/src/lib/timeline/group-resize/compute-resize.ts
new file mode 100644
index 00000000..8d7d9b2d
--- /dev/null
+++ b/apps/web/src/lib/timeline/group-resize/compute-resize.ts
@@ -0,0 +1,290 @@
+import { roundToFrame } from "opencut-wasm";
+import {
+ getSourceSpanAtClipTime,
+ getTimelineDurationForSourceSpan,
+} from "@/lib/retime";
+import { TICKS_PER_SECOND } from "@/lib/wasm";
+import type {
+ ComputeGroupResizeArgs,
+ GroupResizeMember,
+ GroupResizeResult,
+ GroupResizeUpdate,
+ ResizeSide,
+} from "./types";
+
+export function computeGroupResize({
+ members,
+ side,
+ deltaTime,
+ fps,
+}: ComputeGroupResizeArgs): GroupResizeResult {
+ const minDuration = Math.round(
+ (TICKS_PER_SECOND * fps.denominator) / fps.numerator,
+ );
+ const minimumDeltaTime = Math.max(
+ ...members.map((member) =>
+ getMinimumAllowedDeltaTime({
+ member,
+ side,
+ minDuration,
+ }),
+ ),
+ );
+ const maximumDeltaTime = Math.min(
+ ...members.map((member) =>
+ getMaximumAllowedDeltaTime({
+ member,
+ side,
+ minDuration,
+ }),
+ ),
+ );
+ const clampedDeltaTime =
+ minimumDeltaTime > maximumDeltaTime
+ ? minimumDeltaTime
+ : Math.min(maximumDeltaTime, Math.max(minimumDeltaTime, deltaTime));
+
+ return {
+ deltaTime: Object.is(clampedDeltaTime, -0) ? 0 : clampedDeltaTime,
+ updates: members.map((member) =>
+ buildResizeUpdate({
+ member,
+ side,
+ deltaTime: clampedDeltaTime,
+ fps,
+ }),
+ ),
+ };
+}
+
+function buildResizeUpdate({
+ member,
+ side,
+ deltaTime,
+ fps,
+}: {
+ member: GroupResizeMember;
+ side: ResizeSide;
+ deltaTime: number;
+ fps: ComputeGroupResizeArgs["fps"];
+}): GroupResizeUpdate {
+ const totalSourceDuration = getSourceDuration({ member });
+ const sourceDelta = getSourceDeltaForClipDelta({
+ member,
+ clipDelta: deltaTime,
+ });
+ const visibleSourceSpan = getVisibleSourceSpanForDuration({
+ member,
+ duration: member.duration,
+ });
+
+ if (side === "left") {
+ if (deltaTime < 0 && member.sourceDuration == null) {
+ const rawStartTime = member.startTime + deltaTime;
+ const rawDuration = member.duration - deltaTime;
+ return {
+ trackId: member.trackId,
+ elementId: member.elementId,
+ patch: {
+ trimStart: Math.max(0, member.trimStart + sourceDelta),
+ trimEnd: member.trimEnd,
+ startTime:
+ roundToFrame({ time: rawStartTime, rate: fps }) ?? rawStartTime,
+ duration:
+ roundToFrame({ time: rawDuration, rate: fps }) ?? rawDuration,
+ },
+ };
+ }
+
+ const nextTrimStart = Math.max(0, member.trimStart + sourceDelta);
+ const nextVisibleSourceSpan = Math.max(
+ 0,
+ totalSourceDuration - nextTrimStart - member.trimEnd,
+ );
+ const rawDuration = getDurationForVisibleSourceSpan({
+ member,
+ sourceSpan: nextVisibleSourceSpan,
+ });
+ const nextDuration =
+ roundToFrame({ time: rawDuration, rate: fps }) ?? rawDuration;
+ const rawStartTime = member.startTime + (member.duration - nextDuration);
+ return {
+ trackId: member.trackId,
+ elementId: member.elementId,
+ patch: {
+ trimStart:
+ roundToFrame({ time: nextTrimStart, rate: fps }) ?? nextTrimStart,
+ trimEnd: member.trimEnd,
+ startTime:
+ roundToFrame({ time: rawStartTime, rate: fps }) ?? rawStartTime,
+ duration: nextDuration,
+ },
+ };
+ }
+
+ const nextVisibleSourceSpan = Math.max(0, visibleSourceSpan + sourceDelta);
+ if (deltaTime > 0 && member.sourceDuration == null) {
+ const rawDuration = member.duration + deltaTime;
+ return {
+ trackId: member.trackId,
+ elementId: member.elementId,
+ patch: {
+ trimStart: member.trimStart,
+ trimEnd: Math.max(0, member.trimEnd - sourceDelta),
+ startTime: member.startTime,
+ duration: roundToFrame({ time: rawDuration, rate: fps }) ?? rawDuration,
+ },
+ };
+ }
+
+ const nextTrimEnd = Math.max(
+ 0,
+ totalSourceDuration - member.trimStart - nextVisibleSourceSpan,
+ );
+ const rawDuration = getDurationForVisibleSourceSpan({
+ member,
+ sourceSpan: nextVisibleSourceSpan,
+ });
+ return {
+ trackId: member.trackId,
+ elementId: member.elementId,
+ patch: {
+ trimStart: member.trimStart,
+ trimEnd: roundToFrame({ time: nextTrimEnd, rate: fps }) ?? nextTrimEnd,
+ startTime: member.startTime,
+ duration: roundToFrame({ time: rawDuration, rate: fps }) ?? rawDuration,
+ },
+ };
+}
+
+function getMinimumAllowedDeltaTime({
+ member,
+ side,
+ minDuration,
+}: {
+ member: GroupResizeMember;
+ side: ResizeSide;
+ minDuration: number;
+}): number {
+ if (side === "right") {
+ return minDuration - member.duration;
+ }
+
+ const leftNeighborFloor = Number.isFinite(member.leftNeighborBound)
+ ? member.leftNeighborBound - member.startTime
+ : -member.startTime;
+ if (member.sourceDuration == null) {
+ return leftNeighborFloor;
+ }
+
+ const maximumSourceExtension =
+ getDurationForVisibleSourceSpan({
+ member,
+ sourceSpan:
+ getVisibleSourceSpanForDuration({
+ member,
+ duration: member.duration,
+ }) + member.trimStart,
+ }) - member.duration;
+ return Math.max(leftNeighborFloor, -maximumSourceExtension);
+}
+
+function getMaximumAllowedDeltaTime({
+ member,
+ side,
+ minDuration,
+}: {
+ member: GroupResizeMember;
+ side: ResizeSide;
+ minDuration: number;
+}): number {
+ if (side === "left") {
+ return member.duration - minDuration;
+ }
+
+ const rightNeighborCeiling = Number.isFinite(member.rightNeighborBound)
+ ? member.rightNeighborBound - (member.startTime + member.duration)
+ : Infinity;
+ if (member.sourceDuration == null) {
+ return rightNeighborCeiling;
+ }
+
+ const maximumVisibleSourceSpan =
+ getSourceDuration({ member }) - member.trimStart;
+ const maximumDuration = getDurationForVisibleSourceSpan({
+ member,
+ sourceSpan: maximumVisibleSourceSpan,
+ });
+ return Math.min(rightNeighborCeiling, maximumDuration - member.duration);
+}
+
+function getSourceDeltaForClipDelta({
+ member,
+ clipDelta,
+}: {
+ member: GroupResizeMember;
+ clipDelta: number;
+}): number {
+ if (!member.retime) {
+ return clipDelta;
+ }
+
+ return clipDelta >= 0
+ ? getSourceSpanAtClipTime({
+ clipTime: clipDelta,
+ retime: member.retime,
+ })
+ : -getSourceSpanAtClipTime({
+ clipTime: Math.abs(clipDelta),
+ retime: member.retime,
+ });
+}
+
+function getVisibleSourceSpanForDuration({
+ member,
+ duration,
+}: {
+ member: GroupResizeMember;
+ duration: number;
+}): number {
+ if (!member.retime) {
+ return duration;
+ }
+
+ return getSourceSpanAtClipTime({
+ clipTime: duration,
+ retime: member.retime,
+ });
+}
+
+function getDurationForVisibleSourceSpan({
+ member,
+ sourceSpan,
+}: {
+ member: GroupResizeMember;
+ sourceSpan: number;
+}): number {
+ if (!member.retime) {
+ return sourceSpan;
+ }
+
+ return getTimelineDurationForSourceSpan({
+ sourceSpan,
+ retime: member.retime,
+ });
+}
+
+function getSourceDuration({ member }: { member: GroupResizeMember }): number {
+ if (typeof member.sourceDuration === "number") {
+ return member.sourceDuration;
+ }
+
+ return (
+ member.trimStart +
+ getVisibleSourceSpanForDuration({
+ member,
+ duration: member.duration,
+ }) +
+ member.trimEnd
+ );
+}
diff --git a/apps/web/src/lib/timeline/group-resize/index.ts b/apps/web/src/lib/timeline/group-resize/index.ts
new file mode 100644
index 00000000..bc0a7b35
--- /dev/null
+++ b/apps/web/src/lib/timeline/group-resize/index.ts
@@ -0,0 +1,9 @@
+export { computeGroupResize } from "./compute-resize";
+
+export type {
+ ComputeGroupResizeArgs,
+ GroupResizeMember,
+ GroupResizeResult,
+ GroupResizeUpdate,
+ ResizeSide,
+} from "./types";
diff --git a/apps/web/src/lib/timeline/group-resize/types.ts b/apps/web/src/lib/timeline/group-resize/types.ts
new file mode 100644
index 00000000..d5f00c1b
--- /dev/null
+++ b/apps/web/src/lib/timeline/group-resize/types.ts
@@ -0,0 +1,36 @@
+import type { FrameRate } from "opencut-wasm";
+import type { ElementRef, RetimeConfig } from "@/lib/timeline/types";
+
+export type ResizeSide = "left" | "right";
+
+export interface GroupResizeMember extends ElementRef {
+ startTime: number;
+ duration: number;
+ trimStart: number;
+ trimEnd: number;
+ sourceDuration?: number;
+ retime?: RetimeConfig;
+ leftNeighborBound: number;
+ rightNeighborBound: number;
+}
+
+export interface GroupResizeUpdate extends ElementRef {
+ patch: {
+ trimStart: number;
+ trimEnd: number;
+ startTime: number;
+ duration: number;
+ };
+}
+
+export interface GroupResizeResult {
+ deltaTime: number;
+ updates: GroupResizeUpdate[];
+}
+
+export interface ComputeGroupResizeArgs {
+ members: GroupResizeMember[];
+ side: ResizeSide;
+ deltaTime: number;
+ fps: FrameRate;
+}
diff --git a/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts b/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts
index 4d39f5d8..7cadb9b7 100644
--- a/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts
+++ b/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts
@@ -329,7 +329,12 @@ describe("resolveTrackPlacement", () => {
id: "graphic-1",
type: "graphic",
elements: [
- buildElement({ id: "a", type: "graphic", startTime: 0, duration: 5 }),
+ buildElement({
+ id: "a",
+ type: "graphic",
+ startTime: 0,
+ duration: 5,
+ }),
],
}),
],
diff --git a/apps/web/src/lib/timeline/placement/overlap.ts b/apps/web/src/lib/timeline/placement/overlap.ts
index e5f95fa9..5e37c052 100644
--- a/apps/web/src/lib/timeline/placement/overlap.ts
+++ b/apps/web/src/lib/timeline/placement/overlap.ts
@@ -1,6 +1,10 @@
-import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
+import type { TimelineElement } from "@/lib/timeline";
import type { PlacementTimeSpan } from "./types";
+interface TrackWithElements {
+ elements: TimelineElement[];
+}
+
function wouldElementOverlap({
elements,
startTime,
@@ -26,7 +30,7 @@ export function canPlaceTimeSpansOnTrack({
track,
timeSpans,
}: {
- track: TimelineTrack;
+ track: TrackWithElements;
timeSpans: PlacementTimeSpan[];
}): boolean {
return timeSpans.every(({ startTime, duration, excludeElementId }) => {
diff --git a/apps/web/src/lib/timeline/snap-utils.ts b/apps/web/src/lib/timeline/snap-utils.ts
index c1768deb..b2be95a9 100644
--- a/apps/web/src/lib/timeline/snap-utils.ts
+++ b/apps/web/src/lib/timeline/snap-utils.ts
@@ -22,6 +22,7 @@ export function findSnapPoints({
tracks,
playheadTime,
excludeElementId,
+ excludeElementIds,
bookmarks = [],
excludeBookmarkTime,
enableElementSnapping = true,
@@ -32,6 +33,7 @@ export function findSnapPoints({
tracks: SceneTracks;
playheadTime: number;
excludeElementId?: string;
+ excludeElementIds?: Set<string>;
bookmarks?: Array<Bookmark>;
excludeBookmarkTime?: number;
enableElementSnapping?: boolean;
@@ -41,10 +43,14 @@ export function findSnapPoints({
}): SnapPoint[] {
const snapPoints: SnapPoint[] = [];
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
+ const blockedElementIds = new Set(excludeElementIds);
+ if (excludeElementId) {
+ blockedElementIds.add(excludeElementId);
+ }
for (const track of orderedTracks) {
for (const element of track.elements) {
- if (element.id === excludeElementId) continue;
+ if (blockedElementIds.has(element.id)) continue;
if (enableElementSnapping) {
snapPoints.push(
@@ -136,6 +142,7 @@ export function snapElementEdge({
playheadTime,
zoomLevel,
excludeElementId,
+ excludeElementIds,
snapToStart = true,
bookmarks = [],
}: {
@@ -145,6 +152,7 @@ export function snapElementEdge({
playheadTime: number;
zoomLevel: number;
excludeElementId?: string;
+ excludeElementIds?: Set<string>;
snapToStart?: boolean;
bookmarks?: Array<Bookmark>;
}): SnapResult {
@@ -152,6 +160,7 @@ export function snapElementEdge({
tracks,
playheadTime,
excludeElementId,
+ excludeElementIds,
bookmarks,
});