mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +00:00
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.
This commit is contained in:
@@ -1,7 +1,3 @@
|
||||
/**
|
||||
* Layer items, text fields, capabilities, selection resolution, and patch operations
|
||||
* for dom editing.
|
||||
*/
|
||||
import type { PatchOperation } from "../../utils/sourcePatcher";
|
||||
import {
|
||||
resolveEditingAffordances,
|
||||
@@ -36,12 +32,20 @@ import {
|
||||
} from "./domEditingElement";
|
||||
import { isCompositionRootLayer } from "./domEditingRootLayer";
|
||||
|
||||
// ─── Text fields ────────────────────────────────────────────────────────────
|
||||
|
||||
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,
|
||||
@@ -57,6 +61,7 @@ function buildTextField(
|
||||
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}`;
|
||||
@@ -74,6 +79,7 @@ function buildTextField(
|
||||
inlineStyles: getInlineStyles(el),
|
||||
computedStyles: getCuratedComputedStyles(el),
|
||||
source,
|
||||
...(sourceChildIndex == null ? {} : { sourceChildIndex }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -105,7 +111,9 @@ export function collectDomEditTextFields(el: HTMLElement): DomEditTextField[] {
|
||||
});
|
||||
childIdx++;
|
||||
} else if (isHtmlElement(node) && isEditableTextLeaf(node)) {
|
||||
fields.push(buildTextField(node, childIdx, childElements.length, "child"));
|
||||
fields.push(
|
||||
buildTextField(node, childIdx, childElements.length, "child", sameTagChildIndex(node)),
|
||||
);
|
||||
childIdx++;
|
||||
}
|
||||
}
|
||||
@@ -113,7 +121,7 @@ export function collectDomEditTextFields(el: HTMLElement): DomEditTextField[] {
|
||||
}
|
||||
|
||||
return childElements.map((child, index) =>
|
||||
buildTextField(child, index, childElements.length, "child"),
|
||||
buildTextField(child, index, childElements.length, "child", sameTagChildIndex(child)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -172,14 +180,30 @@ export function buildDefaultDomEditTextField(base?: Partial<DomEditTextField>):
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Capabilities ────────────────────────────────────────────────────────────
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the geometry/capability half of EditableElementFacts. Section inputs
|
||||
* (text/timing/animation) are irrelevant to capability resolution, so they are
|
||||
* zeroed here. Shared by the wrapper and the live-selection path so the two
|
||||
* fact-construction sites can't disagree.
|
||||
*/
|
||||
function capabilityFacts(geometry: {
|
||||
hasStableTarget: boolean;
|
||||
tag: string;
|
||||
@@ -276,8 +300,11 @@ async function probeSourceElement(
|
||||
},
|
||||
);
|
||||
if (!response.ok) return true;
|
||||
const data = (await response.json()) as { exists?: boolean };
|
||||
return data.exists !== false;
|
||||
const data = await response.json();
|
||||
if (data && typeof data === "object" && "exists" in data && data.exists === false) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
@@ -475,19 +502,28 @@ export function collectDomEditLayerItems(
|
||||
|
||||
// ─── Patch operations ────────────────────────────────────────────────────────
|
||||
|
||||
export function buildDomEditStylePatchOperation(property: string, value: string): PatchOperation {
|
||||
export function buildDomEditStylePatchOperation(
|
||||
property: string,
|
||||
value: string | null,
|
||||
childLocator?: DomEditChildLocator,
|
||||
): PatchOperation {
|
||||
return {
|
||||
type: "inline-style",
|
||||
property,
|
||||
value,
|
||||
...childLocator,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDomEditTextPatchOperation(value: string): PatchOperation {
|
||||
export function buildDomEditTextPatchOperation(
|
||||
value: string,
|
||||
childLocator?: DomEditChildLocator,
|
||||
): PatchOperation {
|
||||
return {
|
||||
type: "text-content",
|
||||
property: "text",
|
||||
value,
|
||||
...childLocator,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user