diff options
Diffstat (limited to 'apps/web/src/lib')
9 files changed, 304 insertions, 104 deletions
diff --git a/apps/web/src/lib/commands/base-command.ts b/apps/web/src/lib/commands/base-command.ts index 66062c52..a8ce1d34 100644 --- a/apps/web/src/lib/commands/base-command.ts +++ b/apps/web/src/lib/commands/base-command.ts @@ -1,11 +1,17 @@ +import type { ElementRef } from "@/lib/timeline/types"; + +export interface CommandResult { + select?: ElementRef[]; +} + export abstract class Command { - abstract execute(): void; + abstract execute(): CommandResult | undefined; undo(): void { throw new Error("Undo not implemented for this command"); } - redo(): void { - this.execute(); + redo(): CommandResult | undefined { + return this.execute(); } } diff --git a/apps/web/src/lib/commands/batch-command.ts b/apps/web/src/lib/commands/batch-command.ts index 05b51d38..ea23411c 100644 --- a/apps/web/src/lib/commands/batch-command.ts +++ b/apps/web/src/lib/commands/batch-command.ts @@ -1,14 +1,21 @@ -import { Command } from "./base-command"; +import { Command, type CommandResult } from "./base-command"; export class BatchCommand extends Command { constructor(private commands: Command[]) { super(); } - execute(): void { + execute(): CommandResult | undefined { + let latestSelectionResult: CommandResult | undefined; + for (const command of this.commands) { - command.execute(); + const result = command.execute(); + if (result?.select !== undefined) { + latestSelectionResult = result; + } } + + return latestSelectionResult; } undo(): void { @@ -17,9 +24,16 @@ export class BatchCommand extends Command { } } - redo(): void { + redo(): CommandResult | undefined { + let latestSelectionResult: CommandResult | undefined; + for (const command of this.commands) { - command.execute(); + const result = command.redo(); + if (result?.select !== undefined) { + latestSelectionResult = result; + } } + + return latestSelectionResult; } } diff --git a/apps/web/src/lib/commands/index.ts b/apps/web/src/lib/commands/index.ts index 03de07c4..78927ca9 100644 --- a/apps/web/src/lib/commands/index.ts +++ b/apps/web/src/lib/commands/index.ts @@ -1,4 +1,5 @@ export { Command } from "./base-command"; +export type { CommandResult } from "./base-command"; export { BatchCommand } from "./batch-command"; export * from "./timeline"; diff --git a/apps/web/src/lib/commands/timeline/clipboard/paste.ts b/apps/web/src/lib/commands/timeline/clipboard/paste.ts index e1b43364..74a88f0b 100644 --- a/apps/web/src/lib/commands/timeline/clipboard/paste.ts +++ b/apps/web/src/lib/commands/timeline/clipboard/paste.ts @@ -1,4 +1,4 @@ -import { Command } from "@/lib/commands/base-command";
+import { Command, type CommandResult } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type {
TimelineTrack,
@@ -17,28 +17,33 @@ import { cloneAnimations } from "@/lib/animation"; export class PasteCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private pastedElements: { trackId: string; elementId: string }[] = [];
- private previousSelection: { trackId: string; elementId: string }[] = [];
+ private readonly time: number;
+ private readonly clipboardItems: ClipboardItem[];
- constructor(
- private time: number,
- private clipboardItems: ClipboardItem[],
- ) {
+ constructor({
+ time,
+ clipboardItems,
+ }: {
+ time: number;
+ clipboardItems: ClipboardItem[];
+ }) {
super();
+ this.time = time;
+ this.clipboardItems = clipboardItems;
}
- execute(): void {
+ execute(): CommandResult | undefined {
if (this.clipboardItems.length === 0) return;
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
- this.previousSelection = editor.selection.getSelectedElements();
this.pastedElements = [];
const minStart = Math.min(
...this.clipboardItems.map((item) => item.element.startTime),
);
- const updatedTracks = [...this.savedState];
+ let updatedTracks = [...this.savedState];
const itemsByTrackId = groupClipboardItemsByTrackId({
clipboardItems: this.clipboardItems,
});
@@ -116,7 +121,7 @@ export class PasteCommand extends Command { editor.timeline.updateTracks(updatedTracks);
if (this.pastedElements.length > 0) {
- editor.selection.setSelectedElements({ elements: this.pastedElements });
+ return { select: this.pastedElements };
}
}
@@ -124,9 +129,6 @@ export class PasteCommand extends Command { if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
- editor.selection.setSelectedElements({
- elements: this.previousSelection,
- });
}
}
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 4bbb0d48..26277f95 100644 --- a/apps/web/src/lib/commands/timeline/element/delete-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/delete-elements.ts @@ -1,4 +1,4 @@ -import { Command } from "@/lib/commands/base-command";
+import { Command, type CommandResult } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { rippleShiftElements } from "@/lib/timeline";
@@ -21,7 +21,7 @@ export class DeleteElementsCommand extends Command { this.rippleEnabled = rippleEnabled;
}
- execute(): void {
+ execute(): CommandResult {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
@@ -69,6 +69,10 @@ export class DeleteElementsCommand extends Command { .filter((track) => track.elements.length > 0 || isMainTrack(track));
editor.timeline.updateTracks(updatedTracks);
+
+ return {
+ select: [],
+ };
}
undo(): void {
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 a4f7ca82..df3531eb 100644 --- a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts @@ -1,4 +1,4 @@ -import { Command } from "@/lib/commands/base-command";
+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";
@@ -12,7 +12,6 @@ interface DuplicateElementsParams { export class DuplicateElementsCommand extends Command {
private duplicatedElements: { trackId: string; elementId: string }[] = [];
private savedState: TimelineTrack[] | null = null;
- private previousSelection: { trackId: string; elementId: string }[] = [];
private elements: DuplicateElementsParams["elements"];
constructor({ elements }: DuplicateElementsParams) {
@@ -20,13 +19,12 @@ export class DuplicateElementsCommand extends Command { this.elements = elements;
}
- execute(): void {
+ execute(): CommandResult | undefined {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
- this.previousSelection = editor.selection.getSelectedElements();
this.duplicatedElements = [];
- const updatedTracks = [...this.savedState];
+ let updatedTracks = [...this.savedState];
for (const track of this.savedState) {
const elementsToDuplicate = this.elements.filter(
@@ -89,9 +87,9 @@ export class DuplicateElementsCommand extends Command { editor.timeline.updateTracks(updatedTracks);
if (this.duplicatedElements.length > 0) {
- editor.selection.setSelectedElements({
- elements: this.duplicatedElements,
- });
+ return {
+ select: this.duplicatedElements,
+ };
}
}
@@ -99,9 +97,6 @@ export class DuplicateElementsCommand extends Command { if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
- editor.selection.setSelectedElements({
- elements: this.previousSelection,
- });
}
}
diff --git a/apps/web/src/lib/commands/timeline/element/split-elements.ts b/apps/web/src/lib/commands/timeline/element/split-elements.ts index 2acac683..e9742906 100644 --- a/apps/web/src/lib/commands/timeline/element/split-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/split-elements.ts @@ -1,4 +1,4 @@ -import { Command } from "@/lib/commands/base-command"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; import type { TimelineTrack } from "@/lib/timeline"; import { generateUUID } from "@/utils/id"; import { EditorCore } from "@/core"; @@ -9,7 +9,6 @@ import { getSourceSpanAtClipTime } from "@/lib/retime"; 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"; @@ -37,10 +36,9 @@ export class SplitElementsCommand extends Command { return this.rightSideElements; } - execute(): void { + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); this.savedState = editor.timeline.getTracks(); - this.previousSelection = editor.selection.getSelectedElements(); this.rightSideElements = []; const updatedTracks = this.savedState.map((track) => { @@ -173,9 +171,9 @@ export class SplitElementsCommand extends Command { editor.timeline.updateTracks(updatedTracks); if (this.rightSideElements.length > 0) { - editor.selection.setSelectedElements({ - elements: this.rightSideElements, - }); + return { + select: this.rightSideElements, + }; } } @@ -183,9 +181,6 @@ export class SplitElementsCommand extends Command { if (this.savedState) { const editor = EditorCore.getInstance(); editor.timeline.updateTracks(this.savedState); - editor.selection.setSelectedElements({ - elements: this.previousSelection, - }); } } } diff --git a/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts b/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts index 95424160..e067fb16 100644 --- a/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts +++ b/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts @@ -1,7 +1,53 @@ import { describe, expect, test } from "bun:test"; -import type { ElementType, TimelineElement, TimelineTrack, TrackType } from "@/lib/timeline"; +import type { + AudioElement, + AudioTrack, + GraphicElement, + GraphicTrack, + TextElement, + TextTrack, + TimelineTrack, + VideoElement, + VideoTrack, +} from "@/lib/timeline"; +import type { Transform } from "@/lib/rendering"; import { resolveTrackPlacement } from "@/lib/timeline/placement"; +function buildTransform(): Transform { + return { + scaleX: 1, + scaleY: 1, + position: { x: 0, y: 0 }, + rotate: 0, + }; +} + +type TestElement = AudioElement | GraphicElement | TextElement | VideoElement; + +function buildElement(params: { + id: string; + type: "audio"; + startTime: number; + duration: number; +}): AudioElement; +function buildElement(params: { + id: string; + type: "graphic"; + startTime: number; + duration: number; +}): GraphicElement; +function buildElement(params: { + id: string; + type: "text"; + startTime: number; + duration: number; +}): TextElement; +function buildElement(params: { + id: string; + type: "video"; + startTime: number; + duration: number; +}): VideoElement; function buildElement({ id, type, @@ -9,61 +55,171 @@ function buildElement({ duration, }: { id: string; - type: ElementType; + type: TestElement["type"]; startTime: number; duration: number; -}): TimelineElement { - return { - id, - type, - name: id, - startTime, - duration, - trimStart: 0, - trimEnd: 0, - } as TimelineElement; +}): TestElement { + switch (type) { + case "audio": + return { + id, + type: "audio", + name: id, + startTime, + duration, + trimStart: 0, + trimEnd: 0, + volume: 1, + sourceType: "upload", + mediaId: `media-${id}`, + } satisfies AudioElement; + case "graphic": + return { + id, + type: "graphic", + name: id, + startTime, + duration, + trimStart: 0, + trimEnd: 0, + definitionId: `graphic-${id}`, + params: {}, + transform: buildTransform(), + opacity: 1, + } satisfies GraphicElement; + case "text": + return { + id, + type: "text", + name: id, + startTime, + duration, + trimStart: 0, + trimEnd: 0, + content: id, + fontSize: 32, + fontFamily: "sans-serif", + color: "#ffffff", + background: { + enabled: false, + color: "#000000", + }, + textAlign: "left", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", + transform: buildTransform(), + opacity: 1, + } satisfies TextElement; + case "video": + return { + id, + type: "video", + name: id, + startTime, + duration, + trimStart: 0, + trimEnd: 0, + mediaId: `media-${id}`, + transform: buildTransform(), + opacity: 1, + } satisfies VideoElement; + } + + throw new Error(`Unsupported test element type: ${type}`); } -function buildTrack({ - id, - type, - elements = [], - isMain = false, -}: { +type BuildTrackParams = + | { + id: string; + type: "audio"; + elements?: AudioTrack["elements"]; + isMain?: boolean; + } + | { + id: string; + type: "graphic"; + elements?: GraphicTrack["elements"]; + isMain?: boolean; + } + | { + id: string; + type: "text"; + elements?: TextTrack["elements"]; + isMain?: boolean; + } + | { + id: string; + type: "video"; + elements?: VideoTrack["elements"]; + isMain?: boolean; + }; + +function buildTrack(params: { id: string; - type: TrackType; - elements?: TimelineElement[]; + type: "audio"; + elements?: AudioTrack["elements"]; isMain?: boolean; -}): TimelineTrack { - if (type === "video") { - return { - id, - type, - name: id, - elements: elements as TimelineTrack["elements"], - isMain, - muted: false, - hidden: false, - }; - } +}): AudioTrack; +function buildTrack(params: { + id: string; + type: "graphic"; + elements?: GraphicTrack["elements"]; + isMain?: boolean; +}): GraphicTrack; +function buildTrack(params: { + id: string; + type: "text"; + elements?: TextTrack["elements"]; + isMain?: boolean; +}): TextTrack; +function buildTrack(params: { + id: string; + type: "video"; + elements?: VideoTrack["elements"]; + isMain?: boolean; +}): VideoTrack; +function buildTrack(params: BuildTrackParams): TimelineTrack { + const { id, type, isMain = false } = params; - if (type === "audio") { - return { - id, - type, - name: id, - elements: elements as TimelineTrack["elements"], - muted: false, - }; + switch (type) { + case "audio": + return { + id, + type: "audio", + name: id, + elements: params.elements ?? [], + muted: false, + }; + case "graphic": + return { + id, + type: "graphic", + name: id, + elements: params.elements ?? [], + hidden: false, + }; + case "text": + return { + id, + type: "text", + name: id, + elements: params.elements ?? [], + hidden: false, + }; + case "video": + return { + id, + type: "video", + name: id, + elements: params.elements ?? [], + isMain, + muted: false, + hidden: false, + }; } - return { - id, - type, - name: id, - elements: elements as TimelineTrack["elements"], - hidden: false, - }; + throw new Error(`Unsupported test track type: ${type}`); } function buildTimeSpan({ @@ -124,7 +280,9 @@ describe("resolveTrackPlacement", () => { buildTrack({ id: "text-1", type: "text", - elements: [buildElement({ id: "a", type: "text", startTime: 0, duration: 5 })], + elements: [ + buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }), + ], }), buildTrack({ id: "text-2", type: "text" }), ]; @@ -256,7 +414,9 @@ describe("resolveTrackPlacement", () => { buildTrack({ id: "text-middle", type: "text", - elements: [buildElement({ id: "a", type: "text", startTime: 0, duration: 5 })], + elements: [ + buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }), + ], }), buildTrack({ id: "text-source", type: "text" }), ]; @@ -281,12 +441,16 @@ describe("resolveTrackPlacement", () => { buildTrack({ id: "text-top", type: "text", - elements: [buildElement({ id: "a", type: "text", startTime: 0, duration: 5 })], + elements: [ + buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }), + ], }), buildTrack({ id: "text-source", type: "text", - elements: [buildElement({ id: "b", type: "text", startTime: 0, duration: 5 })], + elements: [ + buildElement({ id: "b", type: "text", startTime: 0, duration: 5 }), + ], }), ]; diff --git a/apps/web/src/lib/timeline/placement/resolve.ts b/apps/web/src/lib/timeline/placement/resolve.ts index 235c789a..35718250 100644 --- a/apps/web/src/lib/timeline/placement/resolve.ts +++ b/apps/web/src/lib/timeline/placement/resolve.ts @@ -44,9 +44,7 @@ function buildExistingTrackResult({ trackId: track.id, trackIndex, trackType: track.type, - ...(adjustedStartTime !== requestedStartTime - ? { adjustedStartTime } - : {}), + ...(adjustedStartTime !== requestedStartTime ? { adjustedStartTime } : {}), }; } @@ -114,6 +112,24 @@ function resolveAlwaysNewTrack({ }); } +function getInsertDirection({ + hoverDirection, + verticalDragDirection, +}: { + hoverDirection: "above" | "below"; + verticalDragDirection?: "up" | "down" | null; +}): "above" | "below" { + if (verticalDragDirection === "up") { + return "above"; + } + + if (verticalDragDirection === "down") { + return "below"; + } + + return hoverDirection; +} + export function resolveTrackPlacement({ tracks, ...placement @@ -127,7 +143,9 @@ export function resolveTrackPlacement({ const { timeSpans, strategy } = placement; if (strategy.type === "explicit") { - const trackIndex = tracks.findIndex((track) => track.id === strategy.trackId); + const trackIndex = tracks.findIndex( + (track) => track.id === strategy.trackId, + ); if (trackIndex < 0) { return null; } @@ -182,15 +200,16 @@ export function resolveTrackPlacement({ }); } - const insertDirection = - !isPreferredTrackCompatible && strategy.verticalDragDirection - ? strategy.verticalDragDirection - : strategy.hoverDirection; const { insertIndex, insertPosition } = resolvePreferredNewTrackPlacement({ tracks, trackType, preferredIndex: strategy.trackIndex, - direction: insertDirection, + direction: getInsertDirection({ + hoverDirection: strategy.hoverDirection, + verticalDragDirection: !isPreferredTrackCompatible + ? strategy.verticalDragDirection + : null, + }), }); return buildNewTrackResult({ trackType, |
