/** * SDK document model — adaptation layer on top of @hyperframes/core. * * F6 decision: SDK builds ON core, no parser duplication. * - ensureHfIds (from core) is the parse entry point: all construction starts here. * - DOMParser is NOT used (browser-only). linkedom is the node-safe primitive. * - ParsedHtml (core) is the Studio timeline view (timed elements only). * HyperFramesElement is the editing view (ALL editable elements, with raw attrs). */ import { parseHTML } from "linkedom"; import { ensureHfIds } from "@hyperframes/parsers/hf-ids"; import { parseGsapScriptAcornForWrite } from "@hyperframes/core/gsap-parser-acorn"; import { findRoot, getElementStyles, getOwnText, isNewHostBoundary } from "./engine/model.js"; import type { HyperFramesElement, SdkDocument } from "./types.js"; // Tags that carry no editable content and must not enter the element tree. const EXCLUDED_TAGS = new Set([ "script", "style", "template", "meta", "link", "noscript", "base", "head", ]); // Snapshot text is TRIMMED for display (markup indentation produces noisy // whitespace text nodes). The raw text target is shared with setText so shadow // value checks and dispatch serialization use the same DOM target. function snapshotText(el: Element): string | null { const trimmed = getOwnText(el).trim(); return trimmed.length > 0 ? trimmed : null; } // Parsing the GSAP script (acorn AST walk) is the expensive part and depends // only on the script text, so memoize the {tween id, selector} pairs by script. // Selector→hf-id resolution still runs each call — it depends on the live DOM, // which changes on dispatch. Single-entry cache covers the hot path (same comp, // repeated getElements() rebuilds) and stays bounded. let gsapLocatedCacheKey: string | null = null; let gsapLocatedCacheVal: Array<{ id: string; selector: string }> = []; function parseLocatedCached(script: string): Array<{ id: string; selector: string }> { if (gsapLocatedCacheKey === script) return gsapLocatedCacheVal; const parsed = parseGsapScriptAcornForWrite(script); gsapLocatedCacheVal = parsed ? parsed.located.map(({ id, animation }) => ({ id, selector: animation.targetSelector })) : []; gsapLocatedCacheKey = script; return gsapLocatedCacheVal; } /** * Map each element's data-hf-id → the GSAP tween ids targeting it. Tween ids * come from the acorn parser's stable `targetSelector-method-position` scheme — * the SAME id-space the studio-api read path and the SDK GSAP ops use, so these * ids are dispatchable as-is via setGsapTween/removeGsapTween. Best-effort: a * malformed selector or unparseable script yields no entries (animationIds: []). */ function buildAnimationIdMap(document: Document): Map { const map = new Map(); const script = extractGsapScript(document); if (!script) return map; for (const { id, selector } of parseLocatedCached(script)) { if (!selector) continue; let matches: Element[] = []; try { matches = Array.from(document.querySelectorAll(selector)); } catch { continue; // selector not valid for querySelectorAll — skip } for (const el of matches) { const hfId = el.getAttribute("data-hf-id"); if (!hfId) continue; const list = map.get(hfId); if (list) list.push(id); else map.set(hfId, [id]); } } return map; } // fallow-ignore-next-line complexity function buildElement( el: Element, scopePrefix: string, animationIdsByHfId: Map, ): HyperFramesElement | null { const tag = el.tagName.toLowerCase(); if (EXCLUDED_TAGS.has(tag)) return null; const id = el.getAttribute("data-hf-id") ?? ""; if (!id) return null; // should never happen after ensureHfIds, but guard defensively // scopedId: if we're inside a sub-comp scope, prefix with "scopePrefix/". // The host element itself is in the PARENT scope (no prefix change for its own id). const scopedId = scopePrefix ? `${scopePrefix}/${id}` : id; // Children inherit the scope prefix from their parent. // If this element is a new host boundary (starts a new sub-comp scope), its // children use THIS element's scopedId as their prefix. // Otherwise, children inherit the same prefix that this element used. const childPrefix = isNewHostBoundary(el) ? scopedId : scopePrefix; const inlineStyles = getElementStyles(el); const classAttr = el.getAttribute("class") ?? ""; const classNames = classAttr .split(/\s+/) .map((c) => c.trim()) .filter(Boolean); const attributes: Record = {}; for (const attr of Array.from(el.attributes)) { if (attr.name === "style" || attr.name === "class" || attr.name.startsWith("data-hf-")) { continue; } attributes[attr.name] = attr.value; } const startAttr = el.getAttribute("data-start"); const endAttr = el.getAttribute("data-end"); const trackAttr = el.getAttribute("data-track-index"); const start = startAttr !== null ? parseFloat(startAttr) : null; const duration = start !== null && endAttr !== null ? Math.max(0, parseFloat(endAttr) - start) : null; const trackIndex = trackAttr !== null ? parseInt(trackAttr, 10) : null; const children: HyperFramesElement[] = []; for (const child of Array.from(el.children)) { const built = buildElement(child, childPrefix, animationIdsByHfId); if (built) children.push(built); } return { id, scopedId, tag, children, inlineStyles, classNames, attributes, text: snapshotText(el), start, duration, trackIndex, animationIds: animationIdsByHfId.get(id) ?? [], }; } // fallow-ignore-next-line complexity function extractGsapScript(doc: Document): string | null { // GSAP script is the first