From 74b421c10f332e49eb082e6a946b3bad0cda29bf Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 26 Jun 2026 01:14:14 -0400 Subject: [PATCH 01/19] feat(studio): element groups Wrap selected elements in a
in the composition source, with coordinate rebasing so the members' on-screen positions are unchanged. Supports create (Cmd+G) / ungroup (Cmd+Shift+G or an inspector button), select-as-unit, canvas + layer-tree drill-in with a back breadcrumb, move/scale by reusing the single-element drag/resize intercepts, grouping inside sub-compositions, and member-union bounds shared across the selection, hover, and off-canvas overlays. Core: wrapElementsInHtml / unwrapElementsFromHtml mirror splitElementInHtml (round-trip identity, handles left/top declared in a CSS rule), plus wrap-elements / unwrap-elements file-mutation routes with finite-number coordinate validation. Also polishes the 3D transform widget (tangential to groups): depth via scroll-over-cube instead of a redundant perspective slider, a depth-scaled cube for realistic Z feedback, and a screen-Y-down orientation fix so the gizmo mirrors the element's rotation. --- .../studio-api/helpers/sourceMutation.test.ts | 111 +++++++++++ .../src/studio-api/helpers/sourceMutation.ts | 165 ++++++++++++++- packages/core/src/studio-api/routes/files.ts | 99 +++++++++ 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 | 37 +--- .../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 | 74 ++++++- packages/studio/src/hooks/useGroupCommits.ts | 188 ++++++++++++++++++ .../studio/src/hooks/usePreviewInteraction.ts | 28 ++- .../src/player/components/ShortcutsPanel.tsx | 2 + 26 files changed, 1159 insertions(+), 175 deletions(-) create mode 100644 packages/studio/src/components/editor/InspectorHeaderActions.tsx create mode 100644 packages/studio/src/components/editor/domEditingGroups.ts create mode 100644 packages/studio/src/hooks/useGroupCommits.ts diff --git a/packages/core/src/studio-api/helpers/sourceMutation.test.ts b/packages/core/src/studio-api/helpers/sourceMutation.test.ts index a0f3dedcb1..37145686af 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.test.ts +++ b/packages/core/src/studio-api/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,111 @@ 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); + }); +}); diff --git a/packages/core/src/studio-api/helpers/sourceMutation.ts b/packages/core/src/studio-api/helpers/sourceMutation.ts index 727cd326a2..0e06e4839d 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.ts +++ b/packages/core/src/studio-api/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,149 @@ 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); +} + +// 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); + wrapper.setAttribute( + "style", + `position: absolute; left: ${bbox.left}px; top: ${bbox.top}px; width: ${bbox.width}px; height: ${bbox.height}px`, + ); + + // Insert the wrapper at the first member's slot, then move members into it. + parent.insertBefore(wrapper, ordered[0] ?? 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 }; + + 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/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index 08d4fdd202..5a9eca2544 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/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..fe10ea3493 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,10 @@ export async function resolveDomEditSelection( if (!startEl) return null; const doc = startEl.ownerDocument; - let current: HTMLElement | null = getSelectionCandidate(startEl, options); + const capture = resolveGroupCapture(startEl, options.activeGroupElement ?? null); + if (capture.kind === "out-of-scope") return 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 +479,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 50d82cafa1..70ea56f570 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 b0944f2573..a8074e8556 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..b1b3830f73 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 ── @@ -203,16 +218,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 +259,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 +274,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 +493,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 +531,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" }, ], }, From e6be1bb558ddca92554a45ca7a8c935ac7fd5857 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 26 Jun 2026 13:56:31 -0400 Subject: [PATCH 02/19] feat(gsap): read timelines authored inline (acorn read path) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model the timeline as a structural TimelineRef (identifier OR member expression) instead of a string variable name, so the acorn read parser can detect and read the inline window.__timelines[id] = gsap.timeline() form — matching tweens by AST structure rather than identifier name. Static string/dot keys (single/double quote) supported; computed keys stay flagged unsupported; canonical const form unchanged. --- .../parsers/gsapParserAcorn.inline.test.ts | 75 ++++++++++ packages/core/src/parsers/gsapParserAcorn.ts | 133 ++++++++++++++---- 2 files changed, 177 insertions(+), 31 deletions(-) create mode 100644 packages/core/src/parsers/gsapParserAcorn.inline.test.ts diff --git a/packages/core/src/parsers/gsapParserAcorn.inline.test.ts b/packages/core/src/parsers/gsapParserAcorn.inline.test.ts new file mode 100644 index 0000000000..11170a1164 --- /dev/null +++ b/packages/core/src/parsers/gsapParserAcorn.inline.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from "vitest"; +import { parseGsapScriptAcorn } from "./gsapParserAcorn.js"; + +// U1+U2: the editor must read timelines authored inline as +// `window.__timelines["id"] = gsap.timeline()` — not just the canonical +// `const tl = gsap.timeline(); window.__timelines[id] = tl` form. + +const wrap = (decl: string, tweens: string) => + `window.__timelines = window.__timelines || {};\n${decl}\n${tweens}`; + +describe("inline timeline assignment — read", () => { + it("reads tweens from a double-quoted inline timeline", () => { + const src = wrap( + `window.__timelines["scene"] = gsap.timeline({ paused: true });`, + `window.__timelines["scene"].to("#a", { x: 100, duration: 1 }, 0);\n` + + `window.__timelines["scene"].to("#b", { y: 50, duration: 1 }, 0.5);`, + ); + const parsed = parseGsapScriptAcorn(src); + expect(parsed.unsupportedTimelinePattern).toBeFalsy(); + expect(parsed.animations).toHaveLength(2); + expect(parsed.animations[0]!.targetSelector).toBe("#a"); + expect(parsed.animations[1]!.targetSelector).toBe("#b"); + }); + + it("reads a single-quoted inline timeline", () => { + const src = wrap( + `window.__timelines['scene'] = gsap.timeline();`, + `window.__timelines['scene'].to('#a', { x: 10, duration: 1 }, 0);`, + ); + const parsed = parseGsapScriptAcorn(src); + expect(parsed.unsupportedTimelinePattern).toBeFalsy(); + expect(parsed.animations).toHaveLength(1); + expect(parsed.animations[0]!.targetSelector).toBe("#a"); + }); + + it("reads a static dot-access inline timeline", () => { + const src = wrap( + `window.__timelines.scene = gsap.timeline();`, + `window.__timelines.scene.to("#a", { x: 10, duration: 1 }, 0);`, + ); + const parsed = parseGsapScriptAcorn(src); + expect(parsed.unsupportedTimelinePattern).toBeFalsy(); + expect(parsed.animations).toHaveLength(1); + }); + + it("flags a computed-key timeline as unsupported (cannot statically resolve)", () => { + const src = wrap( + `const id = "scene";\nwindow.__timelines[id] = gsap.timeline();`, + `window.__timelines[id].to("#a", { x: 10, duration: 1 }, 0);`, + ); + const parsed = parseGsapScriptAcorn(src); + expect(parsed.unsupportedTimelinePattern).toBe(true); + }); + + it("does not cross-attribute tweens of a different member slot", () => { + const src = wrap( + `window.__timelines["a"] = gsap.timeline();\nwindow.__timelines["b"] = gsap.timeline();`, + `window.__timelines["a"].to("#a", { x: 1, duration: 1 }, 0);\n` + + `window.__timelines["b"].to("#b", { x: 2, duration: 1 }, 0);`, + ); + const parsed = parseGsapScriptAcorn(src); + // First detected timeline is "a"; only its tween should be attributed here. + expect(parsed.multipleTimelines).toBe(true); + expect(parsed.animations.some((a) => a.targetSelector === "#a")).toBe(true); + expect(parsed.animations.every((a) => a.targetSelector !== "#b")).toBe(true); + }); + + it("leaves the canonical const form working", () => { + const src = `const tl = gsap.timeline();\nwindow.__timelines["scene"] = tl;\ntl.to("#a", { x: 5, duration: 1 }, 0);`; + const parsed = parseGsapScriptAcorn(src); + expect(parsed.unsupportedTimelinePattern).toBeFalsy(); + expect(parsed.animations).toHaveLength(1); + expect(parsed.timelineVar).toBe("tl"); + }); +}); diff --git a/packages/core/src/parsers/gsapParserAcorn.ts b/packages/core/src/parsers/gsapParserAcorn.ts index 38ca4cb8c0..94c88e887c 100644 --- a/packages/core/src/parsers/gsapParserAcorn.ts +++ b/packages/core/src/parsers/gsapParserAcorn.ts @@ -358,12 +358,57 @@ interface TimelineDefaults { duration?: number; } +// How the timeline is referred to in source. `identifier` is the canonical +// `const tl = …` form; `member` is the inline `window.__timelines["scene"] = …` +// form, where the timeline IS the member expression (no variable name). +type TimelineRef = { kind: "identifier"; name: string } | { kind: "member"; node: any }; + interface TimelineDetection { + /** Identifier name for the canonical form, else null (member or none). */ timelineVar: string | null; + /** Structural reference: identifier OR member expression. Null when none found. */ + ref: TimelineRef | null; timelineCount: number; defaults?: TimelineDefaults; } +/** The static string key of a member access (`window.__timelines["scene"]` → "scene"), else null. */ +function staticMemberKey(node: any): string | null { + if (!node || node.type !== "MemberExpression") return null; + if (node.computed) { + const p = node.property; + if (p?.type === "Literal" && typeof p.value === "string") return p.value; + return null; // computed non-string-literal key → not statically resolvable + } + return node.property?.type === "Identifier" ? node.property.name : null; +} + +/** True when a member expression refers to a statically-resolvable timeline slot. */ +function isStaticMemberRef(node: any): boolean { + return node?.type === "MemberExpression" && staticMemberKey(node) !== null; +} + +/** Structural equality of two member-access nodes (object chain + static key), quote-insensitive. */ +function sameMemberAccess(a: any, b: any): boolean { + if (a?.type !== "MemberExpression" || b?.type !== "MemberExpression") return false; + if (staticMemberKey(a) !== staticMemberKey(b) || staticMemberKey(a) === null) return false; + const ao = a.object; + const bo = b.object; + if (ao?.type === "Identifier" && bo?.type === "Identifier") return ao.name === bo.name; + if (ao?.type === "MemberExpression" && bo?.type === "MemberExpression") + return sameMemberAccess(ao, bo); + return false; +} + +/** The source string a tween call is rooted at: identifier name, or the member source as written. */ +function timelineRootSource(ref: TimelineRef, script: string): string { + return ref.kind === "identifier" ? ref.name : script.slice(ref.node.start, ref.node.end); +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + // fallow-ignore-next-line complexity function extractTimelineDefaults( callNode: any, @@ -388,6 +433,7 @@ function extractTimelineDefaults( function findTimelineVar(ast: any, scope?: ScopeBindings): TimelineDetection { let timelineVar: string | null = null; + let ref: TimelineRef | null = null; let timelineCount = 0; let defaults: TimelineDefaults | undefined; const emptyScope: ScopeBindings = scope ?? new Map(); @@ -396,8 +442,9 @@ function findTimelineVar(ast: any, scope?: ScopeBindings): TimelineDetection { VariableDeclarator(node: any) { if (isGsapTimelineCall(node.init)) { timelineCount += 1; - if (!timelineVar) { - timelineVar = node.id?.name ?? null; + if (!ref && node.id?.type === "Identifier") { + timelineVar = node.id.name; + ref = { kind: "identifier", name: node.id.name }; defaults = extractTimelineDefaults(node.init, emptyScope); } } @@ -405,16 +452,23 @@ function findTimelineVar(ast: any, scope?: ScopeBindings): TimelineDetection { AssignmentExpression(node: any) { if (isGsapTimelineCall(node.right)) { timelineCount += 1; - if (!timelineVar) { + if (!ref) { const left = node.left; - if (left?.type === "Identifier") timelineVar = left.name; - defaults = extractTimelineDefaults(node.right, emptyScope); + if (left?.type === "Identifier") { + timelineVar = left.name; + ref = { kind: "identifier", name: left.name }; + defaults = extractTimelineDefaults(node.right, emptyScope); + } else if (isStaticMemberRef(left)) { + // Inline form: `window.__timelines["scene"] = gsap.timeline(...)`. + ref = { kind: "member", node: left }; + defaults = extractTimelineDefaults(node.right, emptyScope); + } } } }, }); - return { timelineVar, timelineCount, defaults }; + return { timelineVar, ref, timelineCount, defaults }; } // ── Tween call collection ───────────────────────────────────────────────────── @@ -447,13 +501,14 @@ export interface TweenCallInfo { global?: boolean; } -/** True when callee chain is rooted at the timeline variable. */ -function isTimelineRootedCall(callNode: any, timelineVar: string): boolean { +/** True when the callee chain is rooted at the timeline reference (identifier or member). */ +function isTimelineRootedCall(callNode: any, ref: TimelineRef): boolean { let obj = callNode.callee?.object; while (obj?.type === "CallExpression") { obj = obj.callee?.object; } - return obj?.type === "Identifier" && obj.name === timelineVar; + if (ref.kind === "identifier") return obj?.type === "Identifier" && obj.name === ref.name; + return sameMemberAccess(obj, ref.node); } /** @@ -465,7 +520,7 @@ function isTimelineRootedCall(callNode: any, timelineVar: string): boolean { */ function findAllTweenCalls( ast: any, - timelineVar: string, + ref: TimelineRef, scope: ScopeBindings, targetBindings: TargetBindings, ): TweenCallInfo[] { @@ -494,7 +549,7 @@ function findAllTweenCalls( if ( callee?.type === "MemberExpression" && callee.property?.type === "Identifier" && - (isTimelineRootedCall(node, timelineVar) || isGlobalSet) && + (isTimelineRootedCall(node, ref) || isGlobalSet) && GSAP_METHODS.has(callee.property.name) ) { const method = callee.property.name; @@ -1092,8 +1147,9 @@ export function parseGsapScriptAcornForWrite(script: string): ParsedGsapAcornFor const scope = collectScopeBindings(ast); const targetBindings = collectTargetBindings(ast, scope); const detection = findTimelineVar(ast, scope); - const timelineVar = detection.timelineVar ?? "tl"; - const calls = findAllTweenCalls(ast, timelineVar, scope, targetBindings); + const ref: TimelineRef = detection.ref ?? { kind: "identifier", name: "tl" }; + const timelineVar = timelineRootSource(ref, script); + const calls = findAllTweenCalls(ast, ref, scope, targetBindings); sortBySourcePosition(calls); const rawAnims = calls.map((call) => tweenCallToAnimation(call, scope, script)); applyTimelineDefaults(rawAnims, detection.defaults); @@ -1104,7 +1160,7 @@ export function parseGsapScriptAcornForWrite(script: string): ParsedGsapAcornFor call, animation: animations[i]!, })); - return { ast, timelineVar, hasTimeline: detection.timelineVar !== null, located }; + return { ast, timelineVar, hasTimeline: detection.ref !== null, located }; } catch { return null; } @@ -1125,30 +1181,41 @@ export function parseGsapScriptAcorn(script: string): ParsedGsap { }); const scope = collectScopeBindings(ast); const detection = findTimelineVar(ast, scope); - const timelineVar = detection.timelineVar ?? "tl"; + const ref: TimelineRef = detection.ref ?? { kind: "identifier", name: "tl" }; + const timelineVar = timelineRootSource(ref, script); // Expand helper-built / bounded-loop timelines before analysis so their // tweens resolve at true positions (read path only — the write path keeps // original source nodes). Degrades to the un-inlined AST on any failure. - try { - inlineComputedTimelines(ast, timelineVar, (node) => resolveNode(node, scope)); - } catch { - /* fall back to current behavior */ + // Only the identifier form uses the helper-built pattern; inline member + // timelines have nothing to inline, so skip (avoids mis-rooting on the member). + if (ref.kind === "identifier") { + try { + inlineComputedTimelines(ast, timelineVar, (node) => resolveNode(node, scope)); + } catch { + /* fall back to current behavior */ + } } const targetBindings = collectTargetBindings(ast, scope); - const calls = findAllTweenCalls(ast, timelineVar, scope, targetBindings); + const calls = findAllTweenCalls(ast, ref, scope, targetBindings); sortBySourcePosition(calls); const rawAnims = calls.map((call) => tweenCallToAnimation(call, scope, script)); applyTimelineDefaults(rawAnims, detection.defaults); resolveTimelinePositions(rawAnims); const animations = assignStableIds(rawAnims); - const timelineMatch = script.match( - new RegExp( - `^[\\s\\S]*?(?:const|let|var)\\s+${timelineVar}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`, - ), - ); - const preamble = - timelineMatch?.[0] ?? `const ${timelineVar} = gsap.timeline({ paused: true });`; + // Preamble = source up to and including the timeline declaration/assignment. + // Identifier keeps the original `const|let|var = …` regex (byte-stable); + // member matches ` = …`. + const declPattern = + ref.kind === "identifier" + ? `(?:const|let|var)\\s+${timelineVar}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?` + : `${escapeRegExp(timelineVar)}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`; + const timelineMatch = script.match(new RegExp(`^[\\s\\S]*?${declPattern}`)); + const fallbackPreamble = + ref.kind === "identifier" + ? `const ${timelineVar} = gsap.timeline({ paused: true });` + : `${timelineVar} = gsap.timeline({ paused: true });`; + const preamble = timelineMatch?.[0] ?? fallbackPreamble; const lastCallIdx = script.lastIndexOf(`${timelineVar}.`); let postamble = ""; @@ -1162,7 +1229,7 @@ export function parseGsapScriptAcorn(script: string): ParsedGsap { const result: ParsedGsap = { animations, timelineVar, preamble, postamble }; if (detection.timelineCount > 1) result.multipleTimelines = true; - if (detection.timelineCount > 0 && detection.timelineVar === null) + if (detection.timelineCount > 0 && detection.ref === null) result.unsupportedTimelinePattern = true; return result; } catch { @@ -1194,7 +1261,7 @@ export function extractGsapLabels(script: string): GsapLabelEntry[] { }); const scope = collectScopeBindings(ast); const detection = findTimelineVar(ast, scope); - const timelineVar = detection.timelineVar ?? "tl"; + const ref: TimelineRef = detection.ref ?? { kind: "identifier", name: "tl" }; const labels: GsapLabelEntry[] = []; @@ -1204,10 +1271,14 @@ export function extractGsapLabels(script: string): GsapLabelEntry[] { const expr = node.expression; if (!expr || expr.type !== "CallExpression") return; const callee = expr.callee; - // Match tl.addLabel(...) + // Match .addLabel(...) for identifier or member timeline refs. + const objMatches = + ref.kind === "identifier" + ? callee.object?.type === "Identifier" && callee.object.name === ref.name + : sameMemberAccess(callee.object, ref.node); if ( callee?.type !== "MemberExpression" || - callee.object?.name !== timelineVar || + !objMatches || callee.property?.name !== "addLabel" ) return; From d1ef72e601302f6f5e15daf4f1e873bb67038884 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 26 Jun 2026 14:19:32 -0400 Subject: [PATCH 03/19] test(gsap): inline-timeline acorn write coverage (edit/add/delete/keyframes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proves the acorn write path round-trips inline window.__timelines[id] = gsap.timeline() edits — the read-path member ref makes the writer emit window.__timelines[id].to(...) with no writer changes. Covers edit-in-place, add (incl. empty timeline), delete, keyframe add, remove-all-keyframes, and single-quote preservation. --- .../parsers/gsapWriterAcorn.inline.test.ts | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 packages/core/src/parsers/gsapWriterAcorn.inline.test.ts diff --git a/packages/core/src/parsers/gsapWriterAcorn.inline.test.ts b/packages/core/src/parsers/gsapWriterAcorn.inline.test.ts new file mode 100644 index 0000000000..a193a23a0b --- /dev/null +++ b/packages/core/src/parsers/gsapWriterAcorn.inline.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from "vitest"; +import { parseGsapScriptAcorn } from "./gsapParserAcorn.js"; +import { + updateAnimationInScript, + addAnimationToScript, + removeAnimationFromScript, + addKeyframeToScript, + removeAllKeyframesFromScript, +} from "./gsapWriterAcorn.js"; + +// U3: edit/add/delete tweens on a timeline authored inline as +// `window.__timelines["scene"] = gsap.timeline()`, emitting the member form. + +const inlineSrc = `window.__timelines = window.__timelines || {}; +window.__timelines["scene"] = gsap.timeline({ paused: true }); +window.__timelines["scene"].to("#a", { x: 100, duration: 1 }, 0); +window.__timelines["scene"].to("#b", { y: 50, duration: 1 }, 0.5);`; + +describe("inline timeline assignment — write", () => { + it("edits an existing inline tween's value in place", () => { + const id = parseGsapScriptAcorn(inlineSrc).animations[0]!.id; + const out = updateAnimationInScript(inlineSrc, id, { properties: { x: 200 } }); + expect(out).toContain('window.__timelines["scene"].to("#a"'); + expect(out).toContain("200"); + const reread = parseGsapScriptAcorn(out); + expect(reread.animations).toHaveLength(2); + expect(reread.unsupportedTimelinePattern).toBeFalsy(); + }); + + it("adds a new tween in the member form", () => { + const { script: out } = addAnimationToScript(inlineSrc, { + method: "to", + targetSelector: "#c", + properties: { opacity: 1 }, + position: 1, + duration: 1, + }); + expect(out).toContain('window.__timelines["scene"].to("#c"'); + expect(parseGsapScriptAcorn(out).animations).toHaveLength(3); + }); + + it("removes an inline tween, leaving the rest", () => { + const id = parseGsapScriptAcorn(inlineSrc).animations[1]!.id; + const out = removeAnimationFromScript(inlineSrc, id); + expect(out).not.toContain('"#b"'); + expect(parseGsapScriptAcorn(out).animations).toHaveLength(1); + }); + + it("preserves single-quote member form on write", () => { + const sq = `window.__timelines = window.__timelines || {}; +window.__timelines['scene'] = gsap.timeline(); +window.__timelines['scene'].to('#a', { x: 1, duration: 1 }, 0);`; + const id = parseGsapScriptAcorn(sq).animations[0]!.id; + const out = updateAnimationInScript(sq, id, { properties: { x: 9 } }); + expect(out).toContain("window.__timelines['scene']"); + expect(parseGsapScriptAcorn(out).animations).toHaveLength(1); + }); + + it("converts an inline tween to keyframes by adding one (the delete-all-keyframes bug area)", () => { + const id = parseGsapScriptAcorn(inlineSrc).animations[0]!.id; + const out = addKeyframeToScript(inlineSrc, id, 50, { x: 150 }); + expect(out).toContain("keyframes"); + expect(out).toContain('window.__timelines["scene"]'); + expect(parseGsapScriptAcorn(out).unsupportedTimelinePattern).toBeFalsy(); + }); + + it("removes all keyframes from an inline keyframed tween", () => { + const kf = `window.__timelines = window.__timelines || {}; +window.__timelines["scene"] = gsap.timeline(); +window.__timelines["scene"].to("#a", { keyframes: { "0%": { x: 0 }, "100%": { x: 100 } }, duration: 1 }, 0);`; + const id = parseGsapScriptAcorn(kf).animations[0]!.id; + const out = removeAllKeyframesFromScript(kf, id); + expect(out).not.toContain("keyframes"); + }); + + it("adds the first tween to an empty inline timeline", () => { + const empty = `window.__timelines = window.__timelines || {}; +window.__timelines["scene"] = gsap.timeline({ paused: true });`; + const { script: out } = addAnimationToScript(empty, { + method: "to", + targetSelector: "#a", + properties: { x: 10 }, + position: 0, + duration: 1, + }); + expect(out).toContain('window.__timelines["scene"].to("#a"'); + expect(parseGsapScriptAcorn(out).animations).toHaveLength(1); + }); + + it("no-op write is stable (read → re-emit same → re-read equal count)", () => { + const parsed = parseGsapScriptAcorn(inlineSrc); + const id = parsed.animations[0]!.id; + const out = updateAnimationInScript(inlineSrc, id, { + properties: parsed.animations[0]!.properties, + }); + expect(parseGsapScriptAcorn(out).animations).toHaveLength(2); + }); +}); From 941a47e52752dc4a3069e0defc978d3e45329a3d Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 26 Jun 2026 14:23:58 -0400 Subject: [PATCH 04/19] feat(gsap): edit inline timelines via the recast writer (default server path) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the TimelineRef model into the recast parser/writer (gsapParser.ts) — the default studio-api write path — so window.__timelines[id] = gsap.timeline() reads AND round-trips edits (the emitter roots tweens at the member source). Static string/dot keys supported; computed keys stay unsupported; canonical const form unchanged (194 recast tests green). 9 inline read+write tests added. --- .../src/parsers/gsapParser.inline.test.ts | 92 ++++++++++++++++ packages/core/src/parsers/gsapParser.ts | 102 ++++++++++++++---- 2 files changed, 171 insertions(+), 23 deletions(-) create mode 100644 packages/core/src/parsers/gsapParser.inline.test.ts diff --git a/packages/core/src/parsers/gsapParser.inline.test.ts b/packages/core/src/parsers/gsapParser.inline.test.ts new file mode 100644 index 0000000000..e610638cba --- /dev/null +++ b/packages/core/src/parsers/gsapParser.inline.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest"; +import { + parseGsapScript, + updateAnimationInScript, + addAnimationToScript, + removeAnimationFromScript, + addKeyframeToScript, + removeAllKeyframesFromScript, +} from "./gsapParser.js"; + +// U4: recast parser/writer parity for the inline form +// `window.__timelines["scene"] = gsap.timeline()` (the default server write path). + +const inlineSrc = `window.__timelines = window.__timelines || {}; +window.__timelines["scene"] = gsap.timeline({ paused: true }); +window.__timelines["scene"].to("#a", { x: 100, duration: 1 }, 0); +window.__timelines["scene"].to("#b", { y: 50, duration: 1 }, 0.5);`; + +describe("recast — inline timeline read", () => { + it("reads inline tweens (double quote)", () => { + const p = parseGsapScript(inlineSrc); + expect(p.unsupportedTimelinePattern).toBeFalsy(); + expect(p.animations).toHaveLength(2); + expect(p.animations[0]!.targetSelector).toBe("#a"); + }); + + it("reads single-quote + dot access", () => { + const sq = `window.__timelines['s'] = gsap.timeline();\nwindow.__timelines['s'].to('#a', { x: 1, duration: 1 }, 0);`; + const dot = `window.__timelines.s = gsap.timeline();\nwindow.__timelines.s.to("#a", { x: 1, duration: 1 }, 0);`; + expect(parseGsapScript(sq).animations).toHaveLength(1); + expect(parseGsapScript(dot).animations).toHaveLength(1); + }); + + it("flags computed key as unsupported", () => { + const c = `const id = "s";\nwindow.__timelines[id] = gsap.timeline();\nwindow.__timelines[id].to("#a", { x: 1, duration: 1 }, 0);`; + expect(parseGsapScript(c).unsupportedTimelinePattern).toBe(true); + }); + + it("keeps the canonical const form unchanged", () => { + const c = `const tl = gsap.timeline();\nwindow.__timelines["s"] = tl;\ntl.to("#a", { x: 5, duration: 1 }, 0);`; + const p = parseGsapScript(c); + expect(p.timelineVar).toBe("tl"); + expect(p.animations).toHaveLength(1); + }); +}); + +describe("recast — inline timeline write", () => { + it("edits an inline tween in place", () => { + const id = parseGsapScript(inlineSrc).animations[0]!.id; + const out = updateAnimationInScript(inlineSrc, id, { properties: { x: 200 } }); + expect(out).toContain('window.__timelines["scene"].to("#a"'); + expect(out).toContain("200"); + expect(parseGsapScript(out).animations).toHaveLength(2); + }); + + it("adds a tween in member form", () => { + const out = addAnimationToScript(inlineSrc, { + method: "to", + targetSelector: "#c", + properties: { opacity: 1 }, + position: 1, + duration: 1, + }); + const script = typeof out === "string" ? out : out.script; + expect(script).toContain('window.__timelines["scene"].to("#c"'); + expect(parseGsapScript(script).animations).toHaveLength(3); + }); + + it("removes an inline tween", () => { + const id = parseGsapScript(inlineSrc).animations[1]!.id; + const out = removeAnimationFromScript(inlineSrc, id); + expect(out).not.toContain('"#b"'); + expect(parseGsapScript(out).animations).toHaveLength(1); + }); + + it("adds + removes keyframes on an inline tween", () => { + const id = parseGsapScript(inlineSrc).animations[0]!.id; + const withKf = addKeyframeToScript(inlineSrc, id, 50, { x: 150 }); + expect(withKf).toContain("keyframes"); + expect(parseGsapScript(withKf).unsupportedTimelinePattern).toBeFalsy(); + const kfId = parseGsapScript(withKf).animations[0]!.id; + const cleared = removeAllKeyframesFromScript(withKf, kfId); + expect(cleared).not.toContain("keyframes"); + }); + + it("preserves single-quote member form on write", () => { + const sq = `window.__timelines['s'] = gsap.timeline();\nwindow.__timelines['s'].to('#a', { x: 1, duration: 1 }, 0);`; + const id = parseGsapScript(sq).animations[0]!.id; + const out = updateAnimationInScript(sq, id, { properties: { x: 9 } }); + expect(out).toContain("window.__timelines['s']"); + }); +}); diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts index ac40a8adf0..96ffd43e4f 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -376,12 +376,54 @@ interface TimelineDefaults { duration?: number; } +// `identifier` is the canonical `const tl = …` form; `member` is the inline +// `window.__timelines["scene"] = …` form (the timeline IS the member expression). +type TimelineRef = { kind: "identifier"; name: string } | { kind: "member"; node: AstNode }; + interface TimelineDetection { timelineVar: string | null; + ref: TimelineRef | null; timelineCount: number; defaults?: TimelineDefaults; } +/** The static string key of a member access (`window.__timelines["scene"]` → "scene"), else null. */ +function staticMemberKey(node: AstNode): string | null { + if (!node || node.type !== "MemberExpression") return null; + if (node.computed) { + const p = node.property; + if (p?.type === "StringLiteral") return p.value; + if (p?.type === "Literal" && typeof p.value === "string") return p.value; + return null; + } + return node.property?.type === "Identifier" ? node.property.name : null; +} + +function isStaticMemberRef(node: AstNode): boolean { + return node?.type === "MemberExpression" && staticMemberKey(node) !== null; +} + +/** Structural equality of two member accesses (object chain + static key), quote-insensitive. */ +function sameMemberAccess(a: AstNode, b: AstNode): boolean { + if (a?.type !== "MemberExpression" || b?.type !== "MemberExpression") return false; + if (staticMemberKey(a) !== staticMemberKey(b) || staticMemberKey(a) === null) return false; + const ao = a.object; + const bo = b.object; + if (ao?.type === "Identifier" && bo?.type === "Identifier") return ao.name === bo.name; + if (ao?.type === "MemberExpression" && bo?.type === "MemberExpression") + return sameMemberAccess(ao, bo); + return false; +} + +/** The source string a tween call roots at: identifier name, or the member source as written. */ +function timelineRootSource(ref: TimelineRef): string { + return ref.kind === "identifier" ? ref.name : recast.print(ref.node).code; +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + function extractTimelineDefaults( callNode: AstNode, scope: ScopeBindings, @@ -401,6 +443,7 @@ function extractTimelineDefaults( function findTimelineVar(ast: AstNode, scope?: ScopeBindings): TimelineDetection { let timelineVar: string | null = null; + let ref: TimelineRef | null = null; let timelineCount = 0; let defaults: TimelineDefaults | undefined; const emptyScope: ScopeBindings = scope ?? new Map(); @@ -408,8 +451,9 @@ function findTimelineVar(ast: AstNode, scope?: ScopeBindings): TimelineDetection visitVariableDeclarator(path: AstPath) { if (isGsapTimelineCall(path.node.init)) { timelineCount += 1; - if (!timelineVar) { - timelineVar = path.node.id?.name ?? null; + if (!ref && path.node.id?.type === "Identifier") { + timelineVar = path.node.id.name; + ref = { kind: "identifier", name: path.node.id.name }; defaults = extractTimelineDefaults(path.node.init, emptyScope); } } @@ -418,16 +462,22 @@ function findTimelineVar(ast: AstNode, scope?: ScopeBindings): TimelineDetection visitAssignmentExpression(path: AstPath) { if (isGsapTimelineCall(path.node.right)) { timelineCount += 1; - if (!timelineVar) { + if (!ref) { const left = path.node.left; - if (left?.type === "Identifier") timelineVar = left.name; - defaults = extractTimelineDefaults(path.node.right, emptyScope); + if (left?.type === "Identifier") { + timelineVar = left.name; + ref = { kind: "identifier", name: left.name }; + defaults = extractTimelineDefaults(path.node.right, emptyScope); + } else if (isStaticMemberRef(left)) { + ref = { kind: "member", node: left }; + defaults = extractTimelineDefaults(path.node.right, emptyScope); + } } } this.traverse(path); }, }); - return { timelineVar, timelineCount, defaults }; + return { timelineVar, ref, timelineCount, defaults }; } // ── Find All Tween Calls ──────────────────────────────────────────────────── @@ -448,17 +498,18 @@ interface TweenCallInfo { * True when the member chain of `callNode.callee` is rooted at the timeline * variable — `tl.to(...)` and every link of a chain `tl.to(...).to(...)`. */ -function isTimelineRootedCall(callNode: AstNode, timelineVar: string): boolean { +function isTimelineRootedCall(callNode: AstNode, ref: TimelineRef): boolean { let obj = callNode.callee?.object; while (obj?.type === "CallExpression") { obj = obj.callee?.object; } - return obj?.type === "Identifier" && obj.name === timelineVar; + if (ref.kind === "identifier") return obj?.type === "Identifier" && obj.name === ref.name; + return sameMemberAccess(obj, ref.node); } function findAllTweenCalls( ast: AstNode, - timelineVar: string, + ref: TimelineRef, scope: ScopeBindings, targetBindings: TargetBindings, ): TweenCallInfo[] { @@ -484,7 +535,7 @@ function findAllTweenCalls( if ( callee?.type === "MemberExpression" && callee.property?.type === "Identifier" && - (isTimelineRootedCall(node, timelineVar) || isGlobalSet) + (isTimelineRootedCall(node, ref) || isGlobalSet) ) { const method = callee.property.name; if (!GSAP_METHODS.has(method)) { @@ -1131,8 +1182,9 @@ function parseGsapAst(script: string): ParsedGsapAst { const scope = collectScopeBindings(ast); const targetBindings = collectTargetBindings(ast, scope); const detection = findTimelineVar(ast, scope); - const timelineVar = detection.timelineVar ?? "tl"; - const calls = findAllTweenCalls(ast, timelineVar, scope, targetBindings); + const ref: TimelineRef = detection.ref ?? { kind: "identifier", name: "tl" }; + const timelineVar = timelineRootSource(ref); + const calls = findAllTweenCalls(ast, ref, scope, targetBindings); sortBySourcePosition(calls); const rawAnims = calls.map((call) => tweenCallToAnimation(call, scope)); applyTimelineDefaults(rawAnims, detection.defaults); @@ -1151,15 +1203,19 @@ function parseGsapAst(script: string): ParsedGsapAst { export function parseGsapScript(script: string): ParsedGsap { try { const { detection, timelineVar, located } = parseGsapAst(script); + const ref: TimelineRef = detection.ref ?? { kind: "identifier", name: "tl" }; const animations = located.map((l) => l.animation); - const timelineMatch = script.match( - new RegExp( - `^[\\s\\S]*?(?:const|let|var)\\s+${timelineVar}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`, - ), - ); - const preamble = - timelineMatch?.[0] ?? `const ${timelineVar} = gsap.timeline({ paused: true });`; + const declPattern = + ref.kind === "identifier" + ? `(?:const|let|var)\\s+${timelineVar}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?` + : `${escapeRegExp(timelineVar)}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`; + const timelineMatch = script.match(new RegExp(`^[\\s\\S]*?${declPattern}`)); + const fallbackPreamble = + ref.kind === "identifier" + ? `const ${timelineVar} = gsap.timeline({ paused: true });` + : `${timelineVar} = gsap.timeline({ paused: true });`; + const preamble = timelineMatch?.[0] ?? fallbackPreamble; const lastCallIdx = script.lastIndexOf(`${timelineVar}.`); let postamble = ""; @@ -1173,7 +1229,7 @@ export function parseGsapScript(script: string): ParsedGsap { const result: ParsedGsap = { animations, timelineVar, preamble, postamble }; if (detection.timelineCount > 1) result.multipleTimelines = true; - if (detection.timelineCount > 0 && detection.timelineVar === null) + if (detection.timelineCount > 0 && detection.ref === null) result.unsupportedTimelinePattern = true; return result; } catch { @@ -1468,7 +1524,7 @@ export function addAnimationToScript( return { script, id: "" }; } // Nothing to anchor against and no timeline to target — treat as parse failure. - if (parsed.located.length === 0 && parsed.detection.timelineVar === null) { + if (parsed.located.length === 0 && parsed.detection.ref === null) { return { script, id: "" }; } @@ -1500,7 +1556,7 @@ export function addAnimationWithKeyframesToScript( console.warn("[gsap-parser] addAnimationWithKeyframesToScript parse failed:", e); return { script, id: "" }; } - if (parsed.located.length === 0 && parsed.detection.timelineVar === null) { + if (parsed.located.length === 0 && parsed.detection.ref === null) { return { script, id: "" }; } @@ -2796,7 +2852,7 @@ export function addMotionPathToScript( console.warn("[gsap-parser] addMotionPathToScript parse failed:", e); return { script, id: null }; } - if (parsed.located.length === 0 && parsed.detection.timelineVar === null) { + if (parsed.located.length === 0 && parsed.detection.ref === null) { return { script, id: null }; } From 0db1e207b6d9d1627f86c1d71f499f658c6b943a Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 26 Jun 2026 14:26:33 -0400 Subject: [PATCH 05/19] feat(studio): enable animation editing for static inline timelines The unsupported-pattern banner now clears for static window.__timelines["id"] = gsap.timeline() (the parser reports it editable), and the banner copy is retargeted to the genuinely-unsupported case: computed/dynamic keys (window.__timelines[var]). --- .../studio/src/components/editor/GsapAnimationSection.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/studio/src/components/editor/GsapAnimationSection.tsx b/packages/studio/src/components/editor/GsapAnimationSection.tsx index 4aff3d3fb1..a71f465dfe 100644 --- a/packages/studio/src/components/editor/GsapAnimationSection.tsx +++ b/packages/studio/src/components/editor/GsapAnimationSection.tsx @@ -46,9 +46,9 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({ )} {unsupportedTimelinePattern && (

- This composition uses a timeline assignment pattern (window.__timelines[...]) that the - editor doesn't support. Use a variable declaration (const tl = gsap.timeline()) to - enable editing. + This timeline uses a computed key (window.__timelines[variable]) the editor can't + resolve statically. Use a string-literal key (window.__timelines["id"]) or a + variable declaration (const tl = gsap.timeline()) to enable editing.

)} {multipleTimelines || unsupportedTimelinePattern ? null : ( From 3fcc8d832d5436f2061c25f908671e1294144778 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 26 Jun 2026 14:38:41 -0400 Subject: [PATCH 06/19] feat(studio): expand sub-composition groups + children in the timeline Sub-comp internal elements (group wrappers + their children) carry no data-start, so the clip tree/manifest never enumerate them and the Scene row had nothing to expand into. Descend into each sub-comp host's DOM studio-side (useTimelineSyncCallbacks), collect groups + children as domClipChildren with parent links, and synthesize child rows spanning the host's bounds at expand time (useExpandedTimelineElements). Manifest stays lean (timed clips only). Verified: selecting a pill shows its siblings under the Scene row; selecting the group shows the group. --- .../hooks/useExpandedTimelineElements.test.ts | 39 ++++++++++++++ .../hooks/useExpandedTimelineElements.ts | 51 +++++++++++++++++-- .../player/hooks/useTimelineSyncCallbacks.ts | 45 ++++++++++++++-- .../studio/src/player/store/playerStore.ts | 20 ++++++++ 4 files changed, 147 insertions(+), 8 deletions(-) diff --git a/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts b/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts index a8399dee8e..9afeb90a3f 100644 --- a/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts +++ b/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts @@ -122,4 +122,43 @@ describe("buildExpandedElements", () => { expect(child.key).toBe("index.html#eyebrow"); expect(child.key).toBe(expectedStoreKey); }); + + // Sub-comp internals (group + pills) have no data-start, so they're not in the + // manifest. They arrive as DOM children and must still expand under their host. + it("expands DOM-only sub-comp children (no manifest clip) under the host", () => { + const elements = [el({ id: "scene-host", start: 5, duration: 6, compositionSrc: "scene.html" })]; + const manifest = [clip({ id: "scene-host", start: 5, duration: 6, compositionSrc: "scene.html" })]; + // pill-3 selected → parent group-1 → host scene-host. None of group-1/pills + // are in the manifest; they're DOM children with parent links. + const parentMap = new Map([ + ["group-1", "scene-host"], + ["pill-1", "group-1"], + ["pill-2", "group-1"], + ["pill-3", "group-1"], + ]); + const domClipChildren = [ + { id: "group-1", parentId: "scene-host", hostId: "scene-host", label: "Group 1" }, + { id: "pill-1", parentId: "group-1", hostId: "scene-host", label: "pill-1" }, + { id: "pill-2", parentId: "group-1", hostId: "scene-host", label: "pill-2" }, + { id: "pill-3", parentId: "group-1", hostId: "scene-host", label: "pill-3" }, + ]; + + // Expanding pill-3's siblings: topLevel scene-host, immediate parent group-1. + const out = buildExpandedElements( + elements, + manifest, + parentMap, + "scene-host", + "group-1", + domClipChildren, + ); + const pills = out.filter((e) => e.domId?.startsWith("pill-")); + expect(pills).toHaveLength(3); + // Children span the host's bounds and rebase onto the host's file. + expect(pills[0]!.start).toBe(5); + expect(pills[0]!.duration).toBe(6); + expect(pills[0]!.sourceFile).toBe("scene.html"); + // The host row is replaced by its children. + expect(out.some((e) => e.domId === "scene-host")).toBe(false); + }); }); diff --git a/packages/studio/src/player/hooks/useExpandedTimelineElements.ts b/packages/studio/src/player/hooks/useExpandedTimelineElements.ts index 9649b2af74..e657c3b988 100644 --- a/packages/studio/src/player/hooks/useExpandedTimelineElements.ts +++ b/packages/studio/src/player/hooks/useExpandedTimelineElements.ts @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { usePlayerStore, type TimelineElement } from "../store/playerStore"; +import { usePlayerStore, type TimelineElement, type DomClipChild } from "../store/playerStore"; import type { ClipManifestClip } from "../lib/playbackTypes"; import { createTimelineElementFromManifestClip } from "../lib/timelineDOM"; import { buildTimelineElementKey } from "../lib/timelineElementHelpers"; @@ -111,6 +111,32 @@ function buildChildElements( return result; } +// Sub-comp DOM children (groups/pills) aren't manifest clips and have no timing +// of their own — they're "always on" within their sub-comp host, so synthesize +// clips spanning the host's full bounds. The host element supplies start/duration +// and the composition file edits write to. +function domSiblingClips( + domClipChildren: DomClipChild[], + siblingParentId: string, + host: TimelineElement, +): ClipManifestClip[] { + return domClipChildren + .filter((c) => c.parentId === siblingParentId) + .map((c) => ({ + id: c.id, + label: c.label, + start: host.start, + duration: host.duration, + track: host.track, + kind: "element" as const, + tagName: null, + compositionId: null, + parentCompositionId: host.id ?? null, + compositionSrc: host.compositionSrc ?? null, + assetUrl: null, + })); +} + // Exported for tests. export function buildExpandedElements( elements: TimelineElement[], @@ -118,11 +144,20 @@ export function buildExpandedElements( parentMap: Map, topLevelId: string, siblingParentId: string, + domClipChildren: DomClipChild[] = [], ): TimelineElement[] { const topLevelElement = elements.find((el) => el.id === topLevelId || el.domId === topLevelId); if (!topLevelElement) return filterToTopLevel(elements, parentMap); - const siblings = manifest.filter((c) => c.id != null && parentMap.get(c.id) === siblingParentId); + // Prefer real manifest children; fall back to DOM-only sub-comp children + // (groups/pills) that have no data-start and thus never enter the manifest. + const siblings = (() => { + const fromManifest = manifest.filter( + (c) => c.id != null && parentMap.get(c.id) === siblingParentId, + ); + if (fromManifest.length > 0) return fromManifest; + return domSiblingClips(domClipChildren, siblingParentId, topLevelElement); + })(); if (siblings.length === 0) return filterToTopLevel(elements, parentMap); // The sub-comp host the children actually live in: top-level host for 1-level @@ -154,6 +189,7 @@ export function useExpandedTimelineElements(): TimelineElement[] { const elements = usePlayerStore((s) => s.elements); const clipManifest = usePlayerStore((s) => s.clipManifest); const clipParentMap = usePlayerStore((s) => s.clipParentMap); + const domClipChildren = usePlayerStore((s) => s.domClipChildren); const selectedElementId = usePlayerStore((s) => s.selectedElementId); return useMemo(() => { @@ -166,6 +202,13 @@ export function useExpandedTimelineElements(): TimelineElement[] { const immediateParent = clipParentMap.get(rawId)!; const topLevel = findTopLevelAncestor(rawId, clipParentMap) ?? immediateParent; - return buildExpandedElements(elements, clipManifest, clipParentMap, topLevel, immediateParent); - }, [elements, clipManifest, clipParentMap, selectedElementId]); + return buildExpandedElements( + elements, + clipManifest, + clipParentMap, + topLevel, + immediateParent, + domClipChildren, + ); + }, [elements, clipManifest, clipParentMap, domClipChildren, selectedElementId]); } diff --git a/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts b/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts index 5abbc32445..4965bcb37a 100644 --- a/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts +++ b/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts @@ -10,7 +10,7 @@ import { useCallback } from "react"; import { liveTime, usePlayerStore } from "../store/playerStore"; -import type { TimelineElement } from "../store/playerStore"; +import type { TimelineElement, DomClipChild } from "../store/playerStore"; import type { PlaybackAdapter, ClipManifestClip, IframeWindow } from "../lib/playbackTypes"; import { parseTimelineFromDOM, @@ -85,8 +85,8 @@ export function useTimelineSyncCallbacks({ | (Window & { __clipTree?: import("@hyperframes/core/runtime/clipTree").ClipTree }) | null; const clipTree = iframeWin?.__clipTree; + const parentMap = new Map(); if (clipTree) { - const parentMap = new Map(); const walk = (nodes: typeof clipTree.roots) => { for (const node of nodes) { if (node.id && node.parentId) parentMap.set(node.id, node.parentId); @@ -94,11 +94,48 @@ export function useTimelineSyncCallbacks({ } }; walk(clipTree.roots); - usePlayerStore.getState().setClipParentMap(parentMap); } + + // Descend into each sub-composition host: its internal elements (group + // wrappers + their children) carry no `data-start`, so the clip + // tree/manifest never enumerate them. Surface them studio-side as DOM + // children + parent links so the timeline can expand a sub-comp/group + // row to show them. Manifest stays lean (timed clips only). + const domClipChildren: DomClipChild[] = []; + if (iframeDoc) { + for (const clip of data.clips) { + if (clip.kind !== "composition" || !clip.id) continue; + const hostEl = iframeDoc.getElementById(clip.id); + if (!hostEl) continue; + for (const groupEl of hostEl.querySelectorAll("[data-hf-group]")) { + if (!groupEl.id) continue; + const groupLabel = groupEl.getAttribute("data-hf-group") || groupEl.id; + domClipChildren.push({ + id: groupEl.id, + parentId: clip.id, + hostId: clip.id, + label: groupLabel, + }); + parentMap.set(groupEl.id, clip.id); + for (const child of Array.from(groupEl.children)) { + if (!child.id) continue; + domClipChildren.push({ + id: child.id, + parentId: groupEl.id, + hostId: clip.id, + label: child.id, + }); + parentMap.set(child.id, groupEl.id); + } + } + } + } + usePlayerStore.getState().setClipParentMap(parentMap); + usePlayerStore.getState().setDomClipChildren(domClipChildren); } catch { - // cross-origin or __clipTree not available — parentMap stays empty + // cross-origin or __clipTree not available — maps stay empty } + const usedHostEls = new Set(); const els: TimelineElement[] = filtered.map((clip, index) => { const hostEl = iframeDoc diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index 3003283d7e..4f108b2650 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -165,6 +165,23 @@ interface PlayerState { setClipManifest: (clips: ClipManifestClip[] | null) => void; clipParentMap: Map; setClipParentMap: (map: Map) => void; + /** + * Sub-composition DOM descendants (groups + their children) that have no + * `data-start`, so they're absent from the clip manifest/tree. Collected + * studio-side from the live preview so the timeline can expand a sub-comp row + * to show its DOM-only children. Keeps the manifest lean (timed clips only). + */ + domClipChildren: DomClipChild[]; + setDomClipChildren: (children: DomClipChild[]) => void; +} + +/** A sub-comp DOM-only timeline child (no data-start) and its nesting context. */ +export interface DomClipChild { + id: string; + parentId: string; + /** The manifest sub-comp host clip id this descendant ultimately lives under. */ + hostId: string; + label: string; } interface BeatHistoryEntry { @@ -296,6 +313,8 @@ export const usePlayerStore = create((set, get) => ({ setClipManifest: (clips) => set({ clipManifest: clips }), clipParentMap: new Map(), setClipParentMap: (map) => set({ clipParentMap: map }), + domClipChildren: [], + setDomClipChildren: (children) => set({ domClipChildren: children }), setIsPlaying: (playing) => { if (get().isPlaying === playing) return; @@ -380,6 +399,7 @@ export const usePlayerStore = create((set, get) => ({ beatPersist: null, clipManifest: null, clipParentMap: new Map(), + domClipChildren: [], }), })); From 661645debe0712b296693d2697f1d64358f8d0b8 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 26 Jun 2026 14:52:51 -0400 Subject: [PATCH 07/19] fix(studio): hoverable group interior + non-sticky drill-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/components/editor/domEditingLayers.ts | 30 ++++++++++++++- packages/studio/src/hooks/useDomSelection.ts | 31 ++++++++++++++++ .../studio/src/utils/studioPreviewHelpers.ts | 37 +++++++++++++++++++ 3 files changed, 96 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/components/editor/domEditingLayers.ts b/packages/studio/src/components/editor/domEditingLayers.ts index fe10ea3493..02de1dee5b 100644 --- a/packages/studio/src/components/editor/domEditingLayers.ts +++ b/packages/studio/src/components/editor/domEditingLayers.ts @@ -309,10 +309,26 @@ export async function resolveDomEditSelection( if (!startEl) return null; const doc = startEl.ownerDocument; - const capture = resolveGroupCapture(startEl, options.activeGroupElement ?? null); - if (capture.kind === "out-of-scope") return null; + 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); + // eslint-disable-next-line no-console + console.log( + "[HF-DBG] resolveDomEditSelection start", + JSON.stringify({ + startEl: startEl.id || startEl.tagName, + captureKind: capture.kind, + startCurrent: current + ? current.id || current.getAttribute("data-hf-group") || current.tagName + : null, + }), + ); while (current && current !== doc.body && current !== doc.documentElement) { const selector = buildStableSelector(current); const hfId = readHfId(current); @@ -364,6 +380,16 @@ export async function resolveDomEditSelection( }); const rect = current.getBoundingClientRect(); + // eslint-disable-next-line no-console + console.log( + "[HF-DBG] resolveDomEditSelection → resolved", + JSON.stringify({ + element: current.id || current.getAttribute("data-hf-group") || current.tagName, + selector, + isGroup: current.hasAttribute("data-hf-group"), + canApplyManualOffset: capabilities.canApplyManualOffset, + }), + ); return { element: current, id: current.id || undefined, diff --git a/packages/studio/src/hooks/useDomSelection.ts b/packages/studio/src/hooks/useDomSelection.ts index b1b3830f73..212b925155 100644 --- a/packages/studio/src/hooks/useDomSelection.ts +++ b/packages/studio/src/hooks/useDomSelection.ts @@ -193,6 +193,37 @@ 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); + } + + // eslint-disable-next-line no-console + console.log( + "[HF-DBG] applyDomSelection", + JSON.stringify({ + additive: isAdditiveSelection, + preserveGroup: options?.preserveGroup ?? false, + incoming: + selection.element.id || + selection.element.getAttribute("data-hf-group") || + selection.element.tagName, + nextSelection: nextSelection + ? nextSelection.element.id || + nextSelection.element.getAttribute("data-hf-group") || + nextSelection.element.tagName + : null, + nextGroupCount: nextGroup.length, + nextGroup: nextGroup.map( + (s) => s.element.id || s.element.getAttribute("data-hf-group") || s.element.tagName, + ), + activeGroupElement: activeGroupElementRef.current?.getAttribute("data-hf-group") ?? null, + }), + ); + if (nextSelection) { if (options?.revealPanel !== false) { setRightCollapsed(false); 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; From 5b690c5a8a1a04e8cbb12281333f667e7de767cb Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 26 Jun 2026 15:06:33 -0400 Subject: [PATCH 08/19] fix(studio): batch 3D depth commit so perspective + z don't race Setting depth fired two un-awaited gsap mutations (transformPerspective, then z) to the same script. They raced read-modify-write: the second read the base before the first landed and wrote back without the other prop, so the lens or z reverted after a seek (depth 'didn't stick' after scrolling). The concurrent writes could also collide on the file and 404 the save. Batch both into one keyframe commit, exactly as commitPose/recenter already do for the rotation axes. --- .../src/components/editor/Transform3DCube.tsx | 14 ++++-- .../editor/propertyPanel3dTransform.tsx | 49 +++++++++++++------ 2 files changed, 44 insertions(+), 19 deletions(-) diff --git a/packages/studio/src/components/editor/Transform3DCube.tsx b/packages/studio/src/components/editor/Transform3DCube.tsx index adf7de058d..3a81c7584e 100644 --- a/packages/studio/src/components/editor/Transform3DCube.tsx +++ b/packages/studio/src/components/editor/Transform3DCube.tsx @@ -29,6 +29,7 @@ const pxToProjPersp = (px: number) => (px > 0 ? Math.max(2.2, Math.min(14, px / export function Transform3DCube({ pose, perspective = 0, + defaultPerspective = 0, z = 0, onPoseDraft, onPoseCommit, @@ -41,6 +42,8 @@ export function Transform3DCube({ pose: CubePose; /** Element's transformPerspective (px); drives the cube's foreshortening. */ perspective?: number; + /** Comp-derived lens used for depth feedback before a perspective is committed. */ + defaultPerspective?: 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). */ @@ -100,15 +103,16 @@ export function Transform3DCube({ // 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))); + // farther (z<0) smaller. Use the committed perspective, else the comp-derived + // lens the panel is about to apply — same value in both, so the cube doesn't + // jump when the commit lands. If neither is known, skip the scale (no lens). + const lens = perspective > 0 ? perspective : defaultPerspective; + const depthScale = lens > 0 ? Math.max(0.4, Math.min(2.2, lens / (lens - shownZ))) : 1; const projOpts = { cx: CX, cy: CY, r: RADIUS * depthScale, - persp: pxToProjPersp(perspective), + persp: pxToProjPersp(lens), }; // 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 diff --git a/packages/studio/src/components/editor/propertyPanel3dTransform.tsx b/packages/studio/src/components/editor/propertyPanel3dTransform.tsx index 22b1aea83b..ad376ab8d5 100644 --- a/packages/studio/src/components/editor/propertyPanel3dTransform.tsx +++ b/packages/studio/src/components/editor/propertyPanel3dTransform.tsx @@ -6,9 +6,19 @@ 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; +// translateZ only foreshortens under a perspective lens. Rather than hardcode one +// (an arbitrary px value reads wrong at different canvas sizes), derive it from the +// element's composition: perspective = composition height puts the virtual camera +// one comp-height back, a natural ~53° vertical FOV that looks the same whether the +// canvas is 720p or 4K. Falls back to the element's own height only if the comp size +// can't be read (detached/unmeasured), never to a fixed magic number. +function naturalDepthPerspective(el: HTMLElement | null | undefined): number { + if (!el) return 0; + const root = el.closest("[data-hf-inner-root],[data-composition-id]") as HTMLElement | null; + const compHeight = root?.offsetHeight || el.ownerDocument?.documentElement?.clientHeight || 0; + if (compHeight > 0) return Math.round(compHeight); + return Math.round((el.offsetHeight || 0) * 4) || 0; +} type KeyframeEntry = Array<{ percentage: number; @@ -74,6 +84,9 @@ function Cube3dControl({ rotationY: gsapRuntimeValues.rotationY ?? 0, rotationZ: gsapRuntimeValues.rotationZ ?? 0, }; + // Comp-derived lens (see naturalDepthPerspective) applied the first time depth is + // set, so the scene's foreshortening scales with the canvas instead of a magic 800. + const depthPerspective = naturalDepthPerspective(element.element); // Commit only the rotation axes the drag actually changed (each rounded to a // whole degree). Reuses the keyframe-aware animated-property commit, so a drag // at the playhead writes/updates a keyframe just like the numeric fields. @@ -127,6 +140,7 @@ function Cube3dControl({ { - // 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, - ); + const props: Record = { z }; + // translateZ is invisible without a perspective lens — apply the + // comp-derived lens 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 && depthPerspective > 0) { + props.transformPerspective = depthPerspective; + } + // ONE keyframe for z + perspective together. Two separate commits raced + // read-modify-write on the same script — the second read the base before + // the first landed and dropped the other prop, so depth/lens reverted + // after a seek (and the colliding writes could 404 the save). Batch like + // commitPose; fall back to per-prop only if no batched commit is wired. + if (onCommitAnimatedProperties) { + void onCommitAnimatedProperties(element, props); + } else { + for (const [p, v] of Object.entries(props)) + void onCommitAnimatedProperty(element, p, v); } - void onCommitAnimatedProperty(element, "z", z); }} onRecenter={recenter} onKeyframe={onKeyframe} From fc4ca0dd942340fc1220adfd6e69624349e0feee Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 26 Jun 2026 15:26:59 -0400 Subject: [PATCH 09/19] fix(studio): Enable keyframes on a set at/before its start drops a 0% keyframe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit promoteSetToKeyframes returned early when the playhead was at or before the set's start (t <= setStart) — so clicking Enable keyframes on a gsap.set element while seeking at 0 (set start 0) did nothing. It only knew how to promote a set into a FORWARD range (held@0% → live@100%). Now, when there's no forward range, replace the set with a single keyframe at the playhead holding its value (matching the no-animation branch), so a 0% keyframe is actually created. --- .../src/hooks/useEnableKeyframes.test.ts | 35 +++++++++++++++++++ .../studio/src/hooks/useEnableKeyframes.ts | 30 +++++++++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/hooks/useEnableKeyframes.test.ts b/packages/studio/src/hooks/useEnableKeyframes.test.ts index 3594a57983..47826780ac 100644 --- a/packages/studio/src/hooks/useEnableKeyframes.test.ts +++ b/packages/studio/src/hooks/useEnableKeyframes.test.ts @@ -167,4 +167,39 @@ describe("promoteSetToKeyframes — auto endpoint", () => { expect(kfs[1].percentage).toBe(100); expect(kfs[1].auto).toBeUndefined(); }); + + it("playhead AT the set (t <= setStart) drops a single 0% keyframe, not a no-op", async () => { + // Regression: enabling keyframes on a `gsap.set` element at t=0 (set start 0) + // returned early (`t <= setStart`) → nothing created. Must give a 0% keyframe. + let committed: Record | undefined; + const session = { + commitMutation: async (mutation: Record) => { + committed = mutation; + }, + } as unknown as EnableKeyframesSession; + const sel = { + id: "box", + selector: "#box", + sourceFile: "index.html", + element: { isConnected: true } as unknown as HTMLElement, + } as unknown as DomEditSelection; + const iframe = { + contentWindow: { gsap: { getProperty: () => -1091 } }, + } as unknown as HTMLIFrameElement; + const setAnim = anim({ + id: "#box-set-0-position", + targetSelector: "#box", + method: "set", + global: true, + resolvedStart: 0, + properties: { x: -1091, y: 280 }, + }); + + await promoteSetToKeyframes(session, sel, setAnim, 0, iframe); + + const kfs = committed?.keyframes as Array<{ percentage: number }>; + expect(committed?.type).toBe("replace-with-keyframes"); + expect(kfs).toHaveLength(1); + expect(kfs[0].percentage).toBe(0); + }); }); diff --git a/packages/studio/src/hooks/useEnableKeyframes.ts b/packages/studio/src/hooks/useEnableKeyframes.ts index 634f545e8a..3c9558ab1a 100644 --- a/packages/studio/src/hooks/useEnableKeyframes.ts +++ b/packages/studio/src/hooks/useEnableKeyframes.ts @@ -253,7 +253,35 @@ export async function promoteSetToKeyframes( ): Promise { const selector = selectorFromSelection(sel); const setStart = resolveTweenStart(setAnim) ?? 0; - if (!selector || !session.commitMutation || t <= setStart) return; + if (!selector || !session.commitMutation) return; + // Playhead at or before the set → there's no forward range to promote into. + // Instead of doing nothing (which read as "can't add a keyframe at 0"), replace + // the set with a single keyframe at the playhead holding its value, matching the + // no-animation branch: one diamond the user can build motion from. + if (t <= setStart) { + const position = readElementPosition(iframe, sel, setAnim); + if (Object.keys(position).length === 0) { + for (const key of Object.keys(setAnim.properties ?? {})) { + const held = setAnim.properties?.[key]; + if (typeof held === "number") position[key] = held; + } + } + if (Object.keys(position).length === 0) return; + const range = resolveNewTweenRange(sel.dataAttributes?.start, sel.dataAttributes?.duration, t); + await session.commitMutation( + { + type: "replace-with-keyframes", + animationId: setAnim.id, + targetSelector: selector, + position: roundTo3(range.start), + duration: roundTo3(range.duration), + keyframes: [{ percentage: 0, properties: position }], + ease: setAnim.ease, + }, + { label: "Enable keyframes", softReload: true }, + ); + return; + } const endPosition = readElementPosition(iframe, sel, setAnim); if (Object.keys(endPosition).length === 0) return; const startPosition: Record = {}; From 5159e983342bf167d3e6e466727a316c6e04c2c2 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 26 Jun 2026 15:50:10 -0400 Subject: [PATCH 10/19] fix(studio): correct keyframes + expansion for sub-composition timeline clips Two gaps for elements inside a sub-composition: 1) Clip keyframes rendered off-clip. The keyframe cache computes clip-relative percentages from the element's start/duration, but sub-comp internals aren't in the timeline elements list, so duration defaulted to 1s and percentages blew past 100%. Resolve the timing basis from the sub-comp HOST's bounds (via domClipChildren, since the host's data-composition-src is stripped in the rendered DOM). Shared resolveClipTimingBasis used by both cache populators, which now re-run when the sub-comp children appear. 2) Only GROUPED sub-comp children expanded. Generalize the DOM-children collector to gather id'd children of the sub-comp inner-root (grouped OR ungrouped), descending through id-less structural wrappers; one level into groups for drill-in. Ungrouped pills now expand into timeline rows too. --- .../studio/src/hooks/useGsapTweenCache.ts | 68 +++++++++++++++---- .../player/hooks/useTimelineSyncCallbacks.ts | 36 +++++----- 2 files changed, 73 insertions(+), 31 deletions(-) diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index 985511271c..97053f6376 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -176,6 +176,37 @@ export async function fetchParsedAnimations( } } +/** + * Clip-relative timing basis for an element. Sub-composition internals (e.g. pills + * inside a scene) aren't timeline clips themselves — they're derived at expand time + * — so they're absent from `elements`. Without a basis, elDuration defaulted to 1 + * and clip-relative keyframe percentages blew past 100% (rendering off the clip). + * Fall back to the sub-comp HOST's bounds, resolved via domClipChildren (the host's + * data-composition-src is stripped in the rendered DOM, so we can't query it). + */ +function resolveClipTimingBasis( + elementId: string, + sourceFile: string, + elements: ReadonlyArray<{ + domId?: string; + key?: string; + id: string; + start: number; + duration: number; + }>, + domClipChildren: ReadonlyArray<{ id: string; hostId: string }>, +): { elStart: number; elDuration: number } { + const direct = elements.find( + (el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`, + ); + if (direct) return { elStart: direct.start, elDuration: direct.duration }; + const hostId = domClipChildren.find((c) => c.id === elementId)?.hostId; + const host = hostId + ? elements.find((el) => el.domId === hostId || (el.key ?? el.id) === `index.html#${hostId}`) + : undefined; + return { elStart: host?.start ?? 0, elDuration: host?.duration ?? 1 }; +} + export function useGsapAnimationsForElement( projectId: string | null, sourceFile: string, @@ -192,6 +223,11 @@ export function useGsapAnimationsForElement( const [unsupportedTimelinePattern, setUnsupportedTimelinePattern] = useState(false); const lastFetchKeyRef = useRef(""); const retryTimerRef = useRef | null>(null); + // Re-run the per-element cache populate when sub-comp DOM children appear, so a + // sub-comp element gets its host-relative keyframe percentages (not elDuration=1). + const domClipChildrenKey = usePlayerStore((s) => + s.domClipChildren.map((c) => `${c.id}<${c.hostId}`).join("|"), + ); useEffect(() => { const targetKey = target?.id ?? target?.selector ?? ""; @@ -351,12 +387,13 @@ export function useGsapAnimationsForElement( // Resolve the element's time range from the player store so we can // convert tween-relative keyframe percentages to clip-relative ones. - const { elements } = usePlayerStore.getState(); - const timelineEl = elements.find( - (el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`, + const { elements, domClipChildren } = usePlayerStore.getState(); + const { elStart, elDuration } = resolveClipTimingBasis( + elementId, + sourceFile, + elements, + domClipChildren, ); - const elStart = timelineEl?.start ?? 0; - const elDuration = timelineEl?.duration ?? 1; const allKeyframes: Array< GsapKeyframesData["keyframes"][0] & { tweenPercentage?: number; propertyGroup?: string } @@ -419,7 +456,8 @@ export function useGsapAnimationsForElement( // PropertyPanel reads the cache by bare elementId (without sourceFile prefix), // so write a duplicate entry under the bare key for cross-component lookups. setKeyframeCache(elementId, merged); - }, [elementId, sourceFile, animations]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [elementId, sourceFile, animations, domClipChildrenKey]); return { animations, multipleTimelines, unsupportedTimelinePattern }; } @@ -442,13 +480,19 @@ export function usePopulateKeyframeCacheForFile( iframeRef?: React.RefObject, ): void { const elementCount = usePlayerStore((s) => s.elements.length); + // Re-run when sub-comp DOM children appear (they supply the host bounds the + // clip-relative keyframe percentages are computed against; without this the + // cache is computed once before they exist and the percentages stay wrong). + const domClipChildrenKey = usePlayerStore((s) => + s.domClipChildren.map((c) => `${c.id}<${c.hostId}`).join("|"), + ); const lastFetchKeyRef = useRef(""); const runtimeScanDoneRef = useRef(""); const astFetchDoneRef = useRef(""); useEffect(() => { - const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}:${elementCount}`; + const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}:${elementCount}:${domClipChildrenKey}`; if (fetchKey === lastFetchKeyRef.current) return; lastFetchKeyRef.current = fetchKey; runtimeScanDoneRef.current = ""; @@ -461,7 +505,7 @@ export function usePopulateKeyframeCacheForFile( if (!parsed) return; const { setKeyframeCache } = usePlayerStore.getState(); clearKeyframeCacheForFile(sf); - const { elements } = usePlayerStore.getState(); + const { elements, domClipChildren } = usePlayerStore.getState(); const doc = iframeRef?.current?.contentDocument; const mergedByElement = new Map(); for (const anim of parsed.animations) { @@ -482,11 +526,7 @@ export function usePopulateKeyframeCacheForFile( // Attribute the tween to every element it animates (handles class / // group / descendant selectors, not just `#id`). for (const id of resolveSelectorElementIds(anim.targetSelector, doc)) { - const timelineEl = elements.find( - (el) => el.domId === id || (el.key ?? el.id) === `${sf}#${id}`, - ); - const elStart = timelineEl?.start ?? 0; - const elDuration = timelineEl?.duration ?? 1; + const { elStart, elDuration } = resolveClipTimingBasis(id, sf, elements, domClipChildren); const clipKeyframes = kfData.keyframes.map((kf) => { const absTime = toAbsoluteTime(tweenPos, tweenDur, kf.percentage); // 0.001% precision (matching useGsapAnimationsForElement above) so a @@ -524,7 +564,7 @@ export function usePopulateKeyframeCacheForFile( // iframeRef is read for DOM selector resolution but intentionally not a dep // (it's a stable ref; the separate runtime-scan effect owns iframe timing). // eslint-disable-next-line react-hooks/exhaustive-deps - }, [projectId, sourceFile, version, elementCount]); + }, [projectId, sourceFile, version, elementCount, domClipChildrenKey]); // Separate effect for runtime keyframe discovery — polls until the iframe // has loaded GSAP timelines, independent of the AST fetch lifecycle. diff --git a/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts b/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts index 4965bcb37a..c943680b09 100644 --- a/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts +++ b/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts @@ -107,27 +107,29 @@ export function useTimelineSyncCallbacks({ if (clip.kind !== "composition" || !clip.id) continue; const hostEl = iframeDoc.getElementById(clip.id); if (!hostEl) continue; - for (const groupEl of hostEl.querySelectorAll("[data-hf-group]")) { - if (!groupEl.id) continue; - const groupLabel = groupEl.getAttribute("data-hf-group") || groupEl.id; - domClipChildren.push({ - id: groupEl.id, - parentId: clip.id, - hostId: clip.id, - label: groupLabel, - }); - parentMap.set(groupEl.id, clip.id); - for (const child of Array.from(groupEl.children)) { - if (!child.id) continue; + const hostId = clip.id; + const innerRoot = hostEl.querySelector("[data-hf-inner-root]") ?? hostEl; + // Collect the sub-comp's id'd descendants (grouped OR ungrouped) so they + // expand into timeline rows. Descends through id-less structural wrappers + // (the inlined sub-comp body), and one level into groups for drill-in. + const collect = (parentEl: Element, parentId: string) => { + for (const child of Array.from(parentEl.children)) { + if (!child.id) { + collect(child, parentId); // unwrap id-less structural containers + continue; + } + const isGroup = child.hasAttribute("data-hf-group"); domClipChildren.push({ id: child.id, - parentId: groupEl.id, - hostId: clip.id, - label: child.id, + parentId, + hostId, + label: isGroup ? child.getAttribute("data-hf-group") || child.id : child.id, }); - parentMap.set(child.id, groupEl.id); + parentMap.set(child.id, parentId); + if (isGroup) collect(child, child.id); } - } + }; + collect(innerRoot, hostId); } } usePlayerStore.getState().setClipParentMap(parentMap); From f7c53270844a25631c6a52f1b0121ec679430cce Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 26 Jun 2026 16:04:54 -0400 Subject: [PATCH 11/19] fix(studio): remove keyframe dragging from the timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dragging timeline keyframe diamonds was unreliable — clip<->tween percentage remapping, an optimistic-hold workaround, and an intermittent no-op/revert when the GSAP session lagged the drag (its own comments document the flakiness). Remove the drag interaction entirely: diamonds still display, click-to-seek, and offer the context menu (add/remove/ease) — keyframe timing is edited via the playhead + panel, which are deterministic. Deletes the keyframe-move plan module + its wiring through TimelineClipDiamonds -> TimelineCanvas -> Timeline -> TimelineEditContext. --- .../src/components/StudioPreviewArea.tsx | 41 ----- .../components/editor/keyframeMove.test.ts | 101 ------------ .../src/components/editor/keyframeMove.ts | 151 ------------------ .../src/contexts/TimelineEditContext.tsx | 1 - .../studio/src/player/components/Timeline.tsx | 15 -- .../src/player/components/TimelineCanvas.tsx | 13 -- .../components/TimelineClipDiamonds.tsx | 129 +-------------- .../player/components/timelineCallbacks.ts | 1 - 8 files changed, 4 insertions(+), 448 deletions(-) delete mode 100644 packages/studio/src/components/editor/keyframeMove.test.ts delete mode 100644 packages/studio/src/components/editor/keyframeMove.ts diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx index 45419a4b14..be92a377a4 100644 --- a/packages/studio/src/components/StudioPreviewArea.tsx +++ b/packages/studio/src/components/StudioPreviewArea.tsx @@ -21,8 +21,6 @@ import { useDomEditActionsContext, useDomEditSelectionContext } from "../context import { TimelineEditProvider } from "../contexts/TimelineEditContext"; import type { BlockPreviewInfo } from "./sidebar/BlocksTab"; import { readStudioUiPreferences } from "../utils/studioUiPreferences"; -import { fetchParsedAnimations } from "../hooks/useGsapTweenCache"; -import { pickKeyframeTween, computeKeyframeMovePlan } from "./editor/keyframeMove"; import type { GestureRecordingState } from "./editor/GestureRecordControl"; export interface StudioPreviewAreaProps { @@ -182,45 +180,6 @@ export function StudioPreviewArea({ } }, // fallow-ignore-next-line complexity - onMoveKeyframe: async (_el: TimelineElement, oldPct: number, newPct: number) => { - // Resolve the dragged element's selection + parsed animations on demand - // (both awaited and cached) rather than relying on the async DOM-edit - // session being loaded for this element — that coupling made the commit - // intermittently no-op (revert) when dragging before the session caught up. - if (!projectId) return; - const sourceFile = _el.sourceFile || activeCompPath || "index.html"; - const [selection, parsed] = await Promise.all([ - buildDomSelectionForTimelineElement(_el), - fetchParsedAnimations(projectId, sourceFile), - ]); - if (!selection || !parsed) return; - - const cached = usePlayerStore.getState().keyframeCache.get(_el.key ?? _el.id); - const cachedKf = cached?.keyframes.find((k) => Math.abs(k.percentage - oldPct) < 0.2); - const origAbsTime = _el.start + (oldPct / 100) * _el.duration; - const anim = pickKeyframeTween( - parsed.animations, - _el, - origAbsTime, - cachedKf?.propertyGroup, - ); - if (!anim) return; - - const plan = computeKeyframeMovePlan( - anim, - cachedKf?.tweenPercentage ?? oldPct, - _el, - newPct, - ); - if (plan.meta) handleGsapUpdateMeta(anim.id, plan.meta, selection); - for (const pct of plan.removes) handleGsapRemoveKeyframe(anim.id, pct, selection); - for (const add of plan.adds) { - for (const [prop, val] of Object.entries(add.properties)) { - handleGsapAddKeyframe(anim.id, add.pct, prop, val, selection); - } - } - }, - // fallow-ignore-next-line complexity onToggleKeyframeAtPlayhead: (el: TimelineElement) => { const currentTime = usePlayerStore.getState().currentTime; const pct = diff --git a/packages/studio/src/components/editor/keyframeMove.test.ts b/packages/studio/src/components/editor/keyframeMove.test.ts deleted file mode 100644 index d7ee679e37..0000000000 --- a/packages/studio/src/components/editor/keyframeMove.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { pickKeyframeTween, computeKeyframeMovePlan } from "./keyframeMove"; - -const flat = (id: string, target: string, position: number, duration: number, group?: string) => ({ - id, - targetSelector: target, - position, - duration, - resolvedStart: position, - propertyGroup: group, -}); - -const el = { start: 0, duration: 10, domId: "box", selector: "#box" }; - -describe("pickKeyframeTween", () => { - it("matches by the element's selector", () => { - const anims = [flat("a", "#other", 0, 5), flat("b", "#box", 2, 3)]; - expect(pickKeyframeTween(anims, el, 3, undefined)?.id).toBe("b"); - }); - - it("prefers the dragged keyframe's property group", () => { - const anims = [flat("pos", "#box", 0, 8, "position"), flat("vis", "#box", 0, 8, "visual")]; - expect(pickKeyframeTween(anims, el, 1, "visual")?.id).toBe("vis"); - }); - - it("among same-group tweens picks the one whose window contains the original time", () => { - const fadeIn = flat("in", "#box", 1, 1, "visual"); - const fadeOut = flat("out", "#box", 8, 1, "visual"); - expect(pickKeyframeTween([fadeIn, fadeOut], el, 8.5, "visual")?.id).toBe("out"); - expect(pickKeyframeTween([fadeIn, fadeOut], el, 1.2, "visual")?.id).toBe("in"); - }); - - it("returns undefined when there are no tweens", () => { - expect(pickKeyframeTween([], el, 1, undefined)).toBeUndefined(); - }); - - it("returns undefined rather than editing another element on a selector mismatch", () => { - const anims = [flat("a", "#other", 0, 5), flat("b", ".unrelated", 2, 3)]; - expect(pickKeyframeTween(anims, el, 3, undefined)).toBeUndefined(); - }); -}); - -describe("computeKeyframeMovePlan — flat tween", () => { - const anim = flat("t", "#box", 2, 4); // window [2, 6] - - it("start point trims the front, keeping the end fixed", () => { - // newPct 30% → abs 3 → start moves to 3, duration shrinks to 3. - const plan = computeKeyframeMovePlan(anim, 0, el, 30); - expect(plan.meta).toEqual({ position: 3, duration: 3 }); - expect(plan.removes).toEqual([]); - }); - - it("end point resizes, keeping the start", () => { - // tweenOldPct 100 (end) → newPct 80% → abs 8 → duration 6, start unchanged. - const plan = computeKeyframeMovePlan(anim, 100, el, 80); - expect(plan.meta).toEqual({ position: 2, duration: 6 }); - }); -}); - -describe("computeKeyframeMovePlan — keyframe-array tween", () => { - const anim = { - id: "k", - targetSelector: "#box", - position: 0, - duration: 10, - resolvedStart: 0, - keyframes: { - keyframes: [ - { percentage: 0, properties: { x: 0 } }, - { percentage: 50, properties: { x: 50 } }, - { percentage: 100, properties: { x: 100 } }, - ], - }, - }; - - it("moves an intermediate keyframe without touching the tween or others", () => { - // mid keyframe (tweenPct 50) → newPct 70% → abs 7 → 70% of the tween. - const plan = computeKeyframeMovePlan(anim, 50, el, 70); - expect(plan.meta).toBeUndefined(); - expect(plan.removes).toEqual([50]); - expect(plan.adds).toEqual([{ pct: 70, properties: { x: 50 } }]); - }); - - it("start move remaps intermediates to preserve their absolute times", () => { - // start (tweenPct 0) → newPct 20% → abs 2 → window [2,10]. The 50% keyframe - // was at abs 5 → now (5-2)/8 = 37.5%. - const plan = computeKeyframeMovePlan(anim, 0, el, 20); - expect(plan.meta).toEqual({ position: 2, duration: 8 }); - expect(plan.removes).toContain(50); - const mid = plan.adds.find((a) => a.properties.x === 50); - expect(mid?.pct).toBeCloseTo(37.5, 1); - }); - - it("is a no-op when the dragged keyframe can't be located (stale cache)", () => { - // tweenOldPct 33 matches no keyframe (0/50/100) → must NOT resize the tween. - const plan = computeKeyframeMovePlan(anim, 33, el, 70); - expect(plan.meta).toBeUndefined(); - expect(plan.removes).toEqual([]); - expect(plan.adds).toEqual([]); - }); -}); diff --git a/packages/studio/src/components/editor/keyframeMove.ts b/packages/studio/src/components/editor/keyframeMove.ts deleted file mode 100644 index 6b38ca7943..0000000000 --- a/packages/studio/src/components/editor/keyframeMove.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * Pure helpers for committing a keyframe-diamond drag: pick the tween the - * dragged keyframe belongs to, and compute the GSAP mutations (tween - * position/duration and/or keyframe add/remove) for the move. Kept free of - * React/store so the timeline drag handler stays a thin orchestrator. - */ - -interface TweenLike { - id: string; - targetSelector: string; - position: number | string; - duration?: number; - resolvedStart?: number; - propertyGroup?: string; - keyframes?: { keyframes: { percentage: number; properties: Record }[] }; -} - -interface ElementWindow { - start: number; - duration: number; - domId?: string; - selector?: string; -} - -export interface KeyframeMovePlan { - /** Tween timing change (start/end point drags). */ - meta?: { position: number; duration: number }; - /** Keyframe percentages to remove, then re-add (intermediate move / remap). */ - removes: number[]; - adds: { pct: number; properties: Record }[]; -} - -const round3 = (n: number) => Math.round(n * 1000) / 1000; -const clampPct = (n: number) => Math.max(0, Math.min(100, Math.round(n * 100) / 100)); -const MIN_DUR = 0.05; - -function tweenWindow(a: TweenLike): { start: number; dur: number } { - return { - start: a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0), - dur: a.duration ?? 0, - }; -} - -type Kf = { percentage: number; properties: Record }; - -/** - * Remap every keyframe except `keepIdx` from the old tween window to the new one - * so their absolute times stay fixed after a start/end resize. Returns the - * remove/add ops (empty for flat tweens, which have no intermediates). - */ -function remapKeyframes( - kfs: Kf[], - keepIdx: number, - oldStart: number, - oldDur: number, - newStart: number, - newDur: number, -): Pick { - const removes: number[] = []; - const adds: KeyframeMovePlan["adds"] = []; - if (newDur <= 0) return { removes, adds }; - for (let i = 0; i < kfs.length; i++) { - if (i === keepIdx) continue; - const k = kfs[i]!; - const absT = oldStart + (k.percentage / 100) * oldDur; - const remapped = clampPct(((absT - newStart) / newDur) * 100); - if (Math.abs(remapped - k.percentage) < 0.05) continue; - removes.push(k.percentage); - adds.push({ pct: remapped, properties: k.properties }); - } - return { removes, adds }; -} - -/** - * Pick the tween the dragged keyframe belongs to: restrict to the element's - * selector and (if known) the keyframe's property group, then choose the one - * whose time window contains — or is nearest — the keyframe's original time. - * An element can have several tweens in one group (e.g. fade-in + fade-out). - */ -export function pickKeyframeTween( - anims: T[], - el: ElementWindow, - origAbsTime: number, - group: string | undefined, -): T | undefined { - const selectors = [el.domId ? `#${el.domId}` : null, el.selector].filter(Boolean); - const forEl = anims.filter((a) => selectors.includes(a.targetSelector)); - // Only ever pick among THIS element's tweens. Don't fall back to all - // animations — a selector mismatch (e.g. a class/compound-selector tween) - // would otherwise edit a different element's keyframes. No match → no-op. - if (forEl.length === 0) return undefined; - const groupPool = group ? forEl.filter((a) => a.propertyGroup === group) : []; - const candidates = groupPool.length > 0 ? groupPool : forEl; - const dist = (a: T): number => { - const { start, dur } = tweenWindow(a); - if (origAbsTime >= start && origAbsTime <= start + dur) return 0; - return Math.min(Math.abs(origAbsTime - start), Math.abs(origAbsTime - (start + dur))); - }; - return candidates.reduce((best, a) => (dist(a) < dist(best) ? a : best), candidates[0]!); -} - -/** - * Compute the mutations for moving a keyframe to `newPct` (clip-relative): - * - start point → trim front (position moves, end fixed), - * - end point → resize (duration changes, start fixed), - * - intermediate → move only that keyframe; start/end moves remap the other - * keyframes so their absolute times stay put. - */ -// fallow-ignore-next-line complexity -export function computeKeyframeMovePlan( - anim: TweenLike, - tweenOldPct: number, - el: ElementWindow, - newPct: number, -): KeyframeMovePlan { - const newAbsTime = el.start + (newPct / 100) * el.duration; - const tweenStart = tweenWindow(anim).start; - const tweenDur = anim.duration ?? el.duration; - const kfs = anim.keyframes - ? anim.keyframes.keyframes.slice().sort((a, b) => a.percentage - b.percentage) - : null; - const idx = kfs ? kfs.findIndex((k) => Math.abs(k.percentage - tweenOldPct) < 0.5) : -1; - - // Keyframe-array tween but the dragged keyframe couldn't be located (stale - // cache / precision drift): no-op rather than falling through to an end-point - // resize that would silently rescale the whole tween and re-time every key. - if (kfs && idx === -1) return { removes: [], adds: [] }; - - if (kfs && idx > 0 && idx < kfs.length - 1) { - const movedPct = tweenDur > 0 ? clampPct(((newAbsTime - tweenStart) / tweenDur) * 100) : 0; - return { removes: [tweenOldPct], adds: [{ pct: movedPct, properties: kfs[idx]!.properties }] }; - } - - const isStartPoint = kfs ? idx === 0 : tweenOldPct <= 50; - let newStart = tweenStart; - let newDur = tweenDur; - if (isStartPoint) { - const end = tweenStart + tweenDur; - newStart = Math.max(0, Math.min(newAbsTime, end - MIN_DUR)); - newDur = end - newStart; - } else { - newDur = Math.max(MIN_DUR, newAbsTime - tweenStart); - } - - const windowChanged = newStart !== tweenStart || newDur !== tweenDur; - const remap = - kfs && windowChanged - ? remapKeyframes(kfs, idx, tweenStart, tweenDur, newStart, newDur) - : { removes: [], adds: [] }; - return { meta: { position: round3(newStart), duration: round3(newDur) }, ...remap }; -} diff --git a/packages/studio/src/contexts/TimelineEditContext.tsx b/packages/studio/src/contexts/TimelineEditContext.tsx index 5ff84cf953..8481d2054a 100644 --- a/packages/studio/src/contexts/TimelineEditContext.tsx +++ b/packages/studio/src/contexts/TimelineEditContext.tsx @@ -39,7 +39,6 @@ export function TimelineEditProvider({ value.onDeleteKeyframe, value.onDeleteAllKeyframes, value.onChangeKeyframeEase, - value.onMoveKeyframe, value.onToggleKeyframeAtPlayhead, ], ); diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index b63589912d..2e2bc2cb3a 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -20,7 +20,6 @@ import { type KeyframeDiamondContextMenuState, } from "./KeyframeDiamondContextMenu"; import { useTimelineClipDrag } from "./useTimelineClipDrag"; -import { snapKeyframePctToBeat } from "./timelineEditing"; import { ClipContextMenu } from "./ClipContextMenu"; import { GUTTER, @@ -87,7 +86,6 @@ export const Timeline = memo(function Timeline({ onDeleteKeyframe, onDeleteAllKeyframes, onChangeKeyframeEase, - onMoveKeyframe, } = useResolvedTimelineEditCallbacks({ onMoveElement: onMoveElementOverride, onResizeElement: onResizeElementOverride, @@ -481,19 +479,6 @@ export const Timeline = memo(function Timeline({ onShiftClickKeyframe={(elId, pct) => { toggleSelectedKeyframe(`${elId}:${pct}`); }} - onDragKeyframe={(el, oldPct, newPct) => { - onMoveKeyframe?.(el, oldPct, newPct); - }} - onSnapKeyframePct={(el, pct) => - snapKeyframePctToBeat(el, pct, adjustedBeatAnalysis?.beatTimes, pps) - } - onPickKeyframeElement={(el) => { - const elKey = el.key ?? el.id; - if (selectedElementId !== elKey) { - setSelectedElementId(elKey); - onSelectElement?.(el); - } - }} onContextMenuKeyframe={(e, elId, pct) => { const el = expandedElements.find((x) => (x.key ?? x.id) === elId); if (el) { diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index fa12a9f1e1..9bdab10aee 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -91,11 +91,6 @@ interface TimelineCanvasProps { currentTime: number; onClickKeyframe?: (element: TimelineElement, percentage: number) => void; onShiftClickKeyframe?: (elementId: string, percentage: number) => void; - onDragKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void; - /** Snap a keyframe's clip-relative % to the nearest beat (returns unchanged when none in range). */ - onSnapKeyframePct?: (element: TimelineElement, pct: number) => number; - /** Select the element when a keyframe drag starts (loads its GSAP session). */ - onPickKeyframeElement?: (element: TimelineElement) => void; onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void; onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void; beatAnalysis?: MusicBeatAnalysis | null; @@ -143,9 +138,6 @@ export const TimelineCanvas = memo(function TimelineCanvas({ currentTime, onClickKeyframe, onShiftClickKeyframe, - onDragKeyframe, - onSnapKeyframePct, - onPickKeyframeElement, onContextMenuKeyframe, onContextMenuClip, beatAnalysis, @@ -446,11 +438,6 @@ export const TimelineCanvas = memo(function TimelineCanvas({ selectedKeyframes={selectedKeyframes} onClickKeyframe={(pct) => onClickKeyframe?.(previewElement, pct)} onShiftClickKeyframe={onShiftClickKeyframe} - onDragKeyframe={(oldPct, newPct) => - onDragKeyframe?.(previewElement, oldPct, newPct) - } - snapPct={(pct) => onSnapKeyframePct?.(previewElement, pct) ?? pct} - onPickForDrag={() => onPickKeyframeElement?.(previewElement)} onContextMenuKeyframe={onContextMenuKeyframe} /> )} diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.tsx index d396e54f8a..84885f754a 100644 --- a/packages/studio/src/player/components/TimelineClipDiamonds.tsx +++ b/packages/studio/src/player/components/TimelineClipDiamonds.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useRef, useState } from "react"; +import { memo } from "react"; import { BEAT_BAND_H } from "./BeatStrip"; interface KeyframeEntry { @@ -28,15 +28,7 @@ interface TimelineClipDiamondsProps { selectedKeyframes: Set; onClickKeyframe?: (percentage: number) => void; onShiftClickKeyframe?: (elementId: string, percentage: number) => void; - onDragKeyframe?: (percentage: number, newPercentage: number) => void; onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void; - /** Snap a clip-relative percentage to the nearest beat (returns it unchanged - * when no beat is within range). Drives live beat-snapping while dragging. */ - snapPct?: (percentage: number) => number; - /** Select this element when a keyframe drag begins, so its GSAP session is - * loaded by the time the move commits (diamonds render on unselected clips - * too, and a drag suppresses the selecting click). */ - onPickForDrag?: () => void; } const DIAMOND_RATIO = 0.8; @@ -59,54 +51,8 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ selectedKeyframes, onClickKeyframe, onShiftClickKeyframe, - onDragKeyframe, onContextMenuKeyframe, - snapPct, - onPickForDrag, }: TimelineClipDiamondsProps) { - // Live drag: which keyframe (by original %) is being dragged and its current - // (beat-snapped) %, so the diamond + its connecting lines follow the cursor. - const dragRef = useRef<{ origPct: number; pct: number; moved: boolean } | null>(null); - const [drag, setDrag] = useState<{ origPct: number; pct: number } | null>(null); - // Commit through the latest callback, not the one captured at pointer-down: - // selecting the element on drag-start loads its GSAP session asynchronously, - // and the commit must use the closure that sees the loaded session. - const onDragKeyframeRef = useRef(onDragKeyframe); - onDragKeyframeRef.current = onDragKeyframe; - // Optimistic hold: after a commit, keep the diamond at the dropped position - // until the cache reflects the change (the file round-trip rewrites - // keyframesData), so it doesn't flash back to the old spot in between. - const pendingRef = useRef(false); - const pendingHeldPctRef = useRef(null); - const pendingTimerRef = useRef | null>(null); - // Cleanup for an in-flight drag's document listeners, so an unmount mid-drag - // (clip deleted, comp switch, zoom-out → early return) doesn't leak them. - const dragCleanupRef = useRef<(() => void) | null>(null); - - useEffect(() => { - if (!pendingRef.current) return; - // Only release the optimistic hold once the cache actually reflects the - // committed position (a keyframe near the held %). An unrelated cache - // rebuild (e.g. elementCount change) rebuilds keyframesData with the SAME - // percentages — releasing then would flash the diamond back to the old spot. - const held = pendingHeldPctRef.current; - if (held != null && !keyframesData.keyframes.some((k) => Math.abs(k.percentage - held) < 0.3)) { - return; - } - pendingRef.current = false; - pendingHeldPctRef.current = null; - if (pendingTimerRef.current) clearTimeout(pendingTimerRef.current); - setDrag(null); - }, [keyframesData]); - - useEffect( - () => () => { - clearTimeout(pendingTimerRef.current ?? undefined); - dragCleanupRef.current?.(); - }, - [], - ); - if (clipWidthPx < 20) return null; // When the beat strip occupies the top band, shrink the diamonds and center @@ -129,79 +75,13 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ } }; - const handlePointerDown = (e: React.PointerEvent, pct: number) => { - if (e.button !== 0) return; - e.stopPropagation(); - // Ignore a new drag while a prior drop is still settling: `pct` comes from - // props (the pre-drop position) but the diamond is held at its dropped spot - // via effPct(), so a re-grab would track from a stale origin and commit - // against the wrong tween. The hold clears on the cache round-trip (≤2s). - if (pendingRef.current) return; - // Select the element up front so its GSAP session loads during the drag and - // the commit (which resolves the animation from the selection) isn't a no-op. - onPickForDrag?.(); - const startX = e.clientX; - dragRef.current = { origPct: pct, pct, moved: false }; - - const handleMove = (me: PointerEvent) => { - const d = dragRef.current; - if (!d) return; - const dx = me.clientX - startX; - // 4px dead zone so a click doesn't register as a drag. - if (!d.moved && Math.abs(dx) <= 4) return; - d.moved = true; - const rawPct = Math.max(0, Math.min(100, pct + (dx / clipWidthPx) * 100)); - const snapped = snapPct ? snapPct(rawPct) : rawPct; - d.pct = snapped; - setDrag({ origPct: pct, pct: snapped }); - }; - - const handleUp = () => { - document.removeEventListener("pointermove", handleMove); - document.removeEventListener("pointerup", handleUp); - dragCleanupRef.current = null; - const d = dragRef.current; - dragRef.current = null; - const willCommit = !!(d && d.moved && Math.abs(d.pct - d.origPct) > 0.5); - if (willCommit && d) { - // Hold the dropped position optimistically; the effect clears it once the - // cache round-trip lands (fallback timeout in case it never does). - pendingRef.current = true; - pendingHeldPctRef.current = d.pct; - setDrag({ origPct: d.origPct, pct: d.pct }); - if (pendingTimerRef.current) clearTimeout(pendingTimerRef.current); - pendingTimerRef.current = setTimeout(() => { - pendingRef.current = false; - pendingHeldPctRef.current = null; - setDrag(null); - }, 2000); - onDragKeyframeRef.current?.(d.origPct, d.pct); - } else { - setDrag(null); - } - }; - - dragCleanupRef.current = () => { - document.removeEventListener("pointermove", handleMove); - document.removeEventListener("pointerup", handleUp); - }; - - document.addEventListener("pointermove", handleMove); - document.addEventListener("pointerup", handleUp); - }; - - const effPct = (p: number): number => (drag && drag.origPct === p ? drag.pct : p); - return (
{sorted.map((kf, i) => { if (i === 0) return null; const prev = sorted[i - 1]!; - const x1 = Math.max( - 0, - Math.min(clipWidthPx, (effPct(prev.percentage) / 100) * clipWidthPx), - ); - const x2 = Math.max(0, Math.min(clipWidthPx, (effPct(kf.percentage) / 100) * clipWidthPx)); + const x1 = Math.max(0, Math.min(clipWidthPx, (prev.percentage / 100) * clipWidthPx)); + const x2 = Math.max(0, Math.min(clipWidthPx, (kf.percentage / 100) * clipWidthPx)); if (x2 - x1 < 1) return null; return (
handleClick(e, kf.percentage)} - onPointerDown={(e) => handlePointerDown(e, kf.percentage)} onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); diff --git a/packages/studio/src/player/components/timelineCallbacks.ts b/packages/studio/src/player/components/timelineCallbacks.ts index 9202acc304..a219e2a710 100644 --- a/packages/studio/src/player/components/timelineCallbacks.ts +++ b/packages/studio/src/player/components/timelineCallbacks.ts @@ -39,6 +39,5 @@ export interface TimelineEditCallbacks { onDeleteKeyframe?: (elementId: string, percentage: number) => void; onDeleteAllKeyframes?: (elementId: string) => void; onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void; - onMoveKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void; onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void; } From eeff082612981f9788465a72ba3be4718c643372 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 26 Jun 2026 16:35:19 -0400 Subject: [PATCH 12/19] fix(studio): strip a group's GSAP when ungrouping Ungrouping removed the wrapper element but left its gsap.set("#group-1") behind, targeting a now-deleted element. GSAP then threw "target not found" on every preview run, which drove a selection re-render storm that made canvas context menus (e.g. Delete All Keyframes) unclickable. unwrapElementsFromHtml now returns the unwrapped wrapper's id, and the unwrap route strips any GSAP animation targeting it (reusing the parser + removeAnimationFromScript). --- .../src/studio-api/helpers/sourceMutation.ts | 26 +++++++++++++++ packages/core/src/studio-api/routes/files.ts | 33 ++++++++++++++----- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/packages/core/src/studio-api/helpers/sourceMutation.ts b/packages/core/src/studio-api/helpers/sourceMutation.ts index 0e06e4839d..64b0ebd688 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.ts @@ -404,6 +404,9 @@ export interface WrapElementsResult { export interface UnwrapElementsResult { html: string; unwrapped: boolean; + /** The unwrapped wrapper's id, so callers can strip GSAP that targeted it + * (the wrapper is gone; a leftover `gsap.set("#id")` would throw at runtime). */ + unwrappedGroupId?: string; } export interface ElementRebase { @@ -421,6 +424,23 @@ function getInlineStylePx(el: Element, property: string): number { return Number.isFinite(n) ? n : 0; } +// 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; +} + function setInlineLeftTop(el: HTMLElement, left: number, top: number): void { let style = el.getAttribute("style") ?? ""; style = patchStyleAttrString(style, "left", `${left}px`); @@ -478,6 +498,10 @@ export function wrapElementsInHtml( 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)); wrapper.setAttribute( "style", `position: absolute; left: ${bbox.left}px; top: ${bbox.top}px; width: ${bbox.width}px; height: ${bbox.height}px`, @@ -524,10 +548,12 @@ export function unwrapElementsFromHtml( } parent.insertBefore(child, group); } + const groupId = group.id || undefined; group.remove(); return { html: wrappedFragment ? document.body.innerHTML || "" : document.toString(), unwrapped: true, + unwrappedGroupId: groupId, }; } diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index 5a9eca2544..a644232241 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/routes/files.ts @@ -314,6 +314,25 @@ function extractGsapScriptBlock(html: string): { return null; } +/** + * Remove every GSAP animation that targets `selector` from an HTML string's + * inline script. Used after unwrapping a group so its leftover `gsap.set("#id")` + * (the wrapper is gone) doesn't throw "target not found" on every preview run. + */ +function stripGsapAnimationsForSelector(html: string, selector: string): string { + const block = extractGsapScriptBlock(html); + if (!block) return html; + const parsed = parseGsapScriptAcorn(block.scriptText); + const matching = parsed.animations.filter((a) => a.targetSelector === selector); + if (matching.length === 0) return html; + let script = block.scriptText; + // Reverse so earlier removals don't shift the spans of later ones. + for (const anim of [...matching].reverse()) { + script = removeAnimationFromScript(script, anim.id); + } + return block.replaceScript(script); +} + function stripStudioEditsFromTarget(document: Document, selector: string): number { if (!selector) return 0; let stripped = 0; @@ -1649,14 +1668,12 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { 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, - ); + // The wrapper is gone — strip any GSAP that targeted it, or a leftover + // `gsap.set("#group-1")` throws "target not found" every preview run. + const cleaned = result.unwrappedGroupId + ? stripGsapAnimationsForSelector(result.html, `#${result.unwrappedGroupId}`) + : result.html; + return writeIfChanged(c, ctx.project.dir, ctx.filePath, ctx.absPath, originalContent, cleaned); }); api.post("/projects/:id/file-mutations/probe-element/*", async (c) => { From 06ddd8be0dc51935437f97c8957b46f3cf4b3a92 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 26 Jun 2026 22:21:58 -0400 Subject: [PATCH 13/19] fix(studio): make 3D transform usable on any element - show the 3D Transform panel for any selected element, not only ones already animated; the first edit creates the gsap.set - scrolling depth on a flat element drops a gentle tilt so depth reads in place - clamp the depth scroll in front of the perspective lens (no runaway z) - register a no-op for the internal _auto keyframe marker so GSAP stops warning --- packages/core/src/runtime/init.ts | 19 ++++++ .../src/components/editor/PropertyPanel.tsx | 65 +++++++++++-------- .../src/components/editor/Transform3DCube.tsx | 18 +++-- .../editor/propertyPanel3dTransform.tsx | 53 ++++++++++----- 4 files changed, 108 insertions(+), 47 deletions(-) diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 1c5e5f82a1..defbab05c9 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -56,6 +56,25 @@ export function initSandboxRuntimeModular(): void { swallow("runtime.init.site1", err); } } + // `_auto` is a Studio-internal keyframe marker (an auto-tracked endpoint the + // parser reads back), NOT an animatable property. Register it as a no-op GSAP + // plugin so GSAP doesn't log "Invalid property _auto" on every tween build — + // that per-frame warning destabilizes the preview and makes the selection + // overlay stop tracking the pointer. Idempotent + best-effort. + const ensureAutoMarkerNoop = (): void => { + const g = window.gsap as + | { registerPlugin?: (plugin: unknown) => void } + | undefined; + const w = window as Window & { __hfAutoNoopRegistered?: boolean }; + if (!g?.registerPlugin || w.__hfAutoNoopRegistered) return; + try { + g.registerPlugin({ name: "_auto", init: () => false }); + w.__hfAutoNoopRegistered = true; + } catch { + // a stray warning is preferable to a broken runtime + } + }; + ensureAutoMarkerNoop(); // Normalize html/body so browser defaults (8px margin, white background) never // bleed into renders as white bars. Runs in both preview and render contexts, // eliminating the preview/render parity gap that existed when only the React diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index a082ac67f1..9aeba6a547 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -148,6 +148,19 @@ export const PropertyPanel = memo(function PropertyPanel({ // eslint-disable-next-line react-hooks/exhaustive-deps [gsapRuntimeValues, gsapAnimations, element, currentTime], ); + // The 3D Transform panel should be reachable on ANY element, not only ones GSAP is + // already animating — otherwise you can't add depth/rotation to a fresh static + // element (the panel never appears, the classic chicken-and-egg). Default to + // identity when there are no runtime values yet; the first edit creates the + // gsap.set via commitStaticSet, after which real runtime values flow in. + const gsap3dValues: Record = gsapRuntimeValues ?? { + rotationX: 0, + rotationY: 0, + rotationZ: 0, + z: 0, + scale: 1, + transformPerspective: 0, + }; if (!element) { return ( @@ -490,33 +503,31 @@ export const PropertyPanel = memo(function PropertyPanel({ )}
- {gsapRuntimeValues && ( - { - const iframe = iframeRef.current; - const win = iframe?.contentWindow as - | { gsap?: { set: (t: Element, v: Record) => void } } - | null - | undefined; - const sel = el.id ? `#${el.id}` : el.selector; - const node = sel ? iframe?.contentDocument?.querySelector(sel) : null; - if (win?.gsap && node) win.gsap.set(node, props); - }} - /> - )} + { + const iframe = iframeRef.current; + const win = iframe?.contentWindow as + | { gsap?: { set: (t: Element, v: Record) => void } } + | null + | undefined; + const sel = el.id ? `#${el.id}` : el.selector; + const node = sel ? iframe?.contentDocument?.querySelector(sel) : null; + if (win?.gsap && node) win.gsap.set(node, props); + }} + />
Stacking diff --git a/packages/studio/src/components/editor/Transform3DCube.tsx b/packages/studio/src/components/editor/Transform3DCube.tsx index 3a81c7584e..d0bb4ffd9b 100644 --- a/packages/studio/src/components/editor/Transform3DCube.tsx +++ b/packages/studio/src/components/editor/Transform3DCube.tsx @@ -71,8 +71,12 @@ export function Transform3DCube({ // 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 }; + // Perspective lens (committed, else the comp-derived default the panel will + // apply). Drives the cube's depth-scale feedback AND clamps the scroll so depth + // can't cross the lens. Defined here so the wheel handler can read it via the ref. + const lens = perspective > 0 ? perspective : defaultPerspective; + const depthRef = useRef({ z, onDepthDraft, onDepthCommit, lens }); + depthRef.current = { z, onDepthDraft, onDepthCommit, lens }; useEffect(() => { const el = svgRef.current; if (!el) return; @@ -84,7 +88,14 @@ export function Transform3DCube({ 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); + let next = Math.round((pending ?? depthRef.current.z) - e.deltaY * 0.25); + // Clamp depth in front of the perspective lens. At z ≥ lens the element sits + // at/behind the virtual camera and the projection lens/(lens−z) blows up or + // inverts — that's the runaway "Z = 3195px past a 1080 lens". Cap just short + // of the lens; allow pushing well back (smaller) but not absurdly far. + const L = depthRef.current.lens; + if (L > 0) next = Math.max(Math.min(next, Math.round(L * 0.85)), Math.round(-L * 4)); + pending = next; draft?.(pending); setDepthDraft(pending); // live-scale the cube while scrolling if (timer) clearTimeout(timer); @@ -106,7 +117,6 @@ export function Transform3DCube({ // farther (z<0) smaller. Use the committed perspective, else the comp-derived // lens the panel is about to apply — same value in both, so the cube doesn't // jump when the commit lands. If neither is known, skip the scale (no lens). - const lens = perspective > 0 ? perspective : defaultPerspective; const depthScale = lens > 0 ? Math.max(0.4, Math.min(2.2, lens / (lens - shownZ))) : 1; const projOpts = { cx: CX, diff --git a/packages/studio/src/components/editor/propertyPanel3dTransform.tsx b/packages/studio/src/components/editor/propertyPanel3dTransform.tsx index ad376ab8d5..830992362c 100644 --- a/packages/studio/src/components/editor/propertyPanel3dTransform.tsx +++ b/packages/studio/src/components/editor/propertyPanel3dTransform.tsx @@ -87,6 +87,12 @@ function Cube3dControl({ // Comp-derived lens (see naturalDepthPerspective) applied the first time depth is // set, so the scene's foreshortening scales with the canvas instead of a magic 800. const depthPerspective = naturalDepthPerspective(element.element); + // A gentle, fixed "depth pose" tilt (degrees) dropped on a flat element the first + // time it gets depth, so translateZ reads as 3D foreshortening instead of a plain + // resize — small enough to look like a premium card, not a flip. + const DEPTH_POSE_X = 10; + const DEPTH_POSE_Y = -15; + const isFlat = Math.round(pose.rotationX) === 0 && Math.round(pose.rotationY) === 0; // Commit only the rotation axes the drag actually changed (each rounded to a // whole degree). Reuses the keyframe-aware animated-property commit, so a drag // at the playhead writes/updates a keyframe just like the numeric fields. @@ -144,27 +150,42 @@ function Cube3dControl({ z={gsapRuntimeValues.z ?? 0} onPoseDraft={livePreview} onPoseCommit={commitPose} - onDepthDraft={(z) => - onLivePreviewProps?.( - element, - gsapRuntimeValues.transformPerspective - ? { z } - : { z, transformPerspective: depthPerspective }, - ) - } + onDepthDraft={(z) => { + // Preview WITH a lens so depth is visible while scrolling — the same + // default the commit applies, so the element doesn't snap on release. + const preview: Record = gsapRuntimeValues.transformPerspective + ? { z } + : { z, transformPerspective: depthPerspective }; + // Depth-pose preview: a flat element only scales under Z, so mirror the + // commit and preview the gentle tilt that makes the depth read as 3D. + if (isFlat) { + preview.rotationX = DEPTH_POSE_X; + preview.rotationY = DEPTH_POSE_Y; + } + onLivePreviewProps?.(element, preview); + }} onDepthCommit={(z) => { + // Best-UX depth: scroll moves Z, and a 3D transform always has a lens — + // like an After Effects camera. translateZ is invisible without a + // perspective, so the FIRST time depth is added (Perspective still 0) we + // set a sensible comp-derived lens ONCE. Every later scroll touches Z + // only, and Perspective stays an independent, editable field. The cube's + // scroll is clamped in front of the lens, so Z can't run away past it. const props: Record = { z }; - // translateZ is invisible without a perspective lens — apply the - // comp-derived lens 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 && depthPerspective > 0) { props.transformPerspective = depthPerspective; } - // ONE keyframe for z + perspective together. Two separate commits raced - // read-modify-write on the same script — the second read the base before - // the first landed and dropped the other prop, so depth/lens reverted - // after a seek (and the colliding writes could 404 the save). Batch like - // commitPose; fall back to per-prop only if no batched commit is wired. + // Depth-pose: a flat element (no tilt) only scales under Z — it can't read + // as depth. So the first time depth lands on a flat element, also drop a + // gentle fixed tilt; the foreshortening makes depth read as 3D IN PLACE + // (no screen travel, per-element lens unchanged). Once the element has any + // tilt, depth scrolls touch Z only. Reset tilt to 0 to go flat again. + if (isFlat) { + props.rotationX = DEPTH_POSE_X; + props.rotationY = DEPTH_POSE_Y; + } + // One commit for all props so the writes can't race read-modify-write on + // the same script (which dropped a prop and reverted after a seek). if (onCommitAnimatedProperties) { void onCommitAnimatedProperties(element, props); } else { From fcc6202483581fc1e4d76c6543d5effef724592c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 26 Jun 2026 22:21:58 -0400 Subject: [PATCH 14/19] fix(studio): track overlay and motion path through perspective transforms A z/perspective transform foreshortens an element by 1/m44; the drag offset and motion-path geometry ignored it, so the selection overlay and path drifted off depth elements. Fold m44 into the drag offset and the motion-path points, with the inverse applied where a pointer maps back to a stored offset. --- .../components/editor/MotionPathOverlay.tsx | 32 +++++++++---- .../src/components/editor/manualOffsetDrag.ts | 25 +++++++++- .../components/editor/useMotionPathData.ts | 48 ++++++++++++++++++- 3 files changed, 94 insertions(+), 11 deletions(-) diff --git a/packages/studio/src/components/editor/MotionPathOverlay.tsx b/packages/studio/src/components/editor/MotionPathOverlay.tsx index 4a27cfaca3..038538717c 100644 --- a/packages/studio/src/components/editor/MotionPathOverlay.tsx +++ b/packages/studio/src/components/editor/MotionPathOverlay.tsx @@ -21,6 +21,7 @@ import { elementHome, hasMotionPathPlugin, isPreviewHtmlElement, + transformWDivisor, useMotionPathData, } from "./useMotionPathData"; @@ -39,6 +40,7 @@ type DragState = { initX: number; initY: number; scale: number; + pScale: number; ref: MotionNodeRef; }; @@ -71,7 +73,7 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({ handleGsapRemoveKeyframe, handleGsapDeleteAllForElement, } = useDomEditContext(); - const { rect, geometry, geometryResolved, visibleInPreview, home } = useMotionPathData( + const { rect, geometry, geometryResolved, visibleInPreview, home, pScale } = useMotionPathData( iframeRef, selectorFor(selection), ); @@ -156,8 +158,12 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({ e.preventDefault(); const sc = r.width / compW; const elHome = elementHome(live); - const px = Math.round((e.clientX - r.left) / sc - elHome.x); - const py = Math.round((e.clientY - r.top) / sc - elHome.y); + // De-magnify: the click lands on the projected (1/m44-magnified) path, so + // divide the home-relative offset by the perspective factor to recover the + // stored composition offset (inverse of the `* pScale` applied at draw). + const ps = 1 / transformWDivisor(live); + const px = Math.round(((e.clientX - r.left) / sc - elHome.x) / ps); + const py = Math.round(((e.clientY - r.top) / sc - elHome.y) / ps); const t = Math.round(usePlayerStore.getState().currentTime * 100) / 100; void commitCreatePath(createSelector, t, px, py, commitMutation); setMotionPathArmed(false); @@ -232,7 +238,16 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({ : geometry.nodes; // ax/ay = absolute composition position (home + offset) for drawing; n.x/n.y // stay offsets so the drag commit writes the right tween values. - const abs = nodes.map((n) => ({ ...n, ax: home.x + n.x, ay: home.y + n.y })); + // Magnify the animated offsets by the element's perspective factor (1/m44, via + // pScale) so the path tracks the *projected* element. `home` is the projection + // pivot (transform-origin), so it stays put; only the offsets foreshorten. 2D + // elements have pScale = 1 (no change). Inverse (de-magnify) applied wherever a + // pointer position is mapped back to a stored offset (create + node drag). + const abs = nodes.map((n) => ({ + ...n, + ax: home.x + n.x * pScale, + ay: home.y + n.y * pScale, + })); const points = abs.map((p) => `${p.ax},${p.ay}`).join(" "); // Map a VIEWPORT pointer to composition space. Use the iframe's LIVE viewport // rect, not `rect` — `rect.left/top` are stored pan-surface-relative (for the @@ -264,6 +279,7 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({ initX: x, initY: y, scale, + pScale, ref, }; setDraft({ index, x, y }); @@ -273,8 +289,8 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({ if (!d) return; setDraft({ index: d.index, - x: d.initX + (e.clientX - d.startX) / d.scale, - y: d.initY + (e.clientY - d.startY) / d.scale, + x: d.initX + (e.clientX - d.startX) / d.scale / d.pScale, + y: d.initY + (e.clientY - d.startY) / d.scale / d.pScale, }); }; // fallow-ignore-next-line complexity @@ -286,8 +302,8 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({ if (!animId) return; const screenDx = e.clientX - d.startX; const screenDy = e.clientY - d.startY; - const x = Math.round(d.initX + screenDx / d.scale); - const y = Math.round(d.initY + screenDy / d.scale); + const x = Math.round(d.initX + screenDx / d.scale / d.pScale); + const y = Math.round(d.initY + screenDy / d.scale / d.pScale); // Click-vs-drag is decided in SCREEN space, not composition px: the old guard // compared rounded comp-px, which at high zoom (scale ≫ 1) swallowed real // multi-px screen drags whose sub-comp-px delta rounds to 0 → the node would diff --git a/packages/studio/src/components/editor/manualOffsetDrag.ts b/packages/studio/src/components/editor/manualOffsetDrag.ts index 1bd3599340..6151a09790 100644 --- a/packages/studio/src/components/editor/manualOffsetDrag.ts +++ b/packages/studio/src/components/editor/manualOffsetDrag.ts @@ -209,6 +209,22 @@ export function applyManualOffsetDragMatrix(matrix: ManualOffsetDragMatrix, poin }; } +/** + * The perspective w-divisor (matrix3d m44) of the element's current transform. + * For a plain `translateZ(z)` under `perspective(p)`, m44 = (p - z) / p, so the + * element renders 1/m44× larger and a translate of `d` composition px moves + * `d / m44` px on screen. Returns 1 for 2D transforms (no foreshortening). Used + * to keep the drag offset → screen-movement mapping correct for depth elements, + * which the flat-scale fast path below would otherwise get wrong by 1/m44. + */ +function readTransformWDivisor(element: HTMLElement): number { + const t = element.ownerDocument.defaultView?.getComputedStyle(element).transform; + if (!t || !t.startsWith("matrix3d(")) return 1; + const parts = t.slice("matrix3d(".length, -1).split(","); + const w = Number.parseFloat(parts[15] ?? ""); + return Number.isFinite(w) && w > 0 ? w : 1; +} + export function measureManualOffsetDragScreenToOffsetMatrix( element: HTMLElement, initialOffset: { x: number; y: number }, @@ -221,7 +237,11 @@ export function measureManualOffsetDragScreenToOffsetMatrix( ) { const sx = options.scaleX || 1; const sy = options.scaleY || 1; - return { ok: true, matrix: { a: 1 / sx, b: 0, c: 0, d: 1 / sy } }; + // Fold in the perspective foreshortening: a depth element (z≠0) moves + // 1/m44× faster on screen than its flat scale implies, so the screen→offset + // matrix must scale by m44 or the element outruns the pointer/overlay. + const w = readTransformWDivisor(element); + return { ok: true, matrix: { a: w / sx, b: 0, c: 0, d: w / sy } }; } const probeSize = options.probeSize ?? DEFAULT_OFFSET_PROBE_PX; @@ -360,6 +380,7 @@ export function createManualOffsetDragMember(input: { // drag is acceptable — the final committed position is always exact. const scaleX = input.rect.editScaleX || 1; const scaleY = input.rect.editScaleY || 1; + const w = readTransformWDivisor(input.element); return { ok: true, member: { @@ -370,7 +391,7 @@ export function createManualOffsetDragMember(input: { baseGsap, initialPathOffset, gestureToken, - screenToOffset: { a: 1 / scaleX, b: 0, c: 0, d: 1 / scaleY }, + screenToOffset: { a: w / scaleX, b: 0, c: 0, d: w / scaleY }, originRect: input.rect, }, }; diff --git a/packages/studio/src/components/editor/useMotionPathData.ts b/packages/studio/src/components/editor/useMotionPathData.ts index 96f170715b..1f5cd67ce5 100644 --- a/packages/studio/src/components/editor/useMotionPathData.ts +++ b/packages/studio/src/components/editor/useMotionPathData.ts @@ -5,6 +5,38 @@ import { buildMotionPathGeometry, type MotionPathGeometry } from "./motionPathGe type Rect = { left: number; top: number; width: number; height: number }; +// The translate (e/f) components of an element's computed transform, in comp px. +// A group wrapper dragged via GSAP carries its offset here, not in offsetLeft/Top. +function transformTranslate(el: HTMLElement): { x: number; y: number } { + const t = el.ownerDocument?.defaultView?.getComputedStyle(el).transform; + if (!t || t === "none") return { x: 0, y: 0 }; + const m3 = t.match(/matrix3d\(([^)]+)\)/); + if (m3) { + const v = m3[1].split(",").map(Number); + return { x: v[12] || 0, y: v[13] || 0 }; + } + const m = t.match(/matrix\(([^)]+)\)/); + if (m) { + const v = m[1].split(",").map(Number); + return { x: v[4] || 0, y: v[5] || 0 }; + } + return { x: 0, y: 0 }; +} + +// Perspective foreshortening of the element's OWN transform (matrix3d m44). A +// depth element (translateZ toward the viewer) renders 1/m44× larger, so its +// animated x/y offsets travel 1/m44× further on screen than the flat preview +// scale implies. Returns 1 for 2D transforms. The motion path magnifies its +// offset points by 1/m44 (and de-magnifies pointer→offset) so the drawn path and +// its draggable nodes track the projected element instead of drifting off it. +export function transformWDivisor(el: HTMLElement): number { + const t = el.ownerDocument?.defaultView?.getComputedStyle(el).transform; + if (!t || !t.startsWith("matrix3d(")) return 1; + const v = t.slice("matrix3d(".length, -1).split(","); + const w = Number.parseFloat(v[15] ?? ""); + return Number.isFinite(w) && w > 0 ? w : 1; +} + export function elementHome(el: HTMLElement): { x: number; y: number } { let left = 0; let top = 0; @@ -12,6 +44,14 @@ export function elementHome(el: HTMLElement): { x: number; y: number } { while (node) { left += node.offsetLeft; top += node.offsetTop; + // Ancestor transforms (e.g. a group wrapper moved via GSAP) shift where the + // element actually renders, so the path must anchor on top of them. The element's + // OWN transform is excluded — that's the animated offset the path itself draws. + if (node !== el) { + const t = transformTranslate(node); + left += t.x; + top += t.y; + } const parent = node.offsetParent as HTMLElement | null; if (!parent || parent.hasAttribute("data-composition-id")) break; node = parent; @@ -62,6 +102,7 @@ export function useMotionPathData( geometryResolved: boolean; visibleInPreview: boolean; home: { x: number; y: number } | null; + pScale: number; } { const [rect, setRect] = useState(null); const [geometry, setGeometry] = useState(null); @@ -69,6 +110,9 @@ export function useMotionPathData( const geometryResolved = resolvedForRef.current === selector; const [visibleInPreview, setVisibleInPreview] = useState(true); const [home, setHome] = useState<{ x: number; y: number } | null>(null); + // Perspective magnification (1/m44) of the selected element — applied to the + // path's offset points so depth (translateZ) elements' paths track on screen. + const [pScale, setPScale] = useState(1); useEffect(() => { if (!selector) { @@ -105,6 +149,8 @@ export function useMotionPathData( setHome((prev) => prev && Math.abs(prev.x - h.x) < 0.5 && Math.abs(prev.y - h.y) < 0.5 ? prev : h, ); + const ps = 1 / transformWDivisor(live); + setPScale((p) => (Math.abs(p - ps) < 0.001 ? p : ps)); } } raf = requestAnimationFrame(tick); @@ -132,5 +178,5 @@ export function useMotionPathData( return () => window.clearInterval(id); }, [selector, iframeRef]); - return { rect, geometry, geometryResolved, visibleInPreview, home }; + return { rect, geometry, geometryResolved, visibleInPreview, home, pScale }; } From e349db22331fe10e30bf0609057f89d89b61c491 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 26 Jun 2026 22:22:49 -0400 Subject: [PATCH 15/19] fix(studio): keyframe commit routing for 3D and cross-group edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pickBestAnimation is group-aware: a rotation/3D edit no longer merges into a position tween — a fresh same-group tween with a 0% baseline is created instead - editing at a playhead past the tween extends it and keyframes there (matches drag) - update-keyframe MERGES into the existing keyframe instead of overwriting, so editing one property no longer drops z/transformPerspective (the lens then animated from 0 and the element popped) - dragging a keyframed element with a constant position tween keyframes rather than writing a static set --- packages/core/src/parsers/gsapWriterAcorn.ts | 18 ++- .../studio/src/hooks/gsapRuntimeBridge.ts | 11 +- .../src/hooks/useAnimatedPropertyCommit.ts | 152 +++++++++++++++--- 3 files changed, 150 insertions(+), 31 deletions(-) diff --git a/packages/core/src/parsers/gsapWriterAcorn.ts b/packages/core/src/parsers/gsapWriterAcorn.ts index 3d9bf70027..96da5d0781 100644 --- a/packages/core/src/parsers/gsapWriterAcorn.ts +++ b/packages/core/src/parsers/gsapWriterAcorn.ts @@ -776,10 +776,22 @@ export function updateKeyframeInScript( const match = findKfPropByPct(kfPropNode.value, percentage); if (!match) return script; - const record: Record = { ...properties }; - if (ease) record.ease = ease; const ms = new MagicString(script); - ms.overwrite(match.prop.value.start, match.prop.value.end, recordToCode(record)); + // MERGE the edited props into the existing keyframe, preserving properties already + // keyframed at this percentage (z, transformPerspective, rotation, …). A whole-value + // overwrite DROPS every prop not in this edit — e.g. editing rotationY at the 0% + // keyframe would strip z / transformPerspective, so the lens then animates from 0 and + // the element pops. Mirrors addKeyframeToScript's merge-into-existing branch. + if (match.prop.value?.type === "ObjectExpression") { + for (const [k, v] of Object.entries(properties)) { + upsertProp(ms, match.prop.value, k, v); + } + if (ease !== undefined) upsertProp(ms, match.prop.value, "ease", ease); + } else { + const record: Record = { ...properties }; + if (ease) record.ease = ease; + ms.overwrite(match.prop.value.start, match.prop.value.end, recordToCode(record)); + } return ms.toString(); } diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index 6efd2ce3f4..55c2e50804 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -241,8 +241,15 @@ export async function tryGsapDragIntercept( // place (idempotent), else add a new one. This also covers the stale-cache // phantom — committing a set is correct because the element genuinely has no live motion. const hasNonHold = hasNonHoldTweenForElement(iframe, selector); - - if (!hasNonHold) { + // A KEYFRAMED position tween — even one that's currently a flat constant ("hold", + // e.g. 0% and 100% identical) — is still an animation the user is building, so a + // drag must add/update a keyframe, NOT fall back to a static `set`. Without this, + // dragging an element whose position tween is constant writes a `gsap.set` that + // fights the tween (the "drag didn't create a keyframe / didn't persist" bug). The + // static path is only for elements with NO keyframed position tween (truly static, + // or just a leftover position-hold `set`). + const hasKeyframedPosTween = !!posAnim?.keyframes; + if (!hasNonHold && !hasKeyframedPosTween) { const existingSet = posAnim && posAnim.method === "set" && posAnim.targetSelector === selector ? posAnim diff --git a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts index eb49b1eb94..28999ee6f3 100644 --- a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts +++ b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts @@ -15,6 +15,8 @@ import { usePlayerStore } from "../player/store/playerStore"; import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeBridge"; import type { SetPatchProps } from "./gsapRuntimePatch"; import { selectorFromSelection, computeElementPercentage } from "./gsapShared"; +import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler"; +import { roundTo3 } from "../utils/rounding"; interface CommitAnimatedPropertyDeps { selectedGsapAnimations: GsapAnimation[]; @@ -45,14 +47,22 @@ function pickBestAnimation( selector: string | null, property?: string, ): GsapAnimation | undefined { - if (animations.length <= 1) return animations[0]; - const currentTime = usePlayerStore.getState().currentTime; const targetGroup = property ? classifyPropertyGroup(property) : undefined; - - // fallow-ignore-next-line complexity - const scored = animations.map((a) => { + // Group-aware: never hand back a tween from a DIFFERENT property group. The old + // `animations.length <= 1` early return merged a rotation/3D edit into the element's + // only tween even when that was a `position` tween — contaminating it and leaving the + // new property with no clean keyframe baseline. When a target group is known, only + // same-group tweens are candidates; if none exist we return undefined and the caller + // creates a fresh same-group tween. + const candidates = + targetGroup !== undefined + ? animations.filter((a) => a.propertyGroup === targetGroup) + : animations; + if (candidates.length === 0) return undefined; + if (candidates.length === 1) return candidates[0]; + const currentTime = usePlayerStore.getState().currentTime; + const scored = candidates.map((a) => { let score = 0; - if (targetGroup && a.propertyGroup === targetGroup) score += 20; if (a.keyframes) score += 10; if (selector && a.targetSelector === selector) score += 5; else if (a.targetSelector.includes(",")) score -= 3; @@ -196,14 +206,15 @@ async function commitKeyframeProps( iframe: HTMLIFrameElement | null, commit: Commit, ): Promise { - if (!anim.keyframes) { + const wasKeyframed = !!anim.keyframes; + if (!wasKeyframed) { await commit( selection, { type: "convert-to-keyframes", animationId: anim.id }, { label: "Convert to keyframes", skipReload: true }, ); } - const pct = computeElementPercentage(usePlayerStore.getState().currentTime, selection, anim); + const ct = usePlayerStore.getState().currentTime; const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {}; const properties: Record = { ...runtimeProps, ...props }; @@ -216,6 +227,52 @@ async function commitKeyframeProps( backfillDefaults[property] = value; } + // Playhead OUTSIDE the keyframe tween's time range → EXTEND the tween to reach it + // and add a keyframe there, exactly like manual drag's extendTweenAndAddKeyframe. + // The add-keyframe below only writes WITHIN the existing range, so without this a + // depth edit past the tween end just overwrites the last keyframe (the bug: no new + // diamond appears at a playhead beyond the tween). Only for an already-keyframed + // tween — a freshly-converted set has no prior range worth remapping. + const kfs = anim.keyframes?.keyframes; + const ts = resolveTweenStart(anim); + const td = resolveTweenDuration(anim); + const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null; + const playheadOutside = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); + const willExtend = wasKeyframed && !!kfs && playheadOutside && !hasSelectedKeyframe; + if (willExtend && kfs && ts !== null) { + const newStart = Math.min(ct, ts); + const newEnd = Math.max(ct, ts + td); + const newDuration = Math.max(0.01, newEnd - newStart); + const remapped = kfs.map((kf) => { + const absTime = ts + (kf.percentage / 100) * td; + const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10; + const p: Record = { ...kf.properties }; + for (const k of Object.keys(properties)) { + if (!(k in p) && backfillDefaults[k] != null) p[k] = backfillDefaults[k]; + } + return { percentage: newPct, properties: p }; + }); + remapped.push({ + percentage: Math.round(((ct - newStart) / newDuration) * 1000) / 10, + properties, + }); + remapped.sort((a, b) => a.percentage - b.percentage); + await commit( + selection, + { + type: "replace-with-keyframes", + animationId: anim.id, + targetSelector: anim.targetSelector, + position: roundTo3(newStart), + duration: roundTo3(newDuration), + keyframes: remapped, + }, + { label: `Edit ${primaryProp} (extended keyframe)`, softReload: true }, + ); + return; + } + + const pct = computeElementPercentage(ct, selection, anim); const existingKf = anim.keyframes?.keyframes.some((kf) => Math.abs(kf.percentage - pct) < 0.05); // Rebuild the live keyframe tween in place so the edit shows instantly (no flash); // rebuildKeyframeTween declines → soft reload if the tween can't be safely rebuilt. @@ -275,8 +332,30 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) { // so the rejection doesn't escape as an uncaught promise, and bump the cache // so selectedGsapAnimations re-syncs and the user's next edit self-heals. try { - // Existing static hold — merge the props into the `set`, then auto-keyframe - // ONLY if the element is already animated (maybeAutoKeyframeSet no-ops if not). + // Animated element → keyframe at the playhead, EXACTLY like manual drag / + // resize / rotate: if the picked anim is still a static `set`, + // commitKeyframeProps converts it to keyframes first, then writes the new + // value as a keyframe at the current time — so the 3D animates instead of + // holding a flat constant. This MUST come before the `set`-update path below, + // or a 3D `set` would short-circuit to an in-place update and the playhead + // keyframe would never land (the bug: scrolling depth on a keyframed element + // just changed the constant instead of dropping a keyframe). + if (elementHasKeyframes && anim) { + await commitKeyframeProps( + selection, + anim, + props, + propEntries, + primaryProp, + selector, + iframe, + gsapCommitMutation, + ); + return; + } + + // Existing static hold on a NON-animated element — merge the props into the + // `set` in place (maybeAutoKeyframeSet no-ops when nothing else is keyframed). if (anim?.method === "set") { await commitSetProps( selection, @@ -289,8 +368,8 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) { return; } - // Static element — persist as a `tl.set`, never keyframes (incl. the - // no-animation case, which now creates a set instead of a keyframed tween). + // Static element (no keyframes anywhere) — persist as a `tl.set`, never + // keyframes (incl. the no-animation case, which creates a fresh set). if (!elementHasKeyframes) { await commitStaticSet( selection, @@ -302,22 +381,43 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) { return; } - // Animated element — write ALL props into ONE keyframe so a multi-axis cube - // edit doesn't race into adjacent duplicates. - if (!anim) { - bumpGsapCache(); + // Animated element but NO same-group tween exists (e.g. the FIRST rotation/3D + // keyframe on an element that only has a position tween). Create a fresh + // same-group keyframed tween WITH a 0% baseline at the playhead, instead of + // contaminating a foreign-group tween. Mirror an existing keyframed tween's + // time range so the new group animates over the same span. The 0% baseline is + // an `_auto` endpoint so it tracks the nearest keyframe as you add more. + if (selector) { + const template = selectedGsapAnimations.find((a) => !!a.keyframes); + const tStart = template ? (resolveTweenStart(template) ?? 0) : 0; + const tDur = template ? resolveTweenDuration(template) || 1 : 1; + const ct = usePlayerStore.getState().currentTime; + const pct = + tDur > 0 + ? Math.max(0, Math.min(100, Math.round(((ct - tStart) / tDur) * 1000) / 10)) + : 0; + const newProps = Object.fromEntries(propEntries); + const keyframes = + pct <= 0.05 + ? [{ percentage: 0, properties: newProps }] + : [ + { percentage: 0, properties: { ...newProps, _auto: 1 } }, + { percentage: pct, properties: newProps }, + ]; + await gsapCommitMutation( + selection, + { + type: "add-with-keyframes", + targetSelector: selector, + position: roundTo3(tStart), + duration: roundTo3(tDur), + keyframes, + }, + { label: `Add ${primaryProp} keyframe`, softReload: true }, + ); return; } - await commitKeyframeProps( - selection, - anim, - props, - propEntries, - primaryProp, - selector, - iframe, - gsapCommitMutation, - ); + bumpGsapCache(); } catch { bumpGsapCache(); } From 2c01c7a348c1ff76a996f1e05cc7eac02d844d8e Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 26 Jun 2026 22:22:49 -0400 Subject: [PATCH 16/19] fix(studio): bake the group transform into members on ungroup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ungroup only baked the wrapper's layout (left/top), not its GSAP transform — so a moved group's members snapped back to their creation-time positions. Distribute the group's static transform onto each member before stripping it: translation is an exact per-axis add; rotation/scale are composed about the group center so off-center members don't drift. Animated group transforms are left to be stripped, not baked. --- .../src/studio-api/helpers/sourceMutation.ts | 28 ++++- packages/core/src/studio-api/routes/files.ts | 103 +++++++++++++++++- 2 files changed, 122 insertions(+), 9 deletions(-) diff --git a/packages/core/src/studio-api/helpers/sourceMutation.ts b/packages/core/src/studio-api/helpers/sourceMutation.ts index 64b0ebd688..69fb18c435 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.ts @@ -407,6 +407,12 @@ export interface UnwrapElementsResult { /** The unwrapped wrapper's id, so callers can strip GSAP that targeted it * (the wrapper is gone; a leftover `gsap.set("#id")` would throw at runtime). */ unwrappedGroupId?: string; + /** Members (id'd children) with their absolute layout centres (post un-rebase), + * so the caller can BAKE the group's GSAP transform into each member before + * stripping it — otherwise the group's moves are lost on ungroup. */ + members?: Array<{ id: string; cx: number; cy: number }>; + /** The wrapper's layout centre — the pivot for baking the group's rotation/scale. */ + groupCenter?: { cx: number; cy: number }; } export interface ElementRebase { @@ -536,15 +542,25 @@ export function unwrapElementsFromHtml( // Undo the rebase: child absolute position = child (rebased) + wrapper origin. const wLeft = getInlineStylePx(group, "left"); const wTop = getInlineStylePx(group, "top"); + const groupCenter = { + cx: wLeft + getInlineStylePx(group, "width") / 2, + cy: wTop + getInlineStylePx(group, "height") / 2, + }; // Move children back to the wrapper's slot, preserving order. + const members: Array<{ id: string; cx: number; cy: number }> = []; for (const child of Array.from(group.children)) { if (isHTMLElement(child)) { - setInlineLeftTop( - child, - getInlineStylePx(child, "left") + wLeft, - getInlineStylePx(child, "top") + wTop, - ); + const newLeft = getInlineStylePx(child, "left") + wLeft; + const newTop = getInlineStylePx(child, "top") + wTop; + setInlineLeftTop(child, newLeft, newTop); + if (child.id) { + members.push({ + id: child.id, + cx: newLeft + getInlineStylePx(child, "width") / 2, + cy: newTop + getInlineStylePx(child, "height") / 2, + }); + } } parent.insertBefore(child, group); } @@ -555,5 +571,7 @@ export function unwrapElementsFromHtml( html: wrappedFragment ? document.body.innerHTML || "" : document.toString(), unwrapped: true, unwrappedGroupId: groupId, + members, + groupCenter, }; } diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index a644232241..81b37046bb 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/routes/files.ts @@ -333,6 +333,90 @@ function stripGsapAnimationsForSelector(html: string, selector: string): string return block.replaceScript(script); } +/** + * Bake a group's STATIC GSAP transform into each member BEFORE the group is + * stripped on ungroup. Moving a group is stored as `gsap.set("#group-1",{x,y,…})`; + * without distributing it to the members they snap back to their creation-time + * positions. Translation (x/y/z) is an exact per-axis add; rotation/scale are + * composed about the group's centre (the pivot) so off-centre members don't drift. + * Animated group transforms (keyframes/tweens) are NOT baked — left to be stripped. + */ +function bakeGroupTransformIntoMembers( + html: string, + groupId: string, + members: Array<{ id: string; cx: number; cy: number }>, + groupCenter: { cx: number; cy: number }, +): string { + const block = extractGsapScriptBlock(html); + if (!block) return html; + const parsed = parseGsapScriptAcorn(block.scriptText); + const groupSel = `#${groupId}`; + const groupSets = parsed.animations.filter( + (a) => a.targetSelector === groupSel && a.method === "set", + ); + if (groupSets.length === 0) return html; + // Merge the group's sets (later per-prop wins) → its effective static transform. + const gt: Record = {}; + for (const s of groupSets) { + for (const [k, v] of Object.entries(s.properties)) if (typeof v === "number") gt[k] = v; + } + const gx = gt.x ?? 0; + const gy = gt.y ?? 0; + const gz = gt.z ?? 0; + const grot = gt.rotation ?? 0; + const gscale = gt.scale ?? 1; + if (gx === 0 && gy === 0 && gz === 0 && grot === 0 && gscale === 1) return html; + + const rad = (grot * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + const round3 = (n: number) => Math.round(n * 1000) / 1000; + + let script = block.scriptText; + for (const m of members) { + const memberSel = `#${m.id}`; + const sets = parsed.animations.filter( + (a) => a.targetSelector === memberSel && a.method === "set", + ); + // Effective member transform (merge its sets — last per-prop wins). + const mProps: Record = {}; + for (const s of sets) Object.assign(mProps, s.properties); + const mx = typeof mProps.x === "number" ? mProps.x : 0; + const my = typeof mProps.y === "number" ? mProps.y : 0; + // Compose the group transform onto the member's centre, then back to an offset. + const dx = m.cx + mx - groupCenter.cx; + const dy = m.cy + my - groupCenter.cy; + const visX = groupCenter.cx + gscale * (cos * dx - sin * dy) + gx; + const visY = groupCenter.cy + gscale * (sin * dx + cos * dy) + gy; + const newProps: Record = { + ...mProps, + x: round3(visX - m.cx), + y: round3(visY - m.cy), + }; + if (gz !== 0) newProps.z = (typeof mProps.z === "number" ? mProps.z : 0) + gz; + if (grot !== 0) { + newProps.rotation = round3((typeof mProps.rotation === "number" ? mProps.rotation : 0) + grot); + } + if (gscale !== 1) { + newProps.scale = round3((typeof mProps.scale === "number" ? mProps.scale : 1) * gscale); + } + + const last = sets[sets.length - 1]; + if (last) { + script = updateAnimationInScript(script, last.id, { properties: newProps }); + } else { + script = addAnimationToScript(script, { + targetSelector: memberSel, + method: "set", + position: 0, + properties: newProps, + global: true, + }).script; + } + } + return block.replaceScript(script); +} + function stripStudioEditsFromTarget(document: Document, selector: string): number { if (!selector) return 0; let stripped = 0; @@ -1668,11 +1752,22 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { if (!result.unwrapped) { return c.json({ ok: false, changed: false, content: originalContent, path: ctx.filePath }); } - // The wrapper is gone — strip any GSAP that targeted it, or a leftover + // BAKE the group's static transform into the members FIRST, so the group's + // accumulated moves are preserved (otherwise members snap back to their + // creation-time positions), THEN strip the group's GSAP — a leftover // `gsap.set("#group-1")` throws "target not found" every preview run. - const cleaned = result.unwrappedGroupId - ? stripGsapAnimationsForSelector(result.html, `#${result.unwrappedGroupId}`) - : result.html; + let cleaned = result.html; + if (result.unwrappedGroupId && result.members && result.groupCenter) { + cleaned = bakeGroupTransformIntoMembers( + cleaned, + result.unwrappedGroupId, + result.members, + result.groupCenter, + ); + } + if (result.unwrappedGroupId) { + cleaned = stripGsapAnimationsForSelector(cleaned, `#${result.unwrappedGroupId}`); + } return writeIfChanged(c, ctx.project.dir, ctx.filePath, ctx.absPath, originalContent, cleaned); }); From 803f84d821ffee939a546a9bb12a786d71ed278e Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 26 Jun 2026 22:23:29 -0400 Subject: [PATCH 17/19] chore(studio): add anonymous usage events --- packages/studio/src/hooks/useAppHotkeys.ts | 20 +++++++++-- .../studio/src/hooks/useDomEditSession.ts | 3 ++ .../src/hooks/useGsapSelectionHandlers.ts | 4 +++ .../studio/src/hooks/usePreviewInteraction.ts | 36 +++++++++++++++++-- 4 files changed, 58 insertions(+), 5 deletions(-) diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index 44ef0ec1d2..2748bbbfce 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -8,6 +8,7 @@ import { shouldHandleTimelineToggleHotkey, isEditableTarget } from "../utils/tim import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers"; import { canSplitElement } from "../utils/timelineElementSplit"; import { STUDIO_RAZOR_TOOL_ENABLED } from "../components/editor/manualEditingAvailability"; +import { trackStudioEvent } from "../utils/studioTelemetry"; function iframeContentWindow(iframe: HTMLIFrameElement | null): Window | null { try { @@ -158,19 +159,27 @@ function dispatchModifierKey(event: KeyboardEvent, key: string, cb: HotkeyCallba !shouldIgnoreHistoryShortcut(event.target) && handleUndoRedoKey( event, - () => void cb.handleUndo(), - () => void cb.handleRedo(), + () => { + trackStudioEvent("keyboard_shortcut", { action: "undo" }); + void cb.handleUndo(); + }, + () => { + trackStudioEvent("keyboard_shortcut", { action: "redo" }); + void cb.handleRedo(); + }, ) ) return true; if (event.key === "1") { event.preventDefault(); + trackStudioEvent("keyboard_shortcut", { action: "tab_compositions" }); cb.leftSidebarRef.current?.selectTab("compositions"); return true; } if (event.key === "2") { event.preventDefault(); + trackStudioEvent("keyboard_shortcut", { action: "tab_assets" }); cb.leftSidebarRef.current?.selectTab("assets"); return true; } @@ -184,17 +193,22 @@ function dispatchModifierKey(event: KeyboardEvent, key: string, cb: HotkeyCallba if (!event.shiftKey && !event.altKey && !isEditableTarget(event.target)) { if (key === "c") { - if (cb.handleCopy()) event.preventDefault(); + if (cb.handleCopy()) { + event.preventDefault(); + trackStudioEvent("keyboard_shortcut", { action: "copy" }); + } return true; } if (key === "v") { event.preventDefault(); + trackStudioEvent("keyboard_shortcut", { action: "paste" }); void cb.handlePaste(); return true; } if (key === "x") { if (usePlayerStore.getState().selectedElementId || cb.domEditSelectionRef.current) { event.preventDefault(); + trackStudioEvent("keyboard_shortcut", { action: "cut" }); void cb.handleCut(); } return true; diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index e749c0a7fa..fbdba55ce9 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -1,4 +1,5 @@ import { useCallback } from "react"; +import { trackStudioEvent } from "../utils/studioTelemetry"; import type { TimelineElement } from "../player"; import type { ImportedFontAsset } from "../components/editor/fontAssets"; import type { EditHistoryKind } from "../utils/editHistory"; @@ -305,6 +306,7 @@ export function useDomEditSession({ showToast("Select at least 2 elements to group", "info"); return; } + trackStudioEvent("group", { action: "create", count: members.length }); void groupSelection(members); }, [domEditGroupSelectionsRef, domEditSelectionRef, groupSelection, showToast]); @@ -315,6 +317,7 @@ export function useDomEditSession({ return; } // Dissolving the group exits any drill-in (the wrapper is about to vanish). + trackStudioEvent("group", { action: "ungroup" }); setActiveGroupElement(null); void ungroupSelection(sel); }, [domEditSelectionRef, ungroupSelection, setActiveGroupElement, showToast]); diff --git a/packages/studio/src/hooks/useGsapSelectionHandlers.ts b/packages/studio/src/hooks/useGsapSelectionHandlers.ts index d3d795e37b..2ac7698f71 100644 --- a/packages/studio/src/hooks/useGsapSelectionHandlers.ts +++ b/packages/studio/src/hooks/useGsapSelectionHandlers.ts @@ -2,6 +2,7 @@ import { useCallback, useRef } from "react"; import type { DomEditSelection } from "../components/editor/domEditing"; import { usePlayerStore } from "../player"; import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics"; +import { trackStudioEvent } from "../utils/studioTelemetry"; /** * Thin useCallback wrappers that guard on `domEditSelection` before @@ -136,6 +137,7 @@ export function useGsapSelectionHandlers({ (targetSelector: string) => { const sel = domEditSelection ?? lastSelectionRef.current; if (!sel) return; + trackStudioEvent("keyframe", { action: "delete_all" }); deleteAllForSelector(sel, targetSelector); }, [domEditSelection, deleteAllForSelector], @@ -206,6 +208,7 @@ export function useGsapSelectionHandlers({ ) => { const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current; if (!sel) return; + trackStudioEvent("keyframe", { action: "add", property }); addKeyframe(sel, animId, percentage, property, value); }, [domEditSelection, addKeyframe], @@ -224,6 +227,7 @@ export function useGsapSelectionHandlers({ (animId: string, percentage: number, selectionOverride?: DomEditSelection | null) => { const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current; if (!sel) return; + trackStudioEvent("keyframe", { action: "remove" }); removeKeyframe(sel, animId, percentage); }, [domEditSelection, removeKeyframe], diff --git a/packages/studio/src/hooks/usePreviewInteraction.ts b/packages/studio/src/hooks/usePreviewInteraction.ts index 148b8207dc..b11b6b80a8 100644 --- a/packages/studio/src/hooks/usePreviewInteraction.ts +++ b/packages/studio/src/hooks/usePreviewInteraction.ts @@ -3,6 +3,7 @@ import { liveTime, usePlayerStore } from "../player"; import { pauseStudioPreviewPlayback } from "../utils/studioPreviewHelpers"; import { STUDIO_PREVIEW_SELECTION_ENABLED } from "../components/editor/manualEditingAvailability"; import { type DomEditSelection } from "../components/editor/domEditing"; +import { trackStudioEvent } from "../utils/studioTelemetry"; // ── Types ── @@ -47,6 +48,12 @@ interface ClickCycleState { const CYCLE_RADIUS_PX = 6; const CYCLE_WINDOW_MS = 600; +// Manual double-click window. `e.detail` can't be trusted here: the first click +// selects the group and re-renders the overlay, so the second click lands on a +// fresh element and the browser's native click-counter resets to 1 — drill-in +// (which keyed off `e.detail >= 2`) never fired. We track time+position instead. +const DOUBLE_CLICK_MS = 400; +const DOUBLE_CLICK_RADIUS_PX = 6; // ── Hook ── @@ -63,21 +70,34 @@ export function usePreviewInteraction({ onClickToSource, }: UsePreviewInteractionParams) { const cycleRef = useRef(null); + const lastDownRef = useRef<{ t: number; x: number; y: number } | null>(null); const handlePreviewCanvasMouseDown = useCallback( // fallow-ignore-next-line complexity async (e: React.MouseEvent, options?: { preferClipAncestor?: boolean }) => { if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) return; + // Manual double-click detection (see DOUBLE_CLICK_MS): the first click + // re-renders the overlay so `e.detail` never reaches 2 on the canvas. + const downTs = Date.now(); + const lastDown = lastDownRef.current; + const isDoubleClick = + e.detail >= 2 || + (lastDown != null && + downTs - lastDown.t < DOUBLE_CLICK_MS && + Math.hypot(e.clientX - lastDown.x, e.clientY - lastDown.y) < DOUBLE_CLICK_RADIUS_PX); + lastDownRef.current = { t: downTs, x: e.clientX, y: e.clientY }; + // 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) { + if (isDoubleClick && !e.shiftKey) { const hit = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY); if (hit?.element.hasAttribute("data-hf-group")) { e.preventDefault(); e.stopPropagation(); cycleRef.current = null; + trackStudioEvent("group", { action: "drill_in" }); setActiveGroupElement(hit.element); const child = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, { activeGroupElement: hit.element, @@ -121,9 +141,21 @@ export function usePreviewInteraction({ } // Fresh click — resolve topmost element - const nextSelection = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, { + let nextSelection = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, { preferClipAncestor: options?.preferClipAncestor ?? false, }); + // A null result while drilled into a group means the click landed OUTSIDE that + // group (resolveGroupCapture → out-of-scope). Drill-in isn't sticky: exit it and + // re-resolve at the top level so this click selects whatever's there (or the + // group as a unit). Without this, a stale drill-in keeps selecting children and + // the "first click selects the group" expectation breaks. + if (!nextSelection) { + setActiveGroupElement(null); + nextSelection = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, { + preferClipAncestor: options?.preferClipAncestor ?? false, + activeGroupElement: null, + }); + } if (!nextSelection) { cycleRef.current = null; applyDomSelection(null, { revealPanel: false }); From 33c2d29675c614d416b3f06481223270ee08a912 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 26 Jun 2026 22:23:29 -0400 Subject: [PATCH 18/19] chore(studio): timeline keyframe button copy and shortcut hint Label the keyframe toolbar button 'Add keyframe (K)' (and the at-playhead / extend variants) so its action and shortcut are discoverable. --- packages/studio/src/components/TimelineToolbar.tsx | 8 ++++---- .../studio/src/components/editor/GestureRecordControl.tsx | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx index fd9d9ac134..63af38fa52 100644 --- a/packages/studio/src/components/TimelineToolbar.tsx +++ b/packages/studio/src/components/TimelineToolbar.tsx @@ -133,12 +133,12 @@ export function TimelineToolbar({
); From c55a2a52d03a5861f10fd1da4879791cad30b583 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 26 Jun 2026 22:23:29 -0400 Subject: [PATCH 19/19] chore(studio): remove debug logging --- .../src/components/editor/domEditingGroups.ts | 19 +++++++++------- .../src/components/editor/domEditingLayers.ts | 21 ------------------ packages/studio/src/hooks/useDomSelection.ts | 22 ------------------- 3 files changed, 11 insertions(+), 51 deletions(-) diff --git a/packages/studio/src/components/editor/domEditingGroups.ts b/packages/studio/src/components/editor/domEditingGroups.ts index 5dc0a4f19a..8b90f84c42 100644 --- a/packages/studio/src/components/editor/domEditingGroups.ts +++ b/packages/studio/src/components/editor/domEditingGroups.ts @@ -27,12 +27,15 @@ export function resolveGroupCapture( 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" }; + const result = ((): GroupCapture => { + 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" }; + })(); + return result; } diff --git a/packages/studio/src/components/editor/domEditingLayers.ts b/packages/studio/src/components/editor/domEditingLayers.ts index 02de1dee5b..5037c78315 100644 --- a/packages/studio/src/components/editor/domEditingLayers.ts +++ b/packages/studio/src/components/editor/domEditingLayers.ts @@ -318,17 +318,6 @@ export async function resolveDomEditSelection( } let current: HTMLElement | null = capture.kind === "unit" ? capture.element : getSelectionCandidate(startEl, options); - // eslint-disable-next-line no-console - console.log( - "[HF-DBG] resolveDomEditSelection start", - JSON.stringify({ - startEl: startEl.id || startEl.tagName, - captureKind: capture.kind, - startCurrent: current - ? current.id || current.getAttribute("data-hf-group") || current.tagName - : null, - }), - ); while (current && current !== doc.body && current !== doc.documentElement) { const selector = buildStableSelector(current); const hfId = readHfId(current); @@ -380,16 +369,6 @@ export async function resolveDomEditSelection( }); const rect = current.getBoundingClientRect(); - // eslint-disable-next-line no-console - console.log( - "[HF-DBG] resolveDomEditSelection → resolved", - JSON.stringify({ - element: current.id || current.getAttribute("data-hf-group") || current.tagName, - selector, - isGroup: current.hasAttribute("data-hf-group"), - canApplyManualOffset: capabilities.canApplyManualOffset, - }), - ); return { element: current, id: current.id || undefined, diff --git a/packages/studio/src/hooks/useDomSelection.ts b/packages/studio/src/hooks/useDomSelection.ts index 212b925155..9dfccc9440 100644 --- a/packages/studio/src/hooks/useDomSelection.ts +++ b/packages/studio/src/hooks/useDomSelection.ts @@ -201,28 +201,6 @@ export function useDomSelection({ setActiveGroupElementState(null); } - // eslint-disable-next-line no-console - console.log( - "[HF-DBG] applyDomSelection", - JSON.stringify({ - additive: isAdditiveSelection, - preserveGroup: options?.preserveGroup ?? false, - incoming: - selection.element.id || - selection.element.getAttribute("data-hf-group") || - selection.element.tagName, - nextSelection: nextSelection - ? nextSelection.element.id || - nextSelection.element.getAttribute("data-hf-group") || - nextSelection.element.tagName - : null, - nextGroupCount: nextGroup.length, - nextGroup: nextGroup.map( - (s) => s.element.id || s.element.getAttribute("data-hf-group") || s.element.tagName, - ), - activeGroupElement: activeGroupElementRef.current?.getAttribute("data-hf-group") ?? null, - }), - ); if (nextSelection) { if (options?.revealPanel !== false) {