summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-03-31 23:40:59 +0200
committerMaze Winther <mazewinther@gmail.com>2026-03-31 23:40:59 +0200
commitb048b739bd598f9d9ff97166f29bcb6e37c40607 (patch)
tree3aaa2c11331b6b65a40142b0294b2ceb340e6531
parent608dc1a62fdb133e218f45796cf936bdfb27c7ae (diff)
parentdc82826951c03cca91ea1431b0b8abdfd9dba583 (diff)
downloadopencut-b048b739bd598f9d9ff97166f29bcb6e37c40607.tar.gz
Merge branch 'refactor-selection-commands'
-rw-r--r--apps/web/src/core/index.ts116
-rw-r--r--apps/web/src/core/managers/commands.ts13
-rw-r--r--apps/web/src/lib/commands/timeline/clipboard/paste.ts368
-rw-r--r--apps/web/src/lib/commands/timeline/element/delete-elements.ts165
-rw-r--r--apps/web/src/lib/commands/timeline/element/duplicate-elements.ts254
-rw-r--r--apps/web/src/lib/commands/timeline/element/move-elements.ts18
6 files changed, 469 insertions, 465 deletions
diff --git a/apps/web/src/core/index.ts b/apps/web/src/core/index.ts
index f1c2d032..1086a6d2 100644
--- a/apps/web/src/core/index.ts
+++ b/apps/web/src/core/index.ts
@@ -1,53 +1,63 @@
-import { PlaybackManager } from "./managers/playback-manager";
-import { TimelineManager } from "./managers/timeline-manager";
-import { ScenesManager } from "./managers/scenes-manager";
-import { ProjectManager } from "./managers/project-manager";
-import { MediaManager } from "./managers/media-manager";
-import { RendererManager } from "./managers/renderer-manager";
-import { CommandManager } from "./managers/commands";
-import { SaveManager } from "./managers/save-manager";
-import { AudioManager } from "./managers/audio-manager";
-import { SelectionManager } from "./managers/selection-manager";
-import { registerDefaultEffects } from "@/lib/effects";
-import { registerDefaultMasks } from "@/lib/masks";
-
-export class EditorCore {
- private static instance: EditorCore | null = null;
- public readonly timeline: TimelineManager;
- public readonly command: CommandManager;
- public readonly playback: PlaybackManager;
- public readonly scenes: ScenesManager;
- public readonly project: ProjectManager;
- public readonly media: MediaManager;
- public readonly renderer: RendererManager;
- public readonly save: SaveManager;
- public readonly audio: AudioManager;
- public readonly selection: SelectionManager;
-
- private constructor() {
- registerDefaultEffects();
- registerDefaultMasks();
- this.command = new CommandManager(this);
- this.timeline = new TimelineManager(this);
- this.playback = new PlaybackManager(this);
- this.scenes = new ScenesManager(this);
- this.project = new ProjectManager(this);
- this.media = new MediaManager(this);
- this.renderer = new RendererManager(this);
- this.save = new SaveManager(this);
- this.audio = new AudioManager(this);
- this.selection = new SelectionManager(this);
- this.save.start();
- }
-
- static getInstance(): EditorCore {
- if (!EditorCore.instance) {
- EditorCore.instance = new EditorCore();
- }
- return EditorCore.instance;
- }
-
- static reset(): void {
- EditorCore.instance = null;
- }
-}
+import { PlaybackManager } from "./managers/playback-manager";
+import { TimelineManager } from "./managers/timeline-manager";
+import { ScenesManager } from "./managers/scenes-manager";
+import { ProjectManager } from "./managers/project-manager";
+import { MediaManager } from "./managers/media-manager";
+import { RendererManager } from "./managers/renderer-manager";
+import { CommandManager } from "./managers/commands";
+import { SaveManager } from "./managers/save-manager";
+import { AudioManager } from "./managers/audio-manager";
+import { SelectionManager } from "./managers/selection-manager";
+import { registerDefaultEffects } from "@/lib/effects";
+import { registerDefaultMasks } from "@/lib/masks";
+import { isMainTrack } from "@/lib/timeline/placement";
+
+export class EditorCore {
+ private static instance: EditorCore | null = null;
+ public readonly timeline: TimelineManager;
+ public readonly command: CommandManager;
+ public readonly playback: PlaybackManager;
+ public readonly scenes: ScenesManager;
+ public readonly project: ProjectManager;
+ public readonly media: MediaManager;
+ public readonly renderer: RendererManager;
+ public readonly save: SaveManager;
+ public readonly audio: AudioManager;
+ public readonly selection: SelectionManager;
+
+ private constructor() {
+ registerDefaultEffects();
+ registerDefaultMasks();
+ this.command = new CommandManager(this);
+ this.timeline = new TimelineManager(this);
+ this.playback = new PlaybackManager(this);
+ this.scenes = new ScenesManager(this);
+ this.project = new ProjectManager(this);
+ this.media = new MediaManager(this);
+ this.renderer = new RendererManager(this);
+ this.save = new SaveManager(this);
+ this.audio = new AudioManager(this);
+ this.selection = new SelectionManager(this);
+ this.command.registerReactor(() => {
+ const tracks = this.timeline.getTracks();
+ const prunedTracks = tracks.filter(
+ (track) => track.elements.length > 0 || isMainTrack(track),
+ );
+ if (prunedTracks.length !== tracks.length) {
+ this.timeline.updateTracks(prunedTracks);
+ }
+ });
+ this.save.start();
+ }
+
+ static getInstance(): EditorCore {
+ if (!EditorCore.instance) {
+ EditorCore.instance = new EditorCore();
+ }
+ return EditorCore.instance;
+ }
+
+ static reset(): void {
+ EditorCore.instance = null;
+ }
+}
diff --git a/apps/web/src/core/managers/commands.ts b/apps/web/src/core/managers/commands.ts
index 4c53857b..918fc33e 100644
--- a/apps/web/src/core/managers/commands.ts
+++ b/apps/web/src/core/managers/commands.ts
@@ -11,6 +11,7 @@ interface CommandHistoryEntry {
export class CommandManager {
private history: CommandHistoryEntry[] = [];
private redoStack: CommandHistoryEntry[] = [];
+ private reactors: Array<() => void> = [];
constructor(private editor: EditorCore) {}
@@ -18,6 +19,7 @@ export class CommandManager {
const previousSelection = this.getSelectionSnapshot();
const result = command.execute();
const selectionOverride = this.applySelectionOverride(result);
+ this.runReactors();
this.history.push({
command,
previousSelection,
@@ -35,6 +37,10 @@ export class CommandManager {
this.redoStack = [];
}
+ registerReactor(reactor: () => void): void {
+ this.reactors.push(reactor);
+ }
+
undo(): void {
if (this.history.length === 0) return;
const entry = this.history.pop();
@@ -64,6 +70,7 @@ export class CommandManager {
const previousSelection = this.getSelectionSnapshot();
const result = entry.command.redo();
const selectionOverride = this.applySelectionOverride(result);
+ this.runReactors();
this.history.push({
command: entry.command,
@@ -100,4 +107,10 @@ export class CommandManager {
this.editor.selection.setSelectedElements({ elements: selectionOverride });
return selectionOverride;
}
+
+ private runReactors(): void {
+ for (const reactor of this.reactors) {
+ reactor();
+ }
+ }
}
diff --git a/apps/web/src/lib/commands/timeline/clipboard/paste.ts b/apps/web/src/lib/commands/timeline/clipboard/paste.ts
index 74a88f0b..59e1af1c 100644
--- a/apps/web/src/lib/commands/timeline/clipboard/paste.ts
+++ b/apps/web/src/lib/commands/timeline/clipboard/paste.ts
@@ -1,184 +1,184 @@
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import { EditorCore } from "@/core";
-import type {
- TimelineTrack,
- TimelineElement,
- ClipboardItem,
-} from "@/lib/timeline";
-import { generateUUID } from "@/utils/id";
-import {
- applyPlacement,
- resolveTrackPlacement,
- isMainTrack,
- enforceMainTrackStart,
-} from "@/lib/timeline/placement";
-import { cloneAnimations } from "@/lib/animation";
-
-export class PasteCommand extends Command {
- private savedState: TimelineTrack[] | null = null;
- private pastedElements: { trackId: string; elementId: string }[] = [];
- private readonly time: number;
- private readonly clipboardItems: ClipboardItem[];
-
- constructor({
- time,
- clipboardItems,
- }: {
- time: number;
- clipboardItems: ClipboardItem[];
- }) {
- super();
- this.time = time;
- this.clipboardItems = clipboardItems;
- }
-
- execute(): CommandResult | undefined {
- if (this.clipboardItems.length === 0) return;
-
- const editor = EditorCore.getInstance();
- this.savedState = editor.timeline.getTracks();
- this.pastedElements = [];
-
- const minStart = Math.min(
- ...this.clipboardItems.map((item) => item.element.startTime),
- );
-
- let updatedTracks = [...this.savedState];
- const itemsByTrackId = groupClipboardItemsByTrackId({
- clipboardItems: this.clipboardItems,
- });
-
- for (const [trackId, items] of itemsByTrackId) {
- const elementsToAdd = buildPastedElements({
- items,
- minStart,
- time: this.time,
- });
-
- if (elementsToAdd.length === 0) {
- continue;
- }
-
- const trackType = items[0].trackType;
- const sourceTrackIndex = updatedTracks.findIndex(
- (track) => track.id === trackId,
- );
- const placementResult = resolveTrackPlacement({
- tracks: updatedTracks,
- trackType,
- timeSpans: elementsToAdd.map((element) => ({
- startTime: element.startTime,
- duration: element.duration,
- })),
- strategy: { type: "aboveSource", sourceTrackIndex },
- });
- if (!placementResult) {
- continue;
- }
-
- let elementsForPlacement = elementsToAdd;
- if (placementResult.kind === "existingTrack") {
- const targetTrack = updatedTracks[placementResult.trackIndex];
- if (isMainTrack(targetTrack)) {
- const earliestElement = elementsToAdd.reduce((earliest, element) =>
- element.startTime < earliest.startTime ? element : earliest,
- );
- const adjustedEarliestStartTime = enforceMainTrackStart({
- tracks: updatedTracks,
- targetTrackId: targetTrack.id,
- requestedStartTime: earliestElement.startTime,
- });
- const delta = adjustedEarliestStartTime - earliestElement.startTime;
-
- if (delta !== 0) {
- elementsForPlacement = elementsToAdd.map((element) => ({
- ...element,
- startTime: Math.max(0, element.startTime + delta),
- }));
- }
- }
- }
-
- const applied = applyPlacement({
- tracks: updatedTracks,
- placementResult,
- elements: elementsForPlacement,
- });
- if (!applied) {
- continue;
- }
-
- updatedTracks = applied.updatedTracks;
-
- for (const element of elementsForPlacement) {
- this.pastedElements.push({
- trackId: applied.targetTrackId,
- elementId: element.id,
- });
- }
- }
-
- editor.timeline.updateTracks(updatedTracks);
-
- if (this.pastedElements.length > 0) {
- return { select: this.pastedElements };
- }
- }
-
- undo(): void {
- if (this.savedState) {
- const editor = EditorCore.getInstance();
- editor.timeline.updateTracks(this.savedState);
- }
- }
-
- getPastedElements(): { trackId: string; elementId: string }[] {
- return this.pastedElements;
- }
-}
-
-function groupClipboardItemsByTrackId({
- clipboardItems,
-}: {
- clipboardItems: ClipboardItem[];
-}): Map<string, ClipboardItem[]> {
- const groupedItems = new Map<string, ClipboardItem[]>();
-
- for (const item of clipboardItems) {
- const existingItems = groupedItems.get(item.trackId) ?? [];
- groupedItems.set(item.trackId, [...existingItems, item]);
- }
-
- return groupedItems;
-}
-
-function buildPastedElements({
- items,
- minStart,
- time,
-}: {
- items: ClipboardItem[];
- minStart: number;
- time: number;
-}): TimelineElement[] {
- const elementsToAdd: TimelineElement[] = [];
-
- for (const item of items) {
- const relativeOffset = item.element.startTime - minStart;
- const startTime = Math.max(0, time + relativeOffset);
- const newElementId = generateUUID();
-
- elementsToAdd.push({
- ...item.element,
- id: newElementId,
- startTime,
- animations: cloneAnimations({
- animations: item.element.animations,
- shouldRegenerateKeyframeIds: true,
- }),
- } as TimelineElement);
- }
-
- return elementsToAdd;
-}
-
+import { Command, type CommandResult } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import type {
+ TimelineTrack,
+ TimelineElement,
+ ClipboardItem,
+} from "@/lib/timeline";
+import { generateUUID } from "@/utils/id";
+import {
+ applyPlacement,
+ resolveTrackPlacement,
+ isMainTrack,
+ enforceMainTrackStart,
+} from "@/lib/timeline/placement";
+import { cloneAnimations } from "@/lib/animation";
+
+export class PasteCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private pastedElements: { trackId: string; elementId: string }[] = [];
+ private readonly time: number;
+ private readonly clipboardItems: ClipboardItem[];
+
+ constructor({
+ time,
+ clipboardItems,
+ }: {
+ time: number;
+ clipboardItems: ClipboardItem[];
+ }) {
+ super();
+ this.time = time;
+ this.clipboardItems = clipboardItems;
+ }
+
+ execute(): CommandResult | undefined {
+ if (this.clipboardItems.length === 0) return;
+
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+ this.pastedElements = [];
+
+ const minStart = Math.min(
+ ...this.clipboardItems.map((item) => item.element.startTime),
+ );
+
+ let updatedTracks = [...this.savedState];
+ const itemsByTrackId = groupClipboardItemsByTrackId({
+ clipboardItems: this.clipboardItems,
+ });
+
+ for (const [trackId, items] of itemsByTrackId) {
+ const elementsToAdd = buildPastedElements({
+ items,
+ minStart,
+ time: this.time,
+ });
+
+ if (elementsToAdd.length === 0) {
+ continue;
+ }
+
+ const trackType = items[0].trackType;
+ const sourceTrackIndex = updatedTracks.findIndex(
+ (track) => track.id === trackId,
+ );
+ const placementResult = resolveTrackPlacement({
+ tracks: updatedTracks,
+ trackType,
+ timeSpans: elementsToAdd.map((element) => ({
+ startTime: element.startTime,
+ duration: element.duration,
+ })),
+ strategy: { type: "aboveSource", sourceTrackIndex },
+ });
+ if (!placementResult) {
+ continue;
+ }
+
+ let elementsForPlacement = elementsToAdd;
+ if (placementResult.kind === "existingTrack") {
+ const targetTrack = updatedTracks[placementResult.trackIndex];
+ if (isMainTrack(targetTrack)) {
+ const earliestElement = elementsToAdd.reduce((earliest, element) =>
+ element.startTime < earliest.startTime ? element : earliest,
+ );
+ const adjustedEarliestStartTime = enforceMainTrackStart({
+ tracks: updatedTracks,
+ targetTrackId: targetTrack.id,
+ requestedStartTime: earliestElement.startTime,
+ });
+ const delta = adjustedEarliestStartTime - earliestElement.startTime;
+
+ if (delta !== 0) {
+ elementsForPlacement = elementsToAdd.map((element) => ({
+ ...element,
+ startTime: Math.max(0, element.startTime + delta),
+ }));
+ }
+ }
+ }
+
+ const applied = applyPlacement({
+ tracks: updatedTracks,
+ placementResult,
+ elements: elementsForPlacement,
+ });
+ if (!applied) {
+ continue;
+ }
+
+ updatedTracks = applied.updatedTracks;
+
+ for (const element of elementsForPlacement) {
+ this.pastedElements.push({
+ trackId: applied.targetTrackId,
+ elementId: element.id,
+ });
+ }
+ }
+
+ editor.timeline.updateTracks(updatedTracks);
+
+ if (this.pastedElements.length > 0) {
+ return { select: this.pastedElements };
+ }
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+
+ getPastedElements(): { trackId: string; elementId: string }[] {
+ return this.pastedElements;
+ }
+}
+
+function groupClipboardItemsByTrackId({
+ clipboardItems,
+}: {
+ clipboardItems: ClipboardItem[];
+}): Map<string, ClipboardItem[]> {
+ const groupedItems = new Map<string, ClipboardItem[]>();
+
+ for (const item of clipboardItems) {
+ const existingItems = groupedItems.get(item.trackId) ?? [];
+ groupedItems.set(item.trackId, [...existingItems, item]);
+ }
+
+ return groupedItems;
+}
+
+function buildPastedElements({
+ items,
+ minStart,
+ time,
+}: {
+ items: ClipboardItem[];
+ minStart: number;
+ time: number;
+}): TimelineElement[] {
+ const elementsToAdd: TimelineElement[] = [];
+
+ for (const item of items) {
+ const relativeOffset = item.element.startTime - minStart;
+ const startTime = Math.max(0, time + relativeOffset);
+ const newElementId = generateUUID();
+
+ elementsToAdd.push({
+ ...item.element,
+ id: newElementId,
+ startTime,
+ animations: cloneAnimations({
+ animations: item.element.animations,
+ shouldRegenerateKeyframeIds: true,
+ }),
+ } as TimelineElement);
+ }
+
+ return elementsToAdd;
+}
+
diff --git a/apps/web/src/lib/commands/timeline/element/delete-elements.ts b/apps/web/src/lib/commands/timeline/element/delete-elements.ts
index 26277f95..9177a3f8 100644
--- a/apps/web/src/lib/commands/timeline/element/delete-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/delete-elements.ts
@@ -1,84 +1,81 @@
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/lib/timeline";
-import { EditorCore } from "@/core";
-import { rippleShiftElements } from "@/lib/timeline";
-import { isMainTrack } from "@/lib/timeline/placement";
-
-export class DeleteElementsCommand extends Command {
- private savedState: TimelineTrack[] | null = null;
- private readonly elements: { trackId: string; elementId: string }[];
- private readonly rippleEnabled: boolean;
-
- constructor({
- elements,
- rippleEnabled = false,
- }: {
- elements: { trackId: string; elementId: string }[];
- rippleEnabled?: boolean;
- }) {
- super();
- this.elements = elements;
- this.rippleEnabled = rippleEnabled;
- }
-
- execute(): CommandResult {
- const editor = EditorCore.getInstance();
- this.savedState = editor.timeline.getTracks();
-
- const updatedTracks = this.savedState
- .map((track) => {
- const elementsToDeleteOnTrack = this.elements.filter(
- (target) => target.trackId === track.id,
- );
- const hasElementsToDelete = elementsToDeleteOnTrack.length > 0;
-
- if (!hasElementsToDelete) {
- return track;
- }
-
- const deletedElementInfos = elementsToDeleteOnTrack
- .map((target) =>
- track.elements.find((element) => element.id === target.elementId),
- )
- .filter((element): element is NonNullable<typeof element> => element !== undefined)
- .map((element) => ({ startTime: element.startTime, duration: element.duration }));
-
- let elements = track.elements.filter(
- (element) =>
- !this.elements.some(
- (target) =>
- target.trackId === track.id && target.elementId === element.id,
- ),
- );
-
- if (this.rippleEnabled && deletedElementInfos.length > 0) {
- const sortedByStartDesc = [...deletedElementInfos].sort(
- (a, b) => b.startTime - a.startTime,
- );
- for (const { startTime, duration } of sortedByStartDesc) {
- elements = rippleShiftElements({
- elements,
- afterTime: startTime,
- shiftAmount: duration,
- });
- }
- }
-
- return { ...track, elements } as typeof track;
- })
- .filter((track) => track.elements.length > 0 || isMainTrack(track));
-
- editor.timeline.updateTracks(updatedTracks);
-
- return {
- select: [],
- };
- }
-
- undo(): void {
- if (this.savedState) {
- const editor = EditorCore.getInstance();
- editor.timeline.updateTracks(this.savedState);
- }
- }
-}
+import { Command, type CommandResult } from "@/lib/commands/base-command";
+import type { TimelineTrack } from "@/lib/timeline";
+import { EditorCore } from "@/core";
+import { rippleShiftElements } from "@/lib/timeline";
+
+export class DeleteElementsCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly elements: { trackId: string; elementId: string }[];
+ private readonly rippleEnabled: boolean;
+
+ constructor({
+ elements,
+ rippleEnabled = false,
+ }: {
+ elements: { trackId: string; elementId: string }[];
+ rippleEnabled?: boolean;
+ }) {
+ super();
+ this.elements = elements;
+ this.rippleEnabled = rippleEnabled;
+ }
+
+ execute(): CommandResult {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = this.savedState.map((track) => {
+ const elementsToDeleteOnTrack = this.elements.filter(
+ (target) => target.trackId === track.id,
+ );
+ const hasElementsToDelete = elementsToDeleteOnTrack.length > 0;
+
+ if (!hasElementsToDelete) {
+ return track;
+ }
+
+ const deletedElementInfos = elementsToDeleteOnTrack
+ .map((target) =>
+ track.elements.find((element) => element.id === target.elementId),
+ )
+ .filter((element): element is NonNullable<typeof element> => element !== undefined)
+ .map((element) => ({ startTime: element.startTime, duration: element.duration }));
+
+ let elements = track.elements.filter(
+ (element) =>
+ !this.elements.some(
+ (target) =>
+ target.trackId === track.id && target.elementId === element.id,
+ ),
+ );
+
+ if (this.rippleEnabled && deletedElementInfos.length > 0) {
+ const sortedByStartDesc = [...deletedElementInfos].sort(
+ (a, b) => b.startTime - a.startTime,
+ );
+ for (const { startTime, duration } of sortedByStartDesc) {
+ elements = rippleShiftElements({
+ elements,
+ afterTime: startTime,
+ shiftAmount: duration,
+ });
+ }
+ }
+
+ return { ...track, elements } as typeof track;
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+
+ return {
+ select: [],
+ };
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts
index df3531eb..ad31ae9c 100644
--- a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts
@@ -1,127 +1,127 @@
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
-import { generateUUID } from "@/utils/id";
-import { EditorCore } from "@/core";
-import { applyPlacement, resolveTrackPlacement } from "@/lib/timeline/placement";
-import { cloneAnimations } from "@/lib/animation";
-
-interface DuplicateElementsParams {
- elements: { trackId: string; elementId: string }[];
-}
-
-export class DuplicateElementsCommand extends Command {
- private duplicatedElements: { trackId: string; elementId: string }[] = [];
- private savedState: TimelineTrack[] | null = null;
- private elements: DuplicateElementsParams["elements"];
-
- constructor({ elements }: DuplicateElementsParams) {
- super();
- this.elements = elements;
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- this.savedState = editor.timeline.getTracks();
- this.duplicatedElements = [];
-
- let updatedTracks = [...this.savedState];
-
- for (const track of this.savedState) {
- const elementsToDuplicate = this.elements.filter(
- (elementEntry) => elementEntry.trackId === track.id,
- );
-
- if (elementsToDuplicate.length === 0) {
- continue;
- }
-
- const elementIdsToDuplicate = new Set(
- elementsToDuplicate.map((element) => element.elementId),
- );
- const newTrackElements: TimelineElement[] = [];
-
- for (const element of track.elements) {
- if (!elementIdsToDuplicate.has(element.id)) {
- continue;
- }
-
- const newId = generateUUID();
- newTrackElements.push(
- buildDuplicateElement({
- element,
- id: newId,
- startTime: element.startTime,
- }),
- );
- }
-
- const placementResult = resolveTrackPlacement({
- tracks: updatedTracks,
- trackType: track.type,
- timeSpans: [],
- strategy: { type: "alwaysNew", position: "highest" },
- });
- if (!placementResult || placementResult.kind !== "newTrack") {
- continue;
- }
-
- const applied = applyPlacement({
- tracks: updatedTracks,
- placementResult,
- elements: newTrackElements,
- });
- if (!applied) {
- continue;
- }
-
- updatedTracks = applied.updatedTracks;
-
- for (const element of newTrackElements) {
- this.duplicatedElements.push({
- trackId: applied.targetTrackId,
- elementId: element.id,
- });
- }
- }
-
- editor.timeline.updateTracks(updatedTracks);
-
- if (this.duplicatedElements.length > 0) {
- return {
- select: this.duplicatedElements,
- };
- }
- }
-
- undo(): void {
- if (this.savedState) {
- const editor = EditorCore.getInstance();
- editor.timeline.updateTracks(this.savedState);
- }
- }
-
- getDuplicatedElements(): { trackId: string; elementId: string }[] {
- return this.duplicatedElements;
- }
-}
-
-function buildDuplicateElement({
- element,
- id,
- startTime,
-}: {
- element: TimelineElement;
- id: string;
- startTime: number;
-}): TimelineElement {
- return {
- ...element,
- id,
- name: `${element.name} (copy)`,
- startTime,
- animations: cloneAnimations({
- animations: element.animations,
- shouldRegenerateKeyframeIds: true,
- }),
- };
-}
+import { Command, type CommandResult } from "@/lib/commands/base-command";
+import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
+import { generateUUID } from "@/utils/id";
+import { EditorCore } from "@/core";
+import { applyPlacement, resolveTrackPlacement } from "@/lib/timeline/placement";
+import { cloneAnimations } from "@/lib/animation";
+
+interface DuplicateElementsParams {
+ elements: { trackId: string; elementId: string }[];
+}
+
+export class DuplicateElementsCommand extends Command {
+ private duplicatedElements: { trackId: string; elementId: string }[] = [];
+ private savedState: TimelineTrack[] | null = null;
+ private elements: DuplicateElementsParams["elements"];
+
+ constructor({ elements }: DuplicateElementsParams) {
+ super();
+ this.elements = elements;
+ }
+
+ execute(): CommandResult | undefined {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+ this.duplicatedElements = [];
+
+ let updatedTracks = [...this.savedState];
+
+ for (const track of this.savedState) {
+ const elementsToDuplicate = this.elements.filter(
+ (elementEntry) => elementEntry.trackId === track.id,
+ );
+
+ if (elementsToDuplicate.length === 0) {
+ continue;
+ }
+
+ const elementIdsToDuplicate = new Set(
+ elementsToDuplicate.map((element) => element.elementId),
+ );
+ const newTrackElements: TimelineElement[] = [];
+
+ for (const element of track.elements) {
+ if (!elementIdsToDuplicate.has(element.id)) {
+ continue;
+ }
+
+ const newId = generateUUID();
+ newTrackElements.push(
+ buildDuplicateElement({
+ element,
+ id: newId,
+ startTime: element.startTime,
+ }),
+ );
+ }
+
+ const placementResult = resolveTrackPlacement({
+ tracks: updatedTracks,
+ trackType: track.type,
+ timeSpans: [],
+ strategy: { type: "alwaysNew", position: "highest" },
+ });
+ if (!placementResult || placementResult.kind !== "newTrack") {
+ continue;
+ }
+
+ const applied = applyPlacement({
+ tracks: updatedTracks,
+ placementResult,
+ elements: newTrackElements,
+ });
+ if (!applied) {
+ continue;
+ }
+
+ updatedTracks = applied.updatedTracks;
+
+ for (const element of newTrackElements) {
+ this.duplicatedElements.push({
+ trackId: applied.targetTrackId,
+ elementId: element.id,
+ });
+ }
+ }
+
+ editor.timeline.updateTracks(updatedTracks);
+
+ if (this.duplicatedElements.length > 0) {
+ return {
+ select: this.duplicatedElements,
+ };
+ }
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+
+ getDuplicatedElements(): { trackId: string; elementId: string }[] {
+ return this.duplicatedElements;
+ }
+}
+
+function buildDuplicateElement({
+ element,
+ id,
+ startTime,
+}: {
+ element: TimelineElement;
+ id: string;
+ startTime: number;
+}): TimelineElement {
+ return {
+ ...element,
+ id,
+ name: `${element.name} (copy)`,
+ startTime,
+ animations: cloneAnimations({
+ animations: element.animations,
+ shouldRegenerateKeyframeIds: true,
+ }),
+ };
+}
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 73d07bff..6d400964 100644
--- a/apps/web/src/lib/commands/timeline/element/move-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/move-elements.ts
@@ -7,7 +7,6 @@ import type {
} from "@/lib/timeline";
import {
buildEmptyTrack,
- isMainTrack,
validateElementTrackCompatibility,
enforceMainTrackStart,
} from "@/lib/timeline/placement";
@@ -100,7 +99,7 @@ export class MoveElementCommand extends Command {
const isSameTrack = this.sourceTrackId === this.targetTrackId;
- let updatedTracks = tracksToUpdate.map((track): TimelineTrack => {
+ const updatedTracks = tracksToUpdate.map((track): TimelineTrack => {
if (isSameTrack && track.id === this.sourceTrackId) {
return {
...track,
@@ -134,21 +133,6 @@ export class MoveElementCommand extends Command {
return track;
});
- if (!isSameTrack) {
- const sourceTrackAfterMove = updatedTracks.find(
- (track) => track.id === this.sourceTrackId,
- );
- if (
- sourceTrackAfterMove &&
- sourceTrackAfterMove.elements.length === 0 &&
- !isMainTrack(sourceTrackAfterMove)
- ) {
- updatedTracks = updatedTracks.filter(
- (track) => track.id !== this.sourceTrackId,
- );
- }
- }
-
editor.timeline.updateTracks(updatedTracks);
}