diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx index 9346704b8..fabb286a3 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx @@ -473,6 +473,86 @@ describe("FlatSlider", () => { expect(track.getAttribute("aria-valuenow")).toBe("80"); act(() => root.unmount()); }); + + it("Escape during a drag reverts to the pre-drag value and releases pointer capture, instead of leaving the last dragged-to position committed", () => { + const onCommit = vi.fn(); + const { host, root } = renderInto( + , + ); + const track = host.querySelector('[data-flat-slider-track="true"]'); + if (!track) throw new Error("expected a track element"); + Object.defineProperty(track, "getBoundingClientRect", { + value: () => ({ left: 0, width: 100, top: 0, height: 20, right: 100, bottom: 20 }), + }); + act(() => { + track.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, clientX: 30, pointerId: 1 }), + ); + }); + // The leading-edge commit already applied the dragged-to value (30). + expect(onCommit).toHaveBeenLastCalledWith(30); + act(() => { + track.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }), + ); + }); + expect(onCommit).toHaveBeenLastCalledWith(10); + expect(track.getAttribute("aria-valuenow")).toBe("10"); + expect(track.hasPointerCapture(1)).toBe(false); + // A subsequent pointermove for the now-released pointer must not resume + // the cancelled drag. + onCommit.mockClear(); + act(() => { + track.dispatchEvent( + new PointerEvent("pointermove", { bubbles: true, clientX: 80, pointerId: 1 }), + ); + }); + expect(onCommit).not.toHaveBeenCalled(); + act(() => root.unmount()); + }); + + it("right-click (contextmenu) during a drag cancels it and reverts to the pre-drag value, instead of committing the last dragged-to position", () => { + const onCommit = vi.fn(); + const { host, root } = renderInto( + , + ); + const track = host.querySelector('[data-flat-slider-track="true"]'); + if (!track) throw new Error("expected a track element"); + Object.defineProperty(track, "getBoundingClientRect", { + value: () => ({ left: 0, width: 100, top: 0, height: 20, right: 100, bottom: 20 }), + }); + act(() => { + track.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, clientX: 65, pointerId: 1 }), + ); + }); + expect(onCommit).toHaveBeenLastCalledWith(65); + const contextMenuEvent = new MouseEvent("contextmenu", { bubbles: true, cancelable: true }); + act(() => { + track.dispatchEvent(contextMenuEvent); + }); + expect(contextMenuEvent.defaultPrevented).toBe(true); + expect(onCommit).toHaveBeenLastCalledWith(10); + expect(track.getAttribute("aria-valuenow")).toBe("10"); + expect(track.hasPointerCapture(1)).toBe(false); + act(() => root.unmount()); + }); }); describe("FlatSlider — Grade extensions", () => { diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx index 21572a51d..86205546c 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx @@ -318,6 +318,11 @@ export function FlatSlider({ // loss (another element steals it, or the browser reclaims it for a // scroll/touch gesture) where no other handler is about to run. const explicitReleaseRef = useRef(false); + // The committed value when the current drag began — Escape and right-click + // both cancel an in-progress drag by reverting to this, not by leaving + // whatever position the pointer last reached committed. + const dragStartValueRef = useRef(value); + const activePointerIdRef = useRef(null); useEffect(() => { if (draggingRef.current) return; @@ -372,6 +377,22 @@ export function FlatSlider({ }, 40 - elapsed); } }; + // Reverts to the pre-drag value instead of leaving whatever position the + // pointer last reached committed — the drag's own leading-edge commit (in + // onPointerDown) may already have applied an intermediate value, so this + // must go through commitDraft (not just a visual setDraft) to actually + // undo it. + const cancelDrag = (target: HTMLDivElement) => { + if (!draggingRef.current) return; + draggingRef.current = false; + const pointerId = activePointerIdRef.current; + if (pointerId !== null && target.hasPointerCapture(pointerId)) { + explicitReleaseRef.current = true; + target.releasePointerCapture(pointerId); + } + setDraft(dragStartValueRef.current); + commitDraft(dragStartValueRef.current); + }; return (
@@ -390,6 +411,8 @@ export function FlatSlider({ onPointerDown={(e) => { if (disabled) return; draggingRef.current = true; + dragStartValueRef.current = latestValueRef.current; + activePointerIdRef.current = e.pointerId; e.currentTarget.setPointerCapture(e.pointerId); const stepped = stepFromClientX(e.clientX, e.currentTarget.getBoundingClientRect()); setDraft(stepped); @@ -447,12 +470,25 @@ export function FlatSlider({ }} onKeyDown={(e) => { if (disabled) return; + if (e.key === "Escape" && draggingRef.current) { + e.preventDefault(); + cancelDrag(e.currentTarget); + return; + } const next = sliderKeyTarget(e.key, draft, min, max, step); if (next === null) return; e.preventDefault(); setDraft(next); commitDraft(next); }} + onContextMenu={(e) => { + // Right-click during a drag must cancel it (revert to the pre-drag + // value), not leave the last dragged-to position committed while + // the native context menu opens on top of the slider. + if (!draggingRef.current) return; + e.preventDefault(); + cancelDrag(e.currentTarget); + }} >
{centerTick && ( @@ -505,88 +541,4 @@ export function FlatSlider({ ); } -/* ------------------------------------------------------------------ */ -/* FlatSelectRow — label/value row backed by a native - * unnamed. Falls back to `label` when omitted. */ - ariaLabel?: string; - value: string; - options: Array; - tier: PropertyValueTier; - disabled?: boolean; - onChange: (nextValue: string) => void; - onReset?: () => void; -}) { - const normalizedOptions = options.map((option) => - typeof option === "string" ? { value: option, label: option } : option, - ); - // A valid authored value outside the preset list (e.g. a `mix-blend-mode` - // or `object-position` this row doesn't offer as a preset) must not be - // silently misrepresented as the first option — the native onChange(e.target.value)} - className={`appearance-none bg-transparent text-right font-mono text-[11px] outline-none disabled:cursor-not-allowed ${VALUE_TIER_VALUE_CLASS[tier]}`} - > - {renderedOptions.map((option) => ( - - ))} - - - - - - {tier === "explicitCustom" && onReset && ( - - )} - -
- ); -} +export { FlatSelectRow } from "./propertyPanelFlatSelectRow"; diff --git a/packages/studio/src/components/editor/propertyPanelFlatSelectRow.tsx b/packages/studio/src/components/editor/propertyPanelFlatSelectRow.tsx new file mode 100644 index 000000000..f3ef5cc6d --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatSelectRow.tsx @@ -0,0 +1,92 @@ +import { RotateCcw } from "../../icons/SystemIcons"; +import { + VALUE_TIER_LABEL_CLASS, + VALUE_TIER_VALUE_CLASS, + type PropertyValueTier, +} from "./propertyPanelValueTier"; + +/* ------------------------------------------------------------------ */ +/* FlatSelectRow — label/value row backed by a native + * unnamed. Falls back to `label` when omitted. */ + ariaLabel?: string; + value: string; + options: Array; + tier: PropertyValueTier; + disabled?: boolean; + onChange: (nextValue: string) => void; + onReset?: () => void; +}) { + const normalizedOptions = options.map((option) => + typeof option === "string" ? { value: option, label: option } : option, + ); + // A valid authored value outside the preset list (e.g. a `mix-blend-mode` + // or `object-position` this row doesn't offer as a preset) must not be + // silently misrepresented as the first option — the native onChange(e.target.value)} + className={`appearance-none bg-transparent text-right font-mono text-[11px] outline-none disabled:cursor-not-allowed ${VALUE_TIER_VALUE_CLASS[tier]}`} + > + {renderedOptions.map((option) => ( + + ))} + + + + + + {tier === "explicitCustom" && onReset && ( + + )} + +
+ ); +}