mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Merge branch 'dev' into ripple-editing
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TimelineTrack, VideoElement } from "@/types/timeline";
|
||||
import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants";
|
||||
import { UpdateElementDurationCommand } from "@/lib/commands/timeline/element/update-element-duration";
|
||||
import { UpdateElementTrimCommand } from "@/lib/commands/timeline/element/update-element-trim";
|
||||
import { SplitElementsCommand } from "@/lib/commands/timeline/element/split-elements";
|
||||
import { DuplicateElementsCommand } from "@/lib/commands/timeline/element/duplicate-elements";
|
||||
import { UpsertKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/upsert-keyframe";
|
||||
import { RemoveKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/remove-keyframe";
|
||||
import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe";
|
||||
|
||||
type MockEditor = {
|
||||
timeline: {
|
||||
getTracks: () => TimelineTrack[];
|
||||
updateTracks: (tracks: TimelineTrack[]) => void;
|
||||
};
|
||||
selection: {
|
||||
getSelectedElements: () => { trackId: string; elementId: string }[];
|
||||
setSelectedElements: ({
|
||||
elements,
|
||||
}: {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
}) => void;
|
||||
};
|
||||
};
|
||||
|
||||
const originalGetInstance = EditorCore.getInstance;
|
||||
|
||||
function mockEditorCore({ editor }: { editor: MockEditor }): void {
|
||||
(
|
||||
EditorCore as unknown as {
|
||||
getInstance: () => EditorCore;
|
||||
}
|
||||
).getInstance = () => editor as unknown as EditorCore;
|
||||
}
|
||||
|
||||
function restoreEditorCore(): void {
|
||||
(
|
||||
EditorCore as unknown as {
|
||||
getInstance: typeof EditorCore.getInstance;
|
||||
}
|
||||
).getInstance = originalGetInstance;
|
||||
}
|
||||
|
||||
function buildVideoElement(): VideoElement {
|
||||
return {
|
||||
id: "element-1",
|
||||
name: "Clip",
|
||||
type: "video",
|
||||
mediaId: "media-1",
|
||||
duration: 8,
|
||||
startTime: 1,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
transform: DEFAULT_TRANSFORM,
|
||||
opacity: 1,
|
||||
animations: {
|
||||
channels: {
|
||||
"transform.scale": {
|
||||
valueKind: "number",
|
||||
keyframes: [
|
||||
{ id: "kf-a", time: 0, value: 1, interpolation: "linear" },
|
||||
{ id: "kf-b", time: 3, value: 1.5, interpolation: "linear" },
|
||||
{ id: "kf-c", time: 6, value: 2, interpolation: "linear" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildTracks({ element }: { element: VideoElement }): TimelineTrack[] {
|
||||
return [
|
||||
{
|
||||
id: "track-1",
|
||||
name: "Main",
|
||||
type: "video",
|
||||
elements: [element],
|
||||
isMain: true,
|
||||
muted: false,
|
||||
hidden: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
restoreEditorCore();
|
||||
});
|
||||
|
||||
describe("keyframe-aware timeline commands", () => {
|
||||
test("duration updates clamp keyframes beyond the new duration", () => {
|
||||
const tracks = buildTracks({ element: buildVideoElement() });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new UpdateElementDurationCommand({
|
||||
trackId: "track-1",
|
||||
elementId: "element-1",
|
||||
duration: 3,
|
||||
}).execute();
|
||||
|
||||
const updatedElement = (updatedTracks[0].elements[0] as VideoElement).animations;
|
||||
expect(
|
||||
updatedElement?.channels["transform.scale"]?.keyframes.map(
|
||||
(keyframe) => keyframe.time,
|
||||
),
|
||||
).toEqual([0, 3]);
|
||||
});
|
||||
|
||||
test("trim updates clamp keyframes when duration is changed", () => {
|
||||
const tracks = buildTracks({ element: buildVideoElement() });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new UpdateElementTrimCommand({
|
||||
elementId: "element-1",
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
startTime: 1,
|
||||
duration: 2,
|
||||
}).execute();
|
||||
|
||||
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
|
||||
expect(updatedElement.duration).toBe(2);
|
||||
expect(
|
||||
updatedElement.animations?.channels["transform.scale"]?.keyframes.map(
|
||||
(keyframe) => keyframe.time,
|
||||
),
|
||||
).toEqual([0]);
|
||||
});
|
||||
|
||||
test("split rebases right-side keyframes and keeps continuity at split time", () => {
|
||||
const tracks = buildTracks({ element: buildVideoElement() });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new SplitElementsCommand({
|
||||
elements: [{ trackId: "track-1", elementId: "element-1" }],
|
||||
splitTime: 5,
|
||||
}).execute();
|
||||
|
||||
const leftElement = updatedTracks[0].elements.find(
|
||||
(element) => element.id === "element-1",
|
||||
) as VideoElement;
|
||||
const rightElement = updatedTracks[0].elements.find(
|
||||
(element) => element.id !== "element-1",
|
||||
) as VideoElement;
|
||||
|
||||
expect(
|
||||
leftElement.animations?.channels["transform.scale"]?.keyframes.map(
|
||||
(keyframe) => keyframe.time,
|
||||
),
|
||||
).toEqual([0, 3, 4]);
|
||||
expect(
|
||||
rightElement.animations?.channels["transform.scale"]?.keyframes.map(
|
||||
(keyframe) => keyframe.time,
|
||||
),
|
||||
).toEqual([0, 2]);
|
||||
expect(
|
||||
rightElement.animations?.channels["transform.scale"]?.keyframes[0]?.value,
|
||||
).toBeCloseTo(5 / 3, 4);
|
||||
});
|
||||
|
||||
test("duplicate creates independent keyframe ids for copied element", () => {
|
||||
const tracks = buildTracks({ element: buildVideoElement() });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [{ trackId: "track-1", elementId: "element-1" }],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new DuplicateElementsCommand({
|
||||
elements: [{ trackId: "track-1", elementId: "element-1" }],
|
||||
}).execute();
|
||||
|
||||
const originalElement = updatedTracks.find(
|
||||
(track) => track.id === "track-1",
|
||||
)?.elements[0] as VideoElement;
|
||||
const duplicatedTrack = updatedTracks.find((track) => track.id !== "track-1");
|
||||
const duplicatedElement = duplicatedTrack?.elements[0] as VideoElement;
|
||||
|
||||
expect(duplicatedElement).toBeDefined();
|
||||
expect(
|
||||
duplicatedElement.animations?.channels["transform.scale"]?.keyframes.map(
|
||||
(keyframe) => keyframe.time,
|
||||
),
|
||||
).toEqual([0, 3, 6]);
|
||||
expect(
|
||||
duplicatedElement.animations?.channels["transform.scale"]?.keyframes[0]?.id,
|
||||
).not.toBe(
|
||||
originalElement.animations?.channels["transform.scale"]?.keyframes[0]?.id,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generic keyframe commands", () => {
|
||||
test("upsert adds or updates keyframe at target time", () => {
|
||||
const element = buildVideoElement();
|
||||
const tracks = buildTracks({ element });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new UpsertKeyframeCommand({
|
||||
trackId: "track-1",
|
||||
elementId: "element-1",
|
||||
propertyPath: "transform.scale",
|
||||
time: 2,
|
||||
value: 2.5,
|
||||
}).execute();
|
||||
|
||||
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
|
||||
const keyframes =
|
||||
updatedElement.animations?.channels["transform.scale"]?.keyframes ?? [];
|
||||
const atTwo = keyframes.find((keyframe) => Math.abs(keyframe.time - 2) < 0.001);
|
||||
expect(atTwo?.value).toBe(2.5);
|
||||
});
|
||||
|
||||
test("remove deletes keyframe by id", () => {
|
||||
const element = buildVideoElement();
|
||||
const tracks = buildTracks({ element });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new RemoveKeyframeCommand({
|
||||
trackId: "track-1",
|
||||
elementId: "element-1",
|
||||
propertyPath: "transform.scale",
|
||||
keyframeId: "kf-b",
|
||||
}).execute();
|
||||
|
||||
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
|
||||
const keyframes =
|
||||
updatedElement.animations?.channels["transform.scale"]?.keyframes ?? [];
|
||||
expect(keyframes).toHaveLength(2);
|
||||
expect(keyframes.find((keyframe) => keyframe.id === "kf-b")).toBeUndefined();
|
||||
expect(updatedElement.transform.scale).toBe(1);
|
||||
});
|
||||
|
||||
test("remove persists value to base property when channel becomes empty", () => {
|
||||
const element: VideoElement = {
|
||||
...buildVideoElement(),
|
||||
transform: {
|
||||
...DEFAULT_TRANSFORM,
|
||||
scale: 1,
|
||||
},
|
||||
animations: {
|
||||
channels: {
|
||||
"transform.scale": {
|
||||
valueKind: "number",
|
||||
keyframes: [
|
||||
{
|
||||
id: "only-scale",
|
||||
time: 2,
|
||||
value: 1.43,
|
||||
interpolation: "linear",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const tracks = buildTracks({ element });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new RemoveKeyframeCommand({
|
||||
trackId: "track-1",
|
||||
elementId: "element-1",
|
||||
propertyPath: "transform.scale",
|
||||
keyframeId: "only-scale",
|
||||
}).execute();
|
||||
|
||||
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
|
||||
expect(updatedElement.transform.scale).toBe(1.43);
|
||||
expect(updatedElement.animations?.channels["transform.scale"]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("upsert supports non-transform paths like opacity", () => {
|
||||
const element = buildVideoElement();
|
||||
const tracks = buildTracks({ element });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new UpsertKeyframeCommand({
|
||||
trackId: "track-1",
|
||||
elementId: "element-1",
|
||||
propertyPath: "opacity",
|
||||
time: 1,
|
||||
value: 0.35,
|
||||
}).execute();
|
||||
|
||||
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
|
||||
const opacityChannel = updatedElement.animations?.channels.opacity;
|
||||
expect(opacityChannel?.valueKind).toBe("number");
|
||||
expect(opacityChannel?.keyframes[0]?.value).toBe(0.35);
|
||||
});
|
||||
|
||||
test("retime moves keyframe to new time", () => {
|
||||
const element = buildVideoElement();
|
||||
const tracks = buildTracks({ element });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new RetimeKeyframeCommand({
|
||||
trackId: "track-1",
|
||||
elementId: "element-1",
|
||||
propertyPath: "transform.scale",
|
||||
keyframeId: "kf-b",
|
||||
nextTime: 4,
|
||||
}).execute();
|
||||
|
||||
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
|
||||
const keyframe = updatedElement.animations?.channels["transform.scale"]?.keyframes.find(
|
||||
(existingKeyframe) => existingKeyframe.id === "kf-b",
|
||||
);
|
||||
expect(keyframe?.time).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
isMainTrack,
|
||||
enforceMainTrackStart,
|
||||
} from "@/lib/timeline/track-utils";
|
||||
import { cloneAnimations } from "@/lib/animation";
|
||||
|
||||
export class PasteCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
@@ -176,6 +177,10 @@ function buildPastedElements({
|
||||
...item.element,
|
||||
id: newElementId,
|
||||
startTime,
|
||||
animations: cloneAnimations({
|
||||
animations: item.element.animations,
|
||||
shouldRegenerateKeyframeIds: true,
|
||||
}),
|
||||
} as TimelineElement);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,19 @@ import { isMainTrack, rippleShiftElements } from "@/lib/timeline";
|
||||
|
||||
export class DeleteElementsCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly elements: { trackId: string; elementId: string }[];
|
||||
private readonly rippleEnabled: boolean;
|
||||
|
||||
constructor(
|
||||
private elements: { trackId: string; elementId: string }[],
|
||||
private rippleEnabled = false,
|
||||
) {
|
||||
constructor({
|
||||
elements,
|
||||
rippleEnabled = false,
|
||||
}: {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
rippleEnabled?: boolean;
|
||||
}) {
|
||||
super();
|
||||
this.elements = elements;
|
||||
this.rippleEnabled = rippleEnabled;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
@@ -19,29 +26,29 @@ export class DeleteElementsCommand extends Command {
|
||||
|
||||
const updatedTracks = this.savedState
|
||||
.map((track) => {
|
||||
const elementsToDeleteOnTrack = this.elements.filter(
|
||||
(target) => target.trackId === track.id,
|
||||
);
|
||||
const hasElementsToDelete = elementsToDeleteOnTrack.length > 0;
|
||||
const elementsToDeleteOnTrack = this.elements.filter(
|
||||
(target) => target.trackId === track.id,
|
||||
);
|
||||
const hasElementsToDelete = elementsToDeleteOnTrack.length > 0;
|
||||
|
||||
if (!hasElementsToDelete) {
|
||||
return track;
|
||||
}
|
||||
if (!hasElementsToDelete) {
|
||||
return track;
|
||||
}
|
||||
|
||||
const deletedElementInfos = elementsToDeleteOnTrack
|
||||
.map((target) =>
|
||||
track.elements.find((element) => element.id === target.elementId),
|
||||
)
|
||||
.filter((element): element is NonNullable<typeof element> => element !== undefined)
|
||||
.map((element) => ({ startTime: element.startTime, duration: element.duration }));
|
||||
const deletedElementInfos = elementsToDeleteOnTrack
|
||||
.map((target) =>
|
||||
track.elements.find((element) => element.id === target.elementId),
|
||||
)
|
||||
.filter((element): element is NonNullable<typeof element> => element !== undefined)
|
||||
.map((element) => ({ startTime: element.startTime, duration: element.duration }));
|
||||
|
||||
let elements = track.elements.filter(
|
||||
(element) =>
|
||||
!this.elements.some(
|
||||
(target) =>
|
||||
target.trackId === track.id && target.elementId === element.id,
|
||||
),
|
||||
);
|
||||
let elements = track.elements.filter(
|
||||
(element) =>
|
||||
!this.elements.some(
|
||||
(target) =>
|
||||
target.trackId === track.id && target.elementId === element.id,
|
||||
),
|
||||
);
|
||||
|
||||
if (this.rippleEnabled && deletedElementInfos.length > 0) {
|
||||
const sortedByStartDesc = [...deletedElementInfos].sort(
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
buildEmptyTrack,
|
||||
getHighestInsertIndexForTrack,
|
||||
} from "@/lib/timeline/track-utils";
|
||||
import { cloneAnimations } from "@/lib/animation";
|
||||
|
||||
interface DuplicateElementsParams {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
@@ -32,7 +33,7 @@ export class DuplicateElementsCommand extends Command {
|
||||
|
||||
for (const track of this.savedState) {
|
||||
const elementsToDuplicate = this.elements.filter(
|
||||
(el) => el.trackId === track.id,
|
||||
(elementEntry) => elementEntry.trackId === track.id,
|
||||
);
|
||||
|
||||
if (elementsToDuplicate.length === 0) {
|
||||
@@ -114,5 +115,14 @@ function buildDuplicateElement({
|
||||
id: string;
|
||||
startTime: number;
|
||||
}): TimelineElement {
|
||||
return { ...element, id, name: `${element.name} (copy)`, startTime };
|
||||
return {
|
||||
...element,
|
||||
id,
|
||||
name: `${element.name} (copy)`,
|
||||
startTime,
|
||||
animations: cloneAnimations({
|
||||
animations: element.animations,
|
||||
shouldRegenerateKeyframeIds: true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,3 +9,4 @@ export { UpdateElementCommand } from "./update-element";
|
||||
export { ToggleElementsVisibilityCommand } from "./toggle-elements-visibility";
|
||||
export { ToggleElementsMutedCommand } from "./toggle-elements-muted";
|
||||
export { MoveElementCommand } from "./move-elements";
|
||||
export * from "./keyframes";
|
||||
|
||||
@@ -173,6 +173,11 @@ export class InsertElementCommand extends Command {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (element.type === "effect" && !element.effectType) {
|
||||
console.error("Effect element must have effectType");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./remove-keyframe";
|
||||
export * from "./retime-keyframe";
|
||||
export * from "./upsert-keyframe";
|
||||
@@ -0,0 +1,141 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import {
|
||||
getChannel,
|
||||
getChannelValueAtTime,
|
||||
getElementBaseValueForProperty,
|
||||
removeElementKeyframe,
|
||||
supportsAnimationProperty,
|
||||
withElementBaseValueForProperty,
|
||||
} from "@/lib/animation";
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { updateElementInTracks } from "@/lib/timeline";
|
||||
import type { AnimationPropertyPath } from "@/types/animation";
|
||||
import type { TimelineElement, TimelineTrack } from "@/types/timeline";
|
||||
|
||||
function sampleValueBeforeRemoval({
|
||||
element,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
keyframeId: string;
|
||||
}): number | null {
|
||||
const channel = getChannel({
|
||||
animations: element.animations,
|
||||
propertyPath,
|
||||
});
|
||||
const keyframe = channel?.keyframes.find(
|
||||
(candidate) => candidate.id === keyframeId,
|
||||
);
|
||||
if (!channel || !keyframe) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseValue = getElementBaseValueForProperty({ element, propertyPath });
|
||||
if (baseValue === null || typeof baseValue !== "number") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sampled = getChannelValueAtTime({
|
||||
channel,
|
||||
time: keyframe.time,
|
||||
fallbackValue: baseValue,
|
||||
});
|
||||
return typeof sampled === "number" ? sampled : null;
|
||||
}
|
||||
|
||||
function removeKeyframeAndPersist({
|
||||
element,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
keyframeId: string;
|
||||
}): TimelineElement {
|
||||
const valueBefore = sampleValueBeforeRemoval({
|
||||
element,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
});
|
||||
|
||||
const nextAnimations = removeElementKeyframe({
|
||||
animations: element.animations,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
});
|
||||
|
||||
const isChannelNowEmpty =
|
||||
getChannel({ animations: nextAnimations, propertyPath }) === undefined;
|
||||
const shouldPersistToBase = isChannelNowEmpty && valueBefore !== null;
|
||||
|
||||
const baseElement = shouldPersistToBase
|
||||
? withElementBaseValueForProperty({
|
||||
element,
|
||||
propertyPath,
|
||||
value: valueBefore,
|
||||
})
|
||||
: element;
|
||||
|
||||
return { ...baseElement, animations: nextAnimations };
|
||||
}
|
||||
|
||||
export class RemoveKeyframeCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly propertyPath: AnimationPropertyPath;
|
||||
private readonly keyframeId: string;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
keyframeId: string;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.propertyPath = propertyPath;
|
||||
this.keyframeId = keyframeId;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = updateElementInTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: (element) =>
|
||||
supportsAnimationProperty({
|
||||
element,
|
||||
propertyPath: this.propertyPath,
|
||||
}),
|
||||
update: (element) =>
|
||||
removeKeyframeAndPersist({
|
||||
element,
|
||||
propertyPath: this.propertyPath,
|
||||
keyframeId: this.keyframeId,
|
||||
}),
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.savedState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import { retimeElementKeyframe, supportsAnimationProperty } from "@/lib/animation";
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { updateElementInTracks } from "@/lib/timeline";
|
||||
import type { AnimationPropertyPath } from "@/types/animation";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
|
||||
export class RetimeKeyframeCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly propertyPath: AnimationPropertyPath;
|
||||
private readonly keyframeId: string;
|
||||
private readonly nextTime: number;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
nextTime,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
keyframeId: string;
|
||||
nextTime: number;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.propertyPath = propertyPath;
|
||||
this.keyframeId = keyframeId;
|
||||
this.nextTime = nextTime;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = updateElementInTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: (element) =>
|
||||
supportsAnimationProperty({
|
||||
element,
|
||||
propertyPath: this.propertyPath,
|
||||
}),
|
||||
update: (element) => {
|
||||
const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration));
|
||||
if (!Number.isFinite(boundedTime)) return element;
|
||||
return {
|
||||
...element,
|
||||
animations: retimeElementKeyframe({
|
||||
animations: element.animations,
|
||||
propertyPath: this.propertyPath,
|
||||
keyframeId: this.keyframeId,
|
||||
time: boundedTime,
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.savedState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { supportsAnimationProperty, upsertElementKeyframe } from "@/lib/animation";
|
||||
import { updateElementInTracks } from "@/lib/timeline";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import type {
|
||||
AnimationInterpolation,
|
||||
AnimationPropertyPath,
|
||||
AnimationValue,
|
||||
} from "@/types/animation";
|
||||
|
||||
export class UpsertKeyframeCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly propertyPath: AnimationPropertyPath;
|
||||
private readonly time: number;
|
||||
private readonly value: AnimationValue;
|
||||
private readonly interpolation: AnimationInterpolation | undefined;
|
||||
private readonly keyframeId: string | undefined;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
time,
|
||||
value,
|
||||
interpolation,
|
||||
keyframeId,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
time: number;
|
||||
value: AnimationValue;
|
||||
interpolation?: AnimationInterpolation;
|
||||
keyframeId?: string;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.propertyPath = propertyPath;
|
||||
this.time = time;
|
||||
this.value = value;
|
||||
this.interpolation = interpolation;
|
||||
this.keyframeId = keyframeId;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = updateElementInTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: (element) =>
|
||||
supportsAnimationProperty({
|
||||
element,
|
||||
propertyPath: this.propertyPath,
|
||||
}),
|
||||
update: (element) => {
|
||||
const boundedTime = Math.max(0, Math.min(this.time, element.duration));
|
||||
return {
|
||||
...element,
|
||||
animations: upsertElementKeyframe({
|
||||
animations: element.animations,
|
||||
propertyPath: this.propertyPath,
|
||||
time: boundedTime,
|
||||
value: this.value,
|
||||
interpolation: this.interpolation,
|
||||
keyframeId: this.keyframeId,
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.savedState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
@@ -14,15 +14,31 @@ import {
|
||||
|
||||
export class MoveElementCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly sourceTrackId: string;
|
||||
private readonly targetTrackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly newStartTime: number;
|
||||
private readonly createTrack: { type: TrackType; index: number } | undefined;
|
||||
|
||||
constructor(
|
||||
private sourceTrackId: string,
|
||||
private targetTrackId: string,
|
||||
private elementId: string,
|
||||
private newStartTime: number,
|
||||
private createTrack?: { type: TrackType; index: number },
|
||||
) {
|
||||
constructor({
|
||||
sourceTrackId,
|
||||
targetTrackId,
|
||||
elementId,
|
||||
newStartTime,
|
||||
createTrack,
|
||||
}: {
|
||||
sourceTrackId: string;
|
||||
targetTrackId: string;
|
||||
elementId: string;
|
||||
newStartTime: number;
|
||||
createTrack?: { type: TrackType; index: number };
|
||||
}) {
|
||||
super();
|
||||
this.sourceTrackId = sourceTrackId;
|
||||
this.targetTrackId = targetTrackId;
|
||||
this.elementId = elementId;
|
||||
this.newStartTime = newStartTime;
|
||||
this.createTrack = createTrack;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
@@ -30,10 +46,10 @@ export class MoveElementCommand extends Command {
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const sourceTrack = this.savedState.find(
|
||||
(t) => t.id === this.sourceTrackId,
|
||||
(track) => track.id === this.sourceTrackId,
|
||||
);
|
||||
const element = sourceTrack?.elements.find(
|
||||
(el) => el.id === this.elementId,
|
||||
(trackElement) => trackElement.id === this.elementId,
|
||||
);
|
||||
|
||||
if (!sourceTrack || !element) {
|
||||
@@ -41,7 +57,7 @@ export class MoveElementCommand extends Command {
|
||||
return;
|
||||
}
|
||||
|
||||
let targetTrack = this.savedState.find((t) => t.id === this.targetTrackId);
|
||||
let targetTrack = this.savedState.find((track) => track.id === this.targetTrackId);
|
||||
let tracksToUpdate = this.savedState;
|
||||
if (!targetTrack && this.createTrack) {
|
||||
const newTrack = buildEmptyTrack({
|
||||
@@ -74,6 +90,7 @@ export class MoveElementCommand extends Command {
|
||||
excludeElementId: this.elementId,
|
||||
});
|
||||
|
||||
// keyframe times remain clip-local, so moving only changes element startTime.
|
||||
const movedElement: TimelineElement = {
|
||||
...element,
|
||||
startTime: adjustedStartTime,
|
||||
@@ -81,12 +98,12 @@ export class MoveElementCommand extends Command {
|
||||
|
||||
const isSameTrack = this.sourceTrackId === this.targetTrackId;
|
||||
|
||||
let updatedTracks = tracksToUpdate.map((track) => {
|
||||
let updatedTracks = tracksToUpdate.map((track): TimelineTrack => {
|
||||
if (isSameTrack && track.id === this.sourceTrackId) {
|
||||
return {
|
||||
...track,
|
||||
elements: track.elements.map((el) =>
|
||||
el.id === this.elementId ? movedElement : el,
|
||||
elements: track.elements.map((trackElement) =>
|
||||
trackElement.id === this.elementId ? movedElement : trackElement,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -94,7 +111,9 @@ export class MoveElementCommand extends Command {
|
||||
if (track.id === this.sourceTrackId) {
|
||||
return {
|
||||
...track,
|
||||
elements: track.elements.filter((el) => el.id !== this.elementId),
|
||||
elements: track.elements.filter(
|
||||
(trackElement) => trackElement.id !== this.elementId,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -105,8 +124,8 @@ export class MoveElementCommand extends Command {
|
||||
};
|
||||
}
|
||||
|
||||
return track;
|
||||
}) as TimelineTrack[];
|
||||
return track;
|
||||
});
|
||||
|
||||
if (!isSameTrack) {
|
||||
const sourceTrackAfterMove = updatedTracks.find(
|
||||
|
||||
@@ -3,19 +3,33 @@ import type { TimelineTrack } from "@/types/timeline";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { EditorCore } from "@/core";
|
||||
import { rippleShiftElements } from "@/lib/timeline";
|
||||
import { splitAnimationsAtTime } from "@/lib/animation";
|
||||
|
||||
export class SplitElementsCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private rightSideElements: { trackId: string; elementId: string }[] = [];
|
||||
private previousSelection: { trackId: string; elementId: string }[] = [];
|
||||
private readonly elements: { trackId: string; elementId: string }[];
|
||||
private readonly splitTime: number;
|
||||
private readonly retainSide: "both" | "left" | "right";
|
||||
private readonly rippleEnabled: boolean;
|
||||
|
||||
constructor(
|
||||
private elements: { trackId: string; elementId: string }[],
|
||||
private splitTime: number,
|
||||
private retainSide: "both" | "left" | "right" = "both",
|
||||
private rippleEnabled = false,
|
||||
) {
|
||||
constructor({
|
||||
elements,
|
||||
splitTime,
|
||||
retainSide = "both",
|
||||
rippleEnabled = false,
|
||||
}: {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
splitTime: number;
|
||||
retainSide?: "both" | "left" | "right";
|
||||
rippleEnabled?: boolean;
|
||||
}) {
|
||||
super();
|
||||
this.elements = elements;
|
||||
this.splitTime = splitTime;
|
||||
this.retainSide = retainSide;
|
||||
this.rippleEnabled = rippleEnabled;
|
||||
}
|
||||
|
||||
getRightSideElements(): { trackId: string; elementId: string }[] {
|
||||
@@ -61,6 +75,11 @@ export class SplitElementsCommand extends Command {
|
||||
const relativeTime = this.splitTime - element.startTime;
|
||||
const leftVisibleDuration = relativeTime;
|
||||
const rightVisibleDuration = element.duration - relativeTime;
|
||||
const { leftAnimations, rightAnimations } = splitAnimationsAtTime({
|
||||
animations: element.animations,
|
||||
splitTime: relativeTime,
|
||||
shouldIncludeSplitBoundary: true,
|
||||
});
|
||||
|
||||
if (this.retainSide === "left") {
|
||||
return [
|
||||
@@ -69,6 +88,7 @@ export class SplitElementsCommand extends Command {
|
||||
duration: leftVisibleDuration,
|
||||
trimEnd: element.trimEnd + rightVisibleDuration,
|
||||
name: `${element.name} (left)`,
|
||||
animations: leftAnimations,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -90,11 +110,13 @@ export class SplitElementsCommand extends Command {
|
||||
duration: rightVisibleDuration,
|
||||
trimStart: element.trimStart + leftVisibleDuration,
|
||||
name: `${element.name} (right)`,
|
||||
animations: rightAnimations,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const secondElementId = generateUUID();
|
||||
// "both" - split into two pieces
|
||||
const secondElementId = generateUUID();
|
||||
this.rightSideElements.push({
|
||||
trackId: track.id,
|
||||
elementId: secondElementId,
|
||||
@@ -106,6 +128,7 @@ export class SplitElementsCommand extends Command {
|
||||
duration: leftVisibleDuration,
|
||||
trimEnd: element.trimEnd + rightVisibleDuration,
|
||||
name: `${element.name} (left)`,
|
||||
animations: leftAnimations,
|
||||
},
|
||||
{
|
||||
...element,
|
||||
@@ -114,14 +137,12 @@ export class SplitElementsCommand extends Command {
|
||||
duration: rightVisibleDuration,
|
||||
trimStart: element.trimStart + leftVisibleDuration,
|
||||
name: `${element.name} (right)`,
|
||||
animations: rightAnimations,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
if (
|
||||
this.rippleEnabled &&
|
||||
leftVisibleDurationForRipple !== null
|
||||
) {
|
||||
if (this.rippleEnabled && leftVisibleDurationForRipple !== null) {
|
||||
elements = rippleShiftElements({
|
||||
elements,
|
||||
afterTime: this.splitTime,
|
||||
|
||||
@@ -1,28 +1,48 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
import { clampAnimationsToDuration } from "@/lib/animation";
|
||||
|
||||
export class UpdateElementDurationCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly duration: number;
|
||||
|
||||
constructor(
|
||||
private trackId: string,
|
||||
private elementId: string,
|
||||
private duration: number,
|
||||
) {
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
duration,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
duration: number;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.duration = duration;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = this.savedState.map((t) => {
|
||||
if (t.id !== this.trackId) return t;
|
||||
const newElements = t.elements.map((el) =>
|
||||
el.id === this.elementId ? { ...el, duration: this.duration } : el,
|
||||
const updatedTracks = this.savedState.map((track) => {
|
||||
if (track.id !== this.trackId) return track;
|
||||
const newElements = track.elements.map((element) =>
|
||||
element.id === this.elementId
|
||||
? {
|
||||
...element,
|
||||
duration: this.duration,
|
||||
animations: clampAnimationsToDuration({
|
||||
animations: element.animations,
|
||||
duration: this.duration,
|
||||
}),
|
||||
}
|
||||
: element,
|
||||
);
|
||||
return { ...t, elements: newElements } as typeof t;
|
||||
return { ...track, elements: newElements } as typeof track;
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
|
||||
@@ -5,12 +5,19 @@ import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
|
||||
|
||||
export class UpdateElementStartTimeCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly elements: { trackId: string; elementId: string }[];
|
||||
private readonly startTime: number;
|
||||
|
||||
constructor(
|
||||
private elements: { trackId: string; elementId: string }[],
|
||||
private startTime: number,
|
||||
) {
|
||||
constructor({
|
||||
elements,
|
||||
startTime,
|
||||
}: {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
startTime: number;
|
||||
}) {
|
||||
super();
|
||||
this.elements = elements;
|
||||
this.startTime = startTime;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
@@ -20,7 +27,7 @@ export class UpdateElementStartTimeCommand extends Command {
|
||||
const currentTracks = this.savedState;
|
||||
const updatedTracks = currentTracks.map((track) => {
|
||||
const hasElementsToUpdate = this.elements.some(
|
||||
(el) => el.trackId === track.id,
|
||||
(elementEntry) => elementEntry.trackId === track.id,
|
||||
);
|
||||
|
||||
if (!hasElementsToUpdate) {
|
||||
@@ -29,7 +36,9 @@ export class UpdateElementStartTimeCommand extends Command {
|
||||
|
||||
const newElements = track.elements.map((element) => {
|
||||
const shouldUpdate = this.elements.some(
|
||||
(el) => el.elementId === element.id && el.trackId === track.id,
|
||||
(elementEntry) =>
|
||||
elementEntry.elementId === element.id &&
|
||||
elementEntry.trackId === track.id,
|
||||
);
|
||||
if (!shouldUpdate) {
|
||||
return element;
|
||||
|
||||
@@ -1,18 +1,35 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
import { clampAnimationsToDuration } from "@/lib/animation";
|
||||
|
||||
export class UpdateElementTrimCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly elementId: string;
|
||||
private readonly trimStart: number;
|
||||
private readonly trimEnd: number;
|
||||
private readonly startTime: number | undefined;
|
||||
private readonly duration: number | undefined;
|
||||
|
||||
constructor(
|
||||
private elementId: string,
|
||||
private trimStart: number,
|
||||
private trimEnd: number,
|
||||
private startTime?: number,
|
||||
private duration?: number,
|
||||
) {
|
||||
constructor({
|
||||
elementId,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
startTime,
|
||||
duration,
|
||||
}: {
|
||||
elementId: string;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
startTime?: number;
|
||||
duration?: number;
|
||||
}) {
|
||||
super();
|
||||
this.elementId = elementId;
|
||||
this.trimStart = trimStart;
|
||||
this.trimEnd = trimEnd;
|
||||
this.startTime = startTime;
|
||||
this.duration = duration;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
@@ -25,12 +42,17 @@ export class UpdateElementTrimCommand extends Command {
|
||||
return element;
|
||||
}
|
||||
|
||||
const nextDuration = this.duration ?? element.duration;
|
||||
return {
|
||||
...element,
|
||||
trimStart: this.trimStart,
|
||||
trimEnd: this.trimEnd,
|
||||
startTime: this.startTime ?? element.startTime,
|
||||
duration: this.duration ?? element.duration,
|
||||
duration: nextDuration,
|
||||
animations: clampAnimationsToDuration({
|
||||
animations: element.animations,
|
||||
duration: nextDuration,
|
||||
}),
|
||||
};
|
||||
});
|
||||
return { ...track, elements: newElements } as typeof track;
|
||||
|
||||
@@ -1,28 +1,38 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import type { TimelineElement, TimelineTrack } from "@/types/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
import { updateElementInTracks } from "@/lib/timeline";
|
||||
|
||||
export class UpdateElementCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly updates: Partial<TimelineElement>;
|
||||
|
||||
constructor(
|
||||
private trackId: string,
|
||||
private elementId: string,
|
||||
private updates: Partial<Record<string, unknown>>,
|
||||
) {
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
updates,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
updates: Partial<TimelineElement>;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.updates = updates;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = this.savedState.map((t) => {
|
||||
if (t.id !== this.trackId) return t;
|
||||
const newElements = t.elements.map((el) =>
|
||||
el.id === this.elementId ? { ...el, ...this.updates } : el,
|
||||
);
|
||||
return { ...t, elements: newElements } as typeof t;
|
||||
const updatedTracks = updateElementInTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
update: (element) => ({ ...element, ...this.updates }) as TimelineElement,
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
|
||||
Reference in New Issue
Block a user