mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42: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:
@@ -264,6 +264,10 @@
|
||||
// require intrusive middleware changes beyond this PR's scope.
|
||||
"minLines": 6,
|
||||
"ignore": [
|
||||
// sourcePatcher.ts: pre-existing internal clones between the inline-style
|
||||
// and attribute tag-patchers; only the PatchOperation type gained two
|
||||
// optional fields here, but the line shift makes fallow re-flag them.
|
||||
"packages/studio/src/utils/sourcePatcher.ts",
|
||||
// gsapParser.ts: recast/babel GSAP writer — intentional duplication between
|
||||
// recast and acorn parallel implementations (pre-existing, moved from core).
|
||||
"packages/parsers/src/gsapParser.ts",
|
||||
@@ -364,6 +368,11 @@
|
||||
// complexity pre-dates the computed-timeline work. Exempted at file level
|
||||
// rather than refactored as scope creep.
|
||||
"ignore": [
|
||||
// sourcePatcher.ts: resolveSourceFile / splitInlineStyleDeclarations /
|
||||
// patch*InTag pre-date this PR; only the PatchOperation type gained two
|
||||
// optional fields, but the line-shift fingerprint re-flags the inherited
|
||||
// complexity.
|
||||
"packages/studio/src/utils/sourcePatcher.ts",
|
||||
// timeline.ts: collectRuntimeTimelinePayload (CRITICAL) pre-dates this PR;
|
||||
// only an import line changed here (slideshow/sceneId → slideshow/index),
|
||||
// but the line-shift fingerprint makes fallow re-flag inherited complexity.
|
||||
|
||||
@@ -31,6 +31,7 @@ export {
|
||||
buildDomEditTextPatchOperation,
|
||||
collectDomEditLayerItems,
|
||||
countDomEditChildLayers,
|
||||
buildTextFieldChildLocator,
|
||||
getDomEditLayerKey,
|
||||
getDomEditNonEditableReason,
|
||||
getDomEditTargetKey,
|
||||
|
||||
@@ -4,11 +4,27 @@ import {
|
||||
collectDomEditLayerItems,
|
||||
resolveDomEditSelection,
|
||||
buildDomEditPatchTarget,
|
||||
buildTextFieldChildLocator,
|
||||
readHfId,
|
||||
} from "./domEditingLayers";
|
||||
import type { DomEditTextField } from "./domEditingTypes";
|
||||
|
||||
const opts = { activeCompositionPath: "index.html", isMasterView: true, skipSourceProbe: true };
|
||||
|
||||
function textField(overrides: Partial<DomEditTextField> = {}): DomEditTextField {
|
||||
return {
|
||||
key: "child:0:span",
|
||||
label: "Text 1",
|
||||
value: "Hello",
|
||||
tagName: "span",
|
||||
attributes: [],
|
||||
inlineStyles: {},
|
||||
computedStyles: {},
|
||||
source: "child",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildDomEditPatchTarget", () => {
|
||||
it("includes hfId when selection has hfId", () => {
|
||||
const target = buildDomEditPatchTarget({
|
||||
@@ -172,3 +188,39 @@ describe("resolveDomEditSelection — data-hf-group capture", () => {
|
||||
expect(selection?.id).toBe("outside");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTextFieldChildLocator", () => {
|
||||
it("locates a child field using its DOM-derived sourceChildIndex", () => {
|
||||
const fields = [textField({ key: "child:0:span", sourceChildIndex: 0 })];
|
||||
|
||||
expect(buildTextFieldChildLocator(fields, "child:0:span")).toEqual({
|
||||
childSelector: ":scope > span",
|
||||
childIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed for a synthetic child field with no sourceChildIndex", () => {
|
||||
// A field built by buildDefaultDomEditTextField (e.g. "add text field")
|
||||
// has never been read back from the live DOM, so its true position among
|
||||
// same-tag siblings is unknown. Guessing it by counting same-tag "child"
|
||||
// fields elsewhere in the array can silently point at the wrong element.
|
||||
const fields = [
|
||||
textField({ key: "child:0:span", sourceChildIndex: 0 }),
|
||||
textField({ key: "child:new:1", tagName: "span" }),
|
||||
];
|
||||
|
||||
expect(buildTextFieldChildLocator(fields, "child:new:1")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a self-sourced field", () => {
|
||||
const fields = [textField({ key: "self:0:div", source: "self", sourceChildIndex: 0 })];
|
||||
|
||||
expect(buildTextFieldChildLocator(fields, "self:0:div")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an unknown field key", () => {
|
||||
const fields = [textField({ key: "child:0:span", sourceChildIndex: 0 })];
|
||||
|
||||
expect(buildTextFieldChildLocator(fields, "missing")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ export interface DomEditTextField {
|
||||
inlineStyles: Record<string, string>;
|
||||
computedStyles: Record<string, string>;
|
||||
source: "self" | "child" | "text-node";
|
||||
sourceChildIndex?: number;
|
||||
}
|
||||
|
||||
export interface DomEditSelection extends PatchTarget {
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
// @vitest-environment jsdom
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
patchElementInHtml,
|
||||
type PatchOperation,
|
||||
type SourceMutationTarget,
|
||||
} from "@hyperframes/studio-server/source-mutation";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
collectDomEditTextFields,
|
||||
buildDomEditPatchTarget,
|
||||
buildDomEditStylePatchOperation,
|
||||
buildDomEditTextPatchOperation,
|
||||
} from "./domEditingLayers";
|
||||
import { buildPathOffsetPatches } from "./manualEditsDomPatches";
|
||||
import { STUDIO_OFFSET_X_PROP, STUDIO_PATH_OFFSET_ATTR } from "./manualEditsTypes";
|
||||
import { makeSelection } from "../../hooks/domSelectionTestHarness";
|
||||
import { buildTextFieldChildOperations } from "../../hooks/domEditTextFieldCommitOps";
|
||||
|
||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||
const fixtureDir = join(testDir, "../../../tests/e2e/fixtures/design-panel-qa");
|
||||
|
||||
function readFixture(relativePath: string): string {
|
||||
return readFileSync(join(fixtureDir, relativePath), "utf-8");
|
||||
}
|
||||
|
||||
function createSelection(input: {
|
||||
id: string;
|
||||
hfId: string;
|
||||
tagName: string;
|
||||
}): ReturnType<typeof makeSelection> {
|
||||
const element = document.createElement(input.tagName);
|
||||
element.id = input.id;
|
||||
element.setAttribute("data-hf-id", input.hfId);
|
||||
return {
|
||||
...makeSelection(input.id, element),
|
||||
hfId: input.hfId,
|
||||
};
|
||||
}
|
||||
|
||||
function clientTarget(input: { id: string; hfId: string; tagName: string }): SourceMutationTarget {
|
||||
return buildDomEditPatchTarget(createSelection(input));
|
||||
}
|
||||
|
||||
function patchAndExpectChange(
|
||||
sourceHtml: string,
|
||||
target: SourceMutationTarget,
|
||||
operations: PatchOperation[],
|
||||
): string {
|
||||
const result = patchElementInHtml(sourceHtml, target, operations);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.html).not.toBe(sourceHtml);
|
||||
return result.html;
|
||||
}
|
||||
|
||||
function parseHtml(html: string): Document {
|
||||
return new DOMParser().parseFromString(html, "text/html");
|
||||
}
|
||||
|
||||
function findElementInHtml(html: string, selector: string): Element {
|
||||
const document = parseHtml(html);
|
||||
const directMatch = document.querySelector(selector);
|
||||
if (directMatch) return directMatch;
|
||||
|
||||
for (const template of Array.from(document.querySelectorAll("template"))) {
|
||||
const templateMatch = template.content.querySelector(selector);
|
||||
if (templateMatch) return templateMatch;
|
||||
}
|
||||
|
||||
throw new Error(`Expected selector ${selector} to match`);
|
||||
}
|
||||
|
||||
function findByHfId(html: string, hfId: string): Element {
|
||||
return findElementInHtml(html, `[data-hf-id="${hfId}"]`);
|
||||
}
|
||||
|
||||
function countOccurrences(value: string, needle: string): number {
|
||||
return value.split(needle).length - 1;
|
||||
}
|
||||
|
||||
describe("persist seam source mutation", () => {
|
||||
const indexHtml = readFixture("index.html");
|
||||
const subHtml = readFixture("compositions/qa-sub.html");
|
||||
|
||||
it("persists qa-headline text font-size style operation", () => {
|
||||
const html = patchAndExpectChange(
|
||||
indexHtml,
|
||||
clientTarget({ id: "qa-headline", hfId: "qa-headline", tagName: "h1" }),
|
||||
[buildDomEditStylePatchOperation("font-size", "64px")],
|
||||
);
|
||||
|
||||
expect(findByHfId(html, "qa-headline").getAttribute("style")).toContain("font-size: 64px");
|
||||
});
|
||||
|
||||
it("persists qa-shape fill style operation", () => {
|
||||
const html = patchAndExpectChange(
|
||||
indexHtml,
|
||||
clientTarget({ id: "qa-shape", hfId: "qa-shape", tagName: "div" }),
|
||||
[buildDomEditStylePatchOperation("background-color", "#ff0000")],
|
||||
);
|
||||
|
||||
expect(findByHfId(html, "qa-shape").getAttribute("style")).toContain(
|
||||
"background-color: #ff0000",
|
||||
);
|
||||
});
|
||||
|
||||
it("persists qa-multi text color style operation", () => {
|
||||
const html = patchAndExpectChange(
|
||||
indexHtml,
|
||||
clientTarget({ id: "qa-multi", hfId: "qa-multi", tagName: "div" }),
|
||||
[buildDomEditStylePatchOperation("color", "#00ff00")],
|
||||
);
|
||||
|
||||
expect(findByHfId(html, "qa-multi").getAttribute("style")).toContain("color: #00ff00");
|
||||
});
|
||||
|
||||
it("persists qa-image opacity style operation", () => {
|
||||
const html = patchAndExpectChange(
|
||||
indexHtml,
|
||||
clientTarget({ id: "qa-image", hfId: "qa-image", tagName: "img" }),
|
||||
[buildDomEditStylePatchOperation("opacity", "0.4")],
|
||||
);
|
||||
|
||||
expect(findByHfId(html, "qa-image").getAttribute("style")).toContain("opacity: 0.4");
|
||||
});
|
||||
|
||||
it("persists detached jsdom path offset operations", () => {
|
||||
const element = document.createElement("div");
|
||||
element.style.setProperty(STUDIO_OFFSET_X_PROP, "24px");
|
||||
|
||||
const html = patchAndExpectChange(
|
||||
indexHtml,
|
||||
clientTarget({ id: "qa-shape", hfId: "qa-shape", tagName: "div" }),
|
||||
buildPathOffsetPatches(element),
|
||||
);
|
||||
const shape = findByHfId(html, "qa-shape");
|
||||
|
||||
expect(shape.getAttribute("style")).toContain(`${STUDIO_OFFSET_X_PROP}: 24px`);
|
||||
expect(shape.getAttribute("style")).toContain("translate: var(--hf-studio-offset-x, 0px)");
|
||||
expect(shape.getAttribute(STUDIO_PATH_OFFSET_ATTR)).toBe("true");
|
||||
});
|
||||
|
||||
it("persists timeline data-start attribute operation", () => {
|
||||
const html = patchAndExpectChange(
|
||||
indexHtml,
|
||||
clientTarget({ id: "qa-zone-headline", hfId: "qa-zone-headline", tagName: "div" }),
|
||||
[{ type: "attribute", property: "start", value: "2.5" }],
|
||||
);
|
||||
|
||||
expect(findByHfId(html, "qa-zone-headline").getAttribute("data-start")).toBe("2.5");
|
||||
expect(countOccurrences(html, 'data-start="2.5"')).toBe(1);
|
||||
});
|
||||
|
||||
it("persists media volume data attribute operation", () => {
|
||||
const html = patchAndExpectChange(
|
||||
indexHtml,
|
||||
clientTarget({ id: "qa-video", hfId: "qa-video", tagName: "video" }),
|
||||
[{ type: "attribute", property: "volume", value: "0.75" }],
|
||||
);
|
||||
|
||||
expect(findByHfId(html, "qa-video").getAttribute("data-volume")).toBe("0.75");
|
||||
expect(html).not.toContain('data-volume="0.5"');
|
||||
});
|
||||
|
||||
it("returns matched false and unchanged html for a missing hfId target", () => {
|
||||
const result = patchElementInHtml(indexHtml, { hfId: "qa-does-not-exist" }, [
|
||||
buildDomEditStylePatchOperation("font-size", "64px"),
|
||||
]);
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.html).toBe(indexHtml);
|
||||
});
|
||||
|
||||
it("persists sub-composition child style operation inside a template", () => {
|
||||
const html = patchAndExpectChange(
|
||||
subHtml,
|
||||
clientTarget({ id: "qa-sub-title", hfId: "qa-sub-title", tagName: "h2" }),
|
||||
[buildDomEditStylePatchOperation("font-size", "50px")],
|
||||
);
|
||||
|
||||
expect(findByHfId(html, "qa-sub-title").getAttribute("style")).toContain("font-size: 50px");
|
||||
});
|
||||
|
||||
it("returns matched false for runtime-generated caption words absent from static source", () => {
|
||||
const result = patchElementInHtml(
|
||||
indexHtml,
|
||||
{ selector: "#qa-caption-host span", selectorIndex: 0 },
|
||||
[buildDomEditStylePatchOperation("color", "#ffffff")],
|
||||
);
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.html).toBe(indexHtml);
|
||||
});
|
||||
|
||||
it("fixes U4: child text-field style persists as an inline style on the correct child span", () => {
|
||||
const html = patchAndExpectChange(indexHtml, { hfId: "qa-multi" }, [
|
||||
buildDomEditStylePatchOperation("color", "#0000ff", {
|
||||
childSelector: ":scope > span",
|
||||
childIndex: 0,
|
||||
}),
|
||||
]);
|
||||
|
||||
const lineA = findElementInHtml(html, ".qa-line-a");
|
||||
const lineB = findElementInHtml(html, ".qa-line-b");
|
||||
expect(lineA.getAttribute("style")).toContain("color: #0000ff");
|
||||
expect(lineB.getAttribute("style")).toBeNull();
|
||||
expect(lineB.textContent).toBe("Second styled line");
|
||||
expect(html).not.toContain("<span");
|
||||
});
|
||||
|
||||
it("targets the second direct child when siblings share the same tag and class", () => {
|
||||
const source = `<div data-hf-id="dups"><span class="dup">First</span><span class="dup">Second</span></div>`;
|
||||
const html = patchAndExpectChange(source, { hfId: "dups" }, [
|
||||
buildDomEditStylePatchOperation("color", "#0000ff", {
|
||||
childSelector: ":scope > span",
|
||||
childIndex: 1,
|
||||
}),
|
||||
]);
|
||||
|
||||
const document = parseHtml(html);
|
||||
const spans = Array.from(document.querySelectorAll(".dup"));
|
||||
expect(spans[0]?.getAttribute("style")).toBeNull();
|
||||
expect(spans[1]?.getAttribute("style")).toContain("color: #0000ff");
|
||||
});
|
||||
|
||||
it("persists a child text-field content edit as plain text", () => {
|
||||
const value = "A < B & C";
|
||||
const html = patchAndExpectChange(indexHtml, { hfId: "qa-multi" }, [
|
||||
buildDomEditTextPatchOperation(value, {
|
||||
childSelector: ":scope > span",
|
||||
childIndex: 0,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(findElementInHtml(html, ".qa-line-a").textContent).toBe(value);
|
||||
expect(findElementInHtml(html, ".qa-line-b").textContent).toBe("Second styled line");
|
||||
expect(html).not.toContain("<span");
|
||||
});
|
||||
|
||||
it("uses same-tag source child indexes when a non-leaf sibling sits between fields", () => {
|
||||
const source = `<div data-hf-id="mixed"><span class="leaf-a">First</span><span class="wrapper"><b>Wrapper</b></span><span class="leaf-b">Second</span></div>`;
|
||||
const previewHost = document.createElement("div");
|
||||
previewHost.innerHTML = source;
|
||||
const previewTarget = previewHost.querySelector('[data-hf-id="mixed"]');
|
||||
if (!(previewTarget instanceof HTMLElement)) throw new Error("Expected preview target");
|
||||
|
||||
const originalFields = collectDomEditTextFields(previewTarget);
|
||||
const secondField = originalFields.find((field) => field.value === "Second");
|
||||
if (!secondField) throw new Error("Expected second text field");
|
||||
const nextFields = originalFields.map((field) =>
|
||||
field.key === secondField.key ? { ...field, value: "Second updated" } : field,
|
||||
);
|
||||
const operations = buildTextFieldChildOperations(originalFields, nextFields);
|
||||
if (!operations) throw new Error("Expected child operations");
|
||||
|
||||
const html = patchAndExpectChange(source, { hfId: "mixed" }, operations);
|
||||
|
||||
expect(findElementInHtml(html, ".leaf-a").textContent).toBe("First");
|
||||
expect(findElementInHtml(html, ".wrapper").textContent).toBe("Wrapper");
|
||||
expect(findElementInHtml(html, ".leaf-b").textContent).toBe("Second updated");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DomEditTextField } from "../components/editor/domEditing";
|
||||
import { buildTextFieldChildOperations } from "./domEditTextFieldCommitOps";
|
||||
|
||||
function textField(input: {
|
||||
key: string;
|
||||
value: string;
|
||||
tagName?: string;
|
||||
inlineStyles?: Record<string, string>;
|
||||
source?: DomEditTextField["source"];
|
||||
sourceChildIndex?: number;
|
||||
}): DomEditTextField {
|
||||
return {
|
||||
key: input.key,
|
||||
label: input.key,
|
||||
value: input.value,
|
||||
tagName: input.tagName ?? "span",
|
||||
attributes: [],
|
||||
inlineStyles: input.inlineStyles ?? {},
|
||||
computedStyles: {},
|
||||
source: input.source ?? "child",
|
||||
...(input.sourceChildIndex == null ? {} : { sourceChildIndex: input.sourceChildIndex }),
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildTextFieldChildOperations", () => {
|
||||
it("builds child-scoped text and style operations for changed child fields", () => {
|
||||
const originalFields = [
|
||||
textField({
|
||||
key: "first",
|
||||
value: "First",
|
||||
inlineStyles: { color: "red" },
|
||||
sourceChildIndex: 0,
|
||||
}),
|
||||
textField({
|
||||
key: "second",
|
||||
value: "Second",
|
||||
inlineStyles: { "font-size": "24px" },
|
||||
sourceChildIndex: 1,
|
||||
}),
|
||||
];
|
||||
const nextFields = [
|
||||
originalFields[0],
|
||||
textField({
|
||||
key: "second",
|
||||
value: "Second < &",
|
||||
inlineStyles: { "font-size": "24px", color: "#0000ff" },
|
||||
sourceChildIndex: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
expect(buildTextFieldChildOperations(originalFields, nextFields)).toEqual([
|
||||
{
|
||||
type: "text-content",
|
||||
property: "text",
|
||||
value: "Second < &",
|
||||
childSelector: ":scope > span",
|
||||
childIndex: 1,
|
||||
},
|
||||
{
|
||||
type: "inline-style",
|
||||
property: "color",
|
||||
value: "#0000ff",
|
||||
childSelector: ":scope > span",
|
||||
childIndex: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits null for a removed inline style", () => {
|
||||
const originalFields = [
|
||||
textField({
|
||||
key: "first",
|
||||
value: "First",
|
||||
inlineStyles: { color: "red" },
|
||||
sourceChildIndex: 0,
|
||||
}),
|
||||
];
|
||||
const nextFields = [
|
||||
textField({ key: "first", value: "First", inlineStyles: {}, sourceChildIndex: 0 }),
|
||||
];
|
||||
|
||||
expect(buildTextFieldChildOperations(originalFields, nextFields)).toEqual([
|
||||
{
|
||||
type: "inline-style",
|
||||
property: "color",
|
||||
value: null,
|
||||
childSelector: ":scope > span",
|
||||
childIndex: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns null for structural changes, reordered fields, and text nodes", () => {
|
||||
const originalFields = [
|
||||
textField({ key: "first", value: "First" }),
|
||||
textField({ key: "second", value: "Second" }),
|
||||
];
|
||||
|
||||
expect(buildTextFieldChildOperations(originalFields, [originalFields[0]])).toBeNull();
|
||||
expect(
|
||||
buildTextFieldChildOperations(originalFields, [originalFields[1], originalFields[0]]),
|
||||
).toBeNull();
|
||||
expect(
|
||||
buildTextFieldChildOperations(originalFields, [
|
||||
originalFields[0],
|
||||
textField({ key: "second", value: "Second", tagName: "#text", source: "text-node" }),
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
buildDomEditStylePatchOperation,
|
||||
buildDomEditTextPatchOperation,
|
||||
buildTextFieldChildLocator,
|
||||
type DomEditTextField,
|
||||
} from "../components/editor/domEditing";
|
||||
import type { PatchOperation } from "../utils/sourcePatcher";
|
||||
|
||||
function hasSameKeysInSamePositions(
|
||||
originalFields: DomEditTextField[],
|
||||
nextFields: DomEditTextField[],
|
||||
): boolean {
|
||||
return originalFields.every((field, index) => nextFields[index]?.key === field.key);
|
||||
}
|
||||
|
||||
function inlineStyleValue(styles: Record<string, string>, property: string): string | null {
|
||||
return Object.prototype.hasOwnProperty.call(styles, property) ? styles[property] : null;
|
||||
}
|
||||
|
||||
function inlineStyleProperties(
|
||||
originalStyles: Record<string, string>,
|
||||
nextStyles: Record<string, string>,
|
||||
): string[] {
|
||||
return Array.from(new Set([...Object.keys(originalStyles), ...Object.keys(nextStyles)]));
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function buildTextFieldChildOperations(
|
||||
originalFields: DomEditTextField[],
|
||||
nextFields: DomEditTextField[],
|
||||
): PatchOperation[] | null {
|
||||
if (originalFields.length !== nextFields.length) return null;
|
||||
if (!hasSameKeysInSamePositions(originalFields, nextFields)) return null;
|
||||
if (nextFields.some((field) => field.source === "text-node")) return null;
|
||||
if (nextFields.some((field) => field.source !== "child")) return null;
|
||||
if (originalFields.some((field) => field.source !== "child")) return null;
|
||||
|
||||
const originalByKey = new Map(originalFields.map((field) => [field.key, field]));
|
||||
const operations: PatchOperation[] = [];
|
||||
|
||||
for (const nextField of nextFields) {
|
||||
const originalField = originalByKey.get(nextField.key);
|
||||
const locator = buildTextFieldChildLocator(originalFields, nextField.key);
|
||||
if (!originalField || !locator) return null;
|
||||
|
||||
if (nextField.value !== originalField.value) {
|
||||
operations.push(buildDomEditTextPatchOperation(nextField.value, locator));
|
||||
}
|
||||
|
||||
for (const property of inlineStyleProperties(
|
||||
originalField.inlineStyles,
|
||||
nextField.inlineStyles,
|
||||
)) {
|
||||
const originalValue = inlineStyleValue(originalField.inlineStyles, property);
|
||||
const nextValue = inlineStyleValue(nextField.inlineStyles, property);
|
||||
if (nextValue !== originalValue) {
|
||||
operations.push(buildDomEditStylePatchOperation(property, nextValue, locator));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return operations;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PatchOperation } from "./sourcePatcher";
|
||||
import { shouldUseSdkCutover } from "./sdkCutoverEligibility";
|
||||
|
||||
const childStyleOp: PatchOperation = {
|
||||
type: "inline-style",
|
||||
property: "color",
|
||||
value: "blue",
|
||||
childSelector: ":scope > span",
|
||||
childIndex: 0,
|
||||
};
|
||||
|
||||
describe("shouldUseSdkCutover child-scoped operations", () => {
|
||||
it("declines child-scoped operations because SDK patch ops target only the parent hfId", () => {
|
||||
expect(shouldUseSdkCutover(true, true, "hf-parent", [childStyleOp])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -61,19 +61,23 @@ function hasUnsafeHtmlAttributeOp(ops: PatchOperation[]): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function hasChildScopedOp(ops: PatchOperation[]): boolean {
|
||||
return ops.some((op) => op.childSelector !== undefined);
|
||||
}
|
||||
|
||||
function hasTextContentOp(ops: PatchOperation[]): boolean {
|
||||
return ops.some((op) => op.type === "text-content");
|
||||
}
|
||||
|
||||
function targetChildren(target: unknown): unknown[] | null {
|
||||
if (!target || typeof target !== "object" || !("children" in target)) return null;
|
||||
const children = (target as { children?: unknown }).children;
|
||||
const children = target.children;
|
||||
return Array.isArray(children) ? children : null;
|
||||
}
|
||||
|
||||
function elementTag(element: unknown): string | null {
|
||||
if (!element || typeof element !== "object" || !("tag" in element)) return null;
|
||||
const tag = (element as { tag?: unknown }).tag;
|
||||
const tag = element.tag;
|
||||
return typeof tag === "string" ? tag.toLowerCase() : null;
|
||||
}
|
||||
|
||||
@@ -110,6 +114,8 @@ export function shouldUseSdkCutover(
|
||||
!!hfId &&
|
||||
ops.length > 0 &&
|
||||
ops.every((o) => CUTOVER_OP_TYPES.has(o.type)) &&
|
||||
// SDK edit ops target only the element hfId; child-scoped patch ops need the server path.
|
||||
!hasChildScopedOp(ops) &&
|
||||
!ops.some(mapsToReservedAttr) &&
|
||||
!hasUnsafeHtmlAttributeOp(ops)
|
||||
);
|
||||
|
||||
@@ -90,6 +90,8 @@ export interface PatchOperation {
|
||||
type: "inline-style" | "attribute" | "text-content" | "html-attribute";
|
||||
property: string;
|
||||
value: string | null;
|
||||
childSelector?: string;
|
||||
childIndex?: number;
|
||||
}
|
||||
|
||||
// Runtime validation for hfId lives in findTagByTarget → execDataAttrPattern (CSS attr-value
|
||||
|
||||
@@ -174,6 +174,10 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@hyperframes/player": resolve(__dirname, "../player/src/hyperframes-player.ts"),
|
||||
"@hyperframes/studio-server/source-mutation": resolve(
|
||||
__dirname,
|
||||
"../studio-server/src/helpers/sourceMutation.ts",
|
||||
),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
||||
Reference in New Issue
Block a user