mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
feat(sdk): session API, optional history + persist-queue, adapters — Phase 3a complete (#1325)
* feat(sdk): session API, optional history + persist-queue, adapters — Phase 3a complete * fix(sdk): address review — live-DOM query cache, single parse, style parse dedup - getElements/getElement/find now walk the live linkedom DOM via buildRoots with a lazily-built cache invalidated on dispatch/applyPatches — no serialize→ensureHfIds→parseHTML round trip per query - openComposition parses once (parseMutable); dropped discarded _doc constructor param and the redundant buildDocument call - document.ts buildElement reuses model.ts getElementStyles — removes duplicated parseInlineStyles (also fixes custom-prop camelCase mangling) - JSDoc note: empty batch() still fires change handlers Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): restore full public exports now session/document modules exist index.ts re-exports document/session/history/persist-queue (trimmed in the engine-layer PR to keep it self-contained); drops the temporary fallow suppressions whose consumers now exist. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): coalesce history by patch paths; replay override-set on open Adversarial-review findings F1 + F2: - history: coalescing now requires identical patch paths in addition to op types + origin + window. Previously two rapid setStyle calls on DIFFERENT elements merged into one entry carrying the second forward + first inverse — undo then reverted the wrong element and stranded the latest edit. Slider drags on one property still coalesce. - T3 init: openComposition({ overrides }) now replays the stored override-set onto the freshly-parsed base before exposing the session (new keyToPath inverse mapping + applyOverrideSet). Previously the overrides were copied into the map but never applied — reopening an embedded composition showed and serialized the base template. - examples: GSAP calls now feature-detect with can() (Phase 3b ops throw UnsupportedOpError as of the engine-layer fix); UnsupportedOpError re-exported from the package entry. - 8 new session tests: coalesce same-path / cross-element / cross-prop, override round-trip (style/text/attr/timing/removal/restore-base). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): transactional batch rollback, sorted coalesce key, root-priority unify Round-2 review (Rames/Miguel) on the session layer: - batch() is now transactional: on throw, accumulated inverse patches are replayed in reverse and the override-set snapshot restored — the model is exactly as it was at batch entry. Previously a throwing batch left the DOM partially mutated with no patch trail, no history entry, no recovery path. 2 new tests (model unchanged + undo is no-op after throwing batch). - history coalesce key sorts opTypes — same op-type set coalesces regardless of dispatch order within a batch. - applyPatches comment documents that emitted PatchEvents carry an empty inversePatches array (hosts keep their own inverse log). - document.ts extractDimensions/extractDuration now use the engine's findRoot — dimension extraction and mutations agree on the root element ([data-hf-root] > #stage > first child). Dimensions prefer the runtime's data-width/data-height forced-override attrs, falling back to inline style. - ownText documented: snapshot .text is trimmed display text; setText writes verbatim. Deferred to follow-up (acknowledged, not ship-blocking): persist-queue flush error surfacing, debounce window, path default, history ring-buffer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
22bb6737c5
commit
7010edac85
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* 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/core/hf-ids";
|
||||
import { findRoot, getElementStyles } 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). setText writes verbatim — engine getOwnText/setOwnText
|
||||
// operate on raw text. el.text is a display value, not a round-trip identity.
|
||||
function ownText(el: Element): string | null {
|
||||
let text = "";
|
||||
el.childNodes.forEach((n) => {
|
||||
if (n.nodeType === 3) text += (n as Text).nodeValue ?? "";
|
||||
});
|
||||
const trimmed = text.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function buildElement(el: Element): 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
|
||||
|
||||
const inlineStyles = getElementStyles(el);
|
||||
|
||||
const classAttr = el.getAttribute("class") ?? "";
|
||||
const classNames = classAttr
|
||||
.split(/\s+/)
|
||||
.map((c) => c.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const attributes: Record<string, string> = {};
|
||||
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);
|
||||
if (built) children.push(built);
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
tag,
|
||||
children,
|
||||
inlineStyles,
|
||||
classNames,
|
||||
attributes,
|
||||
text: ownText(el),
|
||||
start,
|
||||
duration,
|
||||
trackIndex,
|
||||
animationIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function extractGsapScript(doc: Document): string | null {
|
||||
// GSAP script is the first <script> tag whose text references gsap
|
||||
for (const script of Array.from(doc.querySelectorAll("script"))) {
|
||||
const text = script.textContent ?? "";
|
||||
if (text.includes("gsap") || text.includes("ScrollTrigger")) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractStyles(doc: Document): string | null {
|
||||
const styleEl = doc.querySelector("style");
|
||||
return styleEl ? styleEl.textContent : null;
|
||||
}
|
||||
|
||||
// Root resolution delegates to the engine's findRoot so dimension extraction
|
||||
// and mutations agree on which element is the composition root.
|
||||
// fallow-ignore-next-line complexity
|
||||
function extractDimensions(doc: Document): { width: number | null; height: number | null } {
|
||||
const stage = findRoot(doc);
|
||||
if (!stage) return { width: null, height: null };
|
||||
// data-width/data-height are the runtime's forced override — prefer them.
|
||||
const wAttr = stage.getAttribute("data-width");
|
||||
const hAttr = stage.getAttribute("data-height");
|
||||
const style = (stage as HTMLElement).getAttribute?.("style") ?? "";
|
||||
const wm = /width:\s*(\d+)px/.exec(style);
|
||||
const hm = /height:\s*(\d+)px/.exec(style);
|
||||
return {
|
||||
width: wAttr !== null ? parseInt(wAttr, 10) : wm ? parseInt(wm[1] ?? "", 10) : null,
|
||||
height: hAttr !== null ? parseInt(hAttr, 10) : hm ? parseInt(hm[1] ?? "", 10) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function extractDuration(doc: Document): number | null {
|
||||
const root = findRoot(doc) ?? doc.body;
|
||||
const dur = root?.getAttribute("data-duration");
|
||||
return dur ? parseFloat(dur) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the element tree from an already-parsed (hf-id-stamped) linkedom Document.
|
||||
* Walks the live DOM directly — no serialize/re-parse round trip. This is what
|
||||
* the session's query API uses against its mutable document.
|
||||
*/
|
||||
export function buildRoots(document: Document): HyperFramesElement[] {
|
||||
const body = document.body;
|
||||
const roots: HyperFramesElement[] = [];
|
||||
if (body) {
|
||||
for (const child of Array.from(body.children)) {
|
||||
const built = buildElement(child);
|
||||
if (built) roots.push(built);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an HTML string into the SDK document model.
|
||||
* Calls ensureHfIds first so every element has a stable data-hf-id.
|
||||
* Uses linkedom — node-safe (works in agents, CI, server-side).
|
||||
*/
|
||||
export function buildDocument(html: string): SdkDocument {
|
||||
const stamped = ensureHfIds(html);
|
||||
|
||||
const hasShell = /<!doctype|<html[\s>]/i.test(stamped);
|
||||
const wrapped = !hasShell;
|
||||
const { document } = wrapped
|
||||
? parseHTML(`<!DOCTYPE html><html><head></head><body>${stamped}</body></html>`)
|
||||
: parseHTML(stamped);
|
||||
|
||||
const dims = extractDimensions(document);
|
||||
|
||||
return {
|
||||
roots: buildRoots(document),
|
||||
gsapScript: extractGsapScript(document),
|
||||
styles: extractStyles(document),
|
||||
width: dims.width,
|
||||
height: dims.height,
|
||||
compositionDuration: extractDuration(document),
|
||||
html: stamped,
|
||||
};
|
||||
}
|
||||
|
||||
/** Flat walk of the element tree — returns every element in document order */
|
||||
export function flatElements(roots: readonly HyperFramesElement[]): HyperFramesElement[] {
|
||||
const result: HyperFramesElement[] = [];
|
||||
function walk(el: HyperFramesElement) {
|
||||
result.push(el);
|
||||
for (const child of el.children) walk(child);
|
||||
}
|
||||
for (const root of roots) walk(root);
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user