Files
hyperframes/packages/studio/src/components/editor/domEditingLayers.ts
T
Miguel Ángel 22942280b6 fix(studio): per-child patch op builders and persist-seam harness (#1909)
* test(studio): add design-panel QA fixture and triage matrix

Fixture project covering all panel-editable element archetypes,
plus the QA findings matrix from the design-panel bug campaign.

* fix(studio): make canvas selection hit intended elements

- honor author pointer-events:none in hit-testing (was selecting invisible overlays)
- pause playback before mousedown sampling; fall back to hover selection on null resolve
- invalidate committed selection when the active composition changes
- double-click keeps selection and defers to multi-candidate click cycling

* fix(studio): close remaining selection-layer review findings

- hoverSelection fallback now wired at all 3 mousedown call sites (box-click,
  blocked-drag, plain overlay click) instead of just the overlay path
- pointer-events override detection reads computed style, not inline style,
  so a CSS-class opt-in (not just inline style=) on a descendant is honored
- defensively remove the pointer-events override before the group-fallback
  check too, closing a theoretical gap in the no-elementsFromPoint branch
- a click that resolves to nothing (dead-zone / deselect) no longer leaves
  playback paused if it was already playing

* fix(studio-server): child-scoped patch operations with batch abort

- PatchOperation gains optional childSelector/childIndex resolved under the matched parent
- pre-pass resolves every op target; any miss aborts the batch with matched:false, no partial write
- style-decl parsing extracted to sourceStyleMutation to stay under the file-size cap
- new ./source-mutation subpath export (mirrors ./finite-mutation)

* fix(studio): per-child patch op builders and persist-seam harness

- buildTextFieldChildLocator indexes over the parent's full same-tag child list
- buildTextFieldChildOperations emits per-field ops for same-shape multi-field edits
- SDK cutover declines child-scoped batches (hfId mapping would hit the parent)
- persist-seam integration harness drives real client ops through patchElementInHtml

* fix(studio): fail closed on unresolved text-field child index

buildTextFieldChildLocator guessed a synthetic field's position by
counting same-tag "child" fields elsewhere in the array whenever
sourceChildIndex was absent. That heuristic is unreachable today (the
count-mismatch guard in buildTextFieldChildOperations already refuses
add/remove edits before it's reached) but would silently locate the
wrong element for a future caller that wires up synthetic-field
support without also computing a real sourceChildIndex. Return null
instead so the caller falls back to the unsupported-structure path.
2026-07-03 18:14:17 -07:00

596 lines
19 KiB
TypeScript

import type { PatchOperation } from "../../utils/sourcePatcher";
import {
resolveEditingAffordances,
resolveEditingSections,
type EditableElementFacts,
} from "@hyperframes/core/editing";
import { groupScopedLayerRoots, resolveGroupCapture } from "./domEditingGroups";
import type {
DomEditCapabilities,
DomEditContextOptions,
DomEditLayerItem,
DomEditSelection,
DomEditTextField,
} from "./domEditingTypes";
import {
buildElementLabel,
buildStableSelector,
findClosestByAttribute,
getCuratedComputedStyles,
getDataAttributes,
getInlineStyles,
getSelectorIndex,
getSourceFileForElement,
isHtmlElement,
isTextBearingTag,
} from "./domEditingDom";
import {
findElementForSelection,
getDomLayerPatchTarget,
getDirectLayerChildren,
getSelectionCandidate,
} from "./domEditingElement";
import { isCompositionRootLayer } from "./domEditingRootLayer";
export function isEditableTextLeaf(el: HTMLElement): boolean {
return isTextBearingTag(el.tagName.toLowerCase()) && el.children.length === 0;
}
function sameTagChildIndex(el: HTMLElement): number {
let index = 0;
let sibling = el.previousElementSibling;
while (sibling) {
if (sibling.tagName === el.tagName) index += 1;
sibling = sibling.previousElementSibling;
}
return index;
}
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",
sourceChildIndex?: number,
): 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,
...(sourceChildIndex == null ? {} : { sourceChildIndex }),
};
}
// fallow-ignore-next-line complexity
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", sameTagChildIndex(node)),
);
childIdx++;
}
}
return fields;
}
return childElements.map((child, index) =>
buildTextField(child, index, childElements.length, "child", sameTagChildIndex(child)),
);
}
if (isEditableTextLeaf(el)) {
return [buildTextField(el, 0, 1, "self")];
}
return [];
}
function escapeHtmlText(value: string): string {
return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
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, "&quot;")}"`)
.join("");
const style = serializeTextFieldStyle(field);
const styleAttr = style ? ` style="${style.replace(/"/g, "&quot;")}"` : "";
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 interface DomEditChildLocator {
childSelector: string;
childIndex: number;
}
export function buildTextFieldChildLocator(
fields: DomEditTextField[],
fieldKey: string,
): DomEditChildLocator | null {
const field = fields.find((candidate) => candidate.key === fieldKey);
if (!field || field.source !== "child") return null;
// sourceChildIndex is only absent for a synthetic field that was never read
// back from the live DOM (e.g. one built by buildDefaultDomEditTextField).
// Guessing its position by counting same-tag "child" fields elsewhere in
// the array is unreliable and can silently locate the wrong element — fail
// closed instead so the caller falls back to the unsupported-structure path.
if (field.sourceChildIndex == null) return null;
return {
childSelector: `:scope > ${field.tagName}`,
childIndex: field.sourceChildIndex,
};
}
function capabilityFacts(geometry: {
hasStableTarget: boolean;
tag: string;
inlineStyles: Record<string, string>;
computedStyles: Record<string, string>;
isCompositionHost: boolean;
isCompositionRoot: boolean;
isInsideLockedComposition: boolean;
isMasterView: boolean;
existsInSource: boolean;
}): EditableElementFacts {
return {
...geometry,
hasEditableText: false,
hasTimingStart: false,
animationCount: 0,
};
}
/**
* Build core EditableElementFacts from a fully-resolved DomEditSelection.
* `animationCount` is supplied by the caller because live GSAP tweens arrive on
* a separate channel (the PropertyPanel `gsapAnimations` prop), not on the
* selection — `selection.gsapAnimations` is never populated.
*/
export function domEditSelectionToFacts(
selection: DomEditSelection,
animationCount = selection.gsapAnimations?.length ?? 0,
): EditableElementFacts {
return {
hasStableTarget: Boolean(selection.selector || selection.hfId),
tag: selection.tagName,
inlineStyles: selection.inlineStyles,
computedStyles: selection.computedStyles,
isCompositionHost: selection.isCompositionHost,
isCompositionRoot: false,
isInsideLockedComposition: selection.isInsideLockedComposition,
isMasterView: false,
existsInSource: true,
hasEditableText: selection.textFields.length > 0,
hasTimingStart: selection.dataAttributes.start != null,
animationCount,
};
}
/**
* Resolve DOM edit capabilities for a given element.
* Thin wrapper over core resolveEditingAffordances — kept for backward
* compatibility (tests and the barrel import this signature directly).
*/
export function resolveDomEditCapabilities(args: {
selector?: string;
hfId?: string;
tagName?: string;
inlineStyles: Record<string, string>;
computedStyles: Record<string, string>;
isCompositionHost: boolean;
isCompositionRoot?: boolean;
isInsideLockedComposition: boolean;
isMasterView: boolean;
existsInSource?: boolean;
}): DomEditCapabilities {
return resolveEditingAffordances(
capabilityFacts({
hasStableTarget: Boolean(args.selector || args.hfId),
tag: (args.tagName ?? "div").toLowerCase(),
inlineStyles: args.inlineStyles,
computedStyles: args.computedStyles,
isCompositionHost: args.isCompositionHost,
isCompositionRoot: args.isCompositionRoot ?? false,
isInsideLockedComposition: args.isInsideLockedComposition ?? false,
isMasterView: args.isMasterView,
existsInSource: args.existsInSource ?? true,
}),
).capabilities;
}
// ─── Element label ────────────────────────────────────────────────────────────
// ─── Source probe ────────────────────────────────────────────────────────────
async function probeSourceElement(
projectId: string,
sourceFile: string,
target: { id?: string; hfId?: string; selector?: string; selectorIndex?: number },
): Promise<boolean> {
try {
const response = await fetch(
`/api/projects/${projectId}/file-mutations/probe-element/${encodeURIComponent(sourceFile)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ target }),
},
);
if (!response.ok) return true;
const data = await response.json();
if (data && typeof data === "object" && "exists" in data && data.exists === false) {
return false;
}
return true;
} catch {
return true;
}
}
// ─── Selection resolution ────────────────────────────────────────────────────
// fallow-ignore-next-line complexity
export async function resolveDomEditSelection(
startEl: HTMLElement | null,
options: DomEditContextOptions & { projectId?: string | null; skipSourceProbe?: boolean },
): Promise<DomEditSelection | null> {
if (!startEl) return null;
const doc = startEl.ownerDocument;
let capture = resolveGroupCapture(startEl, options.activeGroupElement ?? null);
if (capture.kind === "out-of-scope") {
// Drill-in is non-sticky: clicking/hovering OUTSIDE the drilled-into group
// exits it and resolves the target normally, rather than selecting nothing
// (which felt like "can't select anything" once you'd drilled in).
capture = resolveGroupCapture(startEl, null);
}
let current: HTMLElement | null =
capture.kind === "unit" ? capture.element : getSelectionCandidate(startEl, options);
while (current && current !== doc.body && current !== doc.documentElement) {
const selector = buildStableSelector(current);
const hfId = readHfId(current);
if (!selector && !hfId) {
current = current.parentElement;
continue;
}
const { sourceFile, compositionPath } = getSourceFileForElement(
current,
options.activeCompositionPath,
);
const selectorIndex = selector
? getSelectorIndex(doc, current, selector, sourceFile, options.activeCompositionPath)
: undefined;
const compositionSrc =
current.getAttribute("data-composition-src") ??
current.getAttribute("data-composition-file") ??
undefined;
const inlineStyles = getInlineStyles(current);
const computedStyles = getCuratedComputedStyles(current);
const isCompositionRoot =
(current.hasAttribute("data-composition-id") && !compositionSrc) ||
isCompositionRootLayer(current, doc, computedStyles);
const textFields = collectDomEditTextFields(current);
const isInsideLocked = Boolean(findClosestByAttribute(current, ["data-timeline-locked"]));
let existsInSource: boolean | undefined;
if (!options.skipSourceProbe && options.projectId && (current.id || selector || hfId)) {
const probeTarget: { id?: string; hfId?: string; selector?: string; selectorIndex?: number } =
{};
if (current.id) probeTarget.id = current.id;
if (hfId) probeTarget.hfId = hfId;
if (selector) probeTarget.selector = selector;
if (selectorIndex != null) probeTarget.selectorIndex = selectorIndex;
existsInSource = await probeSourceElement(options.projectId, sourceFile, probeTarget);
}
const capabilities = resolveEditingAffordances(
capabilityFacts({
hasStableTarget: Boolean(selector || hfId),
tag: current.tagName.toLowerCase(),
inlineStyles,
computedStyles,
isCompositionHost: Boolean(compositionSrc),
isCompositionRoot,
isInsideLockedComposition: isInsideLocked,
isMasterView: options.isMasterView,
existsInSource: existsInSource ?? true,
}),
).capabilities;
const rect = current.getBoundingClientRect();
return {
element: current,
id: current.id || undefined,
hfId,
selector,
selectorIndex,
sourceFile,
compositionPath,
compositionSrc,
isCompositionHost: Boolean(compositionSrc),
isInsideLockedComposition: isInsideLocked,
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 async function refreshDomEditSelection(
selection: DomEditSelection,
activeCompositionPath: string | null,
): Promise<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[] = [];
// fallow-ignore-next-line complexity
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,
hfId: target.hfId ?? 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;
}
};
// Drilled into a group → show only its members; otherwise the whole tree.
for (const el of groupScopedLayerRoots(root, options.activeGroupElement ?? null)) visit(el, 0);
return items;
}
// ─── Patch operations ────────────────────────────────────────────────────────
export function buildDomEditStylePatchOperation(
property: string,
value: string | null,
childLocator?: DomEditChildLocator,
): PatchOperation {
return {
type: "inline-style",
property,
value,
...childLocator,
};
}
export function buildDomEditTextPatchOperation(
value: string,
childLocator?: DomEditChildLocator,
): PatchOperation {
return {
type: "text-content",
property: "text",
value,
...childLocator,
};
}
// ─── 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" | "hfId" | "selector" | "selectorIndex" | "sourceFile">,
): string {
return [
selection.sourceFile || "index.html",
selection.hfId ?? "",
selection.id ?? "",
selection.selector ?? "",
selection.selectorIndex ?? "",
].join("|");
}
export function isTextEditableSelection(selection: DomEditSelection): boolean {
return resolveEditingSections(domEditSelectionToFacts(selection)).text;
}
// buildElementAgentPrompt is in domEditingAgentPrompt.ts
export function readHfId(element: Element): string | undefined {
return element.getAttribute("data-hf-id")?.trim() || undefined;
}
export function buildDomEditPatchTarget(
selection: Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex">,
): { id?: string | null; hfId?: string; selector?: string; selectorIndex?: number } {
return {
id: selection.id,
hfId: selection.hfId,
selector: selection.selector,
selectorIndex: selection.selectorIndex,
};
}