diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/actions/setObjectColor.ts b/frontend/apps/artcraft/app/src/pages/PageScene/actions/setObjectColor.ts
index 7ab53ec968..76263612b0 100644
--- a/frontend/apps/artcraft/app/src/pages/PageScene/actions/setObjectColor.ts
+++ b/frontend/apps/artcraft/app/src/pages/PageScene/actions/setObjectColor.ts
@@ -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 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));
+ },
+ };
+}
diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/comps/ControlPanelSceneObject/ControlPanelSceneObject.tsx b/frontend/apps/artcraft/app/src/pages/PageScene/comps/ControlPanelSceneObject/ControlPanelSceneObject.tsx
index 9e7f0759dd..cab0d6c34b 100644
--- a/frontend/apps/artcraft/app/src/pages/PageScene/comps/ControlPanelSceneObject/ControlPanelSceneObject.tsx
+++ b/frontend/apps/artcraft/app/src/pages/PageScene/comps/ControlPanelSceneObject/ControlPanelSceneObject.tsx
@@ -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(null);
+ const lastUuidRef = useRef(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(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 = () => {
) => {
- // 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) => {
+ // 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;
+ }}
/>