fix(studio): a resize saves through the route its press chose, as a move does (#4943)

This commit is contained in:
Miguel Ángel
2026-10-03 14:32:46 -07:00
committed by GitHub
parent 96bc879b5b
commit e27cae0261
13 changed files with 66 additions and 28 deletions
@@ -94,6 +94,7 @@ export interface DomEditOverlayProps {
next: { width: number; height: number },
offset?: { x: number; y: number },
restore?: () => void,
route?: { plainTranslate: boolean },
) => Promise<unknown> | void;
onRotationCommit: (selection: DomEditSelection, next: RotationCommit) => Promise<unknown> | void;
onStyleCommit?: (property: string, value: string) => Promise<unknown> | void;
@@ -275,6 +275,7 @@ export type UseDomEditOverlayGesturesOptions = {
n: { width: number; height: number },
offset?: { x: number; y: number },
restore?: () => void,
route?: { plainTranslate: boolean },
) => Promise<unknown> | void
>;
onRotationCommitRef: RefObject<
@@ -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();
@@ -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)
@@ -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);
}
@@ -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<string, number> = {};
for (const prop of propKeys) {
if (!readsTransform && GSAP_TRANSFORM_KEYS.has(prop)) continue;
@@ -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);
@@ -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) => {
@@ -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<EditGesture, (el: Element) => 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 {
@@ -42,6 +42,7 @@ export interface DomGeometryCommits {
next: { width: number; height: number },
offset?: { x: number; y: number },
restore?: () => void,
route?: { plainTranslate: boolean },
) => Promise<DomEditCommitOutcome>;
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),
@@ -74,6 +74,7 @@ function mountResizeHandler(
size: { width: number; height: number },
offset?: { x: number; y: number },
restore?: () => void,
route?: { plainTranslate: boolean },
) => Promise<void | import("../utils/previewFeatureUsage").GeometryCommitResult>)
| null = null;
let property: ReturnType<typeof useGsapAwareEditing>["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() }));
@@ -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);
}
@@ -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);
}