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
@@ -28,10 +28,26 @@ function buildMockIframe(overrides: Record<string, unknown> = {}) {
...overrides,
};
// Intercept appendChild: when a <script> is appended, simulate execution by
// repopulating __timelines (mimicking what the real GSAP script would do).
const realAppendChild = container.appendChild.bind(container);
container.appendChild = <T extends Node>(node: T): T => {
const result = realAppendChild(node);
if (node instanceof HTMLScriptElement && node.textContent?.includes("gsap.timeline")) {
// Simulate the script populating __timelines
const cw = contentWindow as { __timelines?: Record<string, unknown> };
if (cw.__timelines) {
cw.__timelines.root = { kill: vi.fn(), pause: vi.fn() };
}
}
return result;
};
const contentDocument = {
querySelectorAll: (sel: string) => (sel === "script:not([src])" ? [scriptEl] : []),
createElement: (tag: string) => document.createElement(tag),
body: container,
head: document.createElement("div"),
};
return {
+43 -8
View File
@@ -7,6 +7,8 @@ type IframeWindow = Window & {
gsap?: {
timeline?: (...args: unknown[]) => unknown;
registerPlugin?: (...plugins: unknown[]) => unknown;
set?: (targets: Element | Element[], vars: Record<string, unknown>) => void;
globalTimeline?: { getChildren?: (deep: boolean) => Array<{ kill?: () => void }> };
};
MotionPathPlugin?: unknown;
};
@@ -29,6 +31,14 @@ function findGsapScriptElements(doc: Document): HTMLScriptElement[] {
return results;
}
/** Check that the new script repopulated __timelines with at least one entry. */
function verifyTimelinesPopulated(win: IframeWindow): boolean {
const tlKeys = win.__timelines
? Object.keys(win.__timelines).filter((k) => k !== "__proxied")
: [];
return tlKeys.length > 0;
}
/**
* Replace the GSAP script in the live iframe without reloading. This preserves
* the WebGL context and shader transition cache.
@@ -56,24 +66,30 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
const currentTime = win.__player?.getTime?.() ?? 0;
// Track whether the MotionPath async path was taken. When it is, the script
// executes inside pluginScript.onload — after applySoftReload has already
// returned. We optimistically return true because the script WILL execute
// once the plugin loads; the alternative (returning false) would trigger a
// full iframe reload that destroys the very WebGL context we're preserving.
let deferredToAsync = false;
const doReload = () => {
const timelines = win.__timelines;
const allTargets: Element[] = [];
if (timelines) {
for (const key of Object.keys(timelines)) {
if (key === "__proxied") continue;
try {
const tl = timelines[key] as {
kill?: () => void;
getChildren?: (deep: boolean) => Array<{ targets?: () => Element[] }>;
};
const allTargets: Element[] = [];
if (tl?.getChildren) {
try {
for (const child of tl.getChildren(true)) {
if (typeof child.targets === "function") {
for (const t of child.targets()) {
allTargets.push(t);
delete (t as unknown as Record<string, unknown>)._gsap;
}
for (const t of child.targets()) allTargets.push(t);
}
}
} catch {}
@@ -84,6 +100,23 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
}
}
// Kill bare gsap.to/from tweens not registered on __timelines
if (win.gsap?.globalTimeline?.getChildren) {
try {
for (const child of win.gsap.globalTimeline.getChildren(false)) {
child.kill?.();
}
} catch {}
}
// Clear residual inline transforms left by killed tweens so from() tweens
// don't read stale end values from the DOM on re-execution
if (allTargets.length > 0 && win.gsap?.set) {
try {
win.gsap.set(allTargets, { clearProps: "all" });
} catch {}
}
oldScriptEl.remove();
const executeScript = () => {
@@ -98,10 +131,9 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
win.__hfStudioManualEditsApply?.();
};
// Load MotionPathPlugin on demand if the script uses motionPath.
// Uses the same CDN as composition templates (GSAP_CDN in constants.ts).
const needsMotionPath = /motionPath\s*[:{]/.test(scriptText);
if (needsMotionPath && !win.MotionPathPlugin && win.gsap) {
deferredToAsync = true;
const pluginScript = doc.createElement("script");
pluginScript.src = "https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/MotionPathPlugin.min.js";
pluginScript.onload = () => executeScript();
@@ -119,7 +151,10 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
} else {
doReload();
}
return true;
// When MotionPath needs async loading, the script hasn't executed yet —
// skip the __timelines check and return true optimistically.
if (deferredToAsync) return true;
return verifyTimelinesPopulated(win);
} catch {
return false;
}