mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-03 04:07:03 +00:00
fix(studio): a resize saves through the route its press chose, as a move does (#4943)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user