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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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()));
Expand Down
14 changes: 0 additions & 14 deletions packages/studio/src/components/editor/domEditOverlayGestures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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");
Expand All @@ -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");
Expand All @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -205,23 +193,24 @@ 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({
key: selectionCacheKey(sel),
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);
}
Expand Down Expand Up @@ -285,7 +274,6 @@ export function startGesture(
editScaleY: rect.editScaleY,
contentScaleX,
contentScaleY,
resizeAnchor,
manualEditDragToken,
snapContext,
resizeHandle: kind === "resize" ? (options?.resizeHandle ?? "se") : undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
41 changes: 41 additions & 0 deletions packages/studio/src/components/editor/manualOffsetDrag.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
);
});
10 changes: 5 additions & 5 deletions packages/studio/src/components/editor/manualOffsetDrag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
14 changes: 14 additions & 0 deletions packages/studio/src/hooks/gsapRuntimeKeyframes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
24 changes: 24 additions & 0 deletions packages/studio/src/hooks/useDomGeometryCommit.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading