mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
* feat(studio): add clipboard payload types and ID deduplication * feat(studio): add Ctrl+C/V/X copy/paste for timeline clips and DOM elements * fix(studio): use duck-typing for cross-frame element access in clipboard Elements from the preview iframe are from a different window context, so `el instanceof HTMLElement` always returns false. Use `"outerHTML" in el` instead to correctly detect elements across frame boundaries. * fix(studio): preserve playhead position after paste reloadPreview() used location.reload() which bypassed the NLELayout saveSeekPosition effect, causing the playhead to reset to 0:00 after paste. Switch to setRefreshKey which triggers the effect and restores the seek position after the iframe reloads. * fix(studio): paste DOM elements as siblings, not at composition root DOM element paste was inserting at the composition root, losing the parent context that provides CSS styles and positioning. Now stores the origin selector on copy and inserts the paste as a sibling immediately after the original element, preserving style inheritance. Falls back to root insertion if the selector can't be matched. * fix(studio): address review — deduplicateIds, native copy, altKey guard - deduplicateIds regex used \b which matched data-composition-id, data-clip-id, etc. Switch to lookbehind (?<=\s) so only standalone id="..." attributes are rewritten. Add test pinning this. - Ctrl+C no longer calls preventDefault() before confirming there's a selected element. Native browser copy (text selections outside inputs) is preserved when nothing is selected in the Studio. - Add !event.altKey guard on C/V/X to avoid intercepting Cmd+Alt+V (paste-as-plain-text) and similar OS gestures. - Remove no-op .replace(/"/g, '"') flagged by CodeQL. * fix(studio): address review round 2 — Cmd+X guard, data-start scope, revert drive-by - Cmd+X now pre-checks selection state before preventDefault, mirroring the Cmd+C fix. Native cut preserved when nothing is selected. - handleCut returns Promise<boolean> so the caller can gate on it. - data-start rewrite scoped to the outermost opening tag only, so nested clip timing is preserved on paste. - Removed system clipboard write (cross-tab paste unsupported, in-memory ref is the only read path). - Reverted the reloadPreview drive-by (setRefreshKey→location.reload); the perf branch (#895) handles this properly via refreshPlayer(). * perf(studio): use lightweight iframe.src reload instead of Player teardown Content refreshes (paste, move, resize, delete, asset drop) previously triggered setRefreshKey which changed the Player's React key, causing full web-component destruction + iframe teardown + crossfade animation + re-initialization of all event listeners and asset polling. Now NLELayout intercepts refreshKey changes and calls refreshPlayer() which just appends a cache-busting _t param to the iframe src. The Player web component stays alive, event listeners persist, and the reload is ~10x faster with no "waiting for media" flash. Key-based teardown is preserved for actual structural changes (project switch, composition drill-down via directUrl change). * perf(studio): skip asset-loading overlay on content refreshes The asset-loading overlay ("Preparing preview assets") polled for video/audio readyState on every iframe load, including content refreshes from paste/move/resize. On reloads the browser serves assets from cache so they resolve near-instantly — the overlay just created a disruptive flash. Now skips the polling on subsequent loads (loadCountRef > 1), only showing it on the initial cold load. * feat(studio): add Timing section to inspector Design panel Adds Start, End, and Duration fields to the Design panel when the selected element has data-start/data-duration attributes. Editing any field commits via the attribute patch pipeline (same as timeline edits) and refreshes the preview. End is computed from start+duration and writing End adjusts duration accordingly. * fix(studio): preserve bare text nodes in mixed-content elements collectDomEditTextFields only captured child HTML elements, ignoring bare text nodes. For elements like: <div class="headline">If you're <span>turning 65</span> soon...</div> only the <span> was collected as a text field. When commitDomTextFields serialized back, "If you're " and " soon..." were lost. Now walks childNodes and creates text-node fields for bare text nodes alongside child element fields. serializeDomEditTextFields emits bare text for text-node fields, preserving the complete mixed content. * fix(studio): address #896 review — remove scrub from timing, add mixed-content test - Remove scrub from Timing fields: 1px = 1 second is too coarse. Scroll-wheel and direct typing still work with sub-second precision. - Add mixed-content text-node serialization test in a separate file (domEditingTextFields.test.ts) to avoid bloating the existing domEditing.test.ts past the filesize limit.
495 lines
15 KiB
TypeScript
495 lines
15 KiB
TypeScript
/**
|
|
* Layer items, text fields, capabilities, selection resolution, and patch operations
|
|
* for dom editing.
|
|
*/
|
|
import type { PatchOperation } from "../../utils/sourcePatcher";
|
|
import type {
|
|
DomEditCapabilities,
|
|
DomEditContextOptions,
|
|
DomEditLayerItem,
|
|
DomEditSelection,
|
|
DomEditTextField,
|
|
} from "./domEditingTypes";
|
|
import {
|
|
buildStableSelector,
|
|
getCuratedComputedStyles,
|
|
getDataAttributes,
|
|
getInlineStyles,
|
|
getPreferredClassSelector,
|
|
getSelectorIndex,
|
|
getSourceFileForElement,
|
|
humanizeIdentifier,
|
|
isHtmlElement,
|
|
isIdentityTransform,
|
|
isTextBearingTag,
|
|
parsePx,
|
|
} from "./domEditingDom";
|
|
import {
|
|
findElementForSelection,
|
|
getDomLayerPatchTarget,
|
|
getDirectLayerChildren,
|
|
getSelectionCandidate,
|
|
} from "./domEditingElement";
|
|
|
|
// ─── Text fields ────────────────────────────────────────────────────────────
|
|
|
|
export 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,
|
|
};
|
|
}
|
|
|
|
export function collectDomEditTextFields(el: HTMLElement): DomEditTextField[] {
|
|
const childElements = Array.from(el.children).filter(isHtmlElement).filter(isEditableTextLeaf);
|
|
|
|
if (childElements.length > 0) {
|
|
const hasMixedContent = Array.from(el.childNodes).some(
|
|
(node) => node.nodeType === 3 && node.textContent?.trim(),
|
|
);
|
|
|
|
if (hasMixedContent) {
|
|
const fields: DomEditTextField[] = [];
|
|
let childIdx = 0;
|
|
for (const node of el.childNodes) {
|
|
if (node.nodeType === 3) {
|
|
const text = node.textContent ?? "";
|
|
if (!text.trim()) continue;
|
|
fields.push({
|
|
key: `text-node:${childIdx}`,
|
|
label: `Text ${childIdx + 1}`,
|
|
value: text,
|
|
tagName: "#text",
|
|
attributes: [],
|
|
inlineStyles: {},
|
|
computedStyles: {},
|
|
source: "text-node",
|
|
});
|
|
childIdx++;
|
|
} else if (isHtmlElement(node) && isEditableTextLeaf(node)) {
|
|
fields.push(buildTextField(node, childIdx, childElements.length, "child"));
|
|
childIdx++;
|
|
}
|
|
}
|
|
return fields;
|
|
}
|
|
|
|
return childElements.map((child, index) =>
|
|
buildTextField(child, index, childElements.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" || field.source === "text-node")
|
|
.map((field) => {
|
|
if (field.source === "text-node") {
|
|
return escapeHtmlText(field.value);
|
|
}
|
|
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",
|
|
};
|
|
}
|
|
|
|
// ─── Capabilities ────────────────────────────────────────────────────────────
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
// ─── Element label ────────────────────────────────────────────────────────────
|
|
|
|
export 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();
|
|
}
|
|
|
|
// ─── Selection resolution ────────────────────────────────────────────────────
|
|
|
|
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;
|
|
}
|
|
|
|
// ─── Layer items ─────────────────────────────────────────────────────────────
|
|
|
|
export function getDomEditLayerKey(
|
|
target: Pick<DomEditSelection, "id" | "selector" | "selectorIndex" | "sourceFile">,
|
|
): string {
|
|
const selectorIndex = target.selectorIndex ?? 0;
|
|
return `${target.sourceFile}:${target.id ?? target.selector ?? "layer"}:${selectorIndex}`;
|
|
}
|
|
|
|
export function countDomEditChildLayers(
|
|
root: HTMLElement | null | undefined,
|
|
options: DomEditContextOptions,
|
|
maxCount = 99,
|
|
): number {
|
|
if (!root) return 0;
|
|
|
|
let count = 0;
|
|
const visit = (el: HTMLElement) => {
|
|
for (const child of Array.from(el.children)) {
|
|
if (!isHtmlElement(child)) continue;
|
|
if (getDomLayerPatchTarget(child, options.activeCompositionPath)) {
|
|
count += 1;
|
|
if (count >= maxCount) return;
|
|
}
|
|
visit(child);
|
|
if (count >= maxCount) return;
|
|
}
|
|
};
|
|
|
|
visit(root);
|
|
return count;
|
|
}
|
|
|
|
export function collectDomEditLayerItems(
|
|
root: HTMLElement | null | undefined,
|
|
options: DomEditContextOptions,
|
|
maxItems = 80,
|
|
): DomEditLayerItem[] {
|
|
if (!root) return [];
|
|
|
|
const items: DomEditLayerItem[] = [];
|
|
const visit = (el: HTMLElement, depth: number) => {
|
|
if (items.length >= maxItems) return;
|
|
|
|
const target = getDomLayerPatchTarget(el, options.activeCompositionPath);
|
|
if (target) {
|
|
items.push({
|
|
key: getDomEditLayerKey(target),
|
|
element: el,
|
|
label: buildElementLabel(el),
|
|
tagName: el.tagName.toLowerCase(),
|
|
depth,
|
|
childCount: getDirectLayerChildren(el, options).length,
|
|
id: target.id ?? undefined,
|
|
selector: target.selector ?? undefined,
|
|
selectorIndex: target.selectorIndex,
|
|
sourceFile: target.sourceFile,
|
|
});
|
|
}
|
|
|
|
const nextDepth = target ? depth + 1 : depth;
|
|
for (const child of Array.from(el.children)) {
|
|
if (!isHtmlElement(child)) continue;
|
|
visit(child, nextDepth);
|
|
if (items.length >= maxItems) return;
|
|
}
|
|
};
|
|
|
|
visit(root, 0);
|
|
return items;
|
|
}
|
|
|
|
// ─── Patch operations ────────────────────────────────────────────────────────
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
// ─── Non-editable reason ─────────────────────────────────────────────────────
|
|
|
|
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 getDomEditTargetKey(
|
|
selection: Pick<DomEditSelection, "id" | "selector" | "selectorIndex" | "sourceFile">,
|
|
): string {
|
|
return [
|
|
selection.sourceFile || "index.html",
|
|
selection.id ?? "",
|
|
selection.selector ?? "",
|
|
selection.selectorIndex ?? "",
|
|
].join("|");
|
|
}
|
|
|
|
export function isTextEditableSelection(selection: DomEditSelection): boolean {
|
|
return selection.textFields.length > 0 && !selection.isCompositionHost;
|
|
}
|
|
|
|
// buildElementAgentPrompt is in domEditingAgentPrompt.ts
|