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:
Miguel Angel Simon Sierra
2026-07-11 04:07:06 -04:00
parent 5cc14c2221
commit 3525c7ff52
10 changed files with 703 additions and 257 deletions
@@ -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 ────────────────────────────────────────────────────
+3 -226
View File
@@ -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);
});
});
+37 -10
View File
@@ -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,