From 94ec9ac9f66158cfc70802714bdcae87bab53bcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 4 Oct 2026 12:50:16 -0400 Subject: [PATCH 01/10] fix(studio): undo shows a resize, rotate or GSAP edit undone at once while it saves --- .../editor/domEditOverlayGestures.ts | 2 + .../editor/domEditOverlayStartGesture.ts | 6 + .../components/editor/dragUndoPaint.test.ts | 123 +++++++++++++++ .../components/editor/gestureUndoRevert.ts | 80 ++++++++++ .../src/components/editor/manualOffsetDrag.ts | 11 -- .../src/components/editor/useDomEditNudge.ts | 2 +- .../editor/useDomEditOverlayGestures.ts | 14 +- .../studio/src/hooks/gsapGestureOutcome.ts | 9 +- .../src/hooks/gsapResizeElementSize.test.tsx | 27 ++++ .../studio/src/hooks/gsapResizeIntercept.ts | 146 ++++++++++-------- .../studio/src/hooks/gsapScriptCommitTypes.ts | 2 + .../studio/src/hooks/useGsapAwareEditing.ts | 31 ++-- .../src/hooks/useGsapScriptCommits.test.tsx | 43 +++++- .../studio/src/hooks/useGsapScriptCommits.ts | 15 +- .../studio/src/utils/studioPendingEdits.ts | 56 ++++++- 15 files changed, 450 insertions(+), 117 deletions(-) create mode 100644 packages/studio/src/components/editor/gestureUndoRevert.ts diff --git a/packages/studio/src/components/editor/domEditOverlayGestures.ts b/packages/studio/src/components/editor/domEditOverlayGestures.ts index d5ea88acaff..fd3f0a2ce6a 100644 --- a/packages/studio/src/components/editor/domEditOverlayGestures.ts +++ b/packages/studio/src/components/editor/domEditOverlayGestures.ts @@ -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; diff --git a/packages/studio/src/components/editor/domEditOverlayStartGesture.ts b/packages/studio/src/components/editor/domEditOverlayStartGesture.ts index f04f8cdb290..fc4d76df610 100644 --- a/packages/studio/src/components/editor/domEditOverlayStartGesture.ts +++ b/packages/studio/src/components/editor/domEditOverlayStartGesture.ts @@ -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, diff --git a/packages/studio/src/components/editor/dragUndoPaint.test.ts b/packages/studio/src/components/editor/dragUndoPaint.test.ts index 66693624e39..f856a0e9149 100644 --- a/packages/studio/src/components/editor/dragUndoPaint.test.ts +++ b/packages/studio/src/components/editor/dragUndoPaint.test.ts @@ -1,5 +1,6 @@ // @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"; @@ -8,8 +9,30 @@ import { paintBackNewestStudioPendingEdit, } from "../../utils/studioPendingEdits"; +const gsapOwns = vi.hoisted(() => ({ on: false })); +vi.mock("../../hooks/gsapRuntimeKeyframes", async (importOriginal) => ({ + ...(await importOriginal()), + editsPlainCss: () => !gsapOwns.on, +})); + +/** GSAP's own transform values per element, as `gsap.set` / `gsap.getProperty` see them. */ +function fakeGsap(start: Record) { + const values = new Map>(); + 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) => 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 = (current: T) => ({ current }); @@ -69,3 +92,103 @@ 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) { + 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(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((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((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((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)); +}); diff --git a/packages/studio/src/components/editor/gestureUndoRevert.ts b/packages/studio/src/components/editor/gestureUndoRevert.ts new file mode 100644 index 00000000000..25fe343a467 --- /dev/null +++ b/packages/studio/src/components/editor/gestureUndoRevert.ts @@ -0,0 +1,80 @@ +import { + captureStudioPathOffset, + restoreStudioPathOffset, + type StudioPathOffsetSnapshot, +} from "./manualEdits"; +import { getOffsetDragGsap, type ManualOffsetDragMember } from "./manualOffsetDrag"; +import type { StudioEditRevert } from "../../utils/studioPendingEdits"; + +interface MemberPosition { + offset: StudioPathOffsetSnapshot; + gsap: { x: number; y: number } | null; +} + +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")), + }, + }; +} + +function showMemberPosition(member: ManualOffsetDragMember, position: MemberPosition): void { + restoreStudioPathOffset(member.element, position.offset); + if (position.gsap) getOffsetDragGsap(member.element)?.set(member.element, { ...position.gsap }); +} + +/** Undo's live revert of a move: its members at gesture start. */ +export function manualOffsetMoveRevert(members: ManualOffsetDragMember[]): StudioEditRevert { + return () => { + const shown = members.map(readMemberPosition); + for (const member of members) { + showMemberPosition(member, { + offset: member.initialPathOffset, + gsap: member.plainTranslate ? null : member.baseGsap, + }); + } + return () => members.forEach((member, i) => showMemberPosition(member, shown[i]!)); + }; +} + +export interface StudioElementLook { + style: string | null; + gsap: Record | 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); + }; +} diff --git a/packages/studio/src/components/editor/manualOffsetDrag.ts b/packages/studio/src/components/editor/manualOffsetDrag.ts index 1e37f50fe4b..bb610a0f88a 100644 --- a/packages/studio/src/components/editor/manualOffsetDrag.ts +++ b/packages/studio/src/components/editor/manualOffsetDrag.ts @@ -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) => void; @@ -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) { diff --git a/packages/studio/src/components/editor/useDomEditNudge.ts b/packages/studio/src/components/editor/useDomEditNudge.ts index ff09f0bff8b..fc36637d665 100644 --- a/packages/studio/src/components/editor/useDomEditNudge.ts +++ b/packages/studio/src/components/editor/useDomEditNudge.ts @@ -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, diff --git a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts index 70a72a1bf6b..1477126f5d3 100644 --- a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts +++ b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts @@ -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"); } }; diff --git a/packages/studio/src/hooks/gsapGestureOutcome.ts b/packages/studio/src/hooks/gsapGestureOutcome.ts index 9016e10434e..d9a18bb3cad 100644 --- a/packages/studio/src/hooks/gsapGestureOutcome.ts +++ b/packages/studio/src/hooks/gsapGestureOutcome.ts @@ -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 = (run: () => T): T => (edit ? edit.within(run) : run()); let changed = false; let pendingResults = 0; const actions = new Set(); @@ -21,6 +24,7 @@ export function observeGsapGesture(writer: CommitMutation | null) { pendingResults += 1; return { ...options, + ...(edit && { pendingEdit: edit }), keyframeTelemetry: false, onResult: (result: MutationResult) => { pendingResults -= 1; @@ -43,14 +47,15 @@ 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: (draw: () => T): T => (edit ? edit.drawKeepingUndone(draw) : draw()), recordDomResult: (result: { changed: boolean } | undefined) => { changed ||= result?.changed === true; }, diff --git a/packages/studio/src/hooks/gsapResizeElementSize.test.tsx b/packages/studio/src/hooks/gsapResizeElementSize.test.tsx index 712f3ac876b..c54618536ed 100644 --- a/packages/studio/src/hooks/gsapResizeElementSize.test.tsx +++ b/packages/studio/src/hooks/gsapResizeElementSize.test.tsx @@ -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()); + }); }); diff --git a/packages/studio/src/hooks/gsapResizeIntercept.ts b/packages/studio/src/hooks/gsapResizeIntercept.ts index 8795a379573..9396694d1ff 100644 --- a/packages/studio/src/hooks/gsapResizeIntercept.ts +++ b/packages/studio/src/hooks/gsapResizeIntercept.ts @@ -135,6 +135,7 @@ export async function tryGsapResizeIntercept( commitMutation: GsapDragCommitCallbacks["commitMutation"], fetchFallbackAnimations?: () => Promise, dragOffset?: { x: number; y: number }, + draw: (run: () => T) => T = (run) => run(), ): Promise { const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : []; const outcome = preflightGsapResizeIntercept(selection, animations, iframe, fetchedAnimations); @@ -342,75 +343,86 @@ 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); - } - // 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; + const draftEl = scaleDraftEl; + if (!scaleDraftDropPoint || !selector) { + draw(() => clearStudioBoxSize(draftEl)); + return false; } - // 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); + // 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(draftEl, committedScale.x, committedScale.y); + } + // 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(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 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(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 diff --git a/packages/studio/src/hooks/gsapScriptCommitTypes.ts b/packages/studio/src/hooks/gsapScriptCommitTypes.ts index acbf5a9ae06..32704776dbe 100644 --- a/packages/studio/src/hooks/gsapScriptCommitTypes.ts +++ b/packages/studio/src/hooks/gsapScriptCommitTypes.ts @@ -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 diff --git a/packages/studio/src/hooks/useGsapAwareEditing.ts b/packages/studio/src/hooks/useGsapAwareEditing.ts index 15a9b644704..335b24d15ed 100644 --- a/packages/studio/src/hooks/useGsapAwareEditing.ts +++ b/packages/studio/src/hooks/useGsapAwareEditing.ts @@ -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), ); }); } @@ -280,7 +279,7 @@ export function useGsapAwareEditing({ }; const { newX, newY } = computeDraggedGsapPosition(selection.element, offset, gsapPos); logResize("sync-settle", { gsapPos, offset, newX, newY }); - setElementGsapPosition(selection.element, newX, newY); + writes.drawKeepingUndone(() => setElementGsapPosition(selection.element, newX, newY)); }, persist: async (commit, coalesceKey) => { if (writes.commit) { @@ -294,16 +293,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 +328,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); } diff --git a/packages/studio/src/hooks/useGsapScriptCommits.test.tsx b/packages/studio/src/hooks/useGsapScriptCommits.test.tsx index a10969e7cf6..7718051724c 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.test.tsx +++ b/packages/studio/src/hooks/useGsapScriptCommits.test.tsx @@ -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", () => { diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 53e62f76240..3082f97a2e4 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -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 | null, ): void { - options.beforeReload?.(); - applyPreviewSync(iframe, result, options, reloadPreview, nestedFiles); + redrawUnlessPaintedBack(options, () => { + options.beforeReload?.(); + applyPreviewSync(iframe, result, options, reloadPreview, nestedFiles); + }); onCacheInvalidate(); } diff --git a/packages/studio/src/utils/studioPendingEdits.ts b/packages/studio/src/utils/studioPendingEdits.ts index 890040f8bb9..baf64524e00 100644 --- a/packages/studio/src/utils/studioPendingEdits.ts +++ b/packages/studio/src/utils/studioPendingEdits.ts @@ -14,11 +14,24 @@ export type StudioEditRevert = () => () => void; interface PendingEdit { revert: StudioEditRevert | null; landed: () => Promise; + redraws: Array<() => void>; + showAgain: (() => void) | null; +} + +export interface StudioEditInFlight { + reverted: () => boolean; + within: (run: () => T) => T; + drawUnlessUndone: (draw: () => void) => void; + drawKeepingUndone: (draw: () => T) => T; } const pendingEdits = new Map, 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 { return new Promise((resolve) => setTimeout(resolve, 0)); @@ -73,7 +86,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), @@ -98,14 +111,36 @@ export function beginStudioPendingEdit(revert: StudioEditRevert | null) { entry.revert = revert; let landed = Promise.resolve(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?.(); + const drawn = draw(); + entry.showAgain = revert!(); + return drawn; + }, + }; return { settle, - reverted: () => entry.revert === null && revert !== null, + reverted: inFlight.reverted, // Only what `start` registers synchronously is adopted; a registration after an await is a newer edit. adopt(start: () => T): T { - adopting = true; try { - const committed = start(); + const committed = inFlight.within(start); landed = Promise.resolve(committed).then( () => true, () => false, @@ -114,8 +149,6 @@ export function beginStudioPendingEdit(revert: StudioEditRevert | null) { } catch (error) { settle(); throw error; - } finally { - adopting = false; } }, }; @@ -129,7 +162,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 { From c11f43dd169b2c9301f7d9372343be0af61e5e39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 4 Oct 2026 12:53:52 -0400 Subject: [PATCH 02/10] test(studio): pin the painted-back draw and redraw rules --- .../components/editor/gestureUndoRevert.ts | 9 +--- .../src/utils/studioPendingEdits.test.ts | 41 +++++++++++++++++++ 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/packages/studio/src/components/editor/gestureUndoRevert.ts b/packages/studio/src/components/editor/gestureUndoRevert.ts index 25fe343a467..1a1f4afd4b7 100644 --- a/packages/studio/src/components/editor/gestureUndoRevert.ts +++ b/packages/studio/src/components/editor/gestureUndoRevert.ts @@ -27,16 +27,11 @@ function showMemberPosition(member: ManualOffsetDragMember, position: MemberPosi if (position.gsap) getOffsetDragGsap(member.element)?.set(member.element, { ...position.gsap }); } -/** Undo's live revert of a move: its members at gesture start. */ +/** Undo's live revert of a move: its members at gesture start (the restore resets GSAP's x/y from the gesture's base). */ export function manualOffsetMoveRevert(members: ManualOffsetDragMember[]): StudioEditRevert { return () => { const shown = members.map(readMemberPosition); - for (const member of members) { - showMemberPosition(member, { - offset: member.initialPathOffset, - gsap: member.plainTranslate ? null : member.baseGsap, - }); - } + for (const member of members) restoreStudioPathOffset(member.element, member.initialPathOffset); return () => members.forEach((member, i) => showMemberPosition(member, shown[i]!)); }; } diff --git a/packages/studio/src/utils/studioPendingEdits.test.ts b/packages/studio/src/utils/studioPendingEdits.test.ts index 7712828ac1f..23d2d49922a 100644 --- a/packages/studio/src/utils/studioPendingEdits.test.ts +++ b/packages/studio/src/utils/studioPendingEdits.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { addStudioPendingEditFlushListener, + adoptingStudioPendingEdit, beginStudioPendingEdit, flushStudioPendingEdits, hasStudioPendingEdits, @@ -312,3 +313,43 @@ 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((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(); + }); +}); From ab779e81367cb3c88cfe932f11131ef50fa60904 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 4 Oct 2026 13:07:41 -0400 Subject: [PATCH 03/10] test(studio): the resize intercept also gets the draw that keeps an undone edit undone --- packages/studio/src/hooks/useGsapAwareEditing.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/studio/src/hooks/useGsapAwareEditing.test.tsx b/packages/studio/src/hooks/useGsapAwareEditing.test.tsx index cd00c9a97c6..cecac4f8f5e 100644 --- a/packages/studio/src/hooks/useGsapAwareEditing.test.tsx +++ b/packages/studio/src/hooks/useGsapAwareEditing.test.tsx @@ -423,6 +423,7 @@ describe("useGsapAwareEditing anchored resize", () => { expect.any(Function), expect.any(Function), undefined, + expect.any(Function), ); act(() => h.root.unmount()); }); From a22c72e40a77c04e9b3154da5ded93e884ec3246 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 4 Oct 2026 13:44:47 -0400 Subject: [PATCH 04/10] fix(studio): a scale resize reads its drop point on the box as dropped, even after undo --- .../src/hooks/gsapResizeDropPoint.test.ts | 40 ++++++++++++++++++- .../studio/src/hooks/gsapResizeIntercept.ts | 2 +- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/hooks/gsapResizeDropPoint.test.ts b/packages/studio/src/hooks/gsapResizeDropPoint.test.ts index c1ff52645cc..139b83c83b6 100644 --- a/packages/studio/src/hooks/gsapResizeDropPoint.test.ts +++ b/packages/studio/src/hooks/gsapResizeDropPoint.test.ts @@ -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) => { + el.style.width = shown.w; + el.style.height = shown.h; + Object.assign(live, { pos: { ...shown.pos }, scale: { ...shown.scale } }); + }; + let shownEdit: ReturnType | null = null; + const paintBack = () => { + shownEdit = look(); + show({ w: "", h: "", pos: { ...testCase.base }, scale: { ...testCase.liveScale } }); + }; + const draw = (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); +}); diff --git a/packages/studio/src/hooks/gsapResizeIntercept.ts b/packages/studio/src/hooks/gsapResizeIntercept.ts index 9396694d1ff..e2f02104e4f 100644 --- a/packages/studio/src/hooks/gsapResizeIntercept.ts +++ b/packages/studio/src/hooks/gsapResizeIntercept.ts @@ -316,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 { From a5762faadb1198534212960f4c0ace9666fdd83a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 4 Oct 2026 13:50:16 -0400 Subject: [PATCH 05/10] fix(studio): a group move and a resize settle keep an undone edit undone --- .../useGsapAwareEditing.groupPlan.test.tsx | 30 ++++++++++++++++++- .../studio/src/hooks/useGsapAwareEditing.ts | 16 +++++----- .../studio/src/hooks/useGsapAwareGroupMove.ts | 10 +++---- 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/packages/studio/src/hooks/useGsapAwareEditing.groupPlan.test.tsx b/packages/studio/src/hooks/useGsapAwareEditing.groupPlan.test.tsx index aa2ada2bff7..e613f646294 100644 --- a/packages/studio/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +++ b/packages/studio/src/hooks/useGsapAwareEditing.groupPlan.test.tsx @@ -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()); +}); diff --git a/packages/studio/src/hooks/useGsapAwareEditing.ts b/packages/studio/src/hooks/useGsapAwareEditing.ts index 335b24d15ed..e9f9e924cf5 100644 --- a/packages/studio/src/hooks/useGsapAwareEditing.ts +++ b/packages/studio/src/hooks/useGsapAwareEditing.ts @@ -273,13 +273,15 @@ export function useGsapAwareEditing({ // 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 }); - writes.drawKeepingUndone(() => 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) { diff --git a/packages/studio/src/hooks/useGsapAwareGroupMove.ts b/packages/studio/src/hooks/useGsapAwareGroupMove.ts index a18836761a9..1c2eae4f1c3 100644 --- a/packages/studio/src/hooks/useGsapAwareGroupMove.ts +++ b/packages/studio/src/hooks/useGsapAwareGroupMove.ts @@ -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; } From 570045ebc72beda3ea653af51e044008ecf935c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 4 Oct 2026 13:54:51 -0400 Subject: [PATCH 06/10] fix(studio): undo paints back a GSAP move saved as a left/top offset --- .../components/editor/dragUndoPaint.test.ts | 26 +++++++++++++++++-- .../components/editor/gestureUndoRevert.ts | 21 ++++++++++++++- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/studio/src/components/editor/dragUndoPaint.test.ts b/packages/studio/src/components/editor/dragUndoPaint.test.ts index f856a0e9149..14e88228479 100644 --- a/packages/studio/src/components/editor/dragUndoPaint.test.ts +++ b/packages/studio/src/components/editor/dragUndoPaint.test.ts @@ -5,6 +5,7 @@ import type { DomEditSelection } from "./domEditing"; import type { GestureState } from "./domEditOverlayGestures"; import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures"; import { + adoptingStudioPendingEdit, hasStudioPendingEdits, paintBackNewestStudioPendingEdit, } from "../../utils/studioPendingEdits"; @@ -47,7 +48,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) { +function dragWithSaveRunning(save: Promise | (() => Promise)) { const element = document.createElement("div"); element.style.setProperty("translate", "40px 30px"); document.body.append(element); @@ -62,7 +63,7 @@ function dragWithSaveRunning(save: Promise) { 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), @@ -192,3 +193,24 @@ it("a rotate of a box GSAP turns is painted back to GSAP's angle at press, and s 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; + const element = dragWithSaveRunning(() => { + const edit = adoptingStudioPendingEdit()!; + drawn = new Promise((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)); +}); diff --git a/packages/studio/src/components/editor/gestureUndoRevert.ts b/packages/studio/src/components/editor/gestureUndoRevert.ts index 1a1f4afd4b7..10029134a82 100644 --- a/packages/studio/src/components/editor/gestureUndoRevert.ts +++ b/packages/studio/src/components/editor/gestureUndoRevert.ts @@ -9,6 +9,19 @@ 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 { @@ -19,19 +32,25 @@ function readMemberPosition(member: ManualOffsetDragMember): MemberPosition { 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 }); } /** Undo's live revert of a move: its members at gesture start (the restore resets GSAP's x/y from the gesture's base). */ export function manualOffsetMoveRevert(members: ManualOffsetDragMember[]): StudioEditRevert { + const startPlacement = members.map((member) => placementOf(member.element)); return () => { const shown = members.map(readMemberPosition); - for (const member of members) restoreStudioPathOffset(member.element, member.initialPathOffset); + members.forEach((member, i) => { + restoreStudioPathOffset(member.element, member.initialPathOffset); + showPlacement(member.element, startPlacement[i]!); + }); return () => members.forEach((member, i) => showMemberPosition(member, shown[i]!)); }; } From 6b170f0e8c1c96bfb006e476cd1ec82856402cd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 4 Oct 2026 13:59:58 -0400 Subject: [PATCH 07/10] fix(studio): undo still steps a GSAP edit whose first write saved before a later one failed --- packages/studio/src/hooks/gsapGestureOutcome.ts | 2 ++ .../studio/src/utils/studioPendingEdits.test.ts | 11 +++++++++++ packages/studio/src/utils/studioPendingEdits.ts | 15 ++++++++++----- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/studio/src/hooks/gsapGestureOutcome.ts b/packages/studio/src/hooks/gsapGestureOutcome.ts index d9a18bb3cad..b6a726d1a41 100644 --- a/packages/studio/src/hooks/gsapGestureOutcome.ts +++ b/packages/studio/src/hooks/gsapGestureOutcome.ts @@ -31,6 +31,7 @@ export function observeGsapGesture(writer: CommitMutation | null) { options.onResult?.(result); if (!result.ok || result.changed !== true) return; changed = true; + edit?.markSaved(); const members = changedMutationIndices( result, calls.length, @@ -58,6 +59,7 @@ export function observeGsapGesture(writer: CommitMutation | null) { drawKeepingUndone: (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 }; diff --git a/packages/studio/src/utils/studioPendingEdits.test.ts b/packages/studio/src/utils/studioPendingEdits.test.ts index 23d2d49922a..306fe2d0fcd 100644 --- a/packages/studio/src/utils/studioPendingEdits.test.ts +++ b/packages/studio/src/utils/studioPendingEdits.test.ts @@ -353,3 +353,14 @@ describe("an edit undo painted back while it saves", () => { 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); +}); diff --git a/packages/studio/src/utils/studioPendingEdits.ts b/packages/studio/src/utils/studioPendingEdits.ts index baf64524e00..e0842ec6430 100644 --- a/packages/studio/src/utils/studioPendingEdits.ts +++ b/packages/studio/src/utils/studioPendingEdits.ts @@ -23,6 +23,7 @@ export interface StudioEditInFlight { within: (run: () => T) => T; drawUnlessUndone: (draw: () => void) => void; drawKeepingUndone: (draw: () => T) => T; + markSaved: () => void; } const pendingEdits = new Map, PendingEdit>(); @@ -110,6 +111,7 @@ 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, @@ -129,21 +131,24 @@ export function beginStudioPendingEdit(revert: StudioEditRevert | null) { drawKeepingUndone(draw) { if (!inFlight.reverted()) return draw(); entry.showAgain?.(); - const drawn = draw(); - entry.showAgain = revert!(); - return drawn; + try { + return draw(); + } finally { + entry.showAgain = revert!(); + } }, + markSaved: () => void (saved = true), }; return { settle, reverted: inFlight.reverted, - // Only what `start` registers synchronously is adopted; a registration after an await is a newer edit. + // Only what `start` registers synchronously is adopted; a later write joins only through `within`. adopt(start: () => T): T { try { const committed = inFlight.within(start); landed = Promise.resolve(committed).then( () => true, - () => false, + () => saved, ); return committed; } catch (error) { From 5d4fa99dfd8bf2d1ba53ee0ac823175c83a0c1a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 4 Oct 2026 14:54:25 -0400 Subject: [PATCH 08/10] fix(studio): the resize pixel check measures a box undo painted back as the edit drew it --- .../src/hooks/gestureTransaction.test.ts | 30 +++++++++++++++++++ .../studio/src/hooks/gestureTransaction.ts | 5 +++- .../studio/src/hooks/useGsapAwareEditing.ts | 1 + 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/hooks/gestureTransaction.test.ts b/packages/studio/src/hooks/gestureTransaction.test.ts index 8ff3ff28f82..4249e5178c8 100644 --- a/packages/studio/src/hooks/gestureTransaction.test.ts +++ b/packages/studio/src/hooks/gestureTransaction.test.ts @@ -281,6 +281,36 @@ 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 = (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("skips the pixel assertion for live position tweens", async () => { const element = document.createElement("div"); const getRect = vi.spyOn(element, "getBoundingClientRect"); diff --git a/packages/studio/src/hooks/gestureTransaction.ts b/packages/studio/src/hooks/gestureTransaction.ts index affd363120f..f767e4c49fa 100644 --- a/packages/studio/src/hooks/gestureTransaction.ts +++ b/packages/studio/src/hooks/gestureTransaction.ts @@ -18,6 +18,7 @@ export interface GestureTransaction { afterBufferedCommitsSaved?(): Promise; restore(): void; skipPixelAssert?: boolean; + draw?(run: () => T): T; } let transactionCounter = 0; @@ -150,7 +151,9 @@ export function runGestureTransaction(tx: GestureTransaction): Promise { const durationMs = Math.round(performance.now() - startedAt); logCommit("persisted", { label: tx.label, coalesceKey }); if (before) { - const after = readPixelRect(tx.element); + const after = tx.draw + ? tx.draw(() => readPixelRect(tx.element)) + : readPixelRect(tx.element); const delta = pixelDelta(before, after); if (exceedsPixelTolerance(delta)) { logCommit("persist-changed-pixels", { label: tx.label, before, after, delta }); diff --git a/packages/studio/src/hooks/useGsapAwareEditing.ts b/packages/studio/src/hooks/useGsapAwareEditing.ts index e9f9e924cf5..24c807cca3f 100644 --- a/packages/studio/src/hooks/useGsapAwareEditing.ts +++ b/packages/studio/src/hooks/useGsapAwareEditing.ts @@ -269,6 +269,7 @@ 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. From ee36fa9a7319a1896b1bad88ddb927db9322a843 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 4 Oct 2026 15:11:30 -0400 Subject: [PATCH 09/10] fix(studio): a GSAP move undone mid-save keeps its drag base, so its save adds the drag once --- .../components/editor/dragUndoPaint.test.ts | 23 ++++++++++++++ .../components/editor/gestureUndoRevert.ts | 22 ++++++++++---- .../src/components/editor/manualOffsetDrag.ts | 2 +- .../src/hooks/gestureTransaction.test.ts | 30 +++++++++++++++++++ .../studio/src/hooks/gestureTransaction.ts | 8 ++--- 5 files changed, 74 insertions(+), 11 deletions(-) diff --git a/packages/studio/src/components/editor/dragUndoPaint.test.ts b/packages/studio/src/components/editor/dragUndoPaint.test.ts index 14e88228479..4a36fd34eec 100644 --- a/packages/studio/src/components/editor/dragUndoPaint.test.ts +++ b/packages/studio/src/components/editor/dragUndoPaint.test.ts @@ -8,6 +8,7 @@ import { adoptingStudioPendingEdit, hasStudioPendingEdits, paintBackNewestStudioPendingEdit, + type StudioEditInFlight, } from "../../utils/studioPendingEdits"; const gsapOwns = vi.hoisted(() => ({ on: false })); @@ -214,3 +215,25 @@ it("a GSAP drag saved as a left/top offset stays painted back while that offset 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((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)); +}); diff --git a/packages/studio/src/components/editor/gestureUndoRevert.ts b/packages/studio/src/components/editor/gestureUndoRevert.ts index 10029134a82..30bde86bfbe 100644 --- a/packages/studio/src/components/editor/gestureUndoRevert.ts +++ b/packages/studio/src/components/editor/gestureUndoRevert.ts @@ -3,7 +3,11 @@ import { restoreStudioPathOffset, type StudioPathOffsetSnapshot, } from "./manualEdits"; -import { getOffsetDragGsap, type ManualOffsetDragMember } from "./manualOffsetDrag"; +import { + getOffsetDragGsap, + stampGestureBase, + type ManualOffsetDragMember, +} from "./manualOffsetDrag"; import type { StudioEditRevert } from "../../utils/studioPendingEdits"; interface MemberPosition { @@ -40,17 +44,23 @@ function showMemberPosition(member: ManualOffsetDragMember, position: MemberPosi 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 (the restore resets GSAP's x/y from the gesture's base). */ +/** 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) => { - restoreStudioPathOffset(member.element, member.initialPathOffset); - showPlacement(member.element, startPlacement[i]!); - }); + 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]!)); }; } diff --git a/packages/studio/src/components/editor/manualOffsetDrag.ts b/packages/studio/src/components/editor/manualOffsetDrag.ts index bb610a0f88a..5fc332bb4fd 100644 --- a/packages/studio/src/components/editor/manualOffsetDrag.ts +++ b/packages/studio/src/components/editor/manualOffsetDrag.ts @@ -447,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)); diff --git a/packages/studio/src/hooks/gestureTransaction.test.ts b/packages/studio/src/hooks/gestureTransaction.test.ts index 4249e5178c8..4991d65118c 100644 --- a/packages/studio/src/hooks/gestureTransaction.test.ts +++ b/packages/studio/src/hooks/gestureTransaction.test.ts @@ -311,6 +311,36 @@ describe("runGestureTransaction", () => { ); }); + 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 = (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"); diff --git a/packages/studio/src/hooks/gestureTransaction.ts b/packages/studio/src/hooks/gestureTransaction.ts index f767e4c49fa..eeaa636b209 100644 --- a/packages/studio/src/hooks/gestureTransaction.ts +++ b/packages/studio/src/hooks/gestureTransaction.ts @@ -127,7 +127,9 @@ export function runGestureTransaction(tx: GestureTransaction): Promise { 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; @@ -151,9 +153,7 @@ export function runGestureTransaction(tx: GestureTransaction): Promise { const durationMs = Math.round(performance.now() - startedAt); logCommit("persisted", { label: tx.label, coalesceKey }); if (before) { - const after = tx.draw - ? tx.draw(() => readPixelRect(tx.element)) - : readPixelRect(tx.element); + const after = measure(); const delta = pixelDelta(before, after); if (exceedsPixelTolerance(delta)) { logCommit("persist-changed-pixels", { label: tx.label, before, after, delta }); From 06c753d3ee2534adda1a8914aa6701e45dbe08f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 4 Oct 2026 15:29:01 -0400 Subject: [PATCH 10/10] refactor(studio): trim the scale-resize finalize comments to what the code cannot say --- .../studio/src/hooks/gsapResizeIntercept.ts | 37 +++---------------- 1 file changed, 6 insertions(+), 31 deletions(-) diff --git a/packages/studio/src/hooks/gsapResizeIntercept.ts b/packages/studio/src/hooks/gsapResizeIntercept.ts index e2f02104e4f..9a5ebc233b0 100644 --- a/packages/studio/src/hooks/gsapResizeIntercept.ts +++ b/packages/studio/src/hooks/gsapResizeIntercept.ts @@ -351,33 +351,13 @@ export async function tryGsapResizeIntercept( const dropPoint = scaleDraftDropPoint; const measured = draw(() => { clearStudioBoxSize(draftEl); - // 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. + // 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, 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. + // 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, @@ -410,13 +390,8 @@ export async function tryGsapResizeIntercept( 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). + // 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 }; });