From fe04bb8f76cb9e58e0db7b410a44b0a5f5f847cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 30 Sep 2026 08:50:44 -0400 Subject: [PATCH] fix(studio): start every drag from one position reader and draft what it commits --- .../studio-server/src/routes/files.test.ts | 29 ++++ packages/studio-server/src/routes/files.ts | 10 +- .../anchoredResizeCommitFeedsOffset.test.ts | 10 +- .../editor/domEditOverlayGestures.ts | 14 -- .../editor/domEditOverlayStartGesture.ts | 14 -- .../editor/manualOffsetDrag.test.ts | 118 +++++++++++++++- .../src/components/editor/manualOffsetDrag.ts | 128 +++++++----------- .../studio/src/hooks/draggedGsapPosition.ts | 75 +++++++++- .../studio/src/hooks/gsapPositionDetection.ts | 17 --- .../studio/src/hooks/gsapResizeIntercept.ts | 6 +- .../src/hooks/gsapRuntimeBridge.test.ts | 27 ++++ .../studio/src/hooks/gsapRuntimeBridge.ts | 25 ++-- .../src/hooks/useGsapAwareEditing.test.tsx | 5 - .../studio/src/hooks/useGsapAwareEditing.ts | 8 +- packages/studio/src/utils/elementGsap.ts | 6 - 15 files changed, 324 insertions(+), 168 deletions(-) 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; -}