mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(studio): correct gesture commits for scaled and graded elements
- resize on a scale-driven element commits per-axis scale (scaleX/scaleY for non-uniform drags) with keyframe normalization to the longhands, and clears the width/height draft so size can't double-apply; the intercept moves to gsapResizeIntercept.ts - the drop frame applies the corrected position synchronously in the same microtask chain as the soft reload (no network-window jump), and the draft pins the anchor through accumulated moves on scaled elements - gesture size/position math divides by the element's own content scale - convert-to-keyframes resolves current values through the property-group filter for ALL capture passes (opacity/rotationX from unrelated tweens no longer leak into a rotation commit), and a grading-hidden source's opacity is read from its canvas, not the inline hide - canvas pointer-down confirms the hover target with a synchronous hit-test before starting a marquee (stale-hover race lost selections)
This commit is contained in:
@@ -535,6 +535,47 @@ describe("resolveDomEditResizeGesture", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("divides the cursor delta by the element's content scale (rescaled element)", () => {
|
||||
// Element renders at 2x via a GSAP scale: a 30px cursor delta must grow the
|
||||
// CSS box by only 15px so the RENDERED box tracks the pointer 1:1.
|
||||
const next = resolveDomEditResizeGesture({
|
||||
originWidth: 480, // 240 css x 2 content scale (overlay px at editScale 1)
|
||||
originHeight: 240,
|
||||
actualWidth: 240,
|
||||
actualHeight: 120,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
contentScaleX: 2,
|
||||
contentScaleY: 2,
|
||||
dx: 30,
|
||||
dy: 12,
|
||||
uniform: false,
|
||||
});
|
||||
expect(next.width).toBe(255);
|
||||
expect(next.height).toBe(126);
|
||||
// The overlay box keeps tracking the raw cursor.
|
||||
expect(next.overlayWidth).toBe(510);
|
||||
expect(next.overlayHeight).toBe(252);
|
||||
});
|
||||
|
||||
it("treats a missing/invalid content scale as 1 (unscaled element)", () => {
|
||||
const next = resolveDomEditResizeGesture({
|
||||
originWidth: 240,
|
||||
originHeight: 120,
|
||||
actualWidth: 240,
|
||||
actualHeight: 120,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
contentScaleX: 0,
|
||||
contentScaleY: Number.NaN,
|
||||
dx: 30,
|
||||
dy: 12,
|
||||
uniform: false,
|
||||
});
|
||||
expect(next.width).toBe(270);
|
||||
expect(next.height).toBe(132);
|
||||
});
|
||||
|
||||
it("snaps width and height to the same value when Shift is held", () => {
|
||||
expect(
|
||||
resolveDomEditResizeGesture({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers";
|
||||
import { type DomEditSelection } from "./domEditing";
|
||||
import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction";
|
||||
import { useMarqueeGestures } from "./marqueeCommit";
|
||||
@@ -305,8 +306,18 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
|
||||
|
||||
// Start marquee if clicking on empty canvas (no element under pointer)
|
||||
// Start marquee if clicking on empty canvas (no element under pointer).
|
||||
// The hover selection is an ASYNC cache: on a fast click (or when the
|
||||
// pointer was already resting over an element) it can still be empty while
|
||||
// an element IS under the pointer — starting a marquee here would swallow
|
||||
// the selection mousedown and the click would silently select nothing.
|
||||
// Confirm emptiness with a fresh SYNCHRONOUS hit-test before committing.
|
||||
if (!hoverSelectionRef.current && onMarqueeSelectRef.current && compRect.width > 0) {
|
||||
const iframe = iframeRef.current;
|
||||
const freshTarget = iframe
|
||||
? getPreviewTargetFromPointer(iframe, event.clientX, event.clientY, activeCompositionPath)
|
||||
: null;
|
||||
if (freshTarget) return;
|
||||
const overlayEl = overlayRef.current;
|
||||
if (overlayEl) {
|
||||
const oRect = overlayEl.getBoundingClientRect();
|
||||
|
||||
@@ -39,6 +39,25 @@ export interface GestureState {
|
||||
actualRotation: number;
|
||||
editScaleX: number;
|
||||
editScaleY: number;
|
||||
// Rendered-per-CSS-pixel factor of the element itself at gesture start (a GSAP
|
||||
// scale() transform makes this > 1) — the resize draft divides by it so the box
|
||||
// follows the cursor instead of overshooting by the live scale.
|
||||
contentScaleX: number;
|
||||
contentScaleY: number;
|
||||
// Resize anchor pinning: with a live scale transform, growing the CSS box
|
||||
// shifts the rendered box (scaling happens around the element center), so the
|
||||
// un-dragged corner creeps during the draft. The move handler measures the
|
||||
// gesture-start top-left drift each frame and counters it through the GSAP
|
||||
// position channel; the pin accumulates so the correction converges.
|
||||
// Present only on resize gestures.
|
||||
resizeAnchor?: {
|
||||
anchorX: number;
|
||||
anchorY: number;
|
||||
baseGsapX: number;
|
||||
baseGsapY: number;
|
||||
pinX: number;
|
||||
pinY: number;
|
||||
};
|
||||
manualEditDragToken?: string;
|
||||
snapContext?: SnapContext;
|
||||
lastSnappedDx?: number;
|
||||
@@ -77,21 +96,31 @@ export function resolveDomEditResizeGesture(input: {
|
||||
actualHeight: number;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
// Rendered-per-CSS-pixel factor of the element itself (its live GSAP scale).
|
||||
// The CSS width/height the draft writes get multiplied by this on screen, so
|
||||
// the cursor delta must be divided by it — otherwise the box outruns the
|
||||
// pointer on a rescaled element and snaps back on release. Defaults to 1.
|
||||
contentScaleX?: number;
|
||||
contentScaleY?: number;
|
||||
dx: number;
|
||||
dy: number;
|
||||
uniform: boolean;
|
||||
}): { overlayWidth: number; overlayHeight: number; width: number; height: number } {
|
||||
const scaleX = input.scaleX > 0 ? input.scaleX : 1;
|
||||
const scaleY = input.scaleY > 0 ? input.scaleY : 1;
|
||||
const contentScaleX =
|
||||
input.contentScaleX !== undefined && input.contentScaleX > 0 ? input.contentScaleX : 1;
|
||||
const contentScaleY =
|
||||
input.contentScaleY !== undefined && input.contentScaleY > 0 ? input.contentScaleY : 1;
|
||||
|
||||
if (input.uniform) {
|
||||
const deltaX = input.dx / scaleX;
|
||||
const deltaY = input.dy / scaleY;
|
||||
const deltaX = input.dx / (scaleX * contentScaleX);
|
||||
const deltaY = input.dy / (scaleY * contentScaleY);
|
||||
const delta = Math.abs(deltaX) >= Math.abs(deltaY) ? deltaX : deltaY;
|
||||
const side = Math.max(1, Math.max(input.actualWidth, input.actualHeight) + delta);
|
||||
return {
|
||||
overlayWidth: Math.max(MIN_RESIZE_EDGE_PX, side * scaleX),
|
||||
overlayHeight: Math.max(MIN_RESIZE_EDGE_PX, side * scaleY),
|
||||
overlayWidth: Math.max(MIN_RESIZE_EDGE_PX, side * scaleX * contentScaleX),
|
||||
overlayHeight: Math.max(MIN_RESIZE_EDGE_PX, side * scaleY * contentScaleY),
|
||||
width: side,
|
||||
height: side,
|
||||
};
|
||||
@@ -100,8 +129,8 @@ export function resolveDomEditResizeGesture(input: {
|
||||
return {
|
||||
overlayWidth: Math.max(MIN_RESIZE_EDGE_PX, input.originWidth + input.dx),
|
||||
overlayHeight: Math.max(MIN_RESIZE_EDGE_PX, input.originHeight + input.dy),
|
||||
width: Math.max(1, input.actualWidth + input.dx / scaleX),
|
||||
height: Math.max(1, input.actualHeight + input.dy / scaleY),
|
||||
width: Math.max(1, input.actualWidth + input.dx / (scaleX * contentScaleX)),
|
||||
height: Math.max(1, input.actualHeight + input.dy / (scaleY * contentScaleY)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Gesture-begin functions: startGroupDrag and startGesture.
|
||||
* These are pure "start a new gesture" operations — no draft rect updates.
|
||||
*/
|
||||
import { readElementGsapNumber } from "../../utils/elementGsap";
|
||||
import { type DomEditSelection } from "./domEditing";
|
||||
import {
|
||||
createManualOffsetDragMember,
|
||||
@@ -120,8 +121,37 @@ export function startGesture(
|
||||
// `--hf-studio-rotation` CSS var (old projects), so a rotate gesture starts from the
|
||||
// element's actual visual angle and commits an absolute angle to the timeline.
|
||||
const rotation = { angle: readGsapRotation(sel.element) + readStudioRotation(sel.element).angle };
|
||||
const actualWidth = size.width > 0 ? size.width : rect.width / rect.editScaleX;
|
||||
const actualHeight = size.height > 0 ? size.height : rect.height / rect.editScaleY;
|
||||
// The draft writes CSS width/height, so the resize base must be the CSS
|
||||
// layout size. offsetWidth/Height are transform-free; the overlay-rect
|
||||
// fallback (rect / editScale) includes the element's own GSAP scale and
|
||||
// would make a rescaled element's draft grow from the RENDERED size.
|
||||
const layoutWidth = sel.element.offsetWidth;
|
||||
const layoutHeight = sel.element.offsetHeight;
|
||||
const actualWidth =
|
||||
size.width > 0 ? size.width : layoutWidth > 0 ? layoutWidth : rect.width / rect.editScaleX;
|
||||
const actualHeight =
|
||||
size.height > 0 ? size.height : layoutHeight > 0 ? layoutHeight : rect.height / rect.editScaleY;
|
||||
// overlay rect = cssSize x contentScale x editScale, so the element's own
|
||||
// render factor (its GSAP scale) falls out of the measured rect. 1 when
|
||||
// unscaled or unmeasurable.
|
||||
const rawContentScaleX = rect.width / (rect.editScaleX * actualWidth);
|
||||
const rawContentScaleY = rect.height / (rect.editScaleY * actualHeight);
|
||||
const contentScaleX =
|
||||
Number.isFinite(rawContentScaleX) && rawContentScaleX > 0 ? rawContentScaleX : 1;
|
||||
const contentScaleY =
|
||||
Number.isFinite(rawContentScaleY) && rawContentScaleY > 0 ? rawContentScaleY : 1;
|
||||
let resizeAnchor: GestureState["resizeAnchor"];
|
||||
if (kind === "resize") {
|
||||
const startBcr = sel.element.getBoundingClientRect();
|
||||
resizeAnchor = {
|
||||
anchorX: startBcr.x,
|
||||
anchorY: startBcr.y,
|
||||
baseGsapX: readElementGsapNumber(sel.element, "x") ?? 0,
|
||||
baseGsapY: readElementGsapNumber(sel.element, "y") ?? 0,
|
||||
pinX: 0,
|
||||
pinY: 0,
|
||||
};
|
||||
}
|
||||
let initialPathOffset = captureStudioPathOffset(sel.element);
|
||||
let manualEditDragToken: string | undefined;
|
||||
let pathOffsetMember: ManualOffsetDragMember | undefined;
|
||||
@@ -184,6 +214,9 @@ export function startGesture(
|
||||
actualRotation: rotation.angle,
|
||||
editScaleX: rect.editScaleX,
|
||||
editScaleY: rect.editScaleY,
|
||||
contentScaleX,
|
||||
contentScaleY,
|
||||
resizeAnchor,
|
||||
manualEditDragToken,
|
||||
snapContext,
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* Owns: onPointerMove, onPointerUp, clearPointerState.
|
||||
* startGesture and startGroupDrag live in domEditOverlayStartGesture.ts.
|
||||
*/
|
||||
import { setElementGsapPosition } from "../../utils/elementGsap";
|
||||
import type { RefObject } from "react";
|
||||
import { type DomEditSelection } from "./domEditing";
|
||||
import {
|
||||
@@ -53,6 +54,13 @@ import {
|
||||
resolveEquidistanceGuides,
|
||||
SNAP_THRESHOLD_PX,
|
||||
} from "./snapEngine";
|
||||
/** Undo the resize draft's anchor pin: snap GSAP x/y back to the gesture base. */
|
||||
function restoreResizeAnchorPin(element: HTMLElement, g: GestureState): void {
|
||||
const anchor = g.resizeAnchor;
|
||||
if (!anchor || (anchor.pinX === 0 && anchor.pinY === 0)) return;
|
||||
setElementGsapPosition(element, anchor.baseGsapX, anchor.baseGsapY);
|
||||
}
|
||||
|
||||
export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGesturesOptions) {
|
||||
const setDraftOverlayRect = (next: OverlayRect) => {
|
||||
opts.setOverlayRect(next);
|
||||
@@ -175,7 +183,8 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
||||
actualAngle: g.actualRotation,
|
||||
snap: e.shiftKey,
|
||||
});
|
||||
if (!applyRotationDraftViaGsap(sel.element, rotated.angle)) {
|
||||
const draftViaGsap = applyRotationDraftViaGsap(sel.element, rotated.angle);
|
||||
if (!draftViaGsap) {
|
||||
applyStudioRotationDraft(sel.element, rotated);
|
||||
}
|
||||
return;
|
||||
@@ -278,12 +287,35 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
||||
actualHeight: g.actualHeight,
|
||||
scaleX: g.editScaleX,
|
||||
scaleY: g.editScaleY,
|
||||
contentScaleX: g.contentScaleX,
|
||||
contentScaleY: g.contentScaleY,
|
||||
dx,
|
||||
dy,
|
||||
uniform: e.shiftKey,
|
||||
});
|
||||
applyStudioBoxSizeDraft(sel.element, nextSize);
|
||||
|
||||
// Pin the gesture anchor (top-left): with a live scale transform, the CSS
|
||||
// size change shifts the rendered box around the element center. Measure
|
||||
// the drift of the gesture-start corner and counter it via GSAP x/y —
|
||||
// accumulated onto the previous pin so the correction converges instead
|
||||
// of oscillating. The release-time position compensation re-measures the
|
||||
// drop, so the pin composes with the commit.
|
||||
const anchor = g.resizeAnchor;
|
||||
if (anchor) {
|
||||
const pinned = sel.element.getBoundingClientRect();
|
||||
const nextPinX = anchor.pinX + (anchor.anchorX - pinned.x);
|
||||
const nextPinY = anchor.pinY + (anchor.anchorY - pinned.y);
|
||||
if (
|
||||
setElementGsapPosition(
|
||||
sel.element,
|
||||
anchor.baseGsapX + nextPinX,
|
||||
anchor.baseGsapY + nextPinY,
|
||||
)
|
||||
) {
|
||||
anchor.pinX = nextPinX;
|
||||
anchor.pinY = nextPinY;
|
||||
}
|
||||
}
|
||||
// Re-read BCR after applying dimensions. For elements with a GSAP
|
||||
// scale transform and centered transform-origin the visual top-left
|
||||
// drifts and the visual size diverges from the raw CSS size, so BCR
|
||||
@@ -382,6 +414,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
||||
}
|
||||
|
||||
if (g.kind === "resize" && movedDistance < BLOCKED_MOVE_THRESHOLD_PX) {
|
||||
restoreResizeAnchorPin(sel.element, g);
|
||||
restoreStudioBoxSize(sel.element, g.initialBoxSize);
|
||||
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
|
||||
if (box) {
|
||||
@@ -411,7 +444,8 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
||||
restoreStudioRotation(sel.element, g.initialRotation);
|
||||
}
|
||||
};
|
||||
if (!hasDomEditRotationChanged(g.actualRotation, finalRotation.angle)) {
|
||||
const rotationChanged = hasDomEditRotationChanged(g.actualRotation, finalRotation.angle);
|
||||
if (!rotationChanged) {
|
||||
restoreRotation();
|
||||
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
|
||||
return;
|
||||
@@ -422,14 +456,17 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
||||
applyStudioRotation(sel.element, finalRotation);
|
||||
}
|
||||
void Promise.resolve(opts.onRotationCommitRef.current(sel, finalRotation))
|
||||
.catch(() => {
|
||||
.catch((error) => {
|
||||
console.error("rotate commit failed", error);
|
||||
if (
|
||||
g.manualEditDragToken &&
|
||||
isStudioManualEditGestureCurrent(sel.element, g.manualEditDragToken)
|
||||
)
|
||||
restoreRotation();
|
||||
})
|
||||
.finally(() => endStudioManualEditGesture(sel.element, g.manualEditDragToken));
|
||||
.finally(() => {
|
||||
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
|
||||
});
|
||||
} else if (g.kind === "drag") {
|
||||
const dx = g.lastSnappedDx ?? e.clientX - g.startX;
|
||||
const dy = g.lastSnappedDy ?? e.clientY - g.startY;
|
||||
@@ -469,12 +506,15 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
||||
const finalSize = readStudioBoxSize(sel.element);
|
||||
applyStudioBoxSize(sel.element, finalSize);
|
||||
void Promise.resolve(opts.onBoxSizeCommitRef.current(sel, finalSize))
|
||||
.catch(() => {
|
||||
.catch((error) => {
|
||||
console.error("resize commit failed", error);
|
||||
if (
|
||||
g.manualEditDragToken &&
|
||||
isStudioManualEditGestureCurrent(sel.element, g.manualEditDragToken)
|
||||
)
|
||||
) {
|
||||
restoreResizeAnchorPin(sel.element, g);
|
||||
restoreStudioBoxSize(sel.element, g.initialBoxSize);
|
||||
}
|
||||
})
|
||||
.finally(() => endStudioManualEditGesture(sel.element, g.manualEditDragToken));
|
||||
}
|
||||
@@ -494,6 +534,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
||||
restoreGestureOverlayRect(g);
|
||||
}
|
||||
if (g?.mode === "box-size" && sel) {
|
||||
restoreResizeAnchorPin(sel.element, g);
|
||||
restoreStudioBoxSize(sel.element, g.initialBoxSize);
|
||||
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
|
||||
restoreGestureOverlayRect(g);
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* Resize-gesture GSAP intercept: routes a manual resize on a scale-driven
|
||||
* element into scale commits (per-axis longhands for non-uniform drags, with
|
||||
* keyframe normalization), then settles position synchronously so the drop
|
||||
* frame can't jump. Split from gsapRuntimeBridge, which owns the shared
|
||||
* group-tween resolution used by the drag/resize/rotate intercepts.
|
||||
*/
|
||||
import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import { clearStudioBoxSize } from "../components/editor/manualEdits";
|
||||
import { setElementGsapPosition } from "../utils/elementGsap";
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders";
|
||||
import {
|
||||
commitStaticGsapPosition,
|
||||
commitStaticGsapSize,
|
||||
commitKeyframedSizeFromResize,
|
||||
computeCurrentPercentage,
|
||||
findSizeSetAnimation,
|
||||
materializeIfDynamic,
|
||||
} from "./gsapDragCommit";
|
||||
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
|
||||
import { pickClosestToPlayhead } from "./gsapPositionDetection";
|
||||
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
||||
import { selectorFromSelection } from "./gsapShared";
|
||||
import { roundTo3 } from "../utils/rounding";
|
||||
import { resolveGroupTween, POSITION_CHANNELS } from "./gsapRuntimeBridge";
|
||||
import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
|
||||
import { readGsapPositionFromIframe } from "./gsapPositionDetection";
|
||||
import { findExistingPositionWrite } from "./gsapDragCommit";
|
||||
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
|
||||
|
||||
const IDENTITY_ONE_PROPS = new Set(["opacity", "autoAlpha", "scale", "scaleX", "scaleY"]);
|
||||
|
||||
/** Build identity (zero / one) values for each property in `source`. */
|
||||
function synthesizeIdentityProps(
|
||||
source: Record<string, number | string>,
|
||||
): Record<string, number | string> {
|
||||
const id: Record<string, number | string> = {};
|
||||
for (const [k, v] of Object.entries(source)) {
|
||||
if (typeof v === "number") id[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0;
|
||||
else id[k] = v;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
// ── Resize intercept ──────────────────────────────────────────────────────
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export async function tryGsapResizeIntercept(
|
||||
selection: DomEditSelection,
|
||||
size: { width: number; height: number },
|
||||
animations: GsapAnimation[],
|
||||
iframe: HTMLIFrameElement | null,
|
||||
commitMutation: GsapDragCommitCallbacks["commitMutation"],
|
||||
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
|
||||
): Promise<boolean> {
|
||||
// If the element already has a scale-group tween, resize should modify scale
|
||||
// (the user is resizing something whose visual size is driven by scale).
|
||||
// Otherwise, use the size group (width/height).
|
||||
const hasScaleGroup = animations.some((a) => a.propertyGroup === "scale");
|
||||
const resizeGroup: PropertyGroupName = hasScaleGroup ? "scale" : "size";
|
||||
const resolved = await resolveGroupTween(
|
||||
resizeGroup,
|
||||
animations,
|
||||
selection,
|
||||
commitMutation,
|
||||
fetchFallbackAnimations,
|
||||
);
|
||||
|
||||
let anim = resolved?.anim ?? null;
|
||||
if (!anim || anim.method === "set") {
|
||||
const sel = selectorFromSelection(selection);
|
||||
if (!sel) return false;
|
||||
const sizeSet = anim?.method === "set" ? anim : findSizeSetAnimation(animations, sel);
|
||||
|
||||
// If the element is animated (has a real tween, not just a static size
|
||||
// hold), keyframe the size at the playhead so other keyframes keep theirs —
|
||||
// instead of a global set that resizes every frame.
|
||||
if (resizeGroup === "size") {
|
||||
const animatedTween = pickClosestToPlayhead(
|
||||
animations.filter((a) => a.method !== "set" && resolveTweenDuration(a) > 0),
|
||||
);
|
||||
if (animatedTween) {
|
||||
const handled = await commitKeyframedSizeFromResize(
|
||||
selection,
|
||||
size,
|
||||
sel,
|
||||
sizeSet,
|
||||
animatedTween,
|
||||
{ commitMutation, fetchAnimations: fetchFallbackAnimations },
|
||||
);
|
||||
if (handled) return true;
|
||||
}
|
||||
}
|
||||
|
||||
await commitStaticGsapSize(selection, size, sel, sizeSet, {
|
||||
commitMutation,
|
||||
fetchAnimations: fetchFallbackAnimations,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
|
||||
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
|
||||
if (activeKeyframePct != null) setActiveKeyframePct(null);
|
||||
const coalesceKey = `gsap:resize:${anim.id}`;
|
||||
|
||||
const selector = selectorFromSelection(selection);
|
||||
const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {};
|
||||
|
||||
let resizeProps: Record<string, number>;
|
||||
let scaleDraftEl: HTMLElement | null = null;
|
||||
let scaleDraftDropPoint: { x: number; y: number } | null = null;
|
||||
let nonUniformScale = false;
|
||||
if (resizeGroup === "scale") {
|
||||
const el = iframe?.contentDocument?.querySelector(selector ?? "") as HTMLElement | null;
|
||||
// The resize draft modifies el.style.width/height, so read the ORIGINAL
|
||||
// dimensions saved by the draft system before it ran.
|
||||
const origW = Number.parseFloat(el?.getAttribute("data-hf-studio-original-width") ?? "");
|
||||
const origH = Number.parseFloat(el?.getAttribute("data-hf-studio-original-height") ?? "");
|
||||
const cssW = Number.isFinite(origW) && origW > 0 ? origW : 200;
|
||||
const cssH = Number.isFinite(origH) && origH > 0 ? origH : cssW;
|
||||
// `size` is the draft's CSS box; on screen it is multiplied by the element's
|
||||
// LIVE scale (the draft divides the cursor delta by it — see
|
||||
// resolveDomEditResizeGesture). The committed keyframe REPLACES that live
|
||||
// scale, so it must reproduce the rendered intent: css × live / original.
|
||||
// Live scale is 1 on a fresh element (first resize), so this is a no-op there.
|
||||
const rawLiveScaleX = readGsapProperty(iframe, selector ?? null, "scaleX") ?? 1;
|
||||
const rawLiveScaleY = readGsapProperty(iframe, selector ?? null, "scaleY") ?? 1;
|
||||
const liveScaleX = rawLiveScaleX > 0 ? rawLiveScaleX : 1;
|
||||
const liveScaleY = rawLiveScaleY > 0 ? rawLiveScaleY : 1;
|
||||
const newScaleX = roundTo3((size.width * liveScaleX) / cssW);
|
||||
const newScaleY = roundTo3((size.height * liveScaleY) / cssH);
|
||||
// A free-form corner drag is usually NON-uniform. A single `scale` value
|
||||
// can't represent it — committing width-derived scale used to snap the
|
||||
// height at drop. Commit scaleX/scaleY longhands instead; keep the uniform
|
||||
// shorthand when the two agree (aspect-true drags, shift-drags).
|
||||
nonUniformScale = Math.abs(newScaleX - newScaleY) > 0.01;
|
||||
resizeProps = nonUniformScale ? { scaleX: newScaleX, scaleY: newScaleY } : { scale: newScaleX };
|
||||
scaleDraftEl = el;
|
||||
// Where the user DROPPED the box: the draft (anchor-pinned to the
|
||||
// gesture-start top-left) is still applied here, so this rect is exactly
|
||||
// what the preview showed at release. The committed scale renders around
|
||||
// the element CENTER instead — the finalize step below measures that
|
||||
// difference and compensates, so release matches the drop pixel-for-pixel
|
||||
// regardless of live scale or repeat resizes.
|
||||
if (el) {
|
||||
const dropRect = el.getBoundingClientRect();
|
||||
scaleDraftDropPoint = { x: dropRect.x, y: dropRect.y };
|
||||
}
|
||||
} else {
|
||||
resizeProps = {
|
||||
width: Math.round(size.width),
|
||||
height: Math.round(size.height),
|
||||
};
|
||||
}
|
||||
// Finalize a scale-route commit: tear down the gesture's inline width/height
|
||||
// draft (leaving it applied compounds with the committed scale — the element
|
||||
// jumps past the dragged size), then MEASURE where the committed scale
|
||||
// actually rendered the box and shift the position hold by the residual so
|
||||
// it lands back on the drop point. The compensation only applies to a STATIC
|
||||
// position (a `tl.set` hold or none) — a keyframed position path has no
|
||||
// single anchor to preserve, so it keeps the plain center-scale behavior.
|
||||
// The size route commits the same width/height channels the draft wrote, so
|
||||
// it needs none of this.
|
||||
// ponytail: for a 3D-rotated element the rects are AABBs, so the anchor is
|
||||
// approximate rather than corner-exact.
|
||||
// fallow-ignore-next-line complexity
|
||||
const finalizeScaleResizeCommit = async () => {
|
||||
if (!scaleDraftEl) return;
|
||||
clearStudioBoxSize(scaleDraftEl);
|
||||
if (!scaleDraftDropPoint || !selector) return;
|
||||
const hasLivePositionTween = hasNonHoldTweenForElement(
|
||||
iframe,
|
||||
selector,
|
||||
undefined,
|
||||
POSITION_CHANNELS,
|
||||
);
|
||||
if (hasLivePositionTween) {
|
||||
return;
|
||||
}
|
||||
// The scale commit has rendered (instant patch or soft-reload seek) and the
|
||||
// draft is cleared — this rect is where the element ACTUALLY sits now.
|
||||
const post = scaleDraftEl.getBoundingClientRect();
|
||||
const residual = { x: scaleDraftDropPoint.x - post.x, y: scaleDraftDropPoint.y - post.y };
|
||||
if (!Number.isFinite(residual.x) || !Number.isFinite(residual.y)) return;
|
||||
if (Math.abs(residual.x) < 0.5 && Math.abs(residual.y) < 0.5) return;
|
||||
const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
|
||||
// The ONE corrected position — rounded once so the live runtime and the
|
||||
// persisted file agree exactly (commitStaticGsapPosition composes the same
|
||||
// rounded value from this delta).
|
||||
const corrected = {
|
||||
x: Math.round(gsapPos.x + residual.x),
|
||||
y: Math.round(gsapPos.y + residual.y),
|
||||
};
|
||||
// Correct the LIVE runtime NOW, synchronously: the soft reload above just
|
||||
// rendered the committed scale around the element center — NOT at the drop
|
||||
// point — and everything up to here runs in the same microtask chain as
|
||||
// that reload, so no frame has painted the uncorrected position yet. The
|
||||
// server persist below costs network round-trips; without this set, the
|
||||
// element visibly sits at the wrong spot for those frames (the drop
|
||||
// "jump"). The persisted commit re-applies the same values (idempotent).
|
||||
setElementGsapPosition(scaleDraftEl, corrected.x, corrected.y);
|
||||
// Re-fetch: the scale commit above just rewrote the script, so the caller's
|
||||
// animation list (and its ids) may be stale for the position lookup.
|
||||
const currentAnimations = fetchFallbackAnimations
|
||||
? await fetchFallbackAnimations()
|
||||
: (resolved?.animations ?? animations);
|
||||
const existingSet = findExistingPositionWrite(currentAnimations, selector);
|
||||
// Delta chosen so the drag-path math composes back to exactly `corrected`
|
||||
// (no drag scratch attrs exist during a resize, so base = gsapPos).
|
||||
await commitStaticGsapPosition(
|
||||
selection,
|
||||
{ x: corrected.x - gsapPos.x, y: corrected.y - gsapPos.y },
|
||||
gsapPos,
|
||||
selector,
|
||||
existingSet,
|
||||
{
|
||||
commitMutation,
|
||||
fetchAnimations: fetchFallbackAnimations,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// With auto-keyframe off (#1808), `anim` is already a real (non-"set")
|
||||
// tween for this resize group, so nudge it as a whole rather than adding a
|
||||
// keyframe at the playhead.
|
||||
if (!usePlayerStore.getState().autoKeyframeEnabled) {
|
||||
if (activeKeyframePct != null) setActiveKeyframePct(null);
|
||||
await commitWholePropertyOffset(
|
||||
selection,
|
||||
anim,
|
||||
resizeProps,
|
||||
pct,
|
||||
iframe,
|
||||
{ commitMutation, fetchAnimations: fetchFallbackAnimations },
|
||||
"Resize animation",
|
||||
);
|
||||
await finalizeScaleResizeCommit();
|
||||
return true;
|
||||
}
|
||||
|
||||
const ct = usePlayerStore.getState().currentTime;
|
||||
const ts = resolveTweenStart(anim);
|
||||
const td = resolveTweenDuration(anim);
|
||||
const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); // Convert flat tweens to keyframes only for in-range resizes.
|
||||
// Outside-range uses the extend path which handles everything atomically.
|
||||
if (!outsideRange) {
|
||||
// fallow-ignore-next-line code-duplication
|
||||
if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
|
||||
const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
|
||||
if (newId) anim = { ...anim, id: newId };
|
||||
} else if (!anim.keyframes) {
|
||||
const resolvedFromValues = selector
|
||||
? readAllAnimatedProperties(iframe, selector, anim)
|
||||
: undefined;
|
||||
await commitMutation(
|
||||
selection,
|
||||
{ type: "convert-to-keyframes", animationId: anim.id, resolvedFromValues },
|
||||
{ label: "Convert to keyframes for resize", skipReload: true, coalesceKey },
|
||||
);
|
||||
if (fetchFallbackAnimations) {
|
||||
const fresh = await fetchFallbackAnimations();
|
||||
const refreshed = fresh.find(
|
||||
(a) => a.targetSelector === anim!.targetSelector && a.keyframes,
|
||||
);
|
||||
if (refreshed) anim = refreshed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A NON-uniform scale must also take the full-rewrite path: it mixes
|
||||
// scaleX/scaleY into a tween whose existing keyframes may carry the uniform
|
||||
// `scale` shorthand, and GSAP's percentage keyframes animate each property
|
||||
// name independently — a shorthand/longhand mix would leave the old `scale`
|
||||
// sub-tween running against the new scaleX/scaleY. The rewrite below
|
||||
// normalizes every keyframe to the longhands. For an in-range resize the
|
||||
// min/max window math below degenerates to the tween's own start/duration,
|
||||
// so timing is unchanged.
|
||||
if ((outsideRange || nonUniformScale) && ts !== null) {
|
||||
// For flat tweens, synthesize the keyframes from the tween's properties
|
||||
const kfs =
|
||||
anim.keyframes?.keyframes ??
|
||||
(() => {
|
||||
const fromProps =
|
||||
anim.method === "from" || anim.method === "fromTo"
|
||||
? { ...anim.properties }
|
||||
: synthesizeIdentityProps(anim.properties);
|
||||
const toProps =
|
||||
anim.method === "from"
|
||||
? synthesizeIdentityProps(anim.properties)
|
||||
: { ...anim.properties };
|
||||
return [
|
||||
{ percentage: 0, properties: fromProps },
|
||||
{ percentage: 100, properties: toProps },
|
||||
];
|
||||
})();
|
||||
const newStart = Math.min(ct, ts);
|
||||
const newEnd = Math.max(ct, ts + td);
|
||||
const newDuration = Math.max(0.01, newEnd - newStart);
|
||||
const existingKfs = kfs;
|
||||
const remapped: Array<{ percentage: number; properties: Record<string, number | string> }> = [];
|
||||
for (const kf of existingKfs) {
|
||||
const absTime = ts + (kf.percentage / 100) * td;
|
||||
const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10;
|
||||
const props = { ...kf.properties };
|
||||
// Normalize the uniform `scale` shorthand to longhands when this commit
|
||||
// writes scaleX/scaleY, so the tween never mixes the two forms.
|
||||
if (nonUniformScale && "scale" in props) {
|
||||
const uniform = props.scale;
|
||||
if (typeof uniform === "number") {
|
||||
props.scaleX = uniform;
|
||||
props.scaleY = uniform;
|
||||
}
|
||||
delete props.scale;
|
||||
}
|
||||
// Only backfill properties that the animation already had (x, y, scale).
|
||||
// Don't backfill width/height — they should only appear on the resize keyframe.
|
||||
for (const k of Object.keys(resizeProps)) {
|
||||
if (k in props) continue;
|
||||
if (k === "width" || k === "height") continue;
|
||||
props[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0;
|
||||
}
|
||||
remapped.push({ percentage: newPct, properties: props });
|
||||
}
|
||||
const targetPct = Math.round(((ct - newStart) / newDuration) * 1000) / 10;
|
||||
// An in-range rewrite can land on an existing keyframe's percentage —
|
||||
// merge into it instead of emitting a duplicate step.
|
||||
const collidingKf = remapped.find((kf) => Math.abs(kf.percentage - targetPct) < 0.05);
|
||||
if (collidingKf) Object.assign(collidingKf.properties, resizeProps);
|
||||
else remapped.push({ percentage: targetPct, properties: resizeProps });
|
||||
remapped.sort((a, b) => a.percentage - b.percentage);
|
||||
|
||||
await commitMutation(
|
||||
selection,
|
||||
{
|
||||
type: "replace-with-keyframes",
|
||||
animationId: anim.id,
|
||||
targetSelector: anim.targetSelector,
|
||||
position: roundTo3(newStart),
|
||||
duration: roundTo3(newDuration),
|
||||
keyframes: remapped,
|
||||
},
|
||||
{
|
||||
label: outsideRange
|
||||
? `Resize (extended to ${ct.toFixed(2)}s)`
|
||||
: `Resize (keyframe ${Math.round(((ct - newStart) / newDuration) * 1000) / 10}%)`,
|
||||
softReload: true,
|
||||
coalesceKey,
|
||||
},
|
||||
);
|
||||
await finalizeScaleResizeCommit();
|
||||
return true;
|
||||
}
|
||||
|
||||
const SIZE_PROPS = new Set(["width", "height"]);
|
||||
const backfillDefaults: Record<string, number> = {};
|
||||
for (const k of Object.keys(runtimeProps)) {
|
||||
if (SIZE_PROPS.has(k)) continue;
|
||||
backfillDefaults[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0;
|
||||
}
|
||||
|
||||
await commitMutation(
|
||||
selection,
|
||||
{
|
||||
type: "add-keyframe",
|
||||
animationId: anim.id,
|
||||
percentage: pct,
|
||||
properties: resizeProps,
|
||||
backfillDefaults,
|
||||
},
|
||||
{ label: `Resize (keyframe ${pct}%)`, softReload: true, coalesceKey },
|
||||
);
|
||||
await finalizeScaleResizeCommit();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Rotation intercept ────────────────────────────────────────────────────
|
||||
@@ -17,17 +17,14 @@ import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
|
||||
import {
|
||||
commitStaticGsapPosition,
|
||||
commitStaticGsapRotation,
|
||||
commitStaticGsapSize,
|
||||
commitKeyframedSizeFromResize,
|
||||
commitWholePathOffset,
|
||||
computeCurrentPercentage,
|
||||
findExistingPositionWrite,
|
||||
findRotationSetAnimation,
|
||||
findSizeSetAnimation,
|
||||
materializeIfDynamic,
|
||||
} from "./gsapDragCommit";
|
||||
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
|
||||
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
||||
import { resolveTweenDuration } from "../utils/globalTimeCompiler";
|
||||
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
|
||||
import { selectorFromSelection } from "./gsapShared";
|
||||
import {
|
||||
@@ -36,12 +33,11 @@ import {
|
||||
readGsapPositionFromIframe,
|
||||
} from "./gsapPositionDetection";
|
||||
import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
|
||||
import { roundTo3 } from "../utils/rounding";
|
||||
|
||||
// Position channels — used to scope the "has a live position tween?" check so a
|
||||
// sibling rotation/scale animation never forces a static position hold into the
|
||||
// keyframe branch (which corrupts it into a frozen duration-0 keyframed tween).
|
||||
const POSITION_CHANNELS = [
|
||||
export const POSITION_CHANNELS = [
|
||||
"x",
|
||||
"y",
|
||||
"xPercent",
|
||||
@@ -67,7 +63,7 @@ const POSITION_CHANNELS = [
|
||||
* re-fetch, then return the group tween
|
||||
* 3. null — caller must handle the missing-tween case
|
||||
*/
|
||||
async function resolveGroupTween(
|
||||
export async function resolveGroupTween(
|
||||
group: PropertyGroupName,
|
||||
animations: GsapAnimation[],
|
||||
selection: DomEditSelection,
|
||||
@@ -264,224 +260,6 @@ export { readGsapProperty, readAllAnimatedProperties };
|
||||
|
||||
// ── Identity-prop synthesis ───────────────────────────────────────────────
|
||||
|
||||
const IDENTITY_ONE_PROPS = new Set(["opacity", "autoAlpha", "scale", "scaleX", "scaleY"]);
|
||||
|
||||
/** Build identity (zero / one) values for each property in `source`. */
|
||||
function synthesizeIdentityProps(
|
||||
source: Record<string, number | string>,
|
||||
): Record<string, number | string> {
|
||||
const id: Record<string, number | string> = {};
|
||||
for (const [k, v] of Object.entries(source)) {
|
||||
if (typeof v === "number") id[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0;
|
||||
else id[k] = v;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
// ── Resize intercept ──────────────────────────────────────────────────────
|
||||
|
||||
export async function tryGsapResizeIntercept(
|
||||
selection: DomEditSelection,
|
||||
size: { width: number; height: number },
|
||||
animations: GsapAnimation[],
|
||||
iframe: HTMLIFrameElement | null,
|
||||
commitMutation: GsapDragCommitCallbacks["commitMutation"],
|
||||
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
|
||||
): Promise<boolean> {
|
||||
// If the element already has a scale-group tween, resize should modify scale
|
||||
// (the user is resizing something whose visual size is driven by scale).
|
||||
// Otherwise, use the size group (width/height).
|
||||
const hasScaleGroup = animations.some((a) => a.propertyGroup === "scale");
|
||||
const resizeGroup: PropertyGroupName = hasScaleGroup ? "scale" : "size";
|
||||
const resolved = await resolveGroupTween(
|
||||
resizeGroup,
|
||||
animations,
|
||||
selection,
|
||||
commitMutation,
|
||||
fetchFallbackAnimations,
|
||||
);
|
||||
|
||||
let anim = resolved?.anim ?? null;
|
||||
if (!anim || anim.method === "set") {
|
||||
const sel = selectorFromSelection(selection);
|
||||
if (!sel) return false;
|
||||
const sizeSet = anim?.method === "set" ? anim : findSizeSetAnimation(animations, sel);
|
||||
|
||||
// If the element is animated (has a real tween, not just a static size
|
||||
// hold), keyframe the size at the playhead so other keyframes keep theirs —
|
||||
// instead of a global set that resizes every frame.
|
||||
if (resizeGroup === "size") {
|
||||
const animatedTween = pickClosestToPlayhead(
|
||||
animations.filter((a) => a.method !== "set" && resolveTweenDuration(a) > 0),
|
||||
);
|
||||
if (animatedTween) {
|
||||
const handled = await commitKeyframedSizeFromResize(
|
||||
selection,
|
||||
size,
|
||||
sel,
|
||||
sizeSet,
|
||||
animatedTween,
|
||||
{ commitMutation, fetchAnimations: fetchFallbackAnimations },
|
||||
);
|
||||
if (handled) return true;
|
||||
}
|
||||
}
|
||||
|
||||
await commitStaticGsapSize(selection, size, sel, sizeSet, {
|
||||
commitMutation,
|
||||
fetchAnimations: fetchFallbackAnimations,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
|
||||
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
|
||||
if (activeKeyframePct != null) setActiveKeyframePct(null);
|
||||
const coalesceKey = `gsap:resize:${anim.id}`;
|
||||
|
||||
const selector = selectorFromSelection(selection);
|
||||
const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {};
|
||||
|
||||
let resizeProps: Record<string, number>;
|
||||
if (resizeGroup === "scale") {
|
||||
const el = iframe?.contentDocument?.querySelector(selector ?? "") as HTMLElement | null;
|
||||
// The resize draft modifies el.style.width, so read the ORIGINAL width
|
||||
// saved by the draft system before it ran.
|
||||
const origW = Number.parseFloat(el?.getAttribute("data-hf-studio-original-width") ?? "");
|
||||
const cssW = Number.isFinite(origW) && origW > 0 ? origW : 200;
|
||||
const newScale = roundTo3(size.width / cssW);
|
||||
resizeProps = { scale: newScale };
|
||||
} else {
|
||||
resizeProps = {
|
||||
width: Math.round(size.width),
|
||||
height: Math.round(size.height),
|
||||
};
|
||||
}
|
||||
|
||||
// With auto-keyframe off (#1808), `anim` is already a real (non-"set")
|
||||
// tween for this resize group, so nudge it as a whole rather than adding a
|
||||
// keyframe at the playhead.
|
||||
if (!usePlayerStore.getState().autoKeyframeEnabled) {
|
||||
if (activeKeyframePct != null) setActiveKeyframePct(null);
|
||||
await commitWholePropertyOffset(
|
||||
selection,
|
||||
anim,
|
||||
resizeProps,
|
||||
pct,
|
||||
iframe,
|
||||
{ commitMutation, fetchAnimations: fetchFallbackAnimations },
|
||||
"Resize animation",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
const ct = usePlayerStore.getState().currentTime;
|
||||
const ts = resolveTweenStart(anim);
|
||||
const td = resolveTweenDuration(anim);
|
||||
const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); // Convert flat tweens to keyframes only for in-range resizes.
|
||||
// Outside-range uses the extend path which handles everything atomically.
|
||||
if (!outsideRange) {
|
||||
// fallow-ignore-next-line code-duplication
|
||||
if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
|
||||
const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
|
||||
if (newId) anim = { ...anim, id: newId };
|
||||
} else if (!anim.keyframes) {
|
||||
const resolvedFromValues = selector
|
||||
? readAllAnimatedProperties(iframe, selector, anim)
|
||||
: undefined;
|
||||
await commitMutation(
|
||||
selection,
|
||||
{ type: "convert-to-keyframes", animationId: anim.id, resolvedFromValues },
|
||||
{ label: "Convert to keyframes for resize", skipReload: true, coalesceKey },
|
||||
);
|
||||
if (fetchFallbackAnimations) {
|
||||
const fresh = await fetchFallbackAnimations();
|
||||
const refreshed = fresh.find(
|
||||
(a) => a.targetSelector === anim!.targetSelector && a.keyframes,
|
||||
);
|
||||
if (refreshed) anim = refreshed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (outsideRange && ts !== null) {
|
||||
// For flat tweens, synthesize the keyframes from the tween's properties
|
||||
const kfs =
|
||||
anim.keyframes?.keyframes ??
|
||||
(() => {
|
||||
const fromProps =
|
||||
anim.method === "from" || anim.method === "fromTo"
|
||||
? { ...anim.properties }
|
||||
: synthesizeIdentityProps(anim.properties);
|
||||
const toProps =
|
||||
anim.method === "from"
|
||||
? synthesizeIdentityProps(anim.properties)
|
||||
: { ...anim.properties };
|
||||
return [
|
||||
{ percentage: 0, properties: fromProps },
|
||||
{ percentage: 100, properties: toProps },
|
||||
];
|
||||
})();
|
||||
const newStart = Math.min(ct, ts);
|
||||
const newEnd = Math.max(ct, ts + td);
|
||||
const newDuration = Math.max(0.01, newEnd - newStart);
|
||||
const existingKfs = kfs;
|
||||
const remapped: Array<{ percentage: number; properties: Record<string, number | string> }> = [];
|
||||
for (const kf of existingKfs) {
|
||||
const absTime = ts + (kf.percentage / 100) * td;
|
||||
const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10;
|
||||
const props = { ...kf.properties };
|
||||
// Only backfill properties that the animation already had (x, y, scale).
|
||||
// Don't backfill width/height — they should only appear on the resize keyframe.
|
||||
for (const k of Object.keys(resizeProps)) {
|
||||
if (k in props) continue;
|
||||
if (k === "width" || k === "height") continue;
|
||||
props[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0;
|
||||
}
|
||||
remapped.push({ percentage: newPct, properties: props });
|
||||
}
|
||||
const targetPct = Math.round(((ct - newStart) / newDuration) * 1000) / 10;
|
||||
remapped.push({ percentage: targetPct, properties: resizeProps });
|
||||
remapped.sort((a, b) => a.percentage - b.percentage);
|
||||
|
||||
await commitMutation(
|
||||
selection,
|
||||
{
|
||||
type: "replace-with-keyframes",
|
||||
animationId: anim.id,
|
||||
targetSelector: anim.targetSelector,
|
||||
position: roundTo3(newStart),
|
||||
duration: roundTo3(newDuration),
|
||||
keyframes: remapped,
|
||||
},
|
||||
{ label: `Resize (extended to ${ct.toFixed(2)}s)`, softReload: true, coalesceKey },
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
const SIZE_PROPS = new Set(["width", "height"]);
|
||||
const backfillDefaults: Record<string, number> = {};
|
||||
for (const k of Object.keys(runtimeProps)) {
|
||||
if (SIZE_PROPS.has(k)) continue;
|
||||
backfillDefaults[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0;
|
||||
}
|
||||
|
||||
await commitMutation(
|
||||
selection,
|
||||
{
|
||||
type: "add-keyframe",
|
||||
animationId: anim.id,
|
||||
percentage: pct,
|
||||
properties: resizeProps,
|
||||
backfillDefaults,
|
||||
},
|
||||
{ label: `Resize (keyframe ${pct}%)`, softReload: true, coalesceKey },
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Rotation intercept ────────────────────────────────────────────────────
|
||||
|
||||
export async function tryGsapRotationIntercept(
|
||||
selection: DomEditSelection,
|
||||
angle: number,
|
||||
@@ -517,7 +295,6 @@ export async function tryGsapRotationIntercept(
|
||||
// pointer sweep) or the inspector — so it IS the new rotation. No base re-add: the
|
||||
// gesture's live preview already gsap.set this value (single source of truth).
|
||||
const newRotation = Math.round(angle);
|
||||
|
||||
// STATIC case (single source of truth = GSAP timeline): no rotation tween, so the
|
||||
// angle belongs in a `tl.set("#el",{rotation})`, not a keyframe conversion —
|
||||
// mirroring the static position set. Idempotent: re-rotate updates an existing
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import {
|
||||
COLOR_GRADING_SOURCE_HIDDEN_ATTR,
|
||||
HF_COLOR_GRADING_CANVAS_ID_PREFIX,
|
||||
} from "@hyperframes/core/color-grading";
|
||||
import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders";
|
||||
|
||||
/**
|
||||
* Regression: converting a property-group tween to keyframes resolves "current
|
||||
* values" via readAllAnimatedProperties. Two ways that used to bake garbage
|
||||
* into the composition file:
|
||||
*
|
||||
* 1. The group filter only pruned the tween's OWN properties — the baseline
|
||||
* pass still captured every property ANY tween on the element touches, so
|
||||
* a rotation commit carried `opacity` from the intro from() tween.
|
||||
* 2. `gsap.getProperty(el, "opacity")` on a color-grading source reads the
|
||||
* runtime hide (inline `opacity: 0 !important`), not the animated value —
|
||||
* so the captured opacity was the transient 0, which then animated 0 → 0
|
||||
* on the next full load and the element disappeared.
|
||||
*/
|
||||
|
||||
function fakeIframe(
|
||||
el: Element,
|
||||
opts: { gsapValues: Record<string, number>; otherTweenVars?: Record<string, number> },
|
||||
): HTMLIFrameElement {
|
||||
const children = opts.otherTweenVars
|
||||
? [{ targets: () => [el], vars: { duration: 0.8, ...opts.otherTweenVars } }]
|
||||
: [];
|
||||
return {
|
||||
contentWindow: {
|
||||
__timelines: { main: { getChildren: () => children } },
|
||||
gsap: { getProperty: (_el: Element, prop: string) => opts.gsapValues[prop] ?? 0 },
|
||||
},
|
||||
contentDocument: document,
|
||||
} as unknown as HTMLIFrameElement;
|
||||
}
|
||||
|
||||
function rotationSetAnim(): GsapAnimation {
|
||||
return {
|
||||
id: "#clip-set-0-rotation",
|
||||
targetSelector: "#clip",
|
||||
method: "set",
|
||||
properties: { rotation: 0 },
|
||||
} as unknown as GsapAnimation;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("readAllAnimatedProperties group filter", () => {
|
||||
it("keeps other tweens' out-of-group properties out of a grouped resolve", () => {
|
||||
const el = document.createElement("div");
|
||||
el.id = "clip";
|
||||
document.body.appendChild(el);
|
||||
const iframe = fakeIframe(el, {
|
||||
gsapValues: { rotation: -28.1, opacity: 0, rotationX: 52, rotationY: -47 },
|
||||
otherTweenVars: { opacity: 0, rotationX: 52, rotationY: -47 },
|
||||
});
|
||||
|
||||
const result = readAllAnimatedProperties(iframe, "#clip", rotationSetAnim(), "rotation");
|
||||
|
||||
expect(result).toEqual({ rotation: -28.1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("color-grading opacity truth", () => {
|
||||
function gradedElement(): HTMLElement {
|
||||
const el = document.createElement("img");
|
||||
el.id = "clip";
|
||||
el.setAttribute(COLOR_GRADING_SOURCE_HIDDEN_ATTR, "");
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.id = `${HF_COLOR_GRADING_CANVAS_ID_PREFIX}clip`;
|
||||
canvas.style.opacity = "0.98";
|
||||
document.body.append(el, canvas);
|
||||
return el;
|
||||
}
|
||||
|
||||
it("resolves opacity from the grading canvas, not the runtime hide", () => {
|
||||
const el = gradedElement();
|
||||
const iframe = fakeIframe(el, { gsapValues: { opacity: 0 } });
|
||||
const anim = {
|
||||
id: "#clip-from-200-visual",
|
||||
targetSelector: "#clip",
|
||||
method: "from",
|
||||
properties: { opacity: 0.5 },
|
||||
} as unknown as GsapAnimation;
|
||||
|
||||
const result = readAllAnimatedProperties(iframe, "#clip", anim);
|
||||
|
||||
expect(result.opacity).toBe(0.98);
|
||||
});
|
||||
|
||||
it("readGsapProperty takes the same detour", () => {
|
||||
const el = gradedElement();
|
||||
const iframe = fakeIframe(el, { gsapValues: { opacity: 0 } });
|
||||
|
||||
expect(readGsapProperty(iframe, "#clip", "opacity")).toBe(0.98);
|
||||
});
|
||||
|
||||
it("reads GSAP directly when the source is not grading-hidden", () => {
|
||||
const el = document.createElement("div");
|
||||
el.id = "clip";
|
||||
document.body.appendChild(el);
|
||||
const iframe = fakeIframe(el, { gsapValues: { opacity: 0.3 } });
|
||||
|
||||
expect(readGsapProperty(iframe, "#clip", "opacity")).toBe(0.3);
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,33 @@
|
||||
*/
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { classifyPropertyGroup, type PropertyGroupName } from "@hyperframes/core/gsap-parser";
|
||||
import { getIframeGsap, queryIframeElement } from "./gsapShared";
|
||||
import {
|
||||
COLOR_GRADING_SOURCE_HIDDEN_ATTR,
|
||||
HF_COLOR_GRADING_CANVAS_ID_PREFIX,
|
||||
} from "@hyperframes/core/color-grading";
|
||||
import { getIframeGsap, queryIframeElement, type IframeGsap } from "./gsapShared";
|
||||
import { roundTo3 } from "../utils/rounding";
|
||||
|
||||
/**
|
||||
* The element's live value for `prop` as GSAP drives it. Opacity on a
|
||||
* color-grading-hidden source needs a detour: the runtime hides the source
|
||||
* with inline `opacity: 0 !important`, so computed opacity is the hide, not
|
||||
* the animated value. The grading canvas mirrors the source's effective
|
||||
* opacity every frame, so it is the truth for that one property — reading the
|
||||
* raw 0 here is what bakes `opacity: 0` into committed keyframes.
|
||||
*/
|
||||
function readLiveGsapValue(gsap: IframeGsap, el: Element, prop: string): number {
|
||||
if (prop === "opacity" && el.getAttribute(COLOR_GRADING_SOURCE_HIDDEN_ATTR) != null && el.id) {
|
||||
const canvas = el.ownerDocument.getElementById(HF_COLOR_GRADING_CANVAS_ID_PREFIX + el.id);
|
||||
const win = el.ownerDocument.defaultView;
|
||||
if (canvas && win) {
|
||||
const val = Number(win.getComputedStyle(canvas).opacity);
|
||||
if (Number.isFinite(val)) return val;
|
||||
}
|
||||
}
|
||||
return Number(gsap.getProperty(el, prop));
|
||||
}
|
||||
|
||||
export function readGsapProperty(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
selector: string | null,
|
||||
@@ -17,7 +41,7 @@ export function readGsapProperty(
|
||||
const el = queryIframeElement(iframe, selector);
|
||||
if (!el) return null;
|
||||
try {
|
||||
const val = Number(gsap.getProperty(el, prop));
|
||||
const val = readLiveGsapValue(gsap, el, prop);
|
||||
if (!Number.isFinite(val)) return null;
|
||||
return POSITION_PROPS.has(prop) ? Math.round(val) : roundTo3(val);
|
||||
} catch {
|
||||
@@ -77,15 +101,17 @@ export function readAllAnimatedProperties(
|
||||
for (const p of Object.keys(anim.properties)) propKeys.add(p);
|
||||
}
|
||||
|
||||
// When a group filter is specified, only keep properties belonging to that group.
|
||||
if (group) {
|
||||
for (const p of propKeys) {
|
||||
if (classifyPropertyGroup(p) !== group) propKeys.delete(p);
|
||||
}
|
||||
// When a group filter is specified, only properties belonging to that group
|
||||
// may enter the result — including the baseline passes below. The whole
|
||||
// point of property-group tweens is that a rotation commit never carries
|
||||
// opacity/rotationX/etc. captured from unrelated tweens on the element.
|
||||
const inGroup = (p: string) => !group || classifyPropertyGroup(p) === group;
|
||||
for (const p of propKeys) {
|
||||
if (!inGroup(p)) propKeys.delete(p);
|
||||
}
|
||||
|
||||
for (const prop of propKeys) {
|
||||
const val = Number(gsap.getProperty(el, prop));
|
||||
const val = readLiveGsapValue(gsap, el, prop);
|
||||
if (Number.isFinite(val)) {
|
||||
result[prop] = POSITION_PROPS.has(prop) ? Math.round(val) : roundTo3(val);
|
||||
}
|
||||
@@ -110,7 +136,7 @@ export function readAllAnimatedProperties(
|
||||
const vars = child.vars;
|
||||
if (!vars) continue;
|
||||
for (const k of Object.keys(vars)) {
|
||||
if (!GSAP_CONFIG_KEYS.has(k)) otherTweenProps.add(k);
|
||||
if (!GSAP_CONFIG_KEYS.has(k) && inGroup(k)) otherTweenProps.add(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,7 +178,7 @@ export function readAllAnimatedProperties(
|
||||
for (const [prop, defaultVal] of Object.entries(UNIVERSAL_BASELINE)) {
|
||||
if (prop in result) continue;
|
||||
if (!allTweenedProps.has(prop)) continue;
|
||||
const val = Number(gsap.getProperty(el, prop));
|
||||
const val = readLiveGsapValue(gsap, el, prop);
|
||||
if (Number.isFinite(val) && Math.round(val * 1000) !== Math.round(defaultVal * 1000)) {
|
||||
result[prop] = roundTo3(val);
|
||||
}
|
||||
@@ -184,6 +210,7 @@ export function readAllAnimatedProperties(
|
||||
} catch {}
|
||||
for (const prop of COMPUTED_BASELINE) {
|
||||
if (prop in result) continue;
|
||||
if (!inGroup(prop)) continue;
|
||||
if (otherTweenProps.has(prop)) continue;
|
||||
const gsapVal = Number(gsap.getProperty(el, prop));
|
||||
if (!Number.isFinite(gsapVal)) continue;
|
||||
|
||||
@@ -10,11 +10,8 @@
|
||||
import { useCallback } from "react";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import {
|
||||
tryGsapDragIntercept,
|
||||
tryGsapResizeIntercept,
|
||||
tryGsapRotationIntercept,
|
||||
} from "./gsapRuntimeBridge";
|
||||
import { tryGsapDragIntercept, tryGsapRotationIntercept } from "./gsapRuntimeBridge";
|
||||
import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
|
||||
import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit";
|
||||
import {
|
||||
useGsapSaveFailureTelemetry,
|
||||
|
||||
Reference in New Issue
Block a user