mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
* fix(sdk): setStyle removes hyphenated properties (was kebab/camel key mismatch) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sdk): agree removeElement/getElement on duplicate bare ids A bare hf-id duplicated across a sub-composition element and a top-level element resolved to different instances: removeElement → resolveScoped → querySelector (document-order-first, the inner sub-comp dup) while getElement preferred the canonical match (scopedId === id, the top-level dup). So removeElement(bareId) removed the inner instance and getElement(bareId) still found the surviving top-level one — they disagreed. resolveScoped now resolves an ambiguous BARE id to the canonical (top-level) instance via isCanonicalScope (walks ancestors for isNewHostBoundary), falling back to document order when no canonical match exists — matching getElement. Fully-scoped paths (hf-host/hf-dup) and non-duplicated bare ids are unchanged. Surfaced by SDK shadow parity (op:delete expected removed, actual present). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
281 lines
10 KiB
TypeScript
281 lines
10 KiB
TypeScript
/**
|
|
* Mutable document — linkedom Document wrapper for Phase 3 editing.
|
|
*
|
|
* The linkedom Document IS the mutable backing store. All dispatch mutations
|
|
* go here. serialize() walks the live DOM; no separate mutable tree to sync.
|
|
*/
|
|
|
|
import { parseHTML } from "linkedom";
|
|
import { ensureHfIds } from "@hyperframes/core/hf-ids";
|
|
|
|
export interface ParsedDocument {
|
|
document: Document;
|
|
/** True when the input was a fragment (no <html> shell) and was wrapped. */
|
|
wrapped: boolean;
|
|
/** ensureHfIds-stamped original HTML — used as fallback / diff base. */
|
|
stamped: string;
|
|
}
|
|
|
|
export function parseMutable(html: string): ParsedDocument {
|
|
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);
|
|
return { document: document as unknown as Document, wrapped, stamped };
|
|
}
|
|
|
|
// ─── Element lookup ───────────────────────────────────────────────────────────
|
|
|
|
export function findById(document: Document, id: string): Element | null {
|
|
// Delegate to resolveScoped so patch replay (undo/redo, override-set apply)
|
|
// resolves an id the SAME way forward dispatch does: canonical-first for an
|
|
// ambiguous bare id, and scoped-path ("hf-host/hf-leaf") aware. Otherwise the
|
|
// two paths disagree on which duplicate a bare id targets and undo reverts the
|
|
// wrong element. (function declaration is hoisted.)
|
|
return resolveScoped(document, id);
|
|
}
|
|
|
|
function escapeHfId(id: string): string {
|
|
return id.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
}
|
|
|
|
/**
|
|
* True when an element lives at the top-level (canonical) scope — i.e. its
|
|
* scopedId equals its bare id because no ancestor opens a sub-composition
|
|
* boundary. This mirrors document.ts's scopedId construction (childPrefix only
|
|
* changes at isNewHostBoundary elements) without rebuilding the snapshot tree.
|
|
*/
|
|
function isCanonicalScope(el: Element): boolean {
|
|
for (let cur = el.parentElement; cur; cur = cur.parentElement) {
|
|
if (isNewHostBoundary(cur)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Resolve a bare or scoped hf-id to its DOM element.
|
|
*
|
|
* Bare id ("hf-x"): top-level document search. When the bare id is ambiguous
|
|
* (duplicated across a sub-composition and the top level), prefer the canonical
|
|
* (top-level) instance — the one whose scopedId equals the bare id — falling
|
|
* back to document order when no canonical match exists. This matches
|
|
* getElement()'s resolution rule so removeElement / getElement agree on which
|
|
* instance an ambiguous bare id targets.
|
|
*
|
|
* Scoped id ("hf-HOST/hf-LEAF", any depth): each segment narrows the search
|
|
* into the subtree of the previous match. This unambiguously addresses an
|
|
* element inside a sub-composition even when bare ids collide.
|
|
*/
|
|
export function resolveScoped(document: Document, id: string): Element | null {
|
|
const parts = id.split("/");
|
|
|
|
// Bare id: prefer the canonical (top-level) match when one exists, so
|
|
// resolution agrees with getElement (scopedId === id wins over document order).
|
|
if (parts.length === 1) {
|
|
const escaped = escapeHfId(id);
|
|
const matches = Array.from(document.querySelectorAll(`[data-hf-id="${escaped}"]`));
|
|
if (matches.length === 0) return null;
|
|
return matches.find((el) => isCanonicalScope(el)) ?? matches[0] ?? null;
|
|
}
|
|
|
|
let context: Element | Document = document;
|
|
for (const part of parts) {
|
|
const escaped = escapeHfId(part);
|
|
const found: Element | null =
|
|
context === document
|
|
? (context as Document).querySelector(`[data-hf-id="${escaped}"]`)
|
|
: (context as Element).querySelector(`[data-hf-id="${escaped}"]`);
|
|
if (!found) return null;
|
|
context = found;
|
|
}
|
|
return context as Element;
|
|
}
|
|
|
|
/**
|
|
* Returns true when this element starts a new sub-composition scope — i.e. it
|
|
* is a host element (has data-composition-file) and is NOT the outerHTML
|
|
* innerRoot of the SAME sub-composition (same dcf value as parent).
|
|
*
|
|
* outerHTML case: both host and innerRoot carry data-composition-file="sub.html".
|
|
* The innerRoot has the SAME value as the host (its parent) → not a new boundary.
|
|
* A genuine nested host inside a sub-comp has a DIFFERENT dcf value.
|
|
*/
|
|
export function isNewHostBoundary(el: Element): boolean {
|
|
const dcf = el.getAttribute("data-composition-file");
|
|
if (!dcf) return false;
|
|
const parentDcf = el.parentElement?.getAttribute("data-composition-file") ?? null;
|
|
return dcf !== parentDcf;
|
|
}
|
|
|
|
export function findRoot(document: Document): Element | null {
|
|
return (
|
|
document.querySelector("[data-hf-root]") ??
|
|
document.getElementById("stage") ??
|
|
document.body?.firstElementChild ??
|
|
null
|
|
);
|
|
}
|
|
|
|
// ─── Inline style helpers ─────────────────────────────────────────────────────
|
|
|
|
export function toCamel(prop: string): string {
|
|
if (prop.startsWith("--")) return prop;
|
|
return prop.replace(/-([a-z])/g, (_, c: string) => (c as string).toUpperCase());
|
|
}
|
|
|
|
function toKebab(prop: string): string {
|
|
if (prop.startsWith("--")) return prop;
|
|
return prop.replace(/([A-Z])/g, (c) => `-${c.toLowerCase()}`);
|
|
}
|
|
|
|
/** Parse style attribute string → camelCase map (custom props kept as-is). */
|
|
function parseStyleAttr(styleAttr: string): Record<string, string> {
|
|
const result: Record<string, string> = {};
|
|
for (const decl of styleAttr.split(";")) {
|
|
const idx = decl.indexOf(":");
|
|
if (idx === -1) continue;
|
|
const rawProp = decl.slice(0, idx).trim();
|
|
const value = decl.slice(idx + 1).trim();
|
|
if (!rawProp || !value) continue;
|
|
result[toCamel(rawProp)] = value;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** Serialize camelCase style map → style attribute string. */
|
|
function serializeStyleAttr(styles: Record<string, string>): string {
|
|
return Object.entries(styles)
|
|
.map(([k, v]) => `${toKebab(k)}: ${v}`)
|
|
.join("; ");
|
|
}
|
|
|
|
export function getElementStyles(el: Element): Record<string, string> {
|
|
const attr = el.getAttribute("style") ?? "";
|
|
return parseStyleAttr(attr);
|
|
}
|
|
|
|
export function setElementStyles(el: Element, updates: Record<string, string | null>): void {
|
|
const current = getElementStyles(el);
|
|
for (const [prop, value] of Object.entries(updates)) {
|
|
// Stored map is keyed camelCase (parseStyleAttr); custom props (--foo) stay
|
|
// verbatim. Normalize the incoming key the same way for both set and delete.
|
|
const key = toCamel(prop);
|
|
if (value === null) {
|
|
delete current[key];
|
|
} else {
|
|
current[key] = value;
|
|
}
|
|
}
|
|
const serialized = serializeStyleAttr(current);
|
|
if (serialized) {
|
|
el.setAttribute("style", serialized);
|
|
} else {
|
|
el.removeAttribute("style");
|
|
}
|
|
}
|
|
|
|
// ─── Text helpers ─────────────────────────────────────────────────────────────
|
|
|
|
/** Read only direct (non-descendant) text node content. */
|
|
export function getOwnText(el: Element): string {
|
|
let text = "";
|
|
el.childNodes.forEach((n) => {
|
|
if (n.nodeType === 3) text += (n as Text).nodeValue ?? "";
|
|
});
|
|
return text;
|
|
}
|
|
|
|
/** Replace only direct text nodes — preserves child elements. */
|
|
export function setOwnText(el: Element, text: string): void {
|
|
const doc = el.ownerDocument;
|
|
const children = Array.from(el.childNodes);
|
|
// Track original position of the first text node so we restore there, not at firstChild.
|
|
let firstTextIdx = -1;
|
|
for (let i = 0; i < children.length; i++) {
|
|
if (children[i]?.nodeType === 3) {
|
|
firstTextIdx = i;
|
|
break;
|
|
}
|
|
}
|
|
for (const child of children) {
|
|
if (child.nodeType === 3) el.removeChild(child);
|
|
}
|
|
if (text) {
|
|
// No text nodes before firstTextIdx (it's the first one), so index is stable.
|
|
const current = Array.from(el.childNodes);
|
|
const ref = firstTextIdx >= 0 ? (current[firstTextIdx] ?? null) : null;
|
|
el.insertBefore(doc.createTextNode(text), ref);
|
|
}
|
|
}
|
|
|
|
// ─── CSS style helpers ────────────────────────────────────────────────────────
|
|
|
|
function findStyleElement(document: Document): Element | null {
|
|
return document.querySelector("style") as unknown as Element | null;
|
|
}
|
|
|
|
export function getStyleSheet(document: Document): string {
|
|
return findStyleElement(document)?.textContent ?? "";
|
|
}
|
|
|
|
export function setStyleSheet(document: Document, css: string): void {
|
|
const existing = findStyleElement(document);
|
|
if (!css) {
|
|
existing?.remove();
|
|
return;
|
|
}
|
|
let el = existing;
|
|
if (!el) {
|
|
el = document.createElement("style") as unknown as Element;
|
|
const head =
|
|
(document.querySelector("head") as unknown as Element | null) ??
|
|
(document.body as unknown as Element);
|
|
(head as any).appendChild(el);
|
|
}
|
|
el.textContent = css;
|
|
}
|
|
|
|
// ─── GSAP script helpers ──────────────────────────────────────────────────────
|
|
|
|
function findGsapScriptElement(document: Document): Element | null {
|
|
const scripts = document.querySelectorAll("script");
|
|
for (const script of Array.from(scripts)) {
|
|
const text = script.textContent ?? "";
|
|
if (text.includes("gsap") || text.includes("ScrollTrigger"))
|
|
return script as unknown as Element;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function getGsapScript(document: Document): string | null {
|
|
const el = findGsapScriptElement(document);
|
|
return el ? (el.textContent ?? "") : null;
|
|
}
|
|
|
|
export function setGsapScript(document: Document, newScript: string): void {
|
|
const existing = findGsapScriptElement(document);
|
|
if (!newScript) {
|
|
existing?.remove();
|
|
return;
|
|
}
|
|
let el = existing;
|
|
if (!el) {
|
|
el = document.createElement("script") as unknown as Element;
|
|
const head =
|
|
(document.querySelector("head") as unknown as Element | null) ??
|
|
(document.body as unknown as Element);
|
|
(head as any).appendChild(el);
|
|
}
|
|
el.textContent = newScript;
|
|
}
|
|
|
|
// ─── Sibling index ────────────────────────────────────────────────────────────
|
|
|
|
export function getSiblingIndex(el: Element): number {
|
|
const parent = el.parentElement;
|
|
if (!parent) return 0;
|
|
return Array.from(parent.children).indexOf(el);
|
|
}
|