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
@@ -3,7 +3,7 @@ import { useMountEffect } from "../../hooks/useMountEffect";
import { type DomEditSelection } from "./domEditing";
import { useMarqueeGestures } from "./marqueeCommit";
import { MarqueeOverlay } from "./MarqueeOverlay";
import { resolveDomEditGroupOverlayRect, toOverlayRect } from "./domEditOverlayGeometry";
import { groupAwareOverlayRect, resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry";
import { collectDomEditLayerItems } from "./domEditingLayers";
import { isElementComputedVisible } from "./domEditingElement";
import {
@@ -248,7 +248,10 @@ export const DomEditOverlay = memo(function DomEditOverlay({
const elMap = new Map<string, HTMLElement>();
for (const item of items) {
if (!isElementComputedVisible(item.element)) continue;
const r = toOverlayRect(overlay, iframe, item.element);
// Groups use their members' union (where they actually render), so a group
// whose members sit inside the canvas isn't flagged off-canvas by a stale
// wrapper box.
const r = groupAwareOverlayRect(overlay, iframe, item.element);
if (!r) continue;
// Any edge crossing the composition border → gray-zone indicator (the
// in-canvas portion is clipped away below, so only the sliver shows).
@@ -0,0 +1,62 @@
import { X } from "../../icons/SystemIcons";
import type { DomEditSelection } from "./domEditingTypes";
/** The action buttons in the inspector header: Ungroup (groups only), copy, clear. */
export function InspectorHeaderActions({
element,
copied,
onCopy,
onClear,
onUngroup,
}: {
element: DomEditSelection;
copied: boolean;
onCopy: () => void;
onClear: () => void;
onUngroup?: () => void;
}) {
return (
<div className="flex items-center gap-1">
{onUngroup && element.dataAttributes["hf-group"] != null && (
<button
type="button"
onClick={onUngroup}
title="Ungroup (⌘⇧G)"
className="flex h-6 items-center rounded px-2 text-[11px] font-medium text-neutral-400 transition-colors hover:bg-neutral-800 hover:text-neutral-200"
>
Ungroup
</button>
)}
<button
type="button"
onClick={onCopy}
className={`flex h-6 w-6 items-center justify-center rounded transition-colors ${
copied
? "text-studio-accent"
: "text-neutral-500 hover:bg-neutral-800 hover:text-neutral-300"
}`}
title={copied ? "Copied!" : "Copy element info to clipboard"}
>
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<rect x="5" y="5" width="9" height="9" rx="1.5" />
<path d="M11 5V3.5A1.5 1.5 0 009.5 2h-6A1.5 1.5 0 002 3.5v6A1.5 1.5 0 003.5 11H5" />
</svg>
</button>
<button
type="button"
aria-label="Clear selection"
onClick={onClear}
className="flex h-6 w-6 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
>
<X size={13} />
</button>
</div>
);
}
@@ -59,9 +59,11 @@ export const LayersPanel = memo(function LayersPanel() {
const currentTime = usePlayerStore((s) => s.currentTime);
const {
domEditSelection,
activeGroupElement,
applyDomSelection,
updateDomEditHoverSelection,
handleDomZIndexReorderCommit,
setActiveGroupElement,
} = useDomEditContext();
const [layers, setLayers] = useState<DomEditLayerItem[]>([]);
@@ -86,12 +88,16 @@ export const LayersPanel = memo(function LayersPanel() {
doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement ?? null;
if (!root) return;
// A preview reload detaches the drilled-into wrapper; exit drill-in if so.
if (activeGroupElement && !activeGroupElement.isConnected) setActiveGroupElement(null);
const items = collectDomEditLayerItems(root, {
activeCompositionPath: activeCompPath,
isMasterView,
activeGroupElement,
});
setLayers(sortLayersByZIndex(items));
}, [previewIframeRef, activeCompPath, isMasterView]);
}, [previewIframeRef, activeCompPath, isMasterView, activeGroupElement, setActiveGroupElement]);
useEffect(() => {
collectLayers();
@@ -135,9 +141,10 @@ export const LayersPanel = memo(function LayersPanel() {
activeCompositionPath: activeCompPath,
isMasterView,
preferClipAncestor: false,
activeGroupElement,
});
},
[activeCompPath, isMasterView, previewIframeRef],
[activeCompPath, isMasterView, previewIframeRef, activeGroupElement],
);
const seekToLayer = useCallback(
@@ -183,6 +190,19 @@ export const LayersPanel = memo(function LayersPanel() {
[resolveSelection, applyDomSelection, seekToLayer],
);
// Double-click a group row → drill into it; any other row → select it.
const handleLayerDoubleClick = useCallback(
async (layer: DomEditLayerItem) => {
const selection = await resolveSelection(layer);
if (selection?.element.hasAttribute("data-hf-group")) {
setActiveGroupElement(selection.element);
} else {
await handleSelectLayer(layer);
}
},
[resolveSelection, setActiveGroupElement, handleSelectLayer],
);
const handleLayerHover = useCallback(
async (layer: DomEditLayerItem | null) => {
if (!layer) {
@@ -271,6 +291,18 @@ export const LayersPanel = memo(function LayersPanel() {
onPointerUp={handleContainerPointerUp}
onPointerCancel={handleContainerPointerUp}
>
{activeGroupElement && (
<button
type="button"
onClick={() => setActiveGroupElement(null)}
className="flex w-full items-center gap-1.5 px-2 py-1 text-left text-[11px] text-panel-text-3 hover:bg-panel-hover/40 hover:text-panel-text-1"
>
<span aria-hidden="true"></span>
<span className="truncate">
{activeGroupElement.getAttribute("data-hf-group") || "Group"}
</span>
</button>
)}
{visibleLayers.map((layer, index) => {
const selected = layer.key === selectedKey;
const isDragged = layer.key === dragKey;
@@ -286,6 +318,7 @@ export const LayersPanel = memo(function LayersPanel() {
role="button"
tabIndex={0}
onClick={() => !dragKey && handleSelectLayer(layer)}
onDoubleClick={() => !dragKey && handleLayerDoubleClick(layer)}
onPointerDown={(e) => handleRowPointerDown(index, e)}
onPointerEnter={() => !dragKey && handleLayerHover(layer)}
onKeyDown={(e) => {
@@ -1,5 +1,6 @@
import { memo, useEffect, useMemo, useRef, useState } from "react";
import { Eye, Layers, Move, X } from "../../icons/SystemIcons";
import { Eye, Layers, Move } from "../../icons/SystemIcons";
import { InspectorHeaderActions } from "./InspectorHeaderActions";
import { useStudioShellContext } from "../../contexts/StudioContext";
import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
import {
@@ -53,6 +54,7 @@ export const PropertyPanel = memo(function PropertyPanel({
multiSelectCount = 0,
copiedAgentPrompt: _copiedAgentPrompt,
onClearSelection,
onUngroup,
onSetStyle,
onSetAttribute,
onSetAttributeLive,
@@ -295,38 +297,13 @@ export const PropertyPanel = memo(function PropertyPanel({
</div>
<div className="mt-0.5 truncate text-[11px] text-neutral-500">{sourceLabel}</div>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={handleCopyElementInfo}
className={`flex h-6 w-6 items-center justify-center rounded transition-colors ${
clipboardCopied
? "text-studio-accent"
: "text-neutral-500 hover:bg-neutral-800 hover:text-neutral-300"
}`}
title={clipboardCopied ? "Copied!" : "Copy element info to clipboard"}
>
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<rect x="5" y="5" width="9" height="9" rx="1.5" />
<path d="M11 5V3.5A1.5 1.5 0 009.5 2h-6A1.5 1.5 0 002 3.5v6A1.5 1.5 0 003.5 11H5" />
</svg>
</button>
<button
type="button"
aria-label="Clear selection"
onClick={onClearSelection}
className="flex h-6 w-6 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
>
<X size={13} />
</button>
</div>
<InspectorHeaderActions
element={element}
copied={clipboardCopied}
onCopy={handleCopyElementInfo}
onClear={onClearSelection}
onUngroup={onUngroup}
/>
</div>
</div>
<div className="flex-1 overflow-y-auto">
@@ -1,4 +1,4 @@
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { projectAxes, projectCubeFaces, wrapDeg } from "./transform3dProjection";
export interface CubePose {
@@ -22,80 +22,18 @@ const SENSITIVITY = 0.6; // degrees per pixel of drag
* Presentational only: emits a live draft pose while dragging and a final pose
* on release — the parent owns live-previewing and committing to GSAP props.
*/
// transformPerspective (px) is inversely related to effect strength, with 0 = off.
// Map a 0..1 slider strength to px and to the cube's weak-perspective projection.
const STRONG_PX = 200;
const WEAK_PX = 1600;
const PX_RANGE = WEAK_PX - STRONG_PX;
const strengthToPx = (s: number) => (s <= 0.01 ? 0 : Math.round(WEAK_PX - s * PX_RANGE));
const pxToStrength = (px: number) =>
px <= 0
? 0
: Math.max(0, Math.min(1, (WEAK_PX - Math.max(STRONG_PX, Math.min(WEAK_PX, px))) / PX_RANGE));
// transformPerspective (px) drives the cube's weak-perspective projection;
// 0 = off → flattest (largest projection distance).
const pxToProjPersp = (px: number) => (px > 0 ? Math.max(2.2, Math.min(14, px / 130)) : 14);
/** Horizontal "perspective strength" slider — left = none, right = dramatic. */
function PerspectiveSlider({
value,
onDraft,
onCommit,
}: {
value: number;
onDraft?: (px: number) => void;
onCommit: (px: number) => void;
}) {
const trackRef = useRef<HTMLDivElement | null>(null);
const draggingRef = useRef(false);
const strength = pxToStrength(value);
const fromEvent = (clientX: number) => {
const r = trackRef.current?.getBoundingClientRect();
if (!r || r.width === 0) return 0;
return strengthToPx(Math.max(0, Math.min(1, (clientX - r.left) / r.width)));
};
return (
<div className="flex items-center gap-1.5 px-2 pb-1.5 pt-1">
<span className="text-[8px] font-medium uppercase tracking-wide text-neutral-600">Persp</span>
<div
ref={trackRef}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
draggingRef.current = true;
onDraft?.(fromEvent(e.clientX));
}}
onPointerMove={(e) => {
if (draggingRef.current) onDraft?.(fromEvent(e.clientX));
}}
onPointerUp={(e) => {
if (!draggingRef.current) return;
draggingRef.current = false;
onCommit(fromEvent(e.clientX));
}}
onPointerCancel={() => {
draggingRef.current = false;
}}
className="relative h-3 flex-1 cursor-ew-resize touch-none"
>
<div className="absolute top-1/2 h-0.5 w-full -translate-y-1/2 rounded-full bg-neutral-700" />
<div
className="absolute top-1/2 h-0.5 -translate-y-1/2 rounded-full bg-[#5ff0bf]"
style={{ width: `${strength * 100}%` }}
/>
<div
className="absolute top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border border-neutral-900 bg-[#5ff0bf]"
style={{ left: `${strength * 100}%` }}
/>
</div>
</div>
);
}
export function Transform3DCube({
pose,
perspective = 0,
z = 0,
onPoseDraft,
onPoseCommit,
onPerspectiveDraft,
onPerspectiveCommit,
onDepthDraft,
onDepthCommit,
onRecenter,
onKeyframe,
keyframed,
@@ -103,13 +41,16 @@ export function Transform3DCube({
pose: CubePose;
/** Element's transformPerspective (px); drives the cube's foreshortening. */
perspective?: number;
/** Element's translateZ (px) — "depth", adjusted by scrolling over the cube. */
z?: number;
/** Fires on every drag move with the in-progress pose (parent live-previews). */
onPoseDraft?: (pose: CubePose) => void;
/** Fires once on pointer release with the final pose (commit). */
onPoseCommit: (pose: CubePose) => void;
/** Live + committed perspective (px) from the in-cube slider. */
onPerspectiveDraft?: (px: number) => void;
onPerspectiveCommit?: (px: number) => void;
/** Live depth (translateZ px) during a scroll; parent live-previews it. */
onDepthDraft?: (z: number) => void;
/** Committed depth (translateZ px) once a scroll burst settles. */
onDepthCommit?: (z: number) => void;
/** Reset to identity orientation. */
onRecenter?: () => void;
/** Toggle keyframing the 3D transform (convert the static set → keyframes). */
@@ -118,16 +59,63 @@ export function Transform3DCube({
keyframed?: boolean;
}) {
const [draft, setDraft] = useState<CubePose | null>(null);
const [depthDraft, setDepthDraft] = useState<number | null>(null);
const dragRef = useRef<{ x: number; y: number; pose: CubePose } | null>(null);
const shown = draft ?? pose;
const shownZ = depthDraft ?? z;
// Scroll over the cube to push the element along Z (depth) — matches the
// studio's "scroll = z depth" gesture-recording convention. A non-passive
// listener is required so preventDefault can stop the panel from scrolling.
const svgRef = useRef<SVGSVGElement | null>(null);
const depthRef = useRef({ z, onDepthDraft, onDepthCommit });
depthRef.current = { z, onDepthDraft, onDepthCommit };
useEffect(() => {
const el = svgRef.current;
if (!el) return;
let pending: number | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
const onWheel = (e: WheelEvent) => {
const { onDepthCommit: commit, onDepthDraft: draft } = depthRef.current;
if (!commit) return;
e.preventDefault();
// ponytail: 0.25 px of Z per wheel-delta unit (~25px per notch); tune if
// it feels too fast/slow. Scroll up (deltaY < 0) pushes toward the viewer.
pending = Math.round((pending ?? depthRef.current.z) - e.deltaY * 0.25);
draft?.(pending);
setDepthDraft(pending); // live-scale the cube while scrolling
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
if (pending != null) commit(pending);
pending = null;
setDepthDraft(null); // fall back to the committed z prop
}, 160);
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => {
el.removeEventListener("wheel", onWheel);
if (timer) clearTimeout(timer);
};
}, []);
// Depth feedback: the cube scales like the element would — translateZ(z) under
// a perspective lens P appears scaled by P/(P-z). Closer (z>0) reads bigger,
// farther (z<0) smaller. Fall back to the default lens so depth always reads in
// the gizmo even before a perspective is set.
const lens = perspective > 0 ? perspective : 800;
const depthScale = Math.max(0.4, Math.min(2.2, lens / (lens - shownZ)));
const projOpts = {
cx: CX,
cy: CY,
r: RADIUS,
r: RADIUS * depthScale,
persp: pxToProjPersp(perspective),
};
const faces = projectCubeFaces(shown.rotationX, shown.rotationY, shown.rotationZ, projOpts);
const axes = projectAxes(shown.rotationX, shown.rotationY, shown.rotationZ, projOpts);
// The element lives in CSS's screen-Y-down space; the cube projects Y-up. RotateX
// and RotateZ act in planes that contain Y, so they read inverted in the gizmo
// unless their sign is flipped — RotateY (X-Z plane) matches as-is. This keeps the
// cube's orientation a true mirror of the element.
const faces = projectCubeFaces(-shown.rotationX, shown.rotationY, -shown.rotationZ, projOpts);
const axes = projectAxes(-shown.rotationX, shown.rotationY, -shown.rotationZ, projOpts);
const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
e.currentTarget.setPointerCapture(e.pointerId);
@@ -140,10 +128,13 @@ export function Transform3DCube({
if (!d) return;
const dx = e.clientX - d.x;
const dy = e.clientY - d.y;
// dy→rotationX and shift dx→rotationZ are negated to match the projection's
// sign flip (above), so the cube's response to a drag is unchanged while the
// element now rotates in lock-step with it.
const next: CubePose = e.shiftKey
? { ...d.pose, rotationZ: wrapDeg(d.pose.rotationZ + dx * SENSITIVITY) }
? { ...d.pose, rotationZ: wrapDeg(d.pose.rotationZ - dx * SENSITIVITY) }
: {
rotationX: wrapDeg(d.pose.rotationX - dy * SENSITIVITY),
rotationX: wrapDeg(d.pose.rotationX + dy * SENSITIVITY),
rotationY: wrapDeg(d.pose.rotationY + dx * SENSITIVITY),
rotationZ: d.pose.rotationZ,
};
@@ -161,6 +152,7 @@ export function Transform3DCube({
return (
<div className="relative overflow-hidden rounded-lg border border-neutral-800 bg-gradient-to-b from-neutral-900 to-neutral-950">
<svg
ref={svgRef}
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
className="block w-full cursor-grab touch-none select-none active:cursor-grabbing"
style={{ aspectRatio: `${VIEW_W} / ${VIEW_H}` }}
@@ -169,7 +161,7 @@ export function Transform3DCube({
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
role="slider"
aria-label="Drag to rotate in 3D; hold Shift to roll"
aria-label="Drag to rotate in 3D; hold Shift to roll; scroll to change depth"
aria-valuetext={`X ${Math.round(shown.rotationX)}°, Y ${Math.round(
shown.rotationY,
)}°, Z ${Math.round(shown.rotationZ)}°`}
@@ -301,13 +293,6 @@ export function Transform3DCube({
</svg>
</button>
)}
{onPerspectiveCommit && (
<PerspectiveSlider
value={perspective}
onDraft={onPerspectiveDraft}
onCommit={onPerspectiveCommit}
/>
)}
</div>
);
}
@@ -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)),
@@ -250,7 +250,7 @@ export function querySelectorAllSafely(doc: Document, selector: string): Element
}
}
export function humanizeIdentifier(value: string): string {
function humanizeIdentifier(value: string): string {
return (
value
.replace(/\.html$/i, "")
@@ -270,10 +270,16 @@ export function buildStableSelector(el: HTMLElement): string | undefined {
const compositionId = el.getAttribute("data-composition-id");
if (compositionId) return `[data-composition-id="${escapeCssString(compositionId)}"]`;
// Group wrappers carry no id/class; their data-hf-group value is the unique,
// stable handle the source mutations write — use it so the wrapper is
// selectable, patchable (move/scale), and addressable for ungroup.
const group = el.getAttribute("data-hf-group");
if (group) return `[data-hf-group="${escapeCssString(group)}"]`;
return getPreferredClassSelector(el);
}
export function getPreferredClassSelector(el: HTMLElement): string | undefined {
function getPreferredClassSelector(el: HTMLElement): string | undefined {
const classes = Array.from(el.classList)
.map((value) => value.trim())
.filter(Boolean);
@@ -283,6 +289,34 @@ export function getPreferredClassSelector(el: HTMLElement): string | undefined {
return preferred ? `.${escapeCssIdentifier(preferred)}` : undefined;
}
// fallow-ignore-next-line complexity
export function buildElementLabel(el: HTMLElement): string {
const compositionId = el.getAttribute("data-composition-id");
if (compositionId && compositionId !== "main") {
return humanizeIdentifier(compositionId);
}
const compositionSrc =
el.getAttribute("data-composition-src") ?? el.getAttribute("data-composition-file");
if (compositionSrc) {
return humanizeIdentifier(compositionSrc);
}
const group = el.getAttribute("data-hf-group");
if (group) return group;
if (el.id) return humanizeIdentifier(el.id);
const preferredClass = getPreferredClassSelector(el);
if (preferredClass) {
return humanizeIdentifier(preferredClass.replace(/^\./, ""));
}
const text = (el.textContent ?? "").trim().replace(/\s+/g, " ");
if (text) return text.length > 40 ? `${text.slice(0, 39)}` : text;
return el.tagName.toLowerCase();
}
export function getSelectorIndex(
doc: Document,
el: HTMLElement,
@@ -0,0 +1,38 @@
import { isHtmlElement } from "./domEditingDom";
// `data-hf-group` selection semantics: a group wrapper is selected as one unit
// until the user drills into it; once drilled in, clicks resolve to its children
// (or to the next nested group inside it). One level of drill-in at a time keeps
// nested groups navigable.
export type GroupCapture =
| { kind: "unit"; element: HTMLElement } // select this group wrapper as one unit
| { kind: "child" } // resolve the clicked element normally
| { kind: "out-of-scope" }; // clicked outside the drilled-into group → select nothing
// Layer-tree roots: the drilled-into group's element children, else the doc root.
export function groupScopedLayerRoots(
root: HTMLElement,
activeGroupElement: HTMLElement | null,
): HTMLElement[] {
const els = activeGroupElement?.isConnected ? Array.from(activeGroupElement.children) : [root];
return els.filter(isHtmlElement);
}
export function resolveGroupCapture(
startEl: HTMLElement,
activeGroupElement: HTMLElement | null,
): GroupCapture {
const groups: HTMLElement[] = [];
for (let n: HTMLElement | null = startEl; n; n = n.parentElement) {
if (n.hasAttribute("data-hf-group")) groups.push(n);
}
if (!activeGroupElement) {
const outermost = groups[groups.length - 1];
return outermost ? { kind: "unit", element: outermost } : { kind: "child" };
}
const idx = groups.indexOf(activeGroupElement);
if (idx === -1) return { kind: "out-of-scope" };
const nestedInside = groups[idx - 1];
return nestedInside ? { kind: "unit", element: nestedInside } : { kind: "child" };
}
@@ -1,6 +1,11 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import { resolveDomEditSelection, buildDomEditPatchTarget, readHfId } from "./domEditingLayers";
import {
collectDomEditLayerItems,
resolveDomEditSelection,
buildDomEditPatchTarget,
readHfId,
} from "./domEditingLayers";
const opts = { activeCompositionPath: "index.html", isMasterView: true, skipSourceProbe: true };
@@ -76,3 +81,92 @@ describe("resolveDomEditSelection — hfId from data-hf-id", () => {
expect(selection?.hfId).toBeUndefined();
});
});
describe("resolveDomEditSelection — data-hf-group capture", () => {
// <div id="parent"><div data-hf-group="Group 1"><div data-hf-group="Group 2">
// <span id="child"/></div></div></div>
function buildNestedGroups() {
const parent = document.createElement("div");
parent.id = "parent";
const outer = document.createElement("div");
outer.setAttribute("data-hf-group", "Group 1");
const inner = document.createElement("div");
inner.setAttribute("data-hf-group", "Group 2");
const child = document.createElement("span");
child.id = "child";
inner.appendChild(child);
outer.appendChild(inner);
parent.appendChild(outer);
document.body.appendChild(parent);
return { parent, outer, inner, child };
}
it("selects the outermost group as a unit when clicking a child (not drilled in)", async () => {
const { parent, outer, child } = buildNestedGroups();
const selection = await resolveDomEditSelection(child, opts);
document.body.removeChild(parent);
expect(selection?.element).toBe(outer);
expect(selection?.selector).toBe('[data-hf-group="Group 1"]');
});
it("selects the next nested group when drilled into the outer group", async () => {
const { parent, outer, inner, child } = buildNestedGroups();
const selection = await resolveDomEditSelection(child, { ...opts, activeGroupElement: outer });
document.body.removeChild(parent);
expect(selection?.element).toBe(inner);
expect(selection?.selector).toBe('[data-hf-group="Group 2"]');
});
it("selects the child when drilled all the way into the innermost group", async () => {
const { parent, inner, child } = buildNestedGroups();
const selection = await resolveDomEditSelection(child, { ...opts, activeGroupElement: inner });
document.body.removeChild(parent);
expect(selection?.element).toBe(child);
expect(selection?.id).toBe("child");
});
it("layer tree is scoped to the group's members when drilled in", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
const group = document.createElement("div");
group.setAttribute("data-hf-group", "Group 1");
const inside = document.createElement("div");
inside.id = "inside";
const outside = document.createElement("div");
outside.id = "outside";
group.appendChild(inside);
root.appendChild(group);
root.appendChild(outside);
document.body.appendChild(root);
const opts2 = { activeCompositionPath: "index.html", isMasterView: true };
const full = collectDomEditLayerItems(root, opts2).map((i) => i.id);
const scoped = collectDomEditLayerItems(root, { ...opts2, activeGroupElement: group }).map(
(i) => i.id,
);
document.body.removeChild(root);
expect(full).toContain("outside");
expect(scoped).toContain("inside");
expect(scoped).not.toContain("outside");
});
it("returns null when clicking outside the group the user is drilled into", async () => {
const { parent, inner } = buildNestedGroups();
const outside = document.createElement("div");
outside.id = "outside";
document.body.appendChild(outside);
const selection = await resolveDomEditSelection(outside, {
...opts,
activeGroupElement: inner,
});
document.body.removeChild(parent);
document.body.removeChild(outside);
expect(selection).toBeNull();
});
});
@@ -3,6 +3,7 @@
* for dom editing.
*/
import type { PatchOperation } from "../../utils/sourcePatcher";
import { groupScopedLayerRoots, resolveGroupCapture } from "./domEditingGroups";
import type {
DomEditCapabilities,
DomEditContextOptions,
@@ -11,15 +12,14 @@ import type {
DomEditTextField,
} from "./domEditingTypes";
import {
buildElementLabel,
buildStableSelector,
findClosestByAttribute,
getCuratedComputedStyles,
getDataAttributes,
getInlineStyles,
getPreferredClassSelector,
getSelectorIndex,
getSourceFileForElement,
humanizeIdentifier,
isHtmlElement,
isIdentityTransform,
isTextBearingTag,
@@ -275,31 +275,6 @@ export function resolveDomEditCapabilities(args: {
// ─── Element label ────────────────────────────────────────────────────────────
// fallow-ignore-next-line complexity
export function buildElementLabel(el: HTMLElement): string {
const compositionId = el.getAttribute("data-composition-id");
if (compositionId && compositionId !== "main") {
return humanizeIdentifier(compositionId);
}
const compositionSrc =
el.getAttribute("data-composition-src") ?? el.getAttribute("data-composition-file");
if (compositionSrc) {
return humanizeIdentifier(compositionSrc);
}
if (el.id) return humanizeIdentifier(el.id);
const preferredClass = getPreferredClassSelector(el);
if (preferredClass) {
return humanizeIdentifier(preferredClass.replace(/^\./, ""));
}
const text = (el.textContent ?? "").trim().replace(/\s+/g, " ");
if (text) return text.length > 40 ? `${text.slice(0, 39)}` : text;
return el.tagName.toLowerCase();
}
// ─── Source probe ────────────────────────────────────────────────────────────
async function probeSourceElement(
@@ -334,7 +309,15 @@ export async function resolveDomEditSelection(
if (!startEl) return null;
const doc = startEl.ownerDocument;
let current: HTMLElement | null = getSelectionCandidate(startEl, options);
let capture = resolveGroupCapture(startEl, options.activeGroupElement ?? null);
if (capture.kind === "out-of-scope") {
// Drill-in is non-sticky: clicking/hovering OUTSIDE the drilled-into group
// exits it and resolves the target normally, rather than selecting nothing
// (which felt like "can't select anything" once you'd drilled in).
capture = resolveGroupCapture(startEl, null);
}
let current: HTMLElement | null =
capture.kind === "unit" ? capture.element : getSelectionCandidate(startEl, options);
while (current && current !== doc.body && current !== doc.documentElement) {
const selector = buildStableSelector(current);
const hfId = readHfId(current);
@@ -501,7 +484,8 @@ export function collectDomEditLayerItems(
}
};
visit(root, 0);
// Drilled into a group → show only its members; otherwise the whole tree.
for (const el of groupScopedLayerRoots(root, options.activeGroupElement ?? null)) visit(el, 0);
return items;
}
@@ -108,6 +108,9 @@ export interface DomEditContextOptions {
activeCompositionPath: string | null;
isMasterView: boolean;
preferClipAncestor?: boolean;
/** The group wrapper the user has drilled into (null = top level). Selection
* resolution treats groups as a unit unless drilled into one. */
activeGroupElement?: HTMLElement | null;
}
export interface DomEditViewport {
@@ -6,6 +6,10 @@ import { KeyframeNavigation } from "./KeyframeNavigation";
import { formatPxMetricValue, parsePxMetricValue, RESPONSIVE_GRID } from "./propertyPanelHelpers";
import { Transform3DCube, type CubePose } from "./Transform3DCube";
// Default perspective (px) applied when depth is first set, so translateZ is
// visible. ~800px is a moderate lens — closer = stronger foreshortening.
const DEFAULT_DEPTH_PERSPECTIVE = 800;
type KeyframeEntry = Array<{
percentage: number;
properties: Record<string, number | string>;
@@ -123,18 +127,36 @@ function Cube3dControl({
<Transform3DCube
pose={pose}
perspective={gsapRuntimeValues.transformPerspective ?? 0}
z={gsapRuntimeValues.z ?? 0}
onPoseDraft={livePreview}
onPoseCommit={commitPose}
onPerspectiveDraft={(px) => onLivePreviewProps?.(element, { transformPerspective: px })}
onPerspectiveCommit={(px) =>
void onCommitAnimatedProperty(element, "transformPerspective", px)
onDepthDraft={(z) =>
onLivePreviewProps?.(
element,
gsapRuntimeValues.transformPerspective
? { z }
: { z, transformPerspective: DEFAULT_DEPTH_PERSPECTIVE },
)
}
onDepthCommit={(z) => {
// translateZ is invisible without a perspective lens — apply a sensible
// default the first time depth is set so scrolling visibly moves the
// element. The user can still fine-tune via the Perspective field.
if (!gsapRuntimeValues.transformPerspective) {
void onCommitAnimatedProperty(
element,
"transformPerspective",
DEFAULT_DEPTH_PERSPECTIVE,
);
}
void onCommitAnimatedProperty(element, "z", z);
}}
onRecenter={recenter}
onKeyframe={onKeyframe}
keyframed={keyframed}
/>
<p className="mt-1 text-center text-[9px] leading-snug text-neutral-600">
Drag to tilt · Shift-drag to roll
Drag to tilt · Shift-drag to roll · Scroll for depth
</p>
</div>
</div>
@@ -13,6 +13,8 @@ export interface PropertyPanelProps {
multiSelectCount?: number;
copiedAgentPrompt: boolean;
onClearSelection: () => void;
/** Dissolve the selected data-hf-group wrapper (shown only for group selections). */
onUngroup?: () => void;
onSetStyle: (prop: string, value: string) => void | Promise<void>;
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
onSetAttributeLive: (attr: string, value: string | null) => void | Promise<void>;
@@ -11,6 +11,7 @@ import {
type ResolvedElementRef,
groupOverlayItemsEqual,
isElementVisibleForOverlay,
groupAwareOverlayRect,
rectsEqual,
resolveElementForOverlay,
selectionCacheKey,
@@ -155,7 +156,7 @@ export function useDomEditOverlayRects({
// backgroundless full-bleed scene above a subcomposition), which would wrongly
// hide the selection box. Occlusion stays for hover, where a false hide is cheap.
if (el && isElementVisibleForOverlay(el)) {
const nextRect = toOverlayRect(overlayEl, iframe, el);
const nextRect = groupAwareOverlayRect(overlayEl, iframe, el);
setOverlayRect(nextRect);
const descendants = el.querySelectorAll("*");
if (descendants.length > 0 && descendants.length <= 60) {
@@ -196,9 +197,13 @@ export function useDomEditOverlayRects({
const liveGroupKeys = new Set<string>();
for (const groupSelection of group) {
const key = selectionCacheKey(groupSelection);
// Members of the same group collapse to one selection under select-as-unit,
// so a multi-select can hold the same group twice — dedupe by key to avoid
// duplicate React keys (and a doubled overlay box).
if (liveGroupKeys.has(key)) continue;
liveGroupKeys.add(key);
const el = resolveGroupElement(doc, groupSelection);
const rect = el ? toOverlayRect(overlayEl, iframe, el) : null;
const rect = el ? groupAwareOverlayRect(overlayEl, iframe, el) : null;
if (el && rect)
nextGroupItems.push({ key, selection: groupSelection, element: el, rect });
}
@@ -235,7 +240,7 @@ export function useDomEditOverlayRects({
return;
}
setHoverRect(toOverlayRect(overlayEl, iframe, hoverEl));
setHoverRect(groupAwareOverlayRect(overlayEl, iframe, hoverEl));
};
frame = requestAnimationFrame(update);