codebase overhaul (#697)

This commit is contained in:
Maze
2026-01-31 00:20:04 +01:00
committed by GitHub
parent 0173db9944
commit 7bf0984698
469 changed files with 36184 additions and 32931 deletions
+11
View File
@@ -0,0 +1,11 @@
export abstract class Command {
abstract execute(): void;
undo(): void {
throw new Error("Undo not implemented for this command");
}
redo(): void {
this.execute();
}
}
+6
View File
@@ -0,0 +1,6 @@
export { Command } from "./base-command";
export * from "./timeline";
export * from "./media";
export * from "./scene";
export * from "./project";
@@ -0,0 +1,61 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { MediaAsset } from "@/types/assets";
import { generateUUID } from "@/utils/id";
import { storageService } from "@/services/storage/storage-service";
export class AddMediaAssetCommand extends Command {
private assetId: string;
private savedAssets: MediaAsset[] | null = null;
private createdAsset: MediaAsset | null = null;
constructor(
private projectId: string,
private asset: Omit<MediaAsset, "id">,
) {
super();
this.assetId = generateUUID();
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedAssets = [...editor.media.getAssets()];
this.createdAsset = {
...this.asset,
id: this.assetId,
};
editor.media.setAssets({
assets: [...this.savedAssets, this.createdAsset],
});
storageService
.saveMediaAsset({
projectId: this.projectId,
mediaAsset: this.createdAsset,
})
.catch((error) => {
console.error("Failed to save media item:", error);
});
}
undo(): void {
if (this.savedAssets) {
const editor = EditorCore.getInstance();
editor.media.setAssets({ assets: this.savedAssets });
if (this.createdAsset) {
storageService
.deleteMediaAsset({ projectId: this.projectId, id: this.assetId })
.catch((error) => {
console.error("Failed to delete media item on undo:", error);
});
}
}
}
getAssetId(): string {
return this.assetId;
}
}
+2
View File
@@ -0,0 +1,2 @@
export { AddMediaAssetCommand } from "./add-media-asset";
export { RemoveMediaAssetCommand } from "./remove-media-asset";
@@ -0,0 +1,85 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { MediaAsset } from "@/types/assets";
import { storageService } from "@/services/storage/storage-service";
import { videoCache } from "@/services/media/video-cache";
import { hasMediaId } from "@/lib/timeline/element-utils";
import type { TimelineTrack } from "@/types/timeline";
export class RemoveMediaAssetCommand extends Command {
private savedAssets: MediaAsset[] | null = null;
private savedTracks: TimelineTrack[] | null = null;
private removedAsset: MediaAsset | null = null;
constructor(
private projectId: string,
private assetId: string,
) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
const assets = editor.media.getAssets();
this.savedAssets = [...assets];
this.savedTracks = editor.timeline.getTracks();
this.removedAsset =
assets.find((media) => media.id === this.assetId) ?? null;
if (!this.removedAsset) {
console.error("Media asset not found:", this.assetId);
return;
}
videoCache.clearVideo({ mediaId: this.assetId });
editor.media.setAssets({
assets: assets.filter((media) => media.id !== this.assetId),
});
const elementsToRemove: Array<{ trackId: string; elementId: string }> = [];
for (const track of this.savedTracks) {
for (const element of track.elements) {
if (hasMediaId(element) && element.mediaId === this.assetId) {
elementsToRemove.push({ trackId: track.id, elementId: element.id });
}
}
}
if (elementsToRemove.length > 0) {
editor.timeline.deleteElements({ elements: elementsToRemove });
}
storageService
.deleteMediaAsset({ projectId: this.projectId, id: this.assetId })
.catch((error) => {
console.error("Failed to delete media item:", error);
});
}
undo(): void {
const editor = EditorCore.getInstance();
if (this.savedAssets) {
editor.media.setAssets({ assets: this.savedAssets });
}
if (this.savedTracks) {
editor.timeline.updateTracks(this.savedTracks);
}
if (this.removedAsset) {
storageService
.saveMediaAsset({
projectId: this.projectId,
mediaAsset: this.removedAsset,
})
.catch((error) => {
console.error("Failed to restore media item on undo:", error);
});
}
}
}
@@ -0,0 +1 @@
export * from "./update-project-settings";
@@ -0,0 +1,46 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TProject, TProjectSettings } from "@/types/project";
export class UpdateProjectSettingsCommand extends Command {
private savedSettings: TProjectSettings | null = null;
private savedUpdatedAt: Date | null = null;
constructor(private updates: Partial<TProjectSettings>) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
const activeProject = editor.project.getActive();
if (!activeProject) return;
this.savedSettings = activeProject.settings;
this.savedUpdatedAt = activeProject.metadata.updatedAt;
const updatedProject: TProject = {
...activeProject,
settings: { ...activeProject.settings, ...this.updates },
metadata: { ...activeProject.metadata, updatedAt: new Date() },
};
editor.project.setActiveProject({ project: updatedProject });
editor.save.markDirty();
}
undo(): void {
if (!this.savedSettings || !this.savedUpdatedAt) return;
const editor = EditorCore.getInstance();
const activeProject = editor.project.getActive();
if (!activeProject) return;
const updatedProject: TProject = {
...activeProject,
settings: this.savedSettings,
metadata: { ...activeProject.metadata, updatedAt: this.savedUpdatedAt },
};
editor.project.setActiveProject({ project: updatedProject });
editor.save.markDirty();
}
}
@@ -0,0 +1,40 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TScene } from "@/types/timeline";
import { buildDefaultScene } from "@/lib/scenes";
export class CreateSceneCommand extends Command {
private savedScenes: TScene[] | null = null;
private createdScene: TScene | null = null;
constructor(
private name: string,
private isMain: boolean = false,
) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedScenes = [...editor.scenes.getScenes()];
this.createdScene = buildDefaultScene({
name: this.name,
isMain: this.isMain,
});
const updatedScenes = [...this.savedScenes, this.createdScene];
editor.scenes.setScenes({ scenes: updatedScenes });
}
undo(): void {
if (this.savedScenes) {
const editor = EditorCore.getInstance();
editor.scenes.setScenes({ scenes: this.savedScenes });
}
}
getSceneId(): string {
return this.createdScene?.id ?? "";
}
}
@@ -0,0 +1,59 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TScene } from "@/types/timeline";
import { canDeleteScene, getFallbackSceneAfterDelete } from "@/lib/scenes";
export class DeleteSceneCommand extends Command {
private savedScenes: TScene[] | null = null;
private savedActiveSceneId: string | null = null;
private deletedScene: TScene | null = null;
constructor(private sceneId: string) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
const scenes = editor.scenes.getScenes();
const activeScene = editor.scenes.getActiveScene();
this.savedScenes = [...scenes];
this.savedActiveSceneId = activeScene?.id ?? null;
this.deletedScene = scenes.find((s) => s.id === this.sceneId) ?? null;
if (!this.deletedScene) {
console.error("Scene not found:", this.sceneId);
return;
}
const { canDelete, reason } = canDeleteScene({ scene: this.deletedScene });
if (!canDelete) {
console.error("Cannot delete scene:", reason);
return;
}
const updatedScenes = scenes.filter((s) => s.id !== this.sceneId);
const newActiveScene = getFallbackSceneAfterDelete({
scenes: updatedScenes,
deletedSceneId: this.sceneId,
currentSceneId: activeScene?.id ?? null,
});
editor.scenes.setScenes({
scenes: updatedScenes,
activeSceneId: newActiveScene?.id,
});
}
undo(): void {
if (this.savedScenes && this.deletedScene) {
const editor = EditorCore.getInstance();
editor.scenes.setScenes({
scenes: this.savedScenes,
activeSceneId: this.savedActiveSceneId ?? undefined,
});
}
}
}
+5
View File
@@ -0,0 +1,5 @@
export { CreateSceneCommand } from "./create-scene";
export { DeleteSceneCommand } from "./delete-scene";
export { RenameSceneCommand } from "./rename-scene";
export { ToggleBookmarkCommand } from "./toggle-bookmark";
export { RemoveBookmarkCommand } from "./remove-bookmark";
@@ -0,0 +1,59 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TScene } from "@/types/timeline";
import { updateSceneInArray } from "@/lib/scenes";
import {
getFrameTime,
removeBookmarkFromArray,
} from "@/lib/timeline/bookmarks";
export class RemoveBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null;
private frameTime: number = 0;
constructor(private time: number) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
const activeScene = editor.scenes.getActiveScene();
const activeProject = editor.project.getActive();
if (!activeScene || !activeProject) {
return;
}
const scenes = editor.scenes.getScenes();
this.savedScenes = [...scenes];
this.frameTime = getFrameTime({
time: this.time,
fps: activeProject.settings.fps,
});
const updatedBookmarks = removeBookmarkFromArray({
bookmarks: activeScene.bookmarks,
frameTime: this.frameTime,
});
if (updatedBookmarks.length === activeScene.bookmarks.length) {
return;
}
const updatedScenes = updateSceneInArray({
scenes,
sceneId: activeScene.id,
updates: { bookmarks: updatedBookmarks },
});
editor.scenes.setScenes({ scenes: updatedScenes });
}
undo(): void {
if (this.savedScenes) {
const editor = EditorCore.getInstance();
editor.scenes.setScenes({ scenes: this.savedScenes });
}
}
}
@@ -0,0 +1,46 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TScene } from "@/types/timeline";
import { updateSceneInArray } from "@/lib/scenes";
export class RenameSceneCommand extends Command {
private savedScenes: TScene[] | null = null;
private previousName: string | null = null;
constructor(
private sceneId: string,
private newName: string,
) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
const scenes = editor.scenes.getScenes();
this.savedScenes = [...scenes];
const scene = scenes.find((s) => s.id === this.sceneId);
if (!scene) {
console.error("Scene not found:", this.sceneId);
return;
}
this.previousName = scene.name;
const updatedScenes = updateSceneInArray({
scenes,
sceneId: this.sceneId,
updates: { name: this.newName, updatedAt: new Date() },
});
editor.scenes.setScenes({ scenes: updatedScenes });
}
undo(): void {
if (this.savedScenes && this.previousName !== null) {
const editor = EditorCore.getInstance();
editor.scenes.setScenes({ scenes: this.savedScenes });
}
}
}
@@ -0,0 +1,52 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TScene } from "@/types/timeline";
import { updateSceneInArray } from "@/lib/scenes";
import { getFrameTime, toggleBookmarkInArray } from "@/lib/timeline/bookmarks";
export class ToggleBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null;
private frameTime: number = 0;
constructor(private time: number) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
const activeScene = editor.scenes.getActiveScene();
const activeProject = editor.project.getActive();
if (!activeScene || !activeProject) {
return;
}
const scenes = editor.scenes.getScenes();
this.savedScenes = [...scenes];
this.frameTime = getFrameTime({
time: this.time,
fps: activeProject.settings.fps,
});
const updatedBookmarks = toggleBookmarkInArray({
bookmarks: activeScene.bookmarks,
frameTime: this.frameTime,
});
const updatedScenes = updateSceneInArray({
scenes,
sceneId: activeScene.id,
updates: { bookmarks: updatedBookmarks },
});
editor.scenes.setScenes({ scenes: updatedScenes });
}
undo(): void {
if (this.savedScenes) {
const editor = EditorCore.getInstance();
editor.scenes.setScenes({ scenes: this.savedScenes });
}
}
}
@@ -0,0 +1 @@
export { PasteCommand } from "./paste";
@@ -0,0 +1,263 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type {
TimelineTrack,
TimelineElement,
ClipboardItem,
} from "@/types/timeline";
import { generateUUID } from "@/utils/id";
import { wouldElementOverlap } from "@/lib/timeline/element-utils";
import {
buildEmptyTrack,
getHighestInsertIndexForTrack,
} from "@/lib/timeline/track-utils";
export class PasteCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private pastedElements: { trackId: string; elementId: string }[] = [];
private previousSelection: { trackId: string; elementId: string }[] = [];
constructor(
private time: number,
private clipboardItems: ClipboardItem[],
) {
super();
}
execute(): void {
if (this.clipboardItems.length === 0) return;
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
this.previousSelection = editor.selection.getSelectedElements();
this.pastedElements = [];
const minStart = Math.min(
...this.clipboardItems.map((item) => item.element.startTime),
);
const 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 resolvedTargetIndex = resolveTargetTrackIndex({
tracks: updatedTracks,
sourceTrackIndex,
trackType,
elements: elementsToAdd,
});
if (resolvedTargetIndex >= 0) {
const targetTrack = updatedTracks[resolvedTargetIndex];
updatedTracks[resolvedTargetIndex] = {
...targetTrack,
elements: [...targetTrack.elements, ...elementsToAdd],
} as TimelineTrack;
for (const element of elementsToAdd) {
this.pastedElements.push({
trackId: targetTrack.id,
elementId: element.id,
});
}
continue;
}
const insertIndex = resolveInsertIndexForNewTrack({
tracks: updatedTracks,
sourceTrackIndex,
trackType,
});
const newTrack = buildTrackWithElements({
trackType,
elements: elementsToAdd,
});
updatedTracks.splice(insertIndex, 0, newTrack);
for (const element of elementsToAdd) {
this.pastedElements.push({
trackId: newTrack.id,
elementId: element.id,
});
}
}
editor.timeline.updateTracks(updatedTracks);
if (this.pastedElements.length > 0) {
editor.selection.setSelectedElements({ elements: this.pastedElements });
}
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
editor.selection.setSelectedElements({ elements: this.previousSelection });
}
}
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,
} as TimelineElement);
}
return elementsToAdd;
}
function resolveTargetTrackIndex({
tracks,
sourceTrackIndex,
trackType,
elements,
}: {
tracks: TimelineTrack[];
sourceTrackIndex: number;
trackType: ClipboardItem["trackType"];
elements: TimelineElement[];
}): number {
if (sourceTrackIndex >= 0) {
const aboveIndex = sourceTrackIndex - 1;
if (aboveIndex < 0) {
return -1;
}
const aboveTrack = tracks[aboveIndex];
if (aboveTrack.type !== trackType) {
return -1;
}
const canPlaceOnAbove = canPlaceElementsOnTrack({
track: aboveTrack,
elements,
});
return canPlaceOnAbove ? aboveIndex : -1;
}
const highestCompatibleIndex = tracks.findIndex(
(track) => track.type === trackType,
);
if (highestCompatibleIndex < 0) {
return -1;
}
const highestCompatibleTrack = tracks[highestCompatibleIndex];
const canPlaceOnHighest = canPlaceElementsOnTrack({
track: highestCompatibleTrack,
elements,
});
return canPlaceOnHighest ? highestCompatibleIndex : -1;
}
function resolveInsertIndexForNewTrack({
tracks,
sourceTrackIndex,
trackType,
}: {
tracks: TimelineTrack[];
sourceTrackIndex: number;
trackType: ClipboardItem["trackType"];
}): number {
if (sourceTrackIndex >= 0) {
return sourceTrackIndex;
}
const highestCompatibleIndex = tracks.findIndex(
(track) => track.type === trackType,
);
if (highestCompatibleIndex >= 0) {
return highestCompatibleIndex;
}
return getHighestInsertIndexForTrack({ tracks, trackType });
}
function canPlaceElementsOnTrack({
track,
elements,
}: {
track: TimelineTrack;
elements: TimelineElement[];
}): boolean {
for (const element of elements) {
const endTime = element.startTime + element.duration;
const hasOverlap = wouldElementOverlap({
elements: track.elements,
startTime: element.startTime,
endTime,
});
if (hasOverlap) {
return false;
}
}
return true;
}
function buildTrackWithElements({
trackType,
elements,
}: {
trackType: ClipboardItem["trackType"];
elements: TimelineElement[];
}): TimelineTrack {
const newTrackId = generateUUID();
const newTrackBase = buildEmptyTrack({ id: newTrackId, type: trackType });
return {
...newTrackBase,
elements,
} as TimelineTrack;
}
@@ -0,0 +1,48 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import { EditorCore } from "@/core";
import { isMainTrack } from "@/lib/timeline";
export class DeleteElementsCommand extends Command {
private savedState: TimelineTrack[] | null = null;
constructor(private elements: { trackId: string; elementId: string }[]) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = this.savedState
.map((track) => {
const hasElementsToDelete = this.elements.some(
(el) => el.trackId === track.id,
);
if (!hasElementsToDelete) {
return track;
}
return {
...track,
elements: track.elements.filter(
(element) =>
!this.elements.some(
(el) => el.trackId === track.id && el.elementId === element.id,
),
),
} as typeof track;
})
.filter((track) => track.elements.length > 0 || isMainTrack(track));
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}
@@ -0,0 +1,114 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineElement, TimelineTrack } from "@/types/timeline";
import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core";
import {
buildEmptyTrack,
getHighestInsertIndexForTrack,
} from "@/lib/timeline/track-utils";
interface DuplicateElementsParams {
elements: { trackId: string; elementId: string }[];
}
export class DuplicateElementsCommand extends Command {
private duplicatedElements: { trackId: string; elementId: string }[] = [];
private savedState: TimelineTrack[] | null = null;
private previousSelection: { trackId: string; elementId: string }[] = [];
private elements: DuplicateElementsParams["elements"];
constructor({ elements }: DuplicateElementsParams) {
super();
this.elements = elements;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
this.previousSelection = editor.selection.getSelectedElements();
this.duplicatedElements = [];
const updatedTracks = [...this.savedState];
for (const track of this.savedState) {
const elementsToDuplicate = this.elements.filter(
(el) => el.trackId === track.id,
);
if (elementsToDuplicate.length === 0) {
continue;
}
const elementIdsToDuplicate = new Set(
elementsToDuplicate.map((element) => element.elementId),
);
const newTrackElements: TimelineElement[] = [];
const newTrackId = generateUUID();
const newTrackBase = buildEmptyTrack({
id: newTrackId,
type: track.type,
});
for (const element of track.elements) {
if (!elementIdsToDuplicate.has(element.id)) {
continue;
}
const newId = generateUUID();
this.duplicatedElements.push({
trackId: newTrackId,
elementId: newId,
});
newTrackElements.push(
buildDuplicateElement({
element,
id: newId,
startTime: element.startTime,
}),
);
}
const newTrack = {
...newTrackBase,
elements: newTrackElements,
} as TimelineTrack;
const insertIndex = getHighestInsertIndexForTrack({
tracks: updatedTracks,
trackType: track.type,
});
updatedTracks.splice(insertIndex, 0, newTrack);
}
editor.timeline.updateTracks(updatedTracks);
if (this.duplicatedElements.length > 0) {
editor.selection.setSelectedElements({ elements: this.duplicatedElements });
}
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
editor.selection.setSelectedElements({ elements: this.previousSelection });
}
}
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 };
}
@@ -0,0 +1,11 @@
export { InsertElementCommand } from "./insert-element";
export { DeleteElementsCommand } from "./delete-elements";
export { DuplicateElementsCommand } from "./duplicate-elements";
export { UpdateElementTrimCommand } from "./update-element-trim";
export { UpdateElementDurationCommand } from "./update-element-duration";
export { UpdateElementStartTimeCommand } from "./update-element-start-time";
export { SplitElementsCommand } from "./split-elements";
export { UpdateTextElementCommand } from "./update-text-element";
export { ToggleElementsVisibilityCommand } from "./toggle-elements-visibility";
export { ToggleElementsMutedCommand } from "./toggle-elements-muted";
export { MoveElementCommand } from "./move-elements";
@@ -0,0 +1,311 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type {
CreateTimelineElement,
TimelineTrack,
TimelineElement,
TrackType,
ElementType,
} from "@/types/timeline";
import { generateUUID } from "@/utils/id";
import {
requiresMediaId,
wouldElementOverlap,
} from "@/lib/timeline/element-utils";
import {
buildEmptyTrack,
canElementGoOnTrack,
getDefaultInsertIndexForTrack,
validateElementTrackCompatibility,
} from "@/lib/timeline/track-utils";
import type { MediaAsset } from "@/types/assets";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
type InsertElementPlacement =
| { mode: "explicit"; trackId: string }
| { mode: "auto"; trackType?: TrackType; insertIndex?: number };
export interface InsertElementParams {
element: CreateTimelineElement;
placement: InsertElementPlacement;
}
export class InsertElementCommand extends Command {
private elementId: string;
private savedState: TimelineTrack[] | null = null;
private targetTrackId: string | null = null;
constructor({ element, placement }: InsertElementParams) {
super();
this.elementId = generateUUID();
this.element = element;
this.placement = placement;
}
private element: CreateTimelineElement;
private placement: InsertElementPlacement;
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
if (!this.savedState) {
console.error("Tracks not available");
return;
}
if (!this.validateElementBasics({ element: this.element })) {
return;
}
const totalElementsInTimeline = this.savedState.reduce(
(total, t) => total + t.elements.length,
0,
);
const isFirstElement = totalElementsInTimeline === 0;
const newElement = this.buildElement({ element: this.element });
const updateResult = this.resolveTracksWithElement({
tracks: this.savedState,
element: newElement,
});
if (!updateResult) {
return;
}
const { updatedTracks, targetTrackId } = updateResult;
this.targetTrackId = targetTrackId;
const isVisualMedia =
newElement.type === "video" || newElement.type === "image";
if (isFirstElement && isVisualMedia) {
const mediaAssets = editor.media.getAssets();
const activeProject = editor.project.getActive();
const asset = mediaAssets.find(
(item: MediaAsset) => item.id === newElement.mediaId,
);
if (asset?.width && asset?.height) {
const nextCanvasSize = { width: asset.width, height: asset.height };
const shouldSetOriginalCanvasSize =
!activeProject?.settings.originalCanvasSize;
editor.project.updateSettings({
settings: {
canvasSize: nextCanvasSize,
...(shouldSetOriginalCanvasSize
? { originalCanvasSize: nextCanvasSize }
: {}),
},
pushHistory: false,
});
}
if (asset?.type === "video" && asset?.fps) {
editor.project.updateSettings({
settings: { fps: asset.fps },
pushHistory: false,
});
}
}
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
getElementId(): string {
return this.elementId;
}
getTrackId(): string | null {
return this.targetTrackId;
}
private buildElement({
element,
}: {
element: CreateTimelineElement;
}): TimelineElement {
return {
...element,
id: this.elementId,
startTime: element.startTime,
trimStart: element.trimStart ?? 0,
trimEnd: element.trimEnd ?? 0,
duration: element.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
} as TimelineElement;
}
private validateElementBasics({
element,
}: {
element: CreateTimelineElement;
}): boolean {
if (requiresMediaId({ element }) && !("mediaId" in element)) {
console.error("Element requires mediaId");
return false;
}
if (
element.type === "audio" &&
element.sourceType === "library" &&
!element.sourceUrl
) {
console.error("Library audio element must have sourceUrl");
return false;
}
if (element.type === "sticker" && !element.iconName) {
console.error("Sticker element must have iconName");
return false;
}
if (element.type === "text" && !element.content) {
console.error("Text element must have content");
return false;
}
return true;
}
private resolveTracksWithElement({
tracks,
element,
}: {
tracks: TimelineTrack[];
element: TimelineElement;
}): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null {
const placement = this.placement;
if (placement.mode === "explicit") {
const targetTrack = tracks.find(
(track) => track.id === placement.trackId,
);
if (!targetTrack) {
console.error("Track not found:", placement.trackId);
return null;
}
const validation = validateElementTrackCompatibility({
element,
track: targetTrack,
});
if (!validation.isValid) {
console.error(validation.errorMessage);
return null;
}
const updatedTracks = tracks.map((track) =>
track.id === targetTrack.id
? { ...track, elements: [...track.elements, element] }
: track,
) as TimelineTrack[];
return { updatedTracks, targetTrackId: targetTrack.id };
}
const trackType =
placement.trackType ?? this.getTrackTypeForElement({ element });
if (
placement.trackType &&
!canElementGoOnTrack({
elementType: element.type,
trackType,
})
) {
console.error(
`${element.type} elements cannot be placed on ${trackType} tracks`,
);
return null;
}
const elementEndTime = element.startTime + element.duration;
const existingTrack = tracks.find((track) => {
if (
!canElementGoOnTrack({
elementType: element.type,
trackType: track.type,
})
) {
return false;
}
return !wouldElementOverlap({
elements: track.elements,
startTime: element.startTime,
endTime: elementEndTime,
});
});
if (existingTrack) {
const updatedTracks = tracks.map((track) =>
track.id === existingTrack.id
? { ...track, elements: [...track.elements, element] }
: track,
) as TimelineTrack[];
return { updatedTracks, targetTrackId: existingTrack.id };
}
const newTrackId = generateUUID();
const newTrack = buildEmptyTrack({
id: newTrackId,
type: trackType,
});
const newTrackWithElement = {
...newTrack,
elements: [...newTrack.elements, element],
} as TimelineTrack;
const updatedTracks = [...tracks];
const insertIndex =
placement.insertIndex ??
this.getAutoInsertIndex({ tracks: updatedTracks, trackType });
updatedTracks.splice(insertIndex, 0, newTrackWithElement);
return { updatedTracks, targetTrackId: newTrackId };
}
private getAutoInsertIndex({
tracks,
trackType,
}: {
tracks: TimelineTrack[];
trackType: TrackType;
}): number {
if (trackType === "text") {
const firstVideoTrackIndex = tracks.findIndex(
(track) => track.type === "video",
);
if (firstVideoTrackIndex >= 0) {
return firstVideoTrackIndex;
}
}
return getDefaultInsertIndexForTrack({
tracks,
trackType,
});
}
private getTrackTypeForElement({
element,
}: {
element: { type: ElementType };
}): TrackType {
if (element.type === "video" || element.type === "image") {
return "video";
}
return element.type;
}
}
@@ -0,0 +1,127 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type {
TimelineTrack,
TimelineElement,
TrackType,
} from "@/types/timeline";
import {
buildEmptyTrack,
isMainTrack,
validateElementTrackCompatibility,
} from "@/lib/timeline/track-utils";
export class MoveElementCommand extends Command {
private savedState: TimelineTrack[] | null = null;
constructor(
private sourceTrackId: string,
private targetTrackId: string,
private elementId: string,
private newStartTime: number,
private createTrack?: { type: TrackType; index: number },
) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const sourceTrack = this.savedState.find(
(t) => t.id === this.sourceTrackId,
);
const element = sourceTrack?.elements.find(
(el) => el.id === this.elementId,
);
if (!sourceTrack || !element) {
console.error("Source track or element not found");
return;
}
let targetTrack = this.savedState.find((t) => t.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) {
console.error("Target track not found");
return;
}
const validation = validateElementTrackCompatibility({
element,
track: targetTrack,
});
if (!validation.isValid) {
console.error(validation.errorMessage);
return;
}
const movedElement: TimelineElement = {
...element,
startTime: this.newStartTime,
};
const isSameTrack = this.sourceTrackId === this.targetTrackId;
let updatedTracks = tracksToUpdate.map((track) => {
if (isSameTrack && track.id === this.sourceTrackId) {
return {
...track,
elements: track.elements.map((el) =>
el.id === this.elementId ? movedElement : el,
),
};
}
if (track.id === this.sourceTrackId) {
return {
...track,
elements: track.elements.filter((el) => el.id !== this.elementId),
};
}
if (track.id === this.targetTrackId) {
return {
...track,
elements: [...track.elements, movedElement],
};
}
return track;
}) as TimelineTrack[];
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);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}
@@ -0,0 +1,133 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core";
export class SplitElementsCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private rightSideElements: { trackId: string; elementId: string }[] = [];
private previousSelection: { trackId: string; elementId: string }[] = [];
constructor(
private elements: { trackId: string; elementId: string }[],
private splitTime: number,
private retainSide: "both" | "left" | "right" = "both",
) {
super();
}
getRightSideElements(): { trackId: string; elementId: string }[] {
return this.rightSideElements;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
this.previousSelection = editor.selection.getSelectedElements();
this.rightSideElements = [];
const updatedTracks = this.savedState.map((track) => {
const elementsToSplit = this.elements.filter(
(el) => el.trackId === track.id,
);
if (elementsToSplit.length === 0) {
return track;
}
return {
...track,
elements: track.elements.flatMap((element) => {
const shouldSplit = elementsToSplit.some(
(el) => el.elementId === element.id,
);
if (!shouldSplit) {
return [element];
}
const effectiveStart = element.startTime;
const effectiveEnd = element.startTime + element.duration;
if (
this.splitTime <= effectiveStart ||
this.splitTime >= effectiveEnd
) {
return [element];
}
const relativeTime = this.splitTime - element.startTime;
const leftVisibleDuration = relativeTime;
const rightVisibleDuration = element.duration - relativeTime;
if (this.retainSide === "left") {
return [
{
...element,
duration: leftVisibleDuration,
trimEnd: element.trimEnd + rightVisibleDuration,
name: `${element.name} (left)`,
},
];
}
if (this.retainSide === "right") {
const newId = generateUUID();
this.rightSideElements.push({
trackId: track.id,
elementId: newId,
});
return [
{
...element,
id: newId,
startTime: this.splitTime,
duration: rightVisibleDuration,
trimStart: element.trimStart + leftVisibleDuration,
name: `${element.name} (right)`,
},
];
}
// "both" - split into two pieces
const secondElementId = generateUUID();
this.rightSideElements.push({
trackId: track.id,
elementId: secondElementId,
});
return [
{
...element,
duration: leftVisibleDuration,
trimEnd: element.trimEnd + rightVisibleDuration,
name: `${element.name} (left)`,
},
{
...element,
id: secondElementId,
startTime: this.splitTime,
duration: rightVisibleDuration,
trimStart: element.trimStart + leftVisibleDuration,
name: `${element.name} (right)`,
},
];
}),
} as typeof track;
});
editor.timeline.updateTracks(updatedTracks);
if (this.rightSideElements.length > 0) {
editor.selection.setSelectedElements({ elements: this.rightSideElements });
}
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
editor.selection.setSelectedElements({ elements: this.previousSelection });
}
}
}
@@ -0,0 +1,57 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import { canElementHaveAudio } from "@/lib/timeline/element-utils";
import { EditorCore } from "@/core";
export class ToggleElementsMutedCommand extends Command {
private savedState: TimelineTrack[] | null = null;
constructor(private elements: { trackId: string; elementId: string }[]) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const mutableElements = this.elements.filter(({ trackId, elementId }) => {
const track = this.savedState?.find((t) => t.id === trackId);
const element = track?.elements.find((e) => e.id === elementId);
return element && canElementHaveAudio(element);
});
if (mutableElements.length === 0) {
return;
}
const shouldMute = mutableElements.some(({ trackId, elementId }) => {
const track = this.savedState?.find((t) => t.id === trackId);
const element = track?.elements.find((e) => e.id === elementId);
return element && canElementHaveAudio(element) && !element.muted;
});
const updatedTracks = this.savedState.map((track) => {
const newElements = track.elements.map((element) => {
const shouldUpdate = mutableElements.some(
({ trackId, elementId }) =>
track.id === trackId && element.id === elementId,
);
return shouldUpdate &&
canElementHaveAudio(element) &&
element.muted !== shouldMute
? { ...element, muted: shouldMute }
: element;
});
return { ...track, elements: newElements } as typeof track;
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}
@@ -0,0 +1,47 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import { canElementBeHidden } from "@/lib/timeline/element-utils";
import { EditorCore } from "@/core";
export class ToggleElementsVisibilityCommand extends Command {
private savedState: TimelineTrack[] | null = null;
constructor(private elements: { trackId: string; elementId: string }[]) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const shouldHide = this.elements.some(({ trackId, elementId }) => {
const track = this.savedState?.find((t) => t.id === trackId);
const element = track?.elements.find((e) => e.id === elementId);
return element && canElementBeHidden(element) && !element.hidden;
});
const updatedTracks = this.savedState.map((track) => {
const newElements = track.elements.map((element) => {
const shouldUpdate = this.elements.some(
({ trackId, elementId }) =>
track.id === trackId && element.id === elementId,
);
return shouldUpdate &&
canElementBeHidden(element) &&
element.hidden !== shouldHide
? { ...element, hidden: shouldHide }
: element;
});
return { ...track, elements: newElements } as typeof track;
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}
@@ -0,0 +1,37 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import { EditorCore } from "@/core";
export class UpdateElementDurationCommand extends Command {
private savedState: TimelineTrack[] | null = null;
constructor(
private trackId: string,
private elementId: string,
private duration: number,
) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = this.savedState.map((t) => {
if (t.id !== this.trackId) return t;
const newElements = t.elements.map((el) =>
el.id === this.elementId ? { ...el, duration: this.duration } : el,
);
return { ...t, elements: newElements } as typeof t;
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}
@@ -0,0 +1,48 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import { EditorCore } from "@/core";
export class UpdateElementStartTimeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
constructor(
private elements: { trackId: string; elementId: string }[],
private startTime: number,
) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = this.savedState.map((track) => {
const hasElementsToUpdate = this.elements.some(
(el) => el.trackId === track.id,
);
if (!hasElementsToUpdate) {
return track;
}
const newElements = track.elements.map((element) => {
const shouldUpdate = this.elements.some(
(el) => el.elementId === element.id && el.trackId === track.id,
);
return shouldUpdate
? { ...element, startTime: Math.max(0, this.startTime) }
: element;
});
return { ...track, elements: newElements } as typeof track;
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}
@@ -0,0 +1,48 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import { EditorCore } from "@/core";
export class UpdateElementTrimCommand extends Command {
private savedState: TimelineTrack[] | null = null;
constructor(
private elementId: string,
private trimStart: number,
private trimEnd: number,
private startTime?: number,
private duration?: number,
) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = this.savedState.map((track) => {
const newElements = track.elements.map((element) => {
if (element.id !== this.elementId) {
return element;
}
return {
...element,
trimStart: this.trimStart,
trimEnd: this.trimEnd,
startTime: this.startTime ?? element.startTime,
duration: this.duration ?? element.duration,
};
});
return { ...track, elements: newElements } as typeof track;
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}
@@ -0,0 +1,52 @@
import { Command } from "@/lib/commands/base-command";
import type { TextElement, TimelineTrack } from "@/types/timeline";
import { EditorCore } from "@/core";
export class UpdateTextElementCommand extends Command {
private savedState: TimelineTrack[] | null = null;
constructor(
private trackId: string,
private elementId: string,
private updates: Partial<
Pick<
TextElement,
| "content"
| "fontSize"
| "fontFamily"
| "color"
| "backgroundColor"
| "textAlign"
| "fontWeight"
| "fontStyle"
| "textDecoration"
>
>,
) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = this.savedState.map((t) => {
if (t.id !== this.trackId) return t;
const newElements = t.elements.map((el) =>
el.id === this.elementId && el.type === "text"
? { ...el, ...this.updates }
: el,
);
return { ...t, elements: newElements } as typeof t;
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}
@@ -0,0 +1,3 @@
export * from "./track";
export * from "./element";
export * from "./clipboard";
@@ -0,0 +1,53 @@
import { Command } from "@/lib/commands/base-command";
import type { TrackType, TimelineTrack } from "@/types/timeline";
import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core";
import {
buildEmptyTrack,
getDefaultInsertIndexForTrack,
} from "@/lib/timeline/track-utils";
export class AddTrackCommand extends Command {
private trackId: string;
private savedState: TimelineTrack[] | null = null;
constructor(
private type: TrackType,
private index?: number,
) {
super();
this.trackId = generateUUID();
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const newTrack: TimelineTrack = buildEmptyTrack({
id: this.trackId,
type: this.type,
});
const updatedTracks = [...(this.savedState || [])];
const insertIndex =
this.index ??
getDefaultInsertIndexForTrack({
tracks: updatedTracks,
trackType: this.type,
});
updatedTracks.splice(insertIndex, 0, newTrack);
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
getTrackId(): string {
return this.trackId;
}
}
@@ -0,0 +1,4 @@
export { AddTrackCommand } from "./add-track";
export { RemoveTrackCommand } from "./remove-track";
export { ToggleTrackMuteCommand } from "./toggle-track-mute";
export { ToggleTrackVisibilityCommand } from "./toggle-track-visibility";
@@ -0,0 +1,35 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TimelineTrack } from "@/types/timeline";
import { getMainTrack } from "@/lib/timeline";
export class RemoveTrackCommand extends Command {
private savedState: TimelineTrack[] | null = null;
constructor(private trackId: string) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const targetTrack = this.savedState.find(
(track) => track.id === this.trackId,
);
const mainTrack = getMainTrack({ tracks: this.savedState });
if (mainTrack?.id === targetTrack?.id) {
return;
}
const updatedTracks = this.savedState.filter(
(track) => track.id !== this.trackId,
);
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}
@@ -0,0 +1,39 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import { EditorCore } from "@/core";
import { canTracktHaveAudio } from "@/lib/timeline";
export class ToggleTrackMuteCommand extends Command {
private savedState: TimelineTrack[] | null = null;
constructor(private trackId: string) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const targetTrack = this.savedState.find(
(track) => track.id === this.trackId,
);
if (!targetTrack) {
return;
}
const updatedTracks = this.savedState.map((track) =>
track.id === this.trackId && canTracktHaveAudio(track)
? { ...track, muted: !track.muted }
: track,
);
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}
@@ -0,0 +1,40 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import { EditorCore } from "@/core";
import { canTrackBeHidden } from "@/lib/timeline";
export class ToggleTrackVisibilityCommand extends Command {
private savedState: TimelineTrack[] | null = null;
constructor(private trackId: string) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const targetTrack = this.savedState.find(
(track) => track.id === this.trackId,
);
if (!targetTrack) {
return;
}
const updatedTracks = this.savedState.map((track) => {
if (track.id === this.trackId && canTrackBeHidden(track)) {
return { ...track, hidden: !track.hidden };
}
return track;
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}