fix(studio): keep useGsapTweenCache under the size cap + correct non-sticky drill test (#1770)

- extract deduplicateKeyframes + synthesizeFlatTweenKeyframes into gsapTweenSynth.ts
  so useGsapTweenCache.ts drops from 628 to 573 lines (600-line cap)
- the drilled-into-group selection test asserted the old sticky behavior; the code
  is intentionally non-sticky (clicking outside exits the group and resolves the
  clicked element), so the test now asserts that
This commit is contained in:
Miguel Ángel
2026-06-27 11:48:17 -04:00
committed by GitHub
parent de42af438a
commit a44ef47e1a
3 changed files with 70 additions and 60 deletions
@@ -154,7 +154,7 @@ describe("resolveDomEditSelection — data-hf-group capture", () => {
expect(scoped).not.toContain("outside");
});
it("returns null when clicking outside the group the user is drilled into", async () => {
it("exits the drilled-into group and selects the outside element (non-sticky drill)", async () => {
const { parent, inner } = buildNestedGroups();
const outside = document.createElement("div");
outside.id = "outside";
@@ -167,6 +167,8 @@ describe("resolveDomEditSelection — data-hf-group capture", () => {
document.body.removeChild(parent);
document.body.removeChild(outside);
expect(selection).toBeNull();
// Drill-in is non-sticky: clicking outside the active group exits it and
// resolves the clicked element normally (rather than selecting nothing).
expect(selection?.id).toBe("outside");
});
});
@@ -0,0 +1,64 @@
import type {
GsapAnimation,
GsapKeyframesData,
GsapPercentageKeyframe,
} from "@hyperframes/core/gsap-parser";
import { PROPERTY_DEFAULTS } from "./gsapShared";
export function deduplicateKeyframes(
keyframes: GsapPercentageKeyframe[],
): GsapPercentageKeyframe[] {
const byPct = new Map<number, GsapPercentageKeyframe>();
for (const kf of keyframes) {
const existing = byPct.get(kf.percentage);
if (existing) {
existing.properties = { ...existing.properties, ...kf.properties };
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 {
if (anim.method === "set") {
// A `set` 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 holds for a base
// `gsap.set` (off-timeline) AND an on-timeline `tl.set`, and 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 startProps: Record<string, number | string> = {};
const endProps: Record<string, number | string> = {};
if (anim.method === "from") {
for (const [k, v] of Object.entries(toProps)) {
startProps[k] = v;
endProps[k] = PROPERTY_DEFAULTS[k] ?? 0;
}
} else if (anim.method === "fromTo" && fromProps) {
Object.assign(startProps, fromProps);
Object.assign(endProps, toProps);
} else {
for (const [k, v] of Object.entries(toProps)) {
startProps[k] = PROPERTY_DEFAULTS[k] ?? 0;
endProps[k] = v;
}
}
return {
format: "percentage",
keyframes: [
{ percentage: 0, properties: startProps },
{ percentage: 100, properties: endProps },
],
...(anim.ease ? { ease: anim.ease } : {}),
};
}
+2 -58
View File
@@ -1,6 +1,5 @@
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/core/gsap-parser";
import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
import { isStudioHoldSet } from "@hyperframes/core/gsap-parser";
import { usePlayerStore } from "../player/store/playerStore";
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge";
@@ -8,63 +7,8 @@ import {
clearKeyframeCacheForElement,
clearKeyframeCacheForFile,
} from "./gsapKeyframeCacheHelpers";
import { PROPERTY_DEFAULTS, toAbsoluteTime } from "./gsapShared";
function deduplicateKeyframes(keyframes: GsapPercentageKeyframe[]): GsapPercentageKeyframe[] {
const byPct = new Map<number, GsapPercentageKeyframe>();
for (const kf of keyframes) {
const existing = byPct.get(kf.percentage);
if (existing) {
existing.properties = { ...existing.properties, ...kf.properties };
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
function synthesizeFlatTweenKeyframes(anim: GsapAnimation): GsapKeyframesData | null {
if (anim.method === "set") {
// A `set` 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 holds for a base
// `gsap.set` (off-timeline) AND an on-timeline `tl.set`, and 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 startProps: Record<string, number | string> = {};
const endProps: Record<string, number | string> = {};
if (anim.method === "from") {
for (const [k, v] of Object.entries(toProps)) {
startProps[k] = v;
endProps[k] = PROPERTY_DEFAULTS[k] ?? 0;
}
} else if (anim.method === "fromTo" && fromProps) {
Object.assign(startProps, fromProps);
Object.assign(endProps, toProps);
} else {
for (const [k, v] of Object.entries(toProps)) {
startProps[k] = PROPERTY_DEFAULTS[k] ?? 0;
endProps[k] = v;
}
}
return {
format: "percentage",
keyframes: [
{ percentage: 0, properties: startProps },
{ percentage: 100, properties: endProps },
],
...(anim.ease ? { ease: anim.ease } : {}),
};
}
import { toAbsoluteTime } from "./gsapShared";
import { deduplicateKeyframes, synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
function extractIdFromSelector(selector: string): string | null {
const match = selector.match(/^#([\w-]+)/);