feat(studio): element groups — studio UI (#1759)

* feat(studio): element groups — source mutations

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.

* fix(studio): hoverable group interior + non-sticky drill-in

Two group selection bugs with animated members:

1) Empty space inside a group's overlay didn't hover/select the group. Members
   animated outside the wrapper's static box (110px box vs 340px member union),
   so elementsFromPoint hit only the full-bleed background there. Add a
   member-union hit-test fallback: a point inside a group's live member bounds
   resolves to that group (innermost wins).

2) After drilling into a group and selecting a child, nothing else was
   selectable — out-of-scope resolved to null. Make drill-in non-sticky:
   interacting outside the drilled group re-resolves normally and exits the
   drill-in, so a later click on the group selects it as a unit again.
This commit is contained in:
Miguel Ángel
2026-06-27 11:27:40 -04:00
committed by GitHub
parent 4c461b4a55
commit 1eed7a9b1f
23 changed files with 651 additions and 170 deletions
@@ -186,6 +186,34 @@ export function resolveDomEditGroupOverlayRect(rects: OverlayRect[]): OverlayRec
};
}
// A group's overlay box encompasses its members' actual rendered bounds, not just
// the wrapper's own box — so members moved or transformed out of the wrapper still
// sit inside the box. Used by the selection, hover, and off-canvas overlays so they
// all agree on where a group is.
export function groupAwareOverlayRect(
overlayEl: HTMLDivElement,
iframe: HTMLIFrameElement,
el: HTMLElement,
): OverlayRect | null {
const rect = toOverlayRect(overlayEl, iframe, el);
if (!rect || !el.hasAttribute("data-hf-group")) return rect;
// Union the MEMBERS' rendered rects — where the content actually is — not the
// wrapper's own box. The wrapper is invisible and its box can sit apart from the
// members once they've been moved/transformed, which would otherwise drag the
// group's bounds (and its off-canvas marker) off to a stale position.
const rects: OverlayRect[] = [];
for (const child of Array.from(el.children)) {
const childRect = toOverlayRect(overlayEl, iframe, child as HTMLElement);
if (childRect) rects.push(childRect);
}
const union = rects.length > 0 ? resolveDomEditGroupOverlayRect(rects) : null;
if (!union) return rect; // empty group → fall back to the wrapper box
// resolveDomEditGroupOverlayRect hardcodes editScaleX/Y to 1; keep the wrapper's
// real edit (display) scale, which the drag uses to convert pointer→offset — a
// reset-to-1 makes the group move at ~display-scale speed and lag the cursor.
return { ...union, editScaleX: rect.editScaleX, editScaleY: rect.editScaleY };
}
export function filterNestedDomEditGroupItems<T extends { element: HTMLElement }>(items: T[]): T[] {
return items.filter(
(item) => !items.some((other) => other !== item && other.element.contains(item.element)),