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
+2
View File
@@ -258,6 +258,8 @@ export function StudioApp() {
onResetKeyframes: () => resetKeyframesRef.current(),
onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(),
onAfterUndoRedo: () => invalidateGsapCacheRef.current(),
onGroupSelection: () => domEditSessionRef.current.handleGroupSelection(),
onUngroupSelection: () => domEditSessionRef.current.handleUngroupSelection(),
activeCompPath,
forceReloadSdkSession: sdkHandle.forceReload,
onToggleRecording: STUDIO_KEYFRAMES_ENABLED
@@ -93,6 +93,7 @@ export function StudioRightPanel({
domEditGroupSelections,
copiedAgentPrompt,
clearDomSelection,
handleUngroupSelection,
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomAttributeLiveCommit,
@@ -241,6 +242,7 @@ export function StudioRightPanel({
multiSelectCount={domEditGroupSelections.length}
copiedAgentPrompt={copiedAgentPrompt}
onClearSelection={clearDomSelection}
onUngroup={handleUngroupSelection}
onSetStyle={handleDomStyleCommit}
onSetAttribute={handleDomAttributeCommit}
onSetAttributeLive={handleDomAttributeLiveCommit}
@@ -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);
@@ -31,6 +31,9 @@ export interface DomEditActionsValue extends Pick<
| "handleBlockedDomMove"
| "handleDomManualDragStart"
| "handleDomEditElementDelete"
| "handleGroupSelection"
| "handleUngroupSelection"
| "setActiveGroupElement"
| "buildDomSelectionFromTarget"
| "buildDomSelectionForTimelineElement"
| "updateDomEditHoverSelection"
@@ -72,6 +75,7 @@ export interface DomEditSelectionValue extends Pick<
| "domEditSelection"
| "domEditGroupSelections"
| "domEditHoverSelection"
| "activeGroupElement"
| "domEditSelectionRef"
| "selectedGsapAnimations"
| "gsapMultipleTimelines"
@@ -138,6 +142,10 @@ export function DomEditProvider({
handleBlockedDomMove,
handleDomManualDragStart,
handleDomEditElementDelete,
handleGroupSelection,
handleUngroupSelection,
setActiveGroupElement,
activeGroupElement,
buildDomSelectionFromTarget,
buildDomSelectionForTimelineElement,
updateDomEditHoverSelection,
@@ -216,6 +224,9 @@ export function DomEditProvider({
handleBlockedDomMove,
handleDomManualDragStart,
handleDomEditElementDelete,
handleGroupSelection,
handleUngroupSelection,
setActiveGroupElement,
buildDomSelectionFromTarget,
buildDomSelectionForTimelineElement,
updateDomEditHoverSelection,
@@ -277,6 +288,9 @@ export function DomEditProvider({
handleBlockedDomMove,
handleDomManualDragStart,
handleDomEditElementDelete,
handleGroupSelection,
handleUngroupSelection,
setActiveGroupElement,
buildDomSelectionFromTarget,
buildDomSelectionForTimelineElement,
updateDomEditHoverSelection,
@@ -319,6 +333,7 @@ export function DomEditProvider({
domEditSelection,
domEditGroupSelections,
domEditHoverSelection,
activeGroupElement,
domEditSelectionRef,
selectedGsapAnimations,
gsapMultipleTimelines,
@@ -332,6 +347,7 @@ export function DomEditProvider({
domEditSelection,
domEditGroupSelections,
domEditHoverSelection,
activeGroupElement,
domEditSelectionRef,
selectedGsapAnimations,
gsapMultipleTimelines,
@@ -117,6 +117,10 @@ interface UseAppHotkeysParams {
onDeleteSelectedKeyframes: () => void;
onAfterUndoRedo?: () => void;
onToggleRecording?: () => void;
/** Group the current multi-selection into a data-hf-group wrapper (⌘G). */
onGroupSelection?: () => void;
/** Ungroup the selected group wrapper (⌘⇧G). */
onUngroupSelection?: () => void;
/** Active composition path — used to decide whether undo/redo must resync the SDK session. */
activeCompPath?: string | null;
/**
@@ -142,6 +146,8 @@ interface HotkeyCallbacks {
onResetKeyframes: () => boolean;
onDeleteSelectedKeyframes: () => void;
onToggleRecording?: () => void;
onGroupSelection?: () => void;
onUngroupSelection?: () => void;
leftSidebarRef: React.RefObject<LeftSidebarHandle | null>;
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
showToast: (message: string, tone?: "error" | "info") => void;
@@ -169,6 +175,13 @@ function dispatchModifierKey(event: KeyboardEvent, key: string, cb: HotkeyCallba
return true;
}
if (key === "g" && !event.altKey && !isEditableTarget(event.target)) {
event.preventDefault();
if (event.shiftKey) cb.onUngroupSelection?.();
else cb.onGroupSelection?.();
return true;
}
if (!event.shiftKey && !event.altKey && !isEditableTarget(event.target)) {
if (key === "c") {
if (cb.handleCopy()) event.preventDefault();
@@ -310,6 +323,8 @@ export function useAppHotkeys({
onDeleteSelectedKeyframes,
onAfterUndoRedo,
onToggleRecording,
onGroupSelection,
onUngroupSelection,
activeCompPath,
forceReloadSdkSession,
}: UseAppHotkeysParams) {
@@ -403,6 +418,8 @@ export function useAppHotkeys({
onResetKeyframes,
onDeleteSelectedKeyframes,
onToggleRecording,
onGroupSelection,
onUngroupSelection,
leftSidebarRef,
domEditSelectionRef,
showToast,
@@ -12,6 +12,7 @@ import { useAskAgentModal } from "./useAskAgentModal";
import { useDomSelection } from "./useDomSelection";
import { usePreviewInteraction } from "./usePreviewInteraction";
import { useDomEditCommits } from "./useDomEditCommits";
import { useGroupCommits } from "./useGroupCommits";
import { useGsapScriptCommits } from "./useGsapScriptCommits";
import { useGsapCacheVersion } from "./useGsapTweenCache";
import { useDomEditWiring } from "./useDomEditWiring";
@@ -114,7 +115,10 @@ export function useDomEditSession({
domEditSelection,
domEditGroupSelections,
domEditHoverSelection,
activeGroupElement,
domEditSelectionRef,
domEditGroupSelectionsRef,
setActiveGroupElement,
applyDomSelection,
clearDomSelection,
buildDomSelectionFromTarget,
@@ -279,6 +283,42 @@ export function useDomEditSession({
: undefined,
});
// ── Element groups (wrap selected elements in a data-hf-group div) ──
const { groupSelection, ungroupSelection } = useGroupCommits({
activeCompPath,
showToast,
writeProjectFile,
domEditSaveTimestampRef,
editHistory,
projectIdRef,
reloadPreview,
clearDomSelection,
forceReloadSdkSession,
});
const handleGroupSelection = useCallback(() => {
const group = domEditGroupSelectionsRef.current;
const single = domEditSelectionRef.current;
const members = group.length > 0 ? group : single ? [single] : [];
if (members.length < 2) {
showToast("Select at least 2 elements to group", "info");
return;
}
void groupSelection(members);
}, [domEditGroupSelectionsRef, domEditSelectionRef, groupSelection, showToast]);
const handleUngroupSelection = useCallback(() => {
const sel = domEditSelectionRef.current;
if (!sel?.element.hasAttribute("data-hf-group")) {
showToast("Select a group to ungroup", "info");
return;
}
// Dissolving the group exits any drill-in (the wrapper is about to vanish).
setActiveGroupElement(null);
void ungroupSelection(sel);
}, [domEditSelectionRef, ungroupSelection, setActiveGroupElement, showToast]);
// ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ──
const {
@@ -360,6 +400,7 @@ export function useDomEditSession({
resolveDomSelectionFromPreviewPoint,
resolveAllDomSelectionsFromPreviewPoint,
updateDomEditHoverSelection,
setActiveGroupElement,
onClickToSource,
});
@@ -435,6 +476,7 @@ export function useDomEditSession({
domEditSelection,
domEditGroupSelections,
domEditHoverSelection,
activeGroupElement,
agentModalOpen,
agentModalAnchorPoint,
copiedAgentPrompt,
@@ -467,6 +509,9 @@ export function useDomEditSession({
handleBlockedDomMove,
handleDomManualDragStart,
handleDomEditElementDelete,
handleGroupSelection,
handleUngroupSelection,
setActiveGroupElement,
buildDomSelectionFromTarget,
buildDomSelectionForTimelineElement,
updateDomEditHoverSelection,
+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,
@@ -20,13 +20,19 @@ export interface UsePreviewInteractionParams {
resolveDomSelectionFromPreviewPoint: (
clientX: number,
clientY: number,
options?: { preferClipAncestor?: boolean; skipSourceProbe?: boolean },
options?: {
preferClipAncestor?: boolean;
skipSourceProbe?: boolean;
activeGroupElement?: HTMLElement | null;
},
) => Promise<DomEditSelection | null>;
resolveAllDomSelectionsFromPreviewPoint: (
clientX: number,
clientY: number,
) => Promise<DomEditSelection[]>;
updateDomEditHoverSelection: (selection: DomEditSelection | null) => void;
/** Drill into a group (double-click on the canvas) so its children become selectable. */
setActiveGroupElement: (el: HTMLElement | null) => void;
onClickToSource?: (selection: DomEditSelection) => void;
}
@@ -53,6 +59,7 @@ export function usePreviewInteraction({
resolveDomSelectionFromPreviewPoint,
resolveAllDomSelectionsFromPreviewPoint,
updateDomEditHoverSelection,
setActiveGroupElement,
onClickToSource,
}: UsePreviewInteractionParams) {
const cycleRef = useRef<ClickCycleState | null>(null);
@@ -62,6 +69,24 @@ export function usePreviewInteraction({
async (e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) return;
// Double-click a group → drill into it and select the child under the
// pointer (resolve with the group as the explicit drill-in scope, since the
// activeGroupElement state hasn't re-rendered yet within this handler).
if (e.detail >= 2 && !e.shiftKey) {
const hit = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY);
if (hit?.element.hasAttribute("data-hf-group")) {
e.preventDefault();
e.stopPropagation();
cycleRef.current = null;
setActiveGroupElement(hit.element);
const child = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
activeGroupElement: hit.element,
});
applyDomSelection(child ?? hit);
return;
}
}
const now = Date.now();
const prev = cycleRef.current;
const dx = prev ? e.clientX - prev.x : Infinity;
@@ -125,6 +150,7 @@ export function usePreviewInteraction({
onClickToSource,
resolveAllDomSelectionsFromPreviewPoint,
resolveDomSelectionFromPreviewPoint,
setActiveGroupElement,
],
);
@@ -36,6 +36,8 @@ const SHORTCUT_SECTIONS = [
{ key: "⌘V", label: "Paste element" },
{ key: "⌘X", label: "Cut element" },
{ key: "S", label: "Split clip at playhead" },
{ key: "⌘G", label: "Group elements" },
{ key: "⌘⇧G", label: "Ungroup" },
{ key: "Del", label: "Delete selected element" },
],
},
@@ -81,6 +81,37 @@ function removePointerEventsOverride(style: HTMLStyleElement | null): void {
}
}
// Animated group members can move outside their wrapper's static layout box, so
// the empty space inside a group's *visual* bounds (the member-union the overlay
// draws) doesn't hit-test to the group via elementsFromPoint. Recover it: if the
// point falls within a group's live member-union rect, return that wrapper.
// Innermost (smallest-area) group wins for nested groups.
function findGroupAtPoint(doc: Document, x: number, y: number): HTMLElement | null {
let best: HTMLElement | null = null;
let bestArea = Infinity;
for (const group of Array.from(doc.querySelectorAll<HTMLElement>("[data-hf-group]"))) {
let left = Infinity;
let top = Infinity;
let right = -Infinity;
let bottom = -Infinity;
for (const member of Array.from(group.children)) {
const r = member.getBoundingClientRect();
if (r.width === 0 && r.height === 0) continue;
left = Math.min(left, r.left);
top = Math.min(top, r.top);
right = Math.max(right, r.right);
bottom = Math.max(bottom, r.bottom);
}
if (right < left || x < left || x > right || y < top || y > bottom) continue;
const area = (right - left) * (bottom - top);
if (area < bestArea) {
bestArea = area;
best = group;
}
}
return best;
}
// fallow-ignore-next-line complexity
export function getPreviewTargetFromPointer(
iframe: HTMLIFrameElement,
@@ -113,6 +144,12 @@ export function getPreviewTargetFromPointer(
if (visualTarget) return visualTarget;
}
// No element hit (e.g. empty space inside an animated group's overlay) — fall
// back to the group whose member-union contains the point, so the whole group
// area is hoverable/selectable, not just where a member currently sits.
const groupHit = findGroupAtPoint(doc, localPointer.x, localPointer.y);
if (groupHit && getDomLayerPatchTarget(groupHit, activeCompositionPath)) return groupHit;
const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return null;
if (!isElementComputedVisible(fallback)) return null;