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:
Miguel Ángel
2026-06-27 11:18:36 -04:00
committed by GitHub
parent b6146f8e1b
commit 4c461b4a55
4 changed files with 636 additions and 5 deletions
@@ -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);
});
});