import { getKeyframeById } from "@/animation"; import type { SelectedKeyframeRef } from "@/animation/types"; import type { TimelineElement } from "@/timeline"; import { PasteKeyframesCommand } from "@/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 & { 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">;