fix(studio): per-property-group intercept routing + drag/resize fixes (#1356)

* fix(core): per-property-group keyframe foundations

Add PropertyGroupName type system (position/scale/size/rotation/visual/other),
PROPERTY_GROUPS constant, classifyPropertyGroup/classifyTweenPropertyGroup
functions. Parser generates group-aware animation IDs, resolves position strings
(+=, -=, <, >), uses numeric matching with 2% tolerance, and preserves IDs
across all mutations.

* fix(core): add split-into-property-groups and replace-with-keyframes mutations

Server-side mutations for atomic property-group splitting and keyframe
replacement. Client commitMutation returns early on changed:false instead
of throwing.

* fix(studio): per-property-group intercept routing + drag/resize fixes

Rewire GSAP runtime bridge for property-group routing: drag sends only {x,y}
to position group, resize routes to scale group via data-hf-studio-original-width,
rotation routes to rotation group. Add resolveGroupTween helper, from-extend
with split-first-then-position-only pattern, autoKeyframeEnabled guards,
GSAP base + delta fix in drag draft, cancel-restores-GSAP-x/y from data attrs.
This commit is contained in:
Miguel Ángel
2026-06-12 00:19:13 -04:00
committed by GitHub
parent b6bf1b1190
commit 95029e083e
12 changed files with 503 additions and 176 deletions
+265 -41
View File
@@ -8,7 +8,7 @@
* absolute positions back into the GSAP script, regardless of tween type,
* easing, or seek position.
*/
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { usePlayerStore } from "../player/store/playerStore";
@@ -18,6 +18,7 @@ import {
computeCurrentPercentage,
materializeIfDynamic,
} from "./gsapDragCommit";
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
// ── Runtime reads ──────────────────────────────────────────────────────────
@@ -87,7 +88,7 @@ function findGsapPositionAnimation(
if (a.keyframes) score += 5;
if (selector && a.targetSelector === selector) score += 8;
else if (a.targetSelector.includes(",")) score -= 5;
const pos = typeof a.position === "number" ? a.position : 0;
const pos = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0);
const dur = a.duration ?? 0;
if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) score += 4;
return { anim: a, score };
@@ -104,6 +105,74 @@ function selectorForSelection(selection: DomEditSelection): string | null {
return null;
}
// ── Property-group tween resolution ───────────────────────────────────────
/**
* Find the tween for a given property group, splitting a legacy mixed tween
* if necessary. Returns the resolved animation or null if none exists.
*
* Resolution order:
* 1. Tween already tagged with `propertyGroup === group`
* 2. Legacy mixed tween (`!propertyGroup`) → split via server mutation,
* re-fetch, then return the group tween
* 3. null — caller must handle the missing-tween case
*/
async function resolveGroupTween(
group: PropertyGroupName,
animations: GsapAnimation[],
selection: DomEditSelection,
commitMutation: GsapDragCommitCallbacks["commitMutation"],
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
): Promise<{ anim: GsapAnimation; animations: GsapAnimation[] } | null> {
// 1. Already-split group tween — prefer the one with the most keyframes
// to avoid targeting a stub when a gesture-recorded tween also exists.
const groupAnims = animations.filter((a) => a.propertyGroup === group);
const groupAnim =
groupAnims.length > 1
? groupAnims.sort(
(a, b) => (b.keyframes?.keyframes.length ?? 0) - (a.keyframes?.keyframes.length ?? 0),
)[0]
: (groupAnims[0] ?? null);
if (groupAnim) return { anim: groupAnim, animations };
// 2. Legacy mixed tween — split it, then re-fetch
const legacyMixed = animations.find((a) => !a.propertyGroup);
if (legacyMixed) {
await commitMutation(
selection,
{ type: "split-into-property-groups", animationId: legacyMixed.id },
{ label: "Split mixed tween into property groups", skipReload: true },
);
if (fetchFallbackAnimations) {
const fresh = await fetchFallbackAnimations();
const freshGroupAnim = fresh.find((a) => a.propertyGroup === group);
if (freshGroupAnim) return { anim: freshGroupAnim, animations: fresh };
}
}
// 3. Try fallback fetch (no split needed, just wasn't in the initial list)
if (!legacyMixed && fetchFallbackAnimations) {
const fresh = await fetchFallbackAnimations();
const freshGroupAnim = fresh.find((a) => a.propertyGroup === group);
if (freshGroupAnim) return { anim: freshGroupAnim, animations: fresh };
// Fallback: legacy mixed in the fresh list
const freshLegacy = fresh.find((a) => !a.propertyGroup);
if (freshLegacy) {
await commitMutation(
selection,
{ type: "split-into-property-groups", animationId: freshLegacy.id },
{ label: "Split mixed tween into property groups", skipReload: true },
);
const reFetched = await fetchFallbackAnimations();
const reFetchedGroup = reFetched.find((a) => a.propertyGroup === group);
if (reFetchedGroup) return { anim: reFetchedGroup, animations: reFetched };
}
}
return null;
}
// ── High-level intercept ───────────────────────────────────────────────────
export type { GsapDragCommitCallbacks };
@@ -127,10 +196,24 @@ export async function tryGsapDragIntercept(
const selector = selectorForSelection(selection);
if (!selector) return false;
let posAnim = findGsapPositionAnimation(animations, selector);
if (!posAnim && fetchFallbackAnimations) {
const fresh = await fetchFallbackAnimations();
posAnim = findGsapPositionAnimation(fresh, selector);
// Resolve the position-group tween, splitting legacy mixed tweens if needed.
const resolved = await resolveGroupTween(
"position",
animations,
selection,
commitMutation,
fetchFallbackAnimations,
);
// Fallback: use the legacy scoring heuristic for compositions that don't
// have group-tagged tweens at all (e.g. hand-written scripts).
let posAnim = resolved?.anim ?? null;
if (!posAnim) {
posAnim = findGsapPositionAnimation(animations, selector);
if (!posAnim && fetchFallbackAnimations) {
const fresh = await fetchFallbackAnimations();
posAnim = findGsapPositionAnimation(fresh, selector);
}
}
if (!posAnim) return false;
@@ -151,6 +234,22 @@ export async function tryGsapDragIntercept(
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(
@@ -161,46 +260,155 @@ export async function tryGsapResizeIntercept(
commitMutation: GsapDragCommitCallbacks["commitMutation"],
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
): Promise<boolean> {
let anim = animations.find(
(a) => "width" in a.properties || "height" in a.properties || a.keyframes,
// 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,
);
if (!anim && fetchFallbackAnimations) {
const fresh = await fetchFallbackAnimations();
anim = fresh.find((a) => "width" in a.properties || "height" in a.properties || a.keyframes);
}
if (!anim) return false;
const pct = computeCurrentPercentage(selection, anim);
if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
if (newId) anim = { ...anim, id: newId };
} else if (!anim.keyframes) {
let anim = resolved?.anim ?? null;
if (!anim) {
// No size-group tween exists — create one. Use the element's timing
// from any existing animation, or fall back to element data attributes.
const refAnim = animations[0];
const elStart =
refAnim?.resolvedStart ?? (Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0);
const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "5") || 5;
const ct = usePlayerStore.getState().currentTime;
const pct = elDuration > 0 ? Math.round(((ct - elStart) / elDuration) * 1000) / 10 : 0;
const sel = selectorForSelection(selection);
if (!sel) return false;
await commitMutation(
selection,
{ type: "convert-to-keyframes", animationId: anim.id },
{ label: "Convert to keyframes for resize", skipReload: true },
{
type: "add-with-keyframes",
targetSelector: sel,
position: Math.round(elStart * 1000) / 1000,
duration: Math.round(elDuration * 1000) / 1000,
keyframes: [
{
percentage: Math.max(0, Math.min(100, pct)),
properties: { width: Math.round(size.width), height: Math.round(size.height) },
},
],
},
{ label: "Resize (new size keyframe)", softReload: true },
);
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 = selectorForSelection(selection);
const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {};
const backfillDefaults: Record<string, number> = { ...runtimeProps };
if (!("width" in runtimeProps)) {
const cssW = readGsapProperty(iframe, selector, "width");
backfillDefaults.width = cssW ?? Math.round(size.width);
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 = Math.round((size.width / cssW) * 1000) / 1000;
resizeProps = { scale: newScale };
} else {
resizeProps = {
width: Math.round(size.width),
height: Math.round(size.height),
};
}
if (!("height" in runtimeProps)) {
const cssH = readGsapProperty(iframe, selector, "height");
backfillDefaults.height = cssH ?? Math.round(size.height);
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) {
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 },
);
}
}
const properties = {
...runtimeProps,
width: Math.round(size.width),
height: Math.round(size.height),
};
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: Math.round(newStart * 1000) / 1000,
duration: Math.round(newDuration * 1000) / 1000,
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,
@@ -208,10 +416,10 @@ export async function tryGsapResizeIntercept(
type: "add-keyframe",
animationId: anim.id,
percentage: pct,
properties,
properties: resizeProps,
backfillDefaults,
},
{ label: `Resize (keyframe ${pct}%)`, softReload: true },
{ label: `Resize (keyframe ${pct}%)`, softReload: true, coalesceKey },
);
return true;
}
@@ -226,10 +434,23 @@ export async function tryGsapRotationIntercept(
commitMutation: GsapDragCommitCallbacks["commitMutation"],
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
): Promise<boolean> {
let anim = animations.find((a) => "rotation" in a.properties || a.keyframes);
if (!anim && fetchFallbackAnimations) {
const fresh = await fetchFallbackAnimations();
anim = fresh.find((a) => "rotation" in a.properties || a.keyframes);
// Resolve the rotation-group tween, splitting legacy mixed tweens if needed.
const resolved = await resolveGroupTween(
"rotation",
animations,
selection,
commitMutation,
fetchFallbackAnimations,
);
// Fallback: legacy heuristic for hand-written scripts
let anim = resolved?.anim ?? null;
if (!anim) {
anim = animations.find((a) => "rotation" in a.properties || a.keyframes) ?? null;
if (!anim && fetchFallbackAnimations) {
const fresh = await fetchFallbackAnimations();
anim = fresh.find((a) => "rotation" in a.properties || a.keyframes) ?? null;
}
}
if (!anim) return false;
@@ -261,14 +482,17 @@ export async function tryGsapRotationIntercept(
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, "rotation")
: undefined;
await commitMutation(
selection,
{ type: "convert-to-keyframes", animationId: anim.id },
{ type: "convert-to-keyframes", animationId: anim.id, resolvedFromValues },
{ label: "Convert to keyframes for rotation", skipReload: true },
);
}
const runtimeProps = readAllAnimatedProperties(iframe, selector, anim);
const runtimeProps = readAllAnimatedProperties(iframe, selector, anim, "rotation");
const backfillDefaults: Record<string, number> = { ...runtimeProps };
if (!("rotation" in runtimeProps)) {