diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts
index 26f98d6679..425c1daf3c 100644
--- a/packages/studio-server/src/routes/files.test.ts
+++ b/packages/studio-server/src/routes/files.test.ts
@@ -1714,6 +1714,35 @@ const tl = gsap.timeline({ paused: true });
expect(html.indexOf('tl.set("#card"')).toBeGreaterThan(declaration);
});
+ it("a first write into a script-less sub-composition lands inside its template", async () => {
+ const projectDir = createProjectDir();
+ const app = new Hono();
+ registerFileRoutes(app, createAdapter(projectDir));
+ writeHtml(
+ projectDir,
+ "sub.html",
+ '',
+ );
+
+ const res = await app.request("http://localhost/projects/demo/gsap-mutations/sub.html", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ type: "add",
+ targetSelector: "#card",
+ method: "set",
+ position: 0,
+ properties: { x: 40, y: 30 },
+ global: true,
+ }),
+ });
+
+ expect(res.status).toBe(200);
+ const html = readFileSync(join(projectDir, "sub.html"), "utf-8");
+ expect(html.indexOf('gsap.set("#card"')).toBeGreaterThan(html.indexOf("")).toBeGreaterThan(html.lastIndexOf(""));
+ });
+
it("consolidate-position-writes leaves exactly one position write per selector", async () => {
const projectDir = createProjectDir();
const CORRUPTED = `",
].join("\n");
- html = insertBeforeCloseTag(html, "body", `${bootstrap}\n`) ?? `${html}\n${bootstrap}`;
+ html = insertGsapBootstrap(html, bootstrap);
block = extractGsapScriptBlock(html);
}
if (
diff --git a/packages/studio/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts b/packages/studio/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts
index ab11dbd47d..26e04406cf 100644
--- a/packages/studio/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts
+++ b/packages/studio/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts
@@ -17,19 +17,23 @@ 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`
+// (read back from the drafted `translate`) 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.
vi.mock("./domEditOverlayGeometry", async () => {
const actual = await vi.importActual(
"./domEditOverlayGeometry",
);
- const { readStudioBoxSize, readStudioPathOffset } = await import("./manualEditsDom");
+ const { readStudioBoxSize } = await import("./manualEditsDom");
+ const draftedOffset = (element: HTMLElement) => {
+ const px = element.style.getPropertyValue("translate").match(/-?[\d.]+(?=px)/g) ?? [];
+ return { x: Number(px[0] ?? 0), y: Number(px[1] ?? 0) };
+ };
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 = draftedOffset(element);
return {
x: ORIGIN_CENTER.x + (width - ORIGIN.width) / 2 + offset.x,
y: ORIGIN_CENTER.y + (height - ORIGIN.height) / 2 + offset.y,
diff --git a/packages/studio/src/components/editor/domEditOverlayGestures.ts b/packages/studio/src/components/editor/domEditOverlayGestures.ts
index 471c9a55fc..1ba4f0c845 100644
--- a/packages/studio/src/components/editor/domEditOverlayGestures.ts
+++ b/packages/studio/src/components/editor/domEditOverlayGestures.ts
@@ -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;
diff --git a/packages/studio/src/components/editor/domEditOverlayStartGesture.ts b/packages/studio/src/components/editor/domEditOverlayStartGesture.ts
index 8cba144431..34e6792ad4 100644
--- a/packages/studio/src/components/editor/domEditOverlayStartGesture.ts
+++ b/packages/studio/src/components/editor/domEditOverlayStartGesture.ts
@@ -2,7 +2,6 @@
* 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 {
createManualOffsetDragMember,
@@ -174,18 +173,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;
@@ -283,7 +270,6 @@ export function startGesture(
editScaleY: rect.editScaleY,
contentScaleX,
contentScaleY,
- resizeAnchor,
manualEditDragToken,
snapContext,
resizeHandle: kind === "resize" ? (options?.resizeHandle ?? "se") : undefined,
diff --git a/packages/studio/src/components/editor/manualOffsetDrag.test.ts b/packages/studio/src/components/editor/manualOffsetDrag.test.ts
index 0edbd30f70..af04f9d59e 100644
--- a/packages/studio/src/components/editor/manualOffsetDrag.test.ts
+++ b/packages/studio/src/components/editor/manualOffsetDrag.test.ts
@@ -511,7 +511,7 @@ describe("a stylesheet translate after a committed GSAP drag", () => {
it("is not masked when GSAP does not own the position", () => {
const element = draggedTitle("#title { translate: 0 -200px; }", true, false);
- expect(element.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP);
+ expect(element.style.getPropertyValue("translate")).toMatch(/^calc\(0% \+ -?[\d.]+px\)/);
});
it("is not left behind when nothing sits under the draft's mask", () => {
@@ -566,3 +566,119 @@ describe("resumeGsapTimelines", () => {
expect(() => resumeGsapTimelines(element)).not.toThrow();
});
});
+
+describe("drag in a composition without GSAP", () => {
+ // The commit writes `gsap.set(x, y)`; once GSAP loads it folds the CSS translate and the
+ // transform's translation into x/y, so the drag base must already count them.
+ function committedPosition(css: string, dx: number, dy: number, setup?: (w: Window) => void) {
+ const window = new Window();
+ setup?.(window);
+ window.document.head.innerHTML = ``;
+ const element = window.document.createElement("h1");
+ element.id = "title";
+ window.document.body.append(element);
+ Object.defineProperty(element, "offsetWidth", { value: 146 });
+ Object.defineProperty(element, "offsetHeight", { value: 82 });
+ element.getBoundingClientRect = () => {
+ const x = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)) || 0;
+ const y = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)) || 0;
+ return new window.DOMRect(100 + x, 300 + y, 146, 82);
+ };
+ const result = createManualOffsetDragMember({
+ key: "title",
+ selection: { element } as never,
+ element,
+ rect: { left: 100, top: 300, width: 146, height: 82, editScaleX: 1, editScaleY: 1 },
+ });
+ if (!result.ok) throw new Error(result.reason);
+ const offset = applyManualOffsetDragCommit(result.member, dx, dy);
+ return computeDraggedGsapPosition(element, offset, { x: 0, y: 0 });
+ }
+
+ it("keeps a stylesheet translate, so a 30px drag moves the element 30px", () => {
+ const { newX, newY } = committedPosition("translate: 0 -200px;", 40, 30);
+ expect({ x: newX, y: newY }).toEqual({ x: 40, y: -170 });
+ });
+
+ it("keeps a stylesheet transform translation the same way", () => {
+ const { newX, newY } = committedPosition("transform: translate(-60px, 10px);", 40, 30);
+ expect({ x: newX, y: newY }).toEqual({ x: -20, y: 40 });
+ });
+
+ it("turns the transform's translation by a CSS rotate, as GSAP folds it", () => {
+ const { newX, newY } = committedPosition(
+ "rotate: 90deg; transform: translate(100px, 0);",
+ 40,
+ 30,
+ );
+ expect(newX).toBeCloseTo(40);
+ expect(newY).toBeCloseTo(130);
+ });
+
+ it("scales the transform's translation by a CSS scale", () => {
+ const { newX, newY } = committedPosition("scale: 2; transform: translate(10px, 0);", 40, 30);
+ expect({ x: newX, y: newY }).toEqual({ x: 60, y: 30 });
+ });
+
+ it("falls back to the plain transform, as GSAP does, when the fold does not parse", () => {
+ // Chrome's DOMMatrix rejects an axis rotate inside a transform list; happy-dom's does not.
+ const rejectAxisRotate = (w: Window) => {
+ const Parsed = w.DOMMatrix;
+ class ChromeLike extends Parsed {
+ constructor(init?: string) {
+ if (init?.includes("rotate(x ")) throw new SyntaxError(`Failed to parse '${init}'`);
+ super(init);
+ }
+ }
+ Object.assign(w, { DOMMatrix: ChromeLike });
+ };
+ const { newX, newY } = committedPosition(
+ "rotate: x 30deg; translate: 0 -200px; transform: translate(-60px, 10px);",
+ 40,
+ 30,
+ rejectAxisRotate,
+ );
+ expect({ x: newX, y: newY }).toEqual({ x: -20, y: 40 });
+ });
+
+ it("reads the base from GSAP itself once the page has it", () => {
+ const base: Record = { x: 7, y: 5 };
+ const gsap = {
+ set: () => undefined,
+ getProperty: (_el: Element, prop: string) => base[prop] ?? 0,
+ };
+ const { newX, newY } = committedPosition("translate: 0 -200px;", 40, 30, (w) =>
+ Object.assign(w, { gsap }),
+ );
+ expect({ x: newX, y: newY }).toEqual({ x: 47, y: 35 });
+ });
+
+ it("drafts the committed x/y: a percent translate no longer grows with a resize", () => {
+ const window = new Window();
+ window.document.head.innerHTML = "";
+ const element = window.document.createElement("h1");
+ element.id = "title";
+ window.document.body.append(element);
+ let width = 240;
+ Object.defineProperty(element, "offsetWidth", { get: () => width });
+ Object.defineProperty(element, "offsetHeight", { value: 160 });
+ element.getBoundingClientRect = () => new window.DOMRect(100, 300, width, 160);
+ const result = createManualOffsetDragMember({
+ key: "title",
+ selection: { element } as never,
+ element,
+ rect: { left: 100, top: 300, width: 240, height: 160, editScaleX: 1, editScaleY: 1 },
+ });
+ if (!result.ok) throw new Error(result.reason);
+ width = 340;
+ const offset = applyManualOffsetDragDraft(result.member, 10, 5);
+ const { newX, newY } = computeDraggedGsapPosition(element, offset, result.member.baseGsap);
+ expect({ x: newX, y: newY }).toEqual({ x: 70, y: 5 });
+ expect(element.style.getPropertyValue("translate")).toBe("calc(0% + 70px) calc(-50% + 5px)");
+ });
+
+ it("leaves a -50% centering out of x/y, since GSAP keeps it as xPercent/yPercent", () => {
+ const { newX, newY } = committedPosition("translate: -50% -50%;", 40, 30);
+ expect({ x: newX, y: newY }).toEqual({ x: 40, y: 30 });
+ });
+});
diff --git a/packages/studio/src/components/editor/manualOffsetDrag.ts b/packages/studio/src/components/editor/manualOffsetDrag.ts
index 22a16bd0b0..8c84a5dd54 100644
--- a/packages/studio/src/components/editor/manualOffsetDrag.ts
+++ b/packages/studio/src/components/editor/manualOffsetDrag.ts
@@ -1,6 +1,5 @@
import type { DomEditSelection } from "./domEditing";
import {
- applyStudioPathOffset,
applyStudioPathOffsetDraft,
beginStudioManualEditGesture,
captureStudioPathOffset,
@@ -10,7 +9,11 @@ import {
restoreStudioPathOffset,
type StudioPathOffsetSnapshot,
} from "./manualEdits";
-import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
+import {
+ computeDraggedGsapPosition,
+ cssTranslateForGsapPosition,
+ readGsapPosition,
+} from "../../hooks/draggedGsapPosition";
interface OffsetDragGsap {
set: (el: Element, vars: Record) => void;
@@ -25,35 +28,27 @@ function getOffsetDragGsap(element: HTMLElement): OffsetDragGsap | null {
return gsap?.set && gsap.getProperty ? (gsap as OffsetDragGsap) : null;
}
-/**
- * Live drag preview through the GSAP channel — the SAME channel the commit
- * lands in (a `tl.set`/keyframe on the timeline), so what the user sees while
- * dragging equals what gets written (plan R3/R4). Reuses the commit's
- * base+delta+rotation math so preview and commit agree by construction. Returns
- * true when handled via gsap; false when gsap is unavailable (caller falls back
- * to the CSS draft).
- */
-function applyOffsetDragDraftViaGsap(
- element: HTMLElement,
- offset: { x: number; y: number },
- baseGsap: { x: number; y: number },
-): boolean {
+// The draft shows the x/y the commit writes, from the stable gesture-start base (a live read would
+// integrate frame over frame).
+function applyOffsetDragDraft(member: ManualOffsetDragMember, offset: { x: number; y: number }) {
+ const { element } = member;
+ const { newX, newY } = computeDraggedGsapPosition(element, offset, member.baseGsap);
const gsap = getOffsetDragGsap(element);
- if (!gsap) return false;
- // GSAP owns the transform; neutralize the CSS translate longhand so the two
- // channels can't compose into a doubled position.
+ if (member.cssTranslate || !gsap) {
+ element.style.setProperty(
+ "translate",
+ member.cssTranslate?.(newX, newY) ?? `${newX}px ${newY}px`,
+ );
+ return;
+ }
+ // GSAP owns the transform; neutralize the CSS translate so the two can't compose.
element.style.setProperty("translate", "none");
- // Use the STABLE gesture-start base (captured in JS), NOT `gsap.getProperty`.
- // After `translate: none`, getProperty reads the transform we set last frame,
- // so `base + delta` would integrate frame-over-frame and fling the element.
- const { newX, newY } = computeDraggedGsapPosition(element, offset, baseGsap);
gsap.set(element, { x: newX, y: newY });
- return true;
}
/**
* Live rotation preview through the GSAP channel — the SAME channel the commit
- * lands in (a `tl.set`/keyframe rotation), mirroring `applyOffsetDragDraftViaGsap`.
+ * lands in (a `tl.set`/keyframe rotation), mirroring `applyOffsetDragDraft`.
* GSAP owns the transform rotation, so neutralize the CSS `rotate` longhand to keep
* the two channels from composing. `angle` is the absolute target rotation. Returns
* false when gsap is unavailable (caller falls back to the CSS draft).
@@ -102,9 +97,11 @@ export interface ManualOffsetDragMember {
* re-render (which reverts inline style + wipes the `data-hf-drag-gsap-base-*`
* attrs) can't drop the base. Without this the draft falls back to the LIVE
* transform — i.e. the value it set last frame — and `base + delta` integrates,
- * making the element accelerate away ("flies"). See applyOffsetDragDraftViaGsap.
+ * making the element accelerate away ("flies"). See applyOffsetDragDraft.
*/
baseGsap: { x: number; y: number };
+ /** Without GSAP in the page, how the draft shows x/y (see cssTranslateForGsapPosition). */
+ cssTranslate: ((x: number, y: number) => string) | null;
initialPathOffset: StudioPathOffsetSnapshot;
gestureToken: string;
screenToOffset: ManualOffsetDragMatrix;
@@ -342,12 +339,10 @@ export function createManualOffsetDragMember(input: {
const win = input.element.ownerDocument.defaultView as
| (Window & {
- gsap?: { getProperty?: (el: Element, prop: string) => number };
__timelines?: Record void; paused?: () => boolean }>;
})
| null;
- const gsapX = win?.gsap?.getProperty?.(input.element, "x") || 0;
- const gsapY = win?.gsap?.getProperty?.(input.element, "y") || 0;
+ const { x: gsapX, y: gsapY } = readGsapPosition(input.element);
input.element.setAttribute("data-hf-drag-gsap-base-x", String(gsapX));
input.element.setAttribute("data-hf-drag-gsap-base-y", String(gsapY));
@@ -375,44 +370,27 @@ export function createManualOffsetDragMember(input: {
scaleY: input.rect.editScaleY,
});
const baseGsap = { x: gsapX, y: gsapY };
- if (!measured.ok) {
- // Fallback: when GSAP transforms interfere with probe measurement, use
- // the preview scale as an approximation. The commit path reads the actual
- // GSAP position from the iframe runtime, so visual imprecision during
- // drag is acceptable — the final committed position is always exact.
- const scaleX = input.rect.editScaleX || 1;
- const scaleY = input.rect.editScaleY || 1;
- const w = readTransformWDivisor(input.element);
- return {
- ok: true,
- member: {
- key: input.key,
- selection: input.selection,
- element: input.element,
- initialOffset,
- baseGsap,
- initialPathOffset,
- gestureToken,
- screenToOffset: { a: w / scaleX, b: 0, c: 0, d: w / scaleY },
- originRect: input.rect,
- },
- };
- }
-
- return {
- ok: true,
- member: {
- key: input.key,
- selection: input.selection,
- element: input.element,
- initialOffset,
- baseGsap,
- initialPathOffset,
- gestureToken,
- screenToOffset: measured.matrix,
- originRect: input.rect,
- },
+ const cssTranslate = cssTranslateForGsapPosition(input.element);
+ // Fallback: when GSAP transforms interfere with probe measurement, use the preview scale.
+ const w = readTransformWDivisor(input.element);
+ const screenToOffset = measured.ok
+ ? measured.matrix
+ : { a: w / (input.rect.editScaleX || 1), b: 0, c: 0, d: w / (input.rect.editScaleY || 1) };
+ const member: ManualOffsetDragMember = {
+ key: input.key,
+ selection: input.selection,
+ element: input.element,
+ initialOffset,
+ baseGsap,
+ cssTranslate,
+ initialPathOffset,
+ gestureToken,
+ screenToOffset,
+ originRect: input.rect,
};
+ // Swap the authored translate for the drafted one before a resize can grow its percentages.
+ if (cssTranslate) applyOffsetDragDraft(member, initialOffset);
+ return { ok: true, member };
}
function resolveManualOffsetDragMemberOffset(
@@ -434,12 +412,7 @@ export function applyManualOffsetDragDraft(
dy: number,
): { x: number; y: number } {
const offset = resolveManualOffsetDragMemberOffset(member, dx, dy);
- // Position is single-sourced on the GSAP timeline; preview through gsap.set so
- // the live draft matches the committed `tl.set`/keyframe. CSS draft only when
- // gsap is unavailable (no preview iframe runtime).
- if (!applyOffsetDragDraftViaGsap(member.element, offset, member.baseGsap)) {
- applyStudioPathOffsetDraft(member.element, offset);
- }
+ applyOffsetDragDraft(member, offset);
return offset;
}
@@ -463,13 +436,8 @@ function applyManualOffsetCommitValue(
offset: { x: number; y: number },
): { x: number; y: number } {
restampManualOffsetDragGestureBase(member);
- // Optimistic visual through the GSAP channel (same as the live draft and the
- // committed `tl.set`), so the element holds its dropped position until the
- // source mutation soft-reloads — no transient CSS `--hf-studio-offset` write.
- // CSS apply only when gsap is unavailable.
- if (!applyOffsetDragDraftViaGsap(member.element, offset, member.baseGsap)) {
- applyStudioPathOffset(member.element, offset);
- }
+ // The element holds its dropped position until the source mutation reloads.
+ applyOffsetDragDraft(member, offset);
return offset;
}
@@ -496,9 +464,7 @@ export function applyManualOffsetNudgeDraft(
x: member.initialOffset.x + delta.x,
y: member.initialOffset.y + delta.y,
};
- if (!applyOffsetDragDraftViaGsap(member.element, offset, member.baseGsap)) {
- applyStudioPathOffsetDraft(member.element, offset);
- }
+ applyOffsetDragDraft(member, offset);
return offset;
}
diff --git a/packages/studio/src/hooks/draggedGsapPosition.ts b/packages/studio/src/hooks/draggedGsapPosition.ts
index 8f670a885c..8adf4ce125 100644
--- a/packages/studio/src/hooks/draggedGsapPosition.ts
+++ b/packages/studio/src/hooks/draggedGsapPosition.ts
@@ -1,5 +1,3 @@
-import { roundTo3 } from "../utils/rounding";
-
/**
* Drag → GSAP position math, shared by the commit path
* (`gsapDragCommit.commitGsapPositionFromDrag` / `commitStaticGsapPosition`) and
@@ -8,6 +6,79 @@ import { roundTo3 } from "../utils/rounding";
* it without pulling the GSAP commit graph into its module scope.
*/
+import { roundTo3 } from "../utils/rounding";
+
+const cssValue = (style: CSSStyleDeclaration, prop: string) => {
+ const value = style.getPropertyValue(prop).trim();
+ return value === "none" ? "" : value;
+};
+
+function transformTranslation(view: Window & typeof globalThis, list: string) {
+ const matrix = list ? new view.DOMMatrix(list) : null;
+ return { x: matrix?.m41 ?? 0, y: matrix?.m42 ?? 0 };
+}
+
+// GSAP writes `translate rotate scale transform` as one inline transform; a value the browser rejects
+// (e.g. `rotate: x 30deg`) drops the whole string, leaving GSAP only the plain transform.
+function foldedTranslation(view: Window & typeof globalThis, style: CSSStyleDeclaration) {
+ const [tx = "", ty = ""] = cssValue(style, "translate").split(/\s+/);
+ const rotate = cssValue(style, "rotate");
+ const scale = cssValue(style, "scale");
+ const transform = cssValue(style, "transform");
+ const folded = [
+ rotate && `rotate(${rotate})`,
+ scale && `scale(${scale.split(/\s+/).join(",")})`,
+ transform,
+ ];
+ try {
+ return { tx, ty, ...transformTranslation(view, folded.join(" ").trim()) };
+ } catch {
+ return { tx: "", ty: "", ...transformTranslation(view, transform) };
+ }
+}
+
+function foldedAxis(translate: string, fromTransform: number, size: number) {
+ const raw = Number.parseFloat(translate) || 0;
+ const t = (translate.endsWith("%") ? (raw * size) / 100 : raw) + fromTransform;
+ const centered = t !== 0 && Math.round(size / 2) === Math.round(-t);
+ return { value: centered ? t + size / 2 : t, percent: centered ? -50 : 0, fromTransform };
+}
+
+function readCssFold(element: HTMLElement, view: Window & typeof globalThis) {
+ const moved = foldedTranslation(view, view.getComputedStyle(element));
+ return {
+ x: foldedAxis(moved.tx, moved.x, element.offsetWidth),
+ y: foldedAxis(moved.ty, moved.y, element.offsetHeight),
+ };
+}
+
+type GsapView = Window &
+ typeof globalThis & { gsap?: { getProperty?: (el: Element, p: string) => unknown } };
+
+// The GSAP x/y every position gesture and commit starts from. Without GSAP, what its CSSPlugin will
+// parse once the commit loads it: `translate`, `rotate`, `scale`, `transform`, less a -50% centring.
+export function readGsapPosition(element: HTMLElement): { x: number; y: number } {
+ const view = element.ownerDocument.defaultView as GsapView | null;
+ if (!view) return { x: 0, y: 0 };
+ const getProperty = view.gsap?.getProperty;
+ if (getProperty) {
+ return { x: Number(getProperty(element, "x")), y: Number(getProperty(element, "y")) };
+ }
+ const fold = readCssFold(element, view);
+ return { x: fold.x.value, y: fold.y.value };
+}
+
+// Without GSAP, the inline `translate` that shows x/y where the committed `gsap.set` will. Null with GSAP.
+export function cssTranslateForGsapPosition(
+ element: HTMLElement,
+): ((x: number, y: number) => string) | null {
+ const view = element.ownerDocument.defaultView as GsapView | null;
+ if (!view || typeof view.gsap?.getProperty === "function") return null;
+ const { x: ax, y: ay } = readCssFold(element, view);
+ return (x, y) =>
+ `calc(${ax.percent}% + ${x - ax.fromTransform}px) calc(${ay.percent}% + ${y - ay.fromTransform}px)`;
+}
+
/**
* Translate a studio drag offset into absolute GSAP x/y, accounting for the
* element's rotation and its drag-start base pose. Reads the drag-start
diff --git a/packages/studio/src/hooks/gsapPositionDetection.ts b/packages/studio/src/hooks/gsapPositionDetection.ts
index f140930df9..ecb010c83d 100644
--- a/packages/studio/src/hooks/gsapPositionDetection.ts
+++ b/packages/studio/src/hooks/gsapPositionDetection.ts
@@ -7,25 +7,8 @@
*/
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { usePlayerStore } from "../player/store/playerStore";
-import { getIframeGsap, queryIframeElement } from "./gsapShared";
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
-// fallow-ignore-next-line complexity
-export function readGsapPositionFromIframe(
- iframe: HTMLIFrameElement | null,
- elementSelector: string,
-): { x: number; y: number } | null {
- const gsap = getIframeGsap(iframe);
- if (!gsap) return null;
-
- const element = queryIframeElement(iframe, elementSelector);
- if (!element) return null;
-
- const x = Number(gsap.getProperty(element, "x")) || 0;
- const y = Number(gsap.getProperty(element, "y")) || 0;
- return { x, y };
-}
-
// fallow-ignore-next-line complexity
function animHasPosition(anim: GsapAnimation): boolean {
if (anim.keyframes?.keyframes.some((kf) => "x" in kf.properties || "y" in kf.properties))
diff --git a/packages/studio/src/hooks/gsapResizeIntercept.ts b/packages/studio/src/hooks/gsapResizeIntercept.ts
index dd3f4d2bbb..025f69a23f 100644
--- a/packages/studio/src/hooks/gsapResizeIntercept.ts
+++ b/packages/studio/src/hooks/gsapResizeIntercept.ts
@@ -25,8 +25,8 @@ import {
materializeIfDynamic,
} from "./gsapDragCommit";
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
-import { computeDraggedGsapPosition } from "./draggedGsapPosition";
-import { pickClosestToPlayhead, readGsapPositionFromIframe } from "./gsapPositionDetection";
+import { computeDraggedGsapPosition, readGsapPosition } from "./draggedGsapPosition";
+import { pickClosestToPlayhead } from "./gsapPositionDetection";
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
@@ -337,7 +337,7 @@ export async function tryGsapResizeIntercept(
// 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 gsapPos = readGsapPosition(selection.element);
const { baseGsapX, baseGsapY } = computeDraggedGsapPosition(
selection.element,
{ x: 0, y: 0 },
diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.test.ts b/packages/studio/src/hooks/gsapRuntimeBridge.test.ts
index 39c53ef352..f5708cf5e4 100644
--- a/packages/studio/src/hooks/gsapRuntimeBridge.test.ts
+++ b/packages/studio/src/hooks/gsapRuntimeBridge.test.ts
@@ -33,6 +33,7 @@ function fakeIframe(elId: string, children: unknown[]): HTMLIFrameElement {
// so without the guard the drag would reach commitMutation (resurrecting the tween).
const fakeElement = {
id: "puck-b",
+ ownerDocument: { defaultView: { gsap: { getProperty: () => 0 } } },
style: { getPropertyValue: () => "" },
getAttribute: () => null,
getBoundingClientRect: () => ({ top: 100, left: 100, width: 50, height: 50 }),
@@ -461,6 +462,32 @@ describe("tryGsapDragIntercept — autoKeyframeEnabled toggle (#1808)", () => {
expect(types).not.toContain("add-keyframe");
});
+ it("keeps an xPercent/yPercent set: it does not write x/y, so it is no duplicate", async () => {
+ usePlayerStore.setState({ currentTime: 1 });
+ const centering = {
+ id: "#puck-b-set-position",
+ targetSelector: "#puck-b",
+ propertyGroup: "position",
+ method: "set",
+ properties: { xPercent: -50, yPercent: -50 },
+ position: 0,
+ duration: 0,
+ } as unknown as GsapAnimation;
+ const animations = [centering, keyframedPositionAnim];
+ const commitMutation = vi.fn();
+ await tryGsapDragIntercept(
+ selection,
+ { x: -50, y: 0 },
+ animations,
+ fakeIframe("puck-b", []),
+ commitMutation,
+ async () => animations,
+ );
+ const types = commitMutation.mock.calls.map(([, mutation]) => mutation.type);
+ expect(types).not.toContain("consolidate-position-writes");
+ expect(types).toContain("add-keyframe");
+ });
+
it("still adds/updates a keyframe at the playhead when the toggle is on (default)", async () => {
const { handled, types } = await runAutoKeyframeDrag(true);
expect(handled).toEqual({ status: "persisted" });
diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts
index 6c8448915c..12912e969b 100644
--- a/packages/studio/src/hooks/gsapRuntimeBridge.ts
+++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts
@@ -30,11 +30,8 @@ import { resolveTweenDuration } from "../utils/globalTimeCompiler";
import { roundTo3 } from "../utils/rounding";
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsapShared";
-import {
- findGsapPositionAnimation,
- pickClosestToPlayhead,
- readGsapPositionFromIframe,
-} from "./gsapPositionDetection";
+import { findGsapPositionAnimation, pickClosestToPlayhead } from "./gsapPositionDetection";
+import { readGsapPosition } from "./draggedGsapPosition";
import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
import { getAnimationsForElement } from "./gsapElementMatch";
import {
@@ -60,6 +57,8 @@ export const POSITION_CHANNELS: string[] = [
"translateY",
];
const POSITION_CHANNEL_SET = new Set(POSITION_CHANNELS);
+const XY_CHANNEL_SET = new Set(["x", "y"]);
+const writesXY = (a: GsapAnimation) => animationWritesAnyProperty(a, XY_CHANNEL_SET);
const ROTATION_CHANNELS: string[] = ["rotation", "rotationX", "rotationY", "rotationZ"];
const ROTATION_CHANNEL_SET = new Set(ROTATION_CHANNELS);
@@ -259,8 +258,7 @@ export async function tryGsapDragIntercept(
// the live keyframed/real tween if present (else any), strip the rest, so the
// commit below updates ONE write instead of fighting duplicates.
let workingAnimations = animations;
- const isPosWrite = (a: GsapAnimation) =>
- a.targetSelector === selector && a.propertyGroup === "position";
+ const isPosWrite = (a: GsapAnimation) => a.targetSelector === selector && writesXY(a);
if (animations.filter(isPosWrite).length > 1 && fetchFallbackAnimations) {
const fresh = await fetchFallbackAnimations();
const dupes = fresh.filter(isPosWrite);
@@ -301,7 +299,7 @@ export async function tryGsapDragIntercept(
}
}
- const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
+ const gsapPos = readGsapPosition(selection.element);
// STATIC case (single source of truth = GSAP timeline): the element has no LIVE
// keyframed/tweened position motion. Use the strict non-hold check — a leftover
@@ -343,13 +341,10 @@ export async function tryGsapDragIntercept(
// current ID and avoid a stale-ID remove that creates duplicate tweens.
if (fetchFallbackAnimations) {
const fresh = await fetchFallbackAnimations();
- const freshMatch = fresh.find(
- (a) =>
- a.targetSelector === posAnim!.targetSelector && a.propertyGroup === posAnim!.propertyGroup,
- );
- if (freshMatch && freshMatch.id !== posAnim.id) {
- posAnim = freshMatch;
- }
+ const freshMatch = fresh.some((a) => a.id === posAnim!.id)
+ ? undefined
+ : fresh.find((a) => a.targetSelector === posAnim!.targetSelector && writesXY(a));
+ if (freshMatch) posAnim = freshMatch;
}
const cbs = { commitMutation, fetchAnimations: fetchFallbackAnimations };
diff --git a/packages/studio/src/hooks/useGsapAwareEditing.test.tsx b/packages/studio/src/hooks/useGsapAwareEditing.test.tsx
index 6291ca6f75..26b2bc83d6 100644
--- a/packages/studio/src/hooks/useGsapAwareEditing.test.tsx
+++ b/packages/studio/src/hooks/useGsapAwareEditing.test.tsx
@@ -12,7 +12,6 @@ import { mountReactHarness } from "./domSelectionTestHarness";
const mocks = vi.hoisted(() => ({
resize: vi.fn(),
drag: vi.fn(),
- readPosition: vi.fn(),
setPosition: vi.fn(),
commitAnimatedProperty: vi.fn(),
commitAnimatedProperties: vi.fn(),
@@ -24,9 +23,6 @@ vi.mock("./gsapRuntimeBridge", () => ({
tryGsapDragIntercept: mocks.drag,
tryGsapRotationIntercept: vi.fn(),
}));
-vi.mock("./gsapPositionDetection", () => ({
- readGsapPositionFromIframe: mocks.readPosition,
-}));
vi.mock("../utils/elementGsap", () => ({ setElementGsapPosition: mocks.setPosition }));
vi.mock("./useAnimatedPropertyCommit", () => ({
useAnimatedPropertyCommit: () => ({
@@ -276,7 +272,6 @@ describe("useGsapAwareEditing anchored resize", () => {
});
mocks.resize.mockReturnValue(pendingResize);
mocks.drag.mockResolvedValue({ status: "persisted" });
- mocks.readPosition.mockReturnValue({ x: 120.4, y: 80.2 });
const h = mountResizeHandler([]);
h.selection.element.setAttribute("data-hf-drag-gsap-base-x", "120.4");
h.selection.element.setAttribute("data-hf-drag-gsap-base-y", "80.2");
diff --git a/packages/studio/src/hooks/useGsapAwareEditing.ts b/packages/studio/src/hooks/useGsapAwareEditing.ts
index 4f294423c5..0d806d27c9 100644
--- a/packages/studio/src/hooks/useGsapAwareEditing.ts
+++ b/packages/studio/src/hooks/useGsapAwareEditing.ts
@@ -16,8 +16,7 @@ import {
tryGsapRotationIntercept,
} from "./gsapRuntimeBridge";
import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
-import { computeDraggedGsapPosition } from "./draggedGsapPosition";
-import { readGsapPositionFromIframe } from "./gsapPositionDetection";
+import { computeDraggedGsapPosition, readGsapPosition } from "./draggedGsapPosition";
import { selectorFromSelection } from "./gsapShared";
import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit";
import {
@@ -360,10 +359,7 @@ 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 gsapPos = readGsapPosition(selection.element);
const { newX, newY } = computeDraggedGsapPosition(selection.element, offset, gsapPos);
logResize("sync-settle", { gsapPos, offset, newX, newY });
setElementGsapPosition(selection.element, newX, newY);
diff --git a/packages/studio/src/utils/elementGsap.ts b/packages/studio/src/utils/elementGsap.ts
index c4d1df5890..5ceb6d0804 100644
--- a/packages/studio/src/utils/elementGsap.ts
+++ b/packages/studio/src/utils/elementGsap.ts
@@ -37,9 +37,3 @@ export function setElementGsapScale(element: HTMLElement, x: number, y: number):
gsap.set(element, { scaleX: x, scaleY: y });
return true;
}
-
-/** The element's GSAP numeric property, or null when unreadable. */
-export function readElementGsapNumber(element: HTMLElement, prop: string): number | null {
- const value = Number(gsapOf(element)?.getProperty?.(element, prop));
- return Number.isFinite(value) ? value : null;
-}