diff --git a/packages/parsers/src/hfIds.ts b/packages/parsers/src/hfIds.ts index f2020c61d..0e4329f3f 100644 --- a/packages/parsers/src/hfIds.ts +++ b/packages/parsers/src/hfIds.ts @@ -114,15 +114,49 @@ export function mintHfId(el: Element, assigned: Set): string { * inner id would be duplicated across every clone. Form B is distinguished from * a clone-source by the presence of a direct `[data-composition-id]` child. */ +function getChildElements(parent: Element): Element[] { + const directChildren = Array.from(parent.children); + if (directChildren.length || parent.tagName.toLowerCase() !== "template") return directChildren; + const content = (parent as HTMLTemplateElement).content; + if (content?.children.length) return Array.from(content.children); + return directChildren; +} + export function isCompositionTemplate(el: Element): boolean { if (el.tagName.toLowerCase() !== "template") return false; if (el.getAttribute("data-composition-id") !== null) return true; - for (const child of Array.from(el.children)) { + for (const child of getChildElements(el)) { if (child.getAttribute("data-composition-id") !== null) return true; } return false; } +/** + * Walk document-order descendants, descending through composition templates + * while keeping plain templates inert. linkedom's querySelectorAll does not + * expose template contents, so callers that model the served composition use + * this traversal instead. + */ +export function walkCompositionDescendants( + root: Document | Element, + visit: (el: Element) => void, +): void { + const rootElement: Element | null = + root.nodeType === 9 ? (root as Document).documentElement : (root as Element); + if (!rootElement) return; + + const walk = (parent: Element): void => { + for (const child of getChildElements(parent)) { + const isTemplate = child.tagName.toLowerCase() === "template"; + if (isTemplate && !isCompositionTemplate(child)) continue; + visit(child); + walk(child); + } + }; + + walk(rootElement); +} + /** * Document-order walk of every element under `root`, descending into * composition ` + `); + + const animationIds = comp.getElement("hf-line")?.animationIds ?? []; + expect(animationIds).toHaveLength(1); + expect(comp.getAllAnimationIds()).toEqual(new Set(animationIds)); + }); }); // The authored sub-comp form `hyperframes add` scaffolds: the composition id is diff --git a/packages/sdk/src/session.timings.test.ts b/packages/sdk/src/session.timings.test.ts index 75e67bcd3..39aa2574b 100644 --- a/packages/sdk/src/session.timings.test.ts +++ b/packages/sdk/src/session.timings.test.ts @@ -45,6 +45,19 @@ window.__timelines = { t: tl }; `.trim(); +const GSAP_TEMPLATE_LABEL_HTML = ` +
+ +
+`.trim(); + // ─── getElementTimings — duration-authored clips ────────────────────────────── describe("getElementTimings — duration-authored clips", () => { @@ -111,6 +124,13 @@ describe("getElementTimings — GSAP labels", () => { const after = comp.getElementTimings()["hf-box"]?.labels ?? []; expect(after).toContain("intro"); }); + + it("reads labels from GSAP scripts inside composition templates", async () => { + const comp = await openComposition(GSAP_TEMPLATE_LABEL_HTML); + const labels = comp.getElementTimings()["hf-box"]?.labels ?? []; + + expect(labels).toContain("template-label"); + }); }); // ─── getElementTimings — relative data-start references ────────────────────── diff --git a/packages/sdk/src/session.ts b/packages/sdk/src/session.ts index d84d9c07c..d8cdc1128 100644 --- a/packages/sdk/src/session.ts +++ b/packages/sdk/src/session.ts @@ -35,7 +35,7 @@ import type { PersistAdapter, PreviewAdapter } from "./adapters/types.js"; import { parseMutable } from "./engine/model.js"; import type { ParsedDocument } from "./engine/model.js"; import { applyOp, validateOp, type MutationResult } from "./engine/mutate.js"; -import { getGsapScript, resolveScoped, declarationElement } from "./engine/model.js"; +import { getGsapScripts, resolveScoped, declarationElement } from "./engine/model.js"; import { extractGsapLabels } from "@hyperframes/core/gsap-parser-acorn"; import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler/html-document"; import { parseStartExpression } from "@hyperframes/core/runtime/start-expression"; @@ -314,26 +314,30 @@ class CompositionImpl implements Composition { // ── WS-C: timing accessors + typed setHold ─────────────────────────────────── /** - * Cache of parsed GSAP labels keyed by EXACT script text. extractGsapLabels does - * a full acorn parse; caching avoids re-parsing on repeated getElementTimings reads - * when the script is unchanged. The content (not reference) key means any script - * edit changes the text and invalidates the cache, so renumbered tweens never yield - * stale label positions. + * Cache of parsed GSAP labels keyed by the ordered list of EXACT script texts. + * extractGsapLabels does a full acorn parse; caching avoids re-parsing on repeated + * getElementTimings reads when the scripts are unchanged. */ - private _gsapLabelCache: { script: string; labels: ReturnType } | null = - null; + private _gsapLabelCache: { + scripts: string[]; + labels: ReturnType; + } | null = null; // fallow-ignore-next-line complexity getElementTimings(): Record { - const script = getGsapScript(this.parsed.document); + const scripts = getGsapScripts(this.parsed.document); - // Extract all addLabel("name", position) calls from the GSAP script (see cache note above). + // Extract all addLabel("name", position) calls from every GSAP script. let allLabels: ReturnType; - if (script && this._gsapLabelCache?.script === script) { - allLabels = this._gsapLabelCache.labels; + const cachedScripts = this._gsapLabelCache?.scripts; + const cacheMatches = + cachedScripts?.length === scripts.length && + cachedScripts.every((script, index) => script === scripts[index]); + if (cacheMatches) { + allLabels = this._gsapLabelCache?.labels ?? []; } else { - allLabels = script ? extractGsapLabels(script) : []; - this._gsapLabelCache = script ? { script, labels: allLabels } : null; + allLabels = scripts.flatMap((script) => extractGsapLabels(script)); + this._gsapLabelCache = scripts.length ? { scripts: [...scripts], labels: allLabels } : null; } // Resolve a `data-start` that's a relative-timing REFERENCE ("intro", "intro + 2" — @@ -555,8 +559,11 @@ class CompositionImpl implements Composition { } getAllAnimationIds(): Set { - const script = getGsapScript(this.parsed.document); - return script ? parsedAnimationIds(script) : new Set(); + const ids = new Set(); + for (const script of getGsapScripts(this.parsed.document)) { + for (const id of parsedAnimationIds(script)) ids.add(id); + } + return ids; } // ── Selection API ────────────────────────────────────────────────────────────