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
+73 -9
View File
@@ -48,13 +48,16 @@ export interface UseDomSelectionReturn {
domEditSelection: DomEditSelection | null;
domEditGroupSelections: DomEditSelection[];
domEditHoverSelection: DomEditSelection | null;
activeGroupElement: HTMLElement | null;
// Refs
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
domEditGroupSelectionsRef: React.MutableRefObject<DomEditSelection[]>;
domEditHoverSelectionRef: React.MutableRefObject<DomEditSelection | null>;
activeGroupElementRef: React.MutableRefObject<HTMLElement | null>;
// State setters (needed by useDomEditSession for agent-prompt reset flows)
setDomEditSelection: React.Dispatch<React.SetStateAction<DomEditSelection | null>>;
setDomEditGroupSelections: React.Dispatch<React.SetStateAction<DomEditSelection[]>>;
setActiveGroupElement: (el: HTMLElement | null) => void;
// Callbacks
applyDomSelection: (
selection: DomEditSelection | null,
@@ -67,12 +70,20 @@ export interface UseDomSelectionReturn {
clearDomSelection: () => void;
buildDomSelectionFromTarget: (
target: HTMLElement,
options?: { preferClipAncestor?: boolean },
options?: {
preferClipAncestor?: boolean;
skipSourceProbe?: boolean;
activeGroupElement?: HTMLElement | null;
},
) => Promise<DomEditSelection | null>;
resolveDomSelectionFromPreviewPoint: (
clientX: number,
clientY: number,
options?: { preferClipAncestor?: boolean },
options?: {
preferClipAncestor?: boolean;
skipSourceProbe?: boolean;
activeGroupElement?: HTMLElement | null;
},
) => Promise<DomEditSelection | null>;
resolveAllDomSelectionsFromPreviewPoint: (
clientX: number,
@@ -110,17 +121,21 @@ export function useDomSelection({
const [domEditSelection, setDomEditSelection] = useState<DomEditSelection | null>(null);
const [domEditGroupSelections, setDomEditGroupSelections] = useState<DomEditSelection[]>([]);
const [domEditHoverSelection, setDomEditHoverSelection] = useState<DomEditSelection | null>(null);
// The data-hf-group wrapper the user has drilled into (null = top level).
const [activeGroupElement, setActiveGroupElementState] = useState<HTMLElement | null>(null);
// ── Refs ──
const domEditSelectionRef = useRef<DomEditSelection | null>(domEditSelection);
const domEditGroupSelectionsRef = useRef<DomEditSelection[]>(domEditGroupSelections);
const domEditHoverSelectionRef = useRef<DomEditSelection | null>(domEditHoverSelection);
const activeGroupElementRef = useRef<HTMLElement | null>(activeGroupElement);
// Keep refs in sync with state
domEditSelectionRef.current = domEditSelection;
domEditGroupSelectionsRef.current = domEditGroupSelections;
domEditHoverSelectionRef.current = domEditHoverSelection;
activeGroupElementRef.current = activeGroupElement;
// ── Callbacks ──
@@ -178,6 +193,14 @@ export function useDomSelection({
setDomEditSelection(nextSelection);
setDomEditGroupSelections(nextGroup);
// Selecting something outside the drilled-into group exits the drill-in, so
// a later click on the group selects it as a unit again (non-sticky drill-in).
const activeGroup = activeGroupElementRef.current;
if (activeGroup && nextSelection && !activeGroup.contains(nextSelection.element)) {
activeGroupElementRef.current = null;
setActiveGroupElementState(null);
}
if (nextSelection) {
if (options?.revealPanel !== false) {
setRightCollapsed(false);
@@ -203,16 +226,36 @@ export function useDomSelection({
applyDomSelection(null, { revealPanel: false });
}, [applyDomSelection]);
// Drill into / out of a group. Changing scope clears the current selection so
// the user isn't left with an out-of-scope element selected.
const setActiveGroupElement = useCallback(
(el: HTMLElement | null) => {
setActiveGroupElementState(el);
applyDomSelection(null, { revealPanel: false });
},
[applyDomSelection],
);
const buildDomSelectionFromTarget = useCallback(
(
target: HTMLElement,
options?: { preferClipAncestor?: boolean; skipSourceProbe?: boolean },
options?: {
preferClipAncestor?: boolean;
skipSourceProbe?: boolean;
// Override the drill-in scope (used by canvas double-click to resolve the
// child inside a group before the activeGroupElement state has re-rendered).
activeGroupElement?: HTMLElement | null;
},
) => {
return resolveDomEditSelection(target, {
activeCompositionPath: activeCompPath,
isMasterView,
preferClipAncestor: options?.preferClipAncestor,
skipSourceProbe: options?.skipSourceProbe,
activeGroupElement:
options && "activeGroupElement" in options
? options.activeGroupElement
: activeGroupElementRef.current,
projectId,
});
},
@@ -224,7 +267,11 @@ export function useDomSelection({
async (
clientX: number,
clientY: number,
options?: { preferClipAncestor?: boolean; skipSourceProbe?: boolean },
options?: {
preferClipAncestor?: boolean;
skipSourceProbe?: boolean;
activeGroupElement?: HTMLElement | null;
},
) => {
const iframe = previewIframeRef.current;
if (!iframe || captionEditMode) return null;
@@ -235,10 +282,19 @@ export function useDomSelection({
}
const target = getPreviewTargetFromPointer(iframe, clientX, clientY, activeCompPath);
if (!target) return null;
return buildDomSelectionFromTarget(target, {
preferClipAncestor: options?.preferClipAncestor,
skipSourceProbe: options?.skipSourceProbe,
});
return buildDomSelectionFromTarget(
target,
options && "activeGroupElement" in options
? {
preferClipAncestor: options.preferClipAncestor,
skipSourceProbe: options.skipSourceProbe,
activeGroupElement: options.activeGroupElement,
}
: {
preferClipAncestor: options?.preferClipAncestor,
skipSourceProbe: options?.skipSourceProbe,
},
);
},
[activeCompPath, buildDomSelectionFromTarget, captionEditMode, previewIframeRef],
);
@@ -445,7 +501,12 @@ export function useDomSelection({
if (!domEditSelectionInGroup(nextGroup, s)) nextGroup = [...nextGroup, s];
}
} else {
nextGroup = selections;
// Dedupe by target: under select-as-unit several marquee'd members collapse
// to the same group, which must count as one selection, not many duplicates.
nextGroup = [];
for (const s of selections) {
if (!domEditSelectionInGroup(nextGroup, s)) nextGroup.push(s);
}
}
const nextSelection = additive && current ? current : selections[0];
domEditSelectionRef.current = nextSelection;
@@ -478,13 +539,16 @@ export function useDomSelection({
domEditSelection,
domEditGroupSelections,
domEditHoverSelection,
activeGroupElement,
// Refs
domEditSelectionRef,
domEditGroupSelectionsRef,
domEditHoverSelectionRef,
activeGroupElementRef,
// State setters
setDomEditSelection,
setDomEditGroupSelections,
setActiveGroupElement,
// Callbacks
applyDomSelection,
clearDomSelection,