From 2272598d420cf078aaa3928246bfa3a414f6eddb Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 13 Apr 2026 04:40:56 +0200 Subject: [PATCH] feat: clipboard with keyframe paste Made-with: Cursor --- apps/web/src/core/index.ts | 4 + .../src/core/managers/clipboard-manager.ts | 81 +++++++++ .../src/hooks/actions/use-editor-actions.ts | 43 ++--- apps/web/src/hooks/use-editor.ts | 117 ++++++------ apps/web/src/hooks/use-keybindings.ts | 8 +- apps/web/src/hooks/use-paste-media.ts | 11 +- .../src/lib/clipboard/handlers/elements.ts | 48 +++++ apps/web/src/lib/clipboard/handlers/index.ts | 49 +++++ .../src/lib/clipboard/handlers/keyframes.ts | 170 ++++++++++++++++++ apps/web/src/lib/clipboard/index.ts | 2 + apps/web/src/lib/clipboard/types.ts | 79 ++++++++ .../lib/commands/timeline/clipboard/index.ts | 1 + .../timeline/clipboard/paste-keyframes.ts | 135 ++++++++++++++ .../lib/commands/timeline/clipboard/paste.ts | 19 +- .../element/keyframes/remove-keyframe.ts | 56 ++---- apps/web/src/lib/timeline/types.ts | 2 + apps/web/src/stores/timeline-store.ts | 113 ++++++------ 17 files changed, 727 insertions(+), 211 deletions(-) create mode 100644 apps/web/src/core/managers/clipboard-manager.ts create mode 100644 apps/web/src/lib/clipboard/handlers/elements.ts create mode 100644 apps/web/src/lib/clipboard/handlers/index.ts create mode 100644 apps/web/src/lib/clipboard/handlers/keyframes.ts create mode 100644 apps/web/src/lib/clipboard/index.ts create mode 100644 apps/web/src/lib/clipboard/types.ts create mode 100644 apps/web/src/lib/commands/timeline/clipboard/paste-keyframes.ts diff --git a/apps/web/src/core/index.ts b/apps/web/src/core/index.ts index 8ee76e28..e93b937d 100644 --- a/apps/web/src/core/index.ts +++ b/apps/web/src/core/index.ts @@ -8,6 +8,7 @@ import { CommandManager } from "./managers/commands"; import { SaveManager } from "./managers/save-manager"; import { AudioManager } from "./managers/audio-manager"; import { SelectionManager } from "./managers/selection-manager"; +import { ClipboardManager } from "./managers/clipboard-manager"; import { registerDefaultEffects } from "@/lib/effects"; import { registerDefaultMasks } from "@/lib/masks"; @@ -23,6 +24,7 @@ export class EditorCore { public readonly save: SaveManager; public readonly audio: AudioManager; public readonly selection: SelectionManager; + public readonly clipboard: ClipboardManager; private constructor() { registerDefaultEffects(); @@ -37,6 +39,8 @@ export class EditorCore { this.save = new SaveManager(this); this.audio = new AudioManager(this); this.selection = new SelectionManager(this); + this.clipboard = new ClipboardManager(this); + this.playback.bindTimelineScope(); this.command.registerReactor(() => { const activeScene = this.scenes.getActiveSceneOrNull(); if (!activeScene) { diff --git a/apps/web/src/core/managers/clipboard-manager.ts b/apps/web/src/core/managers/clipboard-manager.ts new file mode 100644 index 00000000..b19343df --- /dev/null +++ b/apps/web/src/core/managers/clipboard-manager.ts @@ -0,0 +1,81 @@ +import type { EditorCore } from "@/core"; +import { + buildPasteClipboardCommand, + copyClipboardEntry, + type ClipboardEntry, + type CopyContext, + type PasteContext, +} from "@/lib/clipboard"; + +export class ClipboardManager { + private entry: ClipboardEntry | null = null; + private listeners = new Set<() => void>(); + + constructor(private editor: EditorCore) {} + + getEntry(): ClipboardEntry | null { + return this.entry; + } + + hasEntry(): boolean { + return this.entry !== null; + } + + copy(): boolean { + const entry = copyClipboardEntry({ + context: this.getCopyContext(), + }); + if (!entry) { + return false; + } + + this.entry = entry; + this.notify(); + return true; + } + + paste({ time }: { time?: number } = {}): boolean { + if (!this.entry) { + return false; + } + + const command = buildPasteClipboardCommand({ + entry: this.entry, + context: this.getPasteContext({ time }), + }); + if (!command) { + return false; + } + + this.editor.command.execute({ command }); + return true; + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private getCopyContext(): CopyContext { + return { + editor: this.editor, + selectedElements: this.editor.selection.getSelectedElements(), + selectedKeyframes: this.editor.selection.getSelectedKeyframes(), + }; + } + + private getPasteContext({ time }: { time?: number }): PasteContext { + return { + editor: this.editor, + selectedElements: this.editor.selection.getSelectedElements(), + selectedKeyframes: this.editor.selection.getSelectedKeyframes(), + time: time ?? this.editor.playback.getCurrentTime(), + }; + } + + private notify(): void { + this.listeners.forEach((listener) => { + listener(); + }); + } +} diff --git a/apps/web/src/hooks/actions/use-editor-actions.ts b/apps/web/src/hooks/actions/use-editor-actions.ts index be04a7d3..1c4735bb 100644 --- a/apps/web/src/hooks/actions/use-editor-actions.ts +++ b/apps/web/src/hooks/actions/use-editor-actions.ts @@ -7,10 +7,7 @@ import { useEditor } from "../use-editor"; import { useElementSelection } from "../timeline/element/use-element-selection"; import { TICKS_PER_SECOND } from "@/lib/wasm"; import { useKeyframeSelection } from "../timeline/element/use-keyframe-selection"; -import { - getElementsAtTime, - hasMediaId, -} from "@/lib/timeline"; +import { getElementsAtTime, hasMediaId } from "@/lib/timeline"; import { cancelInteraction } from "@/lib/cancel-interaction"; import { invokeAction } from "@/lib/actions"; import { canToggleSourceAudio } from "@/lib/timeline/audio-separation"; @@ -24,8 +21,6 @@ export function useEditorActions() { const editor = useEditor(); const { selectedElements, setElementSelection } = useElementSelection(); const { selectedKeyframes, clearKeyframeSelection } = useKeyframeSelection(); - const clipboard = useTimelineStore((s) => s.clipboard); - const setClipboard = useTimelineStore((s) => s.setClipboard); const toggleSnapping = useTimelineStore((s) => s.toggleSnapping); const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled); const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing); @@ -111,7 +106,9 @@ export function useEditorActions() { "frame-step-forward", () => { const fps = editor.project.getActive().settings.fps; - const ticksPerFrame = Math.round(TICKS_PER_SECOND * fps.denominator / fps.numerator); + const ticksPerFrame = Math.round( + (TICKS_PER_SECOND * fps.denominator) / fps.numerator, + ); editor.playback.seek({ time: Math.min( editor.timeline.getTotalDuration(), @@ -126,7 +123,9 @@ export function useEditorActions() { "frame-step-backward", () => { const fps = editor.project.getActive().settings.fps; - const ticksPerFrame = Math.round(TICKS_PER_SECOND * fps.denominator / fps.numerator); + const ticksPerFrame = Math.round( + (TICKS_PER_SECOND * fps.denominator) / fps.numerator, + ); editor.playback.seek({ time: Math.max(0, editor.playback.getCurrentTime() - ticksPerFrame), }); @@ -294,8 +293,9 @@ export function useEditorActions() { } return ( - editor.media.getAssets().find((asset) => asset.id === element.mediaId) ?? - null + editor.media + .getAssets() + .find((asset) => asset.id === element.mediaId) ?? null ); })(); if (!canToggleSourceAudio(selectedElement.element, mediaAsset)) { @@ -387,21 +387,7 @@ export function useEditorActions() { useActionHandler( "copy-selected", () => { - if (selectedElements.length === 0) return; - - const results = editor.timeline.getElementsWithTracks({ - elements: selectedElements, - }); - const items = results.map(({ track, element }) => { - const { id: _, ...elementWithoutId } = element; - return { - trackId: track.id, - trackType: track.type, - element: elementWithoutId, - }; - }); - - setClipboard({ items }); + editor.clipboard.copy(); }, undefined, ); @@ -409,12 +395,7 @@ export function useEditorActions() { useActionHandler( "paste-copied", () => { - if (!clipboard?.items.length) return; - - editor.timeline.pasteAtTime({ - time: editor.playback.getCurrentTime(), - clipboardItems: clipboard.items, - }); + editor.clipboard.paste(); }, undefined, ); diff --git a/apps/web/src/hooks/use-editor.ts b/apps/web/src/hooks/use-editor.ts index bafa6cf7..1a5ca8ae 100644 --- a/apps/web/src/hooks/use-editor.ts +++ b/apps/web/src/hooks/use-editor.ts @@ -1,58 +1,59 @@ -import { useCallback, useMemo, useRef, useSyncExternalStore } from "react"; -import { EditorCore } from "@/core"; - -function isShallowEqual(a: unknown, b: unknown): boolean { - if (Object.is(a, b)) return true; - if (!Array.isArray(a) || !Array.isArray(b)) return false; - if (a.length !== b.length) return false; - return a.every((item, i) => Object.is(item, b[i])); -} - -const subscribeNone = () => () => {}; - -export function useEditor(): EditorCore; -export function useEditor(selector: (editor: EditorCore) => T): T; -export function useEditor( - selector?: (editor: EditorCore) => T, -): EditorCore | T { - const editor = useMemo(() => EditorCore.getInstance(), []); - const selectorRef = useRef(selector); - selectorRef.current = selector; - - const snapshotCacheRef = useRef(undefined); - - const subscribeAll = useCallback( - (onChange: () => void) => { - const unsubscribers = [ - editor.playback.subscribe(onChange), - editor.timeline.subscribe(onChange), - editor.scenes.subscribe(onChange), - editor.project.subscribe(onChange), - editor.media.subscribe(onChange), - editor.renderer.subscribe(onChange), - editor.selection.subscribe(onChange), - ]; - return () => { - unsubscribers.forEach((unsubscribe) => { - unsubscribe(); - }); - }; - }, - [editor], - ); - - const getSnapshot = useCallback(() => { - const next = selectorRef.current ? selectorRef.current(editor) : editor; - if (isShallowEqual(snapshotCacheRef.current, next)) { - return snapshotCacheRef.current; - } - snapshotCacheRef.current = next; - return next; - }, [editor]); - - return useSyncExternalStore( - selector ? subscribeAll : subscribeNone, - getSnapshot, - getSnapshot, - ) as EditorCore | T; -} +import { useCallback, useMemo, useRef, useSyncExternalStore } from "react"; +import { EditorCore } from "@/core"; + +function isShallowEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true; + if (!Array.isArray(a) || !Array.isArray(b)) return false; + if (a.length !== b.length) return false; + return a.every((item, i) => Object.is(item, b[i])); +} + +const subscribeNone = () => () => {}; + +export function useEditor(): EditorCore; +export function useEditor(selector: (editor: EditorCore) => T): T; +export function useEditor( + selector?: (editor: EditorCore) => T, +): EditorCore | T { + const editor = useMemo(() => EditorCore.getInstance(), []); + const selectorRef = useRef(selector); + selectorRef.current = selector; + + const snapshotCacheRef = useRef(undefined); + + const subscribeAll = useCallback( + (onChange: () => void) => { + const unsubscribers = [ + editor.playback.subscribe(onChange), + editor.timeline.subscribe(onChange), + editor.scenes.subscribe(onChange), + editor.project.subscribe(onChange), + editor.media.subscribe(onChange), + editor.renderer.subscribe(onChange), + editor.selection.subscribe(onChange), + editor.clipboard.subscribe(onChange), + ]; + return () => { + unsubscribers.forEach((unsubscribe) => { + unsubscribe(); + }); + }; + }, + [editor], + ); + + const getSnapshot = useCallback(() => { + const next = selectorRef.current ? selectorRef.current(editor) : editor; + if (isShallowEqual(snapshotCacheRef.current, next)) { + return snapshotCacheRef.current; + } + snapshotCacheRef.current = next; + return next; + }, [editor]); + + return useSyncExternalStore( + selector ? subscribeAll : subscribeNone, + getSnapshot, + getSnapshot, + ) as EditorCore | T; +} diff --git a/apps/web/src/hooks/use-keybindings.ts b/apps/web/src/hooks/use-keybindings.ts index 69a4e847..3f96c340 100644 --- a/apps/web/src/hooks/use-keybindings.ts +++ b/apps/web/src/hooks/use-keybindings.ts @@ -1,7 +1,7 @@ import { useEffect } from "react"; import { invokeAction } from "@/lib/actions"; +import { useEditor } from "@/hooks/use-editor"; import { useKeybindingsStore } from "@/stores/keybindings-store"; -import { useTimelineStore } from "@/stores/timeline-store"; import { isTypableDOMElement } from "@/utils/browser"; /** @@ -10,6 +10,7 @@ import { isTypableDOMElement } from "@/utils/browser"; * the appropriate actions based on keybindings */ export function useKeybindingsListener() { + const editor = useEditor(); const { keybindings, getKeybindingString, @@ -17,7 +18,6 @@ export function useKeybindingsListener() { isLoadingProject, isRecording, } = useKeybindingsStore(); - const clipboard = useTimelineStore((state) => state.clipboard); useEffect(() => { const eventOptions: AddEventListenerOptions = { capture: true }; @@ -140,7 +140,7 @@ export function useKeybindingsListener() { if (isTextInput) return; if (boundAction === "paste-copied") { - if (!clipboard?.items.length) return; + if (!editor.clipboard.hasEntry()) return; ev.preventDefault(); invokeAction("paste-copied", undefined, "keypress"); return; @@ -177,6 +177,6 @@ export function useKeybindingsListener() { overlayDepth, isLoadingProject, isRecording, - clipboard, + editor, ]); } diff --git a/apps/web/src/hooks/use-paste-media.ts b/apps/web/src/hooks/use-paste-media.ts index ca821d6b..95020a04 100644 --- a/apps/web/src/hooks/use-paste-media.ts +++ b/apps/web/src/hooks/use-paste-media.ts @@ -2,7 +2,6 @@ import { useEffect } from "react"; import { useEditor } from "@/hooks/use-editor"; import { processMediaAssets } from "@/lib/media/processing"; import { showMediaUploadToast } from "@/lib/media/upload-toast"; -import { invokeAction } from "@/lib/actions"; import { buildElementFromMedia } from "@/lib/timeline/element-utils"; import { AddMediaAssetCommand } from "@/lib/commands/media"; import { InsertElementCommand } from "@/lib/commands/timeline"; @@ -52,7 +51,7 @@ export function usePasteMedia() { }); if (files.length === 0) { event.preventDefault(); - invokeAction("paste-copied"); + editor.clipboard.paste(); return; } @@ -74,10 +73,10 @@ export function usePasteMedia() { asset, ); const assetId = addMediaCmd.getAssetId(); - const duration = - asset.duration != null - ? Math.round(asset.duration * TICKS_PER_SECOND) - : DEFAULT_NEW_ELEMENT_DURATION; + const duration = + asset.duration != null + ? Math.round(asset.duration * TICKS_PER_SECOND) + : DEFAULT_NEW_ELEMENT_DURATION; const trackType = asset.type === "audio" ? "audio" : "video"; const element = buildElementFromMedia({ 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[]; + +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 & { 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">; diff --git a/apps/web/src/lib/clipboard/index.ts b/apps/web/src/lib/clipboard/index.ts new file mode 100644 index 00000000..76d5bddf --- /dev/null +++ b/apps/web/src/lib/clipboard/index.ts @@ -0,0 +1,2 @@ +export * from "./types"; +export * from "./handlers"; diff --git a/apps/web/src/lib/clipboard/types.ts b/apps/web/src/lib/clipboard/types.ts new file mode 100644 index 00000000..41e107ef --- /dev/null +++ b/apps/web/src/lib/clipboard/types.ts @@ -0,0 +1,79 @@ +import type { EditorCore } from "@/core"; +import type { + AnimationInterpolation, + AnimationPath, + AnimationValue, + ScalarCurveKeyframePatch, + SelectedKeyframeRef, +} from "@/lib/animation/types"; +import type { Command } from "@/lib/commands/base-command"; +import type { + CreateTimelineElement, + ElementRef, + TrackType, +} from "@/lib/timeline"; + +export interface ElementClipboardItem { + trackId: string; + trackType: TrackType; + element: CreateTimelineElement; +} + +export interface KeyframeClipboardCurvePatch { + componentKey: string; + patch: ScalarCurveKeyframePatch; +} + +export interface KeyframeClipboardItem { + propertyPath: AnimationPath; + timeOffset: number; + value: AnimationValue; + interpolation: AnimationInterpolation; + curvePatches: KeyframeClipboardCurvePatch[]; +} + +export interface ElementsClipboardEntry { + type: "elements"; + items: ElementClipboardItem[]; +} + +export interface KeyframesClipboardEntry { + type: "keyframes"; + sourceElement: ElementRef; + items: KeyframeClipboardItem[]; +} + +export interface ClipboardEntryByType { + elements: ElementsClipboardEntry; + keyframes: KeyframesClipboardEntry; +} + +export type ClipboardEntry = ClipboardEntryByType[keyof ClipboardEntryByType]; +export type ClipboardEntryType = keyof ClipboardEntryByType; + +export interface CopyContext { + editor: EditorCore; + selectedElements: ElementRef[]; + selectedKeyframes: SelectedKeyframeRef[]; +} + +export interface PasteContext { + editor: EditorCore; + selectedElements: ElementRef[]; + selectedKeyframes: SelectedKeyframeRef[]; + time: number; +} + +export interface ClipboardHandler { + type: TType; + canCopy(context: CopyContext): boolean; + copy(context: CopyContext): ClipboardEntryByType[TType] | null; + paste( + entry: ClipboardEntryByType[TType], + context: PasteContext, + ): Command | null; +} + +export type ClipboardHandlerMap = { + [TType in ClipboardEntryType]: ClipboardHandler; +}; diff --git a/apps/web/src/lib/commands/timeline/clipboard/index.ts b/apps/web/src/lib/commands/timeline/clipboard/index.ts index 11a8bed4..e893b62b 100644 --- a/apps/web/src/lib/commands/timeline/clipboard/index.ts +++ b/apps/web/src/lib/commands/timeline/clipboard/index.ts @@ -1 +1,2 @@ export { PasteCommand } from "./paste"; +export { PasteKeyframesCommand } from "./paste-keyframes"; diff --git a/apps/web/src/lib/commands/timeline/clipboard/paste-keyframes.ts b/apps/web/src/lib/commands/timeline/clipboard/paste-keyframes.ts new file mode 100644 index 00000000..210afbd3 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/clipboard/paste-keyframes.ts @@ -0,0 +1,135 @@ +import { EditorCore } from "@/core"; +import { + getKeyframeAtTime, + resolveAnimationTarget, + updateScalarKeyframeCurve, + upsertPathKeyframe, +} from "@/lib/animation"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import type { KeyframeClipboardItem } from "@/lib/clipboard"; +import type { SceneTracks, TimelineElement } from "@/lib/timeline"; +import { updateElementInSceneTracks } from "@/lib/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/lib/commands/timeline/clipboard/paste.ts b/apps/web/src/lib/commands/timeline/clipboard/paste.ts index 6ccf96fb..347c4f68 100644 --- a/apps/web/src/lib/commands/timeline/clipboard/paste.ts +++ b/apps/web/src/lib/commands/timeline/clipboard/paste.ts @@ -1,10 +1,7 @@ import { Command, type CommandResult } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; -import type { - SceneTracks, - TimelineElement, - ClipboardItem, -} from "@/lib/timeline"; +import type { SceneTracks, TimelineElement } from "@/lib/timeline"; +import type { ElementClipboardItem } from "@/lib/clipboard"; import { generateUUID } from "@/utils/id"; import { applyPlacement, resolveTrackPlacement, enforceMainTrackStart } from "@/lib/timeline/placement"; import { cloneAnimations } from "@/lib/animation"; @@ -13,14 +10,14 @@ export class PasteCommand extends Command { private savedState: SceneTracks | null = null; private pastedElements: { trackId: string; elementId: string }[] = []; private readonly time: number; - private readonly clipboardItems: ClipboardItem[]; + private readonly clipboardItems: ElementClipboardItem[]; constructor({ time, clipboardItems, }: { time: number; - clipboardItems: ClipboardItem[]; + clipboardItems: ElementClipboardItem[]; }) { super(); this.time = time; @@ -145,9 +142,9 @@ export class PasteCommand extends Command { function groupClipboardItemsByTrackId({ clipboardItems, }: { - clipboardItems: ClipboardItem[]; -}): Map { - const groupedItems = new Map(); + clipboardItems: ElementClipboardItem[]; +}): Map { + const groupedItems = new Map(); for (const item of clipboardItems) { const existingItems = groupedItems.get(item.trackId) ?? []; @@ -162,7 +159,7 @@ function buildPastedElements({ minStart, time, }: { - items: ClipboardItem[]; + items: ElementClipboardItem[]; minStart: number; time: number; }): TimelineElement[] { diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts index c757055d..3c3d1c6b 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts @@ -1,9 +1,7 @@ import { EditorCore } from "@/core"; import { hasKeyframesForPath, - getKeyframeById, removeElementKeyframe, - resolveAnimationPathValueAtTime, resolveAnimationTarget, } from "@/lib/animation"; import { Command, type CommandResult } from "@/lib/commands/base-command"; @@ -11,61 +9,22 @@ import { updateElementInSceneTracks } from "@/lib/timeline"; import type { AnimationPath, AnimationValue } from "@/lib/animation/types"; import type { SceneTracks, TimelineElement } from "@/lib/timeline"; -function sampleValueBeforeRemoval({ - element, - propertyPath, - keyframeId, -}: { - element: TimelineElement; - propertyPath: AnimationPath; - keyframeId: string; -}): AnimationValue | null { - const target = resolveAnimationTarget({ element, path: propertyPath }); - if (!target) { - return null; - } - const baseValue = target.getBaseValue(); - if (baseValue === null) { - return null; - } - - const keyframe = getKeyframeById({ - animations: element.animations, - propertyPath, - keyframeId, - }); - if (!keyframe) { - return null; - } - - return resolveAnimationPathValueAtTime({ - animations: element.animations, - propertyPath, - localTime: keyframe.time, - fallbackValue: baseValue, - }); -} - function removeKeyframeAndPersist({ element, propertyPath, keyframeId, + valueAtPlayhead, }: { element: TimelineElement; propertyPath: AnimationPath; keyframeId: string; + valueAtPlayhead: AnimationValue | null; }): TimelineElement { const target = resolveAnimationTarget({ element, path: propertyPath }); if (!target) { return element; } - const valueBefore = sampleValueBeforeRemoval({ - element, - propertyPath, - keyframeId, - }); - const nextAnimations = removeElementKeyframe({ animations: element.animations, propertyPath, @@ -76,10 +35,12 @@ function removeKeyframeAndPersist({ animations: nextAnimations, propertyPath, }); - const shouldPersistToBase = isChannelNowEmpty && valueBefore !== null; + + // When clearing all keyframes, preserve the value at the playhead (what the user was seeing) + const shouldPersistToBase = isChannelNowEmpty && valueAtPlayhead !== null; const baseElement = shouldPersistToBase - ? target.setBaseValue({ value: valueBefore }) + ? target.setBaseValue({ value: valueAtPlayhead }) : element; return { ...baseElement, animations: nextAnimations }; @@ -91,23 +52,27 @@ export class RemoveKeyframeCommand extends Command { private readonly elementId: string; private readonly propertyPath: AnimationPath; private readonly keyframeId: string; + private readonly valueAtPlayhead: AnimationValue | null; constructor({ trackId, elementId, propertyPath, keyframeId, + valueAtPlayhead, }: { trackId: string; elementId: string; propertyPath: AnimationPath; keyframeId: string; + valueAtPlayhead: AnimationValue | null; }) { super(); this.trackId = trackId; this.elementId = elementId; this.propertyPath = propertyPath; this.keyframeId = keyframeId; + this.valueAtPlayhead = valueAtPlayhead; } execute(): CommandResult | undefined { @@ -123,6 +88,7 @@ export class RemoveKeyframeCommand extends Command { element, propertyPath: this.propertyPath, keyframeId: this.keyframeId, + valueAtPlayhead: this.valueAtPlayhead, }), }); diff --git a/apps/web/src/lib/timeline/types.ts b/apps/web/src/lib/timeline/types.ts index 1b2c7c9b..ca9ac999 100644 --- a/apps/web/src/lib/timeline/types.ts +++ b/apps/web/src/lib/timeline/types.ts @@ -272,6 +272,8 @@ export type CreateTimelineElement = export interface ElementDragState { isDragging: boolean; elementId: string | null; + dragElementIds: string[]; + dragTimeOffsets: Record; trackId: string | null; startMouseX: number; startMouseY: number; diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index c8428355..b7ddb885 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -1,56 +1,57 @@ -/** - * UI state for the timeline - * For core logic, use EditorCore instead. - */ - -import { create } from "zustand"; -import { persist } from "zustand/middleware"; -import type { ClipboardItem } from "@/lib/timeline"; - -interface TimelineStore { - snappingEnabled: boolean; - toggleSnapping: () => void; - rippleEditingEnabled: boolean; - toggleRippleEditing: () => void; - clipboard: { - items: ClipboardItem[]; - } | null; - setClipboard: ( - clipboard: { - items: ClipboardItem[]; - } | null, - ) => void; -} - -export const useTimelineStore = create()( - persist( - (set) => ({ - snappingEnabled: true, - - toggleSnapping: () => { - set((state) => ({ snappingEnabled: !state.snappingEnabled })); - }, - - rippleEditingEnabled: false, - - toggleRippleEditing: () => { - set((state) => ({ - rippleEditingEnabled: !state.rippleEditingEnabled, - })); - }, - - clipboard: null, - - setClipboard: (clipboard) => { - set({ clipboard }); - }, - }), - { - name: "timeline-store", - partialize: (state) => ({ - snappingEnabled: state.snappingEnabled, - rippleEditingEnabled: state.rippleEditingEnabled, - }), - }, - ), -); +/** + * UI state for the timeline + * For core logic, use EditorCore instead. + */ + +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +interface TimelineStore { + snappingEnabled: boolean; + toggleSnapping: () => void; + rippleEditingEnabled: boolean; + toggleRippleEditing: () => void; + expandedElementIds: Set; + toggleElementExpanded: (elementId: string) => void; +} + +export const useTimelineStore = create()( + persist( + (set) => ({ + snappingEnabled: true, + + toggleSnapping: () => { + set((state) => ({ snappingEnabled: !state.snappingEnabled })); + }, + + rippleEditingEnabled: false, + + toggleRippleEditing: () => { + set((state) => ({ + rippleEditingEnabled: !state.rippleEditingEnabled, + })); + }, + + expandedElementIds: new Set(), + + toggleElementExpanded: (elementId) => { + set((state) => { + const next = new Set(state.expandedElementIds); + if (next.has(elementId)) { + next.delete(elementId); + } else { + next.add(elementId); + } + return { expandedElementIds: next }; + }); + }, + }), + { + name: "timeline-store", + partialize: (state) => ({ + snappingEnabled: state.snappingEnabled, + rippleEditingEnabled: state.rippleEditingEnabled, + }), + }, + ), +);