mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
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:
@@ -11,8 +11,6 @@ import {
|
||||
resolveTweenStart,
|
||||
resolveTweenDuration,
|
||||
} from "../utils/globalTimeCompiler";
|
||||
import { readAllAnimatedProperties } from "./gsapRuntimeReaders";
|
||||
|
||||
export interface GsapDragCommitCallbacks {
|
||||
commitMutation: (
|
||||
selection: DomEditSelection,
|
||||
@@ -114,7 +112,6 @@ async function extendTweenAndAddKeyframe(
|
||||
const newStart = Math.min(targetTime, tweenStart);
|
||||
const newEnd = Math.max(targetTime, tweenEnd);
|
||||
const newDuration = Math.max(0.01, newEnd - newStart);
|
||||
|
||||
const existingKfs = anim.keyframes?.keyframes ?? [];
|
||||
const remappedKfs: Array<{ percentage: number; properties: Record<string, number | string> }> =
|
||||
[];
|
||||
@@ -126,20 +123,15 @@ async function extendTweenAndAddKeyframe(
|
||||
|
||||
const targetPct = Math.round(((targetTime - newStart) / newDuration) * 1000) / 10;
|
||||
remappedKfs.push({ percentage: targetPct, properties });
|
||||
|
||||
remappedKfs.sort((a, b) => a.percentage - b.percentage);
|
||||
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{ type: "delete", animationId: anim.id },
|
||||
{ label: "Extend tween range", skipReload: true },
|
||||
);
|
||||
|
||||
const selector = anim.targetSelector;
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{
|
||||
type: "add-with-keyframes",
|
||||
targetSelector: selector,
|
||||
type: "replace-with-keyframes",
|
||||
animationId: anim.id,
|
||||
targetSelector: anim.targetSelector,
|
||||
position: Math.round(newStart * 1000) / 1000,
|
||||
duration: Math.round(newDuration * 1000) / 1000,
|
||||
keyframes: remappedKfs,
|
||||
@@ -156,8 +148,9 @@ async function commitKeyframedPosition(
|
||||
callbacks: GsapDragCommitCallbacks,
|
||||
beforeReload?: () => void,
|
||||
): Promise<void> {
|
||||
const pct = computeCurrentPercentage(selection, anim);
|
||||
|
||||
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
|
||||
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
|
||||
if (activeKeyframePct != null) setActiveKeyframePct(null);
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{
|
||||
@@ -182,10 +175,11 @@ async function commitFlatViaKeyframes(
|
||||
callbacks: GsapDragCommitCallbacks,
|
||||
beforeReload?: () => void,
|
||||
): Promise<void> {
|
||||
const coalesceKey = `gsap:convert-drag:${anim.id}`;
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{ type: "convert-to-keyframes", animationId: anim.id },
|
||||
{ label: "Convert to keyframes for drag", skipReload: true },
|
||||
{ label: "Convert to keyframes for drag", skipReload: true, coalesceKey },
|
||||
);
|
||||
|
||||
const pct = computeCurrentPercentage(selection, anim);
|
||||
@@ -198,7 +192,7 @@ async function commitFlatViaKeyframes(
|
||||
percentage: pct,
|
||||
properties,
|
||||
},
|
||||
{ label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload },
|
||||
{ label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload, coalesceKey },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -243,19 +237,20 @@ export async function commitGsapPositionFromDrag(
|
||||
el.removeAttribute("data-hf-drag-initial-offset-y");
|
||||
};
|
||||
|
||||
const ct = usePlayerStore.getState().currentTime;
|
||||
if (anim.keyframes) {
|
||||
const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
|
||||
const effectiveAnim = newId ? { ...anim, id: newId } : anim;
|
||||
const runtimeProps = readAllAnimatedProperties(iframe, selector, anim);
|
||||
const dragProps: Record<string, number> = { x: newX, y: newY };
|
||||
|
||||
const ct = usePlayerStore.getState().currentTime;
|
||||
const ts = resolveTweenStart(effectiveAnim);
|
||||
const td = resolveTweenDuration(effectiveAnim);
|
||||
if (ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01)) {
|
||||
const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
|
||||
if (outsideRange) {
|
||||
await extendTweenAndAddKeyframe(
|
||||
selection,
|
||||
effectiveAnim,
|
||||
{ ...runtimeProps, x: newX, y: newY },
|
||||
dragProps,
|
||||
ct,
|
||||
ts,
|
||||
td,
|
||||
@@ -263,32 +258,126 @@ export async function commitGsapPositionFromDrag(
|
||||
restoreOffset,
|
||||
);
|
||||
} else {
|
||||
await commitKeyframedPosition(
|
||||
selection,
|
||||
effectiveAnim,
|
||||
{ ...runtimeProps, x: newX, y: newY },
|
||||
callbacks,
|
||||
restoreOffset,
|
||||
);
|
||||
await commitKeyframedPosition(selection, effectiveAnim, dragProps, callbacks, restoreOffset);
|
||||
}
|
||||
} else if (anim.method === "from" || anim.method === "fromTo") {
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{
|
||||
type: "convert-to-keyframes",
|
||||
animationId: anim.id,
|
||||
resolvedFromValues: { x: newX, y: newY },
|
||||
},
|
||||
{ label: "Move layer (keyframe rest)", softReload: true, beforeReload: restoreOffset },
|
||||
);
|
||||
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);
|
||||
const dragProps: Record<string, number> = { x: newX, y: newY };
|
||||
|
||||
if (outsideRange && ts !== null) {
|
||||
// Split the original from() tween into property groups first.
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{ type: "split-into-property-groups", animationId: anim.id },
|
||||
{ label: "Split from() for drag", skipReload: true },
|
||||
);
|
||||
|
||||
// Check if a position-group tween already exists (e.g. from gesture recording).
|
||||
// If so, extend it instead of creating a duplicate.
|
||||
const allAnims = await (async () => {
|
||||
const pid = selection.sourceFile || "index.html";
|
||||
try {
|
||||
const r = await fetch(
|
||||
`/api/projects/${encodeURIComponent(window.location.hash.match(/project\/([^?/]+)/)?.[1] ?? "")}/gsap-animations/${encodeURIComponent(pid)}`,
|
||||
);
|
||||
if (!r.ok) return [];
|
||||
const parsed = await r.json();
|
||||
return (parsed?.animations ?? []) as GsapAnimation[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
const existingPosAnim = allAnims.find(
|
||||
(a) => a.propertyGroup === "position" && a.targetSelector === anim.targetSelector,
|
||||
);
|
||||
|
||||
if (existingPosAnim?.keyframes) {
|
||||
// Extend the existing position tween
|
||||
const posTs = resolveTweenStart(existingPosAnim);
|
||||
const posTd = resolveTweenDuration(existingPosAnim);
|
||||
if (posTs !== null) {
|
||||
await extendTweenAndAddKeyframe(
|
||||
selection,
|
||||
existingPosAnim,
|
||||
{ x: newX, y: newY },
|
||||
ct,
|
||||
posTs,
|
||||
posTd,
|
||||
callbacks,
|
||||
restoreOffset,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// No existing position tween — create one
|
||||
const newStart = Math.min(ct, ts);
|
||||
const newEnd = Math.max(ct, ts + td);
|
||||
const newDuration = Math.max(0.01, newEnd - newStart);
|
||||
const dragBefore = ct < ts;
|
||||
const origStartPct = Math.round(((ts - newStart) / newDuration) * 1000) / 10;
|
||||
const origEndPct = Math.round(((ts + td - newStart) / newDuration) * 1000) / 10;
|
||||
|
||||
const keyframes: Array<{ percentage: number; properties: Record<string, number | string> }> =
|
||||
[];
|
||||
if (dragBefore) {
|
||||
keyframes.push({ percentage: 0, properties: { x: newX, y: newY } });
|
||||
if (origStartPct > 0.5 && origStartPct < 99.5) {
|
||||
keyframes.push({ percentage: origStartPct, properties: { x: 0, y: 0 } });
|
||||
}
|
||||
keyframes.push({ percentage: 100, properties: { x: 0, y: 0 } });
|
||||
} else {
|
||||
keyframes.push({ percentage: 0, properties: { x: 0, y: 0 } });
|
||||
if (origEndPct > 0.5 && origEndPct < 99.5) {
|
||||
keyframes.push({ percentage: origEndPct, properties: { x: 0, y: 0 } });
|
||||
}
|
||||
keyframes.push({ percentage: 100, properties: { x: newX, y: newY } });
|
||||
}
|
||||
keyframes.sort((a, b) => a.percentage - b.percentage);
|
||||
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{
|
||||
type: "add-with-keyframes",
|
||||
targetSelector: anim.targetSelector,
|
||||
position: Math.round(newStart * 1000) / 1000,
|
||||
duration: Math.round(newDuration * 1000) / 1000,
|
||||
keyframes,
|
||||
},
|
||||
{ label: "Move layer (from extended)", softReload: true, beforeReload: restoreOffset },
|
||||
);
|
||||
} else {
|
||||
// Inside tween range: convert then add keyframe at current time
|
||||
const coalesceKey = `gsap:convert-drag:${anim.id}`;
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{
|
||||
type: "convert-to-keyframes",
|
||||
animationId: anim.id,
|
||||
},
|
||||
{ label: "Convert from() for drag", skipReload: true, coalesceKey },
|
||||
);
|
||||
const pct = computeCurrentPercentage(selection, anim);
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{
|
||||
type: "add-keyframe",
|
||||
animationId: anim.id,
|
||||
percentage: pct,
|
||||
properties: dragProps,
|
||||
},
|
||||
{
|
||||
label: `Move layer (keyframe ${pct}%)`,
|
||||
softReload: true,
|
||||
beforeReload: restoreOffset,
|
||||
coalesceKey,
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const runtimeProps = readAllAnimatedProperties(iframe, selector, anim);
|
||||
await commitFlatViaKeyframes(
|
||||
selection,
|
||||
anim,
|
||||
{ ...runtimeProps, x: newX, y: newY },
|
||||
callbacks,
|
||||
restoreOffset,
|
||||
);
|
||||
await commitFlatViaKeyframes(selection, anim, { x: newX, y: newY }, callbacks, restoreOffset);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Low-level GSAP runtime property readers shared by gsapRuntimeBridge and gsapDragCommit.
|
||||
*/
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { classifyPropertyGroup, type PropertyGroupName } from "@hyperframes/core/gsap-parser";
|
||||
|
||||
interface IframeGsap {
|
||||
getProperty: (el: Element, prop: string) => number;
|
||||
@@ -19,7 +20,8 @@ export function readGsapProperty(
|
||||
const el = iframe.contentDocument?.querySelector(selector);
|
||||
if (!el) return null;
|
||||
const val = Number(gsap.getProperty(el, prop));
|
||||
return Number.isFinite(val) ? Math.round(val) : null;
|
||||
if (!Number.isFinite(val)) return null;
|
||||
return POSITION_PROPS.has(prop) ? Math.round(val) : Math.round(val * 1000) / 1000;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -51,6 +53,7 @@ export function readAllAnimatedProperties(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
selector: string,
|
||||
anim: GsapAnimation,
|
||||
group?: PropertyGroupName,
|
||||
): Record<string, number> {
|
||||
const result: Record<string, number> = {};
|
||||
if (!iframe?.contentWindow) return result;
|
||||
@@ -81,6 +84,13 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
for (const prop of propKeys) {
|
||||
const val = Number(gsap.getProperty(el, prop));
|
||||
if (Number.isFinite(val)) {
|
||||
@@ -147,9 +157,13 @@ export function readAllAnimatedProperties(
|
||||
sepia: 0,
|
||||
invert: 0,
|
||||
};
|
||||
// Collect all properties that ANY tween on this element explicitly targets.
|
||||
// Only capture baseline values for these — GSAP reports non-default values
|
||||
// (scaleZ=0, brightness=0) for untouched properties, polluting keyframes.
|
||||
const allTweenedProps = new Set([...propKeys, ...otherTweenProps]);
|
||||
for (const [prop, defaultVal] of Object.entries(UNIVERSAL_BASELINE)) {
|
||||
if (prop in result) continue;
|
||||
if (otherTweenProps.has(prop)) continue;
|
||||
if (!allTweenedProps.has(prop)) continue;
|
||||
const val = Number(gsap.getProperty(el, prop));
|
||||
if (Number.isFinite(val) && Math.round(val * 1000) !== Math.round(defaultVal * 1000)) {
|
||||
result[prop] = Math.round(val * 1000) / 1000;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
import { useCallback } from "react";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeBridge";
|
||||
@@ -38,7 +39,7 @@ interface CommitAnimatedPropertyDeps {
|
||||
|
||||
function computePercentage(selection: DomEditSelection, anim?: GsapAnimation): number {
|
||||
const currentTime = usePlayerStore.getState().currentTime;
|
||||
const tweenPos = typeof anim?.position === "number" ? anim.position : 0;
|
||||
const tweenPos = anim?.resolvedStart ?? (typeof anim?.position === "number" ? anim.position : 0);
|
||||
const tweenDur = anim?.duration ?? 0;
|
||||
if (tweenDur > 0) {
|
||||
return Math.max(
|
||||
@@ -56,18 +57,19 @@ function computePercentage(selection: DomEditSelection, anim?: GsapAnimation): n
|
||||
function pickBestAnimation(
|
||||
animations: GsapAnimation[],
|
||||
selector: string | null,
|
||||
property?: string,
|
||||
): GsapAnimation | undefined {
|
||||
if (animations.length <= 1) return animations[0];
|
||||
const currentTime = usePlayerStore.getState().currentTime;
|
||||
const targetGroup = property ? classifyPropertyGroup(property) : undefined;
|
||||
|
||||
const scored = animations.map((a) => {
|
||||
let score = 0;
|
||||
if (targetGroup && a.propertyGroup === targetGroup) score += 20;
|
||||
if (a.keyframes) score += 10;
|
||||
// Prefer single-element selectors over comma-separated groups
|
||||
if (selector && a.targetSelector === selector) score += 5;
|
||||
else if (a.targetSelector.includes(",")) score -= 3;
|
||||
// Prefer tweens active at the current time
|
||||
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 += 8;
|
||||
return { anim: a, score };
|
||||
@@ -102,7 +104,11 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
|
||||
const iframe = previewIframeRef.current;
|
||||
const selector = selectorFor(selection);
|
||||
|
||||
let anim: GsapAnimation | undefined = pickBestAnimation(selectedGsapAnimations, selector);
|
||||
let anim: GsapAnimation | undefined = pickBestAnimation(
|
||||
selectedGsapAnimations,
|
||||
selector,
|
||||
property,
|
||||
);
|
||||
|
||||
// Case 3: No animation — create one first
|
||||
if (!anim) {
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useCallback, useEffect, useRef } from "react";
|
||||
import type { TimelineElement } from "../player";
|
||||
import { usePlayerStore } from "../player";
|
||||
import {
|
||||
STUDIO_GSAP_PANEL_ENABLED,
|
||||
STUDIO_GSAP_DRAG_INTERCEPT_ENABLED,
|
||||
STUDIO_GSAP_PANEL_ENABLED,
|
||||
} from "../components/editor/manualEditingAvailability";
|
||||
import { type DomEditSelection } from "../components/editor/domEditing";
|
||||
import { useDomEditPreviewSync } from "./useDomEditPreviewSync";
|
||||
@@ -329,7 +329,11 @@ export function useDomEditSession({
|
||||
// GSAP-aware: intercept offset/resize/rotation to commit via script mutation when animated.
|
||||
const handleGsapAwarePathOffsetCommit = useCallback(
|
||||
async (selection: DomEditSelection, next: { x: number; y: number }) => {
|
||||
if (gsapCommitMutation && STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) {
|
||||
if (
|
||||
STUDIO_GSAP_DRAG_INTERCEPT_ENABLED &&
|
||||
gsapCommitMutation &&
|
||||
usePlayerStore.getState().autoKeyframeEnabled
|
||||
) {
|
||||
const handled = await tryGsapDragIntercept(
|
||||
selection,
|
||||
next,
|
||||
@@ -375,7 +379,11 @@ export function useDomEditSession({
|
||||
|
||||
const handleGsapAwareBoxSizeCommit = useCallback(
|
||||
async (selection: DomEditSelection, next: { width: number; height: number }) => {
|
||||
if (gsapCommitMutation && STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) {
|
||||
if (
|
||||
STUDIO_GSAP_DRAG_INTERCEPT_ENABLED &&
|
||||
gsapCommitMutation &&
|
||||
usePlayerStore.getState().autoKeyframeEnabled
|
||||
) {
|
||||
const handled = await tryGsapResizeIntercept(
|
||||
selection,
|
||||
next,
|
||||
@@ -399,7 +407,11 @@ export function useDomEditSession({
|
||||
|
||||
const handleGsapAwareRotationCommit = useCallback(
|
||||
async (selection: DomEditSelection, next: { angle: number }) => {
|
||||
if (gsapCommitMutation && STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) {
|
||||
if (
|
||||
STUDIO_GSAP_DRAG_INTERCEPT_ENABLED &&
|
||||
gsapCommitMutation &&
|
||||
usePlayerStore.getState().autoKeyframeEnabled
|
||||
) {
|
||||
const handled = await tryGsapRotationIntercept(
|
||||
selection,
|
||||
next.angle,
|
||||
|
||||
@@ -52,10 +52,12 @@ function readElementPosition(
|
||||
const element = sel.element;
|
||||
if (!element?.isConnected || !gsap?.getProperty) return result;
|
||||
|
||||
const POSITION_PROPS = new Set(["x", "y", "xPercent", "yPercent"]);
|
||||
const props = anim ? Object.keys(anim.properties) : ["x", "y", "opacity"];
|
||||
for (const prop of props) {
|
||||
const val = Number(gsap.getProperty(element, prop));
|
||||
if (Number.isFinite(val)) result[prop] = Math.round(val);
|
||||
if (!Number.isFinite(val)) continue;
|
||||
result[prop] = POSITION_PROPS.has(prop) ? Math.round(val) : Math.round(val * 1000) / 1000;
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
Reference in New Issue
Block a user