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; + }} />