mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-29 21:12:52 -04:00
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:
@@ -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));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+62
-20
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user