diff --git a/packages/studio/src/components/editor/domEditingLayers.test.ts b/packages/studio/src/components/editor/domEditingLayers.test.ts index 03b83743b8..04099ae919 100644 --- a/packages/studio/src/components/editor/domEditingLayers.test.ts +++ b/packages/studio/src/components/editor/domEditingLayers.test.ts @@ -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"); }); }); diff --git a/packages/studio/src/hooks/gsapTweenSynth.ts b/packages/studio/src/hooks/gsapTweenSynth.ts new file mode 100644 index 0000000000..3256286ac5 --- /dev/null +++ b/packages/studio/src/hooks/gsapTweenSynth.ts @@ -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(); + 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 = {}; + const endProps: Record = {}; + + 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 } : {}), + }; +} diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index 97053f6376..f49901a558 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -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(); - 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 = {}; - const endProps: Record = {}; - - 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-]+)/);