From dc82826951c03cca91ea1431b0b8abdfd9dba583 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Tue, 31 Mar 2026 23:39:43 +0200 Subject: [PATCH] refactor: move track pruning into a post-command reactor --- apps/web/src/core/index.ts | 116 +++--- apps/web/src/core/managers/commands.ts | 13 + .../lib/commands/timeline/clipboard/paste.ts | 368 +++++++++--------- .../timeline/element/delete-elements.ts | 165 ++++---- .../timeline/element/duplicate-elements.ts | 254 ++++++------ .../timeline/element/move-elements.ts | 18 +- 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 { - const groupedItems = new Map(); - - 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 { + const groupedItems = new Map(); + + 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 => 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 => 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); }