From 1eed7a9b1f8bf7b93ad8cc22bcc7125c644c43d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 27 Jun 2026 11:27:40 -0400 Subject: [PATCH] =?UTF-8?q?feat(studio):=20element=20groups=20=E2=80=94=20?= =?UTF-8?q?studio=20UI=20(#1759)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- packages/studio/src/App.tsx | 2 + .../src/components/StudioRightPanel.tsx | 2 + .../src/components/editor/DomEditOverlay.tsx | 7 +- .../editor/InspectorHeaderActions.tsx | 62 +++++++ .../src/components/editor/LayersPanel.tsx | 37 ++++- .../src/components/editor/PropertyPanel.tsx | 43 ++--- .../src/components/editor/Transform3DCube.tsx | 153 ++++++++---------- .../editor/domEditOverlayGeometry.ts | 28 ++++ .../src/components/editor/domEditingDom.ts | 38 ++++- .../src/components/editor/domEditingGroups.ts | 38 +++++ .../editor/domEditingLayers.test.ts | 96 ++++++++++- .../src/components/editor/domEditingLayers.ts | 42 ++--- .../src/components/editor/domEditingTypes.ts | 3 + .../editor/propertyPanel3dTransform.tsx | 30 +++- .../components/editor/propertyPanelHelpers.ts | 2 + .../editor/useDomEditOverlayRects.ts | 11 +- .../studio/src/contexts/DomEditContext.tsx | 16 ++ packages/studio/src/hooks/useAppHotkeys.ts | 17 ++ .../studio/src/hooks/useDomEditSession.ts | 45 ++++++ packages/studio/src/hooks/useDomSelection.ts | 82 ++++++++-- .../studio/src/hooks/usePreviewInteraction.ts | 28 +++- .../src/player/components/ShortcutsPanel.tsx | 2 + .../studio/src/utils/studioPreviewHelpers.ts | 37 +++++ 23 files changed, 651 insertions(+), 170 deletions(-) create mode 100644 packages/studio/src/components/editor/InspectorHeaderActions.tsx create mode 100644 packages/studio/src/components/editor/domEditingGroups.ts diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index d3c761a7a..fe9875970 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -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 diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index 07df9b84f..5c0c8701e 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -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} diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index bc1394afa..f0861be64 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -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(); 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). diff --git a/packages/studio/src/components/editor/InspectorHeaderActions.tsx b/packages/studio/src/components/editor/InspectorHeaderActions.tsx new file mode 100644 index 000000000..a8ea81502 --- /dev/null +++ b/packages/studio/src/components/editor/InspectorHeaderActions.tsx @@ -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 ( +
+ {onUngroup && element.dataAttributes["hf-group"] != null && ( + + )} + + +
+ ); +} diff --git a/packages/studio/src/components/editor/LayersPanel.tsx b/packages/studio/src/components/editor/LayersPanel.tsx index 510421c79..15ffedca1 100644 --- a/packages/studio/src/components/editor/LayersPanel.tsx +++ b/packages/studio/src/components/editor/LayersPanel.tsx @@ -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([]); @@ -86,12 +88,16 @@ export const LayersPanel = memo(function LayersPanel() { doc.querySelector("[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 && ( + + )} {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) => { diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index 17bb2c53e..a082ac67f 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -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({
{sourceLabel}
-
- - -
+
diff --git a/packages/studio/src/components/editor/Transform3DCube.tsx b/packages/studio/src/components/editor/Transform3DCube.tsx index 8475b0419..adf7de058 100644 --- a/packages/studio/src/components/editor/Transform3DCube.tsx +++ b/packages/studio/src/components/editor/Transform3DCube.tsx @@ -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(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 ( -
- Persp -
{ - 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" - > -
-
-
-
-
- ); -} - 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(null); + const [depthDraft, setDepthDraft] = useState(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(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 | 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) => { 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 (
)} - {onPerspectiveCommit && ( - - )}
); } diff --git a/packages/studio/src/components/editor/domEditOverlayGeometry.ts b/packages/studio/src/components/editor/domEditOverlayGeometry.ts index fa1a61107..f1e73b759 100644 --- a/packages/studio/src/components/editor/domEditOverlayGeometry.ts +++ b/packages/studio/src/components/editor/domEditOverlayGeometry.ts @@ -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(items: T[]): T[] { return items.filter( (item) => !items.some((other) => other !== item && other.element.contains(item.element)), diff --git a/packages/studio/src/components/editor/domEditingDom.ts b/packages/studio/src/components/editor/domEditingDom.ts index b4ab83aa3..42505e3b1 100644 --- a/packages/studio/src/components/editor/domEditingDom.ts +++ b/packages/studio/src/components/editor/domEditingDom.ts @@ -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, diff --git a/packages/studio/src/components/editor/domEditingGroups.ts b/packages/studio/src/components/editor/domEditingGroups.ts new file mode 100644 index 000000000..5dc0a4f19 --- /dev/null +++ b/packages/studio/src/components/editor/domEditingGroups.ts @@ -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" }; +} diff --git a/packages/studio/src/components/editor/domEditingLayers.test.ts b/packages/studio/src/components/editor/domEditingLayers.test.ts index beb7fb480..03b83743b 100644 --- a/packages/studio/src/components/editor/domEditingLayers.test.ts +++ b/packages/studio/src/components/editor/domEditingLayers.test.ts @@ -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", () => { + //
+ //
+ 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(); + }); +}); diff --git a/packages/studio/src/components/editor/domEditingLayers.ts b/packages/studio/src/components/editor/domEditingLayers.ts index 536abba8d..5037c7831 100644 --- a/packages/studio/src/components/editor/domEditingLayers.ts +++ b/packages/studio/src/components/editor/domEditingLayers.ts @@ -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; } diff --git a/packages/studio/src/components/editor/domEditingTypes.ts b/packages/studio/src/components/editor/domEditingTypes.ts index da52f4f2a..d6ec3cafd 100644 --- a/packages/studio/src/components/editor/domEditingTypes.ts +++ b/packages/studio/src/components/editor/domEditingTypes.ts @@ -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 { diff --git a/packages/studio/src/components/editor/propertyPanel3dTransform.tsx b/packages/studio/src/components/editor/propertyPanel3dTransform.tsx index 6710dc9bf..22b1aea83 100644 --- a/packages/studio/src/components/editor/propertyPanel3dTransform.tsx +++ b/packages/studio/src/components/editor/propertyPanel3dTransform.tsx @@ -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; @@ -123,18 +127,36 @@ function Cube3dControl({ 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} />

- Drag to tilt · Shift-drag to roll + Drag to tilt · Shift-drag to roll · Scroll for depth

diff --git a/packages/studio/src/components/editor/propertyPanelHelpers.ts b/packages/studio/src/components/editor/propertyPanelHelpers.ts index 0899ec779..20d90e0fc 100644 --- a/packages/studio/src/components/editor/propertyPanelHelpers.ts +++ b/packages/studio/src/components/editor/propertyPanelHelpers.ts @@ -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; onSetAttribute: (attr: string, value: string) => void | Promise; onSetAttributeLive: (attr: string, value: string | null) => void | Promise; diff --git a/packages/studio/src/components/editor/useDomEditOverlayRects.ts b/packages/studio/src/components/editor/useDomEditOverlayRects.ts index bf3819b40..df7e12b5f 100644 --- a/packages/studio/src/components/editor/useDomEditOverlayRects.ts +++ b/packages/studio/src/components/editor/useDomEditOverlayRects.ts @@ -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(); 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); diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index 0222a9bff..71206d145 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -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, diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index 56cfffd98..44ef0ec1d 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -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; domEditSelectionRef: React.MutableRefObject; 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, diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 96ffdbc09..e749c0a7f 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -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, diff --git a/packages/studio/src/hooks/useDomSelection.ts b/packages/studio/src/hooks/useDomSelection.ts index 6c9fc36ab..c3f50874a 100644 --- a/packages/studio/src/hooks/useDomSelection.ts +++ b/packages/studio/src/hooks/useDomSelection.ts @@ -48,13 +48,16 @@ export interface UseDomSelectionReturn { domEditSelection: DomEditSelection | null; domEditGroupSelections: DomEditSelection[]; domEditHoverSelection: DomEditSelection | null; + activeGroupElement: HTMLElement | null; // Refs domEditSelectionRef: React.MutableRefObject; domEditGroupSelectionsRef: React.MutableRefObject; domEditHoverSelectionRef: React.MutableRefObject; + activeGroupElementRef: React.MutableRefObject; // State setters (needed by useDomEditSession for agent-prompt reset flows) setDomEditSelection: React.Dispatch>; setDomEditGroupSelections: React.Dispatch>; + 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; resolveDomSelectionFromPreviewPoint: ( clientX: number, clientY: number, - options?: { preferClipAncestor?: boolean }, + options?: { + preferClipAncestor?: boolean; + skipSourceProbe?: boolean; + activeGroupElement?: HTMLElement | null; + }, ) => Promise; resolveAllDomSelectionsFromPreviewPoint: ( clientX: number, @@ -110,17 +121,21 @@ export function useDomSelection({ const [domEditSelection, setDomEditSelection] = useState(null); const [domEditGroupSelections, setDomEditGroupSelections] = useState([]); const [domEditHoverSelection, setDomEditHoverSelection] = useState(null); + // The data-hf-group wrapper the user has drilled into (null = top level). + const [activeGroupElement, setActiveGroupElementState] = useState(null); // ── Refs ── const domEditSelectionRef = useRef(domEditSelection); const domEditGroupSelectionsRef = useRef(domEditGroupSelections); const domEditHoverSelectionRef = useRef(domEditHoverSelection); + const activeGroupElementRef = useRef(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, diff --git a/packages/studio/src/hooks/usePreviewInteraction.ts b/packages/studio/src/hooks/usePreviewInteraction.ts index d3887ff71..148b8207d 100644 --- a/packages/studio/src/hooks/usePreviewInteraction.ts +++ b/packages/studio/src/hooks/usePreviewInteraction.ts @@ -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; resolveAllDomSelectionsFromPreviewPoint: ( clientX: number, clientY: number, ) => Promise; 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(null); @@ -62,6 +69,24 @@ export function usePreviewInteraction({ async (e: React.MouseEvent, 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, ], ); diff --git a/packages/studio/src/player/components/ShortcutsPanel.tsx b/packages/studio/src/player/components/ShortcutsPanel.tsx index 88c21eb37..52b39e750 100644 --- a/packages/studio/src/player/components/ShortcutsPanel.tsx +++ b/packages/studio/src/player/components/ShortcutsPanel.tsx @@ -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" }, ], }, diff --git a/packages/studio/src/utils/studioPreviewHelpers.ts b/packages/studio/src/utils/studioPreviewHelpers.ts index 2ec9911bc..455844b8b 100644 --- a/packages/studio/src/utils/studioPreviewHelpers.ts +++ b/packages/studio/src/utils/studioPreviewHelpers.ts @@ -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("[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;