summaryrefslogtreecommitdiff
path: root/apps/web/src/commands/timeline/element/move-elements.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/commands/timeline/element/move-elements.ts
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/commands/timeline/element/move-elements.ts')
-rw-r--r--apps/web/src/commands/timeline/element/move-elements.ts183
1 files changed, 183 insertions, 0 deletions
diff --git a/apps/web/src/commands/timeline/element/move-elements.ts b/apps/web/src/commands/timeline/element/move-elements.ts
new file mode 100644
index 00000000..3ed31d42
--- /dev/null
+++ b/apps/web/src/commands/timeline/element/move-elements.ts
@@ -0,0 +1,183 @@
+import {
+ Command,
+ createElementSelectionResult,
+ type CommandResult,
+} from "@/commands/base-command";
+import { EditorCore } from "@/core";
+import type { SceneTracks, TimelineElement, TimelineTrack } from "@/timeline";
+import {
+ buildEmptyTrack,
+ validateElementTrackCompatibility,
+} from "@/timeline/placement";
+import type {
+ PlannedElementMove,
+ PlannedTrackCreation,
+} from "@/timeline/group-move";
+import { findTrackInSceneTracks } from "@/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<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 movedElementIds = new Set(this.moves.map((move) => move.elementId));
+ const movedElementsByTargetTrackId = new Map<string, TimelineElement[]>();
+ for (const move of this.moves) {
+ const movedElement = movedElementsById.get(move.elementId);
+ if (!movedElement) {
+ continue;
+ }
+
+ const nextTargetElements =
+ movedElementsByTargetTrackId.get(move.targetTrackId) ?? [];
+ nextTargetElements.push(movedElement);
+ movedElementsByTargetTrackId.set(move.targetTrackId, nextTargetElements);
+ }
+
+ const updatedTracks = mapSceneTracks({
+ tracks: tracksToUpdate,
+ update: (track) => ({
+ ...track,
+ elements: [
+ ...track.elements.filter(
+ (element) => !movedElementIds.has(element.id),
+ ),
+ ...(movedElementsByTargetTrackId.get(track.id) ?? []),
+ ],
+ }),
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ return createElementSelectionResult(
+ 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: <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,
+ 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),
+ ],
+ };
+}