mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
Revert "feat: Persist Studio manual edits via manifest (#593)"
This reverts commit d0abe90a82.
This commit is contained in:
@@ -1,733 +0,0 @@
|
||||
import { formatTime } from "../../player/lib/time";
|
||||
import type { PatchOperation, PatchTarget } from "../../utils/sourcePatcher";
|
||||
|
||||
const CURATED_STYLE_PROPERTIES = [
|
||||
"position",
|
||||
"display",
|
||||
"top",
|
||||
"left",
|
||||
"right",
|
||||
"bottom",
|
||||
"inset",
|
||||
"width",
|
||||
"height",
|
||||
"gap",
|
||||
"justify-content",
|
||||
"align-items",
|
||||
"flex-direction",
|
||||
"font-size",
|
||||
"font-weight",
|
||||
"font-family",
|
||||
"color",
|
||||
"background-color",
|
||||
"background-image",
|
||||
"opacity",
|
||||
"mix-blend-mode",
|
||||
"border-radius",
|
||||
"border-color",
|
||||
"outline-color",
|
||||
"overflow",
|
||||
"box-shadow",
|
||||
"z-index",
|
||||
"transform",
|
||||
] as const;
|
||||
|
||||
export interface DomEditCapabilities {
|
||||
canSelect: boolean;
|
||||
canEditStyles: boolean;
|
||||
/** Directly editable authored left/top style fields. Canvas drag uses manual edits instead. */
|
||||
canMove: boolean;
|
||||
/** Directly editable authored width/height style fields. Canvas resize uses manual edits instead. */
|
||||
canResize: boolean;
|
||||
canApplyManualOffset: boolean;
|
||||
canApplyManualSize: boolean;
|
||||
canApplyManualRotation: boolean;
|
||||
reasonIfDisabled?: string;
|
||||
}
|
||||
|
||||
export interface DomEditTextField {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
tagName: string;
|
||||
attributes: Array<{ name: string; value: string }>;
|
||||
inlineStyles: Record<string, string>;
|
||||
computedStyles: Record<string, string>;
|
||||
source: "self" | "child";
|
||||
}
|
||||
|
||||
export interface DomEditSelection extends PatchTarget {
|
||||
element: HTMLElement;
|
||||
label: string;
|
||||
tagName: string;
|
||||
sourceFile: string;
|
||||
compositionPath: string;
|
||||
compositionSrc?: string;
|
||||
isCompositionHost: boolean;
|
||||
boundingBox: { x: number; y: number; width: number; height: number };
|
||||
textContent: string | null;
|
||||
dataAttributes: Record<string, string>;
|
||||
inlineStyles: Record<string, string>;
|
||||
computedStyles: Record<string, string>;
|
||||
textFields: DomEditTextField[];
|
||||
capabilities: DomEditCapabilities;
|
||||
}
|
||||
|
||||
export interface DomEditContextOptions {
|
||||
activeCompositionPath: string | null;
|
||||
isMasterView: boolean;
|
||||
preferClipAncestor?: boolean;
|
||||
}
|
||||
|
||||
function isHtmlElement(value: unknown): value is HTMLElement {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"nodeType" in value &&
|
||||
typeof (value as { nodeType?: unknown }).nodeType === "number" &&
|
||||
(value as { nodeType: number }).nodeType === 1
|
||||
);
|
||||
}
|
||||
|
||||
function parsePx(value: string | undefined): number | null {
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.endsWith("px")) return null;
|
||||
const parsed = parseFloat(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function isIdentityTransform(value: string | undefined): boolean {
|
||||
const transform = (value ?? "none").trim();
|
||||
if (!transform || transform === "none") return true;
|
||||
|
||||
const matrix = transform.match(/^matrix\(([^)]+)\)$/i);
|
||||
if (matrix) {
|
||||
const values = matrix[1].split(",").map((part) => Number.parseFloat(part.trim()));
|
||||
if (values.length !== 6 || values.some((part) => !Number.isFinite(part))) return false;
|
||||
return (
|
||||
Math.abs(values[0] - 1) < 0.0001 &&
|
||||
Math.abs(values[1]) < 0.0001 &&
|
||||
Math.abs(values[2]) < 0.0001 &&
|
||||
Math.abs(values[3] - 1) < 0.0001 &&
|
||||
Math.abs(values[4]) < 0.0001 &&
|
||||
Math.abs(values[5]) < 0.0001
|
||||
);
|
||||
}
|
||||
|
||||
const matrix3d = transform.match(/^matrix3d\(([^)]+)\)$/i);
|
||||
if (!matrix3d) return false;
|
||||
const values = matrix3d[1].split(",").map((part) => Number.parseFloat(part.trim()));
|
||||
if (values.length !== 16 || values.some((part) => !Number.isFinite(part))) return false;
|
||||
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
||||
return values.every((part, index) => Math.abs(part - identity[index]) < 0.0001);
|
||||
}
|
||||
|
||||
function isTextBearingTag(tagName: string): boolean {
|
||||
return ["div", "span", "p", "strong", "h1", "h2", "h3", "h4", "h5", "h6"].includes(tagName);
|
||||
}
|
||||
|
||||
function getCuratedComputedStyles(el: HTMLElement): Record<string, string> {
|
||||
const styles: Record<string, string> = {};
|
||||
const computed = el.ownerDocument.defaultView?.getComputedStyle(el);
|
||||
if (!computed) return styles;
|
||||
|
||||
for (const prop of CURATED_STYLE_PROPERTIES) {
|
||||
const value = computed.getPropertyValue(prop);
|
||||
if (value) styles[prop] = value;
|
||||
}
|
||||
|
||||
return styles;
|
||||
}
|
||||
|
||||
function findClosestByAttribute(el: HTMLElement, attributeNames: string[]): HTMLElement | null {
|
||||
let current: HTMLElement | null = el;
|
||||
while (current) {
|
||||
const candidate = current;
|
||||
if (attributeNames.some((attribute) => candidate.hasAttribute(attribute))) {
|
||||
return candidate;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSourceFileForElement(
|
||||
el: HTMLElement,
|
||||
activeCompositionPath: string | null,
|
||||
): { sourceFile: string; compositionPath: string } {
|
||||
const sourceHost = findClosestByAttribute(el, ["data-composition-file", "data-composition-src"]);
|
||||
const ownerRoot = findClosestByAttribute(el, ["data-composition-id"]);
|
||||
const sourceFile =
|
||||
sourceHost?.getAttribute("data-composition-file") ??
|
||||
sourceHost?.getAttribute("data-composition-src") ??
|
||||
ownerRoot?.getAttribute("data-composition-file") ??
|
||||
ownerRoot?.getAttribute("data-composition-src") ??
|
||||
activeCompositionPath ??
|
||||
"index.html";
|
||||
|
||||
return {
|
||||
sourceFile,
|
||||
compositionPath: sourceFile,
|
||||
};
|
||||
}
|
||||
|
||||
function getPreferredClipAncestor(startEl: HTMLElement): HTMLElement | null {
|
||||
let current: HTMLElement | null = startEl;
|
||||
while (current) {
|
||||
if (current.classList.contains("clip")) {
|
||||
const isCompositionHost =
|
||||
current.hasAttribute("data-composition-src") ||
|
||||
current.hasAttribute("data-composition-file");
|
||||
if (!isCompositionHost || current === startEl) return current;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSelectionCandidate(startEl: HTMLElement, options: DomEditContextOptions): HTMLElement {
|
||||
if (options.preferClipAncestor) {
|
||||
const clipAncestor = getPreferredClipAncestor(startEl);
|
||||
if (clipAncestor) {
|
||||
return clipAncestor;
|
||||
}
|
||||
}
|
||||
|
||||
return startEl;
|
||||
}
|
||||
|
||||
function getPreferredClassSelector(el: HTMLElement): string | undefined {
|
||||
const classes = el.className
|
||||
.split(/\s+/)
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
if (classes.length === 0) return undefined;
|
||||
const preferred =
|
||||
classes.find((value) => value !== "clip" && !value.startsWith("__hf-")) ?? classes[0];
|
||||
return preferred ? `.${preferred}` : undefined;
|
||||
}
|
||||
|
||||
function humanizeIdentifier(value: string): string {
|
||||
return (
|
||||
value
|
||||
.replace(/\.html$/i, "")
|
||||
.replace(/^compositions\//i, "")
|
||||
.split("/")
|
||||
.at(-1)
|
||||
?.replace(/[-_]+/g, " ")
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase()) ?? value
|
||||
);
|
||||
}
|
||||
|
||||
function buildStableSelector(el: HTMLElement): string | undefined {
|
||||
if (el.id) return `#${el.id}`;
|
||||
|
||||
const compositionId = el.getAttribute("data-composition-id");
|
||||
if (compositionId) return `[data-composition-id="${compositionId}"]`;
|
||||
|
||||
return getPreferredClassSelector(el);
|
||||
}
|
||||
|
||||
function getSelectorIndex(
|
||||
doc: Document,
|
||||
el: HTMLElement,
|
||||
selector: string | undefined,
|
||||
sourceFile: string,
|
||||
activeCompositionPath: string | null,
|
||||
): number | undefined {
|
||||
if (!selector?.startsWith(".")) return undefined;
|
||||
|
||||
const candidates = Array.from(doc.querySelectorAll(selector)).filter(
|
||||
(candidate): candidate is HTMLElement =>
|
||||
isHtmlElement(candidate) &&
|
||||
getSourceFileForElement(candidate, activeCompositionPath).sourceFile === sourceFile,
|
||||
);
|
||||
const index = candidates.indexOf(el);
|
||||
return index >= 0 ? index : undefined;
|
||||
}
|
||||
|
||||
function buildElementLabel(el: HTMLElement): string {
|
||||
const compositionId = el.getAttribute("data-composition-id");
|
||||
if (compositionId && compositionId !== "main") {
|
||||
return humanizeIdentifier(compositionId);
|
||||
}
|
||||
|
||||
const compositionSrc =
|
||||
el.getAttribute("data-composition-src") ?? el.getAttribute("data-composition-file");
|
||||
if (compositionSrc) {
|
||||
return humanizeIdentifier(compositionSrc);
|
||||
}
|
||||
|
||||
if (el.id) return humanizeIdentifier(el.id);
|
||||
|
||||
const preferredClass = getPreferredClassSelector(el);
|
||||
if (preferredClass) {
|
||||
return humanizeIdentifier(preferredClass.replace(/^\./, ""));
|
||||
}
|
||||
|
||||
const text = (el.textContent ?? "").trim().replace(/\s+/g, " ");
|
||||
if (text) return text.length > 40 ? `${text.slice(0, 39)}…` : text;
|
||||
return el.tagName.toLowerCase();
|
||||
}
|
||||
|
||||
function getDataAttributes(el: HTMLElement): Record<string, string> {
|
||||
const attrs: Record<string, string> = {};
|
||||
for (const attr of el.attributes) {
|
||||
if (attr.name.startsWith("data-")) {
|
||||
attrs[attr.name.slice(5)] = attr.value;
|
||||
}
|
||||
}
|
||||
return attrs;
|
||||
}
|
||||
|
||||
function getInlineStyles(el: HTMLElement): Record<string, string> {
|
||||
const styles: Record<string, string> = {};
|
||||
for (const property of CURATED_STYLE_PROPERTIES) {
|
||||
const value = el.style.getPropertyValue(property);
|
||||
if (value) styles[property] = value;
|
||||
}
|
||||
return styles;
|
||||
}
|
||||
|
||||
function isEditableTextLeaf(el: HTMLElement): boolean {
|
||||
return isTextBearingTag(el.tagName.toLowerCase()) && el.children.length === 0;
|
||||
}
|
||||
|
||||
function getTextFieldLabel(
|
||||
_tagName: string,
|
||||
index: number,
|
||||
total: number,
|
||||
source: "self" | "child",
|
||||
): string {
|
||||
if (source === "self" || total === 1) return "Content";
|
||||
return `Text ${index + 1}`;
|
||||
}
|
||||
|
||||
function buildTextField(
|
||||
el: HTMLElement,
|
||||
index: number,
|
||||
total: number,
|
||||
source: "self" | "child",
|
||||
): DomEditTextField {
|
||||
const tagName = el.tagName.toLowerCase();
|
||||
const key = el.getAttribute("data-hf-text-key") ?? `${source}:${index}:${tagName}`;
|
||||
return {
|
||||
key,
|
||||
label: getTextFieldLabel(tagName, index, total, source),
|
||||
value: el.textContent ?? "",
|
||||
tagName,
|
||||
attributes: Array.from(el.attributes)
|
||||
.filter((attribute) => attribute.name !== "style")
|
||||
.map((attribute) => ({
|
||||
name: attribute.name,
|
||||
value: attribute.value,
|
||||
})),
|
||||
inlineStyles: getInlineStyles(el),
|
||||
computedStyles: getCuratedComputedStyles(el),
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
function collectDomEditTextFields(el: HTMLElement): DomEditTextField[] {
|
||||
const childFields = Array.from(el.children).filter(isHtmlElement).filter(isEditableTextLeaf);
|
||||
if (childFields.length > 0) {
|
||||
return childFields.map((child, index) =>
|
||||
buildTextField(child, index, childFields.length, "child"),
|
||||
);
|
||||
}
|
||||
|
||||
if (isEditableTextLeaf(el)) {
|
||||
return [buildTextField(el, 0, 1, "self")];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function escapeHtmlText(value: string): string {
|
||||
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function serializeTextFieldStyle(field: DomEditTextField): string {
|
||||
const entries = Object.entries(field.inlineStyles).filter(([, value]) => Boolean(value));
|
||||
if (entries.length === 0) return "";
|
||||
return entries.map(([key, value]) => `${key}: ${value}`).join("; ");
|
||||
}
|
||||
|
||||
export function serializeDomEditTextFields(fields: DomEditTextField[]): string {
|
||||
return fields
|
||||
.filter((field) => field.source === "child")
|
||||
.map((field) => {
|
||||
const attrs = [
|
||||
...field.attributes.filter((attribute) => attribute.name !== "data-hf-text-key"),
|
||||
{ name: "data-hf-text-key", value: field.key },
|
||||
]
|
||||
.map((attribute) => ` ${attribute.name}="${attribute.value.replace(/"/g, """)}"`)
|
||||
.join("");
|
||||
const style = serializeTextFieldStyle(field);
|
||||
const styleAttr = style ? ` style="${style.replace(/"/g, """)}"` : "";
|
||||
return `<${field.tagName}${attrs}${styleAttr}>${escapeHtmlText(field.value)}</${field.tagName}>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function buildDefaultDomEditTextField(base?: Partial<DomEditTextField>): DomEditTextField {
|
||||
return {
|
||||
key: `child:new:${Date.now()}`,
|
||||
label: "Text",
|
||||
value: "New text",
|
||||
tagName: "span",
|
||||
attributes: [],
|
||||
inlineStyles: {
|
||||
"font-family": base?.computedStyles?.["font-family"] ?? "inherit",
|
||||
"font-size": base?.computedStyles?.["font-size"] ?? "16px",
|
||||
"font-weight": base?.computedStyles?.["font-weight"] ?? "400",
|
||||
color: base?.computedStyles?.color ?? "inherit",
|
||||
},
|
||||
computedStyles: {},
|
||||
source: "child",
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveDomEditCapabilities(args: {
|
||||
selector?: string;
|
||||
tagName?: string;
|
||||
className?: string;
|
||||
inlineStyles: Record<string, string>;
|
||||
computedStyles: Record<string, string>;
|
||||
isCompositionHost: boolean;
|
||||
isMasterView: boolean;
|
||||
}): DomEditCapabilities {
|
||||
if (!args.selector) {
|
||||
return {
|
||||
canSelect: false,
|
||||
canEditStyles: false,
|
||||
canMove: false,
|
||||
canResize: false,
|
||||
canApplyManualOffset: false,
|
||||
canApplyManualSize: false,
|
||||
canApplyManualRotation: false,
|
||||
reasonIfDisabled: "Studio could not resolve a stable patch target for this element.",
|
||||
};
|
||||
}
|
||||
|
||||
const position = args.computedStyles.position;
|
||||
const left = parsePx(args.inlineStyles.left) ?? parsePx(args.computedStyles.left);
|
||||
const top = parsePx(args.inlineStyles.top) ?? parsePx(args.computedStyles.top);
|
||||
const width = parsePx(args.inlineStyles.width) ?? parsePx(args.computedStyles.width);
|
||||
const height = parsePx(args.inlineStyles.height) ?? parsePx(args.computedStyles.height);
|
||||
const hasTransformDrivenGeometry = !isIdentityTransform(args.computedStyles.transform);
|
||||
|
||||
const canMove =
|
||||
(position === "absolute" || position === "fixed") &&
|
||||
left != null &&
|
||||
top != null &&
|
||||
!hasTransformDrivenGeometry;
|
||||
|
||||
const canResize = canMove && (width != null || height != null);
|
||||
const canApplyManualGeometry = !args.isCompositionHost;
|
||||
const canApplyManualOffset = canApplyManualGeometry;
|
||||
const canApplyManualSize = canApplyManualGeometry;
|
||||
const canApplyManualRotation = canApplyManualGeometry;
|
||||
const reasonIfDisabled = canApplyManualGeometry
|
||||
? undefined
|
||||
: "Select an internal layer to transform it.";
|
||||
|
||||
if (args.isCompositionHost && args.isMasterView) {
|
||||
return {
|
||||
canSelect: true,
|
||||
canEditStyles: false,
|
||||
canMove,
|
||||
canResize,
|
||||
canApplyManualOffset,
|
||||
canApplyManualSize,
|
||||
canApplyManualRotation,
|
||||
reasonIfDisabled,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
canSelect: true,
|
||||
canEditStyles: true,
|
||||
canMove,
|
||||
canResize,
|
||||
canApplyManualOffset,
|
||||
canApplyManualSize,
|
||||
canApplyManualRotation,
|
||||
reasonIfDisabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveDomEditSelection(
|
||||
startEl: HTMLElement | null,
|
||||
options: DomEditContextOptions,
|
||||
): DomEditSelection | null {
|
||||
if (!startEl) return null;
|
||||
const doc = startEl.ownerDocument;
|
||||
|
||||
let current: HTMLElement | null = getSelectionCandidate(startEl, options);
|
||||
while (current && current !== doc.body && current !== doc.documentElement) {
|
||||
const selector = buildStableSelector(current);
|
||||
if (!selector) {
|
||||
current = current.parentElement;
|
||||
continue;
|
||||
}
|
||||
|
||||
const { sourceFile, compositionPath } = getSourceFileForElement(
|
||||
current,
|
||||
options.activeCompositionPath,
|
||||
);
|
||||
const selectorIndex = getSelectorIndex(
|
||||
doc,
|
||||
current,
|
||||
selector,
|
||||
sourceFile,
|
||||
options.activeCompositionPath,
|
||||
);
|
||||
const compositionSrc =
|
||||
current.getAttribute("data-composition-src") ??
|
||||
current.getAttribute("data-composition-file") ??
|
||||
undefined;
|
||||
const inlineStyles = getInlineStyles(current);
|
||||
const computedStyles = getCuratedComputedStyles(current);
|
||||
const textFields = collectDomEditTextFields(current);
|
||||
const capabilities = resolveDomEditCapabilities({
|
||||
selector,
|
||||
tagName: current.tagName.toLowerCase(),
|
||||
className: current.className,
|
||||
inlineStyles,
|
||||
computedStyles,
|
||||
isCompositionHost: Boolean(compositionSrc),
|
||||
isMasterView: options.isMasterView,
|
||||
});
|
||||
const rect = current.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
element: current,
|
||||
id: current.id || undefined,
|
||||
selector,
|
||||
selectorIndex,
|
||||
sourceFile,
|
||||
compositionPath,
|
||||
compositionSrc,
|
||||
isCompositionHost: Boolean(compositionSrc),
|
||||
label: buildElementLabel(current),
|
||||
tagName: current.tagName.toLowerCase(),
|
||||
boundingBox: {
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
},
|
||||
textContent: current.textContent?.trim() || null,
|
||||
dataAttributes: getDataAttributes(current),
|
||||
inlineStyles,
|
||||
computedStyles,
|
||||
textFields,
|
||||
capabilities,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function refreshDomEditSelection(
|
||||
selection: DomEditSelection,
|
||||
activeCompositionPath: string | null,
|
||||
): DomEditSelection | null {
|
||||
const doc = selection.element.ownerDocument;
|
||||
const nextElement = findElementForSelection(doc, selection, activeCompositionPath);
|
||||
return nextElement
|
||||
? resolveDomEditSelection(nextElement, {
|
||||
activeCompositionPath,
|
||||
isMasterView: !activeCompositionPath || activeCompositionPath === "index.html",
|
||||
})
|
||||
: null;
|
||||
}
|
||||
|
||||
export function getDomEditTargetKey(
|
||||
selection: Pick<DomEditSelection, "id" | "selector" | "selectorIndex" | "sourceFile">,
|
||||
): string {
|
||||
return [
|
||||
selection.sourceFile || "index.html",
|
||||
selection.id ?? "",
|
||||
selection.selector ?? "",
|
||||
selection.selectorIndex ?? "",
|
||||
].join("|");
|
||||
}
|
||||
|
||||
function hasSupportedDirectEdit(capabilities: DomEditCapabilities): boolean {
|
||||
return (
|
||||
capabilities.canEditStyles ||
|
||||
capabilities.canMove ||
|
||||
capabilities.canResize ||
|
||||
capabilities.canApplyManualOffset ||
|
||||
capabilities.canApplyManualSize ||
|
||||
capabilities.canApplyManualRotation
|
||||
);
|
||||
}
|
||||
|
||||
export function getDomEditNonEditableReason(
|
||||
element: HTMLElement,
|
||||
selection: DomEditSelection | null,
|
||||
): string | null {
|
||||
if (!selection) {
|
||||
return "No stable source target";
|
||||
}
|
||||
|
||||
if (selection.element !== element) {
|
||||
return selection.isCompositionHost
|
||||
? "Nested composition boundary"
|
||||
: `Selection resolves to ${selection.label}`;
|
||||
}
|
||||
|
||||
if (!hasSupportedDirectEdit(selection.capabilities)) {
|
||||
return selection.capabilities.reasonIfDisabled ?? "No supported direct edits";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findElementForSelection(
|
||||
doc: Document,
|
||||
selection: Pick<DomEditSelection, "id" | "selector" | "selectorIndex" | "sourceFile">,
|
||||
activeCompositionPath: string | null = null,
|
||||
): HTMLElement | null {
|
||||
if (selection.id) {
|
||||
const byId = doc.getElementById(selection.id);
|
||||
if (
|
||||
isHtmlElement(byId) &&
|
||||
(!selection.sourceFile ||
|
||||
getSourceFileForElement(byId, activeCompositionPath).sourceFile === selection.sourceFile)
|
||||
) {
|
||||
return byId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!selection.selector) return null;
|
||||
|
||||
if (selection.selector.startsWith(".") && selection.selectorIndex != null) {
|
||||
const matches = Array.from(doc.querySelectorAll(selection.selector)).filter(
|
||||
(candidate): candidate is HTMLElement =>
|
||||
isHtmlElement(candidate) &&
|
||||
(!selection.sourceFile ||
|
||||
getSourceFileForElement(candidate, activeCompositionPath).sourceFile ===
|
||||
selection.sourceFile),
|
||||
);
|
||||
return matches[selection.selectorIndex] ?? null;
|
||||
}
|
||||
|
||||
const matches = Array.from(doc.querySelectorAll(selection.selector)).filter(
|
||||
(candidate): candidate is HTMLElement =>
|
||||
isHtmlElement(candidate) &&
|
||||
(!selection.sourceFile ||
|
||||
getSourceFileForElement(candidate, activeCompositionPath).sourceFile ===
|
||||
selection.sourceFile),
|
||||
);
|
||||
return matches[0] ?? null;
|
||||
}
|
||||
|
||||
export function buildDomEditStylePatchOperation(property: string, value: string): PatchOperation {
|
||||
return {
|
||||
type: "inline-style",
|
||||
property,
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDomEditTextPatchOperation(value: string): PatchOperation {
|
||||
return {
|
||||
type: "text-content",
|
||||
property: "text",
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
function formatBoundingBox(bounds: DomEditSelection["boundingBox"]): string {
|
||||
return `x=${Math.round(bounds.x)}, y=${Math.round(bounds.y)}, width=${Math.round(bounds.width)}, height=${Math.round(bounds.height)}`;
|
||||
}
|
||||
|
||||
function formatStyleBlock(styles: Record<string, string>): string {
|
||||
return Object.entries(styles)
|
||||
.filter(([, value]) => value && value !== "initial")
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function formatTextFields(fields: DomEditTextField[]): string {
|
||||
return fields
|
||||
.map(
|
||||
(field) =>
|
||||
`- key=${field.key}; tag=<${field.tagName}>; source=${field.source}; text=${JSON.stringify(field.value)}`,
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function buildElementAgentPrompt({
|
||||
selection,
|
||||
currentTime,
|
||||
tagSnippet,
|
||||
userInstruction,
|
||||
sourceFilePath,
|
||||
}: {
|
||||
selection: DomEditSelection;
|
||||
currentTime: number;
|
||||
tagSnippet?: string;
|
||||
userInstruction?: string;
|
||||
sourceFilePath?: string;
|
||||
}): string {
|
||||
const displayedSourceFile = sourceFilePath?.trim() || selection.sourceFile;
|
||||
const lines = [
|
||||
"## HyperFrames element edit request v1",
|
||||
"Schema version: 1",
|
||||
"",
|
||||
userInstruction?.trim() || "Edit this selected HyperFrames element.",
|
||||
"",
|
||||
`Composition: ${selection.compositionPath}`,
|
||||
`Playback time: ${formatTime(currentTime)}`,
|
||||
`Source file: ${displayedSourceFile}`,
|
||||
`DOM id: ${selection.id ?? "(none)"}`,
|
||||
`Selector: ${selection.selector ?? "(none)"}`,
|
||||
`Selector index: ${selection.selectorIndex ?? 0}`,
|
||||
`Tag: <${selection.tagName}>`,
|
||||
`Bounds: ${formatBoundingBox(selection.boundingBox)}`,
|
||||
];
|
||||
|
||||
if (selection.textContent) {
|
||||
lines.push(`Text: ${selection.textContent}`);
|
||||
}
|
||||
|
||||
const textFieldsBlock = formatTextFields(selection.textFields);
|
||||
if (textFieldsBlock) {
|
||||
lines.push("", "Text fields:", textFieldsBlock);
|
||||
}
|
||||
|
||||
const inlineStyleBlock = formatStyleBlock(selection.inlineStyles);
|
||||
if (inlineStyleBlock) {
|
||||
lines.push("", "Inline styles:", inlineStyleBlock);
|
||||
}
|
||||
|
||||
const computedStyleBlock = formatStyleBlock(selection.computedStyles);
|
||||
if (computedStyleBlock) {
|
||||
lines.push("", "Computed styles (browser-resolved):", computedStyleBlock);
|
||||
}
|
||||
|
||||
if (tagSnippet) {
|
||||
lines.push("", "Target HTML:", tagSnippet);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
"",
|
||||
"Guardrails:",
|
||||
"- Make a targeted change to this element only.",
|
||||
"- Preserve the rest of the composition and its timing.",
|
||||
"- Do not modify other elements' data-* attributes or positioning.",
|
||||
"- Prefer existing inline styles or existing CSS rules for this element over adding unrelated selectors.",
|
||||
);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function isTextEditableSelection(selection: DomEditSelection): boolean {
|
||||
return selection.textFields.length > 0 && !selection.isCompositionHost;
|
||||
}
|
||||
Reference in New Issue
Block a user