diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-15 05:43:29 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-15 05:43:29 +0200 |
| commit | ac43a5a73fa905cdac905bc956763b6093d6738e (patch) | |
| tree | 8d9ff8c3a9ae55bac8860215bec69cc5b6094a1c /apps/web/src/lib/commands/timeline/element | |
| parent | 6312fc5b078689bc82c52fda9b7f92ee0a21d311 (diff) | |
| download | opencut-ac43a5a73fa905cdac905bc956763b6093d6738e.tar.gz | |
feat: move/resize multiple elements
Diffstat (limited to 'apps/web/src/lib/commands/timeline/element')
| -rw-r--r-- | apps/web/src/lib/commands/timeline/element/move-elements.ts | 199 |
1 files changed, 98 insertions, 101 deletions
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, |
