import { Command, type CommandResult } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; import type { SceneTracks, TimelineElement, TimelineTrack, } from "@/lib/timeline"; import { buildEmptyTrack, validateElementTrackCompatibility, } from "@/lib/timeline/placement"; 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; constructor({ moves, createTracks = [], }: { moves: PlannedElementMove[]; createTracks?: PlannedTrackCreation[]; }) { super(); 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; let tracksToUpdate = this.savedState; for (const createTrack of [...this.createTracks].sort( (firstTrack, secondTrack) => firstTrack.index - secondTrack.index, )) { tracksToUpdate = insertTrackAtDisplayIndex({ tracks: tracksToUpdate, track: buildEmptyTrack({ id: createTrack.id, type: createTrack.type, }), insertIndex: createTrack.index, }); } const movedElementsById = new Map(); 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 movedElementIds = new Set(this.moves.map((move) => move.elementId)); const movedElementsByTargetTrackId = new Map(); for (const move of this.moves) { const movedElement = movedElementsById.get(move.elementId); if (!movedElement) { continue; } const nextTargetElements = movedElementsByTargetTrackId.get(move.targetTrackId) ?? []; nextTargetElements.push(movedElement); movedElementsByTargetTrackId.set(move.targetTrackId, nextTargetElements); } const updatedTracks = mapSceneTracks({ tracks: tracksToUpdate, update: (track) => ({ ...track, elements: [ ...track.elements.filter( (element) => !movedElementIds.has(element.id), ), ...(movedElementsByTargetTrackId.get(track.id) ?? []), ], }), }); editor.timeline.updateTracks(updatedTracks); return { select: this.moves.map(({ elementId, targetTrackId }) => ({ trackId: targetTrackId, elementId, })), }; } undo(): void { if (this.savedState) { const editor = EditorCore.getInstance(); editor.timeline.updateTracks(this.savedState); } } } function mapSceneTracks({ tracks, update, }: { tracks: SceneTracks; update: (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, insertIndex, }: { tracks: SceneTracks; track: TimelineTrack; insertIndex: number; }): SceneTracks { if (track.type === "audio") { const audioInsertIndex = Math.max( 0, Math.min(insertIndex - tracks.overlay.length - 1, tracks.audio.length), ); return { ...tracks, audio: [ ...tracks.audio.slice(0, audioInsertIndex), track, ...tracks.audio.slice(audioInsertIndex), ], }; } const overlayInsertIndex = Math.max( 0, Math.min(insertIndex, tracks.overlay.length), ); return { ...tracks, overlay: [ ...tracks.overlay.slice(0, overlayInsertIndex), track, ...tracks.overlay.slice(overlayInsertIndex), ], }; }