fix(studio): undo shows a resize, rotate or GSAP edit undone at once while it saves (#5022)

* fix(studio): undo shows a resize, rotate or GSAP edit undone at once while it saves

* test(studio): pin the painted-back draw and redraw rules

* test(studio): the resize intercept also gets the draw that keeps an undone edit undone

* fix(studio): a scale resize reads its drop point on the box as dropped, even after undo

* fix(studio): a group move and a resize settle keep an undone edit undone

* fix(studio): undo paints back a GSAP move saved as a left/top offset

* fix(studio): undo still steps a GSAP edit whose first write saved before a later one failed

* fix(studio): the resize pixel check measures a box undo painted back as the edit drew it

* fix(studio): a GSAP move undone mid-save keeps its drag base, so its save adds the drag once

* refactor(studio): trim the scale-resize finalize comments to what the code cannot say
This commit is contained in:
Miguel Ángel
2026-10-04 20:13:06 +00:00
committed by GitHub
parent 99c389bce9
commit 49333a882d
22 changed files with 706 additions and 139 deletions
@@ -6,6 +6,7 @@ import type {
StudioRotationSnapshot,
} from "./manualEdits";
import type { ManualOffsetDragMember } from "./manualOffsetDrag";
import type { StudioElementLook } from "./gestureUndoRevert";
import type { CssRotationTarget, RotationCommit } from "./rotationDraft";
import type { GroupOverlayItem, OverlayRect } from "./domEditOverlayGeometry";
import type { SnapContext } from "./snapTargetCollection";
@@ -57,6 +58,7 @@ export interface GestureState {
initialPathOffset: StudioPathOffsetSnapshot;
initialRotation: StudioRotationSnapshot;
initialBoxSize: StudioBoxSizeSnapshot;
initialLook: StudioElementLook;
pathOffsetMember?: ManualOffsetDragMember;
originLeft: number;
originTop: number;
@@ -9,6 +9,7 @@ import {
restoreManualOffsetDragMembers,
type ManualOffsetDragMember,
} from "./manualOffsetDrag";
import { readElementLook } from "./gestureUndoRevert";
import { readCssRotationTarget, readRotationBase } from "./rotationDraft";
import {
beginStudioManualEditGesture,
@@ -179,6 +180,10 @@ export function startGesture(
Number.isFinite(rawContentScaleX) && rawContentScaleX > 0 ? rawContentScaleX : 1;
const contentScaleY =
Number.isFinite(rawContentScaleY) && rawContentScaleY > 0 ? rawContentScaleY : 1;
const initialLook = readElementLook(
sel.element,
kind !== "drag" && !editsPlainCss(sel.element, kind === "rotate" ? "rotate" : "resize"),
);
let initialPathOffset = captureStudioPathOffset(sel.element);
let manualEditDragToken: string | undefined;
let pathOffsetMember: ManualOffsetDragMember | undefined;
@@ -274,6 +279,7 @@ export function startGesture(
initialPathOffset,
initialRotation: captureStudioRotation(sel.element),
initialBoxSize: captureStudioBoxSize(sel.element),
initialLook,
pathOffsetMember,
originLeft: rect.left,
originTop: rect.top,
@@ -1,15 +1,40 @@
// @vitest-environment happy-dom
import { afterEach, expect, it, vi } from "vitest";
import { savePlainRotation } from "../../hooks/plainRotation";
import type { DomEditSelection } from "./domEditing";
import type { GestureState } from "./domEditOverlayGestures";
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
import {
adoptingStudioPendingEdit,
hasStudioPendingEdits,
paintBackNewestStudioPendingEdit,
type StudioEditInFlight,
} from "../../utils/studioPendingEdits";
const gsapOwns = vi.hoisted(() => ({ on: false }));
vi.mock("../../hooks/gsapRuntimeKeyframes", async (importOriginal) => ({
...(await importOriginal<typeof import("../../hooks/gsapRuntimeKeyframes")>()),
editsPlainCss: () => !gsapOwns.on,
}));
/** GSAP's own transform values per element, as `gsap.set` / `gsap.getProperty` see them. */
function fakeGsap(start: Record<string, number>) {
const values = new Map<Element, Record<string, number>>();
const of = (el: Element) => values.get(el) ?? values.set(el, { ...start }).get(el)!;
gsapOwns.on = true;
Object.assign(window, {
gsap: {
set: (el: Element, props: Record<string, number>) => Object.assign(of(el), props),
getProperty: (el: Element, prop: string) => of(el)[prop] ?? 0,
},
});
return of;
}
afterEach(() => {
document.body.innerHTML = "";
gsapOwns.on = false;
Reflect.deleteProperty(window, "gsap");
});
const ref = <T>(current: T) => ({ current });
@@ -24,7 +49,7 @@ const pointer = (x: number, y: number) => ({
});
/** Drags a box without GSAP 100 px right and 60 px down; its save waits for `save`. */
function dragWithSaveRunning(save: Promise<void>) {
function dragWithSaveRunning(save: Promise<void> | (() => Promise<void>)) {
const element = document.createElement("div");
element.style.setProperty("translate", "40px 30px");
document.body.append(element);
@@ -39,7 +64,7 @@ function dragWithSaveRunning(save: Promise<void>) {
rafPausedRef: ref(false),
onManualDragStartRef: ref(vi.fn()),
onBlockedMoveRef: ref(vi.fn()),
onPathOffsetCommitRef: ref(vi.fn(() => save)),
onPathOffsetCommitRef: ref(vi.fn(typeof save === "function" ? save : () => save)),
snapGuidesRef: ref(null),
groupGestureRef: ref(null),
blockedMoveRef: ref(null),
@@ -69,3 +94,146 @@ it("a drag whose save is still running can be painted back at once, and shown ag
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
expect(paintBackNewestStudioPendingEdit()).toBeNull();
});
/** Resizes or rotates a plain 240x160 box; the commit draws what it saves at once and its save waits for `save`. */
function gestureWithSaveRunning(kind: "resize" | "rotate", save: Promise<void>) {
const element = document.createElement("div");
element.setAttribute(
"style",
"position: absolute; width: 240px; height: 160px; clip-path: inset(10px)",
);
document.body.append(element);
const selection = {
element,
capabilities: { canApplyManualSize: true, canApplyManualRotation: true },
};
const handlers = createDomEditOverlayGestureHandlers({
selectionRef: ref(selection as unknown as DomEditSelection),
overlayRectRef: ref({ left: 0, top: 0, width: 240, height: 160, editScaleX: 1, editScaleY: 1 }),
boxRef: ref(document.createElement("div")),
overlayRef: ref(null),
iframeRef: ref(null),
gestureRef: ref<GestureState | null>(null),
rafPausedRef: ref(false),
onBoxSizeCommitRef: ref(
vi.fn(() => {
element.style.setProperty("clip-path", "inset(15px)");
return save;
}),
),
onRotationCommitRef: ref((sel: DomEditSelection, next: never) =>
savePlainRotation({ commitPositionPatchToHtml: () => save.then(() => undefined) }, sel, next),
),
snapGuidesRef: ref(null),
groupGestureRef: ref(null),
blockedMoveRef: ref(null),
setOverlayRect: vi.fn(),
suppressNextBoxClickRef: ref(false),
hoverSelectionRef: ref(null),
onCanvasMouseDown: vi.fn(),
} as never);
const start = kind === "resize" ? pointer(240, 160) : pointer(240, 80);
expect(handlers.startGesture(kind, start as never, { resizeHandle: "se" })).toBe(true);
const end = kind === "resize" ? pointer(300, 200) : pointer(120, 200);
handlers.onPointerMove(end as never);
handlers.onPointerUp(end as never);
return element;
}
it.each(["resize", "rotate"] as const)(
"a %s whose save is still running can be painted back at once, and shown again",
async (kind) => {
let saved!: () => void;
const element = gestureWithSaveRunning(kind, new Promise<void>((resolve) => (saved = resolve)));
const before = "position: absolute; width: 240px; height: 160px; clip-path: inset(10px)";
const edited = element.getAttribute("style");
expect(edited).not.toBe(before);
const shown = paintBackNewestStudioPendingEdit();
expect(element.getAttribute("style")).toBe(before);
expect(paintBackNewestStudioPendingEdit()).toBeNull();
shown!.showAgain();
expect(element.getAttribute("style")).toBe(edited);
saved();
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
},
);
it("a drag of a box GSAP positions is painted back to where GSAP had it, and shown again", async () => {
const gsapOf = fakeGsap({ x: 5, y: 7 });
let saved!: () => void;
const element = dragWithSaveRunning(new Promise<void>((resolve) => (saved = resolve)));
const moved = { ...gsapOf(element) };
expect(moved).not.toMatchObject({ x: 5, y: 7 });
const shown = paintBackNewestStudioPendingEdit();
expect(gsapOf(element)).toMatchObject({ x: 5, y: 7 });
shown!.showAgain();
expect(gsapOf(element)).toMatchObject({ x: moved.x, y: moved.y });
saved();
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
});
it("a rotate of a box GSAP turns is painted back to GSAP's angle at press, and shown again", async () => {
const gsapOf = fakeGsap({ rotation: 10 });
let saved!: () => void;
const element = gestureWithSaveRunning(
"rotate",
new Promise<void>((resolve) => (saved = resolve)),
);
const turned = gsapOf(element).rotation;
expect(turned).not.toBe(10);
const shown = paintBackNewestStudioPendingEdit();
expect(gsapOf(element).rotation).toBe(10);
shown!.showAgain();
expect(gsapOf(element).rotation).toBe(turned);
saved();
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
});
it("a GSAP drag saved as a left/top offset stays painted back while that offset is drawn", async () => {
fakeGsap({ x: 5, y: 7 });
let saved!: () => void;
let drawn!: Promise<void>;
const element = dragWithSaveRunning(() => {
const edit = adoptingStudioPendingEdit()!;
drawn = new Promise<void>((resolve) => (saved = resolve)).then(() =>
edit.drawKeepingUndone(() => void element.style.setProperty("left", "99px")),
);
return drawn;
});
const shown = paintBackNewestStudioPendingEdit();
saved();
await drawn;
expect(element.style.getPropertyValue("left")).toBe("");
shown!.showAgain();
expect(element.style.getPropertyValue("left")).toBe("99px");
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
});
it("keeps a GSAP drag's base through every repaint of its undo, so its save adds the drag once", async () => {
const gsapOf = fakeGsap({ x: 5, y: 7 });
let edit!: StudioEditInFlight;
let release!: () => void;
const element = dragWithSaveRunning(() => {
edit = adoptingStudioPendingEdit()!;
return new Promise<void>((resolve) => (release = resolve));
});
const moved = { ...gsapOf(element) };
const shown = paintBackNewestStudioPendingEdit();
edit.drawKeepingUndone(() => undefined);
expect(gsapOf(element)).toMatchObject({ x: 5, y: 7 });
expect(element.getAttribute("data-hf-drag-gsap-base-x")).toBe("5");
shown!.showAgain();
expect(gsapOf(element)).toMatchObject({ x: moved.x, y: moved.y });
expect(element.getAttribute("data-hf-drag-gsap-base-x")).toBe("5");
release();
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
});
@@ -0,0 +1,104 @@
import {
captureStudioPathOffset,
restoreStudioPathOffset,
type StudioPathOffsetSnapshot,
} from "./manualEdits";
import {
getOffsetDragGsap,
stampGestureBase,
type ManualOffsetDragMember,
} from "./manualOffsetDrag";
import type { StudioEditRevert } from "../../utils/studioPendingEdits";
interface MemberPosition {
offset: StudioPathOffsetSnapshot;
gsap: { x: number; y: number } | null;
placement: string[];
}
// Where an element-offset move draws the element when no transform channel can take it.
const PLACEMENT = ["position", "left", "top"];
const placementOf = (element: HTMLElement) =>
PLACEMENT.map((prop) => element.style.getPropertyValue(prop));
function showPlacement(element: HTMLElement, placement: string[]): void {
PLACEMENT.forEach((prop, i) => {
if (placement[i]) element.style.setProperty(prop, placement[i]!);
else element.style.removeProperty(prop);
});
}
function readMemberPosition(member: ManualOffsetDragMember): MemberPosition {
const gsap = member.plainTranslate ? null : getOffsetDragGsap(member.element);
return {
offset: captureStudioPathOffset(member.element),
gsap: gsap && {
x: Number(gsap.getProperty(member.element, "x")),
y: Number(gsap.getProperty(member.element, "y")),
},
placement: placementOf(member.element),
};
}
function showMemberPosition(member: ManualOffsetDragMember, position: MemberPosition): void {
restoreStudioPathOffset(member.element, position.offset);
showPlacement(member.element, position.placement);
if (position.gsap) getOffsetDragGsap(member.element)?.set(member.element, { ...position.gsap });
// The restore drops the gesture's base stamps; its save still reads them.
if (!member.plainTranslate)
stampGestureBase(member.element, member.initialOffset, member.baseGsap);
}
/** Undo's live revert of a move: its members at gesture start. */
export function manualOffsetMoveRevert(members: ManualOffsetDragMember[]): StudioEditRevert {
const startPlacement = members.map((member) => placementOf(member.element));
return () => {
const shown = members.map(readMemberPosition);
members.forEach((member, i) =>
showMemberPosition(member, {
offset: member.initialPathOffset,
gsap: member.plainTranslate ? null : member.baseGsap,
placement: startPlacement[i]!,
}),
);
return () => members.forEach((member, i) => showMemberPosition(member, shown[i]!));
};
}
export interface StudioElementLook {
style: string | null;
gsap: Record<string, number> | null;
}
const GSAP_LOOK_PROPS = ["x", "y", "rotation", "scaleX", "scaleY"];
/** Reads GSAP only for an element it owns: reading a plain one makes GSAP bake its CSS into a transform. */
export function readElementLook(element: HTMLElement, gsapOwned: boolean): StudioElementLook {
const gsap = gsapOwned ? getOffsetDragGsap(element) : null;
return {
style: element.getAttribute("style"),
gsap:
gsap &&
Object.fromEntries(
GSAP_LOOK_PROPS.map((prop) => [prop, Number(gsap.getProperty(element, prop))]),
),
};
}
function showElementLook(element: HTMLElement, look: StudioElementLook): void {
if (look.style === null) element.removeAttribute("style");
else element.setAttribute("style", look.style);
if (look.gsap) getOffsetDragGsap(element)?.set(element, { ...look.gsap });
}
/** Undo's live revert of a resize or rotate: the element as it looked at press. */
export function elementLookRevert(
element: HTMLElement,
start: StudioElementLook,
): StudioEditRevert {
return () => {
const shown = readElementLook(element, start.gsap !== null);
showElementLook(element, start);
return () => showElementLook(element, shown);
};
}
@@ -13,7 +13,6 @@ import {
import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
import { editsPlainCss } from "../../hooks/gsapRuntimeKeyframes";
import { readTranslatePx, UNREADABLE_TRANSLATE, writeTranslatePx } from "./plainTranslate";
import type { StudioEditRevert } from "../../utils/studioPendingEdits";
interface OffsetDragGsap {
set: (el: Element, vars: Record<string, number | string>) => void;
@@ -448,7 +447,7 @@ export function applyManualOffsetDragDraft(
* the element flies off-screen the instant you drop it. The member holds the
* true gesture-start values in JS, immune to the re-render.
*/
function stampGestureBase(el: HTMLElement, initialOffset: Point, baseGsap: Point): void {
export function stampGestureBase(el: HTMLElement, initialOffset: Point, baseGsap: Point): void {
el.setAttribute("data-hf-drag-gsap-base-x", String(baseGsap.x));
el.setAttribute("data-hf-drag-gsap-base-y", String(baseGsap.y));
el.setAttribute("data-hf-drag-initial-offset-x", String(initialOffset.x));
@@ -510,16 +509,6 @@ function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void {
endStudioManualEditGesture(member.element, member.gestureToken);
}
/** Undo's live revert of a move: its members at gesture start. Null off the plain route, which GSAP draws. */
export function manualOffsetMoveRevert(members: ManualOffsetDragMember[]): StudioEditRevert | null {
if (!members.every((member) => member.plainTranslate)) return null;
return () => {
const shown = members.map((member) => captureStudioPathOffset(member.element));
for (const member of members) restoreStudioPathOffset(member.element, member.initialPathOffset);
return () => members.forEach((member, i) => restoreStudioPathOffset(member.element, shown[i]!));
};
}
/** Roll back a FAILED drag to the exact gesture-start state. */
export function restoreManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
for (const member of members) {
@@ -32,10 +32,10 @@ import {
applyManualOffsetNudgeDraft,
createManualOffsetDragMember,
endManualOffsetDragMembers,
manualOffsetMoveRevert,
restoreManualOffsetDragMembers,
type ManualOffsetDragMember,
} from "./manualOffsetDrag";
import { manualOffsetMoveRevert } from "./gestureUndoRevert";
import { isStudioManualEditGestureCurrent, restoreStudioPathOffset } from "./manualEdits";
import {
CANVAS_NUDGE_COMMIT_DEBOUNCE_MS,
@@ -12,8 +12,8 @@ import {
applyManualOffsetDragDraft,
endManualOffsetDragMembers,
restoreManualOffsetDragMembers,
manualOffsetMoveRevert,
} from "./manualOffsetDrag";
import { manualOffsetMoveRevert, elementLookRevert } from "./gestureUndoRevert";
import { applyRotationDraft, restoreRotationDraft } from "./rotationDraft";
import {
applyStudioBoxSize,
@@ -443,7 +443,8 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
// Hold the final angle while the commit lands.
applyRotationDraft(sel.element, finalRotation.angle, g.plainRotation);
const commit = { ...finalRotation, plain: g.plainRotation };
void Promise.resolve(opts.onRotationCommitRef.current(sel, commit))
const edit = beginStudioPendingEdit(elementLookRevert(sel.element, g.initialLook));
const saved = Promise.resolve(edit.adopt(() => opts.onRotationCommitRef.current(sel, commit)))
.then((result) => trackPreviewEditResult("rotate", "drag", result))
.catch((error) => {
logGestureCommitFailure("rotate commit failed", error);
@@ -454,6 +455,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
restoreRotation();
})
.finally(() => endStudioManualEditGesture(sel.element, g.manualEditDragToken));
edit.settle(saved);
} else if (g.kind === "drag") {
// A moved drag (taps returned earlier) must not let the release click
// re-select whatever now sits under the pointer — dropping over a
@@ -535,9 +537,10 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
restoreStudioBoxSize(sel.element, g.initialBoxSize);
if (finalOffset) restoreStudioPathOffset(sel.element, g.initialPathOffset);
};
void Promise.resolve(
opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore, member),
)
const commitSize = () =>
opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore, member);
const edit = beginStudioPendingEdit(elementLookRevert(sel.element, g.initialLook));
const saved = Promise.resolve(edit.adopt(commitSize))
.then((result) => trackPreviewEditResult("resize", "drag", result))
.catch((error) => {
logGestureCommitFailure("resize commit failed", error);
@@ -546,6 +549,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
if (member) endManualOffsetDragMembers([member]);
else endStudioManualEditGesture(sel.element, g.manualEditDragToken);
});
edit.settle(saved);
logResizeSettle(sel.element, "post-release");
}
};
@@ -281,6 +281,66 @@ describe("runGestureTransaction", () => {
log.mockRestore();
});
it("measures a box undo painted back mid-save as the edit drew it, so it reports no change", async () => {
const element = document.createElement("div");
let undone = false;
vi.spyOn(element, "getBoundingClientRect").mockImplementation(() =>
undone ? rect(0, 0, 50, 40) : rect(10, 20, 100, 80),
);
const draw = <T>(run: () => T): T => {
undone = false;
try {
return run();
} finally {
undone = true;
}
};
await runGestureTransaction({
element,
label: "Resize layer",
settle: vi.fn(),
persist: async () => void (undone = true),
restore: vi.fn(),
draw,
});
expect(trackStudioEventMock).not.toHaveBeenCalledWith(
"commit_invariant_violation",
expect.anything(),
);
});
it("measures as the edit drew it when undo painted the box back before the transaction began", async () => {
const element = document.createElement("div");
let undone = true;
vi.spyOn(element, "getBoundingClientRect").mockImplementation(() =>
undone ? rect(0, 0, 50, 40) : rect(10, 20, 100, 80),
);
const draw = <T>(run: () => T): T => {
undone = false;
try {
return run();
} finally {
undone = true;
}
};
await runGestureTransaction({
element,
label: "Resize layer",
settle: vi.fn(),
persist: async () => undefined,
restore: vi.fn(),
draw,
});
expect(trackStudioEventMock).not.toHaveBeenCalledWith(
"commit_invariant_violation",
expect.anything(),
);
});
it("skips the pixel assertion for live position tweens", async () => {
const element = document.createElement("div");
const getRect = vi.spyOn(element, "getBoundingClientRect");
@@ -18,6 +18,7 @@ export interface GestureTransaction {
afterBufferedCommitsSaved?(): Promise<void>;
restore(): void;
skipPixelAssert?: boolean;
draw?<T>(run: () => T): T;
}
let transactionCounter = 0;
@@ -126,7 +127,9 @@ export function runGestureTransaction(tx: GestureTransaction): Promise<void> {
tx.settle();
logCommit("settled", { label: tx.label, coalesceKey });
const before = !tx.skipPixelAssert ? readPixelRect(tx.element) : null;
const measure = () =>
tx.draw ? tx.draw(() => readPixelRect(tx.element)) : readPixelRect(tx.element);
const before = !tx.skipPixelAssert ? measure() : null;
const commit: TxCommit = (commitMutation) => {
const wrapped: CommitMutation = (selection, mutation, options) => {
mutationCount += 1;
@@ -150,7 +153,7 @@ export function runGestureTransaction(tx: GestureTransaction): Promise<void> {
const durationMs = Math.round(performance.now() - startedAt);
logCommit("persisted", { label: tx.label, coalesceKey });
if (before) {
const after = readPixelRect(tx.element);
const after = measure();
const delta = pixelDelta(before, after);
if (exceedsPixelTolerance(delta)) {
logCommit("persist-changed-pixels", { label: tx.label, before, after, delta });
@@ -12,8 +12,11 @@ import {
type KeyframeUsageAction,
} from "../utils/keyframeUsage";
import type { GeometryCommitResult } from "../utils/previewFeatureUsage";
import { adoptingStudioPendingEdit } from "../utils/studioPendingEdits";
export function observeGsapGesture(writer: CommitMutation | null) {
const edit = adoptingStudioPendingEdit();
const join = <T>(run: () => T): T => (edit ? edit.within(run) : run());
let changed = false;
let pendingResults = 0;
const actions = new Set<KeyframeUsageAction>();
@@ -21,12 +24,14 @@ export function observeGsapGesture(writer: CommitMutation | null) {
pendingResults += 1;
return {
...options,
...(edit && { pendingEdit: edit }),
keyframeTelemetry: false,
onResult: (result: MutationResult) => {
pendingResults -= 1;
options.onResult?.(result);
if (!result.ok || result.changed !== true) return;
changed = true;
edit?.markSaved();
const members = changedMutationIndices(
result,
calls.length,
@@ -43,16 +48,18 @@ export function observeGsapGesture(writer: CommitMutation | null) {
let commit: CommitMutation | null = null;
if (writer) {
commit = (selection, mutation, options) =>
writer(selection, mutation, observe([{ selection, mutation, options }], options));
join(() => writer(selection, mutation, observe([{ selection, mutation, options }], options)));
if (writer.batch) {
const batch = writer.batch;
commit.batch = (calls, options) => batch(calls, observe(calls, options));
commit.batch = (calls, options) => join(() => batch(calls, observe(calls, options)));
}
}
return {
commit,
drawKeepingUndone: <T>(draw: () => T): T => (edit ? edit.drawKeepingUndone(draw) : draw()),
recordDomResult: (result: { changed: boolean } | undefined) => {
changed ||= result?.changed === true;
if (result?.changed) edit?.markSaved();
},
finish: (domChanged = false): GeometryCommitResult => {
if (pendingResults !== 0) return { ok: true, changed: false };
@@ -75,6 +75,8 @@ interface ResizeCase {
longhandTween?: boolean;
/** What already writes this element's position. */
positionWrite?: "static-set" | "keyframed-tween" | "none";
/** Undo paints the box back to its press look while the animations load. */
paintedBackWhileLoading?: boolean;
}
function createResizeElement(testCase: ResizeCase): HTMLElement {
@@ -241,6 +243,30 @@ async function runCase(testCase: ResizeCase) {
} as unknown as HTMLIFrameElement;
const dropPoint = el.getBoundingClientRect();
// A painted-back edit: undone on screen, shown again only inside a draw (as `drawKeepingUndone` does).
const look = () => ({
w: el.style.width,
h: el.style.height,
pos: { ...live.pos },
scale: { ...live.scale },
});
const show = (shown: ReturnType<typeof look>) => {
el.style.width = shown.w;
el.style.height = shown.h;
Object.assign(live, { pos: { ...shown.pos }, scale: { ...shown.scale } });
};
let shownEdit: ReturnType<typeof look> | null = null;
const paintBack = () => {
shownEdit = look();
show({ w: "", h: "", pos: { ...testCase.base }, scale: { ...testCase.liveScale } });
};
const draw = <T>(run: () => T): T => {
if (!shownEdit) return run();
show(shownEdit);
const drawn = run();
paintBack();
return drawn;
};
const position = positionAnimation(testCase.positionWrite ?? "static-set", testCase.base);
const animations = [scaleTween(!!testCase.longhandTween), ...(position ? [position] : [])];
const commitMutation = vi.fn();
@@ -252,7 +278,12 @@ async function runCase(testCase: ResizeCase) {
animations,
iframe,
commitMutation,
async () => animations,
async () => {
if (testCase.paintedBackWhileLoading) paintBack();
return animations;
},
undefined,
draw,
);
// Re-render what the file now says: the committed scale, the persisted
@@ -393,3 +424,10 @@ it("holds the drop point across a second drag", async () => {
expect(second.settled.y).toBeCloseTo(second.dropPoint.y, 0);
expect(second.settled.w).toBeCloseTo(second.dropPoint.w, 0);
});
it("lands on the drop point when undo paints the box back while its animations load", async () => {
const { dropPoint, settled } = await runCase({ ...CASES[0]!, paintedBackWhileLoading: true });
expect(settled.x).toBeCloseTo(dropPoint.x, 0);
expect(settled.y).toBeCloseTo(dropPoint.y, 0);
expect(settled.w).toBeCloseTo(dropPoint.w, 0);
});
@@ -5,6 +5,10 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { applyStudioBoxSizeDraft } from "../components/editor/manualEdits";
import {
beginStudioPendingEdit,
paintBackNewestStudioPendingEdit,
} from "../utils/studioPendingEdits";
import { mountReactHarness } from "./domSelectionTestHarness";
import { useDomGeometryCommits } from "./useDomGeometryCommits";
import { useGsapAwareEditing } from "./useGsapAwareEditing";
@@ -134,4 +138,27 @@ describe("resizing an element GSAP positions", () => {
expect(cssSize).toEqual([]);
act(() => h.root.unmount());
});
it("keeps a resize undo painted back drawn undone while its save lands, and draws it again on show again", async () => {
const h = mount([positionHold]);
const element = h.selection.element;
const edit = beginStudioPendingEdit(() => {
const shown = element.getAttribute("style");
element.setAttribute("style", "scale: none");
return () => element.setAttribute("style", shown ?? "");
});
const saved = edit.adopt(() => h.resize(h.selection, h.size, { x: -50, y: -33.5 }));
edit.settle(saved);
const shown = paintBackNewestStudioPendingEdit();
await act(() => saved);
expect(h.commitPatch.mock.calls.at(-1)![1]).toEqual(
expect.arrayContaining([{ type: "inline-style", property: "width", value: "340px" }]),
);
expect(element.getAttribute("style")).toBe("scale: none");
shown!.showAgain();
expect(element.getAttribute("style")).toContain("--hf-studio-width: 340px");
act(() => h.root.unmount());
});
});
@@ -135,6 +135,7 @@ export async function tryGsapResizeIntercept(
commitMutation: GsapDragCommitCallbacks["commitMutation"],
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
dragOffset?: { x: number; y: number },
draw: <T>(run: () => T) => T = (run) => run(),
): Promise<GsapEditOutcome> {
const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
const outcome = preflightGsapResizeIntercept(selection, animations, iframe, fetchedAnimations);
@@ -315,7 +316,7 @@ export async function tryGsapResizeIntercept(
// difference and compensates, so release matches the drop pixel-for-pixel
// regardless of live scale or repeat resizes.
if (el) {
const dropRect = el.getBoundingClientRect();
const dropRect = draw(() => el.getBoundingClientRect());
scaleDraftDropPoint = { x: dropRect.x, y: dropRect.y };
}
} else {
@@ -342,75 +343,61 @@ export async function tryGsapResizeIntercept(
// resize took the size route and never moved anything: the drop point is
// the drag's to settle, not ours.
if (!scaleDraftEl) return false;
clearStudioBoxSize(scaleDraftEl);
if (!scaleDraftDropPoint || !selector) return false;
// Put the committed scale on the live element before measuring.
//
// This step reads where the commit lands the box and shifts the position
// hold by the difference. That only works if the commit has actually
// rendered, and whether it had was luck: on the FIRST resize of an element
// the timeline had not re-seeked yet, so this measured the element at its
// natural size, still sitting on the drop point, computed a residual of
// zero, and skipped the correction entirely. The scale then landed, GSAP
// rendered it around the element's centre, and the element jumped by the
// whole drag distance. Elements that had been resized before got a
// correction only because their PREVIOUS scale made the residual non-zero.
//
// Setting it here costs nothing when the commit has already rendered (same
// value) and makes the measurement below mean what it says either way.
if (committedScale) {
setElementGsapScale(scaleDraftEl, committedScale.x, committedScale.y);
const draftEl = scaleDraftEl;
if (!scaleDraftDropPoint || !selector) {
draw(() => clearStudioBoxSize(draftEl));
return false;
}
// Measure from the pre-gesture position, not the draft one.
//
// The resize draft translates the element to keep the dragged corner under
// the cursor, but the scale route never persists that translation — the
// element renders back at its pre-gesture position as soon as the commit
// lands. Measuring while the draft translation was still applied made the
// residual carry the whole drag distance, and the position commit then
// composed that residual onto the pre-gesture base (it reads the gesture's
// own base attributes, not the live value), so the element landed a full
// drag away from the drop point on every scale resize.
const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
const { baseGsapX, baseGsapY } = computeDraggedGsapPosition(
selection.element,
{ x: 0, y: 0 },
gsapPos,
);
const base = { x: baseGsapX, y: baseGsapY };
setElementGsapPosition(scaleDraftEl, base.x, base.y);
const post = scaleDraftEl.getBoundingClientRect();
const residual = { x: scaleDraftDropPoint.x - post.x, y: scaleDraftDropPoint.y - post.y };
if (!Number.isFinite(residual.x) || !Number.isFinite(residual.y)) return false;
if (Math.abs(residual.x) < 0.5 && Math.abs(residual.y) < 0.5) {
logResize("scale-finalize", { skipped: "already-on-drop-point", residual, base });
// Settled, with nothing to write. Still ours: forwarding the drag offset
// on top would move the box off the point it is already sitting on.
return true;
}
// The ONE corrected position — rounded once so the live runtime and the
// persisted file agree exactly (commitStaticGsapPosition composes the same
// rounded value from this delta).
const corrected = {
x: roundTo3(base.x + residual.x),
y: roundTo3(base.y + residual.y),
};
logResize("scale-finalize", {
dropPoint: scaleDraftDropPoint,
post: { x: post.x, y: post.y },
residual,
gsapPos,
base,
corrected,
const dropPoint = scaleDraftDropPoint;
const measured = draw(() => {
clearStudioBoxSize(draftEl);
// Draw the committed scale before measuring: on a first resize the timeline has not re-seeked yet,
// so the box would read at its natural size and the correction would be skipped.
if (committedScale) {
setElementGsapScale(draftEl, committedScale.x, committedScale.y);
}
// Measure from the pre-gesture position: the scale route never saves the draft's translation, and
// the position write composes the residual onto that same base.
const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
const { baseGsapX, baseGsapY } = computeDraggedGsapPosition(
selection.element,
{ x: 0, y: 0 },
gsapPos,
);
const base = { x: baseGsapX, y: baseGsapY };
setElementGsapPosition(draftEl, base.x, base.y);
const post = draftEl.getBoundingClientRect();
const residual = { x: dropPoint.x - post.x, y: dropPoint.y - post.y };
if (!Number.isFinite(residual.x) || !Number.isFinite(residual.y)) return null;
if (Math.abs(residual.x) < 0.5 && Math.abs(residual.y) < 0.5) {
logResize("scale-finalize", { skipped: "already-on-drop-point", residual, base });
// Settled, with nothing to write. Still ours: forwarding the drag offset
// on top would move the box off the point it is already sitting on.
return "settled" as const;
}
// The ONE corrected position — rounded once so the live runtime and the
// persisted file agree exactly (commitStaticGsapPosition composes the same
// rounded value from this delta).
const corrected = {
x: roundTo3(base.x + residual.x),
y: roundTo3(base.y + residual.y),
};
logResize("scale-finalize", {
dropPoint,
post: { x: post.x, y: post.y },
residual,
gsapPos,
base,
corrected,
});
// Correct the live box in the same task as the measurement, so no frame shows it off the drop
// point while the position write is in flight.
setElementGsapPosition(draftEl, corrected.x, corrected.y);
return { base, corrected };
});
// Correct the LIVE runtime NOW, synchronously: the soft reload above just
// rendered the committed scale around the element center — NOT at the drop
// point — and everything up to here runs in the same microtask chain as
// that reload, so no frame has painted the uncorrected position yet. The
// server persist below costs network round-trips; without this set, the
// element visibly sits at the wrong spot for those frames (the drop
// "jump"). The persisted commit re-applies the same values (idempotent).
setElementGsapPosition(scaleDraftEl, corrected.x, corrected.y);
if (measured === null) return false;
if (measured === "settled") return true;
const { base, corrected } = measured;
// Re-fetch: the scale commit above just rewrote the script, so the caller's
// animation list (and its ids) may be stale for the position lookup.
const currentAnimations = fetchFallbackAnimations
@@ -2,6 +2,7 @@ import type { ParsedGsap } from "@hyperframes/core/gsap-parser";
import type { Composition } from "@hyperframes/sdk";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import type { PublishSdkSession } from "../utils/sdkCutover";
import type { StudioEditInFlight } from "../utils/studioPendingEdits";
import type { RuntimeTweenChange } from "./gsapRuntimePatch";
import type { KeyframeUsageAction } from "../utils/keyframeUsage";
@@ -47,6 +48,7 @@ export interface CommitMutationOptions {
/** Carries a deferred write not yet on screen (no patch, or a missed one) to the final render. */
previewFallbackLatch?: { pending: boolean };
beforeReload?: () => void;
pendingEdit?: StudioEditInFlight;
/**
* Serialize this commit against others sharing the same key. Used to chain
* per-animationId GSAP meta updates. Every commit independently takes the
@@ -6,6 +6,10 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import type { DomEditGroupPathOffsetCommit } from "../components/editor/domEditOverlayGestures";
import { usePlayerStore } from "../player/store/playerStore";
import {
beginStudioPendingEdit,
paintBackNewestStudioPendingEdit,
} from "../utils/studioPendingEdits";
import { trackStudioEditBlocked } from "../utils/studioSaveDiagnostics";
import { GSAP_EDIT_BLOCK_COPY } from "./gsapEditOutcome";
import { mountGsapAwareEditing } from "./useGsapAwareEditing.testHelpers";
@@ -65,7 +69,10 @@ function mountGroup(animations: GsapAnimation[]) {
batch: vi.fn().mockResolvedValue(undefined),
});
const showToast = vi.fn();
const stageElementPositionOffset = vi.fn(() => ({ save: vi.fn(), rollback: vi.fn() }));
const stageElementPositionOffset = vi.fn((selection: DomEditSelection) => {
selection.element.style.setProperty("translate", "30px 0px");
return { save: vi.fn().mockResolvedValue(undefined), rollback: vi.fn() };
});
const { editing, root } = mountGsapAwareEditing({
gsapCommitMutation: commitMutation,
activeCompPath: "index.html",
@@ -158,3 +165,24 @@ describe("a group drag plans every member before its first write", () => {
act(() => h.root.unmount());
});
});
it("keeps a member it moves on its own CSS undone when undo painted the group back", async () => {
const h = mountGroup(["b", "c"].map((id) => positionTween(id)));
const plain = h.elements[0]!;
const edit = beginStudioPendingEdit(() => {
const shown = plain.getAttribute("style") ?? "";
plain.setAttribute("style", "");
return () => plain.setAttribute("style", shown);
});
const saved = edit.adopt(() =>
h.groupCommit(h.updates.map((u, i) => (i === 0 ? { ...u, plainTranslate: true } : u))),
);
edit.settle(saved);
const shown = paintBackNewestStudioPendingEdit();
await act(() => saved);
expect(plain.getAttribute("style")).toBe("");
shown!.showAgain();
expect(plain.getAttribute("style")).toContain("translate: 30px 0px");
act(() => h.root.unmount());
});
@@ -423,6 +423,7 @@ describe("useGsapAwareEditing anchored resize", () => {
expect.any(Function),
expect.any(Function),
undefined,
expect.any(Function),
);
act(() => h.root.unmount());
});
@@ -169,8 +169,10 @@ export function useGsapAwareEditing({
modifiers,
);
await saveMove(outcome, async () => {
const result = await stageElementPositionOffset(selection, next, false).save();
writes.recordDomResult(result);
const staged = writes.drawKeepingUndone(() =>
stageElementPositionOffset(selection, next, false),
);
writes.recordDomResult(await staged.save());
});
return writes.finish();
} catch (error) {
@@ -258,11 +260,8 @@ export function useGsapAwareEditing({
// Saved after the size, under its undo key, so the two are one step.
await saveMove(dragOutcome, async () => {
const plainAfterSettle = editsPlainCss(selection.element, "move");
anchorMove = stageElementPositionOffset(
selection,
offset,
plainAfterSettle,
coalesceKey,
anchorMove = writes.drawKeepingUndone(() =>
stageElementPositionOffset(selection, offset, plainAfterSettle, coalesceKey),
);
});
}
@@ -270,17 +269,20 @@ export function useGsapAwareEditing({
await runGestureTransaction({
element: selection.element,
label: "Resize layer",
draw: writes.drawKeepingUndone,
settle: () => {
// Scale resize settles its center-scale residual after the scale commit
// renders. Width/height can settle its anchored position immediately.
if (!offset || scaleRoute || !selector) return;
const gsapPos = readGsapPositionFromIframe(previewIframeRef.current, selector) ?? {
x: 0,
y: 0,
};
const { newX, newY } = computeDraggedGsapPosition(selection.element, offset, gsapPos);
logResize("sync-settle", { gsapPos, offset, newX, newY });
setElementGsapPosition(selection.element, newX, newY);
writes.drawKeepingUndone(() => {
const gsapPos = readGsapPositionFromIframe(previewIframeRef.current, selector) ?? {
x: 0,
y: 0,
};
const { newX, newY } = computeDraggedGsapPosition(selection.element, offset, gsapPos);
logResize("sync-settle", { gsapPos, offset, newX, newY });
setElementGsapPosition(selection.element, newX, newY);
});
},
persist: async (commit, coalesceKey) => {
if (writes.commit) {
@@ -294,16 +296,13 @@ export function useGsapAwareEditing({
commitMutation,
makeFetchFallback(selection),
offset,
writes.drawKeepingUndone,
);
assertGsapEditPersisted(outcome);
// Saved before the buffered GSAP writes, so their reload stays the gesture's last render.
if (outcome.status === "element-size") {
const result = await handleDomBoxSizeCommit(
selection,
next,
undefined,
undefined,
coalesceKey,
const result = await writes.drawKeepingUndone(() =>
handleDomBoxSizeCommit(selection, next, undefined, undefined, coalesceKey),
);
writes.recordDomResult(result);
} else cropUndoKey = coalesceKey;
@@ -332,11 +331,8 @@ export function useGsapAwareEditing({
writes.recordDomResult(anchorResult);
// Only now is the size live for every caller, drag or not.
if (cropUndoKey) {
const cropResult = await saveCropResize(
stageCrop,
selection,
commitPositionPatchToHtml,
cropUndoKey,
const cropResult = await writes.drawKeepingUndone(() =>
saveCropResize(stageCrop, selection, commitPositionPatchToHtml, cropUndoKey!),
);
writes.recordDomResult(cropResult);
}
@@ -123,12 +123,10 @@ export function useGsapAwareGroupMove({
renderOnCommit = index === lastScriptWrite;
const plain = offsetMembers.get(selection);
if (plain !== undefined) {
const result = await stageElementPositionOffset(
selection,
next,
plain,
coalesceKey,
).save();
const staged = writes.drawKeepingUndone(() =>
stageElementPositionOffset(selection, next, plain, coalesceKey),
);
const result = await staged.save();
writes.recordDomResult(result);
continue;
}
@@ -38,7 +38,12 @@ import type {
import { persistSdkSerialize } from "../utils/sdkCutover";
import { jsonResponse } from "./fetchStubTestUtils";
import { applyPreviewSync, useGsapScriptCommits } from "./useGsapScriptCommits";
import { hasStudioPendingEdits } from "../utils/studioPendingEdits";
import {
beginStudioPendingEdit,
hasStudioPendingEdits,
paintBackNewestStudioPendingEdit,
} from "../utils/studioPendingEdits";
import { observeGsapGesture } from "./gsapGestureOutcome";
// ── applyPreviewSync (pure preview-sync decision) ────────────────────────────
@@ -490,6 +495,42 @@ describe("a GSAP script commit", () => {
await act(async () => void (await committed));
expect(hasStudioPendingEdits()).toBe(false);
});
it("a gesture's write after an await is still its edit, and once undo paints that edit back it saves without drawing it", async () => {
applySoftReload.mockReturnValue("applied");
let respond!: () => void;
const body: MutationResult = { ok: true, changed: true, after: "AFTER", scriptText: "SCRIPT" };
vi.stubGlobal(
"fetch",
vi.fn(
() =>
new Promise((resolve) => {
respond = () => resolve({ ok: true, json: async () => body } as unknown as Response);
}),
),
);
const deps = renderCommitHook();
const edit = beginStudioPendingEdit(() => () => undefined);
const saved = edit.adopt(async () => {
const writes = observeGsapGesture(deps.api.commitMutation);
await Promise.resolve();
return writes.commit!(selection, { x: 10 }, { label: "Move layer", softReload: true });
});
edit.settle(saved);
await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
const shown = paintBackNewestStudioPendingEdit();
expect(shown).not.toBeNull();
await act(async () => {
respond();
await saved;
});
expect(deps.onFileContentChanged).toHaveBeenCalledWith("index.html", "AFTER");
expect(applySoftReload).not.toHaveBeenCalled();
shown!.showAgain();
expect(applySoftReload).toHaveBeenCalledTimes(1);
});
});
describe("runCommit — instantPatch wiring", () => {
@@ -113,11 +113,18 @@ function finishUnchangedMutation(
!options.skipReload &&
(instantPatchesFor(options).length > 0 || options.previewFallbackLatch?.pending)
) {
applyPreviewSync(iframe, result, options, reloadPreview);
redrawUnlessPaintedBack(options, () =>
applyPreviewSync(iframe, result, options, reloadPreview),
);
}
return true;
}
function redrawUnlessPaintedBack(options: CommitMutationOptions, redraw: () => void): void {
if (options.pendingEdit) options.pendingEdit.drawUnlessUndone(redraw);
else redraw();
}
function refreshMutationPreview(
iframe: HTMLIFrameElement | null,
result: MutationResult,
@@ -126,8 +133,10 @@ function refreshMutationPreview(
onCacheInvalidate: () => void,
nestedFiles?: Map<string, string> | null,
): void {
options.beforeReload?.();
applyPreviewSync(iframe, result, options, reloadPreview, nestedFiles);
redrawUnlessPaintedBack(options, () => {
options.beforeReload?.();
applyPreviewSync(iframe, result, options, reloadPreview, nestedFiles);
});
onCacheInvalidate();
}
@@ -2,6 +2,7 @@
import { describe, expect, it, vi } from "vitest";
import {
addStudioPendingEditFlushListener,
adoptingStudioPendingEdit,
beginStudioPendingEdit,
flushStudioPendingEdits,
hasStudioPendingEdits,
@@ -312,3 +313,54 @@ describe("a drain of only the current edits", () => {
landLater();
});
});
describe("an edit undo painted back while it saves", () => {
function paintedBackEdit() {
const box = { look: "edited" };
const edit = beginStudioPendingEdit(() => {
const shown = box.look;
box.look = "start";
return () => (box.look = shown);
});
let landSave!: () => void;
const inFlight = edit.adopt(() => adoptingStudioPendingEdit())!;
edit.settle(new Promise<void>((resolve) => (landSave = resolve)));
const shown = paintBackNewestStudioPendingEdit()!;
return { box, inFlight, shown, landSave };
}
it("draws on the edit as shown, then shows it undone again in the same task", () => {
const { box, inFlight, shown, landSave } = paintedBackEdit();
let drawnOver = "";
inFlight.drawKeepingUndone(() => {
drawnOver = box.look;
box.look += "+size";
});
expect(drawnOver).toBe("edited");
expect(box.look).toBe("start");
shown.showAgain();
expect(box.look).toBe("edited+size");
landSave();
});
it("keeps a redraw until the edit is shown again", () => {
const { inFlight, shown, landSave } = paintedBackEdit();
const redraw = vi.fn();
inFlight.drawUnlessUndone(redraw);
expect(redraw).not.toHaveBeenCalled();
shown.showAgain();
expect(redraw).toHaveBeenCalledTimes(1);
landSave();
});
});
it("counts an edit whose later write failed as saved once an earlier write landed, so undo still steps it", async () => {
const edit = beginStudioPendingEdit(() => () => undefined);
const committed = edit.adopt(async () => {
adoptingStudioPendingEdit()!.markSaved();
throw new Error("the crop save failed");
});
edit.settle(committed.catch(() => undefined));
const shown = paintBackNewestStudioPendingEdit()!;
await expect(shown.landed()).resolves.toBe(true);
});
+55 -10
View File
@@ -14,11 +14,25 @@ export type StudioEditRevert = () => () => void;
interface PendingEdit {
revert: StudioEditRevert | null;
landed: () => Promise<boolean>;
redraws: Array<() => void>;
showAgain: (() => void) | null;
}
export interface StudioEditInFlight {
reverted: () => boolean;
within: <T>(run: () => T) => T;
drawUnlessUndone: (draw: () => void) => void;
drawKeepingUndone: <T>(draw: () => T) => T;
markSaved: () => void;
}
const pendingEdits = new Map<Promise<unknown>, PendingEdit>();
const NOT_SAVED = () => Promise.resolve(false);
let adopting = false;
let adopting: StudioEditInFlight | null = null;
export function adoptingStudioPendingEdit(): StudioEditInFlight | null {
return adopting;
}
function waitForPostBlurEffects(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
@@ -73,7 +87,7 @@ export function trackStudioPendingEdit(
if (!result) return undefined;
const promise = Promise.resolve(result);
if (adopting) return promise;
pendingEdits.set(promise, { revert: null, landed: NOT_SAVED });
pendingEdits.set(promise, { revert: null, landed: NOT_SAVED, redraws: [], showAgain: null });
promise.then(
() => pendingEdits.delete(promise),
() => pendingEdits.delete(promise),
@@ -97,25 +111,49 @@ export function beginStudioPendingEdit(revert: StudioEditRevert | null) {
const entry = pendingEdits.get(promise)!;
entry.revert = revert;
let landed = Promise.resolve(false);
let saved = false;
entry.landed = () => landed;
const inFlight: StudioEditInFlight = {
reverted: () => entry.revert === null && revert !== null,
within(run) {
const outer = adopting;
adopting = inFlight;
try {
return run();
} finally {
adopting = outer;
}
},
drawUnlessUndone(draw) {
if (inFlight.reverted()) entry.redraws.push(draw);
else draw();
},
drawKeepingUndone(draw) {
if (!inFlight.reverted()) return draw();
entry.showAgain?.();
try {
return draw();
} finally {
entry.showAgain = revert!();
}
},
markSaved: () => void (saved = true),
};
return {
settle,
reverted: () => entry.revert === null && revert !== null,
// Only what `start` registers synchronously is adopted; a registration after an await is a newer edit.
reverted: inFlight.reverted,
// Only what `start` registers synchronously is adopted; a later write joins only through `within`.
adopt<T>(start: () => T): T {
adopting = true;
try {
const committed = start();
const committed = inFlight.within(start);
landed = Promise.resolve(committed).then(
() => true,
() => false,
() => saved,
);
return committed;
} catch (error) {
settle();
throw error;
} finally {
adopting = false;
}
},
};
@@ -129,7 +167,14 @@ export function paintBackNewestStudioPendingEdit(): {
const revert = newest?.revert;
if (!newest || !revert) return null;
newest.revert = null;
return { showAgain: revert(), landed: newest.landed };
newest.showAgain = revert();
return {
showAgain: () => {
newest.showAgain?.();
for (const redraw of newest.redraws.splice(0)) redraw();
},
landed: newest.landed,
};
}
export function revertNewestStudioPendingEdit(): (() => void) | null {