mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 10:46:06 +00:00
feat(studio): element groups — source mutations (#1758)
Wrap/unwrap source mutations (group geometry, the wrap-elements / unwrap-elements routes) that the studio group feature is built on. Studio UI lands in the next PR.
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
import { parseHTML } from "linkedom";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
removeElementFromHtml,
|
||||
patchElementInHtml,
|
||||
splitElementInHtml,
|
||||
probeElementInSource,
|
||||
wrapElementsInHtml,
|
||||
unwrapElementsFromHtml,
|
||||
} from "./sourceMutation.js";
|
||||
|
||||
describe("removeElementFromHtml", () => {
|
||||
@@ -539,3 +542,147 @@ describe("splitElementInHtml", () => {
|
||||
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