Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions packages/studio-server/src/helpers/sourceMutation.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
207 changes: 202 additions & 5 deletions packages/studio-server/src/helpers/sourceMutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export interface PatchOperation {
}

// fallow-ignore-next-line complexity
function patchStyleAttrString(style: string, property: string, value: string | null): string {
function parseStyleDecls(style: string): { props: Map<string, string>; order: string[] } {
const props = new Map<string, string>();
const order: string[] = [];
// Tokenize declarations robustly: values can contain ';' inside quoted strings
Expand Down Expand Up @@ -171,6 +171,18 @@ function patchStyleAttrString(style: string, property: string, value: string | n
if (!props.has(key)) order.push(key);
props.set(key, val);
}
return { props, order };
}

function serializeStyleDecls(props: Map<string, string>, order: string[]): string {
return order
.map((k) => `${k}: ${props.get(k) ?? ""}`)
.filter((d) => d.trim())
.join("; ");
}

function patchStyleAttrString(style: string, property: string, value: string | null): string {
const { props, order } = parseStyleDecls(style);
if (value === null) {
props.delete(property);
const idx = order.indexOf(property);
Expand All @@ -179,10 +191,7 @@ function patchStyleAttrString(style: string, property: string, value: string | n
if (!props.has(property)) order.push(property);
props.set(property, value);
}
return order
.map((k) => `${k}: ${props.get(k) ?? ""}`)
.filter((d) => d.trim())
.join("; ");
return serializeStyleDecls(props, order);
}

// fallow-ignore-next-line complexity
Expand Down Expand Up @@ -376,3 +385,191 @@ export function splitElementInHtml(
newId,
};
}

// --- Element grouping -------------------------------------------------------
// A group is a real `<div data-hf-group="…">` wrapping its members in the DOM.
// Wrapping rebases each member's left/top so its absolute position is unchanged:
// the wrapper sits at the selection bbox top-left, and each child's new left/top
// is its old left/top minus the wrapper origin (computed client-side, where live
// layout is available, and passed in via `rebases`). GSAP x/y, CSS translate and
// --hf-studio-offset vars are deltas relative to flow position and stay untouched.

export interface WrapElementsResult {
html: string;
matched: boolean;
groupId: string | null;
error?: string;
}

export interface UnwrapElementsResult {
html: string;
unwrapped: boolean;
}

export interface ElementRebase {
target: SourceMutationTarget;
left: number;
top: number;
}

function getInlineStylePx(el: Element, property: string): number {
const style = (isHTMLElement(el) ? el.getAttribute("style") : null) ?? "";
const { props } = parseStyleDecls(style);
const raw = props.get(property);
if (!raw) return 0;
const n = parseFloat(raw);
return Number.isFinite(n) ? n : 0;
}

function setInlineLeftTop(el: HTMLElement, left: number, top: number): void {
let style = el.getAttribute("style") ?? "";
style = patchStyleAttrString(style, "left", `${left}px`);
style = patchStyleAttrString(style, "top", `${top}px`);
el.setAttribute("style", style);
}

// Slug the group name ("Group 1" → "group-1") into a unique, valid element id.
function uniqueGroupDomId(document: Document, groupId: string): string {
const base =
groupId
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || "group";
let id = base;
let n = 2;
while (document.getElementById(id)) {
id = `${base}-${n}`;
n += 1;
}
return id;
}

// fallow-ignore-next-line complexity
export function wrapElementsInHtml(
source: string,
targets: SourceMutationTarget[],
groupId: string,
bbox: { left: number; top: number; width: number; height: number },
rebases: ElementRebase[],
): WrapElementsResult {
const { document, wrappedFragment } = parseSourceDocument(source);
if (targets.length === 0) {
return { html: source, matched: false, groupId: null, error: "no targets" };
}

// Resolve + dedupe by element ref (two targets may point at the same node).
const els: HTMLElement[] = [];
const seen = new Set<Element>();
for (const target of targets) {
const el = findTargetElement(document, target);
if (!el || !isHTMLElement(el) || seen.has(el)) continue;
seen.add(el);
els.push(el);
}
if (els.length === 0) {
return { html: source, matched: false, groupId: null, error: "no targets matched" };
}

// P1: require a single common parent (LCA multi-parent wrapping is P2).
const parent = els[0]?.parentElement;
if (!parent || els.some((el) => el.parentElement !== parent)) {
return {
html: source,
matched: false,
groupId: null,
error: "grouped elements must share a single parent",
};
}

// Order members by their position in the parent (= z-order / stacking order).
const memberSet = new Set<Element>(els);
const ordered = Array.from(parent.children).filter((c): c is HTMLElement => memberSet.has(c));

// Map each member to its rebased left/top (resolved against the same document).
const rebaseByEl = new Map<Element, { left: number; top: number }>();
for (const rebase of rebases) {
const el = findTargetElement(document, rebase.target);
if (el) rebaseByEl.set(el, { left: rebase.left, top: rebase.top });
}

const wrapper = document.createElement("div");
wrapper.setAttribute("data-hf-group", groupId);
// A real `id` (slug of the group name) makes the wrapper a first-class node in the
// clip manifest / timeline parent-map (both keyed by id) and a clean GSAP target —
// without it the wrapper is invisible to the timeline and breaks child enumeration.
wrapper.setAttribute("id", uniqueGroupDomId(document, groupId));
// Adopt the topmost member's stacking level. A group is one stacking unit, so a
// non-member interleaved between two selected members can't stay "between" them
// once they unify. Matching Figma/Sketch, the group lifts to the topmost selected
// layer: the wrapper goes at the LAST member's slot and carries the max member
// z-index — so an interleaved non-member falls below the group instead of hoisting
// above it, and explicit member z-indexes are honored.
const memberZIndexes = ordered
.map((el) =>
Number.parseInt(
parseStyleDecls(el.getAttribute("style") ?? "").props.get("z-index") ?? "",
10,
),
)
.filter((z) => Number.isFinite(z));
const maxZ = memberZIndexes.length > 0 ? Math.max(...memberZIndexes) : null;
wrapper.setAttribute(
"style",
`position: absolute; left: ${bbox.left}px; top: ${bbox.top}px; width: ${bbox.width}px; height: ${bbox.height}px` +
(maxZ !== null ? `; z-index: ${maxZ}` : ""),
);

// Insert the wrapper at the topmost member's slot, then move members into it.
parent.insertBefore(wrapper, ordered[ordered.length - 1] ?? null);
for (const el of ordered) {
const rebase = rebaseByEl.get(el);
if (rebase) setInlineLeftTop(el, rebase.left, rebase.top);
wrapper.appendChild(el); // appendChild moves the node, preserving order
}

return {
html: wrappedFragment ? document.body.innerHTML || "" : document.toString(),
matched: true,
groupId,
};
}

export function unwrapElementsFromHtml(
source: string,
groupTarget: SourceMutationTarget,
): UnwrapElementsResult {
const { document, wrappedFragment } = parseSourceDocument(source);
const group = findTargetElement(document, groupTarget);
if (!group || !isHTMLElement(group)) return { html: source, unwrapped: false };
// Shape guard mirroring the wrap-side contract: only ever dissolve an actual
// group wrapper. A stale/desynced selection that resolves to a plain <div>
// would otherwise be unwrapped — rebasing its children by the parent's origin
// (silent corruption). Wrap enforces invariants; unwrap must too.
if (!group.hasAttribute("data-hf-group")) return { html: source, unwrapped: false };

const parent = group.parentElement;
if (!parent) return { html: source, unwrapped: false };

// Undo the rebase: child absolute position = child (rebased) + wrapper origin.
const wLeft = getInlineStylePx(group, "left");
const wTop = getInlineStylePx(group, "top");

// Move children back to the wrapper's slot, preserving order.
for (const child of Array.from(group.children)) {
if (isHTMLElement(child)) {
setInlineLeftTop(
child,
getInlineStylePx(child, "left") + wLeft,
getInlineStylePx(child, "top") + wTop,
);
}
parent.insertBefore(child, group);
}
group.remove();

return {
html: wrappedFragment ? document.body.innerHTML || "" : document.toString(),
unwrapped: true,
};
}
Loading
Loading