();
+ 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 };
+}