diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-03-31 22:54:06 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-03-31 22:54:06 +0200 |
| commit | cc150c7cfb859dfc38372210ec5bcc8bd196bbc4 (patch) | |
| tree | 6cc57c2c54b6fd941b97c663b919f934b1445dde | |
| parent | 0488a6be455c225535414fd651c838c355a9d3bd (diff) | |
| parent | f8fb238b228881ef970739b7d884912058b41347 (diff) | |
| download | opencut-cc150c7cfb859dfc38372210ec5bcc8bd196bbc4.tar.gz | |
Merge branch 'refactor-selection-commands'
| -rw-r--r-- | apps/web/src/core/index.ts | 2 | ||||
| -rw-r--r-- | apps/web/src/core/managers/commands.ts | 152 | ||||
| -rw-r--r-- | apps/web/src/core/managers/timeline-manager.ts | 2 | ||||
| -rw-r--r-- | apps/web/src/hooks/actions/use-editor-actions.ts | 1 | ||||
| -rw-r--r-- | apps/web/src/lib/commands/base-command.ts | 12 | ||||
| -rw-r--r-- | apps/web/src/lib/commands/batch-command.ts | 24 | ||||
| -rw-r--r-- | apps/web/src/lib/commands/index.ts | 1 | ||||
| -rw-r--r-- | apps/web/src/lib/commands/timeline/clipboard/paste.ts | 26 | ||||
| -rw-r--r-- | apps/web/src/lib/commands/timeline/element/delete-elements.ts | 8 | ||||
| -rw-r--r-- | apps/web/src/lib/commands/timeline/element/duplicate-elements.ts | 15 | ||||
| -rw-r--r-- | apps/web/src/lib/commands/timeline/element/split-elements.ts | 15 |
11 files changed, 164 insertions, 94 deletions
diff --git a/apps/web/src/core/index.ts b/apps/web/src/core/index.ts index ee1873b0..f1c2d032 100644 --- a/apps/web/src/core/index.ts +++ b/apps/web/src/core/index.ts @@ -27,7 +27,7 @@ export class EditorCore { private constructor() {
registerDefaultEffects();
registerDefaultMasks();
- this.command = new CommandManager();
+ this.command = new CommandManager(this);
this.timeline = new TimelineManager(this);
this.playback = new PlaybackManager(this);
this.scenes = new ScenesManager(this);
diff --git a/apps/web/src/core/managers/commands.ts b/apps/web/src/core/managers/commands.ts index d4d4c35c..4c53857b 100644 --- a/apps/web/src/core/managers/commands.ts +++ b/apps/web/src/core/managers/commands.ts @@ -1,49 +1,103 @@ -import type { Command } from "@/lib/commands";
-
-export class CommandManager {
- private history: Command[] = [];
- private redoStack: Command[] = [];
-
- execute({ command }: { command: Command }): Command {
- command.execute();
- this.history.push(command);
- this.redoStack = [];
- return command;
- }
-
- push({ command }: { command: Command }): void {
- this.history.push(command);
- this.redoStack = [];
- }
-
- undo(): void {
- if (this.history.length === 0) return;
- const command = this.history.pop();
- command?.undo();
- if (command) {
- this.redoStack.push(command);
- }
- }
-
- redo(): void {
- if (this.redoStack.length === 0) return;
- const command = this.redoStack.pop();
- command?.redo();
- if (command) {
- this.history.push(command);
- }
- }
-
- canUndo(): boolean {
- return this.history.length > 0;
- }
-
- canRedo(): boolean {
- return this.redoStack.length > 0;
- }
-
- clear(): void {
- this.history = [];
- this.redoStack = [];
- }
-}
+import type { EditorCore } from "@/core"; +import type { Command, CommandResult } from "@/lib/commands"; +import type { ElementRef } from "@/lib/timeline/types"; + +interface CommandHistoryEntry { + command: Command; + previousSelection: ElementRef[]; + selectionOverride?: ElementRef[]; +} + +export class CommandManager { + private history: CommandHistoryEntry[] = []; + private redoStack: CommandHistoryEntry[] = []; + + constructor(private editor: EditorCore) {} + + execute({ command }: { command: Command }): Command { + const previousSelection = this.getSelectionSnapshot(); + const result = command.execute(); + const selectionOverride = this.applySelectionOverride(result); + this.history.push({ + command, + previousSelection, + selectionOverride, + }); + this.redoStack = []; + return command; + } + + push({ command }: { command: Command }): void { + this.history.push({ + command, + previousSelection: this.getSelectionSnapshot(), + }); + this.redoStack = []; + } + + undo(): void { + if (this.history.length === 0) return; + const entry = this.history.pop(); + entry?.command.undo(); + if (entry) { + // Only restore selection for commands that explicitly changed it. + // Commands without selection intent leave selection untouched, + // preserving any UI-driven selection changes (clicks, box select) + // that happened between commands. Commands that remove elements + // must declare { select: [] } to clear stale refs. + if (entry.selectionOverride !== undefined) { + this.editor.selection.setSelectedElements({ + elements: [...entry.previousSelection], + }); + } + this.redoStack.push(entry); + } + } + + redo(): void { + if (this.redoStack.length === 0) return; + const entry = this.redoStack.pop(); + if (!entry) { + return; + } + + const previousSelection = this.getSelectionSnapshot(); + const result = entry.command.redo(); + const selectionOverride = this.applySelectionOverride(result); + + this.history.push({ + command: entry.command, + previousSelection, + selectionOverride, + }); + } + + canUndo(): boolean { + return this.history.length > 0; + } + + canRedo(): boolean { + return this.redoStack.length > 0; + } + + clear(): void { + this.history = []; + this.redoStack = []; + } + + private getSelectionSnapshot(): ElementRef[] { + return [...this.editor.selection.getSelectedElements()]; + } + + private applySelectionOverride( + result: CommandResult | undefined, + ): ElementRef[] | undefined { + if (result?.select === undefined) { + return undefined; + } + + const selectionOverride = [...result.select]; + this.editor.selection.setSelectedElements({ elements: selectionOverride }); + return selectionOverride; + } +} diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts index 263de41f..7189761c 100644 --- a/apps/web/src/core/managers/timeline-manager.ts +++ b/apps/web/src/core/managers/timeline-manager.ts @@ -265,7 +265,7 @@ export class TimelineManager { time: number; clipboardItems: ClipboardItem[]; }): { trackId: string; elementId: string }[] { - const command = new PasteCommand(time, clipboardItems); + const command = new PasteCommand({ time, clipboardItems }); this.editor.command.execute({ command }); return command.getPastedElements(); } diff --git a/apps/web/src/hooks/actions/use-editor-actions.ts b/apps/web/src/hooks/actions/use-editor-actions.ts index bfac931b..ba480a30 100644 --- a/apps/web/src/hooks/actions/use-editor-actions.ts +++ b/apps/web/src/hooks/actions/use-editor-actions.ts @@ -261,7 +261,6 @@ export function useEditorActions() { elements: selectedElements, rippleEnabled: rippleEditingEnabled, }); - editor.selection.clearSelection(); }, undefined, ); 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 cd465ae7..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,21 +17,26 @@ 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(
@@ -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 90e178d0..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,10 +19,9 @@ 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 = [];
let updatedTracks = [...this.savedState];
@@ -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, - }); } } } |
