mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-09 03:16:38 +00:00
fix(studio-server): child-scoped patch operations with batch abort (#1908)
* 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)
This commit is contained in:
@@ -3,10 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
removeElementFromHtml,
|
||||
patchElementInHtml,
|
||||
splitElementInHtml,
|
||||
probeElementInSource,
|
||||
wrapElementsInHtml,
|
||||
unwrapElementsFromHtml,
|
||||
} from "./sourceMutation.js";
|
||||
|
||||
describe("removeElementFromHtml", () => {
|
||||
@@ -130,6 +127,69 @@ describe("patchElementInHtml", () => {
|
||||
expect(result).not.toContain("Hello World");
|
||||
});
|
||||
|
||||
it("applies child-scoped inline style without changing the parent style", () => {
|
||||
const source = `<div data-hf-id="parent" style="color: red"><span class="line">A</span><span class="line">B</span></div>`;
|
||||
const { html: result, matched } = patchElementInHtml(source, { hfId: "parent" }, [
|
||||
{
|
||||
type: "inline-style",
|
||||
property: "color",
|
||||
value: "blue",
|
||||
childSelector: ":scope > span",
|
||||
childIndex: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(matched).toBe(true);
|
||||
const { document } = parseHTML(result);
|
||||
const parent = document.querySelector('[data-hf-id="parent"]');
|
||||
const children = Array.from(document.querySelectorAll(".line"));
|
||||
expect(parent?.getAttribute("style")).toContain("color: red");
|
||||
expect(children[0]?.getAttribute("style")).toBeNull();
|
||||
expect(children[1]?.getAttribute("style")).toContain("color: blue");
|
||||
});
|
||||
|
||||
it("applies child-scoped text content to the child only", () => {
|
||||
const source = `<div data-hf-id="parent"><span class="line">A</span><span class="line">B</span></div>`;
|
||||
const { html: result, matched } = patchElementInHtml(source, { hfId: "parent" }, [
|
||||
{
|
||||
type: "text-content",
|
||||
property: "text",
|
||||
value: "B < C & D",
|
||||
childSelector: ":scope > span",
|
||||
childIndex: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(matched).toBe(true);
|
||||
const { document } = parseHTML(result);
|
||||
const children = Array.from(document.querySelectorAll(".line"));
|
||||
expect(children[0]?.textContent).toBe("A");
|
||||
expect(children[1]?.textContent).toBe("B < C & D");
|
||||
});
|
||||
|
||||
it("rejects the whole batch when a child-scoped operation cannot resolve", () => {
|
||||
const source = `<div data-hf-id="parent"><span class="line">A</span><span class="line">B</span></div>`;
|
||||
const result = patchElementInHtml(source, { hfId: "parent" }, [
|
||||
{
|
||||
type: "inline-style",
|
||||
property: "color",
|
||||
value: "blue",
|
||||
childSelector: ":scope > span",
|
||||
childIndex: 0,
|
||||
},
|
||||
{
|
||||
type: "text-content",
|
||||
property: "text",
|
||||
value: "missing",
|
||||
childSelector: ":scope > strong",
|
||||
childIndex: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.html).toBe(source);
|
||||
});
|
||||
|
||||
it("applies multiple operations in one call", () => {
|
||||
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
||||
{ type: "inline-style", property: "color", value: "blue" },
|
||||
@@ -459,230 +519,3 @@ describe("T7 — data-hf-id targeting (spec for R1)", () => {
|
||||
expect(html).toContain('data-hf-id="hf-a1b2"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitElementInHtml — hfId clone isolation", () => {
|
||||
it("does not copy data-hf-id to the cloned second half", () => {
|
||||
const source = `<html><body><div data-composition-id="root"><div id="clip1" class="clip" data-start="0" data-duration="10" data-hf-id="hf-abc123"></div></div></body></html>`;
|
||||
const { html, matched } = splitElementInHtml(source, { id: "clip1" }, 5, "clip2");
|
||||
|
||||
expect(matched).toBe(true);
|
||||
const occurrences = (html.match(/data-hf-id="hf-abc123"/g) ?? []).length;
|
||||
expect(occurrences).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitElementInHtml", () => {
|
||||
const source = `<!DOCTYPE html><html><head><style>#box { position: absolute; top: 100px; background: red; }</style></head><body><div data-composition-id="root"><div id="box" class="clip" data-start="1" data-duration="6">Hello</div></div></body></html>`;
|
||||
|
||||
it("splits element at the given time", () => {
|
||||
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split");
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.html).toContain('data-duration="2"');
|
||||
expect(result.html).toContain('id="box-split"');
|
||||
expect(result.html).toContain('data-start="3"');
|
||||
expect(result.html).toContain('data-duration="4"');
|
||||
});
|
||||
|
||||
it("duplicates CSS rules for the new element ID", () => {
|
||||
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split");
|
||||
expect(result.html).toContain("#box-split");
|
||||
expect(result.html).toContain("background: red");
|
||||
const cssMatches = result.html.match(/#box-split\s*\{/g);
|
||||
expect(cssMatches?.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("deduplicates IDs when the requested newId already exists", () => {
|
||||
const withExisting = source.replace(
|
||||
"</div></div>",
|
||||
'</div><div id="box-split" data-start="5" data-duration="1">Existing</div></div>',
|
||||
);
|
||||
const result = splitElementInHtml(withExisting, { id: "box" }, 3, "box-split");
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.html).toContain('id="box-split-2"');
|
||||
});
|
||||
|
||||
it("keeps clip class on the cloned element", () => {
|
||||
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split");
|
||||
expect(result.html).toMatch(/id="box-split"[^>]*class="clip"/);
|
||||
});
|
||||
|
||||
it("returns matched false for out-of-range split time", () => {
|
||||
expect(splitElementInHtml(source, { id: "box" }, 0.5, "box-split").matched).toBe(false);
|
||||
expect(splitElementInHtml(source, { id: "box" }, 7.5, "box-split").matched).toBe(false);
|
||||
});
|
||||
|
||||
it("splits a GSAP element with no authored timing using fallback timing", () => {
|
||||
// #title has no data-start/data-duration (GSAP-driven); the store supplies the range.
|
||||
const gsapSource = `<html><body><div data-composition-id="root"><h1 id="title" class="title">Hi</h1></div></body></html>`;
|
||||
const result = splitElementInHtml(gsapSource, { id: "title" }, 2, "title-split", {
|
||||
start: 0,
|
||||
duration: 6,
|
||||
});
|
||||
expect(result.matched).toBe(true);
|
||||
// original windowed to [0, 2], clone to [2, 4] (attribute order is serializer-defined)
|
||||
const original = result.html.match(/<h1[^>]*\bid="title"[^>]*>/)![0];
|
||||
expect(original).toContain('data-start="0"');
|
||||
expect(original).toContain('data-duration="2"');
|
||||
const clone = result.html.match(/<h1[^>]*\bid="title-split"[^>]*>/)![0];
|
||||
expect(clone).toContain('data-start="2"');
|
||||
expect(clone).toContain('data-duration="4"');
|
||||
});
|
||||
|
||||
it("still rejects a no-timing element when no fallback timing is given", () => {
|
||||
const gsapSource = `<html><body><div data-composition-id="root"><h1 id="title">Hi</h1></div></body></html>`;
|
||||
expect(splitElementInHtml(gsapSource, { id: "title" }, 2, "title-split").matched).toBe(false);
|
||||
});
|
||||
|
||||
it("adjusts media playback-start for the second half", () => {
|
||||
const mediaSource = source.replace(
|
||||
'id="box" class="clip" data-start="1" data-duration="6"',
|
||||
'id="box" class="clip" data-start="1" data-duration="6" data-playback-start="0"',
|
||||
);
|
||||
const result = splitElementInHtml(mediaSource, { id: "box" }, 3, "box-split");
|
||||
expect(result.html).toMatch(/id="box-split"[^>]*data-playback-start="2"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapElementsInHtml / unwrapElementsFromHtml", () => {
|
||||
// Three positioning flavours the rebase must leave visually identical:
|
||||
// plain inline left/top, a GSAP transform delta, and a --hf-studio-offset var.
|
||||
const FIXTURE = `<!doctype html><html><body><div data-composition-id="main">
|
||||
<div id="title" class="clip" style="position: absolute; left: 260px; top: 100px">Title</div>
|
||||
<div id="logo" class="clip" style="position: absolute; left: 300px; top: 200px; transform: translate(10px, 5px)">Logo</div>
|
||||
<div id="badge" class="clip" style="position: absolute; left: 400px; top: 50px; --hf-studio-offset: 12px">Badge</div>
|
||||
<div id="outside" class="clip" style="position: absolute; left: 10px; top: 10px">Outside</div>
|
||||
</div></body></html>`;
|
||||
|
||||
// bbox top-left = (min left, min top) over the three members.
|
||||
const BBOX = { left: 260, top: 50, width: 300, height: 300 };
|
||||
const REBASES = [
|
||||
{ target: { id: "title" }, left: 0, top: 50 }, // 260-260, 100-50
|
||||
{ target: { id: "logo" }, left: 40, top: 150 }, // 300-260, 200-50
|
||||
{ target: { id: "badge" }, left: 140, top: 0 }, // 400-260, 50-50
|
||||
];
|
||||
const TARGETS = [{ id: "title" }, { id: "logo" }, { id: "badge" }];
|
||||
|
||||
function leftTop(el: Element): { left: number; top: number } {
|
||||
const style = el.getAttribute("style") ?? "";
|
||||
const left = parseFloat(/(?:^|;)\s*left\s*:\s*([\d.]+)px/.exec(style)?.[1] ?? "NaN");
|
||||
const top = parseFloat(/(?:^|;)\s*top\s*:\s*([\d.]+)px/.exec(style)?.[1] ?? "NaN");
|
||||
return { left, top };
|
||||
}
|
||||
|
||||
it("wraps members in a data-hf-group div, preserving order and rebasing left/top", () => {
|
||||
const { html, matched, groupId } = wrapElementsInHtml(
|
||||
FIXTURE,
|
||||
TARGETS,
|
||||
"Group 1",
|
||||
BBOX,
|
||||
REBASES,
|
||||
);
|
||||
expect(matched).toBe(true);
|
||||
expect(groupId).toBe("Group 1");
|
||||
|
||||
const { document } = parseHTML(html);
|
||||
const group = document.querySelector('[data-hf-group="Group 1"]')!;
|
||||
expect(group).not.toBeNull();
|
||||
|
||||
// Wrapper sits at the bbox top-left.
|
||||
expect(leftTop(group)).toEqual({ left: 260, top: 50 });
|
||||
|
||||
// Members are inside the wrapper, in original DOM order (= z-order).
|
||||
const childIds = Array.from(group.children).map((c) => c.id);
|
||||
expect(childIds).toEqual(["title", "logo", "badge"]);
|
||||
|
||||
// Non-member stays outside.
|
||||
expect(document.querySelector("#outside")!.parentElement).toBe(
|
||||
document.querySelector('[data-composition-id="main"]'),
|
||||
);
|
||||
|
||||
// Each member rebased; transform + offset var untouched.
|
||||
expect(leftTop(document.querySelector("#title")!)).toEqual({ left: 0, top: 50 });
|
||||
expect(leftTop(document.querySelector("#logo")!)).toEqual({ left: 40, top: 150 });
|
||||
expect(document.querySelector("#logo")!.getAttribute("style")).toContain(
|
||||
"transform: translate(10px, 5px)",
|
||||
);
|
||||
expect(leftTop(document.querySelector("#badge")!)).toEqual({ left: 140, top: 0 });
|
||||
expect(document.querySelector("#badge")!.getAttribute("style")).toContain(
|
||||
"--hf-studio-offset: 12px",
|
||||
);
|
||||
});
|
||||
|
||||
it("round-trips: unwrap restores original structure and coordinates", () => {
|
||||
const wrapped = wrapElementsInHtml(FIXTURE, TARGETS, "Group 1", BBOX, REBASES).html;
|
||||
const { html, unwrapped } = unwrapElementsFromHtml(wrapped, {
|
||||
selector: '[data-hf-group="Group 1"]',
|
||||
});
|
||||
expect(unwrapped).toBe(true);
|
||||
|
||||
const { document } = parseHTML(html);
|
||||
expect(document.querySelector("[data-hf-group]")).toBeNull();
|
||||
|
||||
const main = document.querySelector('[data-composition-id="main"]')!;
|
||||
// Members back in the parent, original order relative to the outside sibling.
|
||||
expect(Array.from(main.children).map((c) => c.id)).toEqual([
|
||||
"title",
|
||||
"logo",
|
||||
"badge",
|
||||
"outside",
|
||||
]);
|
||||
|
||||
// Coordinates restored; transform + offset var intact.
|
||||
expect(leftTop(document.querySelector("#title")!)).toEqual({ left: 260, top: 100 });
|
||||
expect(leftTop(document.querySelector("#logo")!)).toEqual({ left: 300, top: 200 });
|
||||
expect(document.querySelector("#logo")!.getAttribute("style")).toContain(
|
||||
"transform: translate(10px, 5px)",
|
||||
);
|
||||
expect(leftTop(document.querySelector("#badge")!)).toEqual({ left: 400, top: 50 });
|
||||
expect(document.querySelector("#badge")!.getAttribute("style")).toContain(
|
||||
"--hf-studio-offset: 12px",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects members that do not share a single parent", () => {
|
||||
const split = `<!doctype html><html><body><div data-composition-id="main"><div id="a" style="position:absolute;left:0;top:0"></div><section><div id="b" style="position:absolute;left:0;top:0"></div></section></div></body></html>`;
|
||||
const result = wrapElementsInHtml(split, [{ id: "a" }, { id: "b" }], "Group 1", BBOX, [
|
||||
{ target: { id: "a" }, left: 0, top: 0 },
|
||||
{ target: { id: "b" }, left: 0, top: 0 },
|
||||
]);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.error).toMatch(/single parent/);
|
||||
expect(result.html).toBe(split);
|
||||
});
|
||||
|
||||
it("lifts the group to the topmost member's slot so an interleaved non-member falls below it", () => {
|
||||
// [low, middle (non-member), high]; group {low, high}. The group adopts the
|
||||
// topmost member's stacking, so `middle` ends up BELOW the wrapper (not hoisted
|
||||
// above it), and the wrapper carries the max member z-index.
|
||||
const fixture = `<!doctype html><html><body><div data-composition-id="main"><div id="low" style="position:absolute;left:0;top:0;z-index:2"></div><div id="middle" style="position:absolute;left:0;top:0;z-index:3"></div><div id="high" style="position:absolute;left:0;top:0;z-index:4"></div></div></body></html>`;
|
||||
const { html, matched } = wrapElementsInHtml(
|
||||
fixture,
|
||||
[{ id: "low" }, { id: "high" }],
|
||||
"Group 1",
|
||||
{ left: 0, top: 0, width: 10, height: 10 },
|
||||
[
|
||||
{ target: { id: "low" }, left: 0, top: 0 },
|
||||
{ target: { id: "high" }, left: 0, top: 0 },
|
||||
],
|
||||
);
|
||||
expect(matched).toBe(true);
|
||||
const { document } = parseHTML(html);
|
||||
const parent = document.querySelector('[data-composition-id="main"]')!;
|
||||
const group = document.querySelector('[data-hf-group="Group 1"]')!;
|
||||
expect(Array.from(group.children).map((c) => c.id)).toEqual(["low", "high"]);
|
||||
// Non-member sits BEFORE (below) the group, not after (above) it.
|
||||
const topChildren = Array.from(parent.children).map(
|
||||
(c) => c.getAttribute("data-hf-group") ?? c.id,
|
||||
);
|
||||
expect(topChildren).toEqual(["middle", "Group 1"]);
|
||||
// Wrapper adopts the topmost member's z-index (max of 2 and 4).
|
||||
expect(group.getAttribute("style")).toMatch(/z-index:\s*4/);
|
||||
});
|
||||
|
||||
it("refuses to unwrap an element without data-hf-group (no silent corruption)", () => {
|
||||
const html = `<!doctype html><html><body><div data-composition-id="main"><div id="plain" style="position:absolute;left:0;top:0"><span id="kid"></span></div></div></body></html>`;
|
||||
const result = unwrapElementsFromHtml(html, { id: "plain" });
|
||||
expect(result.unwrapped).toBe(false);
|
||||
expect(result.html).toBe(html);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user