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/clipboard/paste.ts | |
| parent | 0173db99448d1c4c6415dcf15272e17551b48b04 (diff) | |
| download | opencut-7bf0984698e43b9aa2dd879f65951318ed410390.tar.gz | |
codebase overhaul (#697)
Diffstat (limited to 'apps/web/src/lib/commands/timeline/clipboard/paste.ts')
| -rw-r--r-- | apps/web/src/lib/commands/timeline/clipboard/paste.ts | 263 |
1 files changed, 263 insertions, 0 deletions
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; +} |
