parent
5a2aaf3601
commit
2272598d42
|
|
@ -8,6 +8,7 @@ import { CommandManager } from "./managers/commands";
|
||||||
import { SaveManager } from "./managers/save-manager";
|
import { SaveManager } from "./managers/save-manager";
|
||||||
import { AudioManager } from "./managers/audio-manager";
|
import { AudioManager } from "./managers/audio-manager";
|
||||||
import { SelectionManager } from "./managers/selection-manager";
|
import { SelectionManager } from "./managers/selection-manager";
|
||||||
|
import { ClipboardManager } from "./managers/clipboard-manager";
|
||||||
import { registerDefaultEffects } from "@/lib/effects";
|
import { registerDefaultEffects } from "@/lib/effects";
|
||||||
import { registerDefaultMasks } from "@/lib/masks";
|
import { registerDefaultMasks } from "@/lib/masks";
|
||||||
|
|
||||||
|
|
@ -23,6 +24,7 @@ export class EditorCore {
|
||||||
public readonly save: SaveManager;
|
public readonly save: SaveManager;
|
||||||
public readonly audio: AudioManager;
|
public readonly audio: AudioManager;
|
||||||
public readonly selection: SelectionManager;
|
public readonly selection: SelectionManager;
|
||||||
|
public readonly clipboard: ClipboardManager;
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
registerDefaultEffects();
|
registerDefaultEffects();
|
||||||
|
|
@ -37,6 +39,8 @@ export class EditorCore {
|
||||||
this.save = new SaveManager(this);
|
this.save = new SaveManager(this);
|
||||||
this.audio = new AudioManager(this);
|
this.audio = new AudioManager(this);
|
||||||
this.selection = new SelectionManager(this);
|
this.selection = new SelectionManager(this);
|
||||||
|
this.clipboard = new ClipboardManager(this);
|
||||||
|
this.playback.bindTimelineScope();
|
||||||
this.command.registerReactor(() => {
|
this.command.registerReactor(() => {
|
||||||
const activeScene = this.scenes.getActiveSceneOrNull();
|
const activeScene = this.scenes.getActiveSceneOrNull();
|
||||||
if (!activeScene) {
|
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 { useElementSelection } from "../timeline/element/use-element-selection";
|
||||||
import { TICKS_PER_SECOND } from "@/lib/wasm";
|
import { TICKS_PER_SECOND } from "@/lib/wasm";
|
||||||
import { useKeyframeSelection } from "../timeline/element/use-keyframe-selection";
|
import { useKeyframeSelection } from "../timeline/element/use-keyframe-selection";
|
||||||
import {
|
import { getElementsAtTime, hasMediaId } from "@/lib/timeline";
|
||||||
getElementsAtTime,
|
|
||||||
hasMediaId,
|
|
||||||
} from "@/lib/timeline";
|
|
||||||
import { cancelInteraction } from "@/lib/cancel-interaction";
|
import { cancelInteraction } from "@/lib/cancel-interaction";
|
||||||
import { invokeAction } from "@/lib/actions";
|
import { invokeAction } from "@/lib/actions";
|
||||||
import { canToggleSourceAudio } from "@/lib/timeline/audio-separation";
|
import { canToggleSourceAudio } from "@/lib/timeline/audio-separation";
|
||||||
|
|
@ -24,8 +21,6 @@ export function useEditorActions() {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const { selectedElements, setElementSelection } = useElementSelection();
|
const { selectedElements, setElementSelection } = useElementSelection();
|
||||||
const { selectedKeyframes, clearKeyframeSelection } = useKeyframeSelection();
|
const { selectedKeyframes, clearKeyframeSelection } = useKeyframeSelection();
|
||||||
const clipboard = useTimelineStore((s) => s.clipboard);
|
|
||||||
const setClipboard = useTimelineStore((s) => s.setClipboard);
|
|
||||||
const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
|
const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
|
||||||
const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
|
const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
|
||||||
const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
|
const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
|
||||||
|
|
@ -111,7 +106,9 @@ export function useEditorActions() {
|
||||||
"frame-step-forward",
|
"frame-step-forward",
|
||||||
() => {
|
() => {
|
||||||
const fps = editor.project.getActive().settings.fps;
|
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({
|
editor.playback.seek({
|
||||||
time: Math.min(
|
time: Math.min(
|
||||||
editor.timeline.getTotalDuration(),
|
editor.timeline.getTotalDuration(),
|
||||||
|
|
@ -126,7 +123,9 @@ export function useEditorActions() {
|
||||||
"frame-step-backward",
|
"frame-step-backward",
|
||||||
() => {
|
() => {
|
||||||
const fps = editor.project.getActive().settings.fps;
|
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({
|
editor.playback.seek({
|
||||||
time: Math.max(0, editor.playback.getCurrentTime() - ticksPerFrame),
|
time: Math.max(0, editor.playback.getCurrentTime() - ticksPerFrame),
|
||||||
});
|
});
|
||||||
|
|
@ -294,8 +293,9 @@ export function useEditorActions() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
editor.media.getAssets().find((asset) => asset.id === element.mediaId) ??
|
editor.media
|
||||||
null
|
.getAssets()
|
||||||
|
.find((asset) => asset.id === element.mediaId) ?? null
|
||||||
);
|
);
|
||||||
})();
|
})();
|
||||||
if (!canToggleSourceAudio(selectedElement.element, mediaAsset)) {
|
if (!canToggleSourceAudio(selectedElement.element, mediaAsset)) {
|
||||||
|
|
@ -387,21 +387,7 @@ export function useEditorActions() {
|
||||||
useActionHandler(
|
useActionHandler(
|
||||||
"copy-selected",
|
"copy-selected",
|
||||||
() => {
|
() => {
|
||||||
if (selectedElements.length === 0) return;
|
editor.clipboard.copy();
|
||||||
|
|
||||||
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 });
|
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
@ -409,12 +395,7 @@ export function useEditorActions() {
|
||||||
useActionHandler(
|
useActionHandler(
|
||||||
"paste-copied",
|
"paste-copied",
|
||||||
() => {
|
() => {
|
||||||
if (!clipboard?.items.length) return;
|
editor.clipboard.paste();
|
||||||
|
|
||||||
editor.timeline.pasteAtTime({
|
|
||||||
time: editor.playback.getCurrentTime(),
|
|
||||||
clipboardItems: clipboard.items,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,58 +1,59 @@
|
||||||
import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
|
import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
|
|
||||||
function isShallowEqual(a: unknown, b: unknown): boolean {
|
function isShallowEqual(a: unknown, b: unknown): boolean {
|
||||||
if (Object.is(a, b)) return true;
|
if (Object.is(a, b)) return true;
|
||||||
if (!Array.isArray(a) || !Array.isArray(b)) return false;
|
if (!Array.isArray(a) || !Array.isArray(b)) return false;
|
||||||
if (a.length !== b.length) return false;
|
if (a.length !== b.length) return false;
|
||||||
return a.every((item, i) => Object.is(item, b[i]));
|
return a.every((item, i) => Object.is(item, b[i]));
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscribeNone = () => () => {};
|
const subscribeNone = () => () => {};
|
||||||
|
|
||||||
export function useEditor(): EditorCore;
|
export function useEditor(): EditorCore;
|
||||||
export function useEditor<T>(selector: (editor: EditorCore) => T): T;
|
export function useEditor<T>(selector: (editor: EditorCore) => T): T;
|
||||||
export function useEditor<T>(
|
export function useEditor<T>(
|
||||||
selector?: (editor: EditorCore) => T,
|
selector?: (editor: EditorCore) => T,
|
||||||
): EditorCore | T {
|
): EditorCore | T {
|
||||||
const editor = useMemo(() => EditorCore.getInstance(), []);
|
const editor = useMemo(() => EditorCore.getInstance(), []);
|
||||||
const selectorRef = useRef(selector);
|
const selectorRef = useRef(selector);
|
||||||
selectorRef.current = selector;
|
selectorRef.current = selector;
|
||||||
|
|
||||||
const snapshotCacheRef = useRef<unknown>(undefined);
|
const snapshotCacheRef = useRef<unknown>(undefined);
|
||||||
|
|
||||||
const subscribeAll = useCallback(
|
const subscribeAll = useCallback(
|
||||||
(onChange: () => void) => {
|
(onChange: () => void) => {
|
||||||
const unsubscribers = [
|
const unsubscribers = [
|
||||||
editor.playback.subscribe(onChange),
|
editor.playback.subscribe(onChange),
|
||||||
editor.timeline.subscribe(onChange),
|
editor.timeline.subscribe(onChange),
|
||||||
editor.scenes.subscribe(onChange),
|
editor.scenes.subscribe(onChange),
|
||||||
editor.project.subscribe(onChange),
|
editor.project.subscribe(onChange),
|
||||||
editor.media.subscribe(onChange),
|
editor.media.subscribe(onChange),
|
||||||
editor.renderer.subscribe(onChange),
|
editor.renderer.subscribe(onChange),
|
||||||
editor.selection.subscribe(onChange),
|
editor.selection.subscribe(onChange),
|
||||||
];
|
editor.clipboard.subscribe(onChange),
|
||||||
return () => {
|
];
|
||||||
unsubscribers.forEach((unsubscribe) => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribers.forEach((unsubscribe) => {
|
||||||
});
|
unsubscribe();
|
||||||
};
|
});
|
||||||
},
|
};
|
||||||
[editor],
|
},
|
||||||
);
|
[editor],
|
||||||
|
);
|
||||||
const getSnapshot = useCallback(() => {
|
|
||||||
const next = selectorRef.current ? selectorRef.current(editor) : editor;
|
const getSnapshot = useCallback(() => {
|
||||||
if (isShallowEqual(snapshotCacheRef.current, next)) {
|
const next = selectorRef.current ? selectorRef.current(editor) : editor;
|
||||||
return snapshotCacheRef.current;
|
if (isShallowEqual(snapshotCacheRef.current, next)) {
|
||||||
}
|
return snapshotCacheRef.current;
|
||||||
snapshotCacheRef.current = next;
|
}
|
||||||
return next;
|
snapshotCacheRef.current = next;
|
||||||
}, [editor]);
|
return next;
|
||||||
|
}, [editor]);
|
||||||
return useSyncExternalStore(
|
|
||||||
selector ? subscribeAll : subscribeNone,
|
return useSyncExternalStore(
|
||||||
getSnapshot,
|
selector ? subscribeAll : subscribeNone,
|
||||||
getSnapshot,
|
getSnapshot,
|
||||||
) as EditorCore | T;
|
getSnapshot,
|
||||||
}
|
) as EditorCore | T;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { invokeAction } from "@/lib/actions";
|
import { invokeAction } from "@/lib/actions";
|
||||||
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
||||||
import { useTimelineStore } from "@/stores/timeline-store";
|
|
||||||
import { isTypableDOMElement } from "@/utils/browser";
|
import { isTypableDOMElement } from "@/utils/browser";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -10,6 +10,7 @@ import { isTypableDOMElement } from "@/utils/browser";
|
||||||
* the appropriate actions based on keybindings
|
* the appropriate actions based on keybindings
|
||||||
*/
|
*/
|
||||||
export function useKeybindingsListener() {
|
export function useKeybindingsListener() {
|
||||||
|
const editor = useEditor();
|
||||||
const {
|
const {
|
||||||
keybindings,
|
keybindings,
|
||||||
getKeybindingString,
|
getKeybindingString,
|
||||||
|
|
@ -17,7 +18,6 @@ export function useKeybindingsListener() {
|
||||||
isLoadingProject,
|
isLoadingProject,
|
||||||
isRecording,
|
isRecording,
|
||||||
} = useKeybindingsStore();
|
} = useKeybindingsStore();
|
||||||
const clipboard = useTimelineStore((state) => state.clipboard);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const eventOptions: AddEventListenerOptions = { capture: true };
|
const eventOptions: AddEventListenerOptions = { capture: true };
|
||||||
|
|
@ -140,7 +140,7 @@ export function useKeybindingsListener() {
|
||||||
|
|
||||||
if (isTextInput) return;
|
if (isTextInput) return;
|
||||||
if (boundAction === "paste-copied") {
|
if (boundAction === "paste-copied") {
|
||||||
if (!clipboard?.items.length) return;
|
if (!editor.clipboard.hasEntry()) return;
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
invokeAction("paste-copied", undefined, "keypress");
|
invokeAction("paste-copied", undefined, "keypress");
|
||||||
return;
|
return;
|
||||||
|
|
@ -177,6 +177,6 @@ export function useKeybindingsListener() {
|
||||||
overlayDepth,
|
overlayDepth,
|
||||||
isLoadingProject,
|
isLoadingProject,
|
||||||
isRecording,
|
isRecording,
|
||||||
clipboard,
|
editor,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { useEffect } from "react";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { processMediaAssets } from "@/lib/media/processing";
|
import { processMediaAssets } from "@/lib/media/processing";
|
||||||
import { showMediaUploadToast } from "@/lib/media/upload-toast";
|
import { showMediaUploadToast } from "@/lib/media/upload-toast";
|
||||||
import { invokeAction } from "@/lib/actions";
|
|
||||||
import { buildElementFromMedia } from "@/lib/timeline/element-utils";
|
import { buildElementFromMedia } from "@/lib/timeline/element-utils";
|
||||||
import { AddMediaAssetCommand } from "@/lib/commands/media";
|
import { AddMediaAssetCommand } from "@/lib/commands/media";
|
||||||
import { InsertElementCommand } from "@/lib/commands/timeline";
|
import { InsertElementCommand } from "@/lib/commands/timeline";
|
||||||
|
|
@ -52,7 +51,7 @@ export function usePasteMedia() {
|
||||||
});
|
});
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
invokeAction("paste-copied");
|
editor.clipboard.paste();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,10 +73,10 @@ export function usePasteMedia() {
|
||||||
asset,
|
asset,
|
||||||
);
|
);
|
||||||
const assetId = addMediaCmd.getAssetId();
|
const assetId = addMediaCmd.getAssetId();
|
||||||
const duration =
|
const duration =
|
||||||
asset.duration != null
|
asset.duration != null
|
||||||
? Math.round(asset.duration * TICKS_PER_SECOND)
|
? Math.round(asset.duration * TICKS_PER_SECOND)
|
||||||
: DEFAULT_NEW_ELEMENT_DURATION;
|
: DEFAULT_NEW_ELEMENT_DURATION;
|
||||||
const trackType = asset.type === "audio" ? "audio" : "video";
|
const trackType = asset.type === "audio" ? "audio" : "video";
|
||||||
|
|
||||||
const element = buildElementFromMedia({
|
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 { 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 { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import type {
|
import type { SceneTracks, TimelineElement } from "@/lib/timeline";
|
||||||
SceneTracks,
|
import type { ElementClipboardItem } from "@/lib/clipboard";
|
||||||
TimelineElement,
|
|
||||||
ClipboardItem,
|
|
||||||
} from "@/lib/timeline";
|
|
||||||
import { generateUUID } from "@/utils/id";
|
import { generateUUID } from "@/utils/id";
|
||||||
import { applyPlacement, resolveTrackPlacement, enforceMainTrackStart } from "@/lib/timeline/placement";
|
import { applyPlacement, resolveTrackPlacement, enforceMainTrackStart } from "@/lib/timeline/placement";
|
||||||
import { cloneAnimations } from "@/lib/animation";
|
import { cloneAnimations } from "@/lib/animation";
|
||||||
|
|
@ -13,14 +10,14 @@ export class PasteCommand extends Command {
|
||||||
private savedState: SceneTracks | null = null;
|
private savedState: SceneTracks | null = null;
|
||||||
private pastedElements: { trackId: string; elementId: string }[] = [];
|
private pastedElements: { trackId: string; elementId: string }[] = [];
|
||||||
private readonly time: number;
|
private readonly time: number;
|
||||||
private readonly clipboardItems: ClipboardItem[];
|
private readonly clipboardItems: ElementClipboardItem[];
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
time,
|
time,
|
||||||
clipboardItems,
|
clipboardItems,
|
||||||
}: {
|
}: {
|
||||||
time: number;
|
time: number;
|
||||||
clipboardItems: ClipboardItem[];
|
clipboardItems: ElementClipboardItem[];
|
||||||
}) {
|
}) {
|
||||||
super();
|
super();
|
||||||
this.time = time;
|
this.time = time;
|
||||||
|
|
@ -145,9 +142,9 @@ export class PasteCommand extends Command {
|
||||||
function groupClipboardItemsByTrackId({
|
function groupClipboardItemsByTrackId({
|
||||||
clipboardItems,
|
clipboardItems,
|
||||||
}: {
|
}: {
|
||||||
clipboardItems: ClipboardItem[];
|
clipboardItems: ElementClipboardItem[];
|
||||||
}): Map<string, ClipboardItem[]> {
|
}): Map<string, ElementClipboardItem[]> {
|
||||||
const groupedItems = new Map<string, ClipboardItem[]>();
|
const groupedItems = new Map<string, ElementClipboardItem[]>();
|
||||||
|
|
||||||
for (const item of clipboardItems) {
|
for (const item of clipboardItems) {
|
||||||
const existingItems = groupedItems.get(item.trackId) ?? [];
|
const existingItems = groupedItems.get(item.trackId) ?? [];
|
||||||
|
|
@ -162,7 +159,7 @@ function buildPastedElements({
|
||||||
minStart,
|
minStart,
|
||||||
time,
|
time,
|
||||||
}: {
|
}: {
|
||||||
items: ClipboardItem[];
|
items: ElementClipboardItem[];
|
||||||
minStart: number;
|
minStart: number;
|
||||||
time: number;
|
time: number;
|
||||||
}): TimelineElement[] {
|
}): TimelineElement[] {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import {
|
import {
|
||||||
hasKeyframesForPath,
|
hasKeyframesForPath,
|
||||||
getKeyframeById,
|
|
||||||
removeElementKeyframe,
|
removeElementKeyframe,
|
||||||
resolveAnimationPathValueAtTime,
|
|
||||||
resolveAnimationTarget,
|
resolveAnimationTarget,
|
||||||
} from "@/lib/animation";
|
} from "@/lib/animation";
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
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 { AnimationPath, AnimationValue } from "@/lib/animation/types";
|
||||||
import type { SceneTracks, TimelineElement } from "@/lib/timeline";
|
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({
|
function removeKeyframeAndPersist({
|
||||||
element,
|
element,
|
||||||
propertyPath,
|
propertyPath,
|
||||||
keyframeId,
|
keyframeId,
|
||||||
|
valueAtPlayhead,
|
||||||
}: {
|
}: {
|
||||||
element: TimelineElement;
|
element: TimelineElement;
|
||||||
propertyPath: AnimationPath;
|
propertyPath: AnimationPath;
|
||||||
keyframeId: string;
|
keyframeId: string;
|
||||||
|
valueAtPlayhead: AnimationValue | null;
|
||||||
}): TimelineElement {
|
}): TimelineElement {
|
||||||
const target = resolveAnimationTarget({ element, path: propertyPath });
|
const target = resolveAnimationTarget({ element, path: propertyPath });
|
||||||
if (!target) {
|
if (!target) {
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
const valueBefore = sampleValueBeforeRemoval({
|
|
||||||
element,
|
|
||||||
propertyPath,
|
|
||||||
keyframeId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const nextAnimations = removeElementKeyframe({
|
const nextAnimations = removeElementKeyframe({
|
||||||
animations: element.animations,
|
animations: element.animations,
|
||||||
propertyPath,
|
propertyPath,
|
||||||
|
|
@ -76,10 +35,12 @@ function removeKeyframeAndPersist({
|
||||||
animations: nextAnimations,
|
animations: nextAnimations,
|
||||||
propertyPath,
|
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
|
const baseElement = shouldPersistToBase
|
||||||
? target.setBaseValue({ value: valueBefore })
|
? target.setBaseValue({ value: valueAtPlayhead })
|
||||||
: element;
|
: element;
|
||||||
|
|
||||||
return { ...baseElement, animations: nextAnimations };
|
return { ...baseElement, animations: nextAnimations };
|
||||||
|
|
@ -91,23 +52,27 @@ export class RemoveKeyframeCommand extends Command {
|
||||||
private readonly elementId: string;
|
private readonly elementId: string;
|
||||||
private readonly propertyPath: AnimationPath;
|
private readonly propertyPath: AnimationPath;
|
||||||
private readonly keyframeId: string;
|
private readonly keyframeId: string;
|
||||||
|
private readonly valueAtPlayhead: AnimationValue | null;
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
trackId,
|
trackId,
|
||||||
elementId,
|
elementId,
|
||||||
propertyPath,
|
propertyPath,
|
||||||
keyframeId,
|
keyframeId,
|
||||||
|
valueAtPlayhead,
|
||||||
}: {
|
}: {
|
||||||
trackId: string;
|
trackId: string;
|
||||||
elementId: string;
|
elementId: string;
|
||||||
propertyPath: AnimationPath;
|
propertyPath: AnimationPath;
|
||||||
keyframeId: string;
|
keyframeId: string;
|
||||||
|
valueAtPlayhead: AnimationValue | null;
|
||||||
}) {
|
}) {
|
||||||
super();
|
super();
|
||||||
this.trackId = trackId;
|
this.trackId = trackId;
|
||||||
this.elementId = elementId;
|
this.elementId = elementId;
|
||||||
this.propertyPath = propertyPath;
|
this.propertyPath = propertyPath;
|
||||||
this.keyframeId = keyframeId;
|
this.keyframeId = keyframeId;
|
||||||
|
this.valueAtPlayhead = valueAtPlayhead;
|
||||||
}
|
}
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
|
|
@ -123,6 +88,7 @@ export class RemoveKeyframeCommand extends Command {
|
||||||
element,
|
element,
|
||||||
propertyPath: this.propertyPath,
|
propertyPath: this.propertyPath,
|
||||||
keyframeId: this.keyframeId,
|
keyframeId: this.keyframeId,
|
||||||
|
valueAtPlayhead: this.valueAtPlayhead,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -272,6 +272,8 @@ export type CreateTimelineElement =
|
||||||
export interface ElementDragState {
|
export interface ElementDragState {
|
||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
elementId: string | null;
|
elementId: string | null;
|
||||||
|
dragElementIds: string[];
|
||||||
|
dragTimeOffsets: Record<string, number>;
|
||||||
trackId: string | null;
|
trackId: string | null;
|
||||||
startMouseX: number;
|
startMouseX: number;
|
||||||
startMouseY: number;
|
startMouseY: number;
|
||||||
|
|
|
||||||
|
|
@ -1,56 +1,57 @@
|
||||||
/**
|
/**
|
||||||
* UI state for the timeline
|
* UI state for the timeline
|
||||||
* For core logic, use EditorCore instead.
|
* For core logic, use EditorCore instead.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { persist } from "zustand/middleware";
|
import { persist } from "zustand/middleware";
|
||||||
import type { ClipboardItem } from "@/lib/timeline";
|
|
||||||
|
interface TimelineStore {
|
||||||
interface TimelineStore {
|
snappingEnabled: boolean;
|
||||||
snappingEnabled: boolean;
|
toggleSnapping: () => void;
|
||||||
toggleSnapping: () => void;
|
rippleEditingEnabled: boolean;
|
||||||
rippleEditingEnabled: boolean;
|
toggleRippleEditing: () => void;
|
||||||
toggleRippleEditing: () => void;
|
expandedElementIds: Set<string>;
|
||||||
clipboard: {
|
toggleElementExpanded: (elementId: string) => void;
|
||||||
items: ClipboardItem[];
|
}
|
||||||
} | null;
|
|
||||||
setClipboard: (
|
export const useTimelineStore = create<TimelineStore>()(
|
||||||
clipboard: {
|
persist(
|
||||||
items: ClipboardItem[];
|
(set) => ({
|
||||||
} | null,
|
snappingEnabled: true,
|
||||||
) => void;
|
|
||||||
}
|
toggleSnapping: () => {
|
||||||
|
set((state) => ({ snappingEnabled: !state.snappingEnabled }));
|
||||||
export const useTimelineStore = create<TimelineStore>()(
|
},
|
||||||
persist(
|
|
||||||
(set) => ({
|
rippleEditingEnabled: false,
|
||||||
snappingEnabled: true,
|
|
||||||
|
toggleRippleEditing: () => {
|
||||||
toggleSnapping: () => {
|
set((state) => ({
|
||||||
set((state) => ({ snappingEnabled: !state.snappingEnabled }));
|
rippleEditingEnabled: !state.rippleEditingEnabled,
|
||||||
},
|
}));
|
||||||
|
},
|
||||||
rippleEditingEnabled: false,
|
|
||||||
|
expandedElementIds: new Set<string>(),
|
||||||
toggleRippleEditing: () => {
|
|
||||||
set((state) => ({
|
toggleElementExpanded: (elementId) => {
|
||||||
rippleEditingEnabled: !state.rippleEditingEnabled,
|
set((state) => {
|
||||||
}));
|
const next = new Set(state.expandedElementIds);
|
||||||
},
|
if (next.has(elementId)) {
|
||||||
|
next.delete(elementId);
|
||||||
clipboard: null,
|
} else {
|
||||||
|
next.add(elementId);
|
||||||
setClipboard: (clipboard) => {
|
}
|
||||||
set({ clipboard });
|
return { expandedElementIds: next };
|
||||||
},
|
});
|
||||||
}),
|
},
|
||||||
{
|
}),
|
||||||
name: "timeline-store",
|
{
|
||||||
partialize: (state) => ({
|
name: "timeline-store",
|
||||||
snappingEnabled: state.snappingEnabled,
|
partialize: (state) => ({
|
||||||
rippleEditingEnabled: state.rippleEditingEnabled,
|
snappingEnabled: state.snappingEnabled,
|
||||||
}),
|
rippleEditingEnabled: state.rippleEditingEnabled,
|
||||||
},
|
}),
|
||||||
),
|
},
|
||||||
);
|
),
|
||||||
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue