mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
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:
@@ -21,18 +21,23 @@ export function updateKeyframeCacheFromParsed(
|
|||||||
|
|
||||||
// Convert tween-relative percentages to clip-relative so diamonds
|
// Convert tween-relative percentages to clip-relative so diamonds
|
||||||
// render at the correct position within the timeline clip.
|
// 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 tweenDur = anim.duration ?? 1;
|
||||||
const timelineEl = elements.find(
|
const timelineEl = elements.find(
|
||||||
(el) => el.domId === id || (el.key ?? el.id) === `${targetPath}#${id}`,
|
(el) => el.domId === id || (el.key ?? el.id) === `${targetPath}#${id}`,
|
||||||
);
|
);
|
||||||
const elStart = timelineEl?.start ?? 0;
|
const elStart = timelineEl?.start ?? 0;
|
||||||
const elDuration = timelineEl?.duration ?? 4;
|
const elDuration = timelineEl?.duration ?? 1;
|
||||||
const clipKeyframes = anim.keyframes.keyframes.map((kf) => {
|
const clipKeyframes = anim.keyframes.keyframes.map((kf) => {
|
||||||
const absTime = tweenPos + (kf.percentage / 100) * tweenDur;
|
const absTime = tweenPos + (kf.percentage / 100) * tweenDur;
|
||||||
const clipPct =
|
const clipPct =
|
||||||
elDuration > 0 ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 : kf.percentage;
|
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);
|
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}`;
|
return `${sourceFile}#${elementId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -449,7 +449,9 @@ export function useGsapScriptCommits({
|
|||||||
apply: () => {
|
apply: () => {
|
||||||
const prev = readKeyframeSnapshot(sf, elementId);
|
const prev = readKeyframeSnapshot(sf, elementId);
|
||||||
if (prev) {
|
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 });
|
writeKeyframeCache(sf, elementId, { ...prev, keyframes: newKeyframes });
|
||||||
}
|
}
|
||||||
return prev;
|
return prev;
|
||||||
|
|||||||
@@ -264,9 +264,11 @@ export function useGsapAnimationsForElement(
|
|||||||
(el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`,
|
(el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`,
|
||||||
);
|
);
|
||||||
const elStart = timelineEl?.start ?? 0;
|
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 format: GsapKeyframesData["format"] = "percentage";
|
||||||
let ease: string | undefined;
|
let ease: string | undefined;
|
||||||
let easeEach: string | undefined;
|
let easeEach: string | undefined;
|
||||||
@@ -275,7 +277,8 @@ export function useGsapAnimationsForElement(
|
|||||||
if (!kf) continue;
|
if (!kf) continue;
|
||||||
// Convert tween-relative percentages to clip-relative so diamonds
|
// Convert tween-relative percentages to clip-relative so diamonds
|
||||||
// render at the correct position within the timeline clip.
|
// 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;
|
const tweenDur = anim.duration ?? elDuration;
|
||||||
for (const k of kf.keyframes) {
|
for (const k of kf.keyframes) {
|
||||||
const absTime = tweenPos + (k.percentage / 100) * tweenDur;
|
const absTime = tweenPos + (k.percentage / 100) * tweenDur;
|
||||||
@@ -283,7 +286,12 @@ export function useGsapAnimationsForElement(
|
|||||||
elDuration > 0
|
elDuration > 0
|
||||||
? Math.round(((absTime - elStart) / elDuration) * 1000) / 10
|
? Math.round(((absTime - elStart) / elDuration) * 1000) / 10
|
||||||
: k.percentage;
|
: k.percentage;
|
||||||
allKeyframes.push({ ...k, percentage: clipPct });
|
allKeyframes.push({
|
||||||
|
...k,
|
||||||
|
percentage: clipPct,
|
||||||
|
tweenPercentage: k.percentage,
|
||||||
|
propertyGroup: anim.propertyGroup,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
format = kf.format;
|
format = kf.format;
|
||||||
if (kf.ease) ease = kf.ease;
|
if (kf.ease) ease = kf.ease;
|
||||||
@@ -305,6 +313,9 @@ export function useGsapAnimationsForElement(
|
|||||||
};
|
};
|
||||||
const { setKeyframeCache } = usePlayerStore.getState();
|
const { setKeyframeCache } = usePlayerStore.getState();
|
||||||
setKeyframeCache(`${sourceFile}#${elementId}`, merged);
|
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]);
|
}, [elementId, sourceFile, animations]);
|
||||||
|
|
||||||
return { animations, multipleTimelines, unsupportedTimelinePattern };
|
return { animations, multipleTimelines, unsupportedTimelinePattern };
|
||||||
@@ -327,13 +338,14 @@ export function usePopulateKeyframeCacheForFile(
|
|||||||
version: number,
|
version: number,
|
||||||
iframeRef?: React.RefObject<HTMLIFrameElement | null>,
|
iframeRef?: React.RefObject<HTMLIFrameElement | null>,
|
||||||
): void {
|
): void {
|
||||||
|
const elementCount = usePlayerStore((s) => s.elements.length);
|
||||||
const lastFetchKeyRef = useRef("");
|
const lastFetchKeyRef = useRef("");
|
||||||
|
|
||||||
const runtimeScanDoneRef = useRef("");
|
const runtimeScanDoneRef = useRef("");
|
||||||
const astFetchDoneRef = useRef("");
|
const astFetchDoneRef = useRef("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}`;
|
const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}:${elementCount}`;
|
||||||
if (fetchKey === lastFetchKeyRef.current) return;
|
if (fetchKey === lastFetchKeyRef.current) return;
|
||||||
lastFetchKeyRef.current = fetchKey;
|
lastFetchKeyRef.current = fetchKey;
|
||||||
runtimeScanDoneRef.current = "";
|
runtimeScanDoneRef.current = "";
|
||||||
@@ -358,21 +370,26 @@ export function usePopulateKeyframeCacheForFile(
|
|||||||
if (!id) continue;
|
if (!id) continue;
|
||||||
const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
|
const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
|
||||||
if (!kfData) continue;
|
if (!kfData) continue;
|
||||||
// Convert tween-relative percentages to clip-relative.
|
const tweenPos =
|
||||||
const tweenPos = typeof anim.position === "number" ? anim.position : 0;
|
anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
|
||||||
const tweenDur = anim.duration ?? 1;
|
const tweenDur = anim.duration ?? 1;
|
||||||
const timelineEl = elements.find(
|
const timelineEl = elements.find(
|
||||||
(el) => el.domId === id || (el.key ?? el.id) === `${sf}#${id}`,
|
(el) => el.domId === id || (el.key ?? el.id) === `${sf}#${id}`,
|
||||||
);
|
);
|
||||||
const elStart = timelineEl?.start ?? 0;
|
const elStart = timelineEl?.start ?? 0;
|
||||||
const elDuration = timelineEl?.duration ?? 4;
|
const elDuration = timelineEl?.duration ?? 1;
|
||||||
const clipKeyframes = kfData.keyframes.map((kf) => {
|
const clipKeyframes = kfData.keyframes.map((kf) => {
|
||||||
const absTime = tweenPos + (kf.percentage / 100) * tweenDur;
|
const absTime = tweenPos + (kf.percentage / 100) * tweenDur;
|
||||||
const clipPct =
|
const clipPct =
|
||||||
elDuration > 0
|
elDuration > 0
|
||||||
? Math.round(((absTime - elStart) / elDuration) * 1000) / 10
|
? Math.round(((absTime - elStart) / elDuration) * 1000) / 10
|
||||||
: kf.percentage;
|
: kf.percentage;
|
||||||
return { ...kf, percentage: clipPct };
|
return {
|
||||||
|
...kf,
|
||||||
|
percentage: clipPct,
|
||||||
|
tweenPercentage: kf.percentage,
|
||||||
|
propertyGroup: anim.propertyGroup,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
const existing = mergedByElement.get(id);
|
const existing = mergedByElement.get(id);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -388,7 +405,10 @@ export function usePopulateKeyframeCacheForFile(
|
|||||||
}
|
}
|
||||||
astFetchDoneRef.current = fetchKey;
|
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
|
// Separate effect for runtime keyframe discovery — polls until the iframe
|
||||||
// has loaded GSAP timelines, independent of the AST fetch lifecycle.
|
// has loaded GSAP timelines, independent of the AST fetch lifecycle.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface KeyframeDiamondContextMenuState {
|
|||||||
y: number;
|
y: number;
|
||||||
elementId: string;
|
elementId: string;
|
||||||
percentage: number;
|
percentage: number;
|
||||||
|
tweenPercentage?: number;
|
||||||
currentEase?: string;
|
currentEase?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +114,7 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
|
|||||||
type="button"
|
type="button"
|
||||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onDelete(state.elementId, state.percentage);
|
onDelete(state.elementId, state.tweenPercentage ?? state.percentage);
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -448,6 +448,9 @@ export const Timeline = memo(function Timeline({
|
|||||||
onSelectElement?.(el);
|
onSelectElement?.(el);
|
||||||
const absTime = el.start + (pct / 100) * el.duration;
|
const absTime = el.start + (pct / 100) * el.duration;
|
||||||
onSeek?.(absTime);
|
onSeek?.(absTime);
|
||||||
|
const kfData = keyframeCache?.get(elKey);
|
||||||
|
const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.5);
|
||||||
|
usePlayerStore.getState().setActiveKeyframePct(kf?.tweenPercentage ?? null);
|
||||||
}}
|
}}
|
||||||
onShiftClickKeyframe={(elId, pct) => {
|
onShiftClickKeyframe={(elId, pct) => {
|
||||||
toggleSelectedKeyframe(`${elId}:${pct}`);
|
toggleSelectedKeyframe(`${elId}:${pct}`);
|
||||||
@@ -464,12 +467,13 @@ export const Timeline = memo(function Timeline({
|
|||||||
onSeek?.(absTime);
|
onSeek?.(absTime);
|
||||||
}
|
}
|
||||||
const kfData = keyframeCache.get(elId);
|
const kfData = keyframeCache.get(elId);
|
||||||
const kf = kfData?.keyframes.find((k) => k.percentage === pct);
|
const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2);
|
||||||
setKfContextMenu({
|
setKfContextMenu({
|
||||||
x: e.clientX,
|
x: e.clientX + 4,
|
||||||
y: e.clientY,
|
y: e.clientY + 2,
|
||||||
elementId: elId,
|
elementId: elId,
|
||||||
percentage: pct,
|
percentage: pct,
|
||||||
|
tweenPercentage: kf?.tweenPercentage,
|
||||||
currentEase: kf?.ease ?? kfData?.ease,
|
currentEase: kf?.ease ?? kfData?.ease,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -123,7 +123,8 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|||||||
const kfKey = `${elementId}:${kf.percentage}`;
|
const kfKey = `${elementId}:${kf.percentage}`;
|
||||||
const isKfSelected = selectedKeyframes.has(kfKey);
|
const isKfSelected = selectedKeyframes.has(kfKey);
|
||||||
const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5;
|
const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5;
|
||||||
const color = isKfSelected || atPlayhead ? accentColor : "#a3a3a3";
|
const isHighlighted = isKfSelected || atPlayhead;
|
||||||
|
const color = isHighlighted ? accentColor : "#a3a3a3";
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={`${i}-${kf.percentage}`}
|
key={`${i}-${kf.percentage}`}
|
||||||
@@ -135,6 +136,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|||||||
transform: "translateY(-50%)",
|
transform: "translateY(-50%)",
|
||||||
width: diamondSize,
|
width: diamondSize,
|
||||||
height: diamondSize,
|
height: diamondSize,
|
||||||
|
zIndex: isHighlighted ? 2 : 1,
|
||||||
pointerEvents: "auto",
|
pointerEvents: "auto",
|
||||||
background: "none",
|
background: "none",
|
||||||
border: "none",
|
border: "none",
|
||||||
|
|||||||
@@ -28,10 +28,26 @@ function buildMockIframe(overrides: Record<string, unknown> = {}) {
|
|||||||
...overrides,
|
...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 = {
|
const contentDocument = {
|
||||||
querySelectorAll: (sel: string) => (sel === "script:not([src])" ? [scriptEl] : []),
|
querySelectorAll: (sel: string) => (sel === "script:not([src])" ? [scriptEl] : []),
|
||||||
createElement: (tag: string) => document.createElement(tag),
|
createElement: (tag: string) => document.createElement(tag),
|
||||||
body: container,
|
body: container,
|
||||||
|
head: document.createElement("div"),
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ type IframeWindow = Window & {
|
|||||||
gsap?: {
|
gsap?: {
|
||||||
timeline?: (...args: unknown[]) => unknown;
|
timeline?: (...args: unknown[]) => unknown;
|
||||||
registerPlugin?: (...plugins: unknown[]) => unknown;
|
registerPlugin?: (...plugins: unknown[]) => unknown;
|
||||||
|
set?: (targets: Element | Element[], vars: Record<string, unknown>) => void;
|
||||||
|
globalTimeline?: { getChildren?: (deep: boolean) => Array<{ kill?: () => void }> };
|
||||||
};
|
};
|
||||||
MotionPathPlugin?: unknown;
|
MotionPathPlugin?: unknown;
|
||||||
};
|
};
|
||||||
@@ -29,6 +31,14 @@ function findGsapScriptElements(doc: Document): HTMLScriptElement[] {
|
|||||||
return results;
|
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
|
* Replace the GSAP script in the live iframe without reloading. This preserves
|
||||||
* the WebGL context and shader transition cache.
|
* 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;
|
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 doReload = () => {
|
||||||
const timelines = win.__timelines;
|
const timelines = win.__timelines;
|
||||||
|
const allTargets: Element[] = [];
|
||||||
|
|
||||||
if (timelines) {
|
if (timelines) {
|
||||||
for (const key of Object.keys(timelines)) {
|
for (const key of Object.keys(timelines)) {
|
||||||
|
if (key === "__proxied") continue;
|
||||||
try {
|
try {
|
||||||
const tl = timelines[key] as {
|
const tl = timelines[key] as {
|
||||||
kill?: () => void;
|
kill?: () => void;
|
||||||
getChildren?: (deep: boolean) => Array<{ targets?: () => Element[] }>;
|
getChildren?: (deep: boolean) => Array<{ targets?: () => Element[] }>;
|
||||||
};
|
};
|
||||||
const allTargets: Element[] = [];
|
|
||||||
if (tl?.getChildren) {
|
if (tl?.getChildren) {
|
||||||
try {
|
try {
|
||||||
for (const child of tl.getChildren(true)) {
|
for (const child of tl.getChildren(true)) {
|
||||||
if (typeof child.targets === "function") {
|
if (typeof child.targets === "function") {
|
||||||
for (const t of child.targets()) {
|
for (const t of child.targets()) allTargets.push(t);
|
||||||
allTargets.push(t);
|
|
||||||
delete (t as unknown as Record<string, unknown>)._gsap;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} 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();
|
oldScriptEl.remove();
|
||||||
|
|
||||||
const executeScript = () => {
|
const executeScript = () => {
|
||||||
@@ -98,10 +131,9 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
|
|||||||
win.__hfStudioManualEditsApply?.();
|
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);
|
const needsMotionPath = /motionPath\s*[:{]/.test(scriptText);
|
||||||
if (needsMotionPath && !win.MotionPathPlugin && win.gsap) {
|
if (needsMotionPath && !win.MotionPathPlugin && win.gsap) {
|
||||||
|
deferredToAsync = true;
|
||||||
const pluginScript = doc.createElement("script");
|
const pluginScript = doc.createElement("script");
|
||||||
pluginScript.src = "https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/MotionPathPlugin.min.js";
|
pluginScript.src = "https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/MotionPathPlugin.min.js";
|
||||||
pluginScript.onload = () => executeScript();
|
pluginScript.onload = () => executeScript();
|
||||||
@@ -119,7 +151,10 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
|
|||||||
} else {
|
} else {
|
||||||
doReload();
|
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 {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user