mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
* feat(studio): carry hfId on TimelineElement, wire through buildPatchTarget (R7, T5b) * refactor(studio): extract readHfId helper, fix empty-string normalization, add comments (R7 review) - Extract readHfId(el) to domEditingLayers.ts — centralizes `?.trim() || undefined` normalisation; guards against empty-string data-hf-id reaching findTagByTarget - Wire readHfId into domEditingLayers.ts and useDomEditCommits.ts (the one site that still used `?? undefined` instead of `|| undefined`) - Re-export readHfId through domEditing.ts public API - Add readHfId unit tests: present, absent, empty-string, whitespace-only - Add comment on PatchTarget: runtime validation lives in findTagByTarget, type is docs-only - Suppress pre-existing unused re-exports in timelineDOM.ts (backward-compat re-exports brought into fallow scope by the T5b hfId changes) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(core): clear data-hf-id on split clone to prevent dual-match (R7 review) cloneNode(true) copies all attributes including data-hf-id. Without clearing it, both halves of a split share the same hf-id; the server's findByHfId picks the first match and silently patches the wrong clip. Remove the attribute from the clone so write-back re-mints a fresh id on the next preview load. Adds a test: splitElementInHtml — hfId clone isolation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(studio): add hfId to DomEditLayerItem + getDomLayerPatchTarget return type (R7 review) - Add hfId to DomEditLayerItem interface (domEditingTypes.ts) so layer item construction in collectDomEditLayerItems compiles - Widen getDomLayerPatchTarget return type to include hfId + populate it from data-hf-id attribute (domEditingElement.ts) - Widen findDomEditSelectionTarget to check hfId-first when no id/selector - Widen Pick types in domEditOverlayGeometry.ts and useGsapScriptCommits.ts - Add hfId to buildMissingCompositionElements element construction - Add hfId-targeted test coverage in domEditing.test.ts, domEditOverlayGeometry.test.ts, timelineIframeHelpers.test.ts - Update hfIds.test.ts KNOWN LIMITATION labels — write-back landed in R7 T1-2 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
563 lines
16 KiB
TypeScript
563 lines
16 KiB
TypeScript
/**
|
|
* Source Patcher — Maps visual property edits back to source HTML files.
|
|
* Handles inline style updates, attribute changes, and text content.
|
|
*/
|
|
|
|
function escapeRegex(s: string): string {
|
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
function escapeStyleAttributeValue(value: string, quote: string): string {
|
|
return quote === '"' ? value.replace(/"/g, """) : value.replace(/'/g, "'");
|
|
}
|
|
|
|
/** Escape a string for safe use inside a double-quoted HTML attribute. */
|
|
function escapeHtmlAttribute(value: string): string {
|
|
return value
|
|
.replace(/&/g, "&")
|
|
.replace(/"/g, """)
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">");
|
|
}
|
|
|
|
/** Reverse escapeHtmlAttribute so callers get the original value. */
|
|
function unescapeHtmlAttribute(value: string): string {
|
|
return value
|
|
.replace(/"/g, '"')
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/&/g, "&");
|
|
}
|
|
|
|
function splitInlineStyleDeclarations(style: string): string[] {
|
|
const declarations: string[] = [];
|
|
let current = "";
|
|
let quote: string | null = null;
|
|
let entity = false;
|
|
let parenDepth = 0;
|
|
|
|
for (const char of style) {
|
|
if (entity) {
|
|
current += char;
|
|
if (char === ";") entity = false;
|
|
continue;
|
|
}
|
|
|
|
if (char === "&") {
|
|
entity = true;
|
|
current += char;
|
|
continue;
|
|
}
|
|
|
|
if (quote) {
|
|
current += char;
|
|
if (char === quote) quote = null;
|
|
continue;
|
|
}
|
|
|
|
if (char === '"' || char === "'") {
|
|
quote = char;
|
|
current += char;
|
|
continue;
|
|
}
|
|
|
|
if (char === "(") {
|
|
parenDepth += 1;
|
|
current += char;
|
|
continue;
|
|
}
|
|
|
|
if (char === ")") {
|
|
parenDepth = Math.max(0, parenDepth - 1);
|
|
current += char;
|
|
continue;
|
|
}
|
|
|
|
if (char === ";" && parenDepth === 0) {
|
|
declarations.push(current);
|
|
current = "";
|
|
continue;
|
|
}
|
|
|
|
current += char;
|
|
}
|
|
|
|
if (current) declarations.push(current);
|
|
return declarations;
|
|
}
|
|
|
|
export interface PatchOperation {
|
|
type: "inline-style" | "attribute" | "text-content" | "html-attribute";
|
|
property: string;
|
|
value: string | null;
|
|
}
|
|
|
|
// Runtime validation for hfId lives in findTagByTarget → execDataAttrPattern (CSS attr-value
|
|
// escape). This type is documentation only; the server's MutationTarget mirrors this shape.
|
|
export interface PatchTarget {
|
|
id?: string | null;
|
|
hfId?: string;
|
|
selector?: string;
|
|
selectorIndex?: number;
|
|
}
|
|
|
|
/**
|
|
* Find which source file contains an element by its ID.
|
|
*/
|
|
export function resolveSourceFile(
|
|
elementId: string | null,
|
|
selector: string,
|
|
files: Record<string, string>,
|
|
): string | null {
|
|
if (!elementId && !selector) return null;
|
|
|
|
// Strategy 1: Search by id attribute
|
|
if (elementId) {
|
|
for (const [path, content] of Object.entries(files)) {
|
|
if (content.includes(`id="${elementId}"`) || content.includes(`id='${elementId}'`)) {
|
|
return path;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Strategy 2: Search by data-composition-id from the selector
|
|
const compIdMatch = selector.match(/data-composition-id="([^"]+)"/);
|
|
if (compIdMatch) {
|
|
const compId = compIdMatch[1];
|
|
for (const [path, content] of Object.entries(files)) {
|
|
if (content.includes(`data-composition-id="${compId}"`)) {
|
|
return path;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Strategy 3: Search by class from the selector
|
|
const classMatch = selector.match(/^\.([a-zA-Z0-9_-]+)/);
|
|
if (classMatch) {
|
|
const cls = classMatch[1];
|
|
for (const [path, content] of Object.entries(files)) {
|
|
if (
|
|
content.includes(`class="${cls}"`) ||
|
|
content.includes(`class="${cls} `) ||
|
|
content.includes(` ${cls}"`)
|
|
) {
|
|
return path;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: index.html
|
|
if ("index.html" in files) return "index.html";
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Apply a style property change to an element's inline style in the HTML source.
|
|
*/
|
|
function patchInlineStyle(
|
|
html: string,
|
|
elementId: string,
|
|
prop: string,
|
|
value: string | null,
|
|
): string {
|
|
// Find the element tag with this id
|
|
const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(elementId)}\\2[^>]*)>`, "i");
|
|
const match = idPattern.exec(html);
|
|
if (!match) return html;
|
|
|
|
const tag = match[1];
|
|
return patchInlineStyleInTag(html, tag, prop, value);
|
|
}
|
|
|
|
function patchInlineStyleInTag(
|
|
html: string,
|
|
tag: string,
|
|
prop: string,
|
|
value: string | null,
|
|
): string {
|
|
if (!tag) return html;
|
|
|
|
// Check if there's an existing style attribute
|
|
const styleMatch = /\bstyle=(["'])([\s\S]*?)\1/.exec(tag);
|
|
if (styleMatch) {
|
|
const existingStyle = styleMatch[2];
|
|
const quote = styleMatch[1];
|
|
// Parse existing properties
|
|
const props = new Map<string, string>();
|
|
for (const part of splitInlineStyleDeclarations(existingStyle)) {
|
|
const colon = part.indexOf(":");
|
|
if (colon < 0) continue;
|
|
const key = part.slice(0, colon).trim();
|
|
const val = part.slice(colon + 1).trim();
|
|
if (key) props.set(key, val);
|
|
}
|
|
// Update/add or remove the property
|
|
if (value === null) {
|
|
props.delete(prop);
|
|
} else {
|
|
props.set(prop, value);
|
|
}
|
|
// Rebuild style string; keep style="" if empty (harmless)
|
|
const newStyle = Array.from(props.entries())
|
|
.map(([k, v]) => `${k}: ${escapeStyleAttributeValue(v, quote)}`)
|
|
.join("; ");
|
|
const newTag = tag.replace(styleMatch[0], `style=${quote}${newStyle}${quote}`);
|
|
return html.replace(tag, newTag);
|
|
} else {
|
|
// No existing style attribute
|
|
if (value === null) return html; // nothing to remove
|
|
const selfClosing = /\s*\/$/.test(tag);
|
|
const base = selfClosing ? tag.replace(/\s*\/$/, "") : tag;
|
|
const newTag = `${base} style="${prop}: ${escapeStyleAttributeValue(value, '"')}"${selfClosing ? " /" : ""}`;
|
|
return html.replace(tag, newTag);
|
|
}
|
|
}
|
|
|
|
function patchInlineStyleByTarget(
|
|
html: string,
|
|
target: PatchTarget,
|
|
prop: string,
|
|
value: string | null,
|
|
): string {
|
|
const match = findTagByTarget(html, target);
|
|
if (!match) return html;
|
|
const newTag = patchInlineStyleInTag(match.tag, match.tag, prop, value);
|
|
return replaceTagAtMatch(html, match, newTag);
|
|
}
|
|
|
|
interface TagMatch {
|
|
tag: string;
|
|
start: number;
|
|
end: number;
|
|
}
|
|
|
|
function replaceTagAtMatch(html: string, match: TagMatch, newTag: string): string {
|
|
return `${html.slice(0, match.start)}${newTag}${html.slice(match.end)}`;
|
|
}
|
|
|
|
function execDataAttrPattern(html: string, attr: string, value: string): TagMatch | null {
|
|
const pattern = new RegExp(`(<[^>]*\\b${attr}=(["'])${escapeRegex(value)}\\2[^>]*)>`, "i");
|
|
const match = pattern.exec(html);
|
|
if (match?.index == null) return null;
|
|
// Defensive: a second exact match means a duplicate id/attr in the source
|
|
// (id drift). Don't silently patch the first while leaving the other stale —
|
|
// surface it. By the mint contract this should never fire.
|
|
const all = html.match(new RegExp(`<[^>]*\\b${attr}=(["'])${escapeRegex(value)}\\1[^>]*>`, "gi"));
|
|
if (all && all.length > 1) {
|
|
// eslint-disable-next-line no-console
|
|
console.warn(
|
|
`sourcePatcher: ${attr}="${value}" matched ${all.length} elements; patching the first. ids/attrs must be unique per document.`,
|
|
);
|
|
}
|
|
return { tag: match[1], start: match.index, end: match.index + match[1].length };
|
|
}
|
|
|
|
function findTagByClass(html: string, target: PatchTarget): TagMatch | null {
|
|
const classMatch = target.selector?.match(/^\.([a-zA-Z0-9_-]+)$/);
|
|
if (!classMatch) return null;
|
|
const cls = classMatch[1];
|
|
const pattern = new RegExp(
|
|
`(<[^>]*\\bclass=(["'])[^"']*\\b${escapeRegex(cls)}\\b[^"']*\\2[^>]*)>`,
|
|
"gi",
|
|
);
|
|
const selectorIndex = target.selectorIndex ?? 0;
|
|
let match: RegExpExecArray | null;
|
|
let currentIndex = 0;
|
|
while ((match = pattern.exec(html)) !== null) {
|
|
if (currentIndex === selectorIndex && match.index != null) {
|
|
return {
|
|
tag: match[1],
|
|
start: match.index,
|
|
end: match.index + match[1].length,
|
|
};
|
|
}
|
|
currentIndex += 1;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function findTagByTarget(html: string, target: PatchTarget): TagMatch | null {
|
|
if (target.hfId) {
|
|
const result = execDataAttrPattern(html, "data-hf-id", target.hfId);
|
|
if (result) return result;
|
|
}
|
|
|
|
if (target.id) {
|
|
const result = execDataAttrPattern(html, "id", target.id);
|
|
if (result) return result;
|
|
}
|
|
|
|
if (!target.selector) return null;
|
|
|
|
const compositionIdMatch = target.selector.match(/^\[data-composition-id="([^"]+)"\]$/);
|
|
if (compositionIdMatch) {
|
|
const result = execDataAttrPattern(html, "data-composition-id", compositionIdMatch[1]);
|
|
if (result) return result;
|
|
}
|
|
|
|
return findTagByClass(html, target);
|
|
}
|
|
|
|
export function readAttributeByTarget(
|
|
html: string,
|
|
target: PatchTarget,
|
|
attr: string,
|
|
): string | undefined {
|
|
const match = findTagByTarget(html, target);
|
|
if (!match) return undefined;
|
|
|
|
const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
|
|
const valueMatch = new RegExp(`\\b${fullAttr}=(["'])([^"']*)\\1`).exec(match.tag);
|
|
return valueMatch?.[2] != null ? unescapeHtmlAttribute(valueMatch[2]) : undefined;
|
|
}
|
|
|
|
export function readTagSnippetByTarget(html: string, target: PatchTarget): string | undefined {
|
|
const match = findTagByTarget(html, target);
|
|
return match?.tag;
|
|
}
|
|
|
|
function patchAttributeByTarget(
|
|
html: string,
|
|
target: PatchTarget,
|
|
attr: string,
|
|
value: string | null,
|
|
): string {
|
|
const match = findTagByTarget(html, target);
|
|
if (!match) return html;
|
|
|
|
const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
|
|
const attrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}=(["'])([^"']*)\\1`);
|
|
const tag = match.tag;
|
|
|
|
if (value === null) {
|
|
// Remove the attribute if present
|
|
if (!attrPattern.test(tag)) return html;
|
|
const removePattern = new RegExp(`\\s+${escapeRegex(fullAttr)}=(["'])[^"']*\\1`);
|
|
const newTag = tag.replace(removePattern, "");
|
|
return replaceTagAtMatch(html, match, newTag);
|
|
}
|
|
|
|
const escaped = escapeHtmlAttribute(value);
|
|
if (attrPattern.test(tag)) {
|
|
const newTag = tag.replace(attrPattern, `${fullAttr}="${escaped}"`);
|
|
return replaceTagAtMatch(html, match, newTag);
|
|
}
|
|
|
|
const newTag = tag + ` ${fullAttr}="${escaped}"`;
|
|
return replaceTagAtMatch(html, match, newTag);
|
|
}
|
|
|
|
/**
|
|
* Apply an attribute change to an element in the HTML source.
|
|
*/
|
|
function patchAttribute(
|
|
html: string,
|
|
elementId: string,
|
|
attr: string,
|
|
value: string | null,
|
|
): string {
|
|
const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(elementId)}\\2[^>]*)>`, "i");
|
|
const match = idPattern.exec(html);
|
|
if (!match) return html;
|
|
|
|
const tag = match[1];
|
|
const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
|
|
const attrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}=(["'])([^"']*)\\1`);
|
|
|
|
if (value === null) {
|
|
if (!attrPattern.test(tag)) return html;
|
|
const removePattern = new RegExp(`\\s+${escapeRegex(fullAttr)}=(["'])[^"']*\\1`);
|
|
const newTag = tag.replace(removePattern, "");
|
|
return html.replace(tag, newTag);
|
|
}
|
|
|
|
const escaped = escapeHtmlAttribute(value);
|
|
if (attrPattern.test(tag)) {
|
|
// Update existing attribute
|
|
const newTag = tag.replace(attrPattern, `${fullAttr}="${escaped}"`);
|
|
return html.replace(tag, newTag);
|
|
} else {
|
|
// Add new attribute
|
|
const newTag = tag + ` ${fullAttr}="${escaped}"`;
|
|
return html.replace(tag, newTag);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply a text content change to an element.
|
|
*/
|
|
function patchTextContent(html: string, elementId: string, value: string): string {
|
|
const openTagPattern = new RegExp(
|
|
`(<([a-z0-9-]+)[^>]*\\bid=(["'])${escapeRegex(elementId)}\\3[^>]*>)`,
|
|
"i",
|
|
);
|
|
const match = openTagPattern.exec(html);
|
|
if (!match || match.index == null) return html;
|
|
|
|
const openingTag = match[1];
|
|
const tagName = match[2];
|
|
const contentStart = match.index + openingTag.length;
|
|
const closingIndex = findMatchingClosingTagIndex(html, tagName, contentStart);
|
|
if (closingIndex < 0) return html;
|
|
|
|
return `${html.slice(0, contentStart)}${value}${html.slice(closingIndex)}`;
|
|
}
|
|
|
|
function findMatchingClosingTagIndex(html: string, tagName: string, contentStart: number): number {
|
|
const tagPattern = new RegExp(`</?${escapeRegex(tagName)}\\b[^>]*>`, "gi");
|
|
tagPattern.lastIndex = contentStart;
|
|
let depth = 1;
|
|
let match: RegExpExecArray | null;
|
|
|
|
while ((match = tagPattern.exec(html)) !== null) {
|
|
const tag = match[0];
|
|
if (tag.startsWith("</")) {
|
|
depth -= 1;
|
|
if (depth === 0) return match.index;
|
|
continue;
|
|
}
|
|
if (!tag.endsWith("/>")) depth += 1;
|
|
}
|
|
|
|
return -1;
|
|
}
|
|
|
|
const HTML_BOOLEAN_ATTRIBUTES = new Set([
|
|
"loop",
|
|
"muted",
|
|
"autoplay",
|
|
"playsinline",
|
|
"controls",
|
|
"default",
|
|
"defer",
|
|
"disabled",
|
|
"hidden",
|
|
"nomodule",
|
|
"open",
|
|
"readonly",
|
|
"required",
|
|
"reversed",
|
|
"selected",
|
|
]);
|
|
|
|
function patchHtmlAttributeInTag(
|
|
html: string,
|
|
tag: string,
|
|
attr: string,
|
|
value: string | null,
|
|
): string {
|
|
if (!tag) return html;
|
|
|
|
const isBoolean = HTML_BOOLEAN_ATTRIBUTES.has(attr);
|
|
|
|
if (isBoolean) {
|
|
const escapedAttr = escapeRegex(attr);
|
|
const hasBoolAttr = new RegExp(`(?:^|\\s)${escapedAttr}(?:\\s|=|$)`).test(tag);
|
|
|
|
if (value === null || value === "" || value === "false") {
|
|
if (!hasBoolAttr) return html;
|
|
const removePattern = new RegExp(`\\s+${escapedAttr}(?:=(["'])[^"']*\\1)?`);
|
|
const newTag = tag.replace(removePattern, "");
|
|
return html.replace(tag, newTag);
|
|
}
|
|
if (hasBoolAttr) return html;
|
|
const newTag = tag + ` ${attr}`;
|
|
return html.replace(tag, newTag);
|
|
}
|
|
|
|
const attrPattern = new RegExp(`\\b${escapeRegex(attr)}=(["'])([^"']*)\\1`);
|
|
if (value === null) {
|
|
if (!attrPattern.test(tag)) return html;
|
|
const removePattern = new RegExp(`\\s+${escapeRegex(attr)}=(["'])[^"']*\\1`);
|
|
const newTag = tag.replace(removePattern, "");
|
|
return html.replace(tag, newTag);
|
|
}
|
|
|
|
const escaped = escapeHtmlAttribute(value);
|
|
if (attrPattern.test(tag)) {
|
|
const newTag = tag.replace(attrPattern, `${attr}="${escaped}"`);
|
|
return html.replace(tag, newTag);
|
|
}
|
|
|
|
const newTag = tag + ` ${attr}="${escaped}"`;
|
|
return html.replace(tag, newTag);
|
|
}
|
|
|
|
function patchHtmlAttribute(
|
|
html: string,
|
|
elementId: string,
|
|
attr: string,
|
|
value: string | null,
|
|
): string {
|
|
const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(elementId)}\\2[^>]*)>`, "i");
|
|
const match = idPattern.exec(html);
|
|
if (!match) return html;
|
|
return patchHtmlAttributeInTag(html, match[1], attr, value);
|
|
}
|
|
|
|
function patchHtmlAttributeByTarget(
|
|
html: string,
|
|
target: PatchTarget,
|
|
attr: string,
|
|
value: string | null,
|
|
): string {
|
|
const match = findTagByTarget(html, target);
|
|
if (!match) return html;
|
|
const newTag = patchHtmlAttributeInTag(match.tag, match.tag, attr, value);
|
|
return replaceTagAtMatch(html, match, newTag);
|
|
}
|
|
|
|
function patchTextContentByTarget(html: string, target: PatchTarget, value: string): string {
|
|
const match = findTagByTarget(html, target);
|
|
if (!match) return html;
|
|
|
|
const tagNameMatch = /^<([a-z0-9-]+)/i.exec(match.tag);
|
|
const tagName = tagNameMatch?.[1];
|
|
if (!tagName) return html;
|
|
|
|
const closingIndex = findMatchingClosingTagIndex(html, tagName, match.end + 1);
|
|
if (closingIndex < 0) return html;
|
|
|
|
return `${html.slice(0, match.end + 1)}${value}${html.slice(closingIndex)}`;
|
|
}
|
|
|
|
/**
|
|
* Apply a patch operation to an HTML source file.
|
|
*/
|
|
export function applyPatch(html: string, elementId: string, op: PatchOperation): string {
|
|
switch (op.type) {
|
|
case "inline-style":
|
|
return patchInlineStyle(html, elementId, op.property, op.value);
|
|
case "attribute":
|
|
return patchAttribute(html, elementId, op.property, op.value);
|
|
case "html-attribute":
|
|
return patchHtmlAttribute(html, elementId, op.property, op.value);
|
|
case "text-content":
|
|
return op.value !== null ? patchTextContent(html, elementId, op.value) : html;
|
|
default:
|
|
return html;
|
|
}
|
|
}
|
|
|
|
export function applyPatchByTarget(html: string, target: PatchTarget, op: PatchOperation): string {
|
|
if (target.id) {
|
|
const patchedById = applyPatch(html, target.id, op);
|
|
if (patchedById !== html || !target.selector) {
|
|
return patchedById;
|
|
}
|
|
}
|
|
|
|
switch (op.type) {
|
|
case "inline-style":
|
|
return patchInlineStyleByTarget(html, target, op.property, op.value);
|
|
case "attribute":
|
|
return patchAttributeByTarget(html, target, op.property, op.value);
|
|
case "html-attribute":
|
|
return patchHtmlAttributeByTarget(html, target, op.property, op.value);
|
|
case "text-content":
|
|
return op.value !== null ? patchTextContentByTarget(html, target, op.value) : html;
|
|
default:
|
|
return html;
|
|
}
|
|
}
|