/** * HTML Editor — Utility functions for parsing and manipulating HyperFrame HTML source. */ /** * Parse a CSS inline style string into a key-value map. * e.g. "opacity: 0.5; transform: matrix(1,0,0,1,0,0)" → * { opacity: "0.5", transform: "matrix(1,0,0,1,0,0)" } */ export function parseStyleString(style: string): Record { const result: Record = {}; for (const decl of style.split(";")) { const colonIdx = decl.indexOf(":"); if (colonIdx < 0) continue; const key = decl.slice(0, colonIdx).trim(); const value = decl.slice(colonIdx + 1).trim(); if (key && value) result[key] = value; } return result; } /** * Merge `newStyles` into an opening tag string's `style` attribute. * - New values win over existing ones. * - If no `style` attribute is present, one is added before the closing `>`. */ export function mergeStyleIntoTag(tag: string, newStyles: string): string { if (!newStyles.trim()) return tag; const incoming = parseStyleString(newStyles); // Match style="..." or style='...' — handle multi-line attrs via dotall-like trick const styleAttrRe = /style=(["'])([\s\S]*?)\1/; const match = tag.match(styleAttrRe); if (match) { const quote = match[1]; const existing = parseStyleString(match[2]); const merged = { ...existing, ...incoming }; const serialized = Object.entries(merged) .map(([k, v]) => `${k}: ${v}`) .join("; "); return tag.replace(styleAttrRe, `style=${quote}${serialized}${quote}`); } // No style attribute — insert one before the closing `>` const serialized = Object.entries(incoming) .map(([k, v]) => `${k}: ${v}`) .join("; "); // Handle self-closing tags (`/>`) and regular closing (`>`) return tag.replace(/(\/?>)$/, ` style="${serialized}"$1`); } /** * Find the full element block (opening tag through closing tag) in the source. * Uses quote-aware scanning to handle attributes containing >. * Uses depth counting to handle nested same-name tags. */ export function findElementBlock( html: string, elementId: string, ): { start: number; end: number; openTag: string; tagName: string; indent: string; innerContent: string; isSelfClosing: boolean; } | null { let idIdx = html.indexOf(`id="${elementId}"`); if (idIdx < 0) idIdx = html.indexOf(`id='${elementId}'`); if (idIdx < 0) return null; // Walk backward to find < and capture indent let tagStart = idIdx; while (tagStart > 0 && html[tagStart] !== "<") tagStart--; let indentStart = tagStart; while (indentStart > 0 && html[indentStart - 1] !== "\n") indentStart--; const indent = html.slice(indentStart, tagStart); // Walk forward from id to find the closing > of the opening tag let tagEnd = idIdx; let inQuote: string | null = null; while (tagEnd < html.length) { const ch = html[tagEnd]; if (inQuote) { if (ch === inQuote) inQuote = null; } else { if (ch === '"' || ch === "'") inQuote = ch; if (ch === ">") { tagEnd++; break; } } tagEnd++; } const openTag = html.slice(tagStart, tagEnd); const tagNameMatch = openTag.match(/^<([a-z][a-z0-9]*)/i); if (!tagNameMatch) return null; const tagName = tagNameMatch[1]; const isSelfClosing = openTag.trimEnd().endsWith("/>") || ["img", "br", "hr", "input", "meta", "link", "source"].includes(tagName.toLowerCase()); if (isSelfClosing) { return { start: tagStart, end: tagStart + openTag.length, openTag, tagName, indent: /^[\t ]*$/.test(indent) ? indent : "", innerContent: "", isSelfClosing: true, }; } // Find matching closing tag using depth counting const closeTag = ``; const openPattern = `<${tagName.toLowerCase()}`; let depth = 0; let pos = tagStart; const lower = html.toLowerCase(); while (pos < html.length) { if (lower.startsWith("", pos + 4); pos = commentEnd < 0 ? html.length : commentEnd + 3; continue; } if (lower.startsWith(openPattern, pos) && /[\s>/]/.test(html[pos + openPattern.length] || "")) { depth++; pos += openPattern.length; continue; } if (lower.startsWith(closeTag, pos)) { depth--; if (depth === 0) { const end = pos + closeTag.length; const innerContent = html.slice(tagStart + openTag.length, pos); return { start: tagStart, end, openTag, tagName, indent: /^[\t ]*$/.test(indent) ? indent : "", innerContent, isSelfClosing: false, }; } pos += closeTag.length; continue; } pos++; } return null; }