diff options
Diffstat (limited to 'apps/web/src/lib/commands')
4 files changed, 393 insertions, 412 deletions
diff --git a/apps/web/src/lib/commands/timeline/clipboard/paste.ts b/apps/web/src/lib/commands/timeline/clipboard/paste.ts index 74a88f0b..59e1af1c 100644 --- a/apps/web/src/lib/commands/timeline/clipboard/paste.ts +++ b/apps/web/src/lib/commands/timeline/clipboard/paste.ts @@ -1,184 +1,184 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command";
-import { EditorCore } from "@/core";
-import type {
- TimelineTrack,
- TimelineElement,
- ClipboardItem,
-} from "@/lib/timeline";
-import { generateUUID } from "@/utils/id";
-import {
- applyPlacement,
- resolveTrackPlacement,
- isMainTrack,
- enforceMainTrackStart,
-} from "@/lib/timeline/placement";
-import { cloneAnimations } from "@/lib/animation";
-
-export class PasteCommand extends Command {
- private savedState: TimelineTrack[] | null = null;
- private pastedElements: { trackId: string; elementId: string }[] = [];
- private readonly time: number;
- private readonly clipboardItems: ClipboardItem[];
-
- constructor({
- time,
- clipboardItems,
- }: {
- time: number;
- clipboardItems: ClipboardItem[];
- }) {
- super();
- this.time = time;
- this.clipboardItems = clipboardItems;
- }
-
- execute(): CommandResult | undefined {
- if (this.clipboardItems.length === 0) return;
-
- const editor = EditorCore.getInstance();
- this.savedState = editor.timeline.getTracks();
- this.pastedElements = [];
-
- const minStart = Math.min(
- ...this.clipboardItems.map((item) => item.element.startTime),
- );
-
- let updatedTracks = [...this.savedState];
- const itemsByTrackId = groupClipboardItemsByTrackId({
- clipboardItems: this.clipboardItems,
- });
-
- for (const [trackId, items] of itemsByTrackId) {
- const elementsToAdd = buildPastedElements({
- items,
- minStart,
- time: this.time,
- });
-
- if (elementsToAdd.length === 0) {
- continue;
- }
-
- const trackType = items[0].trackType;
- const sourceTrackIndex = updatedTracks.findIndex(
- (track) => track.id === trackId,
- );
- const placementResult = resolveTrackPlacement({
- tracks: updatedTracks,
- trackType,
- timeSpans: elementsToAdd.map((element) => ({
- startTime: element.startTime,
- duration: element.duration,
- })),
- strategy: { type: "aboveSource", sourceTrackIndex },
- });
- if (!placementResult) {
- continue;
- }
-
- let elementsForPlacement = elementsToAdd;
- if (placementResult.kind === "existingTrack") {
- const targetTrack = updatedTracks[placementResult.trackIndex];
- if (isMainTrack(targetTrack)) {
- const earliestElement = elementsToAdd.reduce((earliest, element) =>
- element.startTime < earliest.startTime ? element : earliest,
- );
- const adjustedEarliestStartTime = enforceMainTrackStart({
- tracks: updatedTracks,
- targetTrackId: targetTrack.id,
- requestedStartTime: earliestElement.startTime,
- });
- const delta = adjustedEarliestStartTime - earliestElement.startTime;
-
- if (delta !== 0) {
- elementsForPlacement = elementsToAdd.map((element) => ({
- ...element,
- startTime: Math.max(0, element.startTime + delta),
- }));
- }
- }
- }
-
- const applied = applyPlacement({
- tracks: updatedTracks,
- placementResult,
- elements: elementsForPlacement,
- });
- if (!applied) {
- continue;
- }
-
- updatedTracks = applied.updatedTracks;
-
- for (const element of elementsForPlacement) {
- this.pastedElements.push({
- trackId: applied.targetTrackId,
- elementId: element.id,
- });
- }
- }
-
- editor.timeline.updateTracks(updatedTracks);
-
- if (this.pastedElements.length > 0) {
- return { select: this.pastedElements };
- }
- }
-
- undo(): void {
- if (this.savedState) {
- const editor = EditorCore.getInstance();
- editor.timeline.updateTracks(this.savedState);
- }
- }
-
- getPastedElements(): { trackId: string; elementId: string }[] {
- return this.pastedElements;
- }
-}
-
-function groupClipboardItemsByTrackId({
- clipboardItems,
-}: {
- clipboardItems: ClipboardItem[];
-}): Map<string, ClipboardItem[]> {
- const groupedItems = new Map<string, ClipboardItem[]>();
-
- for (const item of clipboardItems) {
- const existingItems = groupedItems.get(item.trackId) ?? [];
- groupedItems.set(item.trackId, [...existingItems, item]);
- }
-
- return groupedItems;
-}
-
-function buildPastedElements({
- items,
- minStart,
- time,
-}: {
- items: ClipboardItem[];
- minStart: number;
- time: number;
-}): TimelineElement[] {
- const elementsToAdd: TimelineElement[] = [];
-
- for (const item of items) {
- const relativeOffset = item.element.startTime - minStart;
- const startTime = Math.max(0, time + relativeOffset);
- const newElementId = generateUUID();
-
- elementsToAdd.push({
- ...item.element,
- id: newElementId,
- startTime,
- animations: cloneAnimations({
- animations: item.element.animations,
- shouldRegenerateKeyframeIds: true,
- }),
- } as TimelineElement);
- }
-
- return elementsToAdd;
-}
-
+import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import type { + TimelineTrack, + TimelineElement, + ClipboardItem, +} from "@/lib/timeline"; +import { generateUUID } from "@/utils/id"; +import { + applyPlacement, + resolveTrackPlacement, + isMainTrack, + enforceMainTrackStart, +} from "@/lib/timeline/placement"; +import { cloneAnimations } from "@/lib/animation"; + +export class PasteCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private pastedElements: { trackId: string; elementId: string }[] = []; + private readonly time: number; + private readonly clipboardItems: ClipboardItem[]; + + constructor({ + time, + clipboardItems, + }: { + time: number; + clipboardItems: ClipboardItem[]; + }) { + super(); + this.time = time; + this.clipboardItems = clipboardItems; + } + + execute(): CommandResult | undefined { + if (this.clipboardItems.length === 0) return; + + const editor = EditorCore.getInstance(); + this.savedState = editor.timeline.getTracks(); + this.pastedElements = []; + + const minStart = Math.min( + ...this.clipboardItems.map((item) => item.element.startTime), + ); + + let updatedTracks = [...this.savedState]; + const itemsByTrackId = groupClipboardItemsByTrackId({ + clipboardItems: this.clipboardItems, + }); + + for (const [trackId, items] of itemsByTrackId) { + const elementsToAdd = buildPastedElements({ + items, + minStart, + time: this.time, + }); + + if (elementsToAdd.length === 0) { + continue; + } + + const trackType = items[0].trackType; + const sourceTrackIndex = updatedTracks.findIndex( + (track) => track.id === trackId, + ); + const placementResult = resolveTrackPlacement({ + tracks: updatedTracks, + trackType, + timeSpans: elementsToAdd.map((element) => ({ + startTime: element.startTime, + duration: element.duration, + })), + strategy: { type: "aboveSource", sourceTrackIndex }, + }); + if (!placementResult) { + continue; + } + + let elementsForPlacement = elementsToAdd; + if (placementResult.kind === "existingTrack") { + const targetTrack = updatedTracks[placementResult.trackIndex]; + if (isMainTrack(targetTrack)) { + const earliestElement = elementsToAdd.reduce((earliest, element) => + element.startTime < earliest.startTime ? element : earliest, + ); + const adjustedEarliestStartTime = enforceMainTrackStart({ + tracks: updatedTracks, + targetTrackId: targetTrack.id, + requestedStartTime: earliestElement.startTime, + }); + const delta = adjustedEarliestStartTime - earliestElement.startTime; + + if (delta !== 0) { + elementsForPlacement = elementsToAdd.map((element) => ({ + ...element, + startTime: Math.max(0, element.startTime + delta), + })); + } + } + } + + const applied = applyPlacement({ + tracks: updatedTracks, + placementResult, + elements: elementsForPlacement, + }); + if (!applied) { + continue; + } + + updatedTracks = applied.updatedTracks; + + for (const element of elementsForPlacement) { + this.pastedElements.push({ + trackId: applied.targetTrackId, + elementId: element.id, + }); + } + } + + editor.timeline.updateTracks(updatedTracks); + + if (this.pastedElements.length > 0) { + return { select: this.pastedElements }; + } + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } + + getPastedElements(): { trackId: string; elementId: string }[] { + return this.pastedElements; + } +} + +function groupClipboardItemsByTrackId({ + clipboardItems, +}: { + clipboardItems: ClipboardItem[]; +}): Map<string, ClipboardItem[]> { + const groupedItems = new Map<string, ClipboardItem[]>(); + + for (const item of clipboardItems) { + const existingItems = groupedItems.get(item.trackId) ?? []; + groupedItems.set(item.trackId, [...existingItems, item]); + } + + return groupedItems; +} + +function buildPastedElements({ + items, + minStart, + time, +}: { + items: ClipboardItem[]; + minStart: number; + time: number; +}): TimelineElement[] { + const elementsToAdd: TimelineElement[] = []; + + for (const item of items) { + const relativeOffset = item.element.startTime - minStart; + const startTime = Math.max(0, time + relativeOffset); + const newElementId = generateUUID(); + + elementsToAdd.push({ + ...item.element, + id: newElementId, + startTime, + animations: cloneAnimations({ + animations: item.element.animations, + shouldRegenerateKeyframeIds: true, + }), + } as TimelineElement); + } + + return elementsToAdd; +} + diff --git a/apps/web/src/lib/commands/timeline/element/delete-elements.ts b/apps/web/src/lib/commands/timeline/element/delete-elements.ts index 26277f95..9177a3f8 100644 --- a/apps/web/src/lib/commands/timeline/element/delete-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/delete-elements.ts @@ -1,84 +1,81 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/lib/timeline";
-import { EditorCore } from "@/core";
-import { rippleShiftElements } from "@/lib/timeline";
-import { isMainTrack } from "@/lib/timeline/placement";
-
-export class DeleteElementsCommand extends Command {
- private savedState: TimelineTrack[] | null = null;
- private readonly elements: { trackId: string; elementId: string }[];
- private readonly rippleEnabled: boolean;
-
- constructor({
- elements,
- rippleEnabled = false,
- }: {
- elements: { trackId: string; elementId: string }[];
- rippleEnabled?: boolean;
- }) {
- super();
- this.elements = elements;
- this.rippleEnabled = rippleEnabled;
- }
-
- execute(): CommandResult {
- const editor = EditorCore.getInstance();
- this.savedState = editor.timeline.getTracks();
-
- const updatedTracks = this.savedState
- .map((track) => {
- const elementsToDeleteOnTrack = this.elements.filter(
- (target) => target.trackId === track.id,
- );
- const hasElementsToDelete = elementsToDeleteOnTrack.length > 0;
-
- 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 }));
-
- 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(
- (a, b) => b.startTime - a.startTime,
- );
- for (const { startTime, duration } of sortedByStartDesc) {
- elements = rippleShiftElements({
- elements,
- afterTime: startTime,
- shiftAmount: duration,
- });
- }
- }
-
- return { ...track, elements } as typeof track;
- })
- .filter((track) => track.elements.length > 0 || isMainTrack(track));
-
- editor.timeline.updateTracks(updatedTracks);
-
- return {
- select: [],
- };
- }
-
- undo(): void {
- if (this.savedState) {
- const editor = EditorCore.getInstance();
- editor.timeline.updateTracks(this.savedState);
- }
- }
-}
+import { Command, type CommandResult } from "@/lib/commands/base-command"; +import type { TimelineTrack } from "@/lib/timeline"; +import { EditorCore } from "@/core"; +import { 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({ + elements, + rippleEnabled = false, + }: { + elements: { trackId: string; elementId: string }[]; + rippleEnabled?: boolean; + }) { + super(); + this.elements = elements; + this.rippleEnabled = rippleEnabled; + } + + execute(): CommandResult { + const editor = EditorCore.getInstance(); + this.savedState = editor.timeline.getTracks(); + + const updatedTracks = this.savedState.map((track) => { + const elementsToDeleteOnTrack = this.elements.filter( + (target) => target.trackId === track.id, + ); + const hasElementsToDelete = elementsToDeleteOnTrack.length > 0; + + 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 })); + + 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( + (a, b) => b.startTime - a.startTime, + ); + for (const { startTime, duration } of sortedByStartDesc) { + elements = rippleShiftElements({ + elements, + afterTime: startTime, + shiftAmount: duration, + }); + } + } + + return { ...track, elements } as typeof track; + }); + + editor.timeline.updateTracks(updatedTracks); + + return { + select: [], + }; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts index df3531eb..ad31ae9c 100644 --- a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts @@ -1,127 +1,127 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command";
-import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
-import { generateUUID } from "@/utils/id";
-import { EditorCore } from "@/core";
-import { applyPlacement, resolveTrackPlacement } from "@/lib/timeline/placement";
-import { cloneAnimations } from "@/lib/animation";
-
-interface DuplicateElementsParams {
- elements: { trackId: string; elementId: string }[];
-}
-
-export class DuplicateElementsCommand extends Command {
- private duplicatedElements: { trackId: string; elementId: string }[] = [];
- private savedState: TimelineTrack[] | null = null;
- private elements: DuplicateElementsParams["elements"];
-
- constructor({ elements }: DuplicateElementsParams) {
- super();
- this.elements = elements;
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- this.savedState = editor.timeline.getTracks();
- this.duplicatedElements = [];
-
- let updatedTracks = [...this.savedState];
-
- for (const track of this.savedState) {
- const elementsToDuplicate = this.elements.filter(
- (elementEntry) => elementEntry.trackId === track.id,
- );
-
- if (elementsToDuplicate.length === 0) {
- continue;
- }
-
- const elementIdsToDuplicate = new Set(
- elementsToDuplicate.map((element) => element.elementId),
- );
- const newTrackElements: TimelineElement[] = [];
-
- for (const element of track.elements) {
- if (!elementIdsToDuplicate.has(element.id)) {
- continue;
- }
-
- const newId = generateUUID();
- newTrackElements.push(
- buildDuplicateElement({
- element,
- id: newId,
- startTime: element.startTime,
- }),
- );
- }
-
- const placementResult = resolveTrackPlacement({
- tracks: updatedTracks,
- trackType: track.type,
- timeSpans: [],
- strategy: { type: "alwaysNew", position: "highest" },
- });
- if (!placementResult || placementResult.kind !== "newTrack") {
- continue;
- }
-
- const applied = applyPlacement({
- tracks: updatedTracks,
- placementResult,
- elements: newTrackElements,
- });
- if (!applied) {
- continue;
- }
-
- updatedTracks = applied.updatedTracks;
-
- for (const element of newTrackElements) {
- this.duplicatedElements.push({
- trackId: applied.targetTrackId,
- elementId: element.id,
- });
- }
- }
-
- editor.timeline.updateTracks(updatedTracks);
-
- if (this.duplicatedElements.length > 0) {
- return {
- select: this.duplicatedElements,
- };
- }
- }
-
- undo(): void {
- if (this.savedState) {
- const editor = EditorCore.getInstance();
- editor.timeline.updateTracks(this.savedState);
- }
- }
-
- getDuplicatedElements(): { trackId: string; elementId: string }[] {
- return this.duplicatedElements;
- }
-}
-
-function buildDuplicateElement({
- element,
- id,
- startTime,
-}: {
- element: TimelineElement;
- id: string;
- startTime: number;
-}): TimelineElement {
- return {
- ...element,
- id,
- name: `${element.name} (copy)`,
- startTime,
- animations: cloneAnimations({
- animations: element.animations,
- shouldRegenerateKeyframeIds: true,
- }),
- };
-}
+import { Command, type CommandResult } from "@/lib/commands/base-command"; +import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; +import { generateUUID } from "@/utils/id"; +import { EditorCore } from "@/core"; +import { applyPlacement, resolveTrackPlacement } from "@/lib/timeline/placement"; +import { cloneAnimations } from "@/lib/animation"; + +interface DuplicateElementsParams { + elements: { trackId: string; elementId: string }[]; +} + +export class DuplicateElementsCommand extends Command { + private duplicatedElements: { trackId: string; elementId: string }[] = []; + private savedState: TimelineTrack[] | null = null; + private elements: DuplicateElementsParams["elements"]; + + constructor({ elements }: DuplicateElementsParams) { + super(); + this.elements = elements; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.timeline.getTracks(); + this.duplicatedElements = []; + + let updatedTracks = [...this.savedState]; + + for (const track of this.savedState) { + const elementsToDuplicate = this.elements.filter( + (elementEntry) => elementEntry.trackId === track.id, + ); + + if (elementsToDuplicate.length === 0) { + continue; + } + + const elementIdsToDuplicate = new Set( + elementsToDuplicate.map((element) => element.elementId), + ); + const newTrackElements: TimelineElement[] = []; + + for (const element of track.elements) { + if (!elementIdsToDuplicate.has(element.id)) { + continue; + } + + const newId = generateUUID(); + newTrackElements.push( + buildDuplicateElement({ + element, + id: newId, + startTime: element.startTime, + }), + ); + } + + const placementResult = resolveTrackPlacement({ + tracks: updatedTracks, + trackType: track.type, + timeSpans: [], + strategy: { type: "alwaysNew", position: "highest" }, + }); + if (!placementResult || placementResult.kind !== "newTrack") { + continue; + } + + const applied = applyPlacement({ + tracks: updatedTracks, + placementResult, + elements: newTrackElements, + }); + if (!applied) { + continue; + } + + updatedTracks = applied.updatedTracks; + + for (const element of newTrackElements) { + this.duplicatedElements.push({ + trackId: applied.targetTrackId, + elementId: element.id, + }); + } + } + + editor.timeline.updateTracks(updatedTracks); + + if (this.duplicatedElements.length > 0) { + return { + select: this.duplicatedElements, + }; + } + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } + + getDuplicatedElements(): { trackId: string; elementId: string }[] { + return this.duplicatedElements; + } +} + +function buildDuplicateElement({ + element, + id, + startTime, +}: { + element: TimelineElement; + id: string; + startTime: number; +}): TimelineElement { + return { + ...element, + id, + name: `${element.name} (copy)`, + startTime, + animations: cloneAnimations({ + animations: element.animations, + shouldRegenerateKeyframeIds: true, + }), + }; +} diff --git a/apps/web/src/lib/commands/timeline/element/move-elements.ts b/apps/web/src/lib/commands/timeline/element/move-elements.ts index 73d07bff..6d400964 100644 --- a/apps/web/src/lib/commands/timeline/element/move-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/move-elements.ts @@ -7,7 +7,6 @@ import type { } from "@/lib/timeline";
import {
buildEmptyTrack,
- isMainTrack,
validateElementTrackCompatibility,
enforceMainTrackStart,
} from "@/lib/timeline/placement";
@@ -100,7 +99,7 @@ export class MoveElementCommand extends Command { const isSameTrack = this.sourceTrackId === this.targetTrackId;
- let updatedTracks = tracksToUpdate.map((track): TimelineTrack => {
+ const updatedTracks = tracksToUpdate.map((track): TimelineTrack => {
if (isSameTrack && track.id === this.sourceTrackId) {
return {
...track,
@@ -134,21 +133,6 @@ export class MoveElementCommand extends Command { return track;
});
- if (!isSameTrack) {
- const sourceTrackAfterMove = updatedTracks.find(
- (track) => track.id === this.sourceTrackId,
- );
- if (
- sourceTrackAfterMove &&
- sourceTrackAfterMove.elements.length === 0 &&
- !isMainTrack(sourceTrackAfterMove)
- ) {
- updatedTracks = updatedTracks.filter(
- (track) => track.id !== this.sourceTrackId,
- );
- }
- }
-
editor.timeline.updateTracks(updatedTracks);
}
|
