(null);
+ // Tracks the last value actually sent to onCommit — separate from `value`
+ // (the committed prop) because in a single pointerdown+pointerup click the
+ // leading-edge commit fires before the parent has re-rendered with the new
+ // prop, so the release flush must dedupe against what we just sent, not
+ // against the stale prop, or the same value commits twice.
+ const lastCommittedRef = useRef(value);
- const commitFromClientX = (clientX: number, rect: DOMRect) => {
+ useEffect(() => {
+ setDraft(value);
+ lastCommittedRef.current = value;
+ }, [value]);
+ useEffect(
+ () => () => {
+ if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
+ },
+ [],
+ );
+
+ const clampedPct = Math.max(0, Math.min(100, ((draft - min) / Math.max(max - min, 1e-6)) * 100));
+
+ const stepFromClientX = (clientX: number, rect: DOMRect) => {
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / Math.max(rect.width, 1)));
const raw = min + ratio * (max - min);
const stepped = Math.round(raw / step) * step;
- onCommit(Math.max(min, Math.min(max, stepped)));
+ return Math.max(min, Math.min(max, stepped));
+ };
+ const commitDraft = (nextDraft: number) => {
+ if (commitTimerRef.current) {
+ clearTimeout(commitTimerRef.current);
+ commitTimerRef.current = null;
+ }
+ pendingRef.current = null;
+ lastCommitAtRef.current = Date.now();
+ if (nextDraft !== lastCommittedRef.current) {
+ lastCommittedRef.current = nextDraft;
+ onCommit(nextDraft);
+ }
+ };
+ const scheduleCommit = (nextDraft: number) => {
+ const elapsed = Date.now() - lastCommitAtRef.current;
+ if (elapsed >= 40) {
+ commitDraft(nextDraft);
+ return;
+ }
+ pendingRef.current = nextDraft;
+ if (!commitTimerRef.current) {
+ commitTimerRef.current = setTimeout(() => {
+ commitTimerRef.current = null;
+ if (pendingRef.current !== null) commitDraft(pendingRef.current);
+ }, 40 - elapsed);
+ }
};
return (
@@ -257,22 +312,33 @@ export function FlatSlider({
data-flat-slider-track="true"
role="slider"
aria-label={label}
- aria-valuenow={value}
+ aria-valuenow={draft}
aria-disabled={disabled}
className={`relative h-5 flex-1 ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`}
onPointerDown={(e) => {
if (disabled) return;
e.currentTarget.setPointerCapture(e.pointerId);
- commitFromClientX(e.clientX, e.currentTarget.getBoundingClientRect());
+ const stepped = stepFromClientX(e.clientX, e.currentTarget.getBoundingClientRect());
+ setDraft(stepped);
+ scheduleCommit(stepped);
}}
onPointerMove={(e) => {
if (disabled || !e.currentTarget.hasPointerCapture(e.pointerId)) return;
- commitFromClientX(e.clientX, e.currentTarget.getBoundingClientRect());
+ const stepped = stepFromClientX(e.clientX, e.currentTarget.getBoundingClientRect());
+ setDraft(stepped);
+ scheduleCommit(stepped);
}}
onPointerUp={(e) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
+ // Recompute from the event itself rather than reading the `draft`
+ // closure — if pointerdown+pointerup land in the same React batch
+ // (e.g. a very fast click), the onPointerUp handler can still be
+ // bound to the pre-drag render, making `draft` stale.
+ const stepped = stepFromClientX(e.clientX, e.currentTarget.getBoundingClientRect());
+ setDraft(stepped);
+ commitDraft(stepped);
}}
>
diff --git a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx
index e5c27540fb..018f17ca50 100644
--- a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx
@@ -372,6 +372,7 @@ describe("FlatStyleSection — blur sliders", () => {
});
act(() => {
track.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 }));
+ track.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 50 }));
});
// filterBlurValue=4 -> max=Math.max(40, 4)=40; clientX=50 of width 100 -> ratio 0.5 -> 20px.
expect(onSetStyle).toHaveBeenCalledWith("filter", "blur(20px)");
@@ -390,6 +391,7 @@ describe("FlatStyleSection — blur sliders", () => {
});
act(() => {
backdropTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 }));
+ backdropTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 50 }));
});
// backdropBlurValue=6 -> max=Math.max(60, 6)=60; clientX=50 of width 100 -> ratio 0.5 -> 30px.
expect(onSetStyle).toHaveBeenCalledWith("backdrop-filter", "blur(30px)");
@@ -551,6 +553,7 @@ describe("FlatStyleSection — Overflow and Mask", () => {
});
act(() => {
maskInsetTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 }));
+ maskInsetTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 50 }));
});
// clipInsetValue=8 -> max=Math.max(120, 8)=120; clientX=50 of width 100 -> ratio 0.5 -> 60px.
// border-radius is unset here, so the clip-path's own `round 4px` is not reused — radiusValue
@@ -587,6 +590,7 @@ describe("FlatStyleSection — Opacity", () => {
});
act(() => {
opacityTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 }));
+ opacityTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 50 }));
});
expect(onSetStyle).toHaveBeenCalledWith("opacity", "0.5");
act(() => root.unmount());