mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 04:18:01 +00:00
## Summary Studio manual geometry edits now persist as a project-local manifest instead of being baked into composition source on each gesture. The manifest lives at: ```text .hyperframes/studio-manual-edits.json ``` It is the source of truth for manual drag, resize, rotation, inspector geometry edits, group moves, and selected-layer reset. ## Architecture - **Manifest-backed edits**: each edit stores a kind (`path-offset`, `box-size`, `rotation`), a source-scoped target, and the edit values. - **Source-scoped resolution**: targets include `sourceFile`, `id`, `selector`, and `selectorIndex`, so duplicate selectors in nested compositions resolve against the owning source file. - **Additive CSS layer**: move uses CSS `translate`, resize writes stable dimensions/flex sizing, and rotation uses CSS `rotate` over the authored base. - **Shared replay runtime**: Studio preview, thumbnails, frame capture, producer renders, and CLI Studio renders/thumbnails all use the same core manual-edit render script. - **Animation-safe replay**: Studio reapplies the manual layer after load, refresh, timeline seeks, player operations, playback frames, thumbnail seeks, and render seeks instead of rewriting GSAP timelines. - **History and handoff**: the manifest is a normal project file, so undo/redo and agent edits can preserve, modify, or remove manual visual edits explicitly. ## User Impact Users can move, resize, rotate, group-move, and reset supported layers from the canvas or inspector, then refresh, capture thumbnails/screenshots, play animated compositions, and render videos without manual edits drifting away from the edited state. ## Main Files - `packages/studio/src/components/editor/manualEdits.ts` - `packages/studio/src/components/editor/DomEditOverlay.tsx` - `packages/studio/src/components/editor/PropertyPanel.tsx` - `packages/studio/src/App.tsx` - `packages/core/src/studio-api/helpers/manualEditsRenderScript.ts` - `packages/studio/vite.config.ts` - `packages/cli/src/server/studioServer.ts` - `packages/core/src/compiler/htmlBundler.ts` - `packages/producer/src/services/htmlCompiler.ts` - `packages/core/src/studio-api/routes/thumbnail.ts` - `packages/producer/src/services/fileServer.ts` - `packages/producer/src/services/renderOrchestrator.ts` ## Test Plan ```bash volta run --node 22.20.0 bun run build volta run --node 22.20.0 bun run --filter @hyperframes/core test -- src/studio-api/helpers/manualEditsRenderScript.test.ts volta run --node 22.20.0 bun run --filter @hyperframes/core typecheck volta run --node 22.20.0 bun run --filter @hyperframes/studio typecheck volta run --node 22.20.0 bun run --filter @hyperframes/cli typecheck volta run --node 22.20.0 bunx oxlint <changed files> volta run --node 22.20.0 bunx oxfmt --check <changed files> git diff --check ```
734 lines
22 KiB
TypeScript
734 lines
22 KiB
TypeScript
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;
|
|
}
|