refactor: move track pruning into a post-command reactor

This commit is contained in:
Maze Winther 2026-03-31 23:39:43 +02:00
parent f8fb238b22
commit dc82826951
6 changed files with 469 additions and 465 deletions

View File

@ -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;
}
}

View File

@ -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();
}
}
}

View File

@ -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;
}

View File

@ -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);
}
}
}

View File

@ -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,
}),
};
}

View File

@ -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);
}