Files
hyperframes/packages/studio/src/hooks/useGsapTweenCache.ts
T
Miguel Angel Simon Sierra acf6766ed8 refactor(studio): one owner for clip-relative keyframe rows
Each keyframe-cache writer re-derived a clip-relative percentage inline, and the
post-commit writer rounded to 0.1% while the others used 0.001%. Selection keys
embed that number, so a commit-time rewrite could orphan a live key.
toClipPercentage owns the rounding, toClipKeyframes owns the whole row (percentage
plus the tween percentage and animation identity the lanes read), and the parsed
write reuses elementCacheKeys instead of open-coding the three key variants.
2026-07-26 00:51:19 +02:00

578 lines
23 KiB
TypeScript

import { useEffect, useMemo, useRef, useState, useCallback } from "react";
import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/core/gsap-parser";
import { isStudioHoldSet } from "@hyperframes/core/gsap-parser";
import { usePlayerStore } from "../player/store/playerStore";
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge";
import {
clearKeyframeCacheForElement,
clearKeyframeCacheForFile,
writeGsapAnimationsForElement,
} from "./gsapKeyframeCacheHelpers";
import { toAbsoluteTime, toClipPercentage, toClipKeyframes } from "./gsapShared";
import { deduplicateKeyframes, synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
function extractIdFromSelector(selector: string): string | null {
const match = selector.match(/^#([\w-]+)/);
return match ? match[1] : null;
}
/**
* Resolve a tween's target selector to the ids of the element(s) it animates.
* A bare `#id` resolves directly; anything else (a class like `.dot`, a group
* `.a, .b`, or a descendant selector) is matched against the live preview DOM so
* class/selector tweens (e.g. `gsap.from(".dot", {stagger})`) attribute to every
* element they animate — not just one parsed from the string. Falls back to a
* leading `#id` when there's no DOM (so the cache still populates pre-iframe).
*/
// fallow-ignore-next-line complexity
export function resolveSelectorElementIds(
selector: string,
doc: Document | null | undefined,
): string[] {
const bareId = selector.match(/^#([\w-]+)$/);
if (bareId) return [bareId[1]];
if (!doc) {
const lead = extractIdFromSelector(selector);
return lead ? [lead] : [];
}
const ids = new Set<string>();
for (const part of selector.split(",")) {
const sel = part.trim();
if (!sel) continue;
try {
for (const el of Array.from(doc.querySelectorAll(sel))) {
if (el.id) ids.add(el.id);
}
} catch {
const lead = extractIdFromSelector(sel);
if (lead) ids.add(lead);
}
}
return Array.from(ids);
}
/** The selected element's identity for matching tweens to it. */
export interface GsapElementTarget {
id?: string | null;
selector?: string | null;
}
/**
* A tween belongs to the selected element when its target selector addresses
* that element — by id (`#id`), by the exact CSS selector the element was
* selected through (`.kicker`), or as one member of a group selector
* (`.clock-face, .clock-hand`, emitted for array/`toArray` targets). Real
* compositions target tweens by class via `querySelector`, so id-only matching
* misses them.
*
* When the live DOM `element` is supplied, each comma-part of a tween's selector
* is also tested with `element.matches(part)` — true CSS semantics — so a
* class/descendant tween shared across elements (e.g. `gsap.from(".dot", {stagger})`)
* is attributed to *every* matching element, not just the one whose exact
* selector string happens to equal the tween's.
*/
export function getAnimationsForElement(
animations: GsapAnimation[],
target: GsapElementTarget,
element?: Element | null,
): GsapAnimation[] {
const matchers = new Set<string>();
if (target.id) matchers.add(`#${target.id}`);
if (target.selector) matchers.add(target.selector);
if (matchers.size === 0 && !element) return [];
return animations.filter((a) =>
a.targetSelector.split(",").some((part) => {
const trimmed = part.trim();
if (!trimmed) return false;
if (matchers.has(trimmed)) return true;
const lastSimple = trimmed.split(/\s+/).pop();
if (lastSimple && matchers.has(lastSimple)) return true;
if (element) {
try {
if (element.matches(trimmed)) return true;
} catch {
/* tween selector isn't a valid CSS selector for matches() — skip */
}
}
return false;
}),
);
}
export async function fetchParsedAnimations(
projectId: string,
sourceFile: string,
): Promise<ParsedGsap | null> {
try {
const res = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/gsap-animations/${encodeURIComponent(sourceFile)}`,
// Always re-read the freshly-parsed source; no per-call timestamp (which
// would defeat caching forever and is a deterministic-render no-no).
{ cache: "no-store" },
);
if (!res.ok) return null;
const parsed = (await res.json()) as ParsedGsap;
// Studio-emitted pre-keyframe hold `set`s are an internal runtime detail (they
// hold an element's first keyframe before its tween). They must not surface as
// user animations — otherwise they pollute the keyframe cache / timeline diamonds.
return { ...parsed, animations: parsed.animations.filter((a) => !isStudioHoldSet(a)) };
} catch {
return null;
}
}
/**
* Clip-relative timing basis for an element. Sub-composition internals (e.g. pills
* inside a scene) aren't timeline clips themselves — they're derived at expand time
* — so they're absent from `elements`. Without a basis, elDuration defaulted to 1
* and clip-relative keyframe percentages blew past 100% (rendering off the clip).
* Fall back to the sub-comp HOST's bounds, resolved via domClipChildren (the host's
* data-composition-src is stripped in the rendered DOM, so we can't query it).
*/
export function resolveClipTimingBasis(
elementId: string,
sourceFile: string,
elements: ReadonlyArray<{
domId?: string;
key?: string;
id: string;
start: number;
duration: number;
}>,
domClipChildren: ReadonlyArray<{ id: string; hostId: string }>,
): { elStart: number; elDuration: number } {
const direct = elements.find(
(el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`,
);
if (direct) return { elStart: direct.start, elDuration: direct.duration };
const hostId = domClipChildren.find((c) => c.id === elementId)?.hostId;
const host = hostId
? elements.find((el) => el.domId === hostId || (el.key ?? el.id) === `index.html#${hostId}`)
: undefined;
return { elStart: host?.start ?? 0, elDuration: host?.duration ?? 1 };
}
export function useGsapAnimationsForElement(
projectId: string | null,
sourceFile: string,
target: GsapElementTarget | null,
version: number,
iframeRef?: React.RefObject<HTMLIFrameElement | null>,
): {
animations: GsapAnimation[];
multipleTimelines: boolean;
unsupportedTimelinePattern: boolean;
} {
const [allAnimations, setAllAnimations] = useState<GsapAnimation[]>([]);
const [multipleTimelines, setMultipleTimelines] = useState(false);
const [unsupportedTimelinePattern, setUnsupportedTimelinePattern] = useState(false);
const lastFetchKeyRef = useRef("");
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Re-run the per-element cache populate when sub-comp DOM children appear, so a
// sub-comp element gets its host-relative keyframe percentages (not elDuration=1).
const domClipChildrenKey = usePlayerStore((s) =>
s.domClipChildren.map((c) => `${c.id}<${c.hostId}`).join("|"),
);
useEffect(() => {
const targetKey = target?.id ?? target?.selector ?? "";
const fetchKey = `${projectId}:${sourceFile}:${version}:${targetKey}`;
if (fetchKey === lastFetchKeyRef.current) return;
lastFetchKeyRef.current = fetchKey;
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
if (!projectId) {
setAllAnimations([]);
setMultipleTimelines(false);
setUnsupportedTimelinePattern(false);
return;
}
let cancelled = false;
fetchParsedAnimations(projectId, sourceFile).then((parsed) => {
if (cancelled) {
return;
}
if (!parsed) {
setAllAnimations([]);
setMultipleTimelines(false);
setUnsupportedTimelinePattern(false);
return;
}
setAllAnimations(parsed.animations);
setMultipleTimelines(parsed.multipleTimelines === true);
setUnsupportedTimelinePattern(parsed.unsupportedTimelinePattern === true);
// Retry once if initial fetch returned 0 animations — handles
// cold-load race where the sourceFile isn't resolved yet.
if (parsed.animations.length === 0 && targetKey) {
retryTimerRef.current = setTimeout(() => {
if (cancelled) return;
fetchParsedAnimations(projectId, sourceFile).then((retryParsed) => {
if (cancelled) return;
if (retryParsed && retryParsed.animations.length > 0) {
setAllAnimations(retryParsed.animations);
}
});
}, 800);
}
});
return () => {
cancelled = true;
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
};
}, [projectId, sourceFile, version, target?.id, target?.selector]);
const targetId = target?.id ?? null;
const targetSelector = target?.selector ?? null;
const rawAnimations = useMemo(() => {
if (!targetId && !targetSelector) return [];
// Resolve the live element so class / descendant tweens (e.g.
// gsap.from(".dot", {stagger})) attribute to every matching element, not
// just the one whose exact selector equals the tween's. `version` re-runs
// this after composition reloads.
let element: Element | null = null;
const doc = iframeRef?.current?.contentDocument;
if (doc) {
try {
element =
(targetId ? doc.getElementById(targetId) : null) ??
(targetSelector ? doc.querySelector(targetSelector) : null);
} catch {
element = null;
}
}
return getAnimationsForElement(
allAnimations,
{ id: targetId, selector: targetSelector },
element,
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allAnimations, targetId, targetSelector, version, iframeRef]);
// fallow-ignore-next-line complexity
const animations = useMemo(() => {
const iframe = iframeRef?.current;
let result = rawAnimations;
// Enrich animations with unresolved keyframes from runtime
if (iframe) {
result = result.map((anim) => {
if (!anim.hasUnresolvedKeyframes || anim.keyframes) return anim;
const runtime = readRuntimeKeyframes(iframe, anim.targetSelector);
if (!runtime) return anim;
return {
...anim,
keyframes: {
format: "percentage" as const,
keyframes: runtime.keyframes,
...(runtime.easeEach ? { easeEach: runtime.easeEach } : {}),
},
...(runtime.arcPath ? { arcPath: runtime.arcPath } : {}),
};
});
}
// Match unresolved-selector animations from the parser to runtime tweens
// targeting this element. This handles fully dynamic code (loop with variable selector).
if (iframe && targetId && result.length === 0) {
const unresolvedAnims = allAnimations.filter((a) => a.hasUnresolvedSelector);
if (unresolvedAnims.length > 0) {
const runtimeData = readRuntimeKeyframes(iframe, `#${targetId}`);
if (runtimeData) {
const scanned = scanAllRuntimeKeyframes(iframe);
const runtimeEntry = scanned.get(targetId);
if (runtimeEntry) {
// Find which unresolved animation index matches this element
// by correlating parser order with runtime tween order
const runtimeIds = Array.from(scanned.keys());
const runtimeIndex = runtimeIds.indexOf(targetId);
const matchedAnim =
runtimeIndex >= 0 && runtimeIndex < unresolvedAnims.length
? unresolvedAnims[runtimeIndex]
: unresolvedAnims[0];
if (matchedAnim) {
result = [
{
...matchedAnim,
targetSelector: `#${targetId}`,
keyframes: {
format: "percentage" as const,
keyframes: runtimeEntry.keyframes,
...(runtimeEntry.easeEach ? { easeEach: runtimeEntry.easeEach } : {}),
},
...(runtimeEntry.arcPath ? { arcPath: runtimeEntry.arcPath } : {}),
},
];
}
}
}
}
}
return result;
}, [rawAnimations, allAnimations, iframeRef, targetId]);
// Populate keyframe cache for the selected element.
// Key format must match timeline element keys: "sourceFile#domId".
// Merges keyframes from ALL animations targeting this element and synthesizes
// flat tweens so the cache is never downgraded vs the bulk populate.
const elementId = target?.id ?? null;
// fallow-ignore-next-line complexity
useEffect(() => {
if (!elementId) return;
// No property-group filter: ungrouped tweens are recorded here as well.
const sourceAnimations = animations.filter(
(animation) => animation.keyframes || synthesizeFlatTweenKeyframes(animation),
);
if (sourceAnimations.length > 0)
writeGsapAnimationsForElement(sourceFile, elementId, sourceAnimations);
// Resolve the element's time range from the player store so we can
// convert tween-relative keyframe percentages to clip-relative ones.
const { elements, domClipChildren } = usePlayerStore.getState();
const { elStart, elDuration } = resolveClipTimingBasis(
elementId,
sourceFile,
elements,
domClipChildren,
);
const allKeyframes: Array<
GsapKeyframesData["keyframes"][0] & {
tweenPercentage?: number;
propertyGroup?: string;
animationId?: string;
}
> = [];
let format: GsapKeyframesData["format"] = "percentage";
let ease: string | undefined;
let easeEach: string | undefined;
for (const anim of animations) {
// A static position hold (only x/y, no real motion) is a `set`, not a
// keyframe — don't synthesize a diamond for it. Covers both `tl.set(...)`
// and the `tl.to({ duration: 0, immediateRender: true })` hold that
// remove-all-keyframes collapses to (which is otherwise shown as a stray
// 0% keyframe).
if (
!anim.keyframes &&
Object.keys(anim.properties).length > 0 &&
Object.keys(anim.properties).every((k) => k === "x" || k === "y") &&
(anim.method === "set" || (anim.duration ?? 0) === 0)
)
continue;
const kf = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
if (!kf) continue;
// Convert tween-relative percentages to clip-relative so diamonds
// render at the correct position within the timeline clip.
const tweenPos =
anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
const tweenDur = anim.duration ?? elDuration;
for (const k of kf.keyframes) {
const absTime = toAbsoluteTime(tweenPos, tweenDur, k.percentage);
const clipPct = toClipPercentage(absTime, elStart, elDuration, k.percentage);
allKeyframes.push({
...k,
percentage: clipPct,
tweenPercentage: k.percentage,
propertyGroup: anim.propertyGroup,
animationId: anim.id,
});
}
format = kf.format;
if (kf.ease) ease = kf.ease;
if (kf.easeEach) easeEach = kf.easeEach;
}
if (allKeyframes.length === 0) {
// The per-element parsed-animation match can transiently miss class /
// selector tweens (e.g. `.dot`) that the file-wide populate or runtime
// scan already cached. Only clear when no source cached this element —
// otherwise selecting it would wipe its diamonds.
const { keyframeCache } = usePlayerStore.getState();
const hasCached =
keyframeCache.has(`${sourceFile}#${elementId}`) || keyframeCache.has(elementId);
if (!hasCached) clearKeyframeCacheForElement(sourceFile, elementId);
return;
}
const dedupedKeyframes = deduplicateKeyframes(allKeyframes);
const merged: GsapKeyframesData = {
format,
keyframes: dedupedKeyframes,
...(ease ? { ease } : {}),
...(easeEach ? { easeEach } : {}),
};
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);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [elementId, sourceFile, animations, domClipChildrenKey]);
return { animations, multipleTimelines, unsupportedTimelinePattern };
}
export function useGsapCacheVersion() {
const [version, setVersion] = useState(0);
const bump = useCallback(() => setVersion((v) => v + 1), []);
return { version, bump };
}
/**
* Fetch GSAP animations for a file and populate the keyframe cache for all
* elements. Called from the Timeline component so diamonds show without
* requiring a selection.
*/
export function usePopulateKeyframeCacheForFile(
projectId: string | null,
sourceFile: string,
version: number,
iframeRef?: React.RefObject<HTMLIFrameElement | null>,
): void {
const elementCount = usePlayerStore((s) => s.elements.length);
// Re-run when sub-comp DOM children appear (they supply the host bounds the
// clip-relative keyframe percentages are computed against; without this the
// cache is computed once before they exist and the percentages stay wrong).
const domClipChildrenKey = usePlayerStore((s) =>
s.domClipChildren.map((c) => `${c.id}<${c.hostId}`).join("|"),
);
const lastFetchKeyRef = useRef("");
const runtimeScanDoneRef = useRef("");
const astFetchDoneRef = useRef("");
useEffect(() => {
const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}:${elementCount}:${domClipChildrenKey}`;
if (fetchKey === lastFetchKeyRef.current) return;
lastFetchKeyRef.current = fetchKey;
runtimeScanDoneRef.current = "";
astFetchDoneRef.current = "";
if (!projectId) return;
const sf = sourceFile;
// fallow-ignore-next-line complexity
fetchParsedAnimations(projectId, sf).then((parsed) => {
if (!parsed) return;
const { setKeyframeCache } = usePlayerStore.getState();
clearKeyframeCacheForFile(sf);
const { elements, domClipChildren } = usePlayerStore.getState();
const doc = iframeRef?.current?.contentDocument;
const mergedByElement = new Map<string, GsapKeyframesData>();
const sourceByElement = new Map<string, GsapAnimation[]>();
for (const anim of parsed.animations) {
if (anim.hasUnresolvedKeyframes) continue;
// Position-only static holds are not keyframed animations — skip them so
// they don't draw a timeline diamond. Covers both a `tl.set(...)` and the
// `tl.to({ duration: 0, immediateRender: true })` that remove-all-keyframes
// collapses a keyframed tween to.
if (!anim.keyframes && (anim.method === "set" || (anim.duration ?? 0) === 0)) {
const propKeys = Object.keys(anim.properties).filter((k) => k !== "immediateRender");
if (propKeys.length > 0 && propKeys.every((k) => k === "x" || k === "y")) {
continue;
}
}
const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
if (!kfData) continue;
// Attribute the tween to every element it animates (handles class /
// group / descendant selectors, not just `#id`).
for (const id of resolveSelectorElementIds(anim.targetSelector, doc)) {
// kfData is already resolved (real keyframes OR a synthesized flat
// tween), so a flat tween joins the store like a keyframed one. No
// property-group filter: this map must cover every tween the cache
// below records, or expanded lanes have nothing to render.
sourceByElement.set(id, [...(sourceByElement.get(id) ?? []), anim]);
const { elStart, elDuration } = resolveClipTimingBasis(id, sf, elements, domClipChildren);
const clipKeyframes = toClipKeyframes(kfData.keyframes, anim, elStart, elDuration);
const existing = mergedByElement.get(id);
if (existing) {
existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]);
} else {
mergedByElement.set(id, { ...kfData, keyframes: clipKeyframes });
}
}
}
for (const [id, kfData] of mergedByElement) {
setKeyframeCache(`${sf}#${id}`, kfData);
setKeyframeCache(id, kfData);
if (sf !== "index.html") setKeyframeCache(`index.html#${id}`, kfData);
writeGsapAnimationsForElement(sf, id, sourceByElement.get(id));
}
astFetchDoneRef.current = fetchKey;
});
// 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.
// iframeRef is read for DOM selector resolution but intentionally not a dep
// (it's a stable ref; the separate runtime-scan effect owns iframe timing).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId, sourceFile, version, elementCount, domClipChildrenKey]);
// Separate effect for runtime keyframe discovery — polls until the iframe
// has loaded GSAP timelines, independent of the AST fetch lifecycle.
useEffect(() => {
if (!projectId) return;
const sf = sourceFile;
let attempts = 0;
const maxAttempts = 10;
// fallow-ignore-next-line complexity
const tryRuntimeScan = () => {
if (runtimeScanDoneRef.current === `kf-cache:${projectId}:${sf}:${version}`) return true;
const iframe =
iframeRef?.current ?? document.querySelector<HTMLIFrameElement>("iframe[src*='/preview/']");
if (!iframe) return false;
// Clip dims per element so the scan converts tween-relative keyframes to
// clip-relative (matching the static path) instead of timeline-relative.
const clipById = new Map<string, { start: number; duration: number }>();
for (const el of usePlayerStore.getState().elements) {
if (el.domId) clipById.set(el.domId, { start: el.start, duration: el.duration });
}
const scanned = scanAllRuntimeKeyframes(iframe, clipById);
if (scanned.size === 0) return false;
const { setKeyframeCache, keyframeCache } = usePlayerStore.getState();
for (const [id, data] of scanned) {
const cacheKey = `${sf}#${id}`;
const fallbackKey = `index.html#${id}`;
const alreadyCached =
keyframeCache.has(cacheKey) || keyframeCache.has(fallbackKey) || keyframeCache.has(id);
if (alreadyCached) continue;
// Skip position-only set tweens from runtime too — same filter as AST path
const isPosOnly =
data.keyframes.length === 1 &&
Object.keys(data.keyframes[0].properties).every((k) => k === "x" || k === "y");
if (isPosOnly) {
continue;
}
const entry = {
format: "percentage" as const,
keyframes: data.keyframes,
...(data.easeEach ? { easeEach: data.easeEach } : {}),
};
setKeyframeCache(cacheKey, entry);
if (sf !== "index.html") setKeyframeCache(fallbackKey, entry);
setKeyframeCache(id, entry);
}
runtimeScanDoneRef.current = `kf-cache:${projectId}:${sf}:${version}`;
return true;
};
if (tryRuntimeScan()) return;
const interval = setInterval(() => {
attempts++;
if (tryRuntimeScan() || attempts >= maxAttempts) clearInterval(interval);
}, 500);
return () => clearInterval(interval);
}, [projectId, sourceFile, version, iframeRef]);
}