mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
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:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user