Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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");
});
});
64 changes: 64 additions & 0 deletions packages/studio/src/hooks/gsapTweenSynth.ts
Original file line number Diff line number Diff line change
@@ -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 } : {}),
};
}
60 changes: 2 additions & 58 deletions packages/studio/src/hooks/useGsapTweenCache.ts
Original file line number Diff line number Diff line change
@@ -1,70 +1,14 @@
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";
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-]+)/);
Expand Down
Loading