From e27cae02619037cc01299fe86877ade974c1b3bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 3 Oct 2026 14:32:46 -0700 Subject: [PATCH] fix(studio): a resize saves through the route its press chose, as a move does (#4943) --- .../src/components/editor/DomEditOverlay.tsx | 1 + .../editor/domEditOverlayGestures.ts | 1 + .../editor/domEditOverlayStartGesture.ts | 4 ++-- .../src/components/editor/manualOffsetDrag.ts | 6 +++--- .../src/components/editor/plainTranslate.ts | 8 +++++--- .../components/editor/propertyPanelHelpers.ts | 4 ++-- .../editor/propertyPanelTransformCommit.ts | 4 ++-- .../editor/useDomEditOverlayGestures.ts | 2 +- .../studio/src/hooks/gsapRuntimeKeyframes.ts | 19 +++++++++++++++--- .../studio/src/hooks/useDomGeometryCommit.ts | 5 +++-- .../src/hooks/useGsapAwareEditing.test.tsx | 16 +++++++++++++++ .../studio/src/hooks/useGsapAwareEditing.ts | 20 +++++++++++-------- .../studio/src/hooks/useGsapAwareGroupMove.ts | 4 ++-- 13 files changed, 66 insertions(+), 28 deletions(-) diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index 95636a4fec..e740013132 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -94,6 +94,7 @@ export interface DomEditOverlayProps { next: { width: number; height: number }, offset?: { x: number; y: number }, restore?: () => void, + route?: { plainTranslate: boolean }, ) => Promise | void; onRotationCommit: (selection: DomEditSelection, next: RotationCommit) => Promise | void; onStyleCommit?: (property: string, value: string) => Promise | void; diff --git a/packages/studio/src/components/editor/domEditOverlayGestures.ts b/packages/studio/src/components/editor/domEditOverlayGestures.ts index 069dae076d..d5ea88acaf 100644 --- a/packages/studio/src/components/editor/domEditOverlayGestures.ts +++ b/packages/studio/src/components/editor/domEditOverlayGestures.ts @@ -275,6 +275,7 @@ export type UseDomEditOverlayGesturesOptions = { n: { width: number; height: number }, offset?: { x: number; y: number }, restore?: () => void, + route?: { plainTranslate: boolean }, ) => Promise | void >; onRotationCommitRef: RefObject< diff --git a/packages/studio/src/components/editor/domEditOverlayStartGesture.ts b/packages/studio/src/components/editor/domEditOverlayStartGesture.ts index 9d254697a6..ea43a7aa41 100644 --- a/packages/studio/src/components/editor/domEditOverlayStartGesture.ts +++ b/packages/studio/src/components/editor/domEditOverlayStartGesture.ts @@ -32,7 +32,7 @@ import { type UseDomEditOverlayGesturesOptions, } from "./domEditOverlayGestures"; import { collectSnapContext, buildExcludeElements } from "./snapTargetCollection"; -import { gsapWritesRotation } from "../../hooks/gsapRuntimeKeyframes"; +import { editsPlainCss } from "../../hooks/gsapRuntimeKeyframes"; import { logResize, resetResizeMoveLog } from "../../utils/resizeDebug"; import { logDrag, readDragPositions, resetDragMoveLog } from "../../utils/dragDebug"; @@ -234,7 +234,7 @@ export function startGesture( try { // Rotation base: the angle the element shows. An element GSAP does not turn, or a plain-translate // move, never asks GSAP: reading a property makes it bake the CSS into its transform. - const plain = !!pathOffsetMember?.plainTranslate || !gsapWritesRotation(sel.element); + const plain = !!pathOffsetMember?.plainTranslate || editsPlainCss(sel.element, "rotate"); const plainRotation = plain && kind === "rotate" ? readCssRotationTarget(sel.element) : null; const rotation = { angle: readRotationBase(sel.element, plain) }; const overlayBounds = overlayEl?.getBoundingClientRect(); diff --git a/packages/studio/src/components/editor/manualOffsetDrag.ts b/packages/studio/src/components/editor/manualOffsetDrag.ts index 48002d697d..1e37f50fe4 100644 --- a/packages/studio/src/components/editor/manualOffsetDrag.ts +++ b/packages/studio/src/components/editor/manualOffsetDrag.ts @@ -11,7 +11,7 @@ import { type StudioPathOffsetSnapshot, } from "./manualEdits"; import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition"; -import { gsapWritesBox, gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes"; +import { editsPlainCss } from "../../hooks/gsapRuntimeKeyframes"; import { readTranslatePx, UNREADABLE_TRANSLATE, writeTranslatePx } from "./plainTranslate"; import type { StudioEditRevert } from "../../utils/studioPendingEdits"; @@ -318,8 +318,8 @@ export function createManualOffsetDragMember(input: { rect: ManualOffsetDragRect; gesture?: "drag" | "nudge" | "resize"; // resize: the anchor that keeps its centre planted }): ManualOffsetDragMemberResult { - const gsapOwns = input.gesture === "resize" ? gsapWritesBox : gsapWritesPosition; - const plainTranslate = !!input.gesture && !gsapOwns(input.element); + const plainTranslate = + !!input.gesture && editsPlainCss(input.element, input.gesture === "resize" ? "resize" : "move"); // The APPLIED offset, never a dormant var, so a stale one can't fling the element off-screen. const initialOffset = plainTranslate ? readTranslatePx(input.element) diff --git a/packages/studio/src/components/editor/plainTranslate.ts b/packages/studio/src/components/editor/plainTranslate.ts index 8fdb341042..43a449e247 100644 --- a/packages/studio/src/components/editor/plainTranslate.ts +++ b/packages/studio/src/components/editor/plainTranslate.ts @@ -1,5 +1,5 @@ import { roundTo3 } from "../../utils/rounding"; -import { gsapWritesPosition, gsapWritesRotation } from "../../hooks/gsapRuntimeKeyframes"; +import { editsPlainCss } from "../../hooks/gsapRuntimeKeyframes"; import { readCssRotation } from "../../hooks/draggedGsapPosition"; import { readStudioPathOffset, readStudioRotation, styleUsesStudioOffset } from "./manualEditsDom"; import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers"; @@ -142,10 +142,12 @@ 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, plainTranslate = !gsapWritesPosition(el)): Point { +export function readMoveOffset(el: HTMLElement, plainTranslate = editsPlainCss(el, "move")): Point { return plainTranslate ? readTranslatePx(el) : readStudioPathOffset(el); } export function readShownRotation(el: HTMLElement): { angle: number } { - return gsapWritesRotation(el) ? readStudioRotation(el) : { angle: roundTo3(readCssRotation(el)) }; + return editsPlainCss(el, "rotate") + ? { angle: roundTo3(readCssRotation(el)) } + : readStudioRotation(el); } diff --git a/packages/studio/src/components/editor/propertyPanelHelpers.ts b/packages/studio/src/components/editor/propertyPanelHelpers.ts index 753675968e..c91ffcfb49 100644 --- a/packages/studio/src/components/editor/propertyPanelHelpers.ts +++ b/packages/studio/src/components/editor/propertyPanelHelpers.ts @@ -9,7 +9,7 @@ import { playbackStartAttributeForElement, readPlaybackStartAttributes, } from "../../player/lib/timelineElementHelpers"; -import { GSAP_TRANSFORM_KEYS, gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes"; +import { editsPlainCss, GSAP_TRANSFORM_KEYS } from "../../hooks/gsapRuntimeKeyframes"; export type { BackgroundRemovalProgress, @@ -496,7 +496,7 @@ export function readGsapRuntimeValuesForPanel( const el = findPreviewNode(iframe.contentDocument, element); if (!el) return null; const propKeys = collectPanelPropKeys(gsapAnimations); - const readsTransform = gsapWritesPosition(el); + const readsTransform = !editsPlainCss(el, "move"); const result: Record = {}; for (const prop of propKeys) { if (!readsTransform && GSAP_TRANSFORM_KEYS.has(prop)) continue; diff --git a/packages/studio/src/components/editor/propertyPanelTransformCommit.ts b/packages/studio/src/components/editor/propertyPanelTransformCommit.ts index f368f73e82..e8949c5230 100644 --- a/packages/studio/src/components/editor/propertyPanelTransformCommit.ts +++ b/packages/studio/src/components/editor/propertyPanelTransformCommit.ts @@ -1,4 +1,4 @@ -import { gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes"; +import { editsPlainCss } from "../../hooks/gsapRuntimeKeyframes"; import type { DomEditSelection } from "./domEditingTypes"; import { readStudioBoxSize } from "./manualEdits"; import { readMoveOffset, UNREADABLE_TRANSLATE } from "./plainTranslate"; @@ -75,7 +75,7 @@ export function createTransformCommitHandlers({ ) ) return; - const plainTranslate = !gsapWritesPosition(element.element); + const plainTranslate = editsPlainCss(element.element, "move"); const current = readMoveOffset(element.element, plainTranslate); if (!Number.isFinite(current.x) || !Number.isFinite(current.y)) return void showToast?.(UNREADABLE_TRANSLATE); diff --git a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts index bd792ae38a..c7c6893844 100644 --- a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts +++ b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts @@ -536,7 +536,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu if (finalOffset) restoreStudioPathOffset(sel.element, g.initialPathOffset); }; void Promise.resolve( - opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore), + opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore, member), ) .then((result) => trackPreviewEditResult("resize", "drag", result)) .catch((error) => { diff --git a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts index 53e721ab1c..28daf75ee0 100644 --- a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts +++ b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts @@ -449,12 +449,12 @@ export const ROTATION_CHANNELS: string[] = [ /** 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 { +function gsapWritesPosition(el: Element): boolean { return gsapRendersTransform(el) || gsapWritesChannels(el, MOVE_CHANNELS); } /** `gsapWritesPosition` for a rotate: everything else turns by its own CSS `rotate`. */ -export function gsapWritesRotation(el: Element): boolean { +function gsapWritesRotation(el: Element): boolean { return gsapRendersTransform(el) || gsapWritesChannels(el, ROTATION_CHANNELS); } @@ -474,10 +474,23 @@ const BOX_CHANNELS = [ ]; /** GSAP owns this element's box: its position, or any property the CSS box writer sets. Else a resize writes CSS. */ -export function gsapWritesBox(el: Element): boolean { +function gsapWritesBox(el: Element): boolean { return gsapWritesPosition(el) || gsapWritesChannels(el, BOX_CHANNELS); } +export type EditGesture = "move" | "resize" | "rotate"; +const GSAP_OWNS: Record boolean> = { + move: gsapWritesPosition, + resize: gsapWritesBox, + rotate: gsapWritesRotation, +}; + +/** The one GSAP-or-CSS route decision. Make it when an edit starts and carry it: mid-gesture + * writes make GSAP render the element, so asking again at commit can flip the route. */ +export function editsPlainCss(el: Element, gesture: EditGesture): boolean { + return !GSAP_OWNS[gesture](el); +} + /** `hasNonHoldTweenForElement` for an element in hand, read from its own window's timelines. */ export function elementHasNonHoldTween(el: Element, channels?: string[]): boolean { const win = el.ownerDocument.defaultView as { diff --git a/packages/studio/src/hooks/useDomGeometryCommit.ts b/packages/studio/src/hooks/useDomGeometryCommit.ts index d8cee8f977..b623c59d96 100644 --- a/packages/studio/src/hooks/useDomGeometryCommit.ts +++ b/packages/studio/src/hooks/useDomGeometryCommit.ts @@ -42,6 +42,7 @@ export interface DomGeometryCommits { next: { width: number; height: number }, offset?: { x: number; y: number }, restore?: () => void, + route?: { plainTranslate: boolean }, ) => Promise; commitRotation: ( selection: DomEditSelection, @@ -174,8 +175,8 @@ export function useDomGeometryCommit({ saved(() => handleGsapAwarePathOffsetCommit(selection, next, modifiers)), commitGroupPathOffset: (updates) => saved(() => handleGsapAwareGroupPathOffsetCommit(updates)), - commitBoxSize: (selection, next, offset, restore) => - saved(() => handleGsapAwareBoxSizeCommit(selection, next, offset, restore), restore), + commitBoxSize: (selection, next, offset, restore, route) => + saved(() => handleGsapAwareBoxSizeCommit(selection, next, offset, restore, route), restore), commitRotation: (selection, next) => saved(() => handleGsapAwareRotationCommit(selection, next)), waitForPendingSaves: () => Promise.allSettled([...pending]).then(noop), diff --git a/packages/studio/src/hooks/useGsapAwareEditing.test.tsx b/packages/studio/src/hooks/useGsapAwareEditing.test.tsx index 9568bfda5e..ac469a77c5 100644 --- a/packages/studio/src/hooks/useGsapAwareEditing.test.tsx +++ b/packages/studio/src/hooks/useGsapAwareEditing.test.tsx @@ -74,6 +74,7 @@ function mountResizeHandler( size: { width: number; height: number }, offset?: { x: number; y: number }, restore?: () => void, + route?: { plainTranslate: boolean }, ) => Promise) | null = null; let property: ReturnType["commitAnimatedProperty"] | null = null; @@ -234,6 +235,21 @@ describe("useGsapAwareEditing keeps the route a gesture chose at press", () => { }); }); +describe("useGsapAwareEditing keeps the box route a resize chose at press", () => { + it.each([ + ["the CSS route, on an element GSAP has since taken over", true], + ["the GSAP route, on an element that now looks GSAP-free", false], + ])("%s", async (_, plainTranslate) => { + mocks.resize.mockResolvedValue({ status: "persisted" }); + const h = mountResizeHandler([], [], plainTranslate); + const size = { width: 300, height: 200 }; + await act(() => h.resize(h.selection, size, undefined, vi.fn(), { plainTranslate })); + expect(h.fallback).toHaveBeenCalledTimes(plainTranslate ? 1 : 0); + expect(mocks.resize).toHaveBeenCalledTimes(plainTranslate ? 0 : 1); + act(() => h.root.unmount()); + }); +}); + 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() })); diff --git a/packages/studio/src/hooks/useGsapAwareEditing.ts b/packages/studio/src/hooks/useGsapAwareEditing.ts index e86722c2fe..fd6ca6ada8 100644 --- a/packages/studio/src/hooks/useGsapAwareEditing.ts +++ b/packages/studio/src/hooks/useGsapAwareEditing.ts @@ -30,9 +30,7 @@ import { logResize, logResizeSettle } from "../utils/resizeDebug"; import type { MoveCommitOptions } from "../components/editor/domEditOverlayGestures"; import { runGestureTransaction } from "./gestureTransaction"; import { - gsapWritesBox, - gsapWritesPosition, - gsapWritesRotation, + editsPlainCss, hasNonHoldTweenForElement, POSITION_CHANNELS, } from "./gsapRuntimeKeyframes"; @@ -151,7 +149,7 @@ export function useGsapAwareEditing({ modifiers?: MoveCommitOptions, ) => { const writes = observeGsapGesture(gsapCommitMutation); - if (modifiers?.plainTranslate ?? !gsapWritesPosition(selection.element)) { + if (modifiers?.plainTranslate ?? editsPlainCss(selection.element, "move")) { const result = await stageElementPositionOffset(selection, next, true).save(); return writes.finish(result?.changed === true); } @@ -207,9 +205,10 @@ export function useGsapAwareEditing({ next: { width: number; height: number }, offset?: { x: number; y: number }, restore: () => void = () => undefined, + route?: { plainTranslate: boolean }, ) => { const writes = observeGsapGesture(gsapCommitMutation); - if (!gsapWritesBox(selection.element)) { + if (route?.plainTranslate ?? editsPlainCss(selection.element, "resize")) { const result = await handleDomBoxSizeCommit(selection, next, offset, restore); return writes.finish(result?.changed === true); } @@ -258,8 +257,13 @@ export function useGsapAwareEditing({ ); // Saved after the size, under its undo key, so the two are one step. await saveMove(dragOutcome, async () => { - const plain = !gsapWritesPosition(selection.element); - anchorMove = stageElementPositionOffset(selection, offset, plain, coalesceKey); + const plainAfterSettle = editsPlainCss(selection.element, "move"); + anchorMove = stageElementPositionOffset( + selection, + offset, + plainAfterSettle, + coalesceKey, + ); }); } }; @@ -360,7 +364,7 @@ export function useGsapAwareEditing({ const handleGsapAwareRotationCommit = useCallback( async (selection: DomEditSelection, next: RotationCommit) => { const writes = observeGsapGesture(gsapCommitMutation); - if (next.plain || !gsapWritesRotation(selection.element)) { + if (next.plain || editsPlainCss(selection.element, "rotate")) { const result = await handleDomRotationCommit(selection, next); return writes.finish(result?.changed === true); } diff --git a/packages/studio/src/hooks/useGsapAwareGroupMove.ts b/packages/studio/src/hooks/useGsapAwareGroupMove.ts index a7f115c1e2..a18836761a 100644 --- a/packages/studio/src/hooks/useGsapAwareGroupMove.ts +++ b/packages/studio/src/hooks/useGsapAwareGroupMove.ts @@ -5,7 +5,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { CommitMutationCall, CommitMutationOptions } from "./gsapScriptCommitTypes"; import type { UseGsapAwareEditingParams } from "./useGsapAwareEditing"; import { observeGsapGesture } from "./gsapGestureOutcome"; -import { gsapWritesPosition } from "./gsapRuntimeKeyframes"; +import { editsPlainCss } from "./gsapRuntimeKeyframes"; import { refuseGsapTakeover } from "./elementOffsetStager"; import { tryGsapDragIntercept } from "./gsapRuntimeBridge"; import { assertGsapEditPersisted } from "./gsapEditOutcome"; @@ -81,7 +81,7 @@ export function useGsapAwareGroupMove({ // write nothing and share one in-flight parse per file, so they run together. const preflightResults = await Promise.allSettled( updates.map(async ({ selection, next, plainTranslate }) => { - if (plainTranslate ?? !gsapWritesPosition(selection.element)) { + if (plainTranslate ?? editsPlainCss(selection.element, "move")) { refuseGsapTakeover(selection.element, toastRefusal ? showToast : () => {}); return void offsetMembers.set(selection, true); }