fix(studio): keyframe cache propertyGroup tagging + timeline UI (#1357)

* 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.

* fix(studio): keyframe cache propertyGroup tagging + timeline UI fixes

Tag cached keyframes with propertyGroup for group-aware operations.
Add tweenPercentage for accurate keyframe matching, activeKeyframePct
for diamond-click targeting, context menu offset, selected diamond z-index,
clearProps after kill in soft reload.
This commit is contained in:
Miguel Ángel
2026-06-12 00:19:23 -04:00
committed by GitHub
parent 95029e083e
commit caf23eff8a
8 changed files with 113 additions and 28 deletions
@@ -21,18 +21,23 @@ export function updateKeyframeCacheFromParsed(
// Convert tween-relative percentages to clip-relative so diamonds
// render at the correct position within the timeline clip.
const tweenPos = typeof anim.position === "number" ? anim.position : 0;
const tweenPos = anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
const tweenDur = anim.duration ?? 1;
const timelineEl = elements.find(
(el) => el.domId === id || (el.key ?? el.id) === `${targetPath}#${id}`,
);
const elStart = timelineEl?.start ?? 0;
const elDuration = timelineEl?.duration ?? 4;
const elDuration = timelineEl?.duration ?? 1;
const clipKeyframes = anim.keyframes.keyframes.map((kf) => {
const absTime = tweenPos + (kf.percentage / 100) * tweenDur;
const clipPct =
elDuration > 0 ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 : kf.percentage;
return { ...kf, percentage: clipPct };
return {
...kf,
percentage: clipPct,
tweenPercentage: kf.percentage,
propertyGroup: anim.propertyGroup,
};
});
const existing = merged.get(id);
@@ -66,7 +71,7 @@ export function updateKeyframeCacheFromParsed(
}
}
export function buildCacheKey(sourceFile: string, elementId: string): string {
function buildCacheKey(sourceFile: string, elementId: string): string {
return `${sourceFile}#${elementId}`;
}
@@ -449,7 +449,9 @@ export function useGsapScriptCommits({
apply: () => {
const prev = readKeyframeSnapshot(sf, elementId);
if (prev) {
const newKeyframes = prev.keyframes.filter((kf) => kf.percentage !== percentage);
const newKeyframes = prev.keyframes.filter(
(kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) > 0.2,
);
writeKeyframeCache(sf, elementId, { ...prev, keyframes: newKeyframes });
}
return prev;
+30 -10
View File
@@ -264,9 +264,11 @@ export function useGsapAnimationsForElement(
(el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`,
);
const elStart = timelineEl?.start ?? 0;
const elDuration = timelineEl?.duration ?? 4;
const elDuration = timelineEl?.duration ?? 1;
const allKeyframes: GsapKeyframesData["keyframes"] = [];
const allKeyframes: Array<
GsapKeyframesData["keyframes"][0] & { tweenPercentage?: number; propertyGroup?: string }
> = [];
let format: GsapKeyframesData["format"] = "percentage";
let ease: string | undefined;
let easeEach: string | undefined;
@@ -275,7 +277,8 @@ export function useGsapAnimationsForElement(
if (!kf) continue;
// Convert tween-relative percentages to clip-relative so diamonds
// render at the correct position within the timeline clip.
const tweenPos = typeof anim.position === "number" ? anim.position : 0;
const tweenPos =
anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
const tweenDur = anim.duration ?? elDuration;
for (const k of kf.keyframes) {
const absTime = tweenPos + (k.percentage / 100) * tweenDur;
@@ -283,7 +286,12 @@ export function useGsapAnimationsForElement(
elDuration > 0
? Math.round(((absTime - elStart) / elDuration) * 1000) / 10
: k.percentage;
allKeyframes.push({ ...k, percentage: clipPct });
allKeyframes.push({
...k,
percentage: clipPct,
tweenPercentage: k.percentage,
propertyGroup: anim.propertyGroup,
});
}
format = kf.format;
if (kf.ease) ease = kf.ease;
@@ -305,6 +313,9 @@ export function useGsapAnimationsForElement(
};
const { setKeyframeCache } = usePlayerStore.getState();
setKeyframeCache(`${sourceFile}#${elementId}`, merged);
// PropertyPanel reads the cache by bare elementId (without sourceFile prefix),
// so write a duplicate entry under the bare key for cross-component lookups.
setKeyframeCache(elementId, merged);
}, [elementId, sourceFile, animations]);
return { animations, multipleTimelines, unsupportedTimelinePattern };
@@ -327,13 +338,14 @@ export function usePopulateKeyframeCacheForFile(
version: number,
iframeRef?: React.RefObject<HTMLIFrameElement | null>,
): void {
const elementCount = usePlayerStore((s) => s.elements.length);
const lastFetchKeyRef = useRef("");
const runtimeScanDoneRef = useRef("");
const astFetchDoneRef = useRef("");
useEffect(() => {
const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}`;
const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}:${elementCount}`;
if (fetchKey === lastFetchKeyRef.current) return;
lastFetchKeyRef.current = fetchKey;
runtimeScanDoneRef.current = "";
@@ -358,21 +370,26 @@ export function usePopulateKeyframeCacheForFile(
if (!id) continue;
const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
if (!kfData) continue;
// Convert tween-relative percentages to clip-relative.
const tweenPos = typeof anim.position === "number" ? anim.position : 0;
const tweenPos =
anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
const tweenDur = anim.duration ?? 1;
const timelineEl = elements.find(
(el) => el.domId === id || (el.key ?? el.id) === `${sf}#${id}`,
);
const elStart = timelineEl?.start ?? 0;
const elDuration = timelineEl?.duration ?? 4;
const elDuration = timelineEl?.duration ?? 1;
const clipKeyframes = kfData.keyframes.map((kf) => {
const absTime = tweenPos + (kf.percentage / 100) * tweenDur;
const clipPct =
elDuration > 0
? Math.round(((absTime - elStart) / elDuration) * 1000) / 10
: kf.percentage;
return { ...kf, percentage: clipPct };
return {
...kf,
percentage: clipPct,
tweenPercentage: kf.percentage,
propertyGroup: anim.propertyGroup,
};
});
const existing = mergedByElement.get(id);
if (existing) {
@@ -388,7 +405,10 @@ export function usePopulateKeyframeCacheForFile(
}
astFetchDoneRef.current = fetchKey;
});
}, [projectId, sourceFile, version]);
// elementCount is in the deps because new timeline elements (e.g. after a
// sub-composition expand) need their keyframe cache populated immediately;
// without it the effect won't re-run when elements appear/disappear.
}, [projectId, sourceFile, version, elementCount]);
// Separate effect for runtime keyframe discovery — polls until the iframe
// has loaded GSAP timelines, independent of the AST fetch lifecycle.