diff --git a/packages/studio-server/src/helpers/sourceMutation.test.ts b/packages/studio-server/src/helpers/sourceMutation.test.ts index a0f3dedcb1..fd28dca529 100644 --- a/packages/studio-server/src/helpers/sourceMutation.test.ts +++ b/packages/studio-server/src/helpers/sourceMutation.test.ts @@ -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 = `
+
Title
+ +
Badge
+
Outside
+
`; + + // 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 = `
`; + 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 = `
`; + 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 = `
`; + const result = unwrapElementsFromHtml(html, { id: "plain" }); + expect(result.unwrapped).toBe(false); + expect(result.html).toBe(html); + }); +}); diff --git a/packages/studio-server/src/helpers/sourceMutation.ts b/packages/studio-server/src/helpers/sourceMutation.ts index 14951aaf33..7b957e91a3 100644 --- a/packages/studio-server/src/helpers/sourceMutation.ts +++ b/packages/studio-server/src/helpers/sourceMutation.ts @@ -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; order: string[] } { const props = new Map(); 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, 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 `
` 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(); + 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(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(); + 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
+ // 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, + }; +} diff --git a/packages/studio-server/src/routes/files.ts b/packages/studio-server/src/routes/files.ts index c628d49c6e..8a0d35face 100644 --- a/packages/studio-server/src/routes/files.ts +++ b/packages/studio-server/src/routes/files.ts @@ -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; diff --git a/packages/studio/src/hooks/useGroupCommits.ts b/packages/studio/src/hooks/useGroupCommits.ts new file mode 100644 index 0000000000..144117eede --- /dev/null +++ b/packages/studio/src/hooks/useGroupCommits.ts @@ -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; +} + +interface UseGroupCommitsParams { + activeCompPath: string | null; + showToast: (message: string, tone?: "error" | "info") => void; + writeProjectFile: (path: string, content: string) => Promise; + domEditSaveTimestampRef: React.MutableRefObject; + editHistory: { recordEdit: (entry: RecordEditInput) => Promise }; + projectIdRef: React.MutableRefObject; + 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 => { + 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 => { + 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 }; +}