summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/commands/timeline/clipboard/paste.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-03-27 00:55:15 +0100
committerMaze Winther <mazewinther@gmail.com>2026-03-29 15:56:48 +0200
commit4d127a038bf768eded68aae3cfa5c1b12241bdac (patch)
tree16bbb801caf6770546cff4fdb37b6408ec9ead98 /apps/web/src/lib/commands/timeline/clipboard/paste.ts
parent82817cb50cb0487350573d6129130aec705f924b (diff)
downloadopencut-4d127a038bf768eded68aae3cfa5c1b12241bdac.tar.gz
chore: normalize line endings
Diffstat (limited to 'apps/web/src/lib/commands/timeline/clipboard/paste.ts')
-rw-r--r--apps/web/src/lib/commands/timeline/clipboard/paste.ts586
1 files changed, 293 insertions, 293 deletions
diff --git a/apps/web/src/lib/commands/timeline/clipboard/paste.ts b/apps/web/src/lib/commands/timeline/clipboard/paste.ts
index 00758eb7..d9c54ec3 100644
--- a/apps/web/src/lib/commands/timeline/clipboard/paste.ts
+++ b/apps/web/src/lib/commands/timeline/clipboard/paste.ts
@@ -1,293 +1,293 @@
-import { Command } from "@/lib/commands/base-command";
-import { EditorCore } from "@/core";
-import type {
- TimelineTrack,
- TimelineElement,
- ClipboardItem,
-} from "@/lib/timeline";
-import { generateUUID } from "@/utils/id";
-import { wouldElementOverlap } from "@/lib/timeline/element-utils";
-import {
- buildEmptyTrack,
- getHighestInsertIndexForTrack,
- isMainTrack,
- enforceMainTrackStart,
-} from "@/lib/timeline/track-utils";
-import { cloneAnimations } from "@/lib/animation";
-
-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];
- let adjustedElements = elementsToAdd;
-
- if (isMainTrack(targetTrack)) {
- const earliestElement = elementsToAdd.reduce((earliest, element) =>
- element.startTime < earliest.startTime ? element : earliest,
- );
- const adjustedEarliestStartTime = enforceMainTrackStart({
- tracks: updatedTracks,
- targetTrackId: targetTrack.id,
- requestedStartTime: earliestElement.startTime,
- });
- const delta = adjustedEarliestStartTime - earliestElement.startTime;
-
- if (delta !== 0) {
- adjustedElements = elementsToAdd.map((element) => ({
- ...element,
- startTime: Math.max(0, element.startTime + delta),
- }));
- }
- }
-
- updatedTracks[resolvedTargetIndex] = {
- ...targetTrack,
- elements: [...targetTrack.elements, ...adjustedElements],
- } as TimelineTrack;
- for (const element of adjustedElements) {
- 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,
- animations: cloneAnimations({
- animations: item.element.animations,
- shouldRegenerateKeyframeIds: true,
- }),
- } 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;
-}
+import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import type {
+ TimelineTrack,
+ TimelineElement,
+ ClipboardItem,
+} from "@/lib/timeline";
+import { generateUUID } from "@/utils/id";
+import { wouldElementOverlap } from "@/lib/timeline/element-utils";
+import {
+ buildEmptyTrack,
+ getHighestInsertIndexForTrack,
+ isMainTrack,
+ enforceMainTrackStart,
+} from "@/lib/timeline/track-utils";
+import { cloneAnimations } from "@/lib/animation";
+
+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];
+ let adjustedElements = elementsToAdd;
+
+ if (isMainTrack(targetTrack)) {
+ const earliestElement = elementsToAdd.reduce((earliest, element) =>
+ element.startTime < earliest.startTime ? element : earliest,
+ );
+ const adjustedEarliestStartTime = enforceMainTrackStart({
+ tracks: updatedTracks,
+ targetTrackId: targetTrack.id,
+ requestedStartTime: earliestElement.startTime,
+ });
+ const delta = adjustedEarliestStartTime - earliestElement.startTime;
+
+ if (delta !== 0) {
+ adjustedElements = elementsToAdd.map((element) => ({
+ ...element,
+ startTime: Math.max(0, element.startTime + delta),
+ }));
+ }
+ }
+
+ updatedTracks[resolvedTargetIndex] = {
+ ...targetTrack,
+ elements: [...targetTrack.elements, ...adjustedElements],
+ } as TimelineTrack;
+ for (const element of adjustedElements) {
+ 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,
+ animations: cloneAnimations({
+ animations: item.element.animations,
+ shouldRegenerateKeyframeIds: true,
+ }),
+ } 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;
+}