From 6a729b7e035e79ef08d41ec55e5df5623c98cbfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 27 Jun 2026 11:28:06 -0400 Subject: [PATCH] feat(studio): expand sub-composition groups + children in the timeline (#1761) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(studio): element groups — source mutations Wrap/unwrap source mutations (group geometry, the wrap-elements / unwrap-elements routes) that the studio group feature is built on. Studio UI lands in the next PR. * fix(studio): hoverable group interior + non-sticky drill-in Two group selection bugs with animated members: 1) Empty space inside a group's overlay didn't hover/select the group. Members animated outside the wrapper's static box (110px box vs 340px member union), so elementsFromPoint hit only the full-bleed background there. Add a member-union hit-test fallback: a point inside a group's live member bounds resolves to that group (innermost wins). 2) After drilling into a group and selecting a child, nothing else was selectable — out-of-scope resolved to null. Make drill-in non-sticky: interacting outside the drilled group re-resolves normally and exits the drill-in, so a later click on the group selects it as a unit again. * feat(studio): enable animation editing for static inline timelines The unsupported-pattern banner now clears for static window.__timelines["id"] = gsap.timeline() (the parser reports it editable), and the banner copy is retargeted to the genuinely-unsupported case: computed/dynamic keys (window.__timelines[var]). * fix(studio): correct keyframes + expansion for sub-composition timeline clips Two gaps for elements inside a sub-composition: 1) Clip keyframes rendered off-clip. The keyframe cache computes clip-relative percentages from the element's start/duration, but sub-comp internals aren't in the timeline elements list, so duration defaulted to 1s and percentages blew past 100%. Resolve the timing basis from the sub-comp HOST's bounds (via domClipChildren, since the host's data-composition-src is stripped in the rendered DOM). Shared resolveClipTimingBasis used by both cache populators, which now re-run when the sub-comp children appear. 2) Only GROUPED sub-comp children expanded. Generalize the DOM-children collector to gather id'd children of the sub-comp inner-root (grouped OR ungrouped), descending through id-less structural wrappers; one level into groups for drill-in. Ungrouped pills now expand into timeline rows too. --- .../studio/src/hooks/useGsapTweenCache.ts | 68 +++++++++++++++---- .../hooks/useExpandedTimelineElements.test.ts | 43 ++++++++++++ .../hooks/useExpandedTimelineElements.ts | 51 ++++++++++++-- .../player/hooks/useTimelineSyncCallbacks.ts | 47 +++++++++++-- .../studio/src/player/store/playerStore.ts | 20 ++++++ 5 files changed, 207 insertions(+), 22 deletions(-) diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index 985511271..97053f637 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -176,6 +176,37 @@ export async function fetchParsedAnimations( } } +/** + * Clip-relative timing basis for an element. Sub-composition internals (e.g. pills + * inside a scene) aren't timeline clips themselves — they're derived at expand time + * — so they're absent from `elements`. Without a basis, elDuration defaulted to 1 + * and clip-relative keyframe percentages blew past 100% (rendering off the clip). + * Fall back to the sub-comp HOST's bounds, resolved via domClipChildren (the host's + * data-composition-src is stripped in the rendered DOM, so we can't query it). + */ +function resolveClipTimingBasis( + elementId: string, + sourceFile: string, + elements: ReadonlyArray<{ + domId?: string; + key?: string; + id: string; + start: number; + duration: number; + }>, + domClipChildren: ReadonlyArray<{ id: string; hostId: string }>, +): { elStart: number; elDuration: number } { + const direct = elements.find( + (el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`, + ); + if (direct) return { elStart: direct.start, elDuration: direct.duration }; + const hostId = domClipChildren.find((c) => c.id === elementId)?.hostId; + const host = hostId + ? elements.find((el) => el.domId === hostId || (el.key ?? el.id) === `index.html#${hostId}`) + : undefined; + return { elStart: host?.start ?? 0, elDuration: host?.duration ?? 1 }; +} + export function useGsapAnimationsForElement( projectId: string | null, sourceFile: string, @@ -192,6 +223,11 @@ export function useGsapAnimationsForElement( const [unsupportedTimelinePattern, setUnsupportedTimelinePattern] = useState(false); const lastFetchKeyRef = useRef(""); const retryTimerRef = useRef | null>(null); + // Re-run the per-element cache populate when sub-comp DOM children appear, so a + // sub-comp element gets its host-relative keyframe percentages (not elDuration=1). + const domClipChildrenKey = usePlayerStore((s) => + s.domClipChildren.map((c) => `${c.id}<${c.hostId}`).join("|"), + ); useEffect(() => { const targetKey = target?.id ?? target?.selector ?? ""; @@ -351,12 +387,13 @@ export function useGsapAnimationsForElement( // Resolve the element's time range from the player store so we can // convert tween-relative keyframe percentages to clip-relative ones. - const { elements } = usePlayerStore.getState(); - const timelineEl = elements.find( - (el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`, + const { elements, domClipChildren } = usePlayerStore.getState(); + const { elStart, elDuration } = resolveClipTimingBasis( + elementId, + sourceFile, + elements, + domClipChildren, ); - const elStart = timelineEl?.start ?? 0; - const elDuration = timelineEl?.duration ?? 1; const allKeyframes: Array< GsapKeyframesData["keyframes"][0] & { tweenPercentage?: number; propertyGroup?: string } @@ -419,7 +456,8 @@ export function useGsapAnimationsForElement( // PropertyPanel reads the cache by bare elementId (without sourceFile prefix), // so write a duplicate entry under the bare key for cross-component lookups. setKeyframeCache(elementId, merged); - }, [elementId, sourceFile, animations]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [elementId, sourceFile, animations, domClipChildrenKey]); return { animations, multipleTimelines, unsupportedTimelinePattern }; } @@ -442,13 +480,19 @@ export function usePopulateKeyframeCacheForFile( iframeRef?: React.RefObject, ): void { const elementCount = usePlayerStore((s) => s.elements.length); + // Re-run when sub-comp DOM children appear (they supply the host bounds the + // clip-relative keyframe percentages are computed against; without this the + // cache is computed once before they exist and the percentages stay wrong). + const domClipChildrenKey = usePlayerStore((s) => + s.domClipChildren.map((c) => `${c.id}<${c.hostId}`).join("|"), + ); const lastFetchKeyRef = useRef(""); const runtimeScanDoneRef = useRef(""); const astFetchDoneRef = useRef(""); useEffect(() => { - const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}:${elementCount}`; + const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}:${elementCount}:${domClipChildrenKey}`; if (fetchKey === lastFetchKeyRef.current) return; lastFetchKeyRef.current = fetchKey; runtimeScanDoneRef.current = ""; @@ -461,7 +505,7 @@ export function usePopulateKeyframeCacheForFile( if (!parsed) return; const { setKeyframeCache } = usePlayerStore.getState(); clearKeyframeCacheForFile(sf); - const { elements } = usePlayerStore.getState(); + const { elements, domClipChildren } = usePlayerStore.getState(); const doc = iframeRef?.current?.contentDocument; const mergedByElement = new Map(); for (const anim of parsed.animations) { @@ -482,11 +526,7 @@ export function usePopulateKeyframeCacheForFile( // Attribute the tween to every element it animates (handles class / // group / descendant selectors, not just `#id`). for (const id of resolveSelectorElementIds(anim.targetSelector, doc)) { - const timelineEl = elements.find( - (el) => el.domId === id || (el.key ?? el.id) === `${sf}#${id}`, - ); - const elStart = timelineEl?.start ?? 0; - const elDuration = timelineEl?.duration ?? 1; + const { elStart, elDuration } = resolveClipTimingBasis(id, sf, elements, domClipChildren); const clipKeyframes = kfData.keyframes.map((kf) => { const absTime = toAbsoluteTime(tweenPos, tweenDur, kf.percentage); // 0.001% precision (matching useGsapAnimationsForElement above) so a @@ -524,7 +564,7 @@ export function usePopulateKeyframeCacheForFile( // iframeRef is read for DOM selector resolution but intentionally not a dep // (it's a stable ref; the separate runtime-scan effect owns iframe timing). // eslint-disable-next-line react-hooks/exhaustive-deps - }, [projectId, sourceFile, version, elementCount]); + }, [projectId, sourceFile, version, elementCount, domClipChildrenKey]); // Separate effect for runtime keyframe discovery — polls until the iframe // has loaded GSAP timelines, independent of the AST fetch lifecycle. diff --git a/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts b/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts index a8399dee8..2df7fa9ae 100644 --- a/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts +++ b/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts @@ -122,4 +122,47 @@ describe("buildExpandedElements", () => { expect(child.key).toBe("index.html#eyebrow"); expect(child.key).toBe(expectedStoreKey); }); + + // Sub-comp internals (group + pills) have no data-start, so they're not in the + // manifest. They arrive as DOM children and must still expand under their host. + it("expands DOM-only sub-comp children (no manifest clip) under the host", () => { + const elements = [ + el({ id: "scene-host", start: 5, duration: 6, compositionSrc: "scene.html" }), + ]; + const manifest = [ + clip({ id: "scene-host", start: 5, duration: 6, compositionSrc: "scene.html" }), + ]; + // pill-3 selected → parent group-1 → host scene-host. None of group-1/pills + // are in the manifest; they're DOM children with parent links. + const parentMap = new Map([ + ["group-1", "scene-host"], + ["pill-1", "group-1"], + ["pill-2", "group-1"], + ["pill-3", "group-1"], + ]); + const domClipChildren = [ + { id: "group-1", parentId: "scene-host", hostId: "scene-host", label: "Group 1" }, + { id: "pill-1", parentId: "group-1", hostId: "scene-host", label: "pill-1" }, + { id: "pill-2", parentId: "group-1", hostId: "scene-host", label: "pill-2" }, + { id: "pill-3", parentId: "group-1", hostId: "scene-host", label: "pill-3" }, + ]; + + // Expanding pill-3's siblings: topLevel scene-host, immediate parent group-1. + const out = buildExpandedElements( + elements, + manifest, + parentMap, + "scene-host", + "group-1", + domClipChildren, + ); + const pills = out.filter((e) => e.domId?.startsWith("pill-")); + expect(pills).toHaveLength(3); + // Children span the host's bounds and rebase onto the host's file. + expect(pills[0]!.start).toBe(5); + expect(pills[0]!.duration).toBe(6); + expect(pills[0]!.sourceFile).toBe("scene.html"); + // The host row is replaced by its children. + expect(out.some((e) => e.domId === "scene-host")).toBe(false); + }); }); diff --git a/packages/studio/src/player/hooks/useExpandedTimelineElements.ts b/packages/studio/src/player/hooks/useExpandedTimelineElements.ts index 9649b2af7..e657c3b98 100644 --- a/packages/studio/src/player/hooks/useExpandedTimelineElements.ts +++ b/packages/studio/src/player/hooks/useExpandedTimelineElements.ts @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { usePlayerStore, type TimelineElement } from "../store/playerStore"; +import { usePlayerStore, type TimelineElement, type DomClipChild } from "../store/playerStore"; import type { ClipManifestClip } from "../lib/playbackTypes"; import { createTimelineElementFromManifestClip } from "../lib/timelineDOM"; import { buildTimelineElementKey } from "../lib/timelineElementHelpers"; @@ -111,6 +111,32 @@ function buildChildElements( return result; } +// Sub-comp DOM children (groups/pills) aren't manifest clips and have no timing +// of their own — they're "always on" within their sub-comp host, so synthesize +// clips spanning the host's full bounds. The host element supplies start/duration +// and the composition file edits write to. +function domSiblingClips( + domClipChildren: DomClipChild[], + siblingParentId: string, + host: TimelineElement, +): ClipManifestClip[] { + return domClipChildren + .filter((c) => c.parentId === siblingParentId) + .map((c) => ({ + id: c.id, + label: c.label, + start: host.start, + duration: host.duration, + track: host.track, + kind: "element" as const, + tagName: null, + compositionId: null, + parentCompositionId: host.id ?? null, + compositionSrc: host.compositionSrc ?? null, + assetUrl: null, + })); +} + // Exported for tests. export function buildExpandedElements( elements: TimelineElement[], @@ -118,11 +144,20 @@ export function buildExpandedElements( parentMap: Map, topLevelId: string, siblingParentId: string, + domClipChildren: DomClipChild[] = [], ): TimelineElement[] { const topLevelElement = elements.find((el) => el.id === topLevelId || el.domId === topLevelId); if (!topLevelElement) return filterToTopLevel(elements, parentMap); - const siblings = manifest.filter((c) => c.id != null && parentMap.get(c.id) === siblingParentId); + // Prefer real manifest children; fall back to DOM-only sub-comp children + // (groups/pills) that have no data-start and thus never enter the manifest. + const siblings = (() => { + const fromManifest = manifest.filter( + (c) => c.id != null && parentMap.get(c.id) === siblingParentId, + ); + if (fromManifest.length > 0) return fromManifest; + return domSiblingClips(domClipChildren, siblingParentId, topLevelElement); + })(); if (siblings.length === 0) return filterToTopLevel(elements, parentMap); // The sub-comp host the children actually live in: top-level host for 1-level @@ -154,6 +189,7 @@ export function useExpandedTimelineElements(): TimelineElement[] { const elements = usePlayerStore((s) => s.elements); const clipManifest = usePlayerStore((s) => s.clipManifest); const clipParentMap = usePlayerStore((s) => s.clipParentMap); + const domClipChildren = usePlayerStore((s) => s.domClipChildren); const selectedElementId = usePlayerStore((s) => s.selectedElementId); return useMemo(() => { @@ -166,6 +202,13 @@ export function useExpandedTimelineElements(): TimelineElement[] { const immediateParent = clipParentMap.get(rawId)!; const topLevel = findTopLevelAncestor(rawId, clipParentMap) ?? immediateParent; - return buildExpandedElements(elements, clipManifest, clipParentMap, topLevel, immediateParent); - }, [elements, clipManifest, clipParentMap, selectedElementId]); + return buildExpandedElements( + elements, + clipManifest, + clipParentMap, + topLevel, + immediateParent, + domClipChildren, + ); + }, [elements, clipManifest, clipParentMap, domClipChildren, selectedElementId]); } diff --git a/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts b/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts index 5abbc3244..c943680b0 100644 --- a/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts +++ b/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts @@ -10,7 +10,7 @@ import { useCallback } from "react"; import { liveTime, usePlayerStore } from "../store/playerStore"; -import type { TimelineElement } from "../store/playerStore"; +import type { TimelineElement, DomClipChild } from "../store/playerStore"; import type { PlaybackAdapter, ClipManifestClip, IframeWindow } from "../lib/playbackTypes"; import { parseTimelineFromDOM, @@ -85,8 +85,8 @@ export function useTimelineSyncCallbacks({ | (Window & { __clipTree?: import("@hyperframes/core/runtime/clipTree").ClipTree }) | null; const clipTree = iframeWin?.__clipTree; + const parentMap = new Map(); if (clipTree) { - const parentMap = new Map(); const walk = (nodes: typeof clipTree.roots) => { for (const node of nodes) { if (node.id && node.parentId) parentMap.set(node.id, node.parentId); @@ -94,11 +94,50 @@ export function useTimelineSyncCallbacks({ } }; walk(clipTree.roots); - usePlayerStore.getState().setClipParentMap(parentMap); } + + // Descend into each sub-composition host: its internal elements (group + // wrappers + their children) carry no `data-start`, so the clip + // tree/manifest never enumerate them. Surface them studio-side as DOM + // children + parent links so the timeline can expand a sub-comp/group + // row to show them. Manifest stays lean (timed clips only). + const domClipChildren: DomClipChild[] = []; + if (iframeDoc) { + for (const clip of data.clips) { + if (clip.kind !== "composition" || !clip.id) continue; + const hostEl = iframeDoc.getElementById(clip.id); + if (!hostEl) continue; + const hostId = clip.id; + const innerRoot = hostEl.querySelector("[data-hf-inner-root]") ?? hostEl; + // Collect the sub-comp's id'd descendants (grouped OR ungrouped) so they + // expand into timeline rows. Descends through id-less structural wrappers + // (the inlined sub-comp body), and one level into groups for drill-in. + const collect = (parentEl: Element, parentId: string) => { + for (const child of Array.from(parentEl.children)) { + if (!child.id) { + collect(child, parentId); // unwrap id-less structural containers + continue; + } + const isGroup = child.hasAttribute("data-hf-group"); + domClipChildren.push({ + id: child.id, + parentId, + hostId, + label: isGroup ? child.getAttribute("data-hf-group") || child.id : child.id, + }); + parentMap.set(child.id, parentId); + if (isGroup) collect(child, child.id); + } + }; + collect(innerRoot, hostId); + } + } + usePlayerStore.getState().setClipParentMap(parentMap); + usePlayerStore.getState().setDomClipChildren(domClipChildren); } catch { - // cross-origin or __clipTree not available — parentMap stays empty + // cross-origin or __clipTree not available — maps stay empty } + const usedHostEls = new Set(); const els: TimelineElement[] = filtered.map((clip, index) => { const hostEl = iframeDoc diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index 3003283d7..4f108b265 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -165,6 +165,23 @@ interface PlayerState { setClipManifest: (clips: ClipManifestClip[] | null) => void; clipParentMap: Map; setClipParentMap: (map: Map) => void; + /** + * Sub-composition DOM descendants (groups + their children) that have no + * `data-start`, so they're absent from the clip manifest/tree. Collected + * studio-side from the live preview so the timeline can expand a sub-comp row + * to show its DOM-only children. Keeps the manifest lean (timed clips only). + */ + domClipChildren: DomClipChild[]; + setDomClipChildren: (children: DomClipChild[]) => void; +} + +/** A sub-comp DOM-only timeline child (no data-start) and its nesting context. */ +export interface DomClipChild { + id: string; + parentId: string; + /** The manifest sub-comp host clip id this descendant ultimately lives under. */ + hostId: string; + label: string; } interface BeatHistoryEntry { @@ -296,6 +313,8 @@ export const usePlayerStore = create((set, get) => ({ setClipManifest: (clips) => set({ clipManifest: clips }), clipParentMap: new Map(), setClipParentMap: (map) => set({ clipParentMap: map }), + domClipChildren: [], + setDomClipChildren: (children) => set({ domClipChildren: children }), setIsPlaying: (playing) => { if (get().isPlaying === playing) return; @@ -380,6 +399,7 @@ export const usePlayerStore = create((set, get) => ({ beatPersist: null, clipManifest: null, clipParentMap: new Map(), + domClipChildren: [], }), }));