mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user