summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-03-31 22:53:16 +0200
committerMaze Winther <mazewinther@gmail.com>2026-03-31 22:53:16 +0200
commitf8fb238b228881ef970739b7d884912058b41347 (patch)
treee784085f29377242c5d1441ec46b397ada5170ef
parent0f5dca4f12d926ec0015116e85ec1129a6891f40 (diff)
downloadopencut-f8fb238b228881ef970739b7d884912058b41347.tar.gz
feat: centralize selection management
-rw-r--r--apps/web/src/core/index.ts2
-rw-r--r--apps/web/src/core/managers/commands.ts152
-rw-r--r--apps/web/src/core/managers/timeline-manager.ts2
-rw-r--r--apps/web/src/hooks/actions/use-editor-actions.ts1
-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.ts26
-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.ts15
-rw-r--r--apps/web/src/lib/commands/timeline/element/split-elements.ts15
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,
- });
}
}
}