Merge branch 'refactor-selection-commands'

This commit is contained in:
Maze Winther 2026-03-31 22:54:06 +02:00
commit cc150c7cfb
11 changed files with 164 additions and 94 deletions

View File

@ -27,7 +27,7 @@ export class EditorCore {
private constructor() { private constructor() {
registerDefaultEffects(); registerDefaultEffects();
registerDefaultMasks(); registerDefaultMasks();
this.command = new CommandManager(); this.command = new CommandManager(this);
this.timeline = new TimelineManager(this); this.timeline = new TimelineManager(this);
this.playback = new PlaybackManager(this); this.playback = new PlaybackManager(this);
this.scenes = new ScenesManager(this); this.scenes = new ScenesManager(this);

View File

@ -1,49 +1,103 @@
import type { Command } from "@/lib/commands"; import type { EditorCore } from "@/core";
import type { Command, CommandResult } from "@/lib/commands";
export class CommandManager { import type { ElementRef } from "@/lib/timeline/types";
private history: Command[] = [];
private redoStack: Command[] = []; interface CommandHistoryEntry {
command: Command;
execute({ command }: { command: Command }): Command { previousSelection: ElementRef[];
command.execute(); selectionOverride?: ElementRef[];
this.history.push(command); }
this.redoStack = [];
return command; export class CommandManager {
} private history: CommandHistoryEntry[] = [];
private redoStack: CommandHistoryEntry[] = [];
push({ command }: { command: Command }): void {
this.history.push(command); constructor(private editor: EditorCore) {}
this.redoStack = [];
} execute({ command }: { command: Command }): Command {
const previousSelection = this.getSelectionSnapshot();
undo(): void { const result = command.execute();
if (this.history.length === 0) return; const selectionOverride = this.applySelectionOverride(result);
const command = this.history.pop(); this.history.push({
command?.undo(); command,
if (command) { previousSelection,
this.redoStack.push(command); selectionOverride,
} });
} this.redoStack = [];
return command;
redo(): void { }
if (this.redoStack.length === 0) return;
const command = this.redoStack.pop(); push({ command }: { command: Command }): void {
command?.redo(); this.history.push({
if (command) { command,
this.history.push(command); previousSelection: this.getSelectionSnapshot(),
} });
} this.redoStack = [];
}
canUndo(): boolean {
return this.history.length > 0; undo(): void {
} if (this.history.length === 0) return;
const entry = this.history.pop();
canRedo(): boolean { entry?.command.undo();
return this.redoStack.length > 0; if (entry) {
} // Only restore selection for commands that explicitly changed it.
// Commands without selection intent leave selection untouched,
clear(): void { // preserving any UI-driven selection changes (clicks, box select)
this.history = []; // that happened between commands. Commands that remove elements
this.redoStack = []; // 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;
}
}

View File

@ -265,7 +265,7 @@ export class TimelineManager {
time: number; time: number;
clipboardItems: ClipboardItem[]; clipboardItems: ClipboardItem[];
}): { trackId: string; elementId: string }[] { }): { trackId: string; elementId: string }[] {
const command = new PasteCommand(time, clipboardItems); const command = new PasteCommand({ time, clipboardItems });
this.editor.command.execute({ command }); this.editor.command.execute({ command });
return command.getPastedElements(); return command.getPastedElements();
} }

View File

@ -261,7 +261,6 @@ export function useEditorActions() {
elements: selectedElements, elements: selectedElements,
rippleEnabled: rippleEditingEnabled, rippleEnabled: rippleEditingEnabled,
}); });
editor.selection.clearSelection();
}, },
undefined, undefined,
); );

View File

@ -1,11 +1,17 @@
import type { ElementRef } from "@/lib/timeline/types";
export interface CommandResult {
select?: ElementRef[];
}
export abstract class Command { export abstract class Command {
abstract execute(): void; abstract execute(): CommandResult | undefined;
undo(): void { undo(): void {
throw new Error("Undo not implemented for this command"); throw new Error("Undo not implemented for this command");
} }
redo(): void { redo(): CommandResult | undefined {
this.execute(); return this.execute();
} }
} }

View File

@ -1,14 +1,21 @@
import { Command } from "./base-command"; import { Command, type CommandResult } from "./base-command";
export class BatchCommand extends Command { export class BatchCommand extends Command {
constructor(private commands: Command[]) { constructor(private commands: Command[]) {
super(); super();
} }
execute(): void { execute(): CommandResult | undefined {
let latestSelectionResult: CommandResult | undefined;
for (const command of this.commands) { for (const command of this.commands) {
command.execute(); const result = command.execute();
if (result?.select !== undefined) {
latestSelectionResult = result;
}
} }
return latestSelectionResult;
} }
undo(): void { 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) { for (const command of this.commands) {
command.execute(); const result = command.redo();
if (result?.select !== undefined) {
latestSelectionResult = result;
}
} }
return latestSelectionResult;
} }
} }

View File

@ -1,4 +1,5 @@
export { Command } from "./base-command"; export { Command } from "./base-command";
export type { CommandResult } from "./base-command";
export { BatchCommand } from "./batch-command"; export { BatchCommand } from "./batch-command";
export * from "./timeline"; export * from "./timeline";

View File

@ -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 { EditorCore } from "@/core";
import type { import type {
TimelineTrack, TimelineTrack,
@ -17,21 +17,26 @@ import { cloneAnimations } from "@/lib/animation";
export class PasteCommand extends Command { export class PasteCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: TimelineTrack[] | null = null;
private pastedElements: { trackId: string; elementId: string }[] = []; private pastedElements: { trackId: string; elementId: string }[] = [];
private previousSelection: { trackId: string; elementId: string }[] = []; private readonly time: number;
private readonly clipboardItems: ClipboardItem[];
constructor( constructor({
private time: number, time,
private clipboardItems: ClipboardItem[], clipboardItems,
) { }: {
time: number;
clipboardItems: ClipboardItem[];
}) {
super(); super();
this.time = time;
this.clipboardItems = clipboardItems;
} }
execute(): void { execute(): CommandResult | undefined {
if (this.clipboardItems.length === 0) return; if (this.clipboardItems.length === 0) return;
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks(); this.savedState = editor.timeline.getTracks();
this.previousSelection = editor.selection.getSelectedElements();
this.pastedElements = []; this.pastedElements = [];
const minStart = Math.min( const minStart = Math.min(
@ -116,7 +121,7 @@ export class PasteCommand extends Command {
editor.timeline.updateTracks(updatedTracks); editor.timeline.updateTracks(updatedTracks);
if (this.pastedElements.length > 0) { 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) { if (this.savedState) {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState); editor.timeline.updateTracks(this.savedState);
editor.selection.setSelectedElements({
elements: this.previousSelection,
});
} }
} }

View File

@ -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 type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { rippleShiftElements } from "@/lib/timeline"; import { rippleShiftElements } from "@/lib/timeline";
@ -21,7 +21,7 @@ export class DeleteElementsCommand extends Command {
this.rippleEnabled = rippleEnabled; this.rippleEnabled = rippleEnabled;
} }
execute(): void { execute(): CommandResult {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks(); this.savedState = editor.timeline.getTracks();
@ -69,6 +69,10 @@ export class DeleteElementsCommand extends Command {
.filter((track) => track.elements.length > 0 || isMainTrack(track)); .filter((track) => track.elements.length > 0 || isMainTrack(track));
editor.timeline.updateTracks(updatedTracks); editor.timeline.updateTracks(updatedTracks);
return {
select: [],
};
} }
undo(): void { undo(): void {

View File

@ -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 type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import { generateUUID } from "@/utils/id"; import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
@ -12,7 +12,6 @@ interface DuplicateElementsParams {
export class DuplicateElementsCommand extends Command { export class DuplicateElementsCommand extends Command {
private duplicatedElements: { trackId: string; elementId: string }[] = []; private duplicatedElements: { trackId: string; elementId: string }[] = [];
private savedState: TimelineTrack[] | null = null; private savedState: TimelineTrack[] | null = null;
private previousSelection: { trackId: string; elementId: string }[] = [];
private elements: DuplicateElementsParams["elements"]; private elements: DuplicateElementsParams["elements"];
constructor({ elements }: DuplicateElementsParams) { constructor({ elements }: DuplicateElementsParams) {
@ -20,10 +19,9 @@ export class DuplicateElementsCommand extends Command {
this.elements = elements; this.elements = elements;
} }
execute(): void { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks(); this.savedState = editor.timeline.getTracks();
this.previousSelection = editor.selection.getSelectedElements();
this.duplicatedElements = []; this.duplicatedElements = [];
let updatedTracks = [...this.savedState]; let updatedTracks = [...this.savedState];
@ -89,9 +87,9 @@ export class DuplicateElementsCommand extends Command {
editor.timeline.updateTracks(updatedTracks); editor.timeline.updateTracks(updatedTracks);
if (this.duplicatedElements.length > 0) { if (this.duplicatedElements.length > 0) {
editor.selection.setSelectedElements({ return {
elements: this.duplicatedElements, select: this.duplicatedElements,
}); };
} }
} }
@ -99,9 +97,6 @@ export class DuplicateElementsCommand extends Command {
if (this.savedState) { if (this.savedState) {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState); editor.timeline.updateTracks(this.savedState);
editor.selection.setSelectedElements({
elements: this.previousSelection,
});
} }
} }

View File

@ -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 type { TimelineTrack } from "@/lib/timeline";
import { generateUUID } from "@/utils/id"; import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
@ -9,7 +9,6 @@ import { getSourceSpanAtClipTime } from "@/lib/retime";
export class SplitElementsCommand extends Command { export class SplitElementsCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: TimelineTrack[] | null = null;
private rightSideElements: { trackId: string; elementId: string }[] = []; private rightSideElements: { trackId: string; elementId: string }[] = [];
private previousSelection: { trackId: string; elementId: string }[] = [];
private readonly elements: { trackId: string; elementId: string }[]; private readonly elements: { trackId: string; elementId: string }[];
private readonly splitTime: number; private readonly splitTime: number;
private readonly retainSide: "both" | "left" | "right"; private readonly retainSide: "both" | "left" | "right";
@ -37,10 +36,9 @@ export class SplitElementsCommand extends Command {
return this.rightSideElements; return this.rightSideElements;
} }
execute(): void { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks(); this.savedState = editor.timeline.getTracks();
this.previousSelection = editor.selection.getSelectedElements();
this.rightSideElements = []; this.rightSideElements = [];
const updatedTracks = this.savedState.map((track) => { const updatedTracks = this.savedState.map((track) => {
@ -173,9 +171,9 @@ export class SplitElementsCommand extends Command {
editor.timeline.updateTracks(updatedTracks); editor.timeline.updateTracks(updatedTracks);
if (this.rightSideElements.length > 0) { if (this.rightSideElements.length > 0) {
editor.selection.setSelectedElements({ return {
elements: this.rightSideElements, select: this.rightSideElements,
}); };
} }
} }
@ -183,9 +181,6 @@ export class SplitElementsCommand extends Command {
if (this.savedState) { if (this.savedState) {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState); editor.timeline.updateTracks(this.savedState);
editor.selection.setSelectedElements({
elements: this.previousSelection,
});
} }
} }
} }