diff --git a/packages/studio-server/src/helpers/sourceMutation.test.ts b/packages/studio-server/src/helpers/sourceMutation.test.ts index a0f3dedcb1..fd28dca529 100644 --- a/packages/studio-server/src/helpers/sourceMutation.test.ts +++ b/packages/studio-server/src/helpers/sourceMutation.test.ts @@ -1,9 +1,12 @@ +import { parseHTML } from "linkedom"; import { describe, expect, it } from "vitest"; import { removeElementFromHtml, patchElementInHtml, splitElementInHtml, probeElementInSource, + wrapElementsInHtml, + unwrapElementsFromHtml, } from "./sourceMutation.js"; describe("removeElementFromHtml", () => { @@ -539,3 +542,147 @@ describe("splitElementInHtml", () => { expect(result.html).toMatch(/id="box-split"[^>]*data-playback-start="2"/); }); }); + +describe("wrapElementsInHtml / unwrapElementsFromHtml", () => { + // Three positioning flavours the rebase must leave visually identical: + // plain inline left/top, a GSAP transform delta, and a --hf-studio-offset var. + const FIXTURE = `
+
Title
+ +
Badge
+
Outside
+
`; + + // bbox top-left = (min left, min top) over the three members. + const BBOX = { left: 260, top: 50, width: 300, height: 300 }; + const REBASES = [ + { target: { id: "title" }, left: 0, top: 50 }, // 260-260, 100-50 + { target: { id: "logo" }, left: 40, top: 150 }, // 300-260, 200-50 + { target: { id: "badge" }, left: 140, top: 0 }, // 400-260, 50-50 + ]; + const TARGETS = [{ id: "title" }, { id: "logo" }, { id: "badge" }]; + + function leftTop(el: Element): { left: number; top: number } { + const style = el.getAttribute("style") ?? ""; + const left = parseFloat(/(?:^|;)\s*left\s*:\s*([\d.]+)px/.exec(style)?.[1] ?? "NaN"); + const top = parseFloat(/(?:^|;)\s*top\s*:\s*([\d.]+)px/.exec(style)?.[1] ?? "NaN"); + return { left, top }; + } + + it("wraps members in a data-hf-group div, preserving order and rebasing left/top", () => { + const { html, matched, groupId } = wrapElementsInHtml( + FIXTURE, + TARGETS, + "Group 1", + BBOX, + REBASES, + ); + expect(matched).toBe(true); + expect(groupId).toBe("Group 1"); + + const { document } = parseHTML(html); + const group = document.querySelector('[data-hf-group="Group 1"]')!; + expect(group).not.toBeNull(); + + // Wrapper sits at the bbox top-left. + expect(leftTop(group)).toEqual({ left: 260, top: 50 }); + + // Members are inside the wrapper, in original DOM order (= z-order). + const childIds = Array.from(group.children).map((c) => c.id); + expect(childIds).toEqual(["title", "logo", "badge"]); + + // Non-member stays outside. + expect(document.querySelector("#outside")!.parentElement).toBe( + document.querySelector('[data-composition-id="main"]'), + ); + + // Each member rebased; transform + offset var untouched. + expect(leftTop(document.querySelector("#title")!)).toEqual({ left: 0, top: 50 }); + expect(leftTop(document.querySelector("#logo")!)).toEqual({ left: 40, top: 150 }); + expect(document.querySelector("#logo")!.getAttribute("style")).toContain( + "transform: translate(10px, 5px)", + ); + expect(leftTop(document.querySelector("#badge")!)).toEqual({ left: 140, top: 0 }); + expect(document.querySelector("#badge")!.getAttribute("style")).toContain( + "--hf-studio-offset: 12px", + ); + }); + + it("round-trips: unwrap restores original structure and coordinates", () => { + const wrapped = wrapElementsInHtml(FIXTURE, TARGETS, "Group 1", BBOX, REBASES).html; + const { html, unwrapped } = unwrapElementsFromHtml(wrapped, { + selector: '[data-hf-group="Group 1"]', + }); + expect(unwrapped).toBe(true); + + const { document } = parseHTML(html); + expect(document.querySelector("[data-hf-group]")).toBeNull(); + + const main = document.querySelector('[data-composition-id="main"]')!; + // Members back in the parent, original order relative to the outside sibling. + expect(Array.from(main.children).map((c) => c.id)).toEqual([ + "title", + "logo", + "badge", + "outside", + ]); + + // Coordinates restored; transform + offset var intact. + expect(leftTop(document.querySelector("#title")!)).toEqual({ left: 260, top: 100 }); + expect(leftTop(document.querySelector("#logo")!)).toEqual({ left: 300, top: 200 }); + expect(document.querySelector("#logo")!.getAttribute("style")).toContain( + "transform: translate(10px, 5px)", + ); + expect(leftTop(document.querySelector("#badge")!)).toEqual({ left: 400, top: 50 }); + expect(document.querySelector("#badge")!.getAttribute("style")).toContain( + "--hf-studio-offset: 12px", + ); + }); + + it("rejects members that do not share a single parent", () => { + const split = `
`; + const result = wrapElementsInHtml(split, [{ id: "a" }, { id: "b" }], "Group 1", BBOX, [ + { target: { id: "a" }, left: 0, top: 0 }, + { target: { id: "b" }, left: 0, top: 0 }, + ]); + expect(result.matched).toBe(false); + expect(result.error).toMatch(/single parent/); + expect(result.html).toBe(split); + }); + + it("lifts the group to the topmost member's slot so an interleaved non-member falls below it", () => { + // [low, middle (non-member), high]; group {low, high}. The group adopts the + // topmost member's stacking, so `middle` ends up BELOW the wrapper (not hoisted + // above it), and the wrapper carries the max member z-index. + const fixture = `
`; + const { html, matched } = wrapElementsInHtml( + fixture, + [{ id: "low" }, { id: "high" }], + "Group 1", + { left: 0, top: 0, width: 10, height: 10 }, + [ + { target: { id: "low" }, left: 0, top: 0 }, + { target: { id: "high" }, left: 0, top: 0 }, + ], + ); + expect(matched).toBe(true); + const { document } = parseHTML(html); + const parent = document.querySelector('[data-composition-id="main"]')!; + const group = document.querySelector('[data-hf-group="Group 1"]')!; + expect(Array.from(group.children).map((c) => c.id)).toEqual(["low", "high"]); + // Non-member sits BEFORE (below) the group, not after (above) it. + const topChildren = Array.from(parent.children).map( + (c) => c.getAttribute("data-hf-group") ?? c.id, + ); + expect(topChildren).toEqual(["middle", "Group 1"]); + // Wrapper adopts the topmost member's z-index (max of 2 and 4). + expect(group.getAttribute("style")).toMatch(/z-index:\s*4/); + }); + + it("refuses to unwrap an element without data-hf-group (no silent corruption)", () => { + const html = `
`; + const result = unwrapElementsFromHtml(html, { id: "plain" }); + expect(result.unwrapped).toBe(false); + expect(result.html).toBe(html); + }); +}); diff --git a/packages/studio-server/src/helpers/sourceMutation.ts b/packages/studio-server/src/helpers/sourceMutation.ts index 14951aaf33..7b957e91a3 100644 --- a/packages/studio-server/src/helpers/sourceMutation.ts +++ b/packages/studio-server/src/helpers/sourceMutation.ts @@ -135,7 +135,7 @@ export interface PatchOperation { } // fallow-ignore-next-line complexity -function patchStyleAttrString(style: string, property: string, value: string | null): string { +function parseStyleDecls(style: string): { props: Map; order: string[] } { const props = new Map(); const order: string[] = []; // Tokenize declarations robustly: values can contain ';' inside quoted strings @@ -171,6 +171,18 @@ function patchStyleAttrString(style: string, property: string, value: string | n if (!props.has(key)) order.push(key); props.set(key, val); } + return { props, order }; +} + +function serializeStyleDecls(props: Map, order: string[]): string { + return order + .map((k) => `${k}: ${props.get(k) ?? ""}`) + .filter((d) => d.trim()) + .join("; "); +} + +function patchStyleAttrString(style: string, property: string, value: string | null): string { + const { props, order } = parseStyleDecls(style); if (value === null) { props.delete(property); const idx = order.indexOf(property); @@ -179,10 +191,7 @@ function patchStyleAttrString(style: string, property: string, value: string | n if (!props.has(property)) order.push(property); props.set(property, value); } - return order - .map((k) => `${k}: ${props.get(k) ?? ""}`) - .filter((d) => d.trim()) - .join("; "); + return serializeStyleDecls(props, order); } // fallow-ignore-next-line complexity @@ -376,3 +385,191 @@ export function splitElementInHtml( newId, }; } + +// --- Element grouping ------------------------------------------------------- +// A group is a real `
` wrapping its members in the DOM. +// Wrapping rebases each member's left/top so its absolute position is unchanged: +// the wrapper sits at the selection bbox top-left, and each child's new left/top +// is its old left/top minus the wrapper origin (computed client-side, where live +// layout is available, and passed in via `rebases`). GSAP x/y, CSS translate and +// --hf-studio-offset vars are deltas relative to flow position and stay untouched. + +export interface WrapElementsResult { + html: string; + matched: boolean; + groupId: string | null; + error?: string; +} + +export interface UnwrapElementsResult { + html: string; + unwrapped: boolean; +} + +export interface ElementRebase { + target: SourceMutationTarget; + left: number; + top: number; +} + +function getInlineStylePx(el: Element, property: string): number { + const style = (isHTMLElement(el) ? el.getAttribute("style") : null) ?? ""; + const { props } = parseStyleDecls(style); + const raw = props.get(property); + if (!raw) return 0; + const n = parseFloat(raw); + return Number.isFinite(n) ? n : 0; +} + +function setInlineLeftTop(el: HTMLElement, left: number, top: number): void { + let style = el.getAttribute("style") ?? ""; + style = patchStyleAttrString(style, "left", `${left}px`); + style = patchStyleAttrString(style, "top", `${top}px`); + el.setAttribute("style", style); +} + +// Slug the group name ("Group 1" → "group-1") into a unique, valid element id. +function uniqueGroupDomId(document: Document, groupId: string): string { + const base = + groupId + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "group"; + let id = base; + let n = 2; + while (document.getElementById(id)) { + id = `${base}-${n}`; + n += 1; + } + return id; +} + +// fallow-ignore-next-line complexity +export function wrapElementsInHtml( + source: string, + targets: SourceMutationTarget[], + groupId: string, + bbox: { left: number; top: number; width: number; height: number }, + rebases: ElementRebase[], +): WrapElementsResult { + const { document, wrappedFragment } = parseSourceDocument(source); + if (targets.length === 0) { + return { html: source, matched: false, groupId: null, error: "no targets" }; + } + + // Resolve + dedupe by element ref (two targets may point at the same node). + const els: HTMLElement[] = []; + const seen = new Set(); + for (const target of targets) { + const el = findTargetElement(document, target); + if (!el || !isHTMLElement(el) || seen.has(el)) continue; + seen.add(el); + els.push(el); + } + if (els.length === 0) { + return { html: source, matched: false, groupId: null, error: "no targets matched" }; + } + + // P1: require a single common parent (LCA multi-parent wrapping is P2). + const parent = els[0]?.parentElement; + if (!parent || els.some((el) => el.parentElement !== parent)) { + return { + html: source, + matched: false, + groupId: null, + error: "grouped elements must share a single parent", + }; + } + + // Order members by their position in the parent (= z-order / stacking order). + const memberSet = new Set(els); + const ordered = Array.from(parent.children).filter((c): c is HTMLElement => memberSet.has(c)); + + // Map each member to its rebased left/top (resolved against the same document). + const rebaseByEl = new Map(); + for (const rebase of rebases) { + const el = findTargetElement(document, rebase.target); + if (el) rebaseByEl.set(el, { left: rebase.left, top: rebase.top }); + } + + const wrapper = document.createElement("div"); + wrapper.setAttribute("data-hf-group", groupId); + // A real `id` (slug of the group name) makes the wrapper a first-class node in the + // clip manifest / timeline parent-map (both keyed by id) and a clean GSAP target — + // without it the wrapper is invisible to the timeline and breaks child enumeration. + wrapper.setAttribute("id", uniqueGroupDomId(document, groupId)); + // Adopt the topmost member's stacking level. A group is one stacking unit, so a + // non-member interleaved between two selected members can't stay "between" them + // once they unify. Matching Figma/Sketch, the group lifts to the topmost selected + // layer: the wrapper goes at the LAST member's slot and carries the max member + // z-index — so an interleaved non-member falls below the group instead of hoisting + // above it, and explicit member z-indexes are honored. + const memberZIndexes = ordered + .map((el) => + Number.parseInt( + parseStyleDecls(el.getAttribute("style") ?? "").props.get("z-index") ?? "", + 10, + ), + ) + .filter((z) => Number.isFinite(z)); + const maxZ = memberZIndexes.length > 0 ? Math.max(...memberZIndexes) : null; + wrapper.setAttribute( + "style", + `position: absolute; left: ${bbox.left}px; top: ${bbox.top}px; width: ${bbox.width}px; height: ${bbox.height}px` + + (maxZ !== null ? `; z-index: ${maxZ}` : ""), + ); + + // Insert the wrapper at the topmost member's slot, then move members into it. + parent.insertBefore(wrapper, ordered[ordered.length - 1] ?? null); + for (const el of ordered) { + const rebase = rebaseByEl.get(el); + if (rebase) setInlineLeftTop(el, rebase.left, rebase.top); + wrapper.appendChild(el); // appendChild moves the node, preserving order + } + + return { + html: wrappedFragment ? document.body.innerHTML || "" : document.toString(), + matched: true, + groupId, + }; +} + +export function unwrapElementsFromHtml( + source: string, + groupTarget: SourceMutationTarget, +): UnwrapElementsResult { + const { document, wrappedFragment } = parseSourceDocument(source); + const group = findTargetElement(document, groupTarget); + if (!group || !isHTMLElement(group)) return { html: source, unwrapped: false }; + // Shape guard mirroring the wrap-side contract: only ever dissolve an actual + // group wrapper. A stale/desynced selection that resolves to a plain
+ // would otherwise be unwrapped — rebasing its children by the parent's origin + // (silent corruption). Wrap enforces invariants; unwrap must too. + if (!group.hasAttribute("data-hf-group")) return { html: source, unwrapped: false }; + + const parent = group.parentElement; + if (!parent) return { html: source, unwrapped: false }; + + // Undo the rebase: child absolute position = child (rebased) + wrapper origin. + const wLeft = getInlineStylePx(group, "left"); + const wTop = getInlineStylePx(group, "top"); + + // Move children back to the wrapper's slot, preserving order. + for (const child of Array.from(group.children)) { + if (isHTMLElement(child)) { + setInlineLeftTop( + child, + getInlineStylePx(child, "left") + wLeft, + getInlineStylePx(child, "top") + wTop, + ); + } + parent.insertBefore(child, group); + } + group.remove(); + + return { + html: wrappedFragment ? document.body.innerHTML || "" : document.toString(), + unwrapped: true, + }; +} diff --git a/packages/studio-server/src/routes/files.ts b/packages/studio-server/src/routes/files.ts index c628d49c6e..8a0d35face 100644 --- a/packages/studio-server/src/routes/files.ts +++ b/packages/studio-server/src/routes/files.ts @@ -56,8 +56,11 @@ import { patchElementInHtml, probeElementInSource, splitElementInHtml, + wrapElementsInHtml, + unwrapElementsFromHtml, isHTMLElement, type PatchOperation, + type ElementRebase, } from "../helpers/sourceMutation.js"; import { parseHTML } from "linkedom"; @@ -1560,6 +1563,102 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { }); }); + api.post("/projects/:id/file-mutations/wrap-elements/*", async (c) => { + const ctx = await resolveFileMutationContext(c, adapter, "wrap-elements"); + if ("error" in ctx) return ctx.error; + + const body = (await c.req.json().catch(() => null)) as { + targets?: MutationTarget[]; + groupId?: string; + bbox?: { left?: number; top?: number; width?: number; height?: number }; + rebases?: ElementRebase[]; + } | null; + if (!Array.isArray(body?.targets) || body.targets.length === 0 || !body.groupId) { + return c.json({ error: "targets and groupId required" }, 400); + } + // left/top/width/height are interpolated into inline style strings; reject + // anything non-numeric so a crafted value can't inject extra declarations. + const bbox = body.bbox ?? {}; + const bboxNums = [bbox.left, bbox.top, bbox.width, bbox.height]; + const rebases = body.rebases ?? []; + const allNumeric = + bboxNums.every((n) => typeof n === "number" && Number.isFinite(n)) && + rebases.every( + (r) => + typeof r?.left === "number" && + Number.isFinite(r.left) && + typeof r?.top === "number" && + Number.isFinite(r.top), + ); + if (!allNumeric) { + return c.json({ error: "bbox and rebase coordinates must be finite numbers" }, 400); + } + + let originalContent: string; + try { + originalContent = readFileSync(ctx.absPath, "utf-8"); + } catch { + return c.json({ error: "not found" }, 404); + } + const result = wrapElementsInHtml( + originalContent, + body.targets, + body.groupId, + { left: bbox.left!, top: bbox.top!, width: bbox.width!, height: bbox.height! }, + rebases, + ); + if (!result.matched) { + return c.json( + { + ok: false, + changed: false, + content: originalContent, + path: ctx.filePath, + error: result.error, + }, + result.error === "grouped elements must share a single parent" ? 422 : 400, + ); + } + const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath); + if (backup.error) console.warn(`Failed to create backup for ${ctx.filePath}: ${backup.error}`); + writeFileSync(ctx.absPath, result.html, "utf-8"); + return c.json({ + ok: true, + changed: true, + groupId: result.groupId, + content: result.html, + path: ctx.filePath, + backupPath: backupPathForResponse(ctx.project.dir, backup.backupPath), + }); + }); + + api.post("/projects/:id/file-mutations/unwrap-elements/*", async (c) => { + const ctx = await resolveFileMutationContext(c, adapter, "unwrap-elements"); + if ("error" in ctx) return ctx.error; + + const parsed = await parseMutationBody<{ target?: MutationTarget }>(c); + if ("error" in parsed) return parsed.error; + + let originalContent: string; + try { + originalContent = readFileSync(ctx.absPath, "utf-8"); + } catch { + return c.json({ error: "not found" }, 404); + } + const result = unwrapElementsFromHtml(originalContent, parsed.target); + if (!result.unwrapped) { + return c.json({ ok: false, changed: false, content: originalContent, path: ctx.filePath }); + } + return writeIfChanged( + c, + ctx.project.dir, + ctx.filePath, + ctx.absPath, + originalContent, + result.html, + ); + }); + api.post("/projects/:id/file-mutations/probe-element/*", async (c) => { const ctx = await resolveFileMutationContext(c, adapter, "probe-element"); if ("error" in ctx) return ctx.error; diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index d3c761a7a9..fe98759701 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 07df9b84fa..5c0c8701e2 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 bc1394afaa..f0861be649 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 0000000000..a8ea815020 --- /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 510421c796..15ffedca11 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 17bb2c53e8..a082ac67f1 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 8475b0419c..adf7de058d 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 fa1a61107a..f1e73b7595 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 b4ab83aa36..42505e3b12 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 0000000000..5dc0a4f19a --- /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 beb7fb4808..03b83743b8 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 536abba8df..5037c78315 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 da52f4f2af..d6ec3cafd8 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 6710dc9bfb..22b1aea83b 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 0899ec779e..20d90e0fc7 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 bf3819b40e..df7e12b5fb 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 0222a9bffb..71206d145c 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 56cfffd98f..44ef0ec1d2 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 96ffdbc09d..e749c0a7fa 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 6c9fc36ab6..c3f50874a6 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/useGroupCommits.ts b/packages/studio/src/hooks/useGroupCommits.ts new file mode 100644 index 0000000000..144117eede --- /dev/null +++ b/packages/studio/src/hooks/useGroupCommits.ts @@ -0,0 +1,188 @@ +import { useCallback } from "react"; +import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; +import { createStudioSaveHttpError } from "../utils/studioSaveDiagnostics"; +import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing"; +import type { EditHistoryKind } from "../utils/editHistory"; + +interface RecordEditInput { + label: string; + kind: EditHistoryKind; + coalesceKey?: string; + files: Record; +} + +interface UseGroupCommitsParams { + activeCompPath: string | null; + showToast: (message: string, tone?: "error" | "info") => void; + writeProjectFile: (path: string, content: string) => Promise; + domEditSaveTimestampRef: React.MutableRefObject; + editHistory: { recordEdit: (entry: RecordEditInput) => Promise }; + projectIdRef: React.MutableRefObject; + reloadPreview: () => void; + clearDomSelection: () => void; + /** Resync the SDK session after a server-side write (the wrapper/unwrap changes + * structure the in-memory doc doesn't know about). */ + forceReloadSdkSession?: () => void; +} + +interface PatchTarget { + id?: string | null; + hfId?: string; + selector?: string; + selectorIndex?: number; +} + +interface GroupGeometry { + bbox: { left: number; top: number; width: number; height: number }; + targets: PatchTarget[]; + rebases: Array<{ target: PatchTarget; left: number; top: number }>; +} + +// Wrapper sits at the members' bounding box top-left; each member is rebased so +// its absolute position is unchanged. offsetLeft/Top are layout coordinates in +// composition space (transforms excluded), exactly the space the rebase formula +// `left_new = left_old - W.left` operates in — GSAP x/y and offset vars are +// transform deltas and stay correct without adjustment. +function computeGroupGeometry(members: DomEditSelection[]): GroupGeometry { + const boxes = members.map((m) => ({ + target: buildDomEditPatchTarget(m), + left: m.element.offsetLeft, + top: m.element.offsetTop, + right: m.element.offsetLeft + m.element.offsetWidth, + bottom: m.element.offsetTop + m.element.offsetHeight, + })); + const left = Math.min(...boxes.map((b) => b.left)); + const top = Math.min(...boxes.map((b) => b.top)); + const width = Math.max(...boxes.map((b) => b.right)) - left; + const height = Math.max(...boxes.map((b) => b.bottom)) - top; + return { + bbox: { left, top, width, height }, + targets: boxes.map((b) => b.target), + rebases: boxes.map((b) => ({ target: b.target, left: b.left - left, top: b.top - top })), + }; +} + +// Shared read → mutate-route → save-with-history → reload pipeline for both +// wrap (group) and unwrap (ungroup). Mirrors the structural-mutation pattern in +// useElementLifecycleOps (delete). Returns the route's JSON, or throws. +async function commitStructuralMutation( + pid: string, + targetPath: string, + route: "wrap-elements" | "unwrap-elements", + body: unknown, + label: string, + deps: Pick< + UseGroupCommitsParams, + | "writeProjectFile" + | "editHistory" + | "domEditSaveTimestampRef" + | "clearDomSelection" + | "forceReloadSdkSession" + | "reloadPreview" + >, +): Promise<{ content?: string; groupId?: string }> { + const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`); + if (!response.ok) { + throw await createStudioSaveHttpError(response, `Failed to read ${targetPath}`); + } + const data = (await response.json()) as { content?: string }; + const originalContent = data.content; + if (typeof originalContent !== "string") { + throw new Error(`Missing file contents for ${targetPath}`); + } + + deps.domEditSaveTimestampRef.current = Date.now(); + const mutateResponse = await fetch( + `/api/projects/${pid}/file-mutations/${route}/${encodeURIComponent(targetPath)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + if (!mutateResponse.ok) { + const errBody = (await mutateResponse.json().catch(() => null)) as { error?: string } | null; + throw new Error(errBody?.error ?? `Failed to ${label.toLowerCase()} in ${targetPath}`); + } + const mutateData = (await mutateResponse.json()) as { content?: string; groupId?: string }; + const patchedContent = + typeof mutateData.content === "string" ? mutateData.content : originalContent; + + await saveProjectFilesWithHistory({ + projectId: pid, + label, + kind: "manual", + files: { [targetPath]: patchedContent }, + readFile: async () => originalContent, + writeFile: deps.writeProjectFile, + recordEdit: deps.editHistory.recordEdit, + }); + deps.clearDomSelection(); + deps.forceReloadSdkSession?.(); + deps.reloadPreview(); + return mutateData; +} + +export function useGroupCommits(params: UseGroupCommitsParams) { + const { activeCompPath, showToast, projectIdRef } = params; + + const groupSelection = useCallback( + async (members: DomEditSelection[]): Promise => { + const pid = projectIdRef.current; + if (!pid || members.length === 0) return null; + + // All members must live in the same source file — the wrapper is one node + // in one document. (Cross-file grouping is out of scope.) + const targetPath = members[0].sourceFile || activeCompPath || "index.html"; + if (members.some((m) => (m.sourceFile || activeCompPath || "index.html") !== targetPath)) { + showToast("Can't group elements from different files", "error"); + return null; + } + + // Auto-name "Group N" by the count of existing groups in the document. + const doc = members[0].element.ownerDocument; + const groupId = `Group ${doc.querySelectorAll("[data-hf-group]").length + 1}`; + const { bbox, targets, rebases } = computeGroupGeometry(members); + + try { + const data = await commitStructuralMutation( + pid, + targetPath, + "wrap-elements", + { targets, groupId, bbox, rebases }, + "Group elements", + params, + ); + return data.groupId ?? groupId; + } catch (error) { + showToast(error instanceof Error ? error.message : "Failed to group elements", "error"); + return null; + } + }, + [activeCompPath, projectIdRef, showToast, params], + ); + + const ungroupSelection = useCallback( + async (group: DomEditSelection): Promise => { + const pid = projectIdRef.current; + if (!pid) return; + const targetPath = group.sourceFile || activeCompPath || "index.html"; + + try { + await commitStructuralMutation( + pid, + targetPath, + "unwrap-elements", + { target: buildDomEditPatchTarget(group) }, + "Ungroup elements", + params, + ); + } catch (error) { + showToast(error instanceof Error ? error.message : "Failed to ungroup elements", "error"); + } + }, + [activeCompPath, projectIdRef, showToast, params], + ); + + return { groupSelection, ungroupSelection }; +} diff --git a/packages/studio/src/hooks/usePreviewInteraction.ts b/packages/studio/src/hooks/usePreviewInteraction.ts index d3887ff713..148b8207dc 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 88c21eb370..52b39e7506 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 2ec9911bc5..455844b8bc 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;