diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
| commit | 3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch) | |
| tree | 57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/commands/timeline/clipboard | |
| parent | 729d10592f4e802f91c555c309ff3ba4b119f843 (diff) | |
| download | opencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz | |
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/commands/timeline/clipboard')
| -rw-r--r-- | apps/web/src/commands/timeline/clipboard/index.ts | 2 | ||||
| -rw-r--r-- | apps/web/src/commands/timeline/clipboard/paste-keyframes.ts | 135 | ||||
| -rw-r--r-- | apps/web/src/commands/timeline/clipboard/paste.ts | 193 |
3 files changed, 330 insertions, 0 deletions
diff --git a/apps/web/src/commands/timeline/clipboard/index.ts b/apps/web/src/commands/timeline/clipboard/index.ts new file mode 100644 index 00000000..e893b62b --- /dev/null +++ b/apps/web/src/commands/timeline/clipboard/index.ts @@ -0,0 +1,2 @@ +export { PasteCommand } from "./paste"; +export { PasteKeyframesCommand } from "./paste-keyframes"; diff --git a/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts b/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts new file mode 100644 index 00000000..e05b49fa --- /dev/null +++ b/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts @@ -0,0 +1,135 @@ +import { EditorCore } from "@/core"; +import { + getKeyframeAtTime, + resolveAnimationTarget, + updateScalarKeyframeCurve, + upsertPathKeyframe, +} from "@/animation"; +import { Command, type CommandResult } from "@/commands/base-command"; +import type { KeyframeClipboardItem } from "@/clipboard"; +import type { SceneTracks, TimelineElement } from "@/timeline"; +import { updateElementInSceneTracks } from "@/timeline"; +import { generateUUID } from "@/utils/id"; + +function pasteKeyframesIntoElement({ + element, + time, + clipboardItems, +}: { + element: TimelineElement; + time: number; + clipboardItems: KeyframeClipboardItem[]; +}): TimelineElement { + let nextElement = element; + + for (const item of clipboardItems) { + const target = resolveAnimationTarget({ + element: nextElement, + path: item.propertyPath, + }); + if (!target) { + continue; + } + + const keyframeTime = Math.max( + 0, + Math.min(time + item.timeOffset, nextElement.duration), + ); + const nextAnimations = upsertPathKeyframe({ + animations: nextElement.animations, + propertyPath: item.propertyPath, + time: keyframeTime, + value: item.value, + interpolation: item.interpolation, + keyframeId: generateUUID(), + kind: target.kind, + defaultInterpolation: target.defaultInterpolation, + coerceValue: target.coerceValue, + }); + const pastedKeyframe = getKeyframeAtTime({ + animations: nextAnimations, + propertyPath: item.propertyPath, + time: keyframeTime, + }); + + let patchedAnimations = nextAnimations; + if (pastedKeyframe) { + for (const curvePatch of item.curvePatches) { + const nextPatchedAnimations = updateScalarKeyframeCurve({ + animations: patchedAnimations, + propertyPath: item.propertyPath, + componentKey: curvePatch.componentKey, + keyframeId: pastedKeyframe.id, + patch: curvePatch.patch, + }); + patchedAnimations = nextPatchedAnimations ?? patchedAnimations; + } + } + + nextElement = { + ...nextElement, + animations: patchedAnimations, + }; + } + + return nextElement; +} + +export class PasteKeyframesCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly time: number; + private readonly clipboardItems: KeyframeClipboardItem[]; + + constructor({ + trackId, + elementId, + time, + clipboardItems, + }: { + trackId: string; + elementId: string; + time: number; + clipboardItems: KeyframeClipboardItem[]; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.time = time; + this.clipboardItems = clipboardItems; + } + + execute(): CommandResult | undefined { + if (this.clipboardItems.length === 0) { + return undefined; + } + + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + update: (element) => + pasteKeyframesIntoElement({ + element, + time: this.time, + clipboardItems: this.clipboardItems, + }), + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (!this.savedState) { + return; + } + + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } +} diff --git a/apps/web/src/commands/timeline/clipboard/paste.ts b/apps/web/src/commands/timeline/clipboard/paste.ts new file mode 100644 index 00000000..4523566e --- /dev/null +++ b/apps/web/src/commands/timeline/clipboard/paste.ts @@ -0,0 +1,193 @@ +import { + Command, + createElementSelectionResult, + type CommandResult, +} from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import type { SceneTracks, TimelineElement } from "@/timeline"; +import type { ElementClipboardItem } from "@/clipboard"; +import { generateUUID } from "@/utils/id"; +import { + applyPlacement, + resolveTrackPlacement, + enforceMainTrackStart, +} from "@/timeline/placement"; +import { cloneAnimations } from "@/animation"; + +export class PasteCommand extends Command { + private savedState: SceneTracks | null = null; + private pastedElements: { trackId: string; elementId: string }[] = []; + private readonly time: number; + private readonly clipboardItems: ElementClipboardItem[]; + + constructor({ + time, + clipboardItems, + }: { + time: number; + clipboardItems: ElementClipboardItem[]; + }) { + super(); + this.time = time; + this.clipboardItems = clipboardItems; + } + + execute(): CommandResult | undefined { + if (this.clipboardItems.length === 0) return undefined; + + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + this.pastedElements = []; + + const minStart = Math.min( + ...this.clipboardItems.map((item) => item.element.startTime), + ); + + let updatedTracks = this.savedState; + const itemsByTrackId = groupClipboardItemsByTrackId({ + clipboardItems: this.clipboardItems, + }); + + for (const [trackId, items] of itemsByTrackId) { + const elementsToAdd = buildPastedElements({ + items, + minStart, + time: this.time, + }); + + if (elementsToAdd.length === 0) { + continue; + } + + const trackType = items[0].trackType; + const sourceTrackIndex = [ + ...updatedTracks.overlay, + updatedTracks.main, + ...updatedTracks.audio, + ].findIndex((track) => track.id === trackId); + const placementResult = resolveTrackPlacement({ + tracks: updatedTracks, + trackType, + timeSpans: elementsToAdd.map((element) => ({ + startTime: element.startTime, + duration: element.duration, + })), + strategy: { type: "aboveSource", sourceTrackIndex }, + }); + if (!placementResult) { + continue; + } + + let elementsForPlacement = elementsToAdd; + if (placementResult.kind === "existingTrack") { + const targetTrack = + placementResult.trackIndex < updatedTracks.overlay.length + ? updatedTracks.overlay[placementResult.trackIndex] + : placementResult.trackIndex === updatedTracks.overlay.length + ? updatedTracks.main + : updatedTracks.audio[ + placementResult.trackIndex - updatedTracks.overlay.length - 1 + ]; + if (targetTrack?.id === updatedTracks.main.id) { + const earliestElement = elementsToAdd.reduce((earliest, element) => + element.startTime < earliest.startTime ? element : earliest, + ); + const adjustedEarliestStartTime = enforceMainTrackStart({ + tracks: updatedTracks, + targetTrackId: targetTrack.id, + requestedStartTime: earliestElement.startTime, + }); + const delta = adjustedEarliestStartTime - earliestElement.startTime; + + if (delta !== 0) { + elementsForPlacement = elementsToAdd.map((element) => ({ + ...element, + startTime: Math.max(0, element.startTime + delta), + })); + } + } + } + + const applied = applyPlacement({ + tracks: updatedTracks, + placementResult, + elements: elementsForPlacement, + }); + if (!applied) { + continue; + } + + updatedTracks = applied.updatedTracks; + + for (const element of elementsForPlacement) { + this.pastedElements.push({ + trackId: applied.targetTrackId, + elementId: element.id, + }); + } + } + + editor.timeline.updateTracks(updatedTracks); + + if (this.pastedElements.length > 0) { + return createElementSelectionResult(this.pastedElements); + } + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } + + getPastedElements(): { trackId: string; elementId: string }[] { + return this.pastedElements; + } +} + +function groupClipboardItemsByTrackId({ + clipboardItems, +}: { + clipboardItems: ElementClipboardItem[]; +}): Map<string, ElementClipboardItem[]> { + const groupedItems = new Map<string, ElementClipboardItem[]>(); + + 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: ElementClipboardItem[]; + 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; +} |
