fix(studio): resize an element without GSAP by its own width, height and translate (#4805)

* fix(studio): resize an element without GSAP by its own width, height and translate

A corner resize of an element GSAP does not size or position now saves plain CSS:
its inline width and height, the scaled crop, and the translate that keeps its
centre where it was, in one save and one undo step, with no animation read and
no preview reload. It used to write a GSAP script into a file that had none, and
the box snapped back to its old size on release.

The anchor that keeps the centre planted is the same plain px translate a move
writes, kept to a thousandth of a pixel, and a percent translate is held as px
from the press so a growing box cannot drag it along on the first frame.

* fix(studio): a resize leaves a box to GSAP when GSAP tweens anything the CSS box writer sets

gsapWritesBox now reads its channels from BOX_SIZE_STYLE_PROPS, the writer's own
property set, instead of width and height alone. A host-level test pins the
GSAP-free resize route through useDomGeometryCommit.

* fix(studio): a GSAP scaleX or scaleY tween also keeps a resize on the GSAP route

They are GSAP's names for parts of the one CSS scale the box writer sets.

* test(studio): bank the edit accuracy cases a plain-CSS resize now passes

baseline.json is the edit-accuracy-gate artifact of the CI run at 5ae51f1d,
copied byte for byte: 494 to 530 passing, the 36 resize cases without GSAP,
none regressed.
This commit is contained in:
Miguel Ángel
2026-10-01 00:57:46 -07:00
committed by GitHub
parent 86a7e8cc75
commit 3e011b7e5f
15 changed files with 937 additions and 763 deletions
@@ -16,23 +16,49 @@ const ORIGIN_CENTER = {
// Consistent geometry stub: model the physical truth the real DOM would report.
// A CSS width/height change grows the box from its top-left, so the rendered
// center drifts by half the size delta; the manual offset the gesture applies
// (read back from the element's studio vars) pulls it back. `elementCornerOverlayPoints`
// center drifts by half the size delta; the element's own translate, which the gesture
// writes in plain px, pulls it back. `elementCornerOverlayPoints`
// returns the four corners of that drifted box; `overlayCornersCentroid` (kept
// real) averages them so the anchor loop can measure the true center each frame.
// An authored `translate: 25% 25%` follows the box size until the gesture writes px over it.
const authored = vi.hoisted(() => ({ percent: 0 }));
function inlineTranslate(element: HTMLElement): { x: number; y: number } | null {
const value = element.style.getPropertyValue("translate");
if (!value) return null;
const [x = 0, y = 0] = value.split(" ").map((v) => Number.parseFloat(v) || 0);
return { x, y };
}
function renderedTranslate(element: HTMLElement, width: number, height: number) {
return inlineTranslate(element) ?? { x: authored.percent * width, y: authored.percent * height };
}
vi.mock("./plainTranslate", async () => {
const actual = await vi.importActual<typeof import("./plainTranslate")>("./plainTranslate");
return {
...actual,
readTranslatePx: (element: HTMLElement) =>
renderedTranslate(
element,
element.offsetWidth || ORIGIN.width,
element.offsetHeight || ORIGIN.height,
),
};
});
vi.mock("./domEditOverlayGeometry", async () => {
const actual = await vi.importActual<typeof import("./domEditOverlayGeometry")>(
"./domEditOverlayGeometry",
);
const { readStudioBoxSize, readStudioPathOffset } = await import("./manualEditsDom");
const { readStudioBoxSize } = await import("./manualEditsDom");
const physicalCenter = (element: HTMLElement) => {
const size = readStudioBoxSize(element);
const width = size.width > 0 ? size.width : ORIGIN.width;
const height = size.height > 0 ? size.height : ORIGIN.height;
const offset = readStudioPathOffset(element);
const offset = renderedTranslate(element, width, height);
const start = { x: authored.percent * ORIGIN.width, y: authored.percent * ORIGIN.height };
return {
x: ORIGIN_CENTER.x + (width - ORIGIN.width) / 2 + offset.x,
y: ORIGIN_CENTER.y + (height - ORIGIN.height) / 2 + offset.y,
x: ORIGIN_CENTER.x + (width - ORIGIN.width) / 2 + offset.x - start.x,
y: ORIGIN_CENTER.y + (height - ORIGIN.height) / 2 + offset.y - start.y,
width,
height,
};
@@ -189,6 +215,7 @@ async function finishResize(handlers: ReturnType<typeof createDomEditOverlayGest
afterEach(() => {
document.body.innerHTML = "";
authored.percent = 0;
});
describe("anchored corner resize — the release commit feeds the center-pin offset", () => {
@@ -227,6 +254,19 @@ describe("anchored corner resize — the release commit feeds the center-pin off
expect(offset.y).toBeCloseTo(-(size.height - ORIGIN.height) / 2, 0);
});
it("keeps the centre on the first frame when the authored translate is a percent", async () => {
const { orientedOverlayRect } = await import("./domEditOverlayGeometry");
authored.percent = 0.25;
const { handlers, selection } = buildHarness();
handlers.startGesture("resize", evt(ORIGIN_CENTER.x + 100, ORIGIN_CENTER.y), {
resizeHandle: "se",
});
handlers.onPointerMove(evt(ORIGIN_CENTER.x + 150, ORIGIN_CENTER.y));
const rect = orientedOverlayRect(null as never, null as never, selection.element)!;
expect(rect.left + rect.width / 2).toBeCloseTo(ORIGIN_CENTER.x, 1);
expect(rect.top + rect.height / 2).toBeCloseTo(ORIGIN_CENTER.y, 1);
});
it("does not log a paused save queue as an ordinary resize failure", async () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
const { handlers } = buildHarness(() => Promise.reject(new DomEditSaveQueueOpenError()));
@@ -70,20 +70,6 @@ export interface GestureState {
// follows the cursor instead of overshooting by the live scale.
contentScaleX: number;
contentScaleY: number;
// Resize anchor pinning: with a live scale transform, growing the CSS box
// shifts the rendered box (scaling happens around the element center), so the
// un-dragged corner creeps during the draft. The move handler measures the
// gesture-start top-left drift each frame and counters it through the GSAP
// position channel; the pin accumulates so the correction converges.
// Present only on resize gestures.
resizeAnchor?: {
anchorX: number;
anchorY: number;
baseGsapX: number;
baseGsapY: number;
pinX: number;
pinY: number;
};
manualEditDragToken?: string;
snapContext?: SnapContext;
lastSnappedDx?: number;
@@ -20,22 +20,23 @@ const PRESS = {
currentTarget: { setPointerCapture() {} },
};
/** Presses a drag on the element and returns the gesture it started. */
function pressDrag(element: HTMLElement): GestureState | null {
/** Presses a drag or resize on the element and returns the gesture it started. */
function pressGesture(element: HTMLElement, kind: "drag" | "resize" = "drag"): GestureState | null {
const opts = pressOptions(element);
expect(
createDomEditOverlayGestureHandlers(opts as never).startGesture("drag", PRESS as never),
createDomEditOverlayGestureHandlers(opts as never).startGesture(kind, PRESS as never),
).toBe(true);
return opts.gestureRef.current;
}
function pressOptions(element: HTMLElement) {
const ref = <T>(current: T) => ({ current });
const selection = { element, capabilities: { canApplyManualOffset: true } };
const capabilities = { canApplyManualOffset: true, canApplyManualSize: true };
const selection = { element, capabilities };
return {
selectionRef: ref(selection as unknown as DomEditSelection),
overlayRectRef: ref({ left: 0, top: 0, width: 240, height: 160, editScaleX: 1, editScaleY: 1 }),
boxRef: ref(null),
boxRef: ref(document.createElement("div")),
overlayRef: ref(null),
iframeRef: ref(null),
gestureRef: ref<GestureState | null>(null),
@@ -45,13 +46,14 @@ function pressOptions(element: HTMLElement) {
};
}
describe("a drag press on a page that loads GSAP", () => {
describe("a drag or resize press on a page that loads GSAP", () => {
it.each([
["GSAP animates nothing", false],
["GSAP animates only its parent", true],
])(
"%s: the press never asks GSAP about the element, so its translate stays CSS",
(_, parentTween) => {
["drag", "GSAP animates nothing", false],
["drag", "GSAP animates only its parent", true],
["resize", "GSAP animates nothing", false],
] as const)(
"%s, %s: the press never asks GSAP about the element, so its translate stays CSS",
(kind, _, parentTween) => {
const getProperty = vi.fn(() => 0);
const set = vi.fn();
const parent = document.createElement("div");
@@ -62,7 +64,7 @@ describe("a drag press on a page that loads GSAP", () => {
const tween = { targets: () => [parent], vars: { x: 100 }, duration: () => 2 };
const timelines = { main: { getChildren: () => (parentTween ? [tween] : []) } };
Object.assign(window, { gsap: { getProperty, set }, __timelines: timelines });
expect(pressDrag(element)?.pathOffsetMember?.plainTranslate).toBe(true);
expect(pressGesture(element, kind)?.pathOffsetMember?.plainTranslate).toBe(true);
expect(getProperty).not.toHaveBeenCalled();
expect(set).not.toHaveBeenCalled();
expect(element.style.getPropertyValue("translate")).toBe("40px 30px");
@@ -77,7 +79,7 @@ describe("a drag press on a centred element without GSAP", () => {
"position: absolute; left: 50%; top: 50%; width: 240px; height: 160px; translate: -50% -50%";
document.body.append(element);
const style = element.getAttribute("style");
expect(pressDrag(element)?.pathOffsetMember?.initialOffset).toEqual({ x: -120, y: -80 });
expect(pressGesture(element)?.pathOffsetMember?.initialOffset).toEqual({ x: -120, y: -80 });
expect(element.getAttribute("style")).toBe(style);
});
});
@@ -2,9 +2,9 @@
* Gesture-begin functions: startGroupDrag and startGesture.
* These are pure "start a new gesture" operations — no draft rect updates.
*/
import { readElementGsapNumber } from "../../utils/elementGsap";
import { type DomEditSelection } from "./domEditing";
import {
applyManualOffsetDragDraft,
createManualOffsetDragMember,
readGsapRotation,
restoreManualOffsetDragMembers,
@@ -171,18 +171,6 @@ export function startGesture(
Number.isFinite(rawContentScaleX) && rawContentScaleX > 0 ? rawContentScaleX : 1;
const contentScaleY =
Number.isFinite(rawContentScaleY) && rawContentScaleY > 0 ? rawContentScaleY : 1;
let resizeAnchor: GestureState["resizeAnchor"];
if (kind === "resize") {
const startBcr = sel.element.getBoundingClientRect();
resizeAnchor = {
anchorX: startBcr.x,
anchorY: startBcr.y,
baseGsapX: readElementGsapNumber(sel.element, "x") ?? 0,
baseGsapY: readElementGsapNumber(sel.element, "y") ?? 0,
pinX: 0,
pinY: 0,
};
}
let initialPathOffset = captureStudioPathOffset(sel.element);
let manualEditDragToken: string | undefined;
let pathOffsetMember: ManualOffsetDragMember | undefined;
@@ -205,11 +193,9 @@ export function startGesture(
initialPathOffset = result.member.initialPathOffset;
manualEditDragToken = result.member.gestureToken;
} else {
// Center-anchored corner resize (CapCut model): the element scales about its
// CENTER, which stays planted. All four corners behave identically, so EVERY
// corner needs the manual-offset member that translates the element to re-pin
// its center per frame (the memberless else-branch is only a defensive fallback
// if member creation fails, e.g. the element can't take a manual offset).
// Center-anchored corner resize (CapCut model): the element scales about its planted CENTER,
// so every corner needs the member that re-pins the center per frame (the memberless
// branch is only a fallback for an element that can't take a manual offset).
const needsAnchorOffset = kind === "resize" && sel.capabilities.canApplyManualOffset;
if (needsAnchorOffset) {
const result = createManualOffsetDragMember({
@@ -217,11 +203,14 @@ export function startGesture(
selection: sel,
element: sel.element,
rect,
gesture: "resize",
});
if (result.ok) {
pathOffsetMember = result.member;
initialPathOffset = result.member.initialPathOffset;
manualEditDragToken = result.member.gestureToken;
// Hold a % translate as the same px now, so a growing box can't drag it along mid-frame.
if (result.member.plainTranslate) applyManualOffsetDragDraft(result.member, 0, 0);
} else {
manualEditDragToken = beginStudioManualEditGesture(sel.element);
}
@@ -285,7 +274,6 @@ export function startGesture(
editScaleY: rect.editScaleY,
contentScaleX,
contentScaleY,
resizeAnchor,
manualEditDragToken,
snapContext,
resizeHandle: kind === "resize" ? (options?.resizeHandle ?? "se") : undefined,
@@ -118,7 +118,7 @@ export function buildClearPathOffsetPatches(element: HTMLElement): PatchOperatio
/* ── Box size patches ────────────────────────────────────────────── */
const BOX_SIZE_STYLE_PROPS = [
export const BOX_SIZE_STYLE_PROPS = [
"width",
"height",
"min-width",
@@ -652,3 +652,44 @@ describe("a move of an element GSAP does not position", () => {
expect(member().plainTranslate).toBe(false);
});
});
describe("a resize's anchor member", () => {
function anchorMember(tweenVars?: Record<string, number>) {
const window = new Window();
const element = window.document.createElement("div");
element.id = "box";
window.document.body.append(element);
const tweens = tweenVars ? [{ vars: tweenVars, targets: () => [element] }] : [];
Object.assign(window, { __timelines: { main: { getChildren: () => tweens } } });
element.getBoundingClientRect = () => {
const [x = 0, y = 0] = element.style
.getPropertyValue("translate")
.split(" ")
.map((v) => Number.parseFloat(v));
return new window.DOMRect(10 + (x || 0), 20 + (y || 0), 100, 50);
};
const result = createManualOffsetDragMember({
key: "box",
selection: { element } as never,
element,
rect: { left: 10, top: 20, width: 100, height: 50, editScaleX: 1, editScaleY: 1 },
gesture: "resize",
});
if (!result.ok) throw new Error("member");
return result.member;
}
it("keeps the centre with the element's own plain translate when GSAP does not own the box", () => {
const member = anchorMember();
expect(member.plainTranslate).toBe(true);
applyManualOffsetDragDraft(member, -10.25, 3.5);
expect(member.element.style.getPropertyValue("translate")).toBe("-10.25px 3.5px");
});
it.each(["width", "maxWidth", "minHeight", "flexBasis", "scale", "scaleX"])(
"leaves a box whose %s GSAP tweens, which the CSS box writer also sets, to the GSAP writer",
(channel) => {
expect(anchorMember({ [channel]: 300 }).plainTranslate).toBe(false);
},
);
});
@@ -11,7 +11,7 @@ import {
type StudioPathOffsetSnapshot,
} from "./manualEdits";
import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
import { gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
import { gsapWritesBox, gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
import { readTranslatePx, UNREADABLE_TRANSLATE, writeTranslatePx } from "./plainTranslate";
interface OffsetDragGsap {
@@ -337,11 +337,11 @@ export function createManualOffsetDragMember(input: {
selection: DomEditSelection;
element: HTMLElement;
rect: ManualOffsetDragRect;
gesture?: "drag" | "nudge"; // a resize's anchor member keeps the legacy offset channel
gesture?: "drag" | "nudge" | "resize"; // resize: the anchor that keeps its centre planted
}): ManualOffsetDragMemberResult {
const plainTranslate = !!input.gesture && !gsapWritesPosition(input.element);
// Base the drag on the offset ACTUALLY applied, never a dormant var (readAppliedStudioPathOffset),
// so a stale offset can't fling the element off-screen.
const gsapOwns = input.gesture === "resize" ? gsapWritesBox : gsapWritesPosition;
const plainTranslate = !!input.gesture && !gsapOwns(input.element);
// The APPLIED offset, never a dormant var, so a stale one can't fling the element off-screen.
const initialOffset = plainTranslate
? readTranslatePx(input.element)
: readAppliedStudioPathOffset(input.element);
@@ -11,6 +11,7 @@
import { buildArcPath, type ArcPathConfig } from "@hyperframes/core/gsap-parser-acorn";
import { parsePercentageKeyframes, toAbsoluteTime } from "./gsapShared";
import { roundTo3 } from "../utils/rounding";
import { BOX_SIZE_STYLE_PROPS } from "../components/editor/manualEditsDomPatches";
/**
* A GSAP tween's `vars` object — intentionally open: it mixes channel values
@@ -446,6 +447,19 @@ export function gsapWritesPosition(el: Element): boolean {
return !!cache?.renderTransform || gsapWritesChannels(el, MOVE_CHANNELS);
}
const BOX_CHANNELS = [
...BOX_SIZE_STYLE_PROPS.map((prop) =>
prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()),
),
"scaleX",
"scaleY",
];
/** GSAP owns this element's box: its position, or any property the CSS box writer sets. Else a resize writes CSS. */
export function gsapWritesBox(el: Element): boolean {
return gsapWritesPosition(el) || gsapWritesChannels(el, BOX_CHANNELS);
}
/** `hasNonHoldTweenForElement` for an element in hand, read from its own window's timelines. */
export function elementHasNonHoldTween(el: Element, channels?: string[]): boolean {
const win = el.ownerDocument.defaultView as {
@@ -176,6 +176,28 @@ describe("useDomGeometryCommit, from the package entry", () => {
unmount();
});
it("saves a GSAP-free resize as its inline size, with no GSAP script and no animation read", async () => {
const calls = stubPatchServer();
const { element, recordEdit, hook, unmount } = renderHost();
await expect(
hook().commitBoxSize(makeSelection("card", element), { width: 300, height: 90 }),
).resolves.toEqual({ ok: true });
expect(element.style.getPropertyValue("width")).toBe("300px");
expect(calls.patches).toEqual([
expect.objectContaining({
operations: expect.arrayContaining([
{ type: "inline-style", property: "width", value: "300px" },
{ type: "inline-style", property: "height", value: "90px" },
]),
}),
]);
expect(calls.urls.filter((url) => url.includes("gsap"))).toEqual([]);
expect(recordEdit).toHaveBeenCalledTimes(1);
unmount();
});
it("puts a GSAP-free element's translate back when its move cannot be saved", async () => {
stubPatchServer(500);
const { element, hook, unmount } = renderHost();
@@ -216,6 +238,8 @@ describe("useDomGeometryCommit, from the package entry", () => {
const showToast = vi.fn();
const restore = vi.fn();
const { element, recordEdit, hook, unmount } = renderHost({ showToast });
// GSAP renders the card's transform, so its resize reads animations first.
Object.assign(element, { _gsap: { renderTransform: () => undefined } });
await expect(
hook().commitBoxSize(
@@ -13,8 +13,8 @@ import { useGsapCacheVersion } from "./useGsapTweenCache";
import { createDomEditSaveQueue } from "../utils/domEditSaveQueue";
import { useDomEditPersist } from "./useDomEditPersist";
import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
import { useDomGeometryCommits } from "./useDomGeometryCommits";
import { useMountEffect } from "./useMountEffect";
import { stageElementOffset } from "./elementOffsetStager";
/**
* Studio's `Player` must show the project, with `beginTimelineSession(projectId)` run before it
@@ -47,8 +47,6 @@ export interface DomGeometryCommits {
const noop = () => {};
const NO_SELECTED_ANIMATIONS: GsapAnimation[] = [];
// ponytail: unreachable, the GSAP writer always exists so a resize never takes the DOM route.
const noDomBoxSizeRoute = () => Promise.reject(new Error("Resize has no DOM route here"));
/**
* Saves canvas moves, resizes and rotations through Studio's own GSAP-aware commits, for a host
@@ -107,16 +105,12 @@ export function useDomGeometryCommit({
},
[commitPositionPatchToHtml, queue],
);
const stageElementPositionOffset = useCallback(
(selection: DomEditSelection, next: { x: number; y: number }, coalesceKey?: string) =>
stageElementOffset(
{ commitPositionPatchToHtml: commitWithFreshQueue, showToast },
selection,
next,
coalesceKey,
),
[commitWithFreshQueue, showToast],
);
const { stageElementPositionOffset, handleDomBoxSizeCommit } = useDomGeometryCommits({
previewIframeRef: iframeRef,
showToast,
commitPositionPatchToHtml: commitWithFreshQueue,
readOnlyPreview: false,
});
const makeFetchFallback = useGsapAnimationFetchFallback(projectId);
const trackGsapInteractionFailure = useGsapInteractionFailureTelemetry(activeCompPath, showToast);
const {
@@ -135,7 +129,7 @@ export function useDomGeometryCommit({
makeFetchFallback,
trackGsapInteractionFailure,
stageElementPositionOffset,
handleDomBoxSizeCommit: noDomBoxSizeRoute,
handleDomBoxSizeCommit,
commitPositionPatchToHtml: commitWithFreshQueue,
addGsapAnimation: gsap.addGsapAnimation,
convertToKeyframes: gsap.convertToKeyframes,
@@ -175,6 +175,61 @@ describe("useDomGeometryCommits element position offset", () => {
});
});
describe("useDomGeometryCommits resize anchor", () => {
it("saves the anchor as the element's own plain px translate, sub-pixel, with the size", async () => {
const element = document.createElement("div");
element.id = "anchored";
element.style.setProperty("translate", "40px 30px");
document.body.append(element);
const selection = {
id: "anchored",
selector: "#anchored",
element,
} as unknown as DomEditSelection;
const commit = vi
.fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>()
.mockResolvedValue(undefined);
const { commits, unmount } = mountCommits(commit);
await commits().handleDomBoxSizeCommit(
selection,
{ width: 340, height: 227 },
{ x: -10.25, y: 3.5 },
);
expect(commit).toHaveBeenCalledTimes(1);
const patches = commit.mock.calls[0]![1];
expect(patches).toContainEqual({ type: "inline-style", property: "width", value: "340px" });
expect(patches).toContainEqual({
type: "inline-style",
property: "translate",
value: "-10.25px 3.5px",
});
expect(patches.some((p) => p.property.startsWith("--hf-studio-offset"))).toBe(false);
expect(element.style.getPropertyValue("translate")).toBe("-10.25px 3.5px");
unmount();
});
});
describe("useDomGeometryCommits resize rollback", () => {
it("rolls the whole gesture back once when the resize save fails", async () => {
const element = document.createElement("div");
document.body.append(element);
const selection = { id: "r", selector: "#r", element } as unknown as DomEditSelection;
const failure = new Error("save failed");
const commit = vi
.fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>()
.mockRejectedValue(failure);
const restore = vi.fn();
const { commits, unmount } = mountCommits(commit);
await expect(
commits().handleDomBoxSizeCommit(selection, { width: 50, height: 40 }, undefined, restore),
).rejects.toBe(failure);
expect(restore).toHaveBeenCalledTimes(1);
unmount();
});
});
describe("useDomGeometryCommits resize of a cropped element", () => {
it("saves the crop scaled per axis with the box in the resize's own commit", async () => {
const element = withInlineLayoutBox(document.createElement("div"));
@@ -16,6 +16,7 @@ import {
} from "../components/editor/manualEdits";
import { stageElementOffset } from "./elementOffsetStager";
import { prepareCropResize } from "../components/editor/cropResize";
import { translatePatch, writeTranslatePx } from "../components/editor/plainTranslate";
import {
buildPathOffsetPatches,
buildBoxSizePatches,
@@ -98,38 +99,35 @@ export function useDomGeometryCommits({
selection: DomEditSelection,
next: { width: number; height: number },
offset?: { x: number; y: number },
restore?: () => void,
) => {
if (readOnlyPreview) return Promise.resolve();
const gsapFallback = rejectGsapCssFallback(selection, previewIframeRef, showToast);
if (gsapFallback) return gsapFallback;
const beforeSize = captureStudioBoxSize(selection.element);
const beforeOffset = offset ? captureStudioPathOffset(selection.element) : null;
const stageCrop = prepareCropResize(selection.element);
applyStudioBoxSize(selection.element, next);
const element = selection.element;
const beforeSize = captureStudioBoxSize(element);
const beforeTranslate = element.style.getPropertyValue("translate");
const stageCrop = prepareCropResize(element);
applyStudioBoxSize(element, next);
const crop = stageCrop();
// Anchored-corner resize (NW/NE/SW) also moves the element to keep the
// opposite corner fixed. Apply the offset and emit BOTH patch sets in a
// SINGLE commit: one persist = one undo entry, and there is no
// intermediate re-stamp where the new size is in source but the anchor
// offset is not (that frame was the release "jump"). Both builders read
// the already-mutated live element, so concatenation is safe.
const patches = buildBoxSizePatches(selection.element);
// One commit, one undo entry: the size, the crop that follows it, and the translate
// (as a move writes it) that keeps the centre planted.
const patches = buildBoxSizePatches(element);
if (crop) patches.push(crop.patch);
if (offset) {
applyStudioPathOffset(selection.element, offset);
patches.push(...buildPathOffsetPatches(selection.element));
writeTranslatePx(element, offset);
patches.push(translatePatch(offset));
}
return commitPositionPatchToHtml(selection, patches, {
label: "Resize layer box",
coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
}).catch((error) => {
restoreStudioBoxSize(selection.element, beforeSize);
if (beforeOffset) restoreStudioPathOffset(selection.element, beforeOffset);
restoreStudioBoxSize(element, beforeSize);
if (offset) element.style.setProperty("translate", beforeTranslate);
crop?.revert();
restore?.();
throw error;
});
},
[commitPositionPatchToHtml, previewIframeRef, showToast, readOnlyPreview],
[commitPositionPatchToHtml, readOnlyPreview],
);
const handleDomRotationCommit = useCallback(
@@ -54,8 +54,10 @@ function gsapPositioned(tag: string): HTMLElement {
function mountResizeHandler(
animations: GsapAnimation[],
targetAnimations: GsapAnimation[] = animations,
gsapOwnsBox = true,
hasGsapWriter = true,
) {
const element = document.createElement("div");
const element = gsapOwnsBox ? gsapPositioned("div") : document.createElement("div");
const selection = { element, id: "clip", selector: "#clip" } as unknown as DomEditSelection;
const fallback = vi.fn().mockResolvedValue(undefined);
const anchorSave = vi.fn().mockResolvedValue(undefined);
@@ -63,6 +65,7 @@ function mountResizeHandler(
const elementOffset = vi.fn(() => ({ save: anchorSave, rollback: anchorRollback }));
const commitMutation = vi.fn().mockResolvedValue(undefined);
const commitPatch = vi.fn().mockResolvedValue(undefined);
const fetchAnimations = vi.fn(() => vi.fn().mockResolvedValue(targetAnimations));
let resize:
| ((
selection: DomEditSelection,
@@ -76,11 +79,11 @@ function mountResizeHandler(
const editing = useGsapAwareEditing({
domEditSelection: selection,
selectedGsapAnimations: animations,
gsapCommitMutation: commitMutation,
gsapCommitMutation: hasGsapWriter ? commitMutation : null,
previewIframeRef: { current: null },
showToast: vi.fn(),
bumpGsapCache: vi.fn(),
makeFetchFallback: () => vi.fn().mockResolvedValue(targetAnimations),
makeFetchFallback: fetchAnimations,
trackGsapInteractionFailure: vi.fn(),
stageElementPositionOffset: elementOffset,
handleDomBoxSizeCommit: fallback,
@@ -103,6 +106,7 @@ function mountResizeHandler(
anchorRollback,
commitMutation,
commitPatch,
fetchAnimations,
resize: resize!,
property: property!,
root,
@@ -252,6 +256,36 @@ describe("useGsapAwareEditing anchored resize", () => {
act(() => h.root.unmount());
});
it("resizes a box GSAP does not own through the CSS writer, with no GSAP write or fetch", async () => {
const h = mountResizeHandler([], [], false);
const restore = vi.fn();
await act(() =>
h.resize(h.selection, { width: 300, height: 200 }, { x: -50.5, y: -25 }, restore),
);
expect(h.fallback).toHaveBeenCalledWith(
h.selection,
{ width: 300, height: 200 },
{ x: -50.5, y: -25 },
restore,
);
expect(mocks.resize).not.toHaveBeenCalled();
expect(mocks.drag).not.toHaveBeenCalled();
expect(h.commitMutation).not.toHaveBeenCalled();
expect(h.fetchAnimations).not.toHaveBeenCalled();
act(() => h.root.unmount());
});
it("fails a GSAP-owned resize loudly when there is no GSAP writer, never writing CSS", async () => {
const h = mountResizeHandler([], [], true, false);
const restore = vi.fn();
await expect(
act(() => h.resize(h.selection, { width: 300, height: 200 }, undefined, restore)),
).rejects.toThrow("no GSAP writer");
expect(h.fallback).not.toHaveBeenCalled();
expect(restore).toHaveBeenCalledTimes(1);
act(() => h.root.unmount());
});
it("rejects a blocked resize instead of falling through to a competing DOM write", async () => {
mocks.resize.mockResolvedValue({ status: "blocked", reason: "source-uneditable" });
const h = mountResizeHandler([]);
@@ -30,6 +30,7 @@ import { logResize, logResizeSettle } from "../utils/resizeDebug";
import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
import { runGestureTransaction } from "./gestureTransaction";
import {
gsapWritesBox,
gsapWritesPosition,
hasNonHoldTweenForElement,
POSITION_CHANNELS,
@@ -86,6 +87,7 @@ export interface UseGsapAwareEditingParams {
selection: DomEditSelection,
next: { width: number; height: number },
offset?: { x: number; y: number },
restore?: () => void,
) => Promise<void>;
commitPositionPatchToHtml: ElementOffsetStagerDeps["commitPositionPatchToHtml"];
// GSAP script commit ops (from useGsapScriptCommits)
@@ -328,6 +330,8 @@ export function useGsapAwareEditing({
offset?: { x: number; y: number },
restore: () => void = () => undefined,
) => {
if (!gsapWritesBox(selection.element))
return handleDomBoxSizeCommit(selection, next, offset, restore);
let targetAnimations: GsapAnimation[];
try {
const ownedAnimations = getGsapAnimationsForSelection(selection);
@@ -421,13 +425,7 @@ export function useGsapAwareEditing({
throw error;
}
}
logResize("dom-route", {
next,
offset: offset ?? null,
hadGsapMutation: !!gsapCommitMutation,
});
logResizeSettle(selection.element, "dom-route");
await handleDomBoxSizeCommit(selection, next, offset);
throw new Error("Resize of a GSAP-owned box has no GSAP writer");
},
afterBufferedCommitsSaved: async () => {
await anchorMove?.save();
File diff suppressed because it is too large Load Diff