diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-13 04:40:56 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-13 05:03:10 +0200 |
| commit | 2272598d420cf078aaa3928246bfa3a414f6eddb (patch) | |
| tree | b52460a2e3576c9e517ef8a9dd86959a68f31b69 /apps/web/src/lib/clipboard/handlers | |
| parent | 5a2aaf3601b6b050603f51e487a31fb07862a21f (diff) | |
| download | opencut-2272598d420cf078aaa3928246bfa3a414f6eddb.tar.gz | |
feat: clipboard with keyframe paste
Made-with: Cursor
Diffstat (limited to 'apps/web/src/lib/clipboard/handlers')
| -rw-r--r-- | apps/web/src/lib/clipboard/handlers/elements.ts | 48 | ||||
| -rw-r--r-- | apps/web/src/lib/clipboard/handlers/index.ts | 49 | ||||
| -rw-r--r-- | apps/web/src/lib/clipboard/handlers/keyframes.ts | 170 |
3 files changed, 267 insertions, 0 deletions
diff --git a/apps/web/src/lib/clipboard/handlers/elements.ts b/apps/web/src/lib/clipboard/handlers/elements.ts new file mode 100644 index 00000000..1b58ac78 --- /dev/null +++ b/apps/web/src/lib/clipboard/handlers/elements.ts @@ -0,0 +1,48 @@ +import { PasteCommand } from "@/lib/commands/timeline"; +import type { ClipboardHandler } from "../types"; + +export const ElementsClipboardHandler = { + type: "elements", + + canCopy({ selectedElements }) { + return selectedElements.length > 0; + }, + + copy({ editor, selectedElements }) { + if (selectedElements.length === 0) { + return null; + } + + const results = editor.timeline.getElementsWithTracks({ + elements: selectedElements, + }); + const items = results.map(({ track, element }) => { + const { id: _elementId, ...elementWithoutId } = element; + return { + trackId: track.id, + trackType: track.type, + element: elementWithoutId, + }; + }); + + if (items.length === 0) { + return null; + } + + return { + type: "elements", + items, + }; + }, + + paste(entry, context) { + if (entry.items.length === 0) { + return null; + } + + return new PasteCommand({ + time: context.time, + clipboardItems: entry.items, + }); + }, +} satisfies ClipboardHandler<"elements">; diff --git a/apps/web/src/lib/clipboard/handlers/index.ts b/apps/web/src/lib/clipboard/handlers/index.ts new file mode 100644 index 00000000..29d4d1d0 --- /dev/null +++ b/apps/web/src/lib/clipboard/handlers/index.ts @@ -0,0 +1,49 @@ +import type { + ClipboardEntry, + ClipboardEntryType, + ClipboardHandler, + ClipboardHandlerMap, + CopyContext, + PasteContext, +} from "../types"; +import { ElementsClipboardHandler } from "./elements"; +import { KeyframesClipboardHandler } from "./keyframes"; + +export const clipboardHandlers = { + elements: ElementsClipboardHandler, + keyframes: KeyframesClipboardHandler, +} satisfies ClipboardHandlerMap; + +export const clipboardCopyHandlers = [ + KeyframesClipboardHandler, + ElementsClipboardHandler, +] as const satisfies readonly ClipboardHandler<ClipboardEntryType>[]; + +export function copyClipboardEntry({ + context, +}: { + context: CopyContext; +}): ClipboardEntry | null { + for (const handler of clipboardCopyHandlers) { + if (!handler.canCopy(context)) { + continue; + } + + return handler.copy(context); + } + + return null; +} + +export function buildPasteClipboardCommand({ + entry, + context, +}: { + entry: ClipboardEntry; + context: PasteContext; +}) { + const handler = clipboardHandlers[entry.type] as ClipboardHandler< + typeof entry.type + >; + return handler.paste(entry as never, context); +} diff --git a/apps/web/src/lib/clipboard/handlers/keyframes.ts b/apps/web/src/lib/clipboard/handlers/keyframes.ts new file mode 100644 index 00000000..b746c179 --- /dev/null +++ b/apps/web/src/lib/clipboard/handlers/keyframes.ts @@ -0,0 +1,170 @@ +import { getKeyframeById } from "@/lib/animation"; +import type { SelectedKeyframeRef } from "@/lib/animation/types"; +import type { TimelineElement } from "@/lib/timeline"; +import { PasteKeyframesCommand } from "@/lib/commands/timeline"; +import type { + ClipboardHandler, + KeyframeClipboardCurvePatch, + KeyframeClipboardItem, +} from "../types"; + +function resolveSingleSourceElement({ + selectedKeyframes, +}: { + selectedKeyframes: SelectedKeyframeRef[]; +}) { + const firstKeyframe = selectedKeyframes[0]; + if (!firstKeyframe) { + return null; + } + + const sourceElement = { + trackId: firstKeyframe.trackId, + elementId: firstKeyframe.elementId, + }; + const isSingleSource = selectedKeyframes.every( + (keyframe) => + keyframe.trackId === sourceElement.trackId && + keyframe.elementId === sourceElement.elementId, + ); + + return isSingleSource ? sourceElement : null; +} + +function getCurvePatches({ + element, + propertyPath, + keyframeId, +}: { + element: TimelineElement; + propertyPath: KeyframeClipboardItem["propertyPath"]; + keyframeId: string; +}): KeyframeClipboardCurvePatch[] { + const binding = element.animations?.bindings[propertyPath]; + if (!binding) { + return []; + } + + return binding.components.flatMap((component) => { + const channel = element.animations?.channels[component.channelId]; + if (channel?.kind !== "scalar") { + return []; + } + + const keyframe = channel.keys.find( + (candidate) => candidate.id === keyframeId, + ); + if (!keyframe) { + return []; + } + + return [ + { + componentKey: component.key, + patch: { + leftHandle: keyframe.leftHandle ?? null, + rightHandle: keyframe.rightHandle ?? null, + segmentToNext: keyframe.segmentToNext, + tangentMode: keyframe.tangentMode, + }, + }, + ]; + }); +} + +function buildClipboardItem({ + element, + propertyPath, + keyframeId, +}: { + element: TimelineElement; + propertyPath: KeyframeClipboardItem["propertyPath"]; + keyframeId: string; +}): (Omit<KeyframeClipboardItem, "timeOffset"> & { time: number }) | null { + const keyframe = getKeyframeById({ + animations: element.animations, + propertyPath, + keyframeId, + }); + if (!keyframe) { + return null; + } + + return { + propertyPath, + time: keyframe.time, + value: keyframe.value, + interpolation: keyframe.interpolation, + curvePatches: getCurvePatches({ + element, + propertyPath, + keyframeId, + }), + }; +} + +export const KeyframesClipboardHandler = { + type: "keyframes", + + canCopy({ selectedKeyframes }) { + return selectedKeyframes.length > 0; + }, + + copy({ editor, selectedKeyframes }) { + const sourceElement = resolveSingleSourceElement({ selectedKeyframes }); + if (!sourceElement) { + return null; + } + + const [result] = editor.timeline.getElementsWithTracks({ + elements: [sourceElement], + }); + if (!result) { + return null; + } + + const rawItems = selectedKeyframes.flatMap((keyframeRef) => { + const item = buildClipboardItem({ + element: result.element, + propertyPath: keyframeRef.propertyPath, + keyframeId: keyframeRef.keyframeId, + }); + return item ? [item] : []; + }); + if (rawItems.length === 0) { + return null; + } + + const minTime = Math.min(...rawItems.map((item) => item.time)); + const items = rawItems + .map(({ time, ...item }) => ({ + ...item, + timeOffset: time - minTime, + })) + .sort( + (left, right) => + left.timeOffset - right.timeOffset || + left.propertyPath.localeCompare(right.propertyPath), + ); + + return { + type: "keyframes", + sourceElement, + items, + }; + }, + + paste(entry, { selectedElements, time }) { + const targetElement = selectedElements[0]; + if (!targetElement || entry.items.length === 0) { + return null; + } + + return new PasteKeyframesCommand({ + trackId: targetElement.trackId, + elementId: targetElement.elementId, + time, + clipboardItems: entry.items, + }); + }, +} satisfies ClipboardHandler<"keyframes">; |
