Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ function Transform3dField({
onCommit={(next) => {
const v = parse(next);
if (v != null && onCommitAnimatedProperty) {
void onCommitAnimatedProperty(ctx.element, prop, v);
return onCommitAnimatedProperty(ctx.element, prop, v);
}
}}
/>
Expand Down
39 changes: 37 additions & 2 deletions packages/studio/src/components/editor/propertyPanelCommitField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,27 @@ export function CommitField({
liveCommit?: boolean;
align?: "left" | "right";
onPreview?: (nextValue: string) => void;
onCommit: (nextValue: string) => void;
onCommit: (nextValue: string) => void | Promise<void>;
}) {
const [draft, setDraft] = useState(value);
const valueRef = useRef(value);
const draftRef = useRef(draft);
const inputRef = useRef<HTMLInputElement>(null);
const focusedRef = 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;
draftRef.current = draft;

Expand Down Expand Up @@ -67,14 +80,34 @@ export function CommitField({
}, 250);
};
const cancelGesture = () => {
commitGenerationRef.current += 1;
clearGestureSettleTimer();
gestureActiveRef.current = false;
gestureTransaction.cancel();
};
const commitDraft = (nextValue: string) => {
const generation = ++commitGenerationRef.current;
setDraft(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>) => {
if (!gestureActiveRef.current) return false;
Expand All @@ -89,6 +122,7 @@ export function CommitField({
const nextDraft = adjustNumericToken(draftRef.current, direction, event);
if (!nextDraft) return;
event.preventDefault();
commitGenerationRef.current += 1;
dirtyRef.current = false;
gestureActiveRef.current = true;
gestureTransaction.preview(nextDraft);
Expand Down Expand Up @@ -148,6 +182,7 @@ export function CommitField({
focusedRef.current = true;
}}
onChange={(event) => {
commitGenerationRef.current += 1;
settleGesture();
dirtyRef.current = true;
setDraft(event.target.value);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,75 @@ describe("FlatRow", () => {
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", () => {
vi.useFakeTimers();
const onCommit = vi.fn();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export function FlatRow({
/** Renders a trailing 10px caret-down, for select-backed rows. */
dropdown?: boolean;
onPreview?: (nextValue: string) => void;
onCommit: (nextValue: string) => void;
onCommit: (nextValue: string) => void | Promise<void>;
onReset?: () => void;
}) {
const track = useTrackDesignInput();
Expand All @@ -59,7 +59,7 @@ export function FlatRow({
onPreview={onPreview}
onCommit={(nextValue) => {
track("metric", label);
onCommit(nextValue);
return onCommit(nextValue);
}}
/>
</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,14 @@ export function MetricField({
scrub?: boolean;
suffix?: string;
tooltip?: string;
onCommit: (nextValue: string) => void;
onCommit: (nextValue: string) => void | Promise<void>;
}) {
const track = useTrackDesignInput();
const scrubRef = useRef<{ startX: number; startValue: number; pointerId: number } | null>(null);
const commit = useCallback(
(nextValue: string) => {
if (nextValue !== value) track("metric", label);
onCommit(nextValue);
return onCommit(nextValue);
},
[label, onCommit, track, value],
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,13 @@ export function createTransformCommitHandlers({
// 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
// the manual-transform path only for non-animated elements.
const commitAnimatedTransformValue = (
const commitAnimatedTransformValue = async (
property: string,
value: number,
noCallbacksMessage: string,
): boolean => {
): Promise<boolean> => {
if (onCommitAnimatedProperty && hasGsapAnimation) {
void onCommitAnimatedProperty(element, property, value);
await onCommitAnimatedProperty(element, property, value);
return true;
}
if (gsapKeyframes && gsapAnimId && onAddKeyframe) {
Expand All @@ -62,32 +62,32 @@ export function createTransformCommitHandlers({
return false;
};

const commitManualOffset = (axis: "x" | "y", nextValue: string) => {
const commitManualOffset = async (axis: "x" | "y", nextValue: string) => {
const parsed = parsePxMetricValue(nextValue);
if (parsed == null) return;
if (
commitAnimatedTransformValue(
await commitAnimatedTransformValue(
axis,
parsed,
"Cannot edit position — animation callbacks not available",
)
)
return;
const current = readStudioPathOffset(element.element);
void Promise.resolve(
await Promise.resolve(
onSetManualOffset(element, {
x: axis === "x" ? parsed : current.x,
y: axis === "y" ? parsed : current.y,
}),
).catch(() => undefined);
);
};

// fallow-ignore-next-line complexity
const commitManualSize = (axis: "width" | "height", nextValue: string) => {
const commitManualSize = async (axis: "width" | "height", nextValue: string) => {
const parsed = parsePxMetricValue(nextValue);
if (parsed == null || parsed <= 0) return;
if (onCommitAnimatedProperty && hasGsapAnimation) {
void onCommitAnimatedProperty(element, axis, parsed);
await onCommitAnimatedProperty(element, axis, parsed);
return;
}
if (hasGsapAnimation) {
Comment thread
miguel-heygen marked this conversation as resolved.
Expand All @@ -103,26 +103,26 @@ export function createTransformCommitHandlers({
current.height > 0
? current.height
: (parsePxMetricValue(styles.height ?? "") ?? element.boundingBox.height);
void Promise.resolve(
await Promise.resolve(
onSetManualSize(element, {
width: axis === "width" ? parsed : width,
height: axis === "height" ? parsed : height,
}),
).catch(() => undefined);
);
};

const commitManualRotation = (nextValue: string) => {
const commitManualRotation = async (nextValue: string) => {
const parsed = Number.parseFloat(nextValue);
if (!Number.isFinite(parsed)) return;
if (
commitAnimatedTransformValue(
await commitAnimatedTransformValue(
"rotation",
parsed,
"Cannot edit rotation — animation callbacks not available",
)
)
return;
void Promise.resolve(onSetManualRotation(element, { angle: parsed })).catch(() => undefined);
await Promise.resolve(onSetManualRotation(element, { angle: parsed }));
};

return { commitManualOffset, commitManualSize, commitManualRotation };
Expand Down
12 changes: 9 additions & 3 deletions packages/studio/src/components/editor/propertyPanelTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,15 @@ export interface PropertyPanelProps {
onProgress?: (progress: BackgroundRemovalProgress) => void;
},
) => Promise<BackgroundRemovalResult>;
onSetManualOffset: (element: DomEditSelection, next: { x: number; y: number }) => void;
onSetManualSize: (element: DomEditSelection, next: { width: number; height: number }) => void;
onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void;
onSetManualOffset: (
element: DomEditSelection,
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;
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
onPreviewTextFieldStyle?: (fieldKey: string, property: string, value: string) => void;
Expand Down
Loading
Loading