summaryrefslogtreecommitdiff
path: root/apps/web/src/core/managers
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-15 15:45:50 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-15 15:45:50 +0200
commitc46d28891fee62ca2d3132da876af6411a41d9da (patch)
treef856f71d04c69628f26b904088d675dd298a5080 /apps/web/src/core/managers
parent66e4367bc4727f1d0bcf8f869529c7518812277e (diff)
downloadopencut-c46d28891fee62ca2d3132da876af6411a41d9da.tar.gz
feat: text / custom masks, fix preview pan, refactor selection
Diffstat (limited to 'apps/web/src/core/managers')
-rw-r--r--apps/web/src/core/managers/commands.ts34
-rw-r--r--apps/web/src/core/managers/selection-manager.ts268
-rw-r--r--apps/web/src/core/managers/timeline-manager.ts52
3 files changed, 264 insertions, 90 deletions
diff --git a/apps/web/src/core/managers/commands.ts b/apps/web/src/core/managers/commands.ts
index 2f652a85..7cdd6373 100644
--- a/apps/web/src/core/managers/commands.ts
+++ b/apps/web/src/core/managers/commands.ts
@@ -1,12 +1,13 @@
import type { EditorCore } from "@/core";
import type { Command, CommandResult } from "@/lib/commands";
+import type { EditorSelectionSnapshot } from "@/lib/selection/editor-selection";
import { applyRippleAdjustments, computeRippleAdjustments } from "@/lib/ripple";
-import type { ElementRef, SceneTracks } from "@/lib/timeline/types";
+import type { SceneTracks } from "@/lib/timeline/types";
interface CommandHistoryEntry {
command: Command;
- previousSelection: ElementRef[];
- selectionOverride?: ElementRef[];
+ previousSelection: EditorSelectionSnapshot;
+ selectionOverride?: EditorSelectionSnapshot;
}
export class CommandManager {
@@ -19,7 +20,7 @@ export class CommandManager {
execute({ command }: { command: Command }): Command {
const beforeTracks = this.isRippleEnabled
- ? this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null
+ ? (this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null)
: null;
const previousSelection = this.getSelectionSnapshot();
const result = command.execute();
@@ -55,11 +56,11 @@ export class CommandManager {
// 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.
+ // that happened between commands. Commands that remove editor-owned
+ // selection targets must declare a selection override to clear stale refs.
if (entry.selectionOverride !== undefined) {
- this.editor.selection.setSelectedElements({
- elements: [...entry.previousSelection],
+ this.editor.selection.restoreSnapshot({
+ snapshot: entry.previousSelection,
});
}
this.redoStack.push(entry);
@@ -74,7 +75,7 @@ export class CommandManager {
}
const beforeTracks = this.isRippleEnabled
- ? this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null
+ ? (this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null)
: null;
const previousSelection = this.getSelectionSnapshot();
const result = entry.command.redo();
@@ -102,20 +103,19 @@ export class CommandManager {
this.redoStack = [];
}
- private getSelectionSnapshot(): ElementRef[] {
- return [...this.editor.selection.getSelectedElements()];
+ private getSelectionSnapshot(): EditorSelectionSnapshot {
+ return this.editor.selection.getSnapshot();
}
private applySelectionOverride(
result: CommandResult | undefined,
- ): ElementRef[] | undefined {
- if (result?.select === undefined) {
+ ): EditorSelectionSnapshot | undefined {
+ if (!result?.selection) {
return undefined;
}
-
- const selectionOverride = [...result.select];
- this.editor.selection.setSelectedElements({ elements: selectionOverride });
- return selectionOverride;
+ return this.editor.selection.applySelectionPatch({
+ patch: result.selection,
+ });
}
private runReactors(): void {
diff --git a/apps/web/src/core/managers/selection-manager.ts b/apps/web/src/core/managers/selection-manager.ts
index 9bafc6d7..5da4fa60 100644
--- a/apps/web/src/core/managers/selection-manager.ts
+++ b/apps/web/src/core/managers/selection-manager.ts
@@ -1,73 +1,195 @@
-import type { EditorCore } from "@/core";
-import type { SelectedKeyframeRef } from "@/lib/animation/types";
-import type { ElementRef } from "@/lib/timeline/types";
-
-export class SelectionManager {
- private selectedElements: ElementRef[] = [];
- private selectedKeyframes: SelectedKeyframeRef[] = [];
- private keyframeSelectionAnchor: SelectedKeyframeRef | null = null;
- private listeners = new Set<() => void>();
-
- constructor(editor: EditorCore) {
- void editor;
- }
-
- getSelectedElements(): ElementRef[] {
- return this.selectedElements;
- }
-
- getSelectedKeyframes(): SelectedKeyframeRef[] {
- return this.selectedKeyframes;
- }
-
- getKeyframeSelectionAnchor(): SelectedKeyframeRef | null {
- return this.keyframeSelectionAnchor;
- }
-
- setSelectedElements({ elements }: { elements: ElementRef[] }): void {
- this.selectedElements = elements;
- this.selectedKeyframes = [];
- this.keyframeSelectionAnchor = null;
- this.notify();
- }
-
- setSelectedKeyframes({
- keyframes,
- anchorKeyframe,
- }: {
- keyframes: SelectedKeyframeRef[];
- anchorKeyframe?: SelectedKeyframeRef | null;
- }): void {
- this.selectedKeyframes = keyframes;
- if (anchorKeyframe !== undefined) {
- this.keyframeSelectionAnchor = anchorKeyframe;
- } else if (keyframes.length === 0) {
- this.keyframeSelectionAnchor = null;
- }
- this.notify();
- }
-
- clearSelection(): void {
- this.selectedElements = [];
- this.selectedKeyframes = [];
- this.keyframeSelectionAnchor = null;
- this.notify();
- }
-
- clearKeyframeSelection(): void {
- this.selectedKeyframes = [];
- this.keyframeSelectionAnchor = null;
- this.notify();
- }
-
- subscribe(listener: () => void): () => void {
- this.listeners.add(listener);
- return () => this.listeners.delete(listener);
- }
-
- private notify(): void {
- this.listeners.forEach((fn) => {
- fn();
- });
- }
-}
+import type { EditorCore } from "@/core";
+import type { SelectedKeyframeRef } from "@/lib/animation/types";
+import type {
+ EditorSelectionKind,
+ EditorSelectionPatch,
+ EditorSelectionSnapshot,
+ SelectedMaskPointSelection,
+} from "@/lib/selection/editor-selection";
+import type { ElementRef } from "@/lib/timeline/types";
+
+export class SelectionManager {
+ private selectedElements: ElementRef[] = [];
+ private selectedKeyframes: SelectedKeyframeRef[] = [];
+ private keyframeSelectionAnchor: SelectedKeyframeRef | null = null;
+ private selectedMaskPoints: SelectedMaskPointSelection | null = null;
+ private listeners = new Set<() => void>();
+
+ constructor(editor: EditorCore) {
+ void editor;
+ }
+
+ getSelectedElements(): ElementRef[] {
+ return this.selectedElements;
+ }
+
+ getSelectedKeyframes(): SelectedKeyframeRef[] {
+ return this.selectedKeyframes;
+ }
+
+ getKeyframeSelectionAnchor(): SelectedKeyframeRef | null {
+ return this.keyframeSelectionAnchor;
+ }
+
+ getSelectedMaskPointSelection(): SelectedMaskPointSelection | null {
+ return this.selectedMaskPoints;
+ }
+
+ getActiveSelectionKind(): EditorSelectionKind | null {
+ if ((this.selectedMaskPoints?.pointIds.length ?? 0) > 0) {
+ return "mask-points";
+ }
+ if (this.selectedKeyframes.length > 0) {
+ return "keyframes";
+ }
+ if (this.selectedElements.length > 0) {
+ return "elements";
+ }
+ return null;
+ }
+
+ getSnapshot(): EditorSelectionSnapshot {
+ return {
+ selectedElements: [...this.selectedElements],
+ selectedKeyframes: [...this.selectedKeyframes],
+ keyframeSelectionAnchor: this.keyframeSelectionAnchor,
+ selectedMaskPoints: this.selectedMaskPoints
+ ? {
+ ...this.selectedMaskPoints,
+ pointIds: [...this.selectedMaskPoints.pointIds],
+ }
+ : null,
+ };
+ }
+
+ setSelectedElements({ elements }: { elements: ElementRef[] }): void {
+ this.selectedElements = elements;
+ this.selectedKeyframes = [];
+ this.keyframeSelectionAnchor = null;
+ this.selectedMaskPoints = null;
+ this.notify();
+ }
+
+ setSelectedKeyframes({
+ keyframes,
+ anchorKeyframe,
+ }: {
+ keyframes: SelectedKeyframeRef[];
+ anchorKeyframe?: SelectedKeyframeRef | null;
+ }): void {
+ this.selectedKeyframes = keyframes;
+ if (anchorKeyframe !== undefined) {
+ this.keyframeSelectionAnchor = anchorKeyframe;
+ } else if (keyframes.length === 0) {
+ this.keyframeSelectionAnchor = null;
+ }
+ this.selectedMaskPoints = null;
+ this.notify();
+ }
+
+ setSelectedMaskPoints({
+ selection,
+ }: {
+ selection: SelectedMaskPointSelection | null;
+ }): void {
+ this.selectedMaskPoints =
+ selection && selection.pointIds.length > 0
+ ? {
+ ...selection,
+ pointIds: [...selection.pointIds],
+ }
+ : null;
+ this.selectedKeyframes = [];
+ this.keyframeSelectionAnchor = null;
+ this.notify();
+ }
+
+ clearSelection(): void {
+ this.selectedElements = [];
+ this.selectedKeyframes = [];
+ this.keyframeSelectionAnchor = null;
+ this.selectedMaskPoints = null;
+ this.notify();
+ }
+
+ clearKeyframeSelection(): void {
+ this.selectedKeyframes = [];
+ this.keyframeSelectionAnchor = null;
+ this.notify();
+ }
+
+ clearMaskPointSelection(): void {
+ if (!this.selectedMaskPoints) {
+ return;
+ }
+ this.selectedMaskPoints = null;
+ this.notify();
+ }
+
+ clearMostSpecificSelection(): boolean {
+ const activeSelectionKind = this.getActiveSelectionKind();
+ if (activeSelectionKind === "mask-points") {
+ this.clearMaskPointSelection();
+ return true;
+ }
+ if (activeSelectionKind === "keyframes") {
+ this.clearKeyframeSelection();
+ return true;
+ }
+ if (activeSelectionKind === "elements") {
+ this.setSelectedElements({ elements: [] });
+ return true;
+ }
+ return false;
+ }
+
+ applySelectionPatch({
+ patch,
+ }: {
+ patch: EditorSelectionPatch;
+ }): EditorSelectionSnapshot {
+ if (patch.selectedElements !== undefined) {
+ this.selectedElements = [...patch.selectedElements];
+ }
+ if (patch.selectedKeyframes !== undefined) {
+ this.selectedKeyframes = [...patch.selectedKeyframes];
+ }
+ if (patch.keyframeSelectionAnchor !== undefined) {
+ this.keyframeSelectionAnchor = patch.keyframeSelectionAnchor;
+ }
+ if (patch.selectedMaskPoints !== undefined) {
+ this.selectedMaskPoints = patch.selectedMaskPoints
+ ? {
+ ...patch.selectedMaskPoints,
+ pointIds: [...patch.selectedMaskPoints.pointIds],
+ }
+ : null;
+ }
+ this.notify();
+ return this.getSnapshot();
+ }
+
+ restoreSnapshot({ snapshot }: { snapshot: EditorSelectionSnapshot }): void {
+ this.selectedElements = [...snapshot.selectedElements];
+ this.selectedKeyframes = [...snapshot.selectedKeyframes];
+ this.keyframeSelectionAnchor = snapshot.keyframeSelectionAnchor;
+ this.selectedMaskPoints = snapshot.selectedMaskPoints
+ ? {
+ ...snapshot.selectedMaskPoints,
+ pointIds: [...snapshot.selectedMaskPoints.pointIds],
+ }
+ : null;
+ this.notify();
+ }
+
+ subscribe(listener: () => void): () => void {
+ this.listeners.add(listener);
+ return () => this.listeners.delete(listener);
+ }
+
+ private notify(): void {
+ this.listeners.forEach((fn) => {
+ fn();
+ });
+ }
+}
diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts
index c1398e8b..898a7e89 100644
--- a/apps/web/src/core/managers/timeline-manager.ts
+++ b/apps/web/src/core/managers/timeline-manager.ts
@@ -1,4 +1,5 @@
import type { EditorCore } from "@/core";
+import type { ElementBounds } from "@/lib/preview/element-bounds";
import type { ParamValues } from "@/lib/params";
import type {
SceneTracks,
@@ -44,6 +45,8 @@ import {
RetimeKeyframeCommand,
UpdateScalarKeyframeCurveCommand,
AddClipEffectCommand,
+ DeleteCustomMaskPointsCommand,
+ InsertCustomMaskPointCommand,
RemoveClipEffectCommand,
UpdateClipEffectParamsCommand,
ToggleClipEffectCommand,
@@ -344,6 +347,55 @@ export class TimelineManager {
this.editor.command.execute({ command });
}
+ deleteCustomMaskPoints({
+ trackId,
+ elementId,
+ maskId,
+ pointIds,
+ }: {
+ trackId: string;
+ elementId: string;
+ maskId: string;
+ pointIds: string[];
+ }): void {
+ if (pointIds.length === 0) {
+ return;
+ }
+ const command = new DeleteCustomMaskPointsCommand({
+ trackId,
+ elementId,
+ maskId,
+ pointIds,
+ });
+ this.editor.command.execute({ command });
+ }
+
+ insertCustomMaskPoint({
+ trackId,
+ elementId,
+ maskId,
+ segmentIndex,
+ canvasPoint,
+ bounds,
+ }: {
+ trackId: string;
+ elementId: string;
+ maskId: string;
+ segmentIndex: number;
+ canvasPoint: { x: number; y: number };
+ bounds: ElementBounds;
+ }): void {
+ const command = new InsertCustomMaskPointCommand({
+ trackId,
+ elementId,
+ maskId,
+ segmentIndex,
+ canvasPoint,
+ bounds,
+ });
+ this.editor.command.execute({ command });
+ }
+
updateClipEffectParams({
trackId,
elementId,