From 2c21a30a53b86dcb85b674517ad7cffaa5ef9125 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 30 Sep 2026 22:03:42 -0700 Subject: [PATCH 1/3] fix(studio): a move keeps the route it chose at press through drop and save --- .../src/components/editor/DomEditOverlay.tsx | 5 +- .../editor/domEditOverlayGestures.ts | 8 ++- .../editor/domEditOverlayStartGesture.test.ts | 32 ++++++++++ .../src/components/editor/plainTranslate.ts | 4 +- .../propertyPanelTransformCommit.test.ts | 29 ++++++++++ .../editor/propertyPanelTransformCommit.ts | 13 +++-- .../components/editor/propertyPanelTypes.ts | 1 + .../editor/useDomEditNudge.test.tsx | 33 +++++++++++ .../src/components/editor/useDomEditNudge.ts | 6 +- .../editor/useDomEditOverlayGestures.ts | 6 +- .../studio/src/hooks/elementOffsetStager.ts | 16 +++-- .../studio/src/hooks/gsapRuntimeKeyframes.ts | 7 ++- .../src/hooks/useDomGeometryCommit.test.tsx | 30 ++++++++++ .../studio/src/hooks/useDomGeometryCommit.ts | 7 ++- .../src/hooks/useDomGeometryCommits.test.tsx | 2 +- .../studio/src/hooks/useDomGeometryCommits.ts | 8 ++- .../src/hooks/useGsapAwareEditing.test.tsx | 58 ++++++++++++++++--- .../studio/src/hooks/useGsapAwareEditing.ts | 49 ++++++++-------- 18 files changed, 259 insertions(+), 55 deletions(-) diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index bed082c865..084ca278b5 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -10,6 +10,7 @@ import { type BlockedMoveState, type DomEditGroupPathOffsetCommit, type FocusableDomEditOverlay, + type MoveCommitOptions, type GestureState, type GroupGestureState, focusDomEditOverlayElement, @@ -45,7 +46,7 @@ export { hasDomEditRotationChanged, resolveDomEditRotationGesture, } from "./domEditOverlayGestures"; -export type { DomEditGroupPathOffsetCommit } from "./domEditOverlayGestures"; +export type { DomEditGroupPathOffsetCommit, MoveCommitOptions } from "./domEditOverlayGestures"; export interface DomEditOverlayProps { iframeRef: RefObject; @@ -81,7 +82,7 @@ export interface DomEditOverlayProps { onPathOffsetCommit: ( selection: DomEditSelection, next: { x: number; y: number }, - modifiers?: { altKey?: boolean }, + modifiers?: MoveCommitOptions, ) => Promise | void; onGroupPathOffsetCommit: (updates: DomEditGroupPathOffsetCommit[]) => Promise | void; onBoxSizeCommit: ( diff --git a/packages/studio/src/components/editor/domEditOverlayGestures.ts b/packages/studio/src/components/editor/domEditOverlayGestures.ts index 79d74e9220..f25bf195e6 100644 --- a/packages/studio/src/components/editor/domEditOverlayGestures.ts +++ b/packages/studio/src/components/editor/domEditOverlayGestures.ts @@ -218,9 +218,15 @@ export function hasDomEditRotationChanged(initialAngle: number, nextAngle: numbe // These live here (rather than in DomEditOverlay.tsx or useDomEditOverlayGestures.ts) // to break circular imports between those files. +export interface MoveCommitOptions { + altKey?: boolean; + plainTranslate?: boolean; +} + export interface DomEditGroupPathOffsetCommit { selection: DomEditSelection; next: { x: number; y: number }; + plainTranslate?: boolean; } // Refs are stable across renders; values are read via .current. @@ -245,7 +251,7 @@ export type UseDomEditOverlayGesturesOptions = { ( s: DomEditSelection, n: { x: number; y: number }, - m?: { altKey?: boolean }, + m?: MoveCommitOptions, ) => Promise | void >; onGroupPathOffsetCommitRef: RefObject< diff --git a/packages/studio/src/components/editor/domEditOverlayStartGesture.test.ts b/packages/studio/src/components/editor/domEditOverlayStartGesture.test.ts index 868f89694b..9cbcc5dc0f 100644 --- a/packages/studio/src/components/editor/domEditOverlayStartGesture.test.ts +++ b/packages/studio/src/components/editor/domEditOverlayStartGesture.test.ts @@ -43,6 +43,14 @@ function pressOptions(element: HTMLElement) { rafPausedRef: ref(false), onManualDragStartRef: ref(vi.fn()), onBlockedMoveRef: ref(vi.fn()), + onPathOffsetCommitRef: ref(vi.fn()), + snapGuidesRef: ref(null), + groupGestureRef: ref(null), + blockedMoveRef: ref(null), + setOverlayRect: vi.fn(), + suppressNextBoxClickRef: ref(false), + hoverSelectionRef: ref(null), + onCanvasMouseDown: vi.fn(), }; } @@ -83,3 +91,27 @@ describe("a drag press on a centred element without GSAP", () => { expect(element.getAttribute("style")).toBe(style); }); }); + +describe("a drag on an element without GSAP", () => { + it("drops on the route it chose at press, even if GSAP takes the element over mid-drag", () => { + const element = document.createElement("div"); + element.style.setProperty("translate", "40px 30px"); + document.body.append(element); + const opts = pressOptions(element); + const handlers = createDomEditOverlayGestureHandlers(opts as never); + expect(handlers.startGesture("drag", PRESS as never)).toBe(true); + Object.assign(element, { _gsap: { renderTransform: () => {} } }); + const release = { + ...PRESS, + clientX: 110, + clientY: 70, + currentTarget: { releasePointerCapture() {} }, + }; + handlers.onPointerUp(release as never); + expect(opts.onPathOffsetCommitRef.current).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ plainTranslate: true }), + ); + }); +}); diff --git a/packages/studio/src/components/editor/plainTranslate.ts b/packages/studio/src/components/editor/plainTranslate.ts index f0b275a177..d397118418 100644 --- a/packages/studio/src/components/editor/plainTranslate.ts +++ b/packages/studio/src/components/editor/plainTranslate.ts @@ -110,6 +110,6 @@ export function writeTranslatePx(el: HTMLElement, p: Point): void { } /** The position the panel shows and edits: the translate a move writes, unless GSAP positions it. */ -export function readMoveOffset(el: HTMLElement): Point { - return gsapWritesPosition(el) ? readStudioPathOffset(el) : readTranslatePx(el); +export function readMoveOffset(el: HTMLElement, plainTranslate = !gsapWritesPosition(el)): Point { + return plainTranslate ? readTranslatePx(el) : readStudioPathOffset(el); } diff --git a/packages/studio/src/components/editor/propertyPanelTransformCommit.test.ts b/packages/studio/src/components/editor/propertyPanelTransformCommit.test.ts index 94057491c7..eab784eaad 100644 --- a/packages/studio/src/components/editor/propertyPanelTransformCommit.test.ts +++ b/packages/studio/src/components/editor/propertyPanelTransformCommit.test.ts @@ -106,3 +106,32 @@ describe("the panel's position fields on a translate Studio can't read", () => { expect(showToast).toHaveBeenCalledWith(UNREADABLE_TRANSLATE); }); }); + +describe("the panel's position fields", () => { + it("read and move an element without GSAP by its CSS translate, on the route they chose", async () => { + const onSetManualOffset = vi.fn(); + const box = document.createElement("div"); + box.style.setProperty("translate", "40px 30px"); + const element = { id: "box", selector: "#box", element: box } as unknown as DomEditSelection; + const handlers = createTransformCommitHandlers({ + element, + styles: {}, + hasGsapAnimation: false, + gsapAnimId: null, + gsapKeyframes: null, + currentPct: 0, + onCommitAnimatedProperty: undefined, + onAddKeyframe: undefined, + onSetManualOffset, + onSetManualSize: vi.fn(), + onSetManualRotation: vi.fn(), + showToast: vi.fn(), + }); + await handlers.commitManualOffset("x", "100px"); + expect(onSetManualOffset).toHaveBeenCalledWith( + element, + { x: 100, y: 30 }, + { plainTranslate: true }, + ); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelTransformCommit.ts b/packages/studio/src/components/editor/propertyPanelTransformCommit.ts index 0afa234e2e..f368f73e82 100644 --- a/packages/studio/src/components/editor/propertyPanelTransformCommit.ts +++ b/packages/studio/src/components/editor/propertyPanelTransformCommit.ts @@ -1,3 +1,4 @@ +import { gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes"; import type { DomEditSelection } from "./domEditingTypes"; import { readStudioBoxSize } from "./manualEdits"; import { readMoveOffset, UNREADABLE_TRANSLATE } from "./plainTranslate"; @@ -74,14 +75,16 @@ export function createTransformCommitHandlers({ ) ) return; - const current = readMoveOffset(element.element); + const plainTranslate = !gsapWritesPosition(element.element); + const current = readMoveOffset(element.element, plainTranslate); if (!Number.isFinite(current.x) || !Number.isFinite(current.y)) return void showToast?.(UNREADABLE_TRANSLATE); await Promise.resolve( - onSetManualOffset(element, { - x: axis === "x" ? parsed : current.x, - y: axis === "y" ? parsed : current.y, - }), + onSetManualOffset( + element, + { x: axis === "x" ? parsed : current.x, y: axis === "y" ? parsed : current.y }, + { plainTranslate }, + ), ); }; diff --git a/packages/studio/src/components/editor/propertyPanelTypes.ts b/packages/studio/src/components/editor/propertyPanelTypes.ts index ac93927ccc..e79727496f 100644 --- a/packages/studio/src/components/editor/propertyPanelTypes.ts +++ b/packages/studio/src/components/editor/propertyPanelTypes.ts @@ -79,6 +79,7 @@ export interface PropertyPanelProps { onSetManualOffset: ( element: DomEditSelection, next: { x: number; y: number }, + route?: { plainTranslate: boolean }, ) => void | Promise; onSetManualSize: ( element: DomEditSelection, diff --git a/packages/studio/src/components/editor/useDomEditNudge.test.tsx b/packages/studio/src/components/editor/useDomEditNudge.test.tsx index d568f9ae06..673b3483f5 100644 --- a/packages/studio/src/components/editor/useDomEditNudge.test.tsx +++ b/packages/studio/src/components/editor/useDomEditNudge.test.tsx @@ -227,3 +227,36 @@ describe("useDomEditNudge — selection cleanup keyed on stable identity", () => elementB.remove(); }); }); + +describe("useDomEditNudge carries the route its press chose", () => { + beforeEach(() => { + vi.useFakeTimers(); + __resetForTests(); + }); + afterEach(() => vi.useRealTimers()); + + it("commits an element without GSAP on the CSS route", () => { + const host = document.createElement("div"); + const root = createRoot(host); + const element = document.createElement("div"); + document.body.append(element); + const commit = vi.fn(); + act(() => { + root.render( + React.createElement(Harness, { + selection: makeSelection("Dot", element), + onPathOffsetCommit: commit, + }), + ); + }); + act(() => dispatchArrowRight()); + act(() => vi.advanceTimersByTime(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10)); + expect(commit).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ plainTranslate: true }), + ); + act(() => root.unmount()); + element.remove(); + }); +}); diff --git a/packages/studio/src/components/editor/useDomEditNudge.ts b/packages/studio/src/components/editor/useDomEditNudge.ts index 4385cd8124..05e21a496c 100644 --- a/packages/studio/src/components/editor/useDomEditNudge.ts +++ b/packages/studio/src/components/editor/useDomEditNudge.ts @@ -18,6 +18,7 @@ import { import type { BlockedMoveState, DomEditGroupPathOffsetCommit, + MoveCommitOptions, GestureState, GroupGestureState, } from "./domEditOverlayGestures"; @@ -60,7 +61,7 @@ export interface UseDomEditNudgeParams { ( s: DomEditSelection, n: { x: number; y: number }, - m?: { altKey?: boolean }, + m?: MoveCommitOptions, ) => Promise | void >; onGroupPathOffsetCommitRef: RefObject< @@ -150,11 +151,12 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: () const updates: DomEditGroupPathOffsetCommit[] = session.members.map((member) => ({ selection: member.selection, next: applyManualOffsetNudgeCommit(member, session.accum), + plainTranslate: member.plainTranslate, })); const p = paramsRef.current; const commit = session.isGroup ? p.onGroupPathOffsetCommitRef.current(updates) - : p.onPathOffsetCommitRef.current(updates[0].selection, updates[0].next); + : p.onPathOffsetCommitRef.current(updates[0].selection, updates[0].next, updates[0]); void Promise.resolve(commit) .catch(() => { for (const member of session.members) { diff --git a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts index 90157345c3..d7ba9c926b 100644 --- a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts +++ b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts @@ -336,6 +336,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu const updates = groupG.members.map((member) => ({ selection: member.selection, next: applyManualOffsetDragCommit(member, dx, dy), + plainTranslate: member.plainTranslate, })); logDrag("drop", { pointer: `${Math.round(rawDx)},${Math.round(rawDy)}`, @@ -476,7 +477,10 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu box.style.top = `${nextBoxTop}px`; } void Promise.resolve( - opts.onPathOffsetCommitRef.current(sel, finalOffset, { altKey: e.altKey }), + opts.onPathOffsetCommitRef.current(sel, finalOffset, { + altKey: e.altKey, + plainTranslate: g.pathOffsetMember.plainTranslate, + }), ) .catch(() => { if ( diff --git a/packages/studio/src/hooks/elementOffsetStager.ts b/packages/studio/src/hooks/elementOffsetStager.ts index b6efd719ef..2102160a6a 100644 --- a/packages/studio/src/hooks/elementOffsetStager.ts +++ b/packages/studio/src/hooks/elementOffsetStager.ts @@ -6,7 +6,10 @@ import { import { LAYER_REVEAL_PRIOR_POSITION_ATTR } from "../player/lib/timelineElementHelpers"; import type { PatchOperation } from "../utils/sourcePatcher"; import { translatePatch } from "../components/editor/plainTranslate"; -import { gsapWritesPosition } from "./gsapRuntimeKeyframes"; +import { gsapRendersTransform } from "./gsapRuntimeKeyframes"; + +const GSAP_TOOK_OVER = + "The animation took over this layer's position during the move, so it was not saved. Reload the preview."; const ELEMENT_OFFSET_REFUSED: Record = { anchored: "This layer is anchored from its right or bottom edge. Move it in the Code tab.", @@ -58,17 +61,22 @@ function stagePlainTranslate( return { save, rollback }; } -/** Applies a move on the element itself live now: its translate when GSAP does not position it, - * else left/top for a shared-tween element. Throws, after a toast, when it cannot. */ +/** Applies a move on the element itself live now: its translate on the plain route, else + * left/top for a shared-tween element. Throws, after a toast, when it cannot. */ export function stageElementOffset( { commitPositionPatchToHtml, showToast, readOnlyPreview }: ElementOffsetStagerDeps, selection: DomEditSelection, next: { x: number; y: number }, + plainTranslate: boolean, coalesceKey?: string, ): { save: () => Promise; rollback: () => void } { const el = selection.element; if (readOnlyPreview) return { save: () => Promise.resolve(), rollback: () => undefined }; - if (!gsapWritesPosition(el)) { + if (plainTranslate) { + if (gsapRendersTransform(el)) { + showToast(GSAP_TOOK_OVER, "error"); + throw new Error(GSAP_TOOK_OVER); + } return stagePlainTranslate(commitPositionPatchToHtml, selection, next, coalesceKey); } const previous = { position: el.style.position, left: el.style.left, top: el.style.top }; diff --git a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts index 26be36c45a..d8c7a41236 100644 --- a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts +++ b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts @@ -443,8 +443,11 @@ function gsapWritesChannels(el: Element, channels: string[]): boolean { /** GSAP owns this element's position: a tween or hold writes it, or GSAP already renders its * transform (a CSS translate would then apply twice). Everything else moves by plain CSS. */ export function gsapWritesPosition(el: Element): boolean { - const cache = (el as { _gsap?: { renderTransform?: unknown } })._gsap; - return !!cache?.renderTransform || gsapWritesChannels(el, MOVE_CHANNELS); + return gsapRendersTransform(el) || gsapWritesChannels(el, MOVE_CHANNELS); +} + +export function gsapRendersTransform(el: Element): boolean { + return !!(el as { _gsap?: { renderTransform?: unknown } })._gsap?.renderTransform; } const BOX_CHANNELS = [ diff --git a/packages/studio/src/hooks/useDomGeometryCommit.test.tsx b/packages/studio/src/hooks/useDomGeometryCommit.test.tsx index 2469187526..870efe16a7 100644 --- a/packages/studio/src/hooks/useDomGeometryCommit.test.tsx +++ b/packages/studio/src/hooks/useDomGeometryCommit.test.tsx @@ -210,6 +210,36 @@ describe("useDomGeometryCommit, from the package entry", () => { unmount(); }); + it("refuses a CSS move once GSAP has taken the element's translate, and writes nothing", async () => { + const calls = stubPatchServer(); + const { element, hook, unmount } = renderHost(); + element.style.setProperty("translate", "none"); + Object.assign(element, { _gsap: { renderTransform: () => {} } }); + + await expect( + hook().commitPathOffset( + makeSelection("card", element), + { x: 1, y: 2 }, + { plainTranslate: true }, + ), + ).rejects.toThrow(/animation took over/); + expect(calls.patches).toEqual([]); + expect(element.style.getPropertyValue("translate")).toBe("none"); + unmount(); + }); + + it("leaves a later move's translate alone when an earlier move's save fails", async () => { + stubPatchServer(500); + const { element, hook, unmount } = renderHost(); + element.style.setProperty("translate", "40px 30px"); + + const saving = hook().commitPathOffset(makeSelection("card", element), { x: 1, y: 2 }); + element.style.setProperty("translate", "7px 8px"); + await expect(saving).rejects.toThrow(); + expect(element.style.getPropertyValue("translate")).toBe("7px 8px"); + unmount(); + }); + it("keeps the same commits across renders, so the overlay's handlers stay put", () => { stubServer(); const { hook, rerender, unmount } = renderHost(); diff --git a/packages/studio/src/hooks/useDomGeometryCommit.ts b/packages/studio/src/hooks/useDomGeometryCommit.ts index 67fb0e6c49..a1fb2f9101 100644 --- a/packages/studio/src/hooks/useDomGeometryCommit.ts +++ b/packages/studio/src/hooks/useDomGeometryCommit.ts @@ -1,7 +1,10 @@ import { useCallback, useMemo, useRef, useState } from "react"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditing"; -import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay"; +import type { + DomEditGroupPathOffsetCommit, + MoveCommitOptions, +} from "../components/editor/DomEditOverlay"; import { isPreviewBooted } from "../player/store/playerStore"; import type { DomEditCommitOutcome } from "./domEditCommitRunner"; import type { UseDomStyleCommitOptions } from "./useDomStyleCommit"; @@ -29,7 +32,7 @@ export interface DomGeometryCommits { commitPathOffset: ( selection: DomEditSelection, next: { x: number; y: number }, - modifiers?: { altKey?: boolean }, + modifiers?: MoveCommitOptions, ) => Promise; commitGroupPathOffset: (updates: DomEditGroupPathOffsetCommit[]) => Promise; commitBoxSize: ( diff --git a/packages/studio/src/hooks/useDomGeometryCommits.test.tsx b/packages/studio/src/hooks/useDomGeometryCommits.test.tsx index 5bfdfc21ef..1f8c0cd20c 100644 --- a/packages/studio/src/hooks/useDomGeometryCommits.test.tsx +++ b/packages/studio/src/hooks/useDomGeometryCommits.test.tsx @@ -162,7 +162,7 @@ describe("useDomGeometryCommits element position offset", () => { .mockResolvedValue(undefined); const { commits, unmount } = mountCommits(commitPositionPatchToHtml); - await commits().stageElementPositionOffset(selection, { x: 40, y: 20 }).save(); + await commits().stageElementPositionOffset(selection, { x: 40, y: 20 }, false).save(); const patches = commitPositionPatchToHtml.mock.calls[0]![1]; expect(patches).toEqual([ diff --git a/packages/studio/src/hooks/useDomGeometryCommits.ts b/packages/studio/src/hooks/useDomGeometryCommits.ts index c1272a0834..c463de4cbe 100644 --- a/packages/studio/src/hooks/useDomGeometryCommits.ts +++ b/packages/studio/src/hooks/useDomGeometryCommits.ts @@ -62,11 +62,17 @@ export function useDomGeometryCommits({ readOnlyPreview, }: UseDomGeometryCommitsParams) { const stageElementPositionOffset = useCallback( - (selection: DomEditSelection, next: { x: number; y: number }, coalesceKey?: string) => + ( + selection: DomEditSelection, + next: { x: number; y: number }, + plainTranslate: boolean, + coalesceKey?: string, + ) => stageElementOffset( { commitPositionPatchToHtml, showToast, readOnlyPreview }, selection, next, + plainTranslate, coalesceKey, ), [commitPositionPatchToHtml, readOnlyPreview, showToast], diff --git a/packages/studio/src/hooks/useGsapAwareEditing.test.tsx b/packages/studio/src/hooks/useGsapAwareEditing.test.tsx index 06720bd21e..003c529cdc 100644 --- a/packages/studio/src/hooks/useGsapAwareEditing.test.tsx +++ b/packages/studio/src/hooks/useGsapAwareEditing.test.tsx @@ -126,6 +126,7 @@ function mountGroupHandler({ let pathOffsetCommit!: ( selection: DomEditSelection, next: { x: number; y: number }, + route?: { plainTranslate?: boolean }, ) => Promise; function Harness() { const editing = useGsapAwareEditing({ @@ -152,8 +153,11 @@ function mountGroupHandler({ const root = mountReactHarness(); return { groupCommit: (updates: DomEditGroupPathOffsetCommit[]) => groupCommit(updates), - pathOffsetCommit: (selection: DomEditSelection, next: { x: number; y: number }) => - pathOffsetCommit(selection, next), + pathOffsetCommit: ( + selection: DomEditSelection, + next: { x: number; y: number }, + route?: { plainTranslate?: boolean }, + ) => pathOffsetCommit(selection, next, route), root, }; } @@ -171,7 +175,7 @@ describe("useGsapAwareEditing moves of an element GSAP does not position", () => }); const box = { element: document.createElement("div"), id: "box", selector: "#box" }; await act(() => pathOffsetCommit(box as unknown as DomEditSelection, { x: 130.25, y: 90 })); - expect(stageElementPositionOffset).toHaveBeenCalledWith(box, { x: 130.25, y: 90 }); + expect(stageElementPositionOffset).toHaveBeenCalledWith(box, { x: 130.25, y: 90 }, true); expect(save).toHaveBeenCalledTimes(1); expect(gsapCommitMutation).not.toHaveBeenCalled(); expect(makeFetchFallback).not.toHaveBeenCalled(); @@ -180,6 +184,36 @@ describe("useGsapAwareEditing moves of an element GSAP does not position", () => }); }); +describe("useGsapAwareEditing keeps the route a gesture chose at press", () => { + it.each([ + ["the CSS route, on an element GSAP has since taken over", true, () => gsapPositioned("div")], + [ + "the GSAP route, on an element that now looks GSAP-free", + false, + () => document.createElement("div"), + ], + ])("%s", async (_, plainTranslate, makeElement) => { + mocks.drag.mockResolvedValue({ status: "persisted" }); + const save = vi.fn().mockResolvedValue(undefined); + const stageElementPositionOffset = vi.fn(() => ({ save, rollback: vi.fn() })); + const { pathOffsetCommit, groupCommit, root } = mountGroupHandler({ + gsapCommitMutation: vi.fn().mockResolvedValue(undefined), + makeFetchFallback: () => vi.fn().mockResolvedValue([]), + stageElementPositionOffset, + }); + const box = { + element: makeElement(), + id: "box", + selector: "#box", + } as unknown as DomEditSelection; + await act(() => pathOffsetCommit(box, { x: 40, y: 20 }, { plainTranslate })); + await act(() => groupCommit([{ selection: box, next: { x: 40, y: 20 }, plainTranslate }])); + expect(stageElementPositionOffset.mock.calls.length).toBe(plainTranslate ? 2 : 0); + expect(mocks.drag.mock.calls.some((call) => call[0] === box)).toBe(!plainTranslate); + act(() => root.unmount()); + }); +}); + describe("useGsapAwareEditing shared-tween moves", () => { it("saves a single drag through the element's own offset", async () => { mocks.drag.mockResolvedValue({ status: "element-offset" }); @@ -193,7 +227,7 @@ describe("useGsapAwareEditing shared-tween moves", () => { }); const word = { element: gsapPositioned("span"), hfId: "w0", selector: ".w" }; await act(() => pathOffsetCommit(word as unknown as DomEditSelection, { x: 40, y: 20 })); - expect(stageElementPositionOffset).toHaveBeenCalledWith(word, { x: 40, y: 20 }); + expect(stageElementPositionOffset).toHaveBeenCalledWith(word, { x: 40, y: 20 }, false); expect(save).toHaveBeenCalledTimes(1); expect(commitMutation).not.toHaveBeenCalled(); act(() => root.unmount()); @@ -223,8 +257,18 @@ describe("useGsapAwareEditing shared-tween moves", () => { ] as unknown as DomEditGroupPathOffsetCommit[]), ); const groupKey = expect.stringMatching(/^group-drag:\d+$/); - expect(stageElementPositionOffset).toHaveBeenCalledWith(word, { x: 40, y: 20 }, groupKey); - expect(stageElementPositionOffset).toHaveBeenCalledWith(plain, { x: 40, y: 20 }, groupKey); + expect(stageElementPositionOffset).toHaveBeenCalledWith( + word, + { x: 40, y: 20 }, + false, + groupKey, + ); + expect(stageElementPositionOffset).toHaveBeenCalledWith( + plain, + { x: 40, y: 20 }, + true, + groupKey, + ); expect(mocks.drag.mock.calls.some((call) => call[0] === plain)).toBe(false); act(() => root.unmount()); }); @@ -321,7 +365,7 @@ describe("useGsapAwareEditing anchored resize", () => { await act(() => h.resize(h.selection, { width: 300, height: 200 }, { x: -50, y: -25 })); const key = h.commitMutation.mock.calls[0]![2].coalesceKey; expect(key).toMatch(/^tx:/); - expect(h.elementOffset).toHaveBeenCalledWith(h.selection, { x: -50, y: -25 }, key); + expect(h.elementOffset).toHaveBeenCalledWith(h.selection, { x: -50, y: -25 }, true, key); expect(h.anchorSave.mock.invocationCallOrder[0]).toBeGreaterThan( h.commitMutation.mock.invocationCallOrder[0]!, ); diff --git a/packages/studio/src/hooks/useGsapAwareEditing.ts b/packages/studio/src/hooks/useGsapAwareEditing.ts index 363310c2d1..580fd1bd55 100644 --- a/packages/studio/src/hooks/useGsapAwareEditing.ts +++ b/packages/studio/src/hooks/useGsapAwareEditing.ts @@ -28,6 +28,7 @@ import type { import { setElementGsapPosition } from "../utils/elementGsap"; import { logResize, logResizeSettle } from "../utils/resizeDebug"; import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay"; +import type { MoveCommitOptions } from "../components/editor/domEditOverlayGestures"; import { runGestureTransaction } from "./gestureTransaction"; import { gsapWritesBox, @@ -81,6 +82,7 @@ export interface UseGsapAwareEditingParams { stageElementPositionOffset: ( selection: DomEditSelection, next: { x: number; y: number }, + plainTranslate: boolean, coalesceKey?: string, ) => { save: () => Promise; rollback: () => void }; handleDomBoxSizeCommit: ( @@ -153,10 +155,11 @@ export function useGsapAwareEditing({ async ( selection: DomEditSelection, next: { x: number; y: number }, - modifiers?: { altKey?: boolean }, + modifiers?: MoveCommitOptions, ) => { - if (!gsapWritesPosition(selection.element)) - return stageElementPositionOffset(selection, next).save(); + // A gesture carries the route its press decided; the panel and moveTo decide here. + if (modifiers?.plainTranslate ?? !gsapWritesPosition(selection.element)) + return stageElementPositionOffset(selection, next, true).save(); if (gsapCommitMutation) { try { const ownedAnimations = getGsapAnimationsForSelection(selection); @@ -172,7 +175,7 @@ export function useGsapAwareEditing({ makeFetchFallback(selection), modifiers, ); - await saveMove(outcome, () => stageElementPositionOffset(selection, next).save()); + await saveMove(outcome, () => stageElementPositionOffset(selection, next, false).save()); } catch (error) { trackGsapInteractionFailure(error, selection, "drag", "Move animated layer"); throw error; @@ -199,13 +202,9 @@ export function useGsapAwareEditing({ // it survives the N sequential server round-trips) onto each commit — // otherwise each member records its own entry and it takes N presses to undo. const coalesceKey = `group-drag:${++groupDragCommitCounter}`; - // Members are written one at a time, and a write that re-renders the preview - // re-runs the whole script — which still holds the OLD position of every - // member not yet written. Those members snap back to where they started and - // stay there until their own write lands, which is the single element seen - // jumping mid-commit while the rest of the group sat still. The drafted - // positions are already on screen, so holding the render until the last - // member has been written costs nothing and never shows a half-moved group. + // Members are written one at a time, and a re-render re-runs the script with the OLD + // position of every member not yet written, so they snap back until their own write + // lands. The drafts are already on screen: hold the render until the last member. let renderOnCommit = false; const previewFallbackLatch = { pending: false }; const withGroupOptions = (options: CommitMutationOptions): CommitMutationOptions => ({ @@ -238,17 +237,15 @@ export function useGsapAwareEditing({ return Promise.resolve(); }; const preflightAnimations = new Map(); - const offsetMembers = new Set(); - // Editability is user-atomic: prove every member can be written before - // the first source mutation. Network failures after this point retain the - // existing multi-request semantics, but a blocked member can never leave - // earlier siblings partially moved. - // Every member reads the same file, and a preflight writes nothing — so run - // them together. The parse layer shares one in-flight request per file, which - // turns N sequential round trips into one. + // Members saved on themselves, each with its route: true for its CSS translate. + const offsetMembers = new Map(); + // Editability is user-atomic: prove every member can be written before the first source + // mutation, so a blocked member never leaves earlier siblings partially moved. Preflights + // write nothing and share one in-flight parse per file, so they run together. const preflightResults = await Promise.allSettled( - updates.map(async ({ selection }) => { - if (!gsapWritesPosition(selection.element)) return void offsetMembers.add(selection); + updates.map(async ({ selection, plainTranslate }) => { + if (plainTranslate ?? !gsapWritesPosition(selection.element)) + return void offsetMembers.set(selection, true); const animations = await makeFetchFallback(selection, { failOnFetchError: true })(); preflightAnimations.set(selection, animations); const outcome = await tryGsapDragIntercept( @@ -260,7 +257,7 @@ export function useGsapAwareEditing({ undefined, { preflightOnly: true, group: true }, ); - if (outcome.status === "element-offset") offsetMembers.add(selection); + if (outcome.status === "element-offset") offsetMembers.set(selection, false); assertGsapEditPersisted(outcome); }), ); @@ -279,8 +276,9 @@ export function useGsapAwareEditing({ ); for (const [index, { selection, next }] of updates.entries()) { renderOnCommit = index === lastScriptWrite; - if (offsetMembers.has(selection)) { - await stageElementPositionOffset(selection, next, coalesceKey).save(); + const plain = offsetMembers.get(selection); + if (plain !== undefined) { + await stageElementPositionOffset(selection, next, plain, coalesceKey).save(); continue; } try { @@ -415,7 +413,8 @@ export function useGsapAwareEditing({ ); // Saved after the size, under its undo key, so the two are one step. await saveMove(dragOutcome, async () => { - anchorMove = stageElementPositionOffset(selection, offset, coalesceKey); + const plain = !gsapWritesPosition(selection.element); + anchorMove = stageElementPositionOffset(selection, offset, plain, coalesceKey); }); } logResizeSettle(selection.element, ownsDragOffset ? "gsap-scale" : "gsap-size"); From b94fbe85c1712cd86a1cc5ce18a92a81791e429d Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 30 Sep 2026 23:21:15 -0700 Subject: [PATCH 2/3] fix(studio): a refused move drops the transform GSAP folded it into, so the layer goes back --- packages/studio/src/hooks/elementOffsetStager.ts | 11 ++++++++--- .../studio/src/hooks/useDomGeometryCommit.test.tsx | 6 +++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/studio/src/hooks/elementOffsetStager.ts b/packages/studio/src/hooks/elementOffsetStager.ts index 2102160a6a..43f0702dfc 100644 --- a/packages/studio/src/hooks/elementOffsetStager.ts +++ b/packages/studio/src/hooks/elementOffsetStager.ts @@ -9,7 +9,7 @@ import { translatePatch } from "../components/editor/plainTranslate"; import { gsapRendersTransform } from "./gsapRuntimeKeyframes"; const GSAP_TOOK_OVER = - "The animation took over this layer's position during the move, so it was not saved. Reload the preview."; + "The animation took over this layer's position during the move, so it was not saved."; const ELEMENT_OFFSET_REFUSED: Record = { anchored: "This layer is anchored from its right or bottom edge. Move it in the Code tab.", @@ -26,10 +26,14 @@ export interface ElementOffsetStagerDeps { readOnlyPreview?: boolean; } +function gsapOf(el: HTMLElement): { set: (t: Element, v: object) => void } | undefined { + return (el.ownerDocument.defaultView as { gsap?: { set: (t: Element, v: object) => void } }) + ?.gsap; +} + /** The drag draft moved GSAP's x/y; left/top carries the move now, so put them back. */ function settleGsapDraftAtGestureStart(el: HTMLElement): void { - const gsap = (el.ownerDocument.defaultView as { gsap?: { set: (t: Element, v: object) => void } }) - ?.gsap; + const gsap = gsapOf(el); const x = Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-x") ?? ""); const y = Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-y") ?? ""); if (gsap && Number.isFinite(x) && Number.isFinite(y)) gsap.set(el, { x, y }); @@ -74,6 +78,7 @@ export function stageElementOffset( if (readOnlyPreview) return { save: () => Promise.resolve(), rollback: () => undefined }; if (plainTranslate) { if (gsapRendersTransform(el)) { + gsapOf(el)?.set(el, { clearProps: "transform" }); showToast(GSAP_TOOK_OVER, "error"); throw new Error(GSAP_TOOK_OVER); } diff --git a/packages/studio/src/hooks/useDomGeometryCommit.test.tsx b/packages/studio/src/hooks/useDomGeometryCommit.test.tsx index 870efe16a7..7e4f5757f6 100644 --- a/packages/studio/src/hooks/useDomGeometryCommit.test.tsx +++ b/packages/studio/src/hooks/useDomGeometryCommit.test.tsx @@ -210,11 +210,13 @@ describe("useDomGeometryCommit, from the package entry", () => { unmount(); }); - it("refuses a CSS move once GSAP has taken the element's translate, and writes nothing", async () => { + it("refuses a CSS move once GSAP has taken the element's translate, writes nothing, and drops GSAP's fold", async () => { const calls = stubPatchServer(); const { element, hook, unmount } = renderHost(); element.style.setProperty("translate", "none"); Object.assign(element, { _gsap: { renderTransform: () => {} } }); + const set = vi.fn(); + Object.assign(element.ownerDocument.defaultView!, { gsap: { set } }); await expect( hook().commitPathOffset( @@ -224,6 +226,8 @@ describe("useDomGeometryCommit, from the package entry", () => { ), ).rejects.toThrow(/animation took over/); expect(calls.patches).toEqual([]); + expect(set).toHaveBeenCalledWith(element, { clearProps: "transform" }); + delete (element.ownerDocument.defaultView as { gsap?: unknown }).gsap; expect(element.style.getPropertyValue("translate")).toBe("none"); unmount(); }); From 3197a19fa38ba8562ec4b1705a1c3b4024dc24b0 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 30 Sep 2026 23:43:03 -0700 Subject: [PATCH 3/3] fix(studio): a group refuses a GSAP takeover before writing any member, and only a real fold refuses --- .../editor/domEditOverlayStartGesture.test.ts | 31 +++++++++++++++++++ .../src/components/editor/useDomEditNudge.ts | 4 ++- .../studio/src/hooks/elementOffsetStager.ts | 19 ++++++++---- .../studio/src/hooks/gsapRuntimeKeyframes.ts | 10 ++++-- .../src/hooks/useDomGeometryCommit.test.tsx | 27 +++++++++++++--- .../src/hooks/useGsapAwareEditing.test.tsx | 31 ++++++++++++++++++- .../studio/src/hooks/useGsapAwareEditing.ts | 9 ++++-- 7 files changed, 112 insertions(+), 19 deletions(-) diff --git a/packages/studio/src/components/editor/domEditOverlayStartGesture.test.ts b/packages/studio/src/components/editor/domEditOverlayStartGesture.test.ts index 9cbcc5dc0f..a64f8b3172 100644 --- a/packages/studio/src/components/editor/domEditOverlayStartGesture.test.ts +++ b/packages/studio/src/components/editor/domEditOverlayStartGesture.test.ts @@ -115,3 +115,34 @@ describe("a drag on an element without GSAP", () => { ); }); }); + +describe("a group drag of elements without GSAP", () => { + it("drops every member on the route it chose at press", () => { + const items = [0, 1].map((i) => { + const element = document.createElement("div"); + element.style.setProperty("translate", `${i * 10}px 0px`); + document.body.append(element); + const selection = { element, capabilities: { canApplyManualOffset: true } }; + const rect = { left: 0, top: 0, width: 240, height: 160, editScaleX: 1, editScaleY: 1 }; + return { key: `m${i}`, selection, element, rect }; + }); + const onGroupPathOffsetCommit = vi.fn(); + const opts = { + ...pressOptions(items[0]!.element), + groupOverlayItemsRef: { current: items }, + onGroupPathOffsetCommitRef: { current: onGroupPathOffsetCommit }, + setGroupOverlayItems: vi.fn(), + }; + const handlers = createDomEditOverlayGestureHandlers(opts as never); + expect(handlers.startGroupDrag(PRESS as never)).toBe(true); + const release = { + ...PRESS, + clientX: 110, + clientY: 70, + currentTarget: { releasePointerCapture() {} }, + }; + handlers.onPointerUp(release as never); + const updates = onGroupPathOffsetCommit.mock.calls[0]?.[0] as { plainTranslate?: boolean }[]; + expect(updates.map((update) => update.plainTranslate)).toEqual([true, true]); + }); +}); diff --git a/packages/studio/src/components/editor/useDomEditNudge.ts b/packages/studio/src/components/editor/useDomEditNudge.ts index 05e21a496c..096f77112a 100644 --- a/packages/studio/src/components/editor/useDomEditNudge.ts +++ b/packages/studio/src/components/editor/useDomEditNudge.ts @@ -156,7 +156,9 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: () const p = paramsRef.current; const commit = session.isGroup ? p.onGroupPathOffsetCommitRef.current(updates) - : p.onPathOffsetCommitRef.current(updates[0].selection, updates[0].next, updates[0]); + : p.onPathOffsetCommitRef.current(updates[0].selection, updates[0].next, { + plainTranslate: updates[0].plainTranslate, + }); void Promise.resolve(commit) .catch(() => { for (const member of session.members) { diff --git a/packages/studio/src/hooks/elementOffsetStager.ts b/packages/studio/src/hooks/elementOffsetStager.ts index 43f0702dfc..936b4746a0 100644 --- a/packages/studio/src/hooks/elementOffsetStager.ts +++ b/packages/studio/src/hooks/elementOffsetStager.ts @@ -6,7 +6,8 @@ import { import { LAYER_REVEAL_PRIOR_POSITION_ATTR } from "../player/lib/timelineElementHelpers"; import type { PatchOperation } from "../utils/sourcePatcher"; import { translatePatch } from "../components/editor/plainTranslate"; -import { gsapRendersTransform } from "./gsapRuntimeKeyframes"; +import { gsapHoldsTranslate } from "./gsapRuntimeKeyframes"; +import { markStudioSaveErrorAlreadyToasted } from "../utils/studioSaveDiagnostics"; const GSAP_TOOK_OVER = "The animation took over this layer's position during the move, so it was not saved."; @@ -39,6 +40,16 @@ function settleGsapDraftAtGestureStart(el: HTMLElement): void { if (gsap && Number.isFinite(x) && Number.isFinite(y)) gsap.set(el, { x, y }); } +export function refuseGsapTakeover( + el: HTMLElement, + showToast: ElementOffsetStagerDeps["showToast"], +) { + if (!gsapHoldsTranslate(el)) return; + gsapOf(el)?.set(el, { x: 0, y: 0, xPercent: 0, yPercent: 0 }); + showToast(GSAP_TOOK_OVER, "error"); + throw markStudioSaveErrorAlreadyToasted(new Error(GSAP_TOOK_OVER)); +} + let plainMoveCounter = 0; /** GSAP does not position the element: `next` is its whole translate, live now and saved as drawn. */ @@ -77,11 +88,7 @@ export function stageElementOffset( const el = selection.element; if (readOnlyPreview) return { save: () => Promise.resolve(), rollback: () => undefined }; if (plainTranslate) { - if (gsapRendersTransform(el)) { - gsapOf(el)?.set(el, { clearProps: "transform" }); - showToast(GSAP_TOOK_OVER, "error"); - throw new Error(GSAP_TOOK_OVER); - } + refuseGsapTakeover(el, showToast); return stagePlainTranslate(commitPositionPatchToHtml, selection, next, coalesceKey); } const previous = { position: el.style.position, left: el.style.left, top: el.style.top }; diff --git a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts index d8c7a41236..6c668a5ec2 100644 --- a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts +++ b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts @@ -443,11 +443,15 @@ function gsapWritesChannels(el: Element, channels: string[]): boolean { /** GSAP owns this element's position: a tween or hold writes it, or GSAP already renders its * transform (a CSS translate would then apply twice). Everything else moves by plain CSS. */ export function gsapWritesPosition(el: Element): boolean { - return gsapRendersTransform(el) || gsapWritesChannels(el, MOVE_CHANNELS); + const cache = (el as { _gsap?: { renderTransform?: unknown } })._gsap; + return !!cache?.renderTransform || gsapWritesChannels(el, MOVE_CHANNELS); } -export function gsapRendersTransform(el: Element): boolean { - return !!(el as { _gsap?: { renderTransform?: unknown } })._gsap?.renderTransform; +export function gsapHoldsTranslate(el: Element): boolean { + const cache = (el as { _gsap?: Record })._gsap; + return ["x", "y", "xPercent", "yPercent"].some( + (key) => !!Number.parseFloat(String(cache?.[key])), + ); } const BOX_CHANNELS = [ diff --git a/packages/studio/src/hooks/useDomGeometryCommit.test.tsx b/packages/studio/src/hooks/useDomGeometryCommit.test.tsx index 7e4f5757f6..073062f542 100644 --- a/packages/studio/src/hooks/useDomGeometryCommit.test.tsx +++ b/packages/studio/src/hooks/useDomGeometryCommit.test.tsx @@ -210,11 +210,11 @@ describe("useDomGeometryCommit, from the package entry", () => { unmount(); }); - it("refuses a CSS move once GSAP has taken the element's translate, writes nothing, and drops GSAP's fold", async () => { + it("refuses a CSS move GSAP has folded into its x/y, writes nothing, and hands the translate back", async () => { const calls = stubPatchServer(); const { element, hook, unmount } = renderHost(); element.style.setProperty("translate", "none"); - Object.assign(element, { _gsap: { renderTransform: () => {} } }); + Object.assign(element, { _gsap: { renderTransform: () => {}, x: "94px", y: "66px" } }); const set = vi.fn(); Object.assign(element.ownerDocument.defaultView!, { gsap: { set } }); @@ -225,10 +225,27 @@ describe("useDomGeometryCommit, from the package entry", () => { { plainTranslate: true }, ), ).rejects.toThrow(/animation took over/); - expect(calls.patches).toEqual([]); - expect(set).toHaveBeenCalledWith(element, { clearProps: "transform" }); delete (element.ownerDocument.defaultView as { gsap?: unknown }).gsap; - expect(element.style.getPropertyValue("translate")).toBe("none"); + expect(calls.patches).toEqual([]); + expect(set).toHaveBeenCalledWith(element, { x: 0, y: 0, xPercent: 0, yPercent: 0 }); + unmount(); + }); + + it("saves a CSS move GSAP has only parsed, with nothing folded into its x/y", async () => { + const calls = stubPatchServer(); + const { element, hook, unmount } = renderHost(); + Object.assign(element, { + _gsap: { renderTransform: () => {}, x: "0px", y: "0px", xPercent: 0 }, + }); + + await expect( + hook().commitPathOffset( + makeSelection("card", element), + { x: 1, y: 2 }, + { plainTranslate: true }, + ), + ).resolves.toEqual({ ok: true }); + expect(calls.patches).toHaveLength(1); unmount(); }); diff --git a/packages/studio/src/hooks/useGsapAwareEditing.test.tsx b/packages/studio/src/hooks/useGsapAwareEditing.test.tsx index 003c529cdc..14b6d54779 100644 --- a/packages/studio/src/hooks/useGsapAwareEditing.test.tsx +++ b/packages/studio/src/hooks/useGsapAwareEditing.test.tsx @@ -214,6 +214,35 @@ describe("useGsapAwareEditing keeps the route a gesture chose at press", () => { }); }); +describe("useGsapAwareEditing refuses a group GSAP took over before writing any member", () => { + it("writes no member when one CSS-route member has been folded since the press", async () => { + const stageElementPositionOffset = vi.fn(() => ({ save: vi.fn(), rollback: vi.fn() })); + const { groupCommit, root } = mountGroupHandler({ + gsapCommitMutation: vi.fn().mockResolvedValue(undefined), + makeFetchFallback: () => vi.fn().mockResolvedValue([]), + stageElementPositionOffset, + }); + const card = { + element: document.createElement("div"), + id: "card", + } as unknown as DomEditSelection; + const folded = Object.assign(document.createElement("div"), { + _gsap: { renderTransform: () => {}, x: "94px", y: "66px" }, + }); + const box = { element: folded, id: "box" } as unknown as DomEditSelection; + await expect( + act(() => + groupCommit([ + { selection: card, next: { x: 1, y: 2 }, plainTranslate: true }, + { selection: box, next: { x: 1, y: 2 }, plainTranslate: true }, + ]), + ), + ).rejects.toThrow(/animation took over/); + expect(stageElementPositionOffset).not.toHaveBeenCalled(); + act(() => root.unmount()); + }); +}); + describe("useGsapAwareEditing shared-tween moves", () => { it("saves a single drag through the element's own offset", async () => { mocks.drag.mockResolvedValue({ status: "element-offset" }); @@ -365,7 +394,7 @@ describe("useGsapAwareEditing anchored resize", () => { await act(() => h.resize(h.selection, { width: 300, height: 200 }, { x: -50, y: -25 })); const key = h.commitMutation.mock.calls[0]![2].coalesceKey; expect(key).toMatch(/^tx:/); - expect(h.elementOffset).toHaveBeenCalledWith(h.selection, { x: -50, y: -25 }, true, key); + expect(h.elementOffset).toHaveBeenCalledWith(h.selection, { x: -50, y: -25 }, false, key); expect(h.anchorSave.mock.invocationCallOrder[0]).toBeGreaterThan( h.commitMutation.mock.invocationCallOrder[0]!, ); diff --git a/packages/studio/src/hooks/useGsapAwareEditing.ts b/packages/studio/src/hooks/useGsapAwareEditing.ts index 580fd1bd55..7cb30d497e 100644 --- a/packages/studio/src/hooks/useGsapAwareEditing.ts +++ b/packages/studio/src/hooks/useGsapAwareEditing.ts @@ -38,7 +38,7 @@ import { } from "./gsapRuntimeKeyframes"; import { assertGsapEditPersisted, saveMove } from "./gsapEditOutcome"; import type { GsapAnimationFetchOptions } from "./useGsapAnimationFetchFallback"; -import type { ElementOffsetStagerDeps } from "./elementOffsetStager"; +import { refuseGsapTakeover, type ElementOffsetStagerDeps } from "./elementOffsetStager"; import { prepareCropResize, saveCropResize, @@ -157,7 +157,7 @@ export function useGsapAwareEditing({ next: { x: number; y: number }, modifiers?: MoveCommitOptions, ) => { - // A gesture carries the route its press decided; the panel and moveTo decide here. + // A gesture and the panel carry their route; webmcp's moveTo decides here. if (modifiers?.plainTranslate ?? !gsapWritesPosition(selection.element)) return stageElementPositionOffset(selection, next, true).save(); if (gsapCommitMutation) { @@ -244,8 +244,10 @@ export function useGsapAwareEditing({ // write nothing and share one in-flight parse per file, so they run together. const preflightResults = await Promise.allSettled( updates.map(async ({ selection, plainTranslate }) => { - if (plainTranslate ?? !gsapWritesPosition(selection.element)) + if (plainTranslate ?? !gsapWritesPosition(selection.element)) { + refuseGsapTakeover(selection.element, showToast); return void offsetMembers.set(selection, true); + } const animations = await makeFetchFallback(selection, { failOnFetchError: true })(); preflightAnimations.set(selection, animations); const outcome = await tryGsapDragIntercept( @@ -318,6 +320,7 @@ export function useGsapAwareEditing({ makeFetchFallback, trackGsapInteractionFailure, stageElementPositionOffset, + showToast, ], );