OpenCut/apps/web/src/lib/commands/timeline/element/move-elements.ts

146 lines
4.0 KiB
TypeScript

import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type {
TimelineTrack,
TimelineElement,
TrackType,
} from "@/lib/timeline";
import {
buildEmptyTrack,
validateElementTrackCompatibility,
enforceMainTrackStart,
} from "@/lib/timeline/placement";
import { rippleShiftElements } from "@/lib/timeline/ripple-utils";
export class MoveElementCommand extends Command {
private savedState: TimelineTrack[] | 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;
private readonly rippleEnabled: boolean;
constructor({
sourceTrackId,
targetTrackId,
elementId,
newStartTime,
createTrack,
rippleEnabled = false,
}: {
sourceTrackId: string;
targetTrackId: string;
elementId: string;
newStartTime: number;
createTrack?: { type: TrackType; index: number };
rippleEnabled?: boolean;
}) {
super();
this.sourceTrackId = sourceTrackId;
this.targetTrackId = targetTrackId;
this.elementId = elementId;
this.newStartTime = newStartTime;
this.createTrack = createTrack;
this.rippleEnabled = rippleEnabled;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const sourceTrack = this.savedState.find(
(track) => track.id === 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 = this.savedState.find((track) => track.id === this.targetTrackId);
let tracksToUpdate = this.savedState;
if (!targetTrack && this.createTrack) {
const newTrack = buildEmptyTrack({
id: this.targetTrackId,
type: this.createTrack.type,
});
tracksToUpdate = [...this.savedState];
tracksToUpdate.splice(this.createTrack.index, 0, newTrack);
targetTrack = newTrack;
}
if (!targetTrack) {
throw new Error("Target track not found");
}
const validation = validateElementTrackCompatibility({
element,
track: targetTrack,
});
if (!validation.isValid) {
throw new Error(validation.errorMessage);
}
const adjustedStartTime = enforceMainTrackStart({
tracks: tracksToUpdate,
targetTrackId: this.targetTrackId,
requestedStartTime: this.newStartTime,
excludeElementId: this.elementId,
});
// 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 = tracksToUpdate.map((track): TimelineTrack => {
if (isSameTrack && track.id === this.sourceTrackId) {
return {
...track,
elements: track.elements.map((trackElement) =>
trackElement.id === this.elementId ? movedElement : trackElement,
),
} as typeof track;
}
if (track.id === this.sourceTrackId) {
const remainingElements = track.elements.filter(
(trackElement) => trackElement.id !== this.elementId,
);
const shiftedElements = this.rippleEnabled
? rippleShiftElements({
elements: remainingElements,
afterTime: element.startTime,
shiftAmount: element.duration,
})
: remainingElements;
return { ...track, elements: shiftedElements } as typeof track;
}
if (track.id === this.targetTrackId) {
return {
...track,
elements: [...track.elements, movedElement],
} as typeof track;
}
return track;
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}