/** * 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 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 = /]/i.test(stamped); const wrapped = !hasShell; const { document } = wrapped ? parseHTML(`
${stamped}`) : 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); } export 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 matches.find((el) => isCanonicalScope(el)) ?? matches[0] ?? null; } // Fall back to a sub-composition ROOT addressed by its composition id. A // host element carries data-hf-id (its own leaf id) AND data-composition-id // (the id studio passes when targeting the sub-comp root). data-hf-id takes // precedence above; only when no hf-id matches do we treat the bare id as a // composition id, making comp-ids first-class resolvable addresses. return document.querySelector(`[data-composition-id="${escaped}"]`); } 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). */ interface StyleDeclarationScan { depth: number; quote: "'" | '"' | null; skip: boolean; } function advanceStyleDeclarationScan(scan: StyleDeclarationScan, ch: string, next: string): void { if (scan.quote) { if (ch === "\\" && next) { scan.skip = true; return; } if (ch === scan.quote) scan.quote = null; return; } if (ch === "'" || ch === '"') { scan.quote = ch; return; } if (ch === "(") scan.depth++; else if (ch === ")") scan.depth = Math.max(0, scan.depth - 1); } function splitStyleDeclarations(style: string): string[] { const declarations: string[] = []; const scan: StyleDeclarationScan = { depth: 0, quote: null, skip: false }; let start = 0; for (let i = 0; i < style.length; i++) { if (scan.skip) { scan.skip = false; continue; } const ch = style[i] ?? ""; if (ch === ";" && scan.depth === 0 && scan.quote === null) { declarations.push(style.slice(start, i)); start = i + 1; } else { advanceStyleDeclarationScan(scan, ch, style[i + 1] ?? ""); } } declarations.push(style.slice(start)); return declarations; } function parseStyleAttr(styleAttr: string): Record