Files
hyperframes/packages/studio-server/src/helpers/sourceMutation.ts
T
Miguel Ángel 4e9059ad59 feat(cli): add agent timeline workflows (#4241)
* feat(cli): add timeline agent mutation commands

* fix(cli): type timeline a2 mutations

* style(cli): format timeline a2 commands

* test(cli): cover timeline a2 verbs

* test(cli): assert ids keep named refs

* refactor: centralize timeline duplicate mutation

* fix(cli): narrow set assignment field

* style(cli): format timeline mutation exports

* refactor(cli): split apply plan decisions

* fix(cli): tag apply plan refusals

* refactor(cli): move A2 runners beside timeline helpers

* fix(cli): remove stale A2 imports

* style(cli): format A2 command module

* fix(cli): avoid A2 command import cycle

* refactor(cli): isolate shared timeline mutation logic

* fix(cli): restore timeline mutation command factory

* fix(cli): remove stale command-surface remnants

* fix(cli): complete shared timeline support extraction

* fix(cli): close shared conflict helper

* fix(cli): import shared timeline formatter

* style(cli): format shared mutation runner

* refactor(cli): split mutation command orchestration

* fix(cli): keep shared mutation primitives in support module

* fix(cli): use shared mutation support helpers

* refactor(cli): centralize A2 refusals

* style(cli): format A2 refusal helpers

* fix(cli): clean shared A2 imports

* fix(cli): make A2 runner imports explicit

* refactor(cli): isolate apply plan target validation

* fix(cli): tag prepared plan edits

* style(cli): format plan target helper

* fix(cli): validate sequential plans and duplicate ids

* test(cli): account for stamped composition id

* fix(studio): stamp ids on patched html

* fix(studio): preserve no-op patch bytes

* test(studio): expect stamped patch markup

* fix(studio-server): duplicate elements in nested templates

* refactor(cli): share timeline mutation receipts

* fix(studio-server): harden duplicate and no-op mutations

* fix(studio-server): reduce duplicate helper complexity
2026-09-21 17:14:47 -04:00

698 lines
26 KiB
TypeScript

import { parseHTML } from "linkedom";
import { removeElementWithGsapCascade } from "@hyperframes/parsers";
import postcss from "postcss";
import selectorParser from "postcss-selector-parser";
import { isAllowedHtmlAttribute, isSafeAttributeValue } from "@hyperframes/core/html-attr-safety";
import { sanitizeRichTextChildren } from "@hyperframes/core/rich-text-sanitize";
import {
EXCLUDED_TAGS,
ensureHfIds,
mintHfId,
walkCompositionDescendants,
} from "@hyperframes/parsers/hf-ids";
import { readClipTiming, writeClipTiming } from "@hyperframes/core/composition-contract";
import { parseStyleDecls, patchStyleAttrString } from "./sourceStyleMutation.js";
export interface SourceMutationTarget {
id?: string | null;
hfId?: string;
selector?: string;
selectorIndex?: number;
}
export function parseSourceDocument(source: string): {
document: Document;
wrappedFragment: boolean;
} {
const hasDocumentShell = /<!doctype|<html[\s>]/i.test(source);
if (hasDocumentShell) {
return { document: parseHTML(source).document, wrappedFragment: false };
}
return {
document: parseHTML(`<!DOCTYPE html><html><head></head><body>${source}</body></html>`).document,
wrappedFragment: true,
};
}
function duplicateCssRulesForId(document: Document, originalId: string, newId: string): void {
const idToken = `#${originalId}`;
const transform = selectorParser((selectors) => {
selectors.walkIds((node) => {
if (node.value === originalId) node.value = newId;
});
});
for (const styleEl of document.querySelectorAll("style")) {
const css = styleEl.textContent ?? "";
let root: postcss.Root;
try {
root = postcss.parse(css);
} catch {
continue;
}
const clones: postcss.Rule[] = [];
root.walkRules((rule) => {
if (!rule.selector.includes(idToken)) return;
const newSelector = transform.processSync(rule.selector);
if (newSelector === rule.selector) return;
const clone = rule.clone({ selector: newSelector });
clones.push(clone);
});
if (clones.length > 0) {
for (const c of clones) root.append(c);
styleEl.textContent = root.toString();
}
}
}
function querySelectorAllWithTemplates(root: Document | Element, selector: string): Element[] {
const matches = Array.from(root.querySelectorAll(selector));
if (matches.length > 0) return matches;
// querySelectorAll doesn't traverse <template> content in linkedom.
// Search directly on each template element (NOT .content — removing from
// .content's DocumentFragment doesn't update the serialized output).
// Recurse so NESTED templates resolve too — ensureHfIds and the SDK's
// querySelectorAllDeep descend nested composition templates, so ids exist at
// any template depth; a one-level search here would silently no-op
// server-side ops on those ids while the SDK resolves them.
const templates = Array.from(root.querySelectorAll("template"));
for (const tmpl of templates) {
const inner = querySelectorAllWithTemplates(tmpl, selector);
if (inner.length > 0) return inner;
}
return [];
}
// Prevent CSS attribute-selector injection via a crafted hfId: escape
// backslashes first, then double-quotes. Keeps a malformed/hostile value from
// breaking out of the `[data-hf-id="…"]` selector once callers beyond the
// internal mint contract (R2+ user flows) pass values here.
function escapeCssAttrValue(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function findByHfId(document: Document, hfId: string): Element | null {
try {
const matches = querySelectorAllWithTemplates(
document,
`[data-hf-id="${escapeCssAttrValue(hfId)}"]`,
);
if (matches.length > 1) {
// The mint contract guarantees uniqueness; a duplicate means upstream
// id drift. Don't silently patch an arbitrary one — surface it.
// eslint-disable-next-line no-console
console.warn(
`sourceMutation: data-hf-id "${hfId}" matched ${matches.length} elements; using the first. ids must be unique per document.`,
);
}
return matches[0] ?? null;
} catch {
// Malformed selector despite escaping — let the caller fall back.
return null;
}
}
export function findTargetElement(
document: Document,
target: SourceMutationTarget,
): Element | null {
if (target.hfId) {
const el = findByHfId(document, target.hfId);
if (el) return el;
}
if (target.id) {
const byId = document.getElementById(target.id);
if (byId) return byId;
}
if (!target.selector) return null;
try {
const matches = querySelectorAllWithTemplates(document, target.selector);
return matches[target.selectorIndex ?? 0] ?? null;
} catch {
return null;
}
}
export function removeElementFromHtml(source: string, target: SourceMutationTarget): string {
const { document, wrappedFragment } = parseSourceDocument(source);
const element = findTargetElement(document, target);
if (!element) return source;
removeElementWithGsapCascade(document, element);
return wrappedFragment ? document.body.innerHTML || "" : document.toString();
}
export function isHTMLElement(el: Node): el is HTMLElement {
const HTMLEl = el.ownerDocument?.defaultView?.HTMLElement;
return HTMLEl ? el instanceof HTMLEl : el.nodeType === 1 && "style" in el;
}
export function dedupeClonedCompositionId(document: Document, clone: Element): void {
const compositionId = clone.getAttribute("data-composition-id");
if (!compositionId) return;
const usedCompositionIds = new Set(
querySelectorAllWithTemplates(document, "[data-composition-id]").map((node) =>
node.getAttribute("data-composition-id"),
),
);
const base = `${compositionId}-split`;
let nextCompositionId = base;
let suffix = 2;
while (usedCompositionIds.has(nextCompositionId)) nextCompositionId = `${base}-${suffix++}`;
clone.setAttribute("data-composition-id", nextCompositionId);
}
export interface PatchOperation {
type: "inline-style" | "attribute" | "html-attribute" | "text-content" | "rich-text";
property: string;
value: string | null;
childSelector?: string;
childIndex?: number;
}
interface ResolvedPatchOperation {
op: PatchOperation;
target: HTMLElement;
}
function resolveOperationTarget(parent: HTMLElement, op: PatchOperation): HTMLElement | null {
if (op.childSelector === undefined) return parent;
try {
const child = parent.querySelectorAll(op.childSelector)[op.childIndex ?? 0] ?? null;
return child && isHTMLElement(child) ? child : null;
} catch {
return null;
}
}
/**
* Give the elements a rich-text patch just introduced their stable ids, here,
* in the bytes about to be written and handed back.
*
* Otherwise the next preview request mints them and writes the file a second
* time, after Studio has already recorded the edit in its history. The recorded
* "after" stops matching disk, the content check refuses, and undo reports the
* file as changed outside Studio — for every colour applied to a run of
* characters and every text layer added. The clip split stamps its own clone
* for exactly this reason.
*
* Minted one element at a time with the same function `ensureHfIds` uses, so
* these ids are the ones the next pass would have assigned. Not `ensureHfIds`
* itself: it takes a whole document, and handing it this element's markup would
* put the markup back as one.
*/
function stampNewChildIds(parent: Element): void {
const assigned = new Set<string>();
const root = parent.ownerDocument?.body ?? parent;
walkCompositionDescendants(root, (el) => {
const id = el.getAttribute("data-hf-id");
if (id) assigned.add(id);
});
for (const el of parent.querySelectorAll("*")) {
if (el.getAttribute("data-hf-id")) continue;
if (EXCLUDED_TAGS.has(el.tagName.toLowerCase())) continue;
el.setAttribute("data-hf-id", mintHfId(el, assigned));
}
}
// fallow-ignore-next-line complexity
export function patchElementInHtml(
source: string,
target: SourceMutationTarget,
operations: PatchOperation[],
): { html: string; matched: boolean } {
const { document, wrappedFragment } = parseSourceDocument(source);
const el = findTargetElement(document, target);
if (!el || !isHTMLElement(el)) return { html: source, matched: false };
const htmlEl = el;
const originalHtml = wrappedFragment ? document.body.innerHTML || "" : document.toString();
const resolved: ResolvedPatchOperation[] = [];
for (const op of operations) {
const opTarget = resolveOperationTarget(htmlEl, op);
if (!opTarget) return { html: source, matched: false };
resolved.push({ op, target: opTarget });
}
for (const { op, target: opTarget } of resolved) {
switch (op.type) {
case "inline-style":
// linkedom's CSSStyleDeclaration does not support CSS custom properties
// (--foo) or newer individual transform properties (translate, rotate,
// scale) via style.setProperty(). Manipulate the style attribute string
// directly so all property names survive the round-trip.
{
const raw = opTarget.getAttribute("style") ?? "";
const patched = patchStyleAttrString(raw, op.property, op.value);
opTarget.setAttribute("style", patched);
}
break;
case "attribute":
{
const fullAttr = op.property.startsWith("data-") ? op.property : `data-${op.property}`;
if (op.value != null) {
opTarget.setAttribute(fullAttr, op.value);
} else {
opTarget.removeAttribute(fullAttr);
}
}
break;
case "html-attribute":
if (!isAllowedHtmlAttribute(op.property)) break;
if (op.value != null) {
if (!isSafeAttributeValue(op.property, op.value)) break;
opTarget.setAttribute(op.property, op.value);
} else {
opTarget.removeAttribute(op.property);
}
break;
case "text-content":
if (op.value != null) {
const inner = opTarget.children.length === 1 ? opTarget.firstElementChild : null;
const textTarget = inner && isHTMLElement(inner) ? inner : opTarget;
textTarget.textContent = op.value;
}
break;
// The one operation that can write markup, so the one that has to check
// it. Assigned first and sanitised after, rather than sanitising a
// string: parsing is what turns a payload into the tree the allowlist
// can actually judge, and linkedom never runs anything it parses.
case "rich-text":
if (op.value != null) {
opTarget.innerHTML = op.value;
sanitizeRichTextChildren(opTarget);
stampNewChildIds(opTarget);
}
break;
}
}
const html = wrappedFragment ? document.body.innerHTML || "" : document.toString();
if (html === originalHtml) return { html: source, matched: true };
return { html: ensureHfIds(html), matched: true };
}
export function probeElementInSource(source: string, target: SourceMutationTarget): boolean {
if (!target.id && !target.hfId && !target.selector) return false;
const { document } = parseSourceDocument(source);
const el = findTargetElement(document, target);
return el != null && isHTMLElement(el);
}
export interface SplitElementResult {
html: string;
matched: boolean;
newId: string | null;
}
function resolveElementTiming(el: Element): {
start: number;
duration: number;
} {
const timing = readClipTiming(el);
return { start: timing.start ?? 0, duration: timing.duration ?? 0 };
}
function setElementDuration(
el: Element,
start: number,
duration: number,
trackIndex?: number,
): void {
writeClipTiming(el, {
start: Math.round(start * 1000) / 1000,
duration: Math.round(duration * 1000) / 1000,
...(trackIndex != null ? { trackIndex } : {}),
});
}
// fallow-ignore-next-line complexity
export function splitElementInHtml(
source: string,
target: SourceMutationTarget,
splitTime: number,
newId: string,
fallbackTiming?: {
start: number;
duration: number;
playbackStart?: number;
playbackRate?: number;
stampPlaybackStart?: boolean;
// The element's current resolved track (authored, or the runtime's
// positional-index fallback when unauthored). Stamped onto both halves so
// inserting the clone can't shift either one to a different row — see
// parseAuthoredTrack's fallback in core/runtime/timeline.ts.
track?: number;
},
): SplitElementResult {
const { document, wrappedFragment } = parseSourceDocument(source);
const el = findTargetElement(document, target);
if (!el || !isHTMLElement(el)) return { html: source, matched: false, newId: null };
const timing = resolveElementTiming(el);
let { start, duration } = timing;
// GSAP-animated elements carry their timing in the script, not in data-* attrs,
// so the source has no authored duration. Fall back to the store's (GSAP-derived)
// range — the runtime windows visibility off data-start/data-duration regardless
// of class, so stamping both halves below makes each half show only in its window.
if (duration <= 0 && fallbackTiming && fallbackTiming.duration > 0) {
start = fallbackTiming.start;
duration = fallbackTiming.duration;
}
if (duration <= 0 || splitTime <= start || splitTime >= start + duration) {
return { html: source, matched: false, newId: null };
}
if (document.getElementById(newId)) {
let suffix = 2;
const base = newId;
while (document.getElementById(newId)) {
newId = `${base}-${suffix++}`;
}
}
const firstDuration = splitTime - start;
const secondDuration = duration - firstDuration;
const clone = el.cloneNode(true);
if (!isHTMLElement(clone)) return { html: source, matched: false, newId: null };
clone.setAttribute("id", newId);
dedupeClonedCompositionId(document, clone);
clone.removeAttribute("data-hf-id");
// Descendants carry their own data-hf-id; leaving them duplicates the id of
// every nested node (e.g. an inner <span>), so strip them on the clone too.
for (const node of clone.querySelectorAll("[data-hf-id]")) node.removeAttribute("data-hf-id");
setElementDuration(clone, splitTime, secondDuration, fallbackTiming?.track);
// Keep the "clip" class — the runtime uses it to control visibility
// based on data-start/data-duration timing.
// A split creates two views over the same media source. Even an untrimmed
// audio/video element needs an explicit zero in-point stamped on the first
// half so the second half can advance from it instead of restarting at zero.
const playbackStartAttr = el.hasAttribute("data-playback-start")
? "data-playback-start"
: el.hasAttribute("data-media-start")
? "data-media-start"
: fallbackTiming?.stampPlaybackStart
? "data-playback-start"
: el.matches("audio, video")
? "data-media-start"
: null;
if (playbackStartAttr) {
const currentTrim =
parseFloat(el.getAttribute(playbackStartAttr) ?? "") || fallbackTiming?.playbackStart || 0;
const rateRaw = parseFloat(el.getAttribute("data-playback-rate") ?? "");
const rate =
Number.isFinite(rateRaw) && rateRaw > 0 ? rateRaw : (fallbackTiming?.playbackRate ?? 1);
el.setAttribute(playbackStartAttr, String(Math.round(currentTrim * 1000) / 1000));
clone.setAttribute(
playbackStartAttr,
String(Math.round((currentTrim + firstDuration * rate) * 1000) / 1000),
);
}
// Duplicate CSS rules targeting the original ID so the clone inherits the same styles.
const originalId = el.getAttribute("id");
if (originalId) {
duplicateCssRulesForId(document, originalId, newId);
}
// Trim the original element's duration. A GSAP element had no data-start; stamp
// it so the runtime windows the first half (visibility selects on [data-start]).
setElementDuration(el, start, firstDuration, fallbackTiming?.track);
// Insert clone after original
if (el.nextSibling) {
el.parentElement!.insertBefore(clone, el.nextSibling);
} else {
el.parentElement!.appendChild(clone);
}
const html = wrappedFragment ? document.body.innerHTML || "" : document.toString();
return {
// The split owns its new nodes' stable ids. Leaving the clone unstamped makes
// the next preview request persist different bytes after history is recorded.
html: ensureHfIds(html),
matched: true,
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;
/** The unwrapped wrapper's id, so callers can strip GSAP that targeted it
* (the wrapper is gone; a leftover `gsap.set("#id")` would throw at runtime). */
unwrappedGroupId?: string;
/** Members (id'd children) with their absolute layout centres (post un-rebase),
* so the caller can BAKE the group's GSAP transform into each member before
* stripping it — otherwise the group's moves are lost on ungroup. */
members?: Array<{ id: string; cx: number; cy: number }>;
/** The wrapper's layout centre — the pivot for baking the group's rotation/scale. */
groupCenter?: { cx: number; cy: number };
}
export interface ElementRebase {
target: SourceMutationTarget;
left: number;
top: number;
/** The member's current resolved track (authored, or the runtime's
* positional-index fallback). Stamped explicitly so moving it into the
* wrapper can't shift its computed row — same hazard split closes. */
track?: 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, "-")
// Normalization above leaves at most one hyphen at either edge.
.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 and track (resolved against the same document).
const rebaseByEl = new Map<Element, { left: number; top: number; track?: number }>();
for (const rebase of rebases) {
const el = findTargetElement(document, rebase.target);
if (el) rebaseByEl.set(el, { left: rebase.left, top: rebase.top, track: rebase.track });
}
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);
if (rebase?.track != null) writeClipTiming(el, { trackIndex: Math.round(rebase.track) });
wrapper.appendChild(el); // appendChild moves the node, preserving order
}
return {
html: wrappedFragment ? document.body.innerHTML || "" : document.toString(),
matched: true,
groupId,
};
}
export interface UnwrapChildTrack {
target: SourceMutationTarget;
/** The child's current resolved track, same hazard and fix as wrap's members. */
track?: number;
}
// Only children actually inside the group and given a resolved track qualify —
// same hazard and fix as wrap's members, scoped to this group's own children.
function buildChildTrackMap(
document: Document,
group: Element,
childTracks: UnwrapChildTrack[],
): Map<Element, number> {
const trackByEl = new Map<Element, number>();
for (const entry of childTracks) {
const el = findTargetElement(document, entry.target);
if (el && group.contains(el) && entry.track != null) trackByEl.set(el, entry.track);
}
return trackByEl;
}
// Undoes the wrap-side rebase (child absolute = child rebased + wrapper
// origin), stamps each child's resolved track where one was given, and moves
// every child back into the parent ahead of the wrapper — preserving order.
function relocateGroupChildren(
group: Element,
parent: Element,
wLeft: number,
wTop: number,
trackByEl: Map<Element, number>,
): Array<{ id: string; cx: number; cy: number }> {
const members: Array<{ id: string; cx: number; cy: number }> = [];
for (const child of Array.from(group.children)) {
if (isHTMLElement(child)) {
const newLeft = getInlineStylePx(child, "left") + wLeft;
const newTop = getInlineStylePx(child, "top") + wTop;
setInlineLeftTop(child, newLeft, newTop);
const track = trackByEl.get(child);
if (track != null) writeClipTiming(child, { trackIndex: Math.round(track) });
if (child.id) {
members.push({
id: child.id,
cx: newLeft + getInlineStylePx(child, "width") / 2,
cy: newTop + getInlineStylePx(child, "height") / 2,
});
}
}
parent.insertBefore(child, group);
}
return members;
}
export function unwrapElementsFromHtml(
source: string,
groupTarget: SourceMutationTarget,
childTracks: UnwrapChildTrack[] = [],
): 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 };
const trackByEl = buildChildTrackMap(document, group, childTracks);
// Undo the rebase: child absolute position = child (rebased) + wrapper origin.
const wLeft = getInlineStylePx(group, "left");
const wTop = getInlineStylePx(group, "top");
const groupCenter = {
cx: wLeft + getInlineStylePx(group, "width") / 2,
cy: wTop + getInlineStylePx(group, "height") / 2,
};
const members = relocateGroupChildren(group, parent, wLeft, wTop, trackByEl);
const groupId = group.id || undefined;
group.remove();
return {
html: wrappedFragment ? document.body.innerHTML || "" : document.toString(),
unwrapped: true,
unwrappedGroupId: groupId,
members,
groupCenter,
};
}