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
@@ -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 };
}