Files
hyperframes/packages/sdk/src/engine/model.ts
T
Vance IngallsandClaude Opus 4.8 cc055f318d fix(sdk): agree removeElement/getElement on duplicate bare ids (#1511)
* 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>
2026-06-16 12:30:54 -07:00

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);
}