summaryrefslogtreecommitdiff
path: root/apps/web/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/lib')
-rw-r--r--apps/web/src/lib/commands/base-command.ts12
-rw-r--r--apps/web/src/lib/commands/batch-command.ts24
-rw-r--r--apps/web/src/lib/commands/index.ts1
-rw-r--r--apps/web/src/lib/commands/timeline/clipboard/paste.ts28
-rw-r--r--apps/web/src/lib/commands/timeline/element/delete-elements.ts8
-rw-r--r--apps/web/src/lib/commands/timeline/element/duplicate-elements.ts17
-rw-r--r--apps/web/src/lib/commands/timeline/element/split-elements.ts15
-rw-r--r--apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts266
-rw-r--r--apps/web/src/lib/timeline/placement/resolve.ts37
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,