fix(studio): make inspector commits transactional (#2987)

* fix(studio): make inspector commits transactional

* fix(studio): make inspector persistence atomic

* fix(studio): preserve synchronous gesture semantics
This commit is contained in:
Miguel Ángel
2026-08-04 20:26:43 +00:00
committed by GitHub
parent cde5bae4c5
commit 552419c52d
14 changed files with 634 additions and 90 deletions
@@ -253,7 +253,7 @@ function Transform3dField({
onCommit={(next) => { onCommit={(next) => {
const v = parse(next); const v = parse(next);
if (v != null && onCommitAnimatedProperty) { if (v != null && onCommitAnimatedProperty) {
void onCommitAnimatedProperty(ctx.element, prop, v); return onCommitAnimatedProperty(ctx.element, prop, v);
} }
}} }}
/> />
@@ -21,7 +21,7 @@ export function CommitField({
liveCommit?: boolean; liveCommit?: boolean;
align?: "left" | "right"; align?: "left" | "right";
onPreview?: (nextValue: string) => void; onPreview?: (nextValue: string) => void;
onCommit: (nextValue: string) => void; onCommit: (nextValue: string) => void | Promise<void>;
}) { }) {
const [draft, setDraft] = useState(value); const [draft, setDraft] = useState(value);
const valueRef = useRef(value); const valueRef = useRef(value);
@@ -29,6 +29,19 @@ export function CommitField({
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const focusedRef = useRef(false); const focusedRef = useRef(false);
const dirtyRef = useRef(false); const dirtyRef = useRef(false);
const commitGenerationRef = useRef(0);
const pendingCommitRef = useRef<{
baseline: string;
optimistic: string;
} | null>(null);
const lastValueRef = useRef(value);
if (!Object.is(lastValueRef.current, value)) {
lastValueRef.current = value;
if (!Object.is(pendingCommitRef.current?.optimistic, value)) {
commitGenerationRef.current += 1;
pendingCommitRef.current = null;
}
}
valueRef.current = value; valueRef.current = value;
draftRef.current = draft; draftRef.current = draft;
@@ -67,14 +80,34 @@ export function CommitField({
}, 250); }, 250);
}; };
const cancelGesture = () => { const cancelGesture = () => {
commitGenerationRef.current += 1;
clearGestureSettleTimer(); clearGestureSettleTimer();
gestureActiveRef.current = false; gestureActiveRef.current = false;
gestureTransaction.cancel(); gestureTransaction.cancel();
}; };
const commitDraft = (nextValue: string) => { const commitDraft = (nextValue: string) => {
const generation = ++commitGenerationRef.current;
setDraft(nextValue); setDraft(nextValue);
onPreview?.(nextValue); onPreview?.(nextValue);
if (nextValue !== valueRef.current) onCommit(nextValue); if (nextValue !== valueRef.current) {
const baseline = valueRef.current;
pendingCommitRef.current = { baseline, optimistic: nextValue };
const rollback = () => {
if (generation !== commitGenerationRef.current) return;
pendingCommitRef.current = null;
// The source write is authoritative. A rejected mutation must not leave
// the field showing an optimistic value that will disappear on seek.
setDraft(baseline);
onPreview?.(baseline);
};
try {
void Promise.resolve(onCommit(nextValue)).then(() => {
if (generation === commitGenerationRef.current) pendingCommitRef.current = null;
}, rollback);
} catch {
rollback();
}
}
}; };
const cancelGestureFromKeyEvent = (event: React.KeyboardEvent<HTMLInputElement>) => { const cancelGestureFromKeyEvent = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (!gestureActiveRef.current) return false; if (!gestureActiveRef.current) return false;
@@ -89,6 +122,7 @@ export function CommitField({
const nextDraft = adjustNumericToken(draftRef.current, direction, event); const nextDraft = adjustNumericToken(draftRef.current, direction, event);
if (!nextDraft) return; if (!nextDraft) return;
event.preventDefault(); event.preventDefault();
commitGenerationRef.current += 1;
dirtyRef.current = false; dirtyRef.current = false;
gestureActiveRef.current = true; gestureActiveRef.current = true;
gestureTransaction.preview(nextDraft); gestureTransaction.preview(nextDraft);
@@ -148,6 +182,7 @@ export function CommitField({
focusedRef.current = true; focusedRef.current = true;
}} }}
onChange={(event) => { onChange={(event) => {
commitGenerationRef.current += 1;
settleGesture(); settleGesture();
dirtyRef.current = true; dirtyRef.current = true;
setDraft(event.target.value); setDraft(event.target.value);
@@ -90,6 +90,75 @@ describe("FlatRow", () => {
act(() => root.unmount()); act(() => root.unmount());
}); });
it("restores its durable value when an async commit rejects", async () => {
let rejectCommit: ((error: Error) => void) | null = null;
const onCommit = vi.fn(
() =>
new Promise<void>((_resolve, reject) => {
rejectCommit = reject;
}),
);
const row = (value: string) => (
<FlatRow label="X" value={value} tier="explicitDefault" onCommit={onCommit} />
);
const { host, root } = renderInto(row("22px"));
const input = host.querySelector<HTMLInputElement>("input");
if (!input) throw new Error("expected an input");
act(() => {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
nativeInputValueSetter?.call(input, "99px");
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("focusout", { bubbles: true }));
});
// The parent can echo the preview before persistence settles. That is not a
// durable acknowledgement and must not invalidate the pending rollback.
act(() => root.render(row("99px")));
await act(async () => {
rejectCommit?.(new Error("save failed"));
await Promise.resolve();
});
expect(onCommit).toHaveBeenCalledWith("99px");
expect(input.value).toBe("22px");
act(() => root.unmount());
});
it("does not let an older rejected commit overwrite a newer draft", async () => {
let rejectCommit: ((error: Error) => void) | null = null;
const onCommit = vi.fn(
() =>
new Promise<void>((_resolve, reject) => {
rejectCommit = reject;
}),
);
const { host, root } = renderInto(
<FlatRow label="X" value="22px" tier="explicitDefault" onCommit={onCommit} />,
);
const input = host.querySelector<HTMLInputElement>("input");
if (!input) throw new Error("expected an input");
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
act(() => {
nativeInputValueSetter?.call(input, "99px");
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("focusout", { bubbles: true }));
nativeInputValueSetter?.call(input, "100px");
input.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => {
rejectCommit?.(new Error("old save failed"));
await Promise.resolve();
});
expect(input.value).toBe("100px");
act(() => root.unmount());
});
it("persists a rapid numeric arrow-key burst as one commit", () => { it("persists a rapid numeric arrow-key burst as one commit", () => {
vi.useFakeTimers(); vi.useFakeTimers();
const onCommit = vi.fn(); const onCommit = vi.fn();
@@ -35,7 +35,7 @@ export function FlatRow({
/** Renders a trailing 10px caret-down, for select-backed rows. */ /** Renders a trailing 10px caret-down, for select-backed rows. */
dropdown?: boolean; dropdown?: boolean;
onPreview?: (nextValue: string) => void; onPreview?: (nextValue: string) => void;
onCommit: (nextValue: string) => void; onCommit: (nextValue: string) => void | Promise<void>;
onReset?: () => void; onReset?: () => void;
}) { }) {
const track = useTrackDesignInput(); const track = useTrackDesignInput();
@@ -59,7 +59,7 @@ export function FlatRow({
onPreview={onPreview} onPreview={onPreview}
onCommit={(nextValue) => { onCommit={(nextValue) => {
track("metric", label); track("metric", label);
onCommit(nextValue); return onCommit(nextValue);
}} }}
/> />
</span> </span>
@@ -29,14 +29,14 @@ export function MetricField({
scrub?: boolean; scrub?: boolean;
suffix?: string; suffix?: string;
tooltip?: string; tooltip?: string;
onCommit: (nextValue: string) => void; onCommit: (nextValue: string) => void | Promise<void>;
}) { }) {
const track = useTrackDesignInput(); const track = useTrackDesignInput();
const scrubRef = useRef<{ startX: number; startValue: number; pointerId: number } | null>(null); const scrubRef = useRef<{ startX: number; startValue: number; pointerId: number } | null>(null);
const commit = useCallback( const commit = useCallback(
(nextValue: string) => { (nextValue: string) => {
if (nextValue !== value) track("metric", label); if (nextValue !== value) track("metric", label);
onCommit(nextValue); return onCommit(nextValue);
}, },
[label, onCommit, track, value], [label, onCommit, track, value],
); );
@@ -0,0 +1,57 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from "vitest";
import type { DomEditSelection } from "./domEditingTypes";
import { GsapEditBlockedError } from "../../hooks/gsapEditOutcome";
import { createTransformCommitHandlers } from "./propertyPanelTransformCommit";
describe("createTransformCommitHandlers", () => {
it.each([
[
"position",
(handlers: ReturnType<typeof createTransformCommitHandlers>) =>
handlers.commitManualOffset("x", "20px"),
],
[
"size",
(handlers: ReturnType<typeof createTransformCommitHandlers>) =>
handlers.commitManualSize("width", "200px"),
],
[
"rotation",
(handlers: ReturnType<typeof createTransformCommitHandlers>) =>
handlers.commitManualRotation("45"),
],
])("propagates blocked %s edits so the field can roll back", async (_name, commit) => {
const blocked = new GsapEditBlockedError("unroll-required");
const onCommitAnimatedProperty = vi.fn().mockRejectedValue(blocked);
const onSetManualOffset = vi.fn();
const onSetManualSize = vi.fn();
const onSetManualRotation = vi.fn();
const element = {
id: "box",
selector: "#box",
element: document.createElement("div"),
boundingBox: { width: 100, height: 100 },
} as unknown as DomEditSelection;
const handlers = createTransformCommitHandlers({
element,
styles: {},
hasGsapAnimation: true,
gsapAnimId: "#box-to-position",
gsapKeyframes: null,
currentPct: 0,
onCommitAnimatedProperty,
onAddKeyframe: undefined,
onSetManualOffset,
onSetManualSize,
onSetManualRotation,
showToast: vi.fn(),
});
await expect(commit(handlers)).rejects.toBe(blocked);
expect(onSetManualOffset).not.toHaveBeenCalled();
expect(onSetManualSize).not.toHaveBeenCalled();
expect(onSetManualRotation).not.toHaveBeenCalled();
});
});
@@ -41,13 +41,13 @@ export function createTransformCommitHandlers({
// Route a transform value into the GSAP animation (or a new keyframe) when the // Route a transform value into the GSAP animation (or a new keyframe) when the
// element is animated. Returns true when handled, so callers fall through to // element is animated. Returns true when handled, so callers fall through to
// the manual-transform path only for non-animated elements. // the manual-transform path only for non-animated elements.
const commitAnimatedTransformValue = ( const commitAnimatedTransformValue = async (
property: string, property: string,
value: number, value: number,
noCallbacksMessage: string, noCallbacksMessage: string,
): boolean => { ): Promise<boolean> => {
if (onCommitAnimatedProperty && hasGsapAnimation) { if (onCommitAnimatedProperty && hasGsapAnimation) {
void onCommitAnimatedProperty(element, property, value); await onCommitAnimatedProperty(element, property, value);
return true; return true;
} }
if (gsapKeyframes && gsapAnimId && onAddKeyframe) { if (gsapKeyframes && gsapAnimId && onAddKeyframe) {
@@ -62,11 +62,11 @@ export function createTransformCommitHandlers({
return false; return false;
}; };
const commitManualOffset = (axis: "x" | "y", nextValue: string) => { const commitManualOffset = async (axis: "x" | "y", nextValue: string) => {
const parsed = parsePxMetricValue(nextValue); const parsed = parsePxMetricValue(nextValue);
if (parsed == null) return; if (parsed == null) return;
if ( if (
commitAnimatedTransformValue( await commitAnimatedTransformValue(
axis, axis,
parsed, parsed,
"Cannot edit position — animation callbacks not available", "Cannot edit position — animation callbacks not available",
@@ -74,20 +74,20 @@ export function createTransformCommitHandlers({
) )
return; return;
const current = readStudioPathOffset(element.element); const current = readStudioPathOffset(element.element);
void Promise.resolve( await Promise.resolve(
onSetManualOffset(element, { onSetManualOffset(element, {
x: axis === "x" ? parsed : current.x, x: axis === "x" ? parsed : current.x,
y: axis === "y" ? parsed : current.y, y: axis === "y" ? parsed : current.y,
}), }),
).catch(() => undefined); );
}; };
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
const commitManualSize = (axis: "width" | "height", nextValue: string) => { const commitManualSize = async (axis: "width" | "height", nextValue: string) => {
const parsed = parsePxMetricValue(nextValue); const parsed = parsePxMetricValue(nextValue);
if (parsed == null || parsed <= 0) return; if (parsed == null || parsed <= 0) return;
if (onCommitAnimatedProperty && hasGsapAnimation) { if (onCommitAnimatedProperty && hasGsapAnimation) {
void onCommitAnimatedProperty(element, axis, parsed); await onCommitAnimatedProperty(element, axis, parsed);
return; return;
} }
if (hasGsapAnimation) { if (hasGsapAnimation) {
@@ -103,26 +103,26 @@ export function createTransformCommitHandlers({
current.height > 0 current.height > 0
? current.height ? current.height
: (parsePxMetricValue(styles.height ?? "") ?? element.boundingBox.height); : (parsePxMetricValue(styles.height ?? "") ?? element.boundingBox.height);
void Promise.resolve( await Promise.resolve(
onSetManualSize(element, { onSetManualSize(element, {
width: axis === "width" ? parsed : width, width: axis === "width" ? parsed : width,
height: axis === "height" ? parsed : height, height: axis === "height" ? parsed : height,
}), }),
).catch(() => undefined); );
}; };
const commitManualRotation = (nextValue: string) => { const commitManualRotation = async (nextValue: string) => {
const parsed = Number.parseFloat(nextValue); const parsed = Number.parseFloat(nextValue);
if (!Number.isFinite(parsed)) return; if (!Number.isFinite(parsed)) return;
if ( if (
commitAnimatedTransformValue( await commitAnimatedTransformValue(
"rotation", "rotation",
parsed, parsed,
"Cannot edit rotation — animation callbacks not available", "Cannot edit rotation — animation callbacks not available",
) )
) )
return; return;
void Promise.resolve(onSetManualRotation(element, { angle: parsed })).catch(() => undefined); await Promise.resolve(onSetManualRotation(element, { angle: parsed }));
}; };
return { commitManualOffset, commitManualSize, commitManualRotation }; return { commitManualOffset, commitManualSize, commitManualRotation };
@@ -71,9 +71,15 @@ export interface PropertyPanelProps {
onProgress?: (progress: BackgroundRemovalProgress) => void; onProgress?: (progress: BackgroundRemovalProgress) => void;
}, },
) => Promise<BackgroundRemovalResult>; ) => Promise<BackgroundRemovalResult>;
onSetManualOffset: (element: DomEditSelection, next: { x: number; y: number }) => void; onSetManualOffset: (
onSetManualSize: (element: DomEditSelection, next: { width: number; height: number }) => void; element: DomEditSelection,
onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void; next: { x: number; y: number },
) => void | Promise<void>;
onSetManualSize: (
element: DomEditSelection,
next: { width: number; height: number },
) => void | Promise<void>;
onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void | Promise<void>;
onSetText: (value: string, fieldKey?: string) => void; onSetText: (value: string, fieldKey?: string) => void;
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void; onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
onPreviewTextFieldStyle?: (fieldKey: string, property: string, value: string) => void; onPreviewTextFieldStyle?: (fieldKey: string, property: string, value: string) => void;
@@ -8,6 +8,30 @@ import { useInspectorGestureTransaction } from "./useInspectorGestureTransaction
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("useInspectorGestureTransaction", () => { describe("useInspectorGestureTransaction", () => {
it("restores the durable baseline when an inspector commit rejects", async () => {
const host = document.createElement("div");
const root = createRoot(host);
const onPreview = vi.fn();
const onCommit = vi.fn().mockRejectedValue(new Error("save failed"));
let gesture: ReturnType<typeof useInspectorGestureTransaction<number>> | null = null;
function Probe() {
gesture = useInspectorGestureTransaction({ sourceValue: 10, onPreview, onCommit });
return null;
}
act(() => root.render(<Probe />));
act(() => {
gesture?.preview(25);
gesture?.settle();
});
expect(onPreview.mock.calls.map(([value]) => value)).toEqual([25]);
await act(async () => Promise.resolve());
expect(onPreview.mock.calls.map(([value]) => value)).toEqual([25, 10]);
act(() => root.unmount());
});
it("keeps a new gesture active when the prior async commit is acknowledged", () => { it("keeps a new gesture active when the prior async commit is acknowledged", () => {
const host = document.createElement("div"); const host = document.createElement("div");
const root = createRoot(host); const root = createRoot(host);
@@ -34,4 +58,45 @@ describe("useInspectorGestureTransaction", () => {
act(() => root.unmount()); act(() => root.unmount());
}); });
it("does not let an older rejected commit roll back a newer successful gesture", async () => {
const host = document.createElement("div");
const root = createRoot(host);
const onPreview = vi.fn();
let rejectFirst: ((error: Error) => void) | null = null;
const onCommit = vi
.fn()
.mockImplementationOnce((value: number) => {
onPreview(value);
return new Promise<void>((_resolve, reject) => {
rejectFirst = reject;
});
})
.mockImplementationOnce((value: number) => {
onPreview(value);
return Promise.resolve();
});
let gesture: ReturnType<typeof useInspectorGestureTransaction<number>> | null = null;
function Probe() {
gesture = useInspectorGestureTransaction({ sourceValue: 10, onPreview, onCommit });
return null;
}
act(() => root.render(<Probe />));
act(() => {
gesture?.preview(20);
gesture?.settle();
gesture?.preview(30);
gesture?.settle();
});
await act(async () => {
rejectFirst?.(new Error("old save failed"));
await Promise.resolve();
});
expect(onCommit.mock.calls.map(([value]) => value)).toEqual([20, 30]);
expect(onPreview).toHaveBeenLastCalledWith(30);
act(() => root.unmount());
});
}); });
@@ -1,5 +1,9 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
function isPromiseCommit(result: void | Promise<void>): result is Promise<void> {
return Boolean(result && typeof result.then === "function");
}
/** One owner for continuous inspector edits: preview freely, persist once. */ /** One owner for continuous inspector edits: preview freely, persist once. */
export function useInspectorGestureTransaction<T>({ export function useInspectorGestureTransaction<T>({
sourceValue, sourceValue,
@@ -8,44 +12,96 @@ export function useInspectorGestureTransaction<T>({
}: { }: {
sourceValue: T; sourceValue: T;
onPreview: (value: T) => void; onPreview: (value: T) => void;
onCommit: (value: T) => void; onCommit: (value: T) => void | Promise<void>;
}) { }) {
const sourceRef = useRef(sourceValue); const sourceRef = useRef(sourceValue);
const activeRef = useRef<{ before: T; latest: T } | null>(null); const activeRef = useRef<{ before: T; latest: T } | null>(null);
const previewRef = useRef(onPreview); const previewRef = useRef(onPreview);
const commitRef = useRef(onCommit); const commitRef = useRef(onCommit);
if (!activeRef.current) sourceRef.current = sourceValue; const generationRef = useRef(0);
const pendingRef = useRef<{ before: T; latest: T } | null>(null);
const awaitingSourceAckRef = useRef<{ generation: number; value: T } | null>(null);
const lastSourceValueRef = useRef(sourceValue);
if (!Object.is(lastSourceValueRef.current, sourceValue)) {
lastSourceValueRef.current = sourceValue;
const matchesSourceAck = Object.is(awaitingSourceAckRef.current?.value, sourceValue);
const matchesOptimisticValue =
(activeRef.current && Object.is(activeRef.current.latest, sourceValue)) ||
(pendingRef.current && Object.is(pendingRef.current.latest, sourceValue)) ||
matchesSourceAck;
if (matchesSourceAck) awaitingSourceAckRef.current = null;
if (!matchesOptimisticValue) {
generationRef.current += 1;
activeRef.current = null;
pendingRef.current = null;
awaitingSourceAckRef.current = null;
sourceRef.current = sourceValue;
} else {
sourceRef.current = sourceValue;
}
}
previewRef.current = onPreview; previewRef.current = onPreview;
commitRef.current = onCommit; commitRef.current = onCommit;
const begin = useCallback(() => { const begin = useCallback(() => {
if (!activeRef.current) { if (!activeRef.current) {
generationRef.current += 1;
activeRef.current = { before: sourceRef.current, latest: sourceRef.current }; activeRef.current = { before: sourceRef.current, latest: sourceRef.current };
} }
}, []); }, []);
const preview = useCallback((value: T) => { const preview = useCallback((value: T) => {
if (!activeRef.current) { if (!activeRef.current) {
generationRef.current += 1;
activeRef.current = { before: sourceRef.current, latest: sourceRef.current }; activeRef.current = { before: sourceRef.current, latest: sourceRef.current };
} }
activeRef.current.latest = value; activeRef.current.latest = value;
previewRef.current(value); previewRef.current(value);
}, []); }, []);
const rollbackCommit = useCallback((active: { before: T; latest: T }, generation: number) => {
if (generation !== generationRef.current) return;
pendingRef.current = null;
if (awaitingSourceAckRef.current?.generation === generation) {
awaitingSourceAckRef.current = null;
}
sourceRef.current = active.before;
previewRef.current(active.before);
}, []);
const settle = useCallback(() => { const settle = useCallback(() => {
const active = activeRef.current; const active = activeRef.current;
activeRef.current = null; activeRef.current = null;
if (active && !Object.is(active.before, active.latest)) { if (active && !Object.is(active.before, active.latest)) {
const generation = ++generationRef.current;
sourceRef.current = active.latest; sourceRef.current = active.latest;
// Restore the captured baseline before the persistent commit captures pendingRef.current = active;
// rollback state. The commit reapplies `latest` synchronously, so this awaitingSourceAckRef.current = { generation, value: active.latest };
// is not visible but a failed save can now correctly restore `before`. try {
previewRef.current(active.before); const result = commitRef.current(active.latest);
commitRef.current(active.latest); if (isPromiseCommit(result)) {
void result.then(
() => {
if (generation === generationRef.current) pendingRef.current = null;
},
() => rollbackCommit(active, generation),
);
} else if (generation === generationRef.current) {
pendingRef.current = null;
// Synchronous inspector consumers historically restore their preview
// after persisting (color pickers close, curves release the pointer).
// Async source mutations keep the optimistic preview until the write
// resolves so they do not flash back to the baseline while pending.
previewRef.current(active.before);
}
} catch {
rollbackCommit(active, generation);
}
} }
}, []); }, [rollbackCommit]);
const cancel = useCallback(() => { const cancel = useCallback(() => {
generationRef.current += 1;
const active = activeRef.current; const active = activeRef.current;
activeRef.current = null; activeRef.current = null;
if (active && !Object.is(active.before, active.latest)) { if (active && !Object.is(active.before, active.latest)) {
@@ -66,7 +122,7 @@ export function useInspectorGestureDraft<T>({
}: { }: {
sourceValue: T; sourceValue: T;
onPreview: (value: T) => void; onPreview: (value: T) => void;
onCommit: (value: T) => void; onCommit: (value: T) => void | Promise<void>;
}) { }) {
const [draft, setDraft] = useState(sourceValue); const [draft, setDraft] = useState(sourceValue);
const transaction = useInspectorGestureTransaction({ const transaction = useInspectorGestureTransaction({
@@ -77,7 +133,7 @@ export function useInspectorGestureDraft<T>({
}, },
onCommit: (next) => { onCommit: (next) => {
setDraft(next); setDraft(next);
onCommit(next); return onCommit(next);
}, },
}); });
@@ -43,17 +43,43 @@ function renderHookWith(
animations: GsapAnimation[], animations: GsapAnimation[],
onMutation: (mutation: Record<string, unknown>, label: string) => unknown | Promise<unknown>, onMutation: (mutation: Record<string, unknown>, label: string) => unknown | Promise<unknown>,
onReady: (commit: Commit) => void, onReady: (commit: Commit) => void,
bumpGsapCache = vi.fn(),
onBatch?: (
calls: Array<{ mutation: Record<string, unknown>; options: { label: string } }>,
label: string,
) => unknown | Promise<unknown>,
) { ) {
function Harness() { function Harness() {
const { commitAnimatedProperties } = useAnimatedPropertyCommit({ const gsapCommitMutation = Object.assign(
selectedGsapAnimations: animations, async (
gsapCommitMutation: async (_sel, mutation, options) => { _sel: DomEditSelection,
mutation: Record<string, unknown>,
options: { label: string },
) => {
await onMutation(mutation, options.label); await onMutation(mutation, options.label);
}, },
onBatch
? {
batch: async (
calls: Array<{
selection: DomEditSelection;
mutation: Record<string, unknown>;
options: { label: string };
}>,
options: { label: string },
) => {
await onBatch(calls, options.label);
},
}
: {},
);
const { commitAnimatedProperties } = useAnimatedPropertyCommit({
selectedGsapAnimations: animations,
gsapCommitMutation,
addGsapAnimation: vi.fn(), addGsapAnimation: vi.fn(),
convertToKeyframes: vi.fn(), convertToKeyframes: vi.fn(),
previewIframeRef: { current: null }, previewIframeRef: { current: null },
bumpGsapCache: vi.fn(), bumpGsapCache,
}); });
onReady(commitAnimatedProperties); onReady(commitAnimatedProperties);
return null; return null;
@@ -87,8 +113,53 @@ describe("useAnimatedPropertyCommit — ownership and rejection propagation", ()
act(() => root.unmount()); act(() => root.unmount());
}); });
it("rejects runtime-computed property ownership before sending a mutation", async () => {
const runtimePosition = {
...keyframedAnim,
hasUnresolvedKeyframes: true,
} as GsapAnimation;
const mutations: Array<Record<string, unknown>> = [];
let commit!: Commit;
const root = renderHookWith(
[runtimePosition],
(mutation) => mutations.push(mutation),
(ready) => (commit = ready),
);
await expect(commit(selection, { x: 50 })).rejects.toMatchObject({
reason: "source-uneditable",
});
expect(mutations).toHaveLength(0);
act(() => root.unmount());
});
it("rejects every property before a mixed-group commit can partially persist", async () => {
const helperOpacity = {
id: "#box-to-visual",
targetSelector: "#box",
propertyGroup: "visual",
method: "to",
properties: { opacity: 0.5 },
provenance: { kind: "helper", fn: "fade", callSite: 1 },
} as unknown as GsapAnimation;
const mutations: Array<Record<string, unknown>> = [];
let commit!: Commit;
const root = renderHookWith(
[helperOpacity],
(mutation) => mutations.push(mutation),
(ready) => (commit = ready),
);
await expect(commit(selection, { x: 50, opacity: 0.8 })).rejects.toMatchObject({
reason: "unroll-required",
});
expect(mutations).toHaveLength(0);
act(() => root.unmount());
});
it("rethrows a persistence failure to the telemetry wrapper", async () => { it("rethrows a persistence failure to the telemetry wrapper", async () => {
const failure = new Error("save failed"); const failure = new Error("save failed");
const bumpGsapCache = vi.fn();
let commit!: Commit; let commit!: Commit;
const root = renderHookWith( const root = renderHookWith(
[keyframedAnim], [keyframedAnim],
@@ -96,9 +167,11 @@ describe("useAnimatedPropertyCommit — ownership and rejection propagation", ()
throw failure; throw failure;
}, },
(ready) => (commit = ready), (ready) => (commit = ready),
bumpGsapCache,
); );
await expect(commit(selection, { x: 50 })).rejects.toBe(failure); await expect(commit(selection, { x: 50 })).rejects.toBe(failure);
expect(bumpGsapCache).toHaveBeenCalledTimes(1);
act(() => root.unmount()); act(() => root.unmount());
}); });
}); });
@@ -107,7 +180,13 @@ function renderCommitHook(
mutations: Array<Record<string, unknown>>, mutations: Array<Record<string, unknown>>,
onReady: (commit: Commit) => void, onReady: (commit: Commit) => void,
) { ) {
return renderHookWith([keyframedAnim], (mutation) => mutations.push(mutation), onReady); return renderHookWith(
[keyframedAnim],
(mutation) => {
mutations.push(mutation);
},
onReady,
);
} }
// Regression (#1808): a "3D transform" / design-panel property edit on an // Regression (#1808): a "3D transform" / design-panel property edit on an
@@ -160,7 +239,9 @@ describe("commitStaticSet group routing", () => {
) { ) {
return renderHookWith( return renderHookWith(
[positionSet], [positionSet],
(mutation, label) => committed.push({ mutation, label }), (mutation, label) => {
committed.push({ mutation, label });
},
onReady, onReady,
); );
} }
@@ -208,7 +289,9 @@ describe("commitStaticSet group routing", () => {
let commit!: Commit; let commit!: Commit;
renderHookWith( renderHookWith(
[positionSet, instantSizeHold], [positionSet, instantSizeHold],
(mutation, label) => committed.push({ mutation, label }), (mutation, label) => {
committed.push({ mutation, label });
},
(c) => (commit = c), (c) => (commit = c),
); );
@@ -226,4 +309,56 @@ describe("commitStaticSet group routing", () => {
expect(committed.some(({ mutation }) => mutation.type === "add")).toBe(false); expect(committed.some(({ mutation }) => mutation.type === "add")).toBe(false);
expect(committed[0]!.mutation.animationId).not.toBe(positionSet.id); expect(committed[0]!.mutation.animationId).not.toBe(positionSet.id);
}); });
it("persists multiple property groups in one atomic batch", async () => {
const committed: Array<{ mutation: Record<string, unknown>; label: string }> = [];
const batches: Array<{
calls: Array<{ mutation: Record<string, unknown>; options: { label: string } }>;
label: string;
}> = [];
let commit!: Commit;
const root = renderHookWith(
[positionSet],
(mutation, label) => committed.push({ mutation, label }),
(ready) => (commit = ready),
vi.fn(),
(calls, label) => batches.push({ calls, label }),
);
await act(async () => {
await commit(selection, { x: 400, width: 500 });
});
expect(committed).toHaveLength(0);
expect(batches).toHaveLength(1);
expect(batches[0]!.label).toBe("Set properties");
expect(batches[0]!.calls.map(({ mutation }) => mutation)).toEqual([
{
type: "update-properties",
animationId: positionSet.id,
properties: { x: 400 },
},
{
type: "add",
targetSelector: "#box",
method: "set",
position: 0,
properties: { width: 500 },
global: true,
},
]);
act(() => root.unmount());
});
it("fails before sending anything when an atomic multi-group batch is unavailable", async () => {
const committed: Array<{ mutation: Record<string, unknown>; label: string }> = [];
let commit!: Commit;
const root = renderStaticHook(committed, (ready) => (commit = ready));
await expect(commit(selection, { x: 400, width: 500 })).rejects.toThrow(
"Atomic GSAP property batch is unavailable",
);
expect(committed).toHaveLength(0);
act(() => root.unmount());
});
}); });
@@ -24,22 +24,16 @@ import {
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler"; import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
import { roundTo3 } from "../utils/rounding"; import { roundTo3 } from "../utils/rounding";
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit"; import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
import { assertGsapEditPersisted, directEditOutcomeForProperties } from "./gsapEditOutcome"; import {
assertGsapEditPersisted,
directEditOutcomeForProperties,
GsapEditBlockedError,
} from "./gsapEditOutcome";
import type { CommitMutation, CommitMutationCall } from "./gsapScriptCommitTypes";
interface CommitAnimatedPropertyDeps { interface CommitAnimatedPropertyDeps {
selectedGsapAnimations: GsapAnimation[]; selectedGsapAnimations: GsapAnimation[];
gsapCommitMutation: gsapCommitMutation: CommitMutation | null;
| ((
selection: DomEditSelection,
mutation: Record<string, unknown>,
options: {
label: string;
coalesceKey?: string;
softReload?: boolean;
skipReload?: boolean;
},
) => Promise<void>)
| null;
addGsapAnimation: ( addGsapAnimation: (
selection: DomEditSelection, selection: DomEditSelection,
method: "to" | "from" | "set" | "fromTo", method: "to" | "from" | "set" | "fromTo",
@@ -110,7 +104,7 @@ async function maybeAutoKeyframeSet(
); );
} }
type Commit = NonNullable<CommitAnimatedPropertyDeps["gsapCommitMutation"]>; type Commit = CommitMutation;
/** Undo-history label for a static-set commit, from the group it writes. */ /** Undo-history label for a static-set commit, from the group it writes. */
const STATIC_SET_LABELS: Partial<Record<ReturnType<typeof classifyPropertyGroup>, string>> = { const STATIC_SET_LABELS: Partial<Record<ReturnType<typeof classifyPropertyGroup>, string>> = {
@@ -140,6 +134,17 @@ async function commitSetProps(
animations: GsapAnimation[], animations: GsapAnimation[],
commit: Commit, commit: Commit,
): Promise<void> { ): Promise<void> {
const call = buildSetPropsCall(selection, setAnim, propEntries, selector);
await commit(call.selection, call.mutation, call.options);
await maybeAutoKeyframeSet(selection, setAnim, animations, commit);
}
function buildSetPropsCall(
selection: DomEditSelection,
setAnim: GsapAnimation,
propEntries: [string, number | string][],
selector: string | null,
): CommitMutationCall {
const properties = Object.fromEntries(propEntries); const properties = Object.fromEntries(propEntries);
const numericProps: SetPatchProps = {}; const numericProps: SetPatchProps = {};
for (const [k, v] of propEntries) { for (const [k, v] of propEntries) {
@@ -155,16 +160,15 @@ async function commitSetProps(
}, },
} }
: undefined; : undefined;
await commit( return {
selection, selection,
{ type: "update-properties", animationId: setAnim.id, properties }, mutation: { type: "update-properties", animationId: setAnim.id, properties },
{ options: {
label: staticSetLabel(propEntries), label: staticSetLabel(propEntries),
softReload: true, softReload: true,
...(instantPatch ? { instantPatch } : {}), ...(instantPatch ? { instantPatch } : {}),
}, },
); };
await maybeAutoKeyframeSet(selection, setAnim, animations, commit);
} }
/** /**
@@ -180,7 +184,25 @@ async function commitStaticSet(
animations: GsapAnimation[], animations: GsapAnimation[],
commit: Commit, commit: Commit,
): Promise<void> { ): Promise<void> {
if (!selector) return; const calls = planStaticSetCalls(selection, propEntries, selector, animations);
const only = calls[0];
if (!only) return;
if (calls.length === 1) {
await commit(only.selection, only.mutation, only.options);
return;
}
if (!commit.batch) {
throw new Error("Atomic GSAP property batch is unavailable");
}
await commit.batch(calls, {
label: staticSetLabel(propEntries),
softReload: true,
});
}
function groupStaticSetEntries(
propEntries: [string, number | string][],
): Map<string, [string, number | string][]> {
// One commit per PROPERTY GROUP, each into a static write that owns that group — // One commit per PROPERTY GROUP, each into a static write that owns that group —
// never a live tween, and never a foreign-group write (a width edit used to // never a live tween, and never a foreign-group write (a width edit used to
// merge into the element's position set, producing a mixed write the split // merge into the element's position set, producing a mixed write the split
@@ -194,9 +216,22 @@ async function commitStaticSet(
batch.push(entry); batch.push(entry);
byGroup.set(group, batch); byGroup.set(group, batch);
} }
const staticWrites = animations.filter( return byGroup;
(a) => isInstantHold(a) && tweenTargetsElement(a.targetSelector, selector, selection.element), }
);
function planStaticSetCalls(
selection: DomEditSelection,
propEntries: [string, number | string][],
selector: string | null,
animations: GsapAnimation[],
): CommitMutationCall[] {
const byGroup = groupStaticSetEntries(propEntries);
const staticWrites = selector
? animations.filter(
(a) =>
isInstantHold(a) && tweenTargetsElement(a.targetSelector, selector, selection.element),
)
: [];
// Resolve every group's target BEFORE committing anything, and coalesce // Resolve every group's target BEFORE committing anything, and coalesce
// groups that land on the SAME write into one commit: the snapshot is captured // groups that land on the SAME write into one commit: the snapshot is captured
// once, so if two groups resolved to one legacy mixed write, a first // once, so if two groups resolved to one legacy mixed write, a first
@@ -212,13 +247,12 @@ async function commitStaticSet(
newSetBatches.push(batch); newSetBatches.push(batch);
} }
} }
for (const [targetWrite, batch] of byTargetWrite) { return [
await commitSetProps(selection, targetWrite, batch, selector, animations, commit); ...[...byTargetWrite].map(([targetWrite, batch]) =>
} buildSetPropsCall(selection, targetWrite, batch, selector),
// Fresh adds don't reshape existing sets, so their ids can't go stale. ),
for (const batch of newSetBatches) { ...newSetBatches.map((batch) => buildGlobalStaticSetCall(selection, batch)),
await addGlobalStaticSet(selection, batch, commit); ];
}
} }
/** /**
@@ -244,11 +278,10 @@ function findGroupOwningStaticWrite(
* the timeline (matches the manual-drag UX). The global-set instant patch applies * the timeline (matches the manual-drag UX). The global-set instant patch applies
* it straight to the element so the first edit shows with no soft-reload flash. * it straight to the element so the first edit shows with no soft-reload flash.
*/ */
async function addGlobalStaticSet( function buildGlobalStaticSetCall(
selection: DomEditSelection, selection: DomEditSelection,
batch: [string, number | string][], batch: [string, number | string][],
commit: Commit, ): CommitMutationCall {
): Promise<void> {
const numericProps: SetPatchProps = {}; const numericProps: SetPatchProps = {};
for (const [k, v] of batch) { for (const [k, v] of batch) {
if (typeof v === "number") numericProps[k as keyof SetPatchProps] = v; if (typeof v === "number") numericProps[k as keyof SetPatchProps] = v;
@@ -257,10 +290,10 @@ async function addGlobalStaticSet(
// selector is the bare class an id-less element yields, which would hold every // selector is the bare class an id-less element yields, which would hold every
// sibling. No one-element form means no write at all (see writeTargetSelector). // sibling. No one-element form means no write at all (see writeTargetSelector).
const target = writeTargetSelector(selection); const target = writeTargetSelector(selection);
if (!target) return; if (!target) throw new GsapEditBlockedError("no-selector");
await commit( return {
selection, selection,
{ mutation: {
type: "add", type: "add",
targetSelector: target, targetSelector: target,
method: "set", method: "set",
@@ -268,7 +301,7 @@ async function addGlobalStaticSet(
properties: Object.fromEntries(batch), properties: Object.fromEntries(batch),
global: true, global: true,
}, },
{ options: {
label: staticSetLabel(batch), label: staticSetLabel(batch),
softReload: true, softReload: true,
...(Object.keys(numericProps).length > 0 ...(Object.keys(numericProps).length > 0
@@ -280,7 +313,7 @@ async function addGlobalStaticSet(
} }
: {}), : {}),
}, },
); };
} }
/** Convert-if-flat, then write ALL props into ONE keyframe at the playhead. */ /** Convert-if-flat, then write ALL props into ONE keyframe at the playhead. */
@@ -418,6 +451,9 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
selector, selector,
primaryProp, primaryProp,
); );
if (!anim && !writeTargetSelector(selection)) {
throw new GsapEditBlockedError("no-selector");
}
// Whether the element is animated at all. A 3D edit only creates/edits // Whether the element is animated at all. A 3D edit only creates/edits
// keyframes when it IS — a static element (no keyframes on any of its tweens) // keyframes when it IS — a static element (no keyframes on any of its tweens)
// gets a `tl.set`, never new keyframes (matches manual drag / resize / rotate). // gets a `tl.set`, never new keyframes (matches manual drag / resize / rotate).
@@ -472,12 +508,15 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
return; return;
} }
// Existing static hold on a NON-animated element — merge the props into the // Static element (no keyframes anywhere) — persist as a `tl.set`, never
// same write (maybeAutoKeyframeSet no-ops when nothing else is keyframed). // keyframes (incl. the no-animation case, which creates a fresh set).
if (anim && isInstantHold(anim)) { // Route the complete property set through the group-aware planner even
await commitSetProps( // when pickBestAnimation found one existing set: a mixed X+width edit
// must update the position set AND create a size set atomically rather
// than contaminating the first set with a foreign property group.
if (!elementHasKeyframes) {
await commitStaticSet(
selection, selection,
anim,
propEntries, propEntries,
selector, selector,
selectedGsapAnimations, selectedGsapAnimations,
@@ -486,11 +525,12 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
return; return;
} }
// Static element (no keyframes anywhere)persist as a `tl.set`, never // Existing static hold on an otherwise animated elementmerge the props
// keyframes (incl. the no-animation case, which creates a fresh set). // into the same write, then auto-keyframe it against the sibling tween.
if (!elementHasKeyframes) { if (anim && isInstantHold(anim)) {
await commitStaticSet( await commitSetProps(
selection, selection,
anim,
propEntries, propEntries,
selector, selector,
selectedGsapAnimations, selectedGsapAnimations,
@@ -509,7 +549,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
// one-element form the edit is dropped rather than written onto every // one-element form the edit is dropped rather than written onto every
// class sibling (see writeTargetSelector). // class sibling (see writeTargetSelector).
const newTweenTarget = writeTargetSelector(selection); const newTweenTarget = writeTargetSelector(selection);
if (selector && newTweenTarget) { if (newTweenTarget) {
const template = selectedGsapAnimations.find((a) => !!a.keyframes); const template = selectedGsapAnimations.find((a) => !!a.keyframes);
const tStart = template ? (resolveTweenStart(template) ?? 0) : 0; const tStart = template ? (resolveTweenStart(template) ?? 0) : 0;
const tDur = template ? resolveTweenDuration(template) || 1 : 1; const tDur = template ? resolveTweenDuration(template) || 1 : 1;
@@ -539,7 +579,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
); );
return; return;
} }
bumpGsapCache(); throw new GsapEditBlockedError("no-selector");
} catch (error) { } catch (error) {
bumpGsapCache(); bumpGsapCache();
throw error; throw error;
@@ -0,0 +1,61 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, expect, it, vi } from "vitest";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import {
applyStudioBoxSize,
applyStudioPathOffset,
applyStudioRotation,
readStudioBoxSize,
readStudioPathOffset,
readStudioRotation,
} from "../components/editor/manualEdits";
import { useDomGeometryCommits } from "./useDomGeometryCommits";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("useDomGeometryCommits rollback", () => {
it("restores every optimistic geometry mutation when persistence rejects", async () => {
const element = document.createElement("div");
element.id = "box";
document.body.append(element);
applyStudioPathOffset(element, { x: 10, y: 20 });
applyStudioBoxSize(element, { width: 100, height: 80 });
applyStudioRotation(element, { angle: 15 });
const selection = {
id: "box",
selector: "#box",
element,
} as unknown as DomEditSelection;
const failure = new Error("save failed");
const commitPositionPatchToHtml = vi.fn().mockRejectedValue(failure);
let commits: ReturnType<typeof useDomGeometryCommits> | null = null;
const host = document.createElement("div");
const root = createRoot(host);
function Probe() {
commits = useDomGeometryCommits({
previewIframeRef: { current: null },
showToast: vi.fn(),
commitPositionPatchToHtml,
});
return null;
}
act(() => root.render(<Probe />));
await expect(commits!.handleDomPathOffsetCommit(selection, { x: 50, y: 60 })).rejects.toBe(
failure,
);
await expect(
commits!.handleDomBoxSizeCommit(selection, { width: 200, height: 160 }, { x: 30, y: 40 }),
).rejects.toBe(failure);
await expect(commits!.handleDomRotationCommit(selection, { angle: 45 })).rejects.toBe(failure);
expect(readStudioPathOffset(element)).toEqual({ x: 10, y: 20 });
expect(readStudioBoxSize(element)).toEqual({ width: 100, height: 80 });
expect(readStudioRotation(element)).toEqual({ angle: 15 });
act(() => root.unmount());
});
});
@@ -4,6 +4,12 @@ import {
applyStudioPathOffset, applyStudioPathOffset,
applyStudioBoxSize, applyStudioBoxSize,
applyStudioRotation, applyStudioRotation,
captureStudioPathOffset,
captureStudioBoxSize,
captureStudioRotation,
restoreStudioPathOffset,
restoreStudioBoxSize,
restoreStudioRotation,
clearStudioPathOffset, clearStudioPathOffset,
clearStudioBoxSize, clearStudioBoxSize,
clearStudioRotation, clearStudioRotation,
@@ -51,10 +57,14 @@ export function useDomGeometryCommits({
showToast(error.message, "error"); showToast(error.message, "error");
return Promise.reject(error); return Promise.reject(error);
} }
const before = captureStudioPathOffset(selection.element);
applyStudioPathOffset(selection.element, next); applyStudioPathOffset(selection.element, next);
return commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), { return commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
label: "Move layer", label: "Move layer",
coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`, coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`,
}).catch((error) => {
restoreStudioPathOffset(selection.element, before);
throw error;
}); });
}, },
[commitPositionPatchToHtml, previewIframeRef, showToast], [commitPositionPatchToHtml, previewIframeRef, showToast],
@@ -71,6 +81,8 @@ export function useDomGeometryCommits({
showToast(error.message, "error"); showToast(error.message, "error");
return Promise.reject(error); return Promise.reject(error);
} }
const beforeSize = captureStudioBoxSize(selection.element);
const beforeOffset = offset ? captureStudioPathOffset(selection.element) : null;
applyStudioBoxSize(selection.element, next); applyStudioBoxSize(selection.element, next);
// Anchored-corner resize (NW/NE/SW) also moves the element to keep the // Anchored-corner resize (NW/NE/SW) also moves the element to keep the
// opposite corner fixed. Apply the offset and emit BOTH patch sets in a // opposite corner fixed. Apply the offset and emit BOTH patch sets in a
@@ -86,6 +98,10 @@ export function useDomGeometryCommits({
return commitPositionPatchToHtml(selection, patches, { return commitPositionPatchToHtml(selection, patches, {
label: "Resize layer box", label: "Resize layer box",
coalesceKey: `box-size:${getDomEditTargetKey(selection)}`, coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
}).catch((error) => {
restoreStudioBoxSize(selection.element, beforeSize);
if (beforeOffset) restoreStudioPathOffset(selection.element, beforeOffset);
throw error;
}); });
}, },
[commitPositionPatchToHtml, previewIframeRef, showToast], [commitPositionPatchToHtml, previewIframeRef, showToast],
@@ -98,10 +114,14 @@ export function useDomGeometryCommits({
showToast(error.message, "error"); showToast(error.message, "error");
return Promise.reject(error); return Promise.reject(error);
} }
const before = captureStudioRotation(selection.element);
applyStudioRotation(selection.element, next); applyStudioRotation(selection.element, next);
return commitPositionPatchToHtml(selection, buildRotationPatches(selection.element), { return commitPositionPatchToHtml(selection, buildRotationPatches(selection.element), {
label: "Rotate layer", label: "Rotate layer",
coalesceKey: `rotation:${getDomEditTargetKey(selection)}`, coalesceKey: `rotation:${getDomEditTargetKey(selection)}`,
}).catch((error) => {
restoreStudioRotation(selection.element, before);
throw error;
}); });
}, },
[commitPositionPatchToHtml, previewIframeRef, showToast], [commitPositionPatchToHtml, previewIframeRef, showToast],