PageScene: stop panel undo pollution from color + transform inputs

Two related session-boundary bugs in ControlPanelSceneObject — both
caused N undo entries per user gesture instead of one.

Bug A (color picker):
- Native <input type="color"> fires onChange on every slider pixel
  while the dialog is open, not just on commit. The previous one-shot
  setObjectColor recorded one ColorAction per fire, so a single
  white→red pick became dozens of micro-entries. Ctrl+Z reverted only
  the last micro-step → user reported "undo doesn't work."
- Fix: add ColorSession to actions/setObjectColor (apply/commit
  pair). Panel opens it on input.onFocus, applies on onChange (visual
  feedback only), commits on onBlur (one entry per pick).

Bug B (panel transform inputs — keystrokes AND scrub drags):
- The panel's [currentSceneObject] useEffect cleanup was committing
  the transform session on every frame the selected obj's transform
  changed. The chain: panel writes to engine -> next frame
  editor.ts:744 sees getSelectedSum changed -> updateSelectedUI()
  pushes a fresh objectPanel into Zustand -> currentSceneObject
  reference changes -> useEffect cleanup fires -> commitPanelTransform.
  Result: one entry per keystroke, dozens per scrub drag.
- Fix: track lastUuidRef. Commit the session only when the selection
  uuid actually changes (real boundary), not on every same-uuid
  re-push. Move the unmount-commit into a separate effect.

Both fixes use the same shape: a session ref opened on first edit
and committed at a properly-throttled boundary (focus/blur for color,
uuid-change/unmount for transform). Mirrors the engine-side
TransformAction begin/commit pattern.
This commit is contained in:
Tanish Manku
2026-05-01 04:40:02 -07:00
parent 3bfb405e8e
commit 72071c0066
2 changed files with 104 additions and 26 deletions
@@ -1,13 +1,14 @@
import type Editor from "../engine/editor";
import { ColorAction } from "../engine/editor/actions/ColorAction";
// Apply a color change to an object: writes the new hex through
// activeScene.setColor (which handles material/Chroma plumbing), and
// records a ColorAction on the undo stack with the before-state read
// from the live object so the helper is self-contained.
// One-shot apply: use when the change is atomic (e.g. a button click
// or programmatic setter). Records a single ColorAction.
//
// View callers only need to provide editor + uuid + new hex; the
// helper handles engine + history orchestration.
// For interactive flows where a single user gesture fires many
// intermediate values (the native <input type="color"> dialog spams
// onChange per slider pixel), use beginColorSession instead — it
// applies each intermediate value to the engine for visual feedback
// but records only ONE ColorAction on commit.
export function setObjectColor(
editor: Editor,
uuid: string,
@@ -20,3 +21,38 @@ export function setObjectColor(
editor.activeScene.setColor(uuid, color);
editor.history.record(new ColorAction(editor, uuid, before, color));
}
// Session-style color edit: snapshots the before-state at construction
// (focus / picker open), applies each intermediate value to the engine
// (visual feedback during slider drag), and records exactly one
// ColorAction on commit (focus loss / picker close). No-op on commit
// if the final color matches the before-state.
export type ColorSession = {
apply(color: string): void;
commit(): void;
};
export function beginColorSession(
editor: Editor,
uuid: string,
): ColorSession {
const startObj = editor.activeScene.scene.getObjectByProperty(
"uuid",
uuid,
);
const before = (startObj?.userData.color as string) ?? "#ffffff";
return {
apply(color: string) {
editor.activeScene.setColor(uuid, color);
},
commit() {
const endObj = editor.activeScene.scene.getObjectByProperty(
"uuid",
uuid,
);
const after = (endObj?.userData.color as string) ?? before;
if (before === after) return;
editor.history.record(new ColorAction(editor, uuid, before, after));
},
};
}
@@ -25,7 +25,10 @@ import { objectMismatch } from "~/pages/PageScene/comps/ControlPanelSceneObject/
import { XYZ } from "~/pages/PageScene/datastructures/common";
import { pageHeight } from "~/signals";
import { DraggablePrecisionMutator } from "./DraggablePrecisionMutator";
import { setObjectColor } from "~/pages/PageScene/actions/setObjectColor";
import {
beginColorSession,
ColorSession,
} from "~/pages/PageScene/actions/setObjectColor";
import {
beginTransformSession,
TransformSession,
@@ -70,10 +73,13 @@ export const ControlPanelSceneObject = () => {
const [color, setColor] = useState("#ffffff");
// Pending transform session — opened on first panel edit, committed
// on selection change / unmount. The action layer (transformObject)
// owns recording + Zustand sync; this view just opens and closes the
// session boundary.
// when the *selection's uuid* actually changes (NOT on every per-frame
// updateSelectedUI re-push that just creates a new currentSceneObject
// reference for the same uuid). lastUuidRef tracks the previous uuid
// so the commit effect can distinguish a real selection change from
// a same-uuid resync.
const transformSessionRef = useRef<TransformSession | null>(null);
const lastUuidRef = useRef<string | null>(null);
const beginPanelTransform = () => {
if (transformSessionRef.current) return;
@@ -89,6 +95,12 @@ export const ControlPanelSceneObject = () => {
transformSessionRef.current = null;
};
// Color picker session — opened on input focus (picker dialog opens),
// committed on input blur (picker dialog closes). Records exactly one
// ColorAction per pick regardless of how many onChange events the
// native picker fires while the user drags the slider.
const colorSessionRef = useRef<ColorSession | null>(null);
const colorInputId = useId();
const toggleCollapse = () => {
@@ -128,6 +140,20 @@ export const ControlPanelSceneObject = () => {
return;
}
// The per-frame `updateSelectedUI` in the engine pushes a fresh
// `currentSceneObject` object into the store every frame the
// selected obj's transform changes. That re-fires this effect with
// the *same* uuid but new vectors. Only an actual selection change
// (different uuid) is a real session boundary — commit the pending
// session for the previous uuid before we resync local state.
if (
lastUuidRef.current !== null &&
lastUuidRef.current !== currentSceneObject.object_uuid
) {
commitPanelTransform();
}
lastUuidRef.current = currentSceneObject.object_uuid;
const vectors = currentSceneObject.objectVectors;
// local state relies on strings
@@ -137,13 +163,15 @@ export const ControlPanelSceneObject = () => {
setLocked(editorEngine.selection.isObjectLocked(editorEngine?.selected?.uuid || ""));
setColor(editorEngine?.selected?.userData.color);
// On selection change / unmount, commit any in-flight panel
// transform from the previous selection. The action references
// its captured uuid, so commit still targets the right object.
return () => commitPanelTransform();
// No cleanup function — uuid-change commit lives in the body
// above. Unmount commit is handled by the separate effect below.
}, [currentSceneObject, editorEngine]);
// Final commit on panel unmount.
useEffect(() => {
return () => commitPanelTransform();
}, []);
if (!currentSceneObject) {
return null;
}
@@ -259,20 +287,34 @@ export const ControlPanelSceneObject = () => {
<input
className="h-0 w-0 cursor-pointer opacity-0"
id={colorInputId}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
// <input type="color"> fires onChange once per committed
// pick (when the picker closes), so the action helper
// gets exactly one record per user-visible color change.
const uuid = editorEngine?.selected?.uuid;
const after = e.target.value;
if (uuid && editorEngine) {
setObjectColor(editorEngine, uuid, after);
}
setColor(after);
}}
type="color"
value={color}
disabled={locked}
onFocus={() => {
// Picker is opening — open a session that captures the
// before-state once. apply()/commit() handle visual
// feedback during the drag and the final undo entry on
// close.
const uuid = editorEngine?.selected?.uuid;
if (uuid && editorEngine) {
colorSessionRef.current = beginColorSession(
editorEngine,
uuid,
);
}
}}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
// Native color picker fires per-pixel during slider
// drag. apply() updates the engine for visual feedback
// but does NOT record. Recording happens once on blur.
const after = e.target.value;
colorSessionRef.current?.apply(after);
setColor(after);
}}
onBlur={() => {
colorSessionRef.current?.commit();
colorSessionRef.current = null;
}}
/>
<Button
className="cursor-pointer p-3.5"