Files
hyperframes/packages/studio/src/hooks/gsapTweenSynth.ts
T
Miguel Angel Simon Sierra 3c7400af89 fix(studio): close the review findings in this PR instead of at the stack tip
The R1/R3 residuals on this PR were fixed at the top of the stack, so they
only cleared once every branch above landed. They belong here, next to the
code they correct:

- `idFromSelector` inverts `idSelector` for both regex readers, so the
  post-commit cache refresh stops skipping the CSS-unsafe ids `idSelector`
  exists to support.
- `deduplicateKeyframes` drops `ease` when it is ambiguous; the flag was the
  only honest answer and the last-writer-wins curve belonged to an arbitrary
  colliding tween.
- `isStaticPositionHold` is now the single owner of the hold skip. The
  `sourceAnimations` filter and the `allKeyframes` filter had diverged on
  whether `immediateRender` counts as a property.
- The keyframe-cache setters no-op when the write changes nothing, instead of
  handing every subscriber a fresh Map.
- `reset()` clears `focusedEaseSegment`.
- The test hook `delete`s its window key rather than setting it to undefined,
  so feature detection still works.
- The `toClipKeyframes` fixture uses `as unknown as T` with the justification
  CONTRIBUTING.md asks for.
2026-07-27 19:02:21 +02:00

118 lines
5.2 KiB
TypeScript

import type {
GsapAnimation,
GsapKeyframesData,
GsapPercentageKeyframe,
} from "@hyperframes/core/gsap-parser";
import { PROPERTY_DEFAULTS } from "./gsapShared";
/**
* A static position hold (only x/y, no real motion) is a `set`, not a keyframe —
* it must not synthesize a diamond. Covers both `tl.set(...)` and the
* `tl.to({ duration: 0, immediateRender: true })` hold that remove-all-keyframes
* collapses to (otherwise shown as a stray 0% keyframe).
*
* Single owner: the collapsed keyframe cache and the expanded property lanes'
* `gsapAnimations` map MUST agree on it, or a hold draws a phantom expanded lane
* with no matching collapsed diamond.
*/
export function isStaticPositionHold(anim: GsapAnimation): boolean {
if (anim.keyframes) return false;
if (anim.method !== "set" && (anim.duration ?? 0) !== 0) return false;
const propKeys = Object.keys(anim.properties).filter((k) => k !== "immediateRender");
return propKeys.length > 0 && propKeys.every((k) => k === "x" || k === "y");
}
export function deduplicateKeyframes<
T extends GsapPercentageKeyframe & { animationId?: string; easeAmbiguous?: boolean },
>(keyframes: T[]): T[] {
const byPct = new Map<number, T>();
for (const kf of keyframes) {
const existing = byPct.get(kf.percentage);
if (existing) {
existing.properties = { ...existing.properties, ...kf.properties };
// Two DIFFERENT source animations with a keyframe at the same clip %: a
// single inline ease button can only target one of them, and which one is
// arbitrary (each may also inherit a different easeEach/animation ease, so
// comparing raw keyframe eases isn't enough). Flag it so the collapsed row
// hides the button there and the user edits per-lane instead.
if (
existing.animationId !== undefined &&
kf.animationId !== undefined &&
existing.animationId !== kf.animationId
) {
existing.easeAmbiguous = true;
}
// Whichever tween iterated last used to win `ease`, so the merged
// keyframe carried an arbitrary one of the colliding curves. Readers that
// do not check easeAmbiguous (drag readouts, lane hints) then showed a
// curve belonging to a different animation than the one an edit targets.
// Drop it instead: ambiguous means "no single ease", and the flag is the
// only honest answer.
if (existing.easeAmbiguous) delete existing.ease;
else if (kf.ease) existing.ease = kf.ease;
} else {
byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } });
}
}
return Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage);
}
// fallow-ignore-next-line complexity
export function synthesizeFlatTweenKeyframes(anim: GsapAnimation): GsapKeyframesData | null {
// Both parsers store extras as raw source text (`__raw:${code}`) so
// non-editable config like `stagger: {...}` survives verbatim — a literal
// `immediateRender: true` prints as exactly this string, not a boolean.
const hasImmediateRenderHold = anim.extras?.immediateRender === "__raw:true";
if (anim.method === "set" || (anim.duration === 0 && hasImmediateRenderHold)) {
// A `set` — or a `to()`/`from()` collapsed to a zero-duration
// immediateRender hold (what removeAllKeyframesFromScript collapses a
// keyframed tween to) — is a STATIC HOLD: a value applied at one point,
// not an animated keyframe. It must NOT synthesize a keyframe, or the
// timeline + panel show a phantom diamond for a value that doesn't
// animate. This aligns the AST path with the runtime scan, which already
// skips every zero-duration set.
return null;
}
const toProps = anim.properties;
const fromProps = anim.fromProperties;
if (!toProps || Object.keys(toProps).length === 0) return null;
const rawStart: Record<string, number | string> = {};
const rawEnd: Record<string, number | string> = {};
if (anim.method === "from") {
for (const [k, v] of Object.entries(toProps)) {
rawStart[k] = v;
rawEnd[k] = PROPERTY_DEFAULTS[k] ?? 0;
}
} else if (anim.method === "fromTo" && fromProps) {
Object.assign(rawStart, fromProps);
Object.assign(rawEnd, toProps);
} else {
for (const [k, v] of Object.entries(toProps)) {
rawStart[k] = PROPERTY_DEFAULTS[k] ?? 0;
rawEnd[k] = v;
}
}
// Only numeric props are keyframe-interpolatable — a flat tween of a
// non-numeric prop (e.g. backgroundColor: "#fff") can't be a 2-keyframe lane.
const numericKeys = Object.keys(rawEnd).filter(
(k) => typeof rawStart[k] === "number" && typeof rawEnd[k] === "number",
);
if (numericKeys.length === 0) return null;
const startProps = Object.fromEntries(numericKeys.map((k) => [k, rawStart[k]]));
const endProps = Object.fromEntries(numericKeys.map((k) => [k, rawEnd[k]]));
return {
format: "percentage",
keyframes: [
{ percentage: 0, properties: startProps },
// Segment ease lives on the destination keyframe (Figma/AE model) so the
// lane + cache surface it; also kept data-level for useGsapTweenCache.
{ percentage: 100, properties: endProps, ...(anim.ease ? { ease: anim.ease } : {}) },
],
...(anim.ease ? { ease: anim.ease } : {}),
};
}