PageScene: add concrete UndoableAction classes

Six self-contained classes under engine/editor/actions/, one per
action kind. Each implements UndoableAction, captures its own state
in the constructor, and runs apply/revert against subsystem refs
held privately. Adding a new kind = a new file here, no edits
elsewhere.

- CreateAction: snapshots an obj, recreates on apply (redo),
  removes via utils.deleteObject on revert (undo). Uses the
  non-recording delete path so undo of create can't recurse.
- DeleteAction: mirror — removes on apply, recreates on revert.
- TransformAction: encapsulates begin/end transaction state. Caller
  constructs at dragstart (captures before), calls commit() at
  dragend to capture after + check for a no-op move. records only
  if commit() returns true.
- ColorAction: simple before/after pair, routes through
  activeScene.setColor.
- LockAction: routes through SelectionBridge.setLockState so the
  gizmo attach/detach side effects fire on undo.
- VisibilityAction: writes obj.visible + userData.visible directly.

No call sites use these yet — that's the next commit. HistoryManager
is unchanged; it already accepts UndoableAction via record().
This commit is contained in:
Tanish Manku committed 2026-05-01 02:57:28 -07:00
1 parent a4c3be4c99
commit f4005603f6
6 files changed
+216

No files matched your search

@@ -0,0 +1,24 @@
import type Editor from "../../editor";
import type { UndoableAction } from "../HistoryManager";
// Records a color change. apply() and revert() both call
// activeScene.setColor — the engine handles material magic. The action
// captures the uuid + before/after hex pair at construction time.
export class ColorAction implements UndoableAction {
readonly label = "Color";
constructor(
private readonly editor: Editor,
private readonly uuid: string,
private readonly before: string,
private readonly after: string,
) {}
apply(): void {
this.editor.activeScene.setColor(this.uuid, this.after);
}
revert(): void {
this.editor.activeScene.setColor(this.uuid, this.before);
}
}
@@ -0,0 +1,36 @@
import * as THREE from "three";
import type Editor from "../../editor";
import type { UndoableAction } from "../HistoryManager";
import {
ObjectSnap,
recreateFromSnapshot,
snapshotObject,
} from "./snapshots";
// Records the creation of an object. apply() re-creates from the
// captured snapshot (used on redo). revert() removes the object using
// the lower-level utils.deleteObject path so it does NOT recurse
// through editor.deleteObject (which would try to record a fresh
// delete entry).
export class CreateAction implements UndoableAction {
readonly label: string;
private readonly snap: ObjectSnap;
constructor(
private readonly editor: Editor,
obj: THREE.Object3D,
) {
this.snap = snapshotObject(obj);
this.label = `Create ${this.snap.name}`;
}
async apply(): Promise<void> {
await recreateFromSnapshot(this.editor, this.snap);
this.editor.selection.refreshOutliner();
}
async revert(): Promise<void> {
this.editor.utils.deleteObject(this.snap.uuid);
this.editor.selection.refreshOutliner();
}
}
@@ -0,0 +1,35 @@
import * as THREE from "three";
import type Editor from "../../editor";
import type { UndoableAction } from "../HistoryManager";
import {
ObjectSnap,
recreateFromSnapshot,
snapshotObject,
} from "./snapshots";
// Records the deletion of an object. Mirror of CreateAction with
// apply/revert swapped. apply() removes via utils.deleteObject (the
// non-recording path) so a redo of a delete during replay can't
// re-trigger recordDelete.
export class DeleteAction implements UndoableAction {
readonly label: string;
private readonly snap: ObjectSnap;
constructor(
private readonly editor: Editor,
obj: THREE.Object3D,
) {
this.snap = snapshotObject(obj);
this.label = `Delete ${this.snap.name}`;
}
async apply(): Promise<void> {
this.editor.utils.deleteObject(this.snap.uuid);
this.editor.selection.refreshOutliner();
}
async revert(): Promise<void> {
await recreateFromSnapshot(this.editor, this.snap);
this.editor.selection.refreshOutliner();
}
}
@@ -0,0 +1,27 @@
import type { SelectionBridge } from "../SelectionBridge";
import type { UndoableAction } from "../HistoryManager";
// Records a lock-toggle. Routes both apply() and revert() through
// SelectionBridge.setLockState so the gizmo attach/detach side effects
// fire — the same path the user-toggle uses, just at a target state
// instead of a flip.
export class LockAction implements UndoableAction {
readonly label: string;
constructor(
private readonly selection: SelectionBridge,
private readonly uuid: string,
private readonly before: boolean,
private readonly after: boolean,
) {
this.label = after ? "Lock" : "Unlock";
}
apply(): void {
this.selection.setLockState(this.uuid, this.after);
}
revert(): void {
this.selection.setLockState(this.uuid, this.before);
}
}
@@ -0,0 +1,58 @@
import type Editor from "../../editor";
import type { UndoableAction } from "../HistoryManager";
import {
TransformSnap,
snapshotTransform,
transformsEqual,
writeTransform,
} from "./snapshots";
// Records a transform change. Caller holds the instance between the
// drag/edit boundary callbacks (gizmo dragstart/dragend, panel input
// focus/blur) and calls commit() at the end. If the transform didn't
// actually change, commit() returns false and the caller drops the
// action without recording.
//
// Begin/end transaction state lives on the action itself — no need for
// a per-manager pendingTransform field.
export class TransformAction implements UndoableAction {
readonly label = "Transform";
private readonly before: TransformSnap;
private after?: TransformSnap;
constructor(
private readonly editor: Editor,
private readonly uuid: string,
) {
const obj = editor.activeScene.scene.getObjectByProperty("uuid", uuid);
if (!obj) {
this.before = {
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 },
};
return;
}
this.before = snapshotTransform(obj);
}
// Capture the after-state from the live object. Returns true if it
// differs from the before-state (worth recording).
commit(): boolean {
const obj = this.editor.activeScene.scene.getObjectByProperty(
"uuid",
this.uuid,
);
if (!obj) return false;
this.after = snapshotTransform(obj);
return !transformsEqual(this.before, this.after);
}
apply(): void {
if (this.after) writeTransform(this.editor, this.uuid, this.after);
}
revert(): void {
writeTransform(this.editor, this.uuid, this.before);
}
}
@@ -0,0 +1,36 @@
import type Editor from "../../editor";
import type { UndoableAction } from "../HistoryManager";
// Records a visibility toggle. Writes both obj.visible and
// userData.visible directly. The outliner refresh fires in
// HistoryManager's replay wrapper so the row icon stays in sync.
export class VisibilityAction implements UndoableAction {
readonly label: string;
constructor(
private readonly editor: Editor,
private readonly uuid: string,
private readonly before: boolean,
private readonly after: boolean,
) {
this.label = after ? "Show" : "Hide";
}
apply(): void {
this.write(this.after);
}
revert(): void {
this.write(this.before);
}
private write(visible: boolean): void {
const obj = this.editor.activeScene.scene.getObjectByProperty(
"uuid",
this.uuid,
);
if (!obj) return;
obj.visible = visible;
obj.userData["visible"] = visible;
}
}