parent
5a2aaf3601
commit
2272598d42
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<T>(selector: (editor: EditorCore) => T): T;
|
||||
export function useEditor<T>(
|
||||
selector?: (editor: EditorCore) => T,
|
||||
): EditorCore | T {
|
||||
const editor = useMemo(() => EditorCore.getInstance(), []);
|
||||
const selectorRef = useRef(selector);
|
||||
selectorRef.current = selector;
|
||||
|
||||
const snapshotCacheRef = useRef<unknown>(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<T>(selector: (editor: EditorCore) => T): T;
|
||||
export function useEditor<T>(
|
||||
selector?: (editor: EditorCore) => T,
|
||||
): EditorCore | T {
|
||||
const editor = useMemo(() => EditorCore.getInstance(), []);
|
||||
const selectorRef = useRef(selector);
|
||||
selectorRef.current = selector;
|
||||
|
||||
const snapshotCacheRef = useRef<unknown>(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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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">;
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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">;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./types";
|
||||
export * from "./handlers";
|
||||
|
|
@ -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<TType extends ClipboardEntryType> {
|
||||
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<TType>;
|
||||
};
|
||||
|
|
@ -1 +1,2 @@
|
|||
export { PasteCommand } from "./paste";
|
||||
export { PasteKeyframesCommand } from "./paste-keyframes";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, ClipboardItem[]> {
|
||||
const groupedItems = new Map<string, ClipboardItem[]>();
|
||||
clipboardItems: ElementClipboardItem[];
|
||||
}): Map<string, ElementClipboardItem[]> {
|
||||
const groupedItems = new Map<string, ElementClipboardItem[]>();
|
||||
|
||||
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[] {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -272,6 +272,8 @@ export type CreateTimelineElement =
|
|||
export interface ElementDragState {
|
||||
isDragging: boolean;
|
||||
elementId: string | null;
|
||||
dragElementIds: string[];
|
||||
dragTimeOffsets: Record<string, number>;
|
||||
trackId: string | null;
|
||||
startMouseX: number;
|
||||
startMouseY: number;
|
||||
|
|
|
|||
|
|
@ -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<TimelineStore>()(
|
||||
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<string>;
|
||||
toggleElementExpanded: (elementId: string) => void;
|
||||
}
|
||||
|
||||
export const useTimelineStore = create<TimelineStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
snappingEnabled: true,
|
||||
|
||||
toggleSnapping: () => {
|
||||
set((state) => ({ snappingEnabled: !state.snappingEnabled }));
|
||||
},
|
||||
|
||||
rippleEditingEnabled: false,
|
||||
|
||||
toggleRippleEditing: () => {
|
||||
set((state) => ({
|
||||
rippleEditingEnabled: !state.rippleEditingEnabled,
|
||||
}));
|
||||
},
|
||||
|
||||
expandedElementIds: new Set<string>(),
|
||||
|
||||
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,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue