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);
});
});
@@ -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
@@ -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);
@@ -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
@@ -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,
};
}
@@ -56,8 +56,11 @@ import {
patchElementInHtml,
probeElementInSource,
splitElementInHtml,
wrapElementsInHtml,
unwrapElementsFromHtml,
isHTMLElement,
type PatchOperation,
type ElementRebase,
} from "../helpers/sourceMutation.js";
import { parseHTML } from "linkedom";
@@ -1560,6 +1563,102 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
});
});
api.post("/projects/:id/file-mutations/wrap-elements/*", async (c) => {
const ctx = await resolveFileMutationContext(c, adapter, "wrap-elements");
if ("error" in ctx) return ctx.error;
const body = (await c.req.json().catch(() => null)) as {
targets?: MutationTarget[];
groupId?: string;
bbox?: { left?: number; top?: number; width?: number; height?: number };
rebases?: ElementRebase[];
} | null;
if (!Array.isArray(body?.targets) || body.targets.length === 0 || !body.groupId) {
return c.json({ error: "targets and groupId required" }, 400);
}
// left/top/width/height are interpolated into inline style strings; reject
// anything non-numeric so a crafted value can't inject extra declarations.
const bbox = body.bbox ?? {};
const bboxNums = [bbox.left, bbox.top, bbox.width, bbox.height];
const rebases = body.rebases ?? [];
const allNumeric =
bboxNums.every((n) => typeof n === "number" && Number.isFinite(n)) &&
rebases.every(
(r) =>
typeof r?.left === "number" &&
Number.isFinite(r.left) &&
typeof r?.top === "number" &&
Number.isFinite(r.top),
);
if (!allNumeric) {
return c.json({ error: "bbox and rebase coordinates must be finite numbers" }, 400);
}
let originalContent: string;
try {
originalContent = readFileSync(ctx.absPath, "utf-8");
} catch {
return c.json({ error: "not found" }, 404);
}
const result = wrapElementsInHtml(
originalContent,
body.targets,
body.groupId,
{ left: bbox.left!, top: bbox.top!, width: bbox.width!, height: bbox.height! },
rebases,
);
if (!result.matched) {
return c.json(
{
ok: false,
changed: false,
content: originalContent,
path: ctx.filePath,
error: result.error,
},
result.error === "grouped elements must share a single parent" ? 422 : 400,
);
}
const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath);
if (backup.error) console.warn(`Failed to create backup for ${ctx.filePath}: ${backup.error}`);
writeFileSync(ctx.absPath, result.html, "utf-8");
return c.json({
ok: true,
changed: true,
groupId: result.groupId,
content: result.html,
path: ctx.filePath,
backupPath: backupPathForResponse(ctx.project.dir, backup.backupPath),
});
});
api.post("/projects/:id/file-mutations/unwrap-elements/*", async (c) => {
const ctx = await resolveFileMutationContext(c, adapter, "unwrap-elements");
if ("error" in ctx) return ctx.error;
const parsed = await parseMutationBody<{ target?: MutationTarget }>(c);
if ("error" in parsed) return parsed.error;
let originalContent: string;
try {
originalContent = readFileSync(ctx.absPath, "utf-8");
} catch {
return c.json({ error: "not found" }, 404);
}
const result = unwrapElementsFromHtml(originalContent, parsed.target);
if (!result.unwrapped) {
return c.json({ ok: false, changed: false, content: originalContent, path: ctx.filePath });
}
return writeIfChanged(
c,
ctx.project.dir,
ctx.filePath,
ctx.absPath,
originalContent,
result.html,
);
});
api.post("/projects/:id/file-mutations/probe-element/*", async (c) => {
const ctx = await resolveFileMutationContext(c, adapter, "probe-element");
if ("error" in ctx) return ctx.error;
@@ -0,0 +1,188 @@
import { useCallback } from "react";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import { createStudioSaveHttpError } from "../utils/studioSaveDiagnostics";
import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing";
import type { EditHistoryKind } from "../utils/editHistory";
interface RecordEditInput {
label: string;
kind: EditHistoryKind;
coalesceKey?: string;
files: Record<string, { before: string; after: string }>;
}
interface UseGroupCommitsParams {
activeCompPath: string | null;
showToast: (message: string, tone?: "error" | "info") => void;
writeProjectFile: (path: string, content: string) => Promise<void>;
domEditSaveTimestampRef: React.MutableRefObject<number>;
editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
projectIdRef: React.MutableRefObject<string | null>;
reloadPreview: () => void;
clearDomSelection: () => void;
/** Resync the SDK session after a server-side write (the wrapper/unwrap changes
* structure the in-memory doc doesn't know about). */
forceReloadSdkSession?: () => void;
}
interface PatchTarget {
id?: string | null;
hfId?: string;
selector?: string;
selectorIndex?: number;
}
interface GroupGeometry {
bbox: { left: number; top: number; width: number; height: number };
targets: PatchTarget[];
rebases: Array<{ target: PatchTarget; left: number; top: number }>;
}
// Wrapper sits at the members' bounding box top-left; each member is rebased so
// its absolute position is unchanged. offsetLeft/Top are layout coordinates in
// composition space (transforms excluded), exactly the space the rebase formula
// `left_new = left_old - W.left` operates in — GSAP x/y and offset vars are
// transform deltas and stay correct without adjustment.
function computeGroupGeometry(members: DomEditSelection[]): GroupGeometry {
const boxes = members.map((m) => ({
target: buildDomEditPatchTarget(m),
left: m.element.offsetLeft,
top: m.element.offsetTop,
right: m.element.offsetLeft + m.element.offsetWidth,
bottom: m.element.offsetTop + m.element.offsetHeight,
}));
const left = Math.min(...boxes.map((b) => b.left));
const top = Math.min(...boxes.map((b) => b.top));
const width = Math.max(...boxes.map((b) => b.right)) - left;
const height = Math.max(...boxes.map((b) => b.bottom)) - top;
return {
bbox: { left, top, width, height },
targets: boxes.map((b) => b.target),
rebases: boxes.map((b) => ({ target: b.target, left: b.left - left, top: b.top - top })),
};
}
// Shared read → mutate-route → save-with-history → reload pipeline for both
// wrap (group) and unwrap (ungroup). Mirrors the structural-mutation pattern in
// useElementLifecycleOps (delete). Returns the route's JSON, or throws.
async function commitStructuralMutation(
pid: string,
targetPath: string,
route: "wrap-elements" | "unwrap-elements",
body: unknown,
label: string,
deps: Pick<
UseGroupCommitsParams,
| "writeProjectFile"
| "editHistory"
| "domEditSaveTimestampRef"
| "clearDomSelection"
| "forceReloadSdkSession"
| "reloadPreview"
>,
): Promise<{ content?: string; groupId?: string }> {
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
if (!response.ok) {
throw await createStudioSaveHttpError(response, `Failed to read ${targetPath}`);
}
const data = (await response.json()) as { content?: string };
const originalContent = data.content;
if (typeof originalContent !== "string") {
throw new Error(`Missing file contents for ${targetPath}`);
}
deps.domEditSaveTimestampRef.current = Date.now();
const mutateResponse = await fetch(
`/api/projects/${pid}/file-mutations/${route}/${encodeURIComponent(targetPath)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!mutateResponse.ok) {
const errBody = (await mutateResponse.json().catch(() => null)) as { error?: string } | null;
throw new Error(errBody?.error ?? `Failed to ${label.toLowerCase()} in ${targetPath}`);
}
const mutateData = (await mutateResponse.json()) as { content?: string; groupId?: string };
const patchedContent =
typeof mutateData.content === "string" ? mutateData.content : originalContent;
await saveProjectFilesWithHistory({
projectId: pid,
label,
kind: "manual",
files: { [targetPath]: patchedContent },
readFile: async () => originalContent,
writeFile: deps.writeProjectFile,
recordEdit: deps.editHistory.recordEdit,
});
deps.clearDomSelection();
deps.forceReloadSdkSession?.();
deps.reloadPreview();
return mutateData;
}
export function useGroupCommits(params: UseGroupCommitsParams) {
const { activeCompPath, showToast, projectIdRef } = params;
const groupSelection = useCallback(
async (members: DomEditSelection[]): Promise<string | null> => {
const pid = projectIdRef.current;
if (!pid || members.length === 0) return null;
// All members must live in the same source file — the wrapper is one node
// in one document. (Cross-file grouping is out of scope.)
const targetPath = members[0].sourceFile || activeCompPath || "index.html";
if (members.some((m) => (m.sourceFile || activeCompPath || "index.html") !== targetPath)) {
showToast("Can't group elements from different files", "error");
return null;
}
// Auto-name "Group N" by the count of existing groups in the document.
const doc = members[0].element.ownerDocument;
const groupId = `Group ${doc.querySelectorAll("[data-hf-group]").length + 1}`;
const { bbox, targets, rebases } = computeGroupGeometry(members);
try {
const data = await commitStructuralMutation(
pid,
targetPath,
"wrap-elements",
{ targets, groupId, bbox, rebases },
"Group elements",
params,
);
return data.groupId ?? groupId;
} catch (error) {
showToast(error instanceof Error ? error.message : "Failed to group elements", "error");
return null;
}
},
[activeCompPath, projectIdRef, showToast, params],
);
const ungroupSelection = useCallback(
async (group: DomEditSelection): Promise<void> => {
const pid = projectIdRef.current;
if (!pid) return;
const targetPath = group.sourceFile || activeCompPath || "index.html";
try {
await commitStructuralMutation(
pid,
targetPath,
"unwrap-elements",
{ target: buildDomEditPatchTarget(group) },
"Ungroup elements",
params,
);
} catch (error) {
showToast(error instanceof Error ? error.message : "Failed to ungroup elements", "error");
}
},
[activeCompPath, projectIdRef, showToast, params],
);
return { groupSelection, ungroupSelection };
}