Files
hyperframes/packages/studio/src/hooks/gsapRuntimeKeyframes.ts
T
Miguel Ángel 5d59835446 fix(studio): static-position drag no longer freezes an element beside an animated rotation (#2016)
## What

Brief description of the change.

## Why

Why is this change needed?

## How

How was this implemented? Any notable design decisions?

## Test plan

How was this tested?

- [ ] Unit tests added/updated
- [ ] Manual testing performed
- [ ] Documentation updated (if applicable)
2026-07-07 03:45:17 -04:00

490 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Read GSAP keyframe data from the live runtime in the preview iframe.
* Used to discover dynamic keyframes that the AST parser can't resolve
* (data-driven loops, fetched values, computed selectors).
*
* Keyframe percentages returned here are TWEEN-RELATIVE (0100 within the
* tween), matching the static parser. Callers convert to clip-relative via
* `toAbsoluteTime` + the element's clip start/duration. `scanAllRuntimeKeyframes`
* does that conversion itself when given a `clipById` map.
*/
import { buildArcPath, type ArcPathConfig } from "@hyperframes/core/gsap-parser-acorn";
import { parsePercentageKeyframes, toAbsoluteTime } from "./gsapShared";
import { roundTo3 } from "../utils/rounding";
/**
* A GSAP tween's `vars` object — intentionally open: it mixes channel values
* (numbers), easing (strings), flags (booleans), nested keyframes (objects) and
* callbacks. Named so call sites read as "GSAP config", not an untyped escape hatch.
*/
export type GsapVars = Record<string, unknown>;
export interface RuntimeTween {
targets?: () => Element[];
vars?: GsapVars;
duration?: () => number;
startTime?: () => number;
invalidate?: () => RuntimeTween;
/** Remove this tween from its parent timeline (GSAP `kill()`). */
kill?: () => void;
/** The timeline this tween lives in — used to re-insert a rebuilt tween. */
parent?: RuntimeTimeline;
}
export interface RuntimeTimeline {
getChildren?: (deep: boolean) => RuntimeTween[];
duration?: () => number;
time?: () => number;
invalidate?: () => RuntimeTimeline;
/** Add a tween at an absolute position — used to rebuild a keyframe tween in place. */
to?: (targets: Element[], vars: GsapVars, position?: number) => RuntimeTween;
}
type Pct = { percentage: number; properties: Record<string, number | string> };
export type ReadTween = { keyframes: Pct[]; easeEach?: string; arcPath?: ArcPathConfig };
export interface RuntimeKeyframeEntry {
keyframes: Pct[];
easeEach?: string;
/** Present when the live tween uses motionPath — drives the Arc Motion panel. */
arcPath?: ArcPathConfig;
/** Absolute start time of the source tween (seconds). */
tweenStart: number;
/** Duration of the source tween (seconds). */
tweenDuration: number;
}
/** Clip start/duration per element id, to convert tween-relative % to clip-relative. */
export type ClipDims = Map<string, { start: number; duration: number }>;
const FLAT_SKIP_KEYS = new Set([
"ease",
"duration",
"delay",
"stagger",
"motionPath",
"overwrite",
"immediateRender",
"onComplete",
"onUpdate",
"onStart",
"keyframes",
]);
function timelinesOf(iframe: HTMLIFrameElement | null): Record<string, RuntimeTimeline> | null {
if (!iframe?.contentWindow) return null;
try {
return (
(iframe.contentWindow as unknown as { __timelines?: Record<string, RuntimeTimeline> })
.__timelines ?? null
);
} catch {
return null;
}
}
function isXY(p: unknown): p is { x: number; y: number } {
return !!p && typeof (p as any).x === "number" && typeof (p as any).y === "number";
}
/**
* A tween we must skip when reading keyframes: a zero-duration `set`/hold (incl.
* the studio pre-keyframe position hold, tagged `data: STUDIO_HOLD_MARKER`).
* These sit before the real keyframed tween and otherwise shadow it — `readTween`
* would fall back to a degenerate 2-point flat path from the set's values, hiding
* the actual multi-keyframe motion. `!(duration > 0)` also rejects NaN durations.
*/
function isZeroDurationSet(duration: number): boolean {
return !(duration > 0);
}
/** Coordinates + curviness from a live `vars.motionPath` value (object or array form), or null. */
function coordsFromMotionPath(mp: unknown): {
coords: Array<{ x: number; y: number }>;
curviness: number;
autoRotate: boolean | number;
isCubic: boolean;
} | null {
if (!mp || typeof mp !== "object") return null;
const obj = mp as Record<string, unknown>;
const pathVal = Array.isArray(mp) ? mp : obj.path;
if (!Array.isArray(pathVal)) return null;
const coords = pathVal.filter(isXY).map((p) => ({ x: p.x, y: p.y }));
if (coords.length < 2) return null;
const curviness = typeof obj.curviness === "number" ? obj.curviness : 1;
const autoRotate = typeof obj.autoRotate === "number" ? obj.autoRotate : obj.autoRotate === true;
return { coords, curviness, autoRotate, isCubic: obj.type === "cubic" };
}
/** Build an arcPath config from a live `vars.motionPath` value. */
export function arcPathFromMotionPathValue(mp: unknown): ArcPathConfig | undefined {
const parsed = coordsFromMotionPath(mp);
if (!parsed) return undefined;
return buildArcPath(parsed.coords, parsed.curviness, parsed.autoRotate, parsed.isCubic)?.arcPath;
}
function flatTweenKeyframes(vars: Record<string, unknown>): Pct[] | null {
const properties: Record<string, number | string> = {};
for (const [k, v] of Object.entries(vars)) {
if (FLAT_SKIP_KEYS.has(k)) continue;
if (typeof v === "number") properties[k] = roundTo3(v);
else if (typeof v === "string") properties[k] = v;
}
if (Object.keys(properties).length === 0) return null;
return [
{ percentage: 0, properties },
{ percentage: 100, properties },
];
}
/** Tween-relative keyframes + optional arcPath for one live tween, or null. */
function readTween(vars: Record<string, unknown>): ReadTween | null {
if (vars.keyframes && typeof vars.keyframes === "object") {
const parsed = parsePercentageKeyframes(vars.keyframes as Record<string, unknown>);
if (parsed) return parsed;
}
const mp = coordsFromMotionPath(vars.motionPath);
if (mp) {
const shape = buildArcPath(mp.coords, mp.curviness, mp.autoRotate, mp.isCubic);
if (shape) {
const n = shape.waypoints.length;
const keyframes = shape.waypoints.map((wp, i) => ({
percentage: n > 1 ? Math.round((i / (n - 1)) * 100) : 0,
properties: { x: wp.x, y: wp.y },
}));
return { keyframes, arcPath: shape.arcPath };
}
}
const flat = flatTweenKeyframes(vars);
return flat ? { keyframes: flat } : null;
}
function matchesElement(tween: RuntimeTween, el: Element): boolean {
if (!tween.targets) return false;
for (const t of tween.targets()) {
if (t === el || (el.id && (t as Element).id === el.id)) return true;
}
return false;
}
function tweenTiming(tween: RuntimeTween): { start: number; duration: number } {
const rawStart = typeof tween.startTime === "function" ? tween.startTime() : 0;
const rawDur = typeof tween.duration === "function" ? tween.duration() : 0;
return {
start: Number.isFinite(rawStart) ? rawStart : 0,
duration: Number.isFinite(rawDur) ? rawDur : 0,
};
}
export interface ResolvedRuntimeTween {
/** The live GSAP tween targeting the selector. */
tween: RuntimeTween;
/** The composition timeline that owns it. */
timeline: RuntimeTimeline;
}
/**
* Whether a tween's `vars` carry at least one of `channels` as an OWN property.
* Used to disambiguate co-located `set`s: an element can have separate
* `tl.set("#el",{x,y})` and `tl.set("#el",{rotation})` tweens, and a position
* patch must land on the {x,y} set — never the rotation-only one.
*/
function varsCarryChannel(vars: Record<string, unknown> | undefined, channels: string[]): boolean {
if (!vars) return false;
for (const ch of channels) {
if (Object.prototype.hasOwnProperty.call(vars, ch)) return true;
}
return false;
}
/**
* Like `varsCarryChannel` but for a keyframe tween: the channels live inside the
* keyframe steps (`vars.keyframes`), not as own props of `vars`. Handles the object
* form (`{ "0%": {...} }`) and the array form (`[{...}, ...]`).
*/
function keyframeVarsCarryChannel(
vars: Record<string, unknown> | undefined,
channels: string[],
): boolean {
const kf = vars?.keyframes;
if (!kf || typeof kf !== "object") return false;
const steps = Array.isArray(kf) ? kf : Object.values(kf);
return steps.some(
(step) =>
step != null &&
typeof step === "object" &&
channels.some((ch) => Object.prototype.hasOwnProperty.call(step, ch)),
);
}
/**
* Resolve the live tween targeting `selector` using the SAME all-timelines scan
* `readRuntimeKeyframes` uses, so read and write agree on "which tween". With
* `kind: "keyframe"` it skips zero-duration `set`s and prefers the tween whose
* range contains the playhead (matching the reader). With `kind: "set"` it picks
* the zero-duration `set`/hold instead. Returns null when none matches.
*
* `channels` disambiguates co-located `set`s (CHANNEL-BLIND otherwise): when
* provided with `kind: "set"`, a set carrying ONE of those channels wins, and a
* set carrying ONLY disjoint channels is skipped (so patching {x,y} never lands
* on a rotation-only set). With no channel-matching set, it falls back to the
* first matching set (back-compat). `channels` is ignored for `kind: "keyframe"`.
*/
// fallow-ignore-next-line complexity
export function resolveRuntimeTween(
iframe: HTMLIFrameElement | null,
selector: string,
kind: "keyframe" | "set",
compositionId?: string,
channels?: string[],
// fallow-ignore-next-line code-duplication
): ResolvedRuntimeTween | null {
const timelines = timelinesOf(iframe);
if (!timelines) return null;
let targetEl: Element | null = null;
try {
targetEl = iframe?.contentDocument?.querySelector(selector) ?? null;
} catch {
return null;
}
if (!targetEl) return null;
const tlIds = compositionId
? [compositionId]
: Object.keys(timelines).filter((k) => typeof timelines[k]?.getChildren === "function");
// Channels disambiguate co-located tweens for BOTH kinds: a `set` carries them as
// own vars props, a keyframe tween carries them inside its keyframe steps. An
// element can have a rotation keyframe tween AND a position keyframe tween; a
// rotation edit must land on the former. The reader passes no channels, so its
// playhead-containment path below is unchanged.
const wantChannels = channels && channels.length > 0 ? channels : null;
let first: ResolvedRuntimeTween | null = null;
// fallow-ignore-next-line code-duplication
let channelMatch: ResolvedRuntimeTween | null = null;
for (const tlId of tlIds) {
const timeline = timelines[tlId];
if (!timeline?.getChildren) continue;
const now = typeof timeline.time === "function" ? timeline.time() : null;
for (const tween of timeline.getChildren(true)) {
if (!tween.vars || !matchesElement(tween, targetEl)) continue;
const dur = typeof tween.duration === "function" ? tween.duration() : 0;
const isSet = !(dur > 0);
if (kind === "set" ? !isSet : isSet) continue;
if (wantChannels) {
const carries =
kind === "set"
? varsCarryChannel(tween.vars, wantChannels)
: keyframeVarsCarryChannel(tween.vars, wantChannels);
if (carries) {
if (channelMatch === null) channelMatch = { tween, timeline };
} else if (first === null) {
// A tween carrying only disjoint channels: remember as last-resort
// fallback, but never prefer it over a channel-matching one.
first = { tween, timeline };
}
continue;
}
if (first === null) first = { tween, timeline };
if (kind === "keyframe" && now != null) {
const start = typeof tween.startTime === "function" ? tween.startTime() : 0;
if (now >= start - 1e-3 && now <= start + dur + 1e-3) return { tween, timeline };
}
}
}
return channelMatch ?? first;
}
/** Whether a read carries at least one of `channels` as a keyframe property. */
function readCarriesChannel(read: ReadTween, channels: string[]): boolean {
return read.keyframes.some((kf) => channels.some((c) => kf.properties[c] != null));
}
/**
* Read keyframes (incl. motionPath arcs) for one selector from the live timeline.
* Returns tween-relative percentages; callers convert to clip-relative.
*
* `requireChannels` restricts the scan to tweens whose read carries one of those
* properties — e.g. the motion-path overlay passes `["x","y"]` so it never picks
* up a co-located size/scale tween (which has no x/y and would blank the path
* whenever the playhead sits in that tween's range but outside the position
* tween's). Omitted → any keyframed tween qualifies (back-compat).
*/
// fallow-ignore-next-line complexity
export function readRuntimeKeyframes(
iframe: HTMLIFrameElement | null,
selector: string,
compositionId?: string,
requireChannels?: string[],
// fallow-ignore-next-line code-duplication
): ReadTween | null {
const timelines = timelinesOf(iframe);
if (!timelines) return null;
let targetEl: Element | null = null;
try {
targetEl = iframe?.contentDocument?.querySelector(selector) ?? null;
} catch {
return null;
}
if (!targetEl) return null;
// Search the element's OWN composition timeline. With inlined subcompositions the
// preview has multiple timelines (one per composition), and the element belongs to
// exactly one — so we can't assume the first key (order isn't stable across soft
// reloads, which delete+re-add the rebuilt key). Scan every timeline for tweens
// targeting this element; only its composition's timeline matches. An explicit
// compositionId still pins the search. (`__proxied` and other non-timeline markers
// are skipped by the getChildren guard.)
const tlIds = compositionId
? [compositionId]
: Object.keys(timelines).filter((k) => typeof timelines[k]?.getChildren === "function");
if (tlIds.length === 0) return null;
// The element can have MORE THAN ONE keyframed tween at disjoint time ranges
// (e.g. two non-overlapping gesture recordings → two separate `to()`s). The
// overlay must draw the segment under the PLAYHEAD, not blindly the first one
// — otherwise recording a second gesture leaves the path stuck on the first.
// fallow-ignore-next-line code-duplication
let firstRead: ReadTween | null = null;
for (const tlId of tlIds) {
const timeline = timelines[tlId];
if (!timeline?.getChildren) continue;
const now = typeof timeline.time === "function" ? timeline.time() : null;
// fallow-ignore-next-line code-duplication
for (const tween of timeline.getChildren(true)) {
if (!tween.vars || !matchesElement(tween, targetEl)) continue;
const dur = typeof tween.duration === "function" ? tween.duration() : 0;
if (isZeroDurationSet(dur)) continue; // skip hold/set tweens (see isZeroDurationSet)
const read = readTween(tween.vars);
if (!read) continue;
if (requireChannels && !readCarriesChannel(read, requireChannels)) continue;
if (firstRead === null) firstRead = read;
// Prefer the tween whose [start, start+dur] contains the playhead.
if (now != null) {
const start = typeof tween.startTime === "function" ? tween.startTime() : 0;
if (now >= start - 1e-3 && now <= start + dur + 1e-3) return read;
}
}
}
// Playhead outside every tween's range (or timeline has no clock): the element
// still has motion, so fall back to the first keyframed tween.
return firstRead;
}
/**
* Whether the live timeline has at least one NON-HOLD tween (non-zero duration,
* not the studio position-hold `set`) targeting `selector`. Stricter than a
* truthy `readRuntimeKeyframes`: that returns a flat read for any property-bearing
* tween, so it can't distinguish a real animation from a leftover hold/marker.
* The drag's stale-parse guard needs this exact distinction — after a delete-all
* only a hold may remain, and resurrecting the deleted tween from the stale parse
* must be avoided.
* When `channels` is provided, only tweens carrying one of those keyframe
* properties count as non-hold motion (e.g. position channels), so a sibling
* rotation/scale tween doesn't make a static position hold enter the keyframe
* branch.
*/
// fallow-ignore-next-line complexity
export function hasNonHoldTweenForElement(
iframe: HTMLIFrameElement | null,
selector: string,
compositionId?: string,
channels?: string[],
): boolean {
const timelines = timelinesOf(iframe);
if (!timelines) return false;
const tlId =
compositionId ||
Object.keys(timelines).find((k) => typeof timelines[k]?.getChildren === "function");
if (!tlId) return false;
const timeline = timelines[tlId];
if (!timeline?.getChildren) return false;
let targetEl: Element | null = null;
try {
targetEl = iframe?.contentDocument?.querySelector(selector) ?? null;
} catch {
return false;
}
if (!targetEl) return false;
// fallow-ignore-next-line code-duplication
for (const tween of timeline.getChildren(true)) {
if (!tween.vars || !matchesElement(tween, targetEl)) continue;
const dur = typeof tween.duration === "function" ? tween.duration() : 0;
if (isZeroDurationSet(dur)) continue; // skip hold/set tweens (see isZeroDurationSet)
const read = readTween(tween.vars);
if (read && (!channels || readCarriesChannel(read, channels))) return true;
}
return false;
}
/** Convert tween-relative keyframes to clip-relative % using the element's clip dims. */
function toClipRelative(
keyframes: Pct[],
tweenStart: number,
tweenDuration: number,
clip: { start: number; duration: number } | undefined,
): Pct[] {
if (!clip || clip.duration <= 0) return keyframes;
return keyframes.map((kf) => {
const abs = toAbsoluteTime(tweenStart, tweenDuration, kf.percentage);
return { ...kf, percentage: Math.round(((abs - clip.start) / clip.duration) * 100000) / 1000 };
});
}
function buildEntry(
read: ReadTween,
start: number,
duration: number,
clip: { start: number; duration: number } | undefined,
): RuntimeKeyframeEntry {
return {
keyframes: toClipRelative(read.keyframes, start, duration, clip),
tweenStart: start,
tweenDuration: duration,
...(read.easeEach ? { easeEach: read.easeEach } : {}),
...(read.arcPath ? { arcPath: read.arcPath } : {}),
};
}
/** Record one tween's keyframes under each target id (first-tween-per-id wins). */
function addScanEntry(
result: Map<string, RuntimeKeyframeEntry>,
tween: RuntimeTween,
clipById?: ClipDims,
): void {
if (!tween.targets || !tween.vars) return;
const { start, duration } = tweenTiming(tween);
if (isZeroDurationSet(duration)) return; // skip hold/set tweens (see isZeroDurationSet)
const read = readTween(tween.vars);
if (!read) return;
for (const target of tween.targets()) {
const id = (target as HTMLElement).id;
if (id && !result.has(id)) result.set(id, buildEntry(read, start, duration, clipById?.get(id)));
}
}
/**
* Scan every live tween, grouping keyframes by element id. Percentages are
* tween-relative unless `clipById` is supplied, in which case each entry's
* keyframes are converted to clip-relative. First keyframe-bearing tween per
* element wins (the common single-primary-tween case).
*/
export function scanAllRuntimeKeyframes(
iframe: HTMLIFrameElement | null,
clipById?: ClipDims,
): Map<string, RuntimeKeyframeEntry> {
const result = new Map<string, RuntimeKeyframeEntry>();
const timelines = timelinesOf(iframe);
if (!timelines) return result;
for (const timeline of Object.values(timelines)) {
if (!timeline?.getChildren) continue;
for (const tween of timeline.getChildren(true)) addScanEntry(result, tween, clipById);
}
return result;
}