diff options
| author | Maze <167211895+mazeincoding@users.noreply.github.com> | 2026-01-31 00:20:04 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-31 00:20:04 +0100 |
| commit | 7bf0984698e43b9aa2dd879f65951318ed410390 (patch) | |
| tree | ccf2cd590a47d550152afc07129cb55c71a9ec35 /apps/web/src/lib/commands/timeline | |
| parent | 0173db99448d1c4c6415dcf15272e17551b48b04 (diff) | |
| download | opencut-7bf0984698e43b9aa2dd879f65951318ed410390.tar.gz | |
codebase overhaul (#697)
Diffstat (limited to 'apps/web/src/lib/commands/timeline')
20 files changed, 1471 insertions, 0 deletions
diff --git a/apps/web/src/lib/commands/timeline/clipboard/index.ts b/apps/web/src/lib/commands/timeline/clipboard/index.ts new file mode 100644 index 00000000..11a8bed4 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/clipboard/index.ts @@ -0,0 +1 @@ +export { PasteCommand } from "./paste"; diff --git a/apps/web/src/lib/commands/timeline/clipboard/paste.ts b/apps/web/src/lib/commands/timeline/clipboard/paste.ts new file mode 100644 index 00000000..9c979790 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/clipboard/paste.ts @@ -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; +} diff --git a/apps/web/src/lib/commands/timeline/element/delete-elements.ts b/apps/web/src/lib/commands/timeline/element/delete-elements.ts new file mode 100644 index 00000000..91eb6397 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/delete-elements.ts @@ -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); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts new file mode 100644 index 00000000..4bec6444 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts @@ -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 }; +} diff --git a/apps/web/src/lib/commands/timeline/element/index.ts b/apps/web/src/lib/commands/timeline/element/index.ts new file mode 100644 index 00000000..8b719afa --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/index.ts @@ -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"; diff --git a/apps/web/src/lib/commands/timeline/element/insert-element.ts b/apps/web/src/lib/commands/timeline/element/insert-element.ts new file mode 100644 index 00000000..f95935a5 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/insert-element.ts @@ -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; + } +} diff --git a/apps/web/src/lib/commands/timeline/element/move-elements.ts b/apps/web/src/lib/commands/timeline/element/move-elements.ts new file mode 100644 index 00000000..2d4f9236 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/move-elements.ts @@ -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); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/element/split-elements.ts b/apps/web/src/lib/commands/timeline/element/split-elements.ts new file mode 100644 index 00000000..13d4d730 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/split-elements.ts @@ -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 }); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts b/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts new file mode 100644 index 00000000..1fa7421e --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts @@ -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); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts b/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts new file mode 100644 index 00000000..daeb759a --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts @@ -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); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/element/update-element-duration.ts b/apps/web/src/lib/commands/timeline/element/update-element-duration.ts new file mode 100644 index 00000000..6b3a860b --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/update-element-duration.ts @@ -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); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts b/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts new file mode 100644 index 00000000..5312000f --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts @@ -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); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/element/update-element-trim.ts b/apps/web/src/lib/commands/timeline/element/update-element-trim.ts new file mode 100644 index 00000000..99c63287 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/update-element-trim.ts @@ -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); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/element/update-text-element.ts b/apps/web/src/lib/commands/timeline/element/update-text-element.ts new file mode 100644 index 00000000..e3cc4a32 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/update-text-element.ts @@ -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); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/index.ts b/apps/web/src/lib/commands/timeline/index.ts new file mode 100644 index 00000000..30bc81ef --- /dev/null +++ b/apps/web/src/lib/commands/timeline/index.ts @@ -0,0 +1,3 @@ +export * from "./track"; +export * from "./element"; +export * from "./clipboard"; diff --git a/apps/web/src/lib/commands/timeline/track/add-track.ts b/apps/web/src/lib/commands/timeline/track/add-track.ts new file mode 100644 index 00000000..95338888 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/track/add-track.ts @@ -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; + } +} diff --git a/apps/web/src/lib/commands/timeline/track/index.ts b/apps/web/src/lib/commands/timeline/track/index.ts new file mode 100644 index 00000000..9d4bd9c3 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/track/index.ts @@ -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"; diff --git a/apps/web/src/lib/commands/timeline/track/remove-track.ts b/apps/web/src/lib/commands/timeline/track/remove-track.ts new file mode 100644 index 00000000..a32aacd7 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/track/remove-track.ts @@ -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); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts b/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts new file mode 100644 index 00000000..3fe526c7 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts @@ -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); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts b/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts new file mode 100644 index 00000000..e81714ac --- /dev/null +++ b/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts @@ -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); + } + } +} |
