From 275994748a3eebe34d048016752b5f28cfed4939 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 19 Jun 2026 12:27:14 -0400 Subject: [PATCH 1/2] feat(studio): single-source manual offset + rotation via the GSAP timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dragging or rotating an element writes into the GSAP timeline (the single source of truth) instead of a parallel --hf-studio-offset / --hf-studio-rotation CSS var: static elements commit a tl.set (idempotent on re-edit), tweened elements edit keyframes, and the live preview moves via gsap.set so what you see equals what is written and renders. Removes the dual-channel CSS-var/transform reconciliation behind the fling / disappear / runaway / double-stack / wrong-start bug class — for BOTH position and rotation (gesture base read from the gsap transform, gsap.set live preview, tl.set/ keyframe commit, dropped the handleDom*Commit CSS fallbacks). Subcompositions edit the same single-source way, which surfaced and fixes: - resolve a subcomp element's source file via the composition-id map (the runtime drops the source linkage when inlining the subcomposition); - a selected element's selection box AND motion path use basic visibility, not the occlusion heuristic (a backgroundless opacity-1 scene above it is not an opaque cover); - soft reload rebuilds ONLY the committed composition's timeline, leaving other compositions' timelines intact (no cross-composition revert); - read keyframes from the element's OWN composition timeline (scan all timelines, not the first unstable key); - delete-all uses a soft reload too, so editing no longer hard-reloads the iframe. --- .../components/editor/DomEditOverlay.test.ts | 13 -- .../src/components/editor/DomEditOverlay.tsx | 11 +- .../components/editor/MotionPathOverlay.tsx | 24 ++- .../editor/domEditOverlayStartGesture.ts | 6 +- .../src/components/editor/domEditingDom.ts | 26 +++ .../src/components/editor/manualEdits.ts | 1 + .../src/components/editor/manualEditsDom.ts | 13 ++ .../editor/manualOffsetDrag.test.ts | 12 ++ .../src/components/editor/manualOffsetDrag.ts | 83 +++++++++- .../editor/useDomEditOverlayGestures.ts | 50 ++++-- .../editor/useDomEditOverlayRects.ts | 10 +- .../studio/src/components/nle/NLELayout.tsx | 4 + .../studio/src/hooks/draggedGsapPosition.ts | 47 ++++++ packages/studio/src/hooks/gsapDragCommit.ts | 147 +++++++++++++++-- .../src/hooks/gsapRuntimeBridge.test.ts | 29 +++- .../studio/src/hooks/gsapRuntimeBridge.ts | 67 +++++--- .../studio/src/hooks/gsapRuntimeKeyframes.ts | 48 +++--- .../studio/src/hooks/useDomEditSession.ts | 4 - packages/studio/src/hooks/useGestureCommit.ts | 156 ++++++++---------- .../studio/src/hooks/useGestureRecording.ts | 37 +---- .../studio/src/hooks/useGsapAnimationOps.ts | 2 +- .../studio/src/hooks/useGsapAwareEditing.ts | 25 ++- packages/studio/src/utils/gsapSoftReload.ts | 73 ++++---- 23 files changed, 604 insertions(+), 284 deletions(-) create mode 100644 packages/studio/src/hooks/draggedGsapPosition.ts diff --git a/packages/studio/src/components/editor/DomEditOverlay.test.ts b/packages/studio/src/components/editor/DomEditOverlay.test.ts index 7fb292913c..468bebfdfe 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.test.ts +++ b/packages/studio/src/components/editor/DomEditOverlay.test.ts @@ -282,7 +282,6 @@ describe("DomEditOverlay", () => { }; let currentSelection: DomEditSelection | null = selection; - const onToggleRecording = vi.fn(); const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null }; const originalPointerCapture = HTMLDivElement.prototype.setPointerCapture; HTMLDivElement.prototype.setPointerCapture = () => {}; @@ -298,8 +297,6 @@ describe("DomEditOverlay", () => { hoverSelection: null, onSelectionChange: (next: DomEditSelection) => setSelected(next), }), - recordingState: "idle", - onToggleRecording, }); } @@ -340,16 +337,6 @@ describe("DomEditOverlay", () => { "drag", expect.objectContaining({ button: 0 }), ); - const recordButton = host.querySelector( - '[aria-label="Record gesture (R)"]', - ) as HTMLButtonElement; - expect(recordButton).toBeTruthy(); - - act(() => { - recordButton.click(); - }); - - expect(onToggleRecording).toHaveBeenCalledTimes(1); act(() => { root.unmount(); diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index 64ca5147f0..ddb0630d82 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -14,7 +14,7 @@ import { useDomEditOverlayRects } from "./useDomEditOverlayRects"; import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures"; import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay"; import { GridOverlay } from "./GridOverlay"; -import { GestureRecordBadge, type GestureRecordingState } from "./GestureRecordControl"; +import type { GestureRecordingState } from "./GestureRecordControl"; // Re-exports for external consumers — preserving existing import paths. export { @@ -87,8 +87,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({ onGroupPathOffsetCommit, onBoxSizeCommit, onRotationCommit, - recordingState, - onToggleRecording, }: DomEditOverlayProps) { const overlayRef = useRef(null); const boxRef = useRef(null); @@ -434,13 +432,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({ /> )} - {onToggleRecording && ( - - )}
(prev === vis ? prev : vis)); if (live) { const h = elementHome(live); @@ -271,6 +275,9 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({ // modifies it rather than adding a keyframe. const activeKeyframePct = usePlayerStore((s) => s.activeKeyframePct); const dragRef = useRef(null); + // Park-on-click is debounced so a double-click cancels the seek (see onUp). + const parkTimerRef = useRef | undefined>(undefined); + useEffect(() => () => clearTimeout(parkTimerRef.current), []); // Create mode: a selected element with no positional motion. A double-click on // the canvas authors a new motionPath from the element to that point. Gated on @@ -408,6 +415,7 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({ ref: MotionNodeRef, ) => { if (!interactive) return; + if (e.button !== 0) return; // primary button only — right-click is the context menu e.stopPropagation(); (e.target as Element).setPointerCapture(e.pointerId); dragRef.current = { @@ -453,8 +461,16 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({ // activeKeyframePct) instead of creating a new one. if (d.ref.type === "keyframe") { usePlayerStore.getState().setActiveKeyframePct(d.ref.pct); - const anim = selectedGsapAnimations?.find((a) => a.id === animId); - if (anim) parkPlayheadOnKeyframe(anim, d.ref.pct); + const ref = d.ref; + // Debounce the playhead seek: a double-click cancels it (e.detail >= 2), + // so only a lone single-click parks the playhead on the keyframe. + clearTimeout(parkTimerRef.current); + if (e.detail < 2) { + parkTimerRef.current = setTimeout(() => { + const anim = selectedGsapAnimations?.find((a) => a.id === animId); + if (anim) parkPlayheadOnKeyframe(anim, ref.pct); + }, 250); + } } return; // no commit } diff --git a/packages/studio/src/components/editor/domEditOverlayStartGesture.ts b/packages/studio/src/components/editor/domEditOverlayStartGesture.ts index b45d68db8d..f91a8a4347 100644 --- a/packages/studio/src/components/editor/domEditOverlayStartGesture.ts +++ b/packages/studio/src/components/editor/domEditOverlayStartGesture.ts @@ -5,6 +5,7 @@ import { type DomEditSelection } from "./domEditing"; import { createManualOffsetDragMember, + readGsapRotation, restoreManualOffsetDragMembers, type ManualOffsetDragMember, } from "./manualOffsetDrag"; @@ -115,7 +116,10 @@ export function startGesture( return false; const size = readStudioBoxSize(sel.element); - const rotation = readStudioRotation(sel.element); + // Single-source rotation base = the live GSAP transform rotation plus any legacy + // `--hf-studio-rotation` CSS var (old projects), so a rotate gesture starts from the + // element's actual visual angle and commits an absolute angle to the timeline. + const rotation = { angle: readGsapRotation(sel.element) + readStudioRotation(sel.element).angle }; const actualWidth = size.width > 0 ? size.width : rect.width / rect.editScaleX; const actualHeight = size.height > 0 ? size.height : rect.height / rect.editScaleY; let initialPathOffset = captureStudioPathOffset(sel.element); diff --git a/packages/studio/src/components/editor/domEditingDom.ts b/packages/studio/src/components/editor/domEditingDom.ts index 7c22b2348b..19c82b9368 100644 --- a/packages/studio/src/components/editor/domEditingDom.ts +++ b/packages/studio/src/components/editor/domEditingDom.ts @@ -141,6 +141,31 @@ export function getElementDepth(el: HTMLElement): number { // ─── Composition source resolution ─────────────────────────────────────────── +// The runtime INLINES subcompositions and strips the source-file linkage from the +// mounted root (it keeps `data-composition-id` but drops `data-composition-src`/ +// `-file`), so a subcomp element's DOM ancestors no longer say which file it came +// from. This project-global map (composition-id → source file, built once from +// index.html's clips — see NLELayout) recovers it. The studio loads one project at a +// time, so module scope is the right lifetime; it's empty until set, in which case +// resolution falls back to the historical attribute-only behavior. +let compositionSourceMap: Map = new Map(); + +export function setCompositionSourceMap(map: Map): void { + compositionSourceMap = map; +} + +function sourceFromCompositionId(ownerRoot: HTMLElement | null): string | undefined { + if (!ownerRoot || compositionSourceMap.size === 0) return undefined; + // The runtime may rename the mounted id to a runtime-unique one, preserving the + // authored id on `data-hf-original-composition-id` — prefer that, then the current id. + const authored = ownerRoot.getAttribute("data-hf-original-composition-id"); + const current = ownerRoot.getAttribute("data-composition-id"); + return ( + (authored ? compositionSourceMap.get(authored) : undefined) ?? + (current ? compositionSourceMap.get(current) : undefined) + ); +} + export function getSourceFileForElement( el: HTMLElement, activeCompositionPath: string | null, @@ -152,6 +177,7 @@ export function getSourceFileForElement( sourceHost?.getAttribute("data-composition-src") ?? ownerRoot?.getAttribute("data-composition-file") ?? ownerRoot?.getAttribute("data-composition-src") ?? + sourceFromCompositionId(ownerRoot) ?? activeCompositionPath ?? "index.html"; diff --git a/packages/studio/src/components/editor/manualEdits.ts b/packages/studio/src/components/editor/manualEdits.ts index 5c0e01be22..61e735323d 100644 --- a/packages/studio/src/components/editor/manualEdits.ts +++ b/packages/studio/src/components/editor/manualEdits.ts @@ -16,6 +16,7 @@ export { endStudioManualEditGesture, isStudioManualEditGestureCurrent, readStudioPathOffset, + readAppliedStudioPathOffset, readStudioBoxSize, readStudioRotation, applyStudioPathOffset, diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index c7a9fee5e0..ea0ee2cbc4 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -70,6 +70,19 @@ export function readStudioPathOffset(element: HTMLElement): { x: number; y: numb }; } +/** + * The path offset ACTUALLY applied right now. The `--hf-studio-offset` vars can + * linger after GSAP re-bakes the element's transform (`translate:"none"`), so the + * raw var isn't a safe drag base — using it re-commits a phantom offset and flings + * the element off-screen. The offset only counts when the inline `translate` is the + * studio var-translate; otherwise it's dormant and the applied offset is zero. + */ +export function readAppliedStudioPathOffset(element: HTMLElement): { x: number; y: number } { + return (element.style.translate || "").includes(STUDIO_OFFSET_X_PROP) + ? readStudioPathOffset(element) + : { x: 0, y: 0 }; +} + export function readStudioBoxSize(element: HTMLElement): { width: number; height: number } { return { width: readPxCustomProperty(element, STUDIO_WIDTH_PROP), diff --git a/packages/studio/src/components/editor/manualOffsetDrag.test.ts b/packages/studio/src/components/editor/manualOffsetDrag.test.ts index 6c2a449865..a4c633a441 100644 --- a/packages/studio/src/components/editor/manualOffsetDrag.test.ts +++ b/packages/studio/src/components/editor/manualOffsetDrag.test.ts @@ -193,6 +193,12 @@ describe("createManualOffsetDragMember uses raw CSS var offset", () => { element.style.setProperty(STUDIO_OFFSET_X_PROP, "30px"); element.style.setProperty(STUDIO_OFFSET_Y_PROP, "10px"); + // Old projects bake the offset by referencing the vars in the inline + // `translate` longhand — that's what makes the offset "applied" and thus the + // valid drag base (readAppliedStudioPathOffset). A raw var with no applied + // translate is dormant and reads as zero. Assign the typed `.translate` + // accessor (happy-dom doesn't surface it via setProperty). + element.style.translate = `var(${STUDIO_OFFSET_X_PROP}, 0px) var(${STUDIO_OFFSET_Y_PROP}, 0px)`; element.style.setProperty("transform", "translate(50px, -15px)"); element.getBoundingClientRect = () => { @@ -228,6 +234,12 @@ describe("createManualOffsetDragMember uses raw CSS var offset", () => { // Simulate GSAP baking a translate into transform each cycle for (let cycle = 0; cycle < 3; cycle++) { element.style.setProperty("transform", `translate(${50 * (cycle + 1)}px, 0px)`); + // Mark the offset as APPLIED (the inline translate references the studio + // vars, the form an old project bakes) so readAppliedStudioPathOffset reads + // the var, not zero. Without this the var is dormant and reads as zero. + // Assign the typed `.translate` accessor (happy-dom doesn't surface it via + // setProperty). + element.style.translate = `var(${STUDIO_OFFSET_X_PROP}, 0px) var(${STUDIO_OFFSET_Y_PROP}, 0px)`; const result = createManualOffsetDragMember({ key: "test", diff --git a/packages/studio/src/components/editor/manualOffsetDrag.ts b/packages/studio/src/components/editor/manualOffsetDrag.ts index d6ac554d02..9cfe694f0b 100644 --- a/packages/studio/src/components/editor/manualOffsetDrag.ts +++ b/packages/studio/src/components/editor/manualOffsetDrag.ts @@ -5,10 +5,71 @@ import { beginStudioManualEditGesture, captureStudioPathOffset, endStudioManualEditGesture, - readStudioPathOffset, + readAppliedStudioPathOffset, restoreStudioPathOffset, type StudioPathOffsetSnapshot, } from "./manualEdits"; +import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition"; + +interface OffsetDragGsap { + set: (el: Element, vars: Record) => void; + getProperty: (el: Element, prop: string) => number; +} + +function getOffsetDragGsap(element: HTMLElement): OffsetDragGsap | null { + const win = element.ownerDocument.defaultView as + | (Window & { gsap?: Partial }) + | null; + const gsap = win?.gsap; + return gsap?.set && gsap.getProperty ? (gsap as OffsetDragGsap) : null; +} + +/** + * Live drag preview through the GSAP channel — the SAME channel the commit + * lands in (a `tl.set`/keyframe on the timeline), so what the user sees while + * dragging equals what gets written (plan R3/R4). Reuses the commit's + * base+delta+rotation math so preview and commit agree by construction. Returns + * true when handled via gsap; false when gsap is unavailable (caller falls back + * to the CSS draft). + */ +function applyOffsetDragDraftViaGsap( + element: HTMLElement, + offset: { x: number; y: number }, +): boolean { + const gsap = getOffsetDragGsap(element); + if (!gsap) return false; + // GSAP owns the transform; neutralize the CSS translate longhand so the two + // channels can't compose into a doubled position. + element.style.setProperty("translate", "none"); + const fallbackBase = { + x: Number(gsap.getProperty(element, "x")) || 0, + y: Number(gsap.getProperty(element, "y")) || 0, + }; + const { newX, newY } = computeDraggedGsapPosition(element, offset, fallbackBase); + gsap.set(element, { x: newX, y: newY }); + return true; +} + +/** + * Live rotation preview through the GSAP channel — the SAME channel the commit + * lands in (a `tl.set`/keyframe rotation), mirroring `applyOffsetDragDraftViaGsap`. + * GSAP owns the transform rotation, so neutralize the CSS `rotate` longhand to keep + * the two channels from composing. `angle` is the absolute target rotation. Returns + * false when gsap is unavailable (caller falls back to the CSS draft). + */ +export function applyRotationDraftViaGsap(element: HTMLElement, angle: number): boolean { + const gsap = getOffsetDragGsap(element); + if (!gsap) return false; + element.style.setProperty("rotate", "none"); + gsap.set(element, { rotation: angle }); + return true; +} + +/** Current GSAP transform rotation — the single-source rotation base. 0 if gsap is unavailable. */ +export function readGsapRotation(element: HTMLElement): number { + const gsap = getOffsetDragGsap(element); + return gsap ? Number(gsap.getProperty(element, "rotation")) || 0 : 0; +} const DEFAULT_OFFSET_PROBE_PX = 100; const MIN_PROBE_VECTOR_LENGTH_PX = 0.01; @@ -241,7 +302,10 @@ export function createManualOffsetDragMember(input: { element: HTMLElement; rect: ManualOffsetDragRect; }): ManualOffsetDragMemberResult { - const initialOffset = readStudioPathOffset(input.element); + // Base the drag on the offset ACTUALLY applied, never the raw (possibly dormant) + // var — see readAppliedStudioPathOffset. This keeps the commit purely relative + // (applied + delta) so a stale offset can't fling the element off-screen. + const initialOffset = readAppliedStudioPathOffset(input.element); input.element.setAttribute("data-hf-drag-initial-offset-x", String(initialOffset.x)); input.element.setAttribute("data-hf-drag-initial-offset-y", String(initialOffset.y)); @@ -335,7 +399,12 @@ export function applyManualOffsetDragDraft( dy: number, ): { x: number; y: number } { const offset = resolveManualOffsetDragMemberOffset(member, dx, dy); - applyStudioPathOffsetDraft(member.element, offset); + // Position is single-sourced on the GSAP timeline; preview through gsap.set so + // the live draft matches the committed `tl.set`/keyframe. CSS draft only when + // gsap is unavailable (no preview iframe runtime). + if (!applyOffsetDragDraftViaGsap(member.element, offset)) { + applyStudioPathOffsetDraft(member.element, offset); + } return offset; } @@ -345,7 +414,13 @@ export function applyManualOffsetDragCommit( dy: number, ): { x: number; y: number } { const offset = resolveManualOffsetDragMemberOffset(member, dx, dy); - applyStudioPathOffset(member.element, offset); + // Optimistic visual through the GSAP channel (same as the live draft and the + // committed `tl.set`), so the element holds its dropped position until the + // source mutation soft-reloads — no transient CSS `--hf-studio-offset` write. + // CSS apply only when gsap is unavailable. + if (!applyOffsetDragDraftViaGsap(member.element, offset)) { + applyStudioPathOffset(member.element, offset); + } return offset; } diff --git a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts index 459abc4aff..8f8376f864 100644 --- a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts +++ b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts @@ -9,6 +9,7 @@ import { type DomEditSelection } from "./domEditing"; import { applyManualOffsetDragCommit, applyManualOffsetDragDraft, + applyRotationDraftViaGsap, endManualOffsetDragMembers, restoreManualOffsetDragMembers, resumeGsapTimelines, @@ -161,19 +162,21 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu let dy = e.clientY - g.startY; if (g.kind === "rotate") { - applyStudioRotationDraft( - sel.element, - resolveDomEditRotationGesture({ - centerX: g.centerX, - centerY: g.centerY, - startX: g.startX, - startY: g.startY, - currentX: e.clientX, - currentY: e.clientY, - actualAngle: g.actualRotation, - snap: e.shiftKey, - }), - ); + // Single source of truth: preview the rotation through the GSAP channel (the + // same channel the commit lands in), not the `--hf-studio-rotation` CSS var. + const rotated = resolveDomEditRotationGesture({ + centerX: g.centerX, + centerY: g.centerY, + startX: g.startX, + startY: g.startY, + currentX: e.clientX, + currentY: e.clientY, + actualAngle: g.actualRotation, + snap: e.shiftKey, + }); + if (!applyRotationDraftViaGsap(sel.element, rotated.angle)) { + applyStudioRotationDraft(sel.element, rotated); + } return; } @@ -393,25 +396,38 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu actualAngle: g.actualRotation, snap: e.shiftKey, }); + const restoreRotation = () => { + // Single source of truth: snap the GSAP rotation back to the gesture's base + // angle; fall back to the legacy CSS-var restore when gsap is unavailable. + if (!applyRotationDraftViaGsap(sel.element, g.actualRotation)) { + restoreStudioRotation(sel.element, g.initialRotation); + } + }; if (!hasDomEditRotationChanged(g.actualRotation, finalRotation.angle)) { - restoreStudioRotation(sel.element, g.initialRotation); + restoreRotation(); endStudioManualEditGesture(sel.element, g.manualEditDragToken); return; } - applyStudioRotation(sel.element, finalRotation); + // Keep the preview at the final angle through the GSAP channel (NOT the CSS var) + // while the commit lands a `tl.set`/keyframe rotation on the timeline. + if (!applyRotationDraftViaGsap(sel.element, finalRotation.angle)) { + applyStudioRotation(sel.element, finalRotation); + } void Promise.resolve(opts.onRotationCommitRef.current(sel, finalRotation)) .catch(() => { if ( g.manualEditDragToken && isStudioManualEditGestureCurrent(sel.element, g.manualEditDragToken) ) - restoreStudioRotation(sel.element, g.initialRotation); + restoreRotation(); }) .finally(() => endStudioManualEditGesture(sel.element, g.manualEditDragToken)); } else if (g.kind === "drag") { const dx = g.lastSnappedDx ?? e.clientX - g.startX; const dy = g.lastSnappedDy ?? e.clientY - g.startY; - if (!g.pathOffsetMember) return; + if (!g.pathOffsetMember) { + return; + } const finalOffset = applyManualOffsetDragCommit(g.pathOffsetMember, dx, dy); const nextBoxLeft = g.originLeft + dx; const nextBoxTop = g.originTop + dy; diff --git a/packages/studio/src/components/editor/useDomEditOverlayRects.ts b/packages/studio/src/components/editor/useDomEditOverlayRects.ts index a922d4404f..bf3819b40e 100644 --- a/packages/studio/src/components/editor/useDomEditOverlayRects.ts +++ b/packages/studio/src/components/editor/useDomEditOverlayRects.ts @@ -10,7 +10,7 @@ import { type OverlayRect, type ResolvedElementRef, groupOverlayItemsEqual, - isElementVisibleInPreview, + isElementVisibleForOverlay, rectsEqual, resolveElementForOverlay, selectionCacheKey, @@ -148,7 +148,13 @@ export function useDomEditOverlayRects({ activeCompositionPathRef.current, resolvedElementRef as ResolvedElementRef, ); - if (el && isElementVisibleInPreview(el)) { + // An explicitly-selected element's overlay must track it whenever it's laid + // out and not display:none/visibility:hidden/opacity:0 — use basic visibility, + // NOT the occlusion heuristic. Occlusion (isElementVisibleInPreview) treats any + // opacity:1 ancestor as an opaque cover even when it paints nothing (e.g. a + // 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); setOverlayRect(nextRect); const descendants = el.querySelectorAll("*"); diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index 2ec2b88a7a..70cb43f7fa 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -16,6 +16,7 @@ import { CompositionBreadcrumb } from "./CompositionBreadcrumb"; import { usePreviewBlockDrop } from "./usePreviewBlockDrop"; import { useCompositionStack } from "./useCompositionStack"; import { useTimelineEditContext } from "../../contexts/TimelineEditContext"; +import { setCompositionSourceMap } from "../editor/domEditingDom"; import { trackStudioExpandedClipEdit } from "../../telemetry/events"; import { TIMELINE_TOGGLE_SHORTCUT_LABEL, @@ -294,6 +295,9 @@ export const NLELayout = memo(function NLELayout({ if (id && src) map.set(id, src); } setCompIdToSrc(map); + // Let DOM source-resolution recover a subcomposition element's source file + // (the runtime drops the linkage when inlining — see getSourceFileForElement). + setCompositionSourceMap(map); onCompIdToSrcChange?.(map); }) .catch(() => {}); diff --git a/packages/studio/src/hooks/draggedGsapPosition.ts b/packages/studio/src/hooks/draggedGsapPosition.ts new file mode 100644 index 0000000000..63cf68813a --- /dev/null +++ b/packages/studio/src/hooks/draggedGsapPosition.ts @@ -0,0 +1,47 @@ +/** + * Drag → GSAP position math, shared by the commit path + * (`gsapDragCommit.commitGsapPositionFromDrag` / `commitStaticGsapPosition`) and + * the live preview (`manualOffsetDrag.applyManualOffsetDrag*`). Kept in its own + * leaf module — no store/runtime/core imports — so the live-preview file can use + * it without pulling the GSAP commit graph into its module scope. + */ + +/** + * Translate a studio drag offset into absolute GSAP x/y, accounting for the + * element's rotation and its drag-start base pose. Reads the drag-start + * attributes stamped by `createManualOffsetDragMember` + * (`data-hf-drag-initial-offset-*`, `data-hf-drag-gsap-base-*`); `fallbackBase` + * is used when the base attributes are absent (e.g. a static element that GSAP + * hasn't given an x/y yet). + * + * Used by both the tweened commit and the static `set` commit / live preview, so + * the preview and the committed value agree by construction. + */ +// fallow-ignore-next-line complexity +export function computeDraggedGsapPosition( + element: HTMLElement, + studioOffset: { x: number; y: number }, + fallbackBase: { x: number; y: number }, +): { newX: number; newY: number; baseGsapX: number; baseGsapY: number } { + const rotStyle = element.style.getPropertyValue("--hf-studio-rotation"); + const rotDeg = Number.parseFloat(rotStyle) || 0; + const rad = (-rotDeg * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + const origX = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-x") ?? "") || 0; + const origY = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-y") ?? "") || 0; + const deltaX = studioOffset.x - origX; + const deltaY = studioOffset.y - origY; + const adjX = deltaX * cos - deltaY * sin; + const adjY = deltaX * sin + deltaY * cos; + const parsedBaseX = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-x") ?? ""); + const parsedBaseY = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-y") ?? ""); + const baseGsapX = Number.isFinite(parsedBaseX) ? parsedBaseX : fallbackBase.x; + const baseGsapY = Number.isFinite(parsedBaseY) ? parsedBaseY : fallbackBase.y; + return { + newX: Math.round(baseGsapX + adjX), + newY: Math.round(baseGsapY + adjY), + baseGsapX, + baseGsapY, + }; +} diff --git a/packages/studio/src/hooks/gsapDragCommit.ts b/packages/studio/src/hooks/gsapDragCommit.ts index 518abf4e99..0b7ff5b728 100644 --- a/packages/studio/src/hooks/gsapDragCommit.ts +++ b/packages/studio/src/hooks/gsapDragCommit.ts @@ -9,6 +9,7 @@ import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyf import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler"; import { roundTo3 } from "../utils/rounding"; import { computeElementPercentage } from "./gsapShared"; +import { computeDraggedGsapPosition } from "./draggedGsapPosition"; export interface GsapDragCommitCallbacks { commitMutation: ( selection: DomEditSelection, @@ -311,6 +312,132 @@ async function commitFlatViaKeyframes( if (editedSelected) parkPlayheadOnKeyframe(anim, pct); } +// ── Drag → GSAP position math ────────────────────────────────────────────── + +// Math lives in its own leaf module so the live-preview file can reuse it +// without importing the GSAP commit graph (store/runtime/core). +export { computeDraggedGsapPosition }; + +/** + * Find the studio position-hold `set` for a selector — a `tl.set("#el",{x,y})` + * with no duration. This is what a static-element nudge writes/updates. + */ +function findPositionSetAnimation( + animations: GsapAnimation[], + selector: string, +): GsapAnimation | null { + return ( + animations.find( + (a) => + a.method === "set" && + a.targetSelector === selector && + ("x" in a.properties || "y" in a.properties), + ) ?? null + ); +} + +/** + * Commit a STATIC element drag as a `tl.set("#el",{x,y})` — the single-source + * position channel for elements with no position animation. Idempotent: a + * re-nudge of an element that already has a `set` UPDATES that set's x/y + * (two `update-property` mutations) rather than stacking a second set or + * converting it to keyframes (plan R2 / KTD3). New elements get one `add` + * mutation with `method:"set"` at position 0. + */ +export async function commitStaticGsapPosition( + selection: DomEditSelection, + studioOffset: { x: number; y: number }, + gsapPos: { x: number; y: number }, + selector: string, + existingSet: GsapAnimation | null, + callbacks: GsapDragCommitCallbacks, +): Promise { + const { newX, newY } = computeDraggedGsapPosition(selection.element, studioOffset, gsapPos); + if (existingSet) { + // Update in place — two single-property mutations (the API updates one prop + // per call). Coalesce them and reload only after the second lands. + const coalesceKey = `gsap:set-nudge:${existingSet.id}`; + await callbacks.commitMutation( + selection, + { type: "update-property", animationId: existingSet.id, property: "x", value: newX }, + { label: "Move layer", skipReload: true, coalesceKey }, + ); + await callbacks.commitMutation( + selection, + { type: "update-property", animationId: existingSet.id, property: "y", value: newY }, + { label: "Move layer", softReload: true, coalesceKey }, + ); + return; + } + await callbacks.commitMutation( + selection, + { + type: "add", + targetSelector: selector, + method: "set", + position: 0, + properties: { x: newX, y: newY }, + }, + { label: "Move layer", softReload: true }, + ); +} + +export { findPositionSetAnimation }; + +function findRotationSetAnimation( + animations: GsapAnimation[], + selector: string, +): GsapAnimation | null { + return ( + animations.find( + (a) => a.method === "set" && a.targetSelector === selector && "rotation" in a.properties, + ) ?? null + ); +} + +/** + * Commit a STATIC element rotation as a `tl.set("#el",{rotation})` — the single- + * source rotation channel for elements with no rotation animation (mirrors + * `commitStaticGsapPosition`). `newRotation` is the already-resolved absolute angle + * (current runtime rotation + drag delta). Idempotent: re-rotating an element that + * already has a rotation `set` UPDATES it in place (one `update-property`, rotation + * is a single value unlike x/y); a new element gets one `add` with `method:"set"`. + */ +export async function commitStaticGsapRotation( + selection: DomEditSelection, + newRotation: number, + selector: string, + existingSet: GsapAnimation | null, + callbacks: GsapDragCommitCallbacks, +): Promise { + if (existingSet) { + await callbacks.commitMutation( + selection, + { + type: "update-property", + animationId: existingSet.id, + property: "rotation", + value: newRotation, + }, + { label: "Rotate layer", softReload: true }, + ); + return; + } + await callbacks.commitMutation( + selection, + { + type: "add", + targetSelector: selector, + method: "set", + position: 0, + properties: { rotation: newRotation }, + }, + { label: "Rotate layer", softReload: true }, + ); +} + +export { findRotationSetAnimation }; + // ── Main drag commit ────────────────────────────────────────────────────── /** @@ -327,24 +454,14 @@ export async function commitGsapPositionFromDrag( selector: string, callbacks: GsapDragCommitCallbacks, ): Promise { - const rotStyle = selection.element.style.getPropertyValue("--hf-studio-rotation"); - const rotDeg = Number.parseFloat(rotStyle) || 0; - const rad = (-rotDeg * Math.PI) / 180; - const cos = Math.cos(rad); - const sin = Math.sin(rad); const el = selection.element; + const { newX, newY, baseGsapX, baseGsapY } = computeDraggedGsapPosition( + el, + studioOffset, + gsapPos, + ); const origX = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-x") ?? "") || 0; const origY = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-y") ?? "") || 0; - const deltaX = studioOffset.x - origX; - const deltaY = studioOffset.y - origY; - const adjX = deltaX * cos - deltaY * sin; - const adjY = deltaX * sin + deltaY * cos; - const parsedBaseX = Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-x") ?? ""); - const parsedBaseY = Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-y") ?? ""); - const baseGsapX = Number.isFinite(parsedBaseX) ? parsedBaseX : gsapPos.x; - const baseGsapY = Number.isFinite(parsedBaseY) ? parsedBaseY : gsapPos.y; - const newX = Math.round(baseGsapX + adjX); - const newY = Math.round(baseGsapY + adjY); const restoreOffset = () => { el.style.setProperty("--hf-studio-offset-x", `${origX}px`); el.style.setProperty("--hf-studio-offset-y", `${origY}px`); diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.test.ts b/packages/studio/src/hooks/gsapRuntimeBridge.test.ts index 9ff5c79019..45108d5263 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.test.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.test.ts @@ -7,9 +7,10 @@ import { tryGsapDragIntercept } from "./gsapRuntimeBridge"; * Regression: `selectedGsapAnimations` (and the fetch fallback) is an async * server-parse that LAGS a delete-all. A drag in that window would resolve a * phantom position tween from the stale cache and re-commit it — resurrecting the - * just-deleted animation. tryGsapDragIntercept must trust the live runtime: if no - * non-hold tween exists for the element, it bails (returns false → CSS fallback) - * instead of committing. + * just-deleted animation. tryGsapDragIntercept must trust the LIVE runtime: when + * the runtime has no keyframed/tweened position motion, the element is STATIC + * (single-source model), so the drag commits a position-hold `tl.set("#el",{x,y})` + * rather than re-committing the phantom tween. The stale `to` parse is ignored. */ // A preview iframe whose runtime timeline holds `children`, resolves the element, @@ -57,9 +58,10 @@ const stalePositionAnim = { afterEach(() => vi.restoreAllMocks()); describe("tryGsapDragIntercept — stale-parse guard (no resurrection after delete-all)", () => { - it("bails without committing when the runtime has no tween (only the parse is stale)", async () => { + it("commits a static set (not the stale tween) when the runtime has no live position motion", async () => { const commitMutation = vi.fn(); - // Runtime empty (tween deleted) — readRuntimeKeyframes returns null. + // Runtime empty (tween deleted) — readRuntimeKeyframes returns null, so the + // element is treated as STATIC. The stale `to` parse must NOT be re-committed. const iframe = fakeIframe("puck-b", []); const handled = await tryGsapDragIntercept( @@ -70,8 +72,21 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele commitMutation, ); - expect(handled).toBe(false); - expect(commitMutation).not.toHaveBeenCalled(); + expect(handled).toBe(true); + // No existing `set` for the selector → one `add` mutation with `method:"set"`. + expect(commitMutation).toHaveBeenCalledTimes(1); + const [, mutation] = commitMutation.mock.calls[0]; + expect(mutation).toMatchObject({ + type: "add", + method: "set", + targetSelector: "#puck-b", + position: 0, + }); + // Drag delta (-50, 30) off a zero base → the committed set holds that position. + expect(mutation.properties).toEqual({ x: -50, y: 30 }); + // It must NOT resurrect the stale tween via a tween/keyframe mutation. + expect(mutation.type).not.toBe("update-property"); + expect(mutation.type).not.toBe("add-keyframe"); }); it("does not trip the stale-parse guard when the runtime still has the tween", async () => { diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index 90e9f42ada..ff2a8f1a9f 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -15,7 +15,11 @@ import { usePlayerStore } from "../player/store/playerStore"; import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders"; import { commitGsapPositionFromDrag, + commitStaticGsapPosition, + commitStaticGsapRotation, computeCurrentPercentage, + findPositionSetAnimation, + findRotationSetAnimation, materializeIfDynamic, } from "./gsapDragCommit"; import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler"; @@ -212,30 +216,38 @@ export async function tryGsapDragIntercept( ); let posAnim = resolved?.anim ?? null; + let resolvedAnimations = resolved?.animations ?? animations; if (!posAnim) { posAnim = findGsapPositionAnimation(animations, selector); if (!posAnim && fetchFallbackAnimations) { const fresh = await fetchFallbackAnimations(); + resolvedAnimations = fresh; posAnim = findGsapPositionAnimation(fresh, selector); } } - if (!posAnim) { - return false; - } - // The live runtime is authoritative; `selectedGsapAnimations` (and the fetch - // fallback) is an async server-parse that LAGS a delete-all, so `posAnim` can - // be a phantom of a just-deleted tween. If the live timeline has no non-hold - // tween for this element, the parse is stale — bail so the drag falls back to - // the CSS path instead of resurrecting the deleted animation from stale cache. - // Use the strict existence check (not a truthy keyframe read): a leftover hold - // `set` after a delete-all must NOT count as a live tween. + const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 }; + + // STATIC case (single source of truth = GSAP timeline): the element has no LIVE + // keyframed/tweened position motion. Use the strict non-hold check — a leftover + // position-hold `set` (after a delete-all, or a stale parse that lags it) must + // NOT count as live motion. Either way the position belongs in a + // `tl.set("#el",{x,y})`, not a keyframe conversion: re-nudge an existing set in + // 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. if (!hasNonHoldTweenForElement(iframe, selector)) { - return false; + const existingSet = + posAnim && posAnim.method === "set" && posAnim.targetSelector === selector + ? posAnim + : findPositionSetAnimation(resolvedAnimations, selector); + await commitStaticGsapPosition(selection, offset, gsapPos, selector, existingSet, { + commitMutation, + fetchAnimations: fetchFallbackAnimations, + }); + return true; } - const gsapPos = readGsapPositionFromIframe(iframe, selector); - if (!gsapPos) { + if (!posAnim) { return false; } @@ -450,6 +462,9 @@ export async function tryGsapRotationIntercept( commitMutation: GsapDragCommitCallbacks["commitMutation"], fetchFallbackAnimations?: () => Promise, ): Promise { + const selector = selectorFromSelection(selection); + if (!selector) return false; + // Resolve the rotation-group tween, splitting legacy mixed tweens if needed. const resolved = await resolveGroupTween( "rotation", @@ -458,6 +473,7 @@ export async function tryGsapRotationIntercept( commitMutation, fetchFallbackAnimations, ); + const resolvedAnimations = resolved?.animations ?? animations; // Fallback: legacy heuristic for hand-written scripts let anim = resolved?.anim ?? null; @@ -468,20 +484,27 @@ export async function tryGsapRotationIntercept( anim = fresh.find((a) => "rotation" in a.properties || a.keyframes) ?? null; } } - if (!anim) return false; - const selector = selectorFromSelection(selection); - if (!selector) return false; + // `angle` is the ABSOLUTE target rotation resolved by the gesture (gsap base + + // pointer sweep) or the inspector — so it IS the new rotation. No base re-add: the + // gesture's live preview already gsap.set this value (single source of truth). + const newRotation = Math.round(angle); - let gsapRotation = 0; - const gsap = getIframeGsap(iframe); - const rotEl = gsap ? queryIframeElement(iframe, selector) : null; - if (gsap && rotEl) { - gsapRotation = Number(gsap.getProperty(rotEl, "rotation")) || 0; + // STATIC case (single source of truth = GSAP timeline): no rotation tween, so the + // angle belongs in a `tl.set("#el",{rotation})`, not a keyframe conversion — + // mirroring the static position set. Idempotent: re-rotate updates an existing + // rotation set in place, else add a new one. This replaces the old + // `--hf-studio-rotation` CSS-var fallback (the same dual-channel bug class). + if (!anim) { + const existingSet = findRotationSetAnimation(resolvedAnimations, selector); + await commitStaticGsapRotation(selection, newRotation, selector, existingSet, { + commitMutation, + fetchAnimations: fetchFallbackAnimations, + }); + return true; } const pct = computeCurrentPercentage(selection, anim); - const newRotation = Math.round(gsapRotation + angle); if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) { const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection); diff --git a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts index 73dbaa3021..a08cf2ff30 100644 --- a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts +++ b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts @@ -172,14 +172,6 @@ export function readRuntimeKeyframes( ): ReadTween | null { const timelines = timelinesOf(iframe); if (!timelines) return null; - // Skip non-timeline markers (e.g. the studio's `__proxied` flag) when no - // explicit composition id is given — picking those yields no getChildren. - const tlId = - compositionId || - Object.keys(timelines).find((k) => typeof timelines[k]?.getChildren === "function"); - if (!tlId) return null; - const timeline = timelines[tlId]; - if (!timeline?.getChildren) return null; let targetEl: Element | null = null; try { @@ -189,23 +181,39 @@ export function readRuntimeKeyframes( } if (!targetEl) return null; + // Search the element's OWN composition timeline. With inlined subcompositions the + // preview has multiple timelines (one per composition), and the element belongs to + // exactly one — so we can't assume the first key (order isn't stable across soft + // reloads, which delete+re-add the rebuilt key). Scan every timeline for tweens + // targeting this element; only its composition's timeline matches. An explicit + // compositionId still pins the search. (`__proxied` and other non-timeline markers + // are skipped by the getChildren guard.) + const tlIds = compositionId + ? [compositionId] + : Object.keys(timelines).filter((k) => typeof timelines[k]?.getChildren === "function"); + if (tlIds.length === 0) return null; + // The element can have MORE THAN ONE keyframed tween at disjoint time ranges // (e.g. two non-overlapping gesture recordings → two separate `to()`s). The // overlay must draw the segment under the PLAYHEAD, not blindly the first one // — otherwise recording a second gesture leaves the path stuck on the first. - const now = typeof timeline.time === "function" ? timeline.time() : null; let firstRead: ReadTween | null = null; - for (const tween of timeline.getChildren(true)) { - if (!tween.vars || !matchesElement(tween, targetEl)) continue; - const dur = typeof tween.duration === "function" ? tween.duration() : 0; - if (isZeroDurationSet(dur)) continue; // skip hold/set tweens (see isZeroDurationSet) - const read = readTween(tween.vars); - if (!read) continue; - if (firstRead === null) firstRead = read; - // Prefer the tween whose [start, start+dur] contains the playhead. - if (now != null) { - const start = typeof tween.startTime === "function" ? tween.startTime() : 0; - if (now >= start - 1e-3 && now <= start + dur + 1e-3) return read; + for (const tlId of tlIds) { + const timeline = timelines[tlId]; + if (!timeline?.getChildren) continue; + const now = typeof timeline.time === "function" ? timeline.time() : null; + for (const tween of timeline.getChildren(true)) { + if (!tween.vars || !matchesElement(tween, targetEl)) continue; + const dur = typeof tween.duration === "function" ? tween.duration() : 0; + if (isZeroDurationSet(dur)) continue; // skip hold/set tweens (see isZeroDurationSet) + const read = readTween(tween.vars); + if (!read) continue; + if (firstRead === null) firstRead = read; + // Prefer the tween whose [start, start+dur] contains the playhead. + if (now != null) { + const start = typeof tween.startTime === "function" ? tween.startTime() : 0; + if (now >= start - 1e-3 && now <= start + dur + 1e-3) return read; + } } } // Playhead outside every tween's range (or timeline has no clock): the element diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 81e0f50a0c..d4c582a0d4 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -213,10 +213,8 @@ export function useDomEditSession({ handleDomTextFieldStyleCommit, handleDomAddTextField, handleDomRemoveTextField, - handleDomPathOffsetCommit, handleDomGroupPathOffsetCommit, handleDomBoxSizeCommit, - handleDomRotationCommit, handleDomManualEditsReset, handleDomEditElementDelete, handleDomZIndexReorderCommit, @@ -384,9 +382,7 @@ export function useDomEditSession({ bumpGsapCache, makeFetchFallback, trackGsapInteractionFailure, - handleDomPathOffsetCommit, handleDomBoxSizeCommit, - handleDomRotationCommit, addGsapAnimation, convertToKeyframes, setArcPath, diff --git a/packages/studio/src/hooks/useGestureCommit.ts b/packages/studio/src/hooks/useGestureCommit.ts index 53969c4c9f..775296feef 100644 --- a/packages/studio/src/hooks/useGestureCommit.ts +++ b/packages/studio/src/hooks/useGestureCommit.ts @@ -21,42 +21,6 @@ interface GestureSessionRef { ) => Promise; } -type RecordedKeyframe = { - percentage: number; - properties: Record; -}; - -// Split a recorded gesture's keyframes by property group so each emitted tween -// carries a single group's props. A single-group `.to(...)` parses back with a -// concrete `propertyGroup` (position/scale/...); a mixed-prop tween parses as a -// legacy untagged tween, which the position-only drag intercept can't target. -// A gesture spanning multiple groups (e.g. x/y + opacity) therefore yields one -// correctly-tagged tween per group rather than one mixed tween. -function partitionKeyframesByGroup(keyframes: RecordedKeyframe[]): RecordedKeyframe[][] { - const byGroup = new Map(); - for (const kf of keyframes) { - const perGroup = new Map>(); - for (const [key, value] of Object.entries(kf.properties)) { - const group = classifyPropertyGroup(key); - let props = perGroup.get(group); - if (!props) { - props = {}; - perGroup.set(group, props); - } - props[key] = value; - } - for (const [group, props] of perGroup) { - let arr = byGroup.get(group); - if (!arr) { - arr = []; - byGroup.set(group, arr); - } - arr.push({ percentage: kf.percentage, properties: props }); - } - } - return Array.from(byGroup.values()); -} - interface UseGestureCommitParams { domEditSessionRef: React.MutableRefObject; previewIframeRef: React.RefObject; @@ -157,80 +121,92 @@ export function useGestureCommit({ ? allAnims.find((a) => a.propertyGroup === "position" && a.targetSelector === selector) : undefined; if (existingPositionTween) { - const tweenStart = existingPositionTween.resolvedStart ?? 0; - const tweenDur = existingPositionTween.duration ?? duration; - const tweenEnd = tweenStart + tweenDur; - const recEnd = recStart + duration; - - // Only merge if the recording overlaps the existing tween's time range. - // No overlap → fall through to add-with-keyframes (creates a separate tween). - const overlaps = recStart < tweenEnd + 0.05 && recEnd > tweenStart - 0.05; - - if (overlaps) { - const existingKfs = existingPositionTween.keyframes?.keyframes ?? []; - const rangeStartPct = - tweenDur > 0 ? Math.max(0, ((recStart - tweenStart) / tweenDur) * 100) : 0; - const rangeEndPct = - tweenDur > 0 ? Math.min(100, ((recEnd - tweenStart) / tweenDur) * 100) : 100; - - const preserved = existingKfs - .filter( - (kf) => kf.percentage < rangeStartPct - 0.5 || kf.percentage > rangeEndPct + 0.5, - ) - .map((kf) => ({ - percentage: kf.percentage, - properties: kf.properties, - ...(kf.ease ? { ease: kf.ease } : {}), - })); - - const mapped = keyframes.map((kf) => ({ - percentage: rangeStartPct + (kf.percentage / 100) * (rangeEndPct - rangeStartPct), - properties: kf.properties, - })); - - const merged = [...preserved, ...mapped].sort((a, b) => a.percentage - b.percentage); - + if (existingPositionTween.method === "set") { + // A `set` is a static hold, not a tween to merge into — replace it with + // the recorded motion (which already starts from the set's position). await liveSession.commitMutation( { type: "replace-with-keyframes", animationId: existingPositionTween.id, targetSelector: selector, - position: - typeof existingPositionTween.position === "number" - ? existingPositionTween.position - : tweenStart, - duration: tweenDur, - keyframes: merged, + position: roundTo3(recStart), + duration: roundTo3(duration), + keyframes, }, - { label: "Gesture recording (merge)", softReload: true }, + { label: "Gesture recording (replace set)", softReload: true }, ); } else { - for (const groupKfs of partitionKeyframesByGroup(keyframes)) { + const tweenStart = existingPositionTween.resolvedStart ?? 0; + const tweenDur = existingPositionTween.duration ?? duration; + const tweenEnd = tweenStart + tweenDur; + const recEnd = recStart + duration; + + // Only merge if the recording overlaps the existing tween's time range. + // No overlap → fall through to add-with-keyframes (creates a separate tween). + const overlaps = recStart < tweenEnd + 0.05 && recEnd > tweenStart - 0.05; + + if (overlaps) { + const existingKfs = existingPositionTween.keyframes?.keyframes ?? []; + const rangeStartPct = + tweenDur > 0 ? Math.max(0, ((recStart - tweenStart) / tweenDur) * 100) : 0; + const rangeEndPct = + tweenDur > 0 ? Math.min(100, ((recEnd - tweenStart) / tweenDur) * 100) : 100; + + const preserved = existingKfs + .filter( + (kf) => kf.percentage < rangeStartPct - 0.5 || kf.percentage > rangeEndPct + 0.5, + ) + .map((kf) => ({ + percentage: kf.percentage, + properties: kf.properties, + ...(kf.ease ? { ease: kf.ease } : {}), + })); + + const mapped = keyframes.map((kf) => ({ + percentage: rangeStartPct + (kf.percentage / 100) * (rangeEndPct - rangeStartPct), + properties: kf.properties, + })); + + const merged = [...preserved, ...mapped].sort((a, b) => a.percentage - b.percentage); + + await liveSession.commitMutation( + { + type: "replace-with-keyframes", + animationId: existingPositionTween.id, + targetSelector: selector, + position: + typeof existingPositionTween.position === "number" + ? existingPositionTween.position + : tweenStart, + duration: tweenDur, + keyframes: merged, + }, + { label: "Gesture recording (merge)", softReload: true }, + ); + } else { await liveSession.commitMutation( { type: "add-with-keyframes", targetSelector: selector, position: roundTo3(recStart), duration: roundTo3(duration), - keyframes: groupKfs, + keyframes, }, { label: "Gesture recording (new range)", softReload: true }, ); } } } else { - for (const groupKfs of partitionKeyframesByGroup(keyframes)) { - await liveSession.commitMutation( - { - type: "add-with-keyframes", - targetSelector: selector, - position: roundTo3(recStart), - duration: roundTo3(duration), - keyframes: groupKfs, - }, - { label: "Gesture recording", softReload: true }, - ); - } + await liveSession.commitMutation( + { + type: "add-with-keyframes", + targetSelector: selector, + position: roundTo3(recStart), + duration: roundTo3(duration), + keyframes, + }, + { label: "Gesture recording", softReload: true }, + ); } } showToast(`Recorded ${sortedPcts.length} keyframes`, "info"); diff --git a/packages/studio/src/hooks/useGestureRecording.ts b/packages/studio/src/hooks/useGestureRecording.ts index 1598917b97..12be5a6799 100644 --- a/packages/studio/src/hooks/useGestureRecording.ts +++ b/packages/studio/src/hooks/useGestureRecording.ts @@ -221,7 +221,6 @@ interface RecordingRefs { basePosition: { x: number; y: number }; cssVarOffset: { x: number; y: number }; scale: number; - pointerElementOffset: { x: number; y: number }; runtime: GsapRuntime | null; rafId: number; samples: GestureSample[]; @@ -240,7 +239,6 @@ function createRecordingRefs(): RecordingRefs { basePosition: { x: 0, y: 0 }, cssVarOffset: { x: 0, y: 0 }, scale: 1, - pointerElementOffset: { x: 0, y: 0 }, runtime: null, rafId: 0, samples: [], @@ -297,23 +295,10 @@ export function useGestureRecording() { r.accumulated = { opacity: base.baseOpacity, scale: base.baseScale, z: 0 }; r.basePosition = { x: base.baseX, y: base.baseY }; - // --- Phase 2: scale + element center, measured BEFORE clearing the path offset --- - // baseX/baseY fold in the CSS path offset (`--hf-studio-offset`, see - // readBasePosition), so the element's on-screen center must be read while that - // offset is still applied — otherwise the pointer-centering offset is wrong by - // exactly the path offset and the element doesn't sit under the pointer (it - // looked correct only for elements that had no path offset). - // element.getBoundingClientRect() is in the iframe's viewport; convert to the - // studio (parent) viewport using the iframe's position and scale. + // --- Phase 2: iframe → studio scale, measured BEFORE clearing the path offset --- + // The pointer deltas in the RAF loop are in studio-viewport pixels; divide by + // this scale to convert them to the iframe's composition pixels. r.scale = computeIframeScale(iframeEl); - const iframeScale = r.scale || 1; - const iframeRect = iframeEl.getBoundingClientRect(); - const elRect = element.getBoundingClientRect(); - const elCenterViewport = { - x: iframeRect.left + (elRect.left + elRect.width / 2) * iframeScale, - y: iframeRect.top + (elRect.top + elRect.height / 2) * iframeScale, - }; - r.pointerElementOffset = { x: 0, y: 0 }; // Now clear the optimistic path offset (already folded into baseX/baseY). if (base.cssOffX || base.cssOffY) { @@ -336,12 +321,6 @@ export function useGestureRecording() { // preventing an enormous bogus first keyframe from stale startPointer. if (!r.hasMoved) { r.startPointer = { x: r.pointer.x, y: r.pointer.y }; - r.pointerElementOffset = { - x: r.pointer.x - elCenterViewport.x, - y: r.pointer.y - elCenterViewport.y, - }; - r.basePosition.x += r.pointerElementOffset.x / iframeScale; - r.basePosition.y += r.pointerElementOffset.y / iframeScale; r.hasMoved = true; } r.scrollDelta += e.deltaY; @@ -362,12 +341,12 @@ export function useGestureRecording() { r.startPointer = { ...r.pointer }; const captureStart = (e: PointerEvent) => { if (!r.hasMoved) { + // Anchor the delta at the grab point — the element then moves by the + // pointer's *movement* from its actual position (preserving both the + // manual-drag start position and the grab offset). Do NOT snap the + // element's center to the pointer: that discarded the manual position + // and made the recorded 0% keyframe wrong. r.startPointer = { x: e.clientX, y: e.clientY }; - const offX = e.clientX - elCenterViewport.x; - const offY = e.clientY - elCenterViewport.y; - r.pointerElementOffset = { x: offX, y: offY }; - r.basePosition.x += offX / iframeScale; - r.basePosition.y += offY / iframeScale; r.hasMoved = true; } }; diff --git a/packages/studio/src/hooks/useGsapAnimationOps.ts b/packages/studio/src/hooks/useGsapAnimationOps.ts index 390db74ffc..046d8b6f00 100644 --- a/packages/studio/src/hooks/useGsapAnimationOps.ts +++ b/packages/studio/src/hooks/useGsapAnimationOps.ts @@ -101,7 +101,7 @@ export function useGsapAnimationOps({ void commitMutation( selection, { type: "delete-all-for-selector", targetSelector }, - { label: "Delete all animations for element" }, + { label: "Delete all animations for element", softReload: true }, ); }, [commitMutation, activeCompPath, sdkSession, sdkDeps], diff --git a/packages/studio/src/hooks/useGsapAwareEditing.ts b/packages/studio/src/hooks/useGsapAwareEditing.ts index 4cc366e26d..18aa84ca20 100644 --- a/packages/studio/src/hooks/useGsapAwareEditing.ts +++ b/packages/studio/src/hooks/useGsapAwareEditing.ts @@ -39,15 +39,10 @@ export interface UseGsapAwareEditingParams { label: string, ) => void; // DOM fallbacks (from useDomEditCommits) - handleDomPathOffsetCommit: ( - selection: DomEditSelection, - next: { x: number; y: number }, - ) => Promise; handleDomBoxSizeCommit: ( selection: DomEditSelection, next: { width: number; height: number }, ) => Promise; - handleDomRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise; // GSAP script commit ops (from useGsapScriptCommits) addGsapAnimation: ( sel: DomEditSelection, @@ -89,9 +84,7 @@ export function useGsapAwareEditing({ bumpGsapCache, makeFetchFallback, trackGsapInteractionFailure, - handleDomPathOffsetCommit, handleDomBoxSizeCommit, - handleDomRotationCommit, addGsapAnimation, convertToKeyframes, setArcPath, @@ -108,7 +101,12 @@ export function useGsapAwareEditing({ } if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) { try { - const handled = await tryGsapDragIntercept( + // The GSAP timeline is the single source of truth for element position — + // for the top-level composition AND subcompositions. tryGsapDragIntercept + // resolves the element's OWN iframe/runtime + source file, so it handles + // tweened elements (keyframe mutations) and static ones (a `tl.set`) in + // either. It returns false only for a selectorless element — a no-op. + await tryGsapDragIntercept( selection, next, selectedGsapAnimations, @@ -116,16 +114,13 @@ export function useGsapAwareEditing({ gsapCommitMutation, makeFetchFallback(selection), ); - if (handled) return; } catch (error) { trackGsapInteractionFailure(error, selection, "drag", "Move animated layer"); throw error; } } - return handleDomPathOffsetCommit(selection, next); }, [ - handleDomPathOffsetCommit, selectedGsapAnimations, gsapCommitMutation, previewIframeRef, @@ -169,7 +164,10 @@ export function useGsapAwareEditing({ async (selection: DomEditSelection, next: { angle: number }) => { if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) { try { - const handled = await tryGsapRotationIntercept( + // Single source of truth for rotation too: tryGsapRotationIntercept handles + // tweened elements (keyframes) and static ones (a tl.set), so there's no + // CSS-var fallback. It returns false only for a selectorless element (no-op). + await tryGsapRotationIntercept( selection, next.angle, selectedGsapAnimations, @@ -177,16 +175,13 @@ export function useGsapAwareEditing({ gsapCommitMutation, makeFetchFallback(selection), ); - if (handled) return; } catch (error) { trackGsapInteractionFailure(error, selection, "rotation", "Rotate animated layer"); throw error; } } - return handleDomRotationCommit(selection, next); }, [ - handleDomRotationCommit, selectedGsapAnimations, gsapCommitMutation, previewIframeRef, diff --git a/packages/studio/src/utils/gsapSoftReload.ts b/packages/studio/src/utils/gsapSoftReload.ts index e60e001d86..e0b7a80965 100644 --- a/packages/studio/src/utils/gsapSoftReload.ts +++ b/packages/studio/src/utils/gsapSoftReload.ts @@ -73,9 +73,27 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st if (!win || !doc) return false; if (!win.gsap || !win.__hfForceTimelineRebind) return false; + // Which composition(s) does this script rebuild? A soft reload re-runs ONE + // composition's GSAP script, which re-registers its own window.__timelines[key]. + // In a multi-composition preview (top-level + inlined subcompositions) each + // composition owns a separate timeline keyed by its id, and they're all children + // of the global timeline — so tearing down ALL of them (or the global timeline's + // children) and re-running a single script wipes every OTHER composition, + // reverting its edits. Scope the teardown to the keys THIS script re-registers. + const targetKeys = [...scriptText.matchAll(/__timelines\s*\[\s*["'`]([^"'`]+)["'`]\s*\]/g)] + .map((m) => m[1]!) + .filter((key) => key !== "__proxied"); + if (targetKeys.length === 0) return false; // can't scope safely → caller does a full reload const gsapScripts = findGsapScriptElements(doc); - if (gsapScripts.length !== 1) return false; - const oldScriptEl = gsapScripts[0]!; + if (gsapScripts.length === 0) return false; + // Remove only the stale script element(s) that registered a target key; one we + // can't match in the doc is left alone (re-running appends a fresh element). + const staleScripts = gsapScripts.filter((script) => + targetKeys.some((key) => { + const text = script.textContent || ""; + return text.includes(`__timelines["${key}"]`) || text.includes(`__timelines['${key}']`); + }), + ); const currentTime = win.__player?.getTime?.() ?? 0; @@ -91,47 +109,42 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st const timelines = win.__timelines; const allTargets: Element[] = []; + // Kill ONLY the target composition's timeline(s) — leaving every other + // composition's timeline (and its children on the global timeline) intact. if (timelines) { - for (const key of Object.keys(timelines)) { - if (key === "__proxied") continue; - try { - const tl = timelines[key] as { - kill?: () => void; - getChildren?: (deep: boolean) => Array<{ targets?: () => Element[] }>; - }; - if (tl?.getChildren) { - try { - for (const child of tl.getChildren(true)) { - if (typeof child.targets === "function") { - for (const t of child.targets()) allTargets.push(t); - } + for (const key of targetKeys) { + const tl = timelines[key] as + | { + kill?: () => void; + getChildren?: (deep: boolean) => Array<{ targets?: () => Element[] }>; + } + | undefined; + if (!tl) continue; + if (tl.getChildren) { + try { + for (const child of tl.getChildren(true)) { + if (typeof child.targets === "function") { + for (const t of child.targets()) allTargets.push(t); } - } catch {} - } - tl?.kill?.(); + } + } catch {} + } + try { + tl.kill?.(); } catch {} delete timelines[key]; } } - // Kill bare gsap.to/from tweens not registered on __timelines - if (win.gsap?.globalTimeline?.getChildren) { - try { - for (const child of win.gsap.globalTimeline.getChildren(false)) { - child.kill?.(); - } - } catch {} - } - - // Clear residual inline transforms left by killed tweens so from() tweens - // don't read stale end values from the DOM on re-execution + // Clear residual inline transforms on the re-run composition's targets only, so + // from() tweens don't read stale end values from the DOM on re-execution. if (allTargets.length > 0 && win.gsap?.set) { try { win.gsap.set(allTargets, { clearProps: "all" }); } catch {} } - oldScriptEl.remove(); + for (const script of staleScripts) script.remove(); const executeScript = () => { if (win.MotionPathPlugin && win.gsap?.registerPlugin) { From 5ef68d01ec301cdfb089e23f0fe458a668b5c0ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 20 Jun 2026 16:50:36 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix(studio):=20address=20#1567=20review=20?= =?UTF-8?q?=E2=80=94=20drop=20drag-intercept=20flag,=20harden=20softReload?= =?UTF-8?q?=20onerror,=20tighten=20runtime=20ladder,=20per-group=20gesture?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DROP STUDIO_GSAP_DRAG_INTERCEPT_ENABLED: single-source GSAP intercept is the only position/rotation channel; the false branch silently killed drag+rotate (and let GSAP elements into the keyframe-corrupting CSS path). Removed flag + dead branch + env def + tests. - gsapSoftReload: plugin onerror no longer fakes success — signals onAsyncFailure so the caller full-reloads; honors __hfMotionPathPluginLoading so a concurrent reload can't queue a dup script. - gsapDragCommit: resolveDragRuntime narrows the as-any ladder; a mid-seek throw logs + drops partial reads (no phantom identity) and re-applies the drag override in finally. - MotionPathOverlay: park-timer cleanup keyed on animId change. - useGestureCommit: partitionKeyframesByGroup wraps the add-with-keyframes sites (per #1611 review). --- .../components/editor/MotionPathOverlay.tsx | 11 +- .../editor/manualEditingAvailability.test.ts | 12 -- .../editor/manualEditingAvailability.ts | 10 -- packages/studio/src/hooks/gsapDragCommit.ts | 127 +++++++++++++----- packages/studio/src/hooks/gsapTargetCache.ts | 3 - packages/studio/src/hooks/useGestureCommit.ts | 88 +++++++++--- .../studio/src/hooks/useGsapAwareEditing.ts | 14 +- .../studio/src/hooks/useGsapScriptCommits.ts | 8 +- packages/studio/src/utils/gsapSoftReload.ts | 51 ++++++- 9 files changed, 225 insertions(+), 99 deletions(-) diff --git a/packages/studio/src/components/editor/MotionPathOverlay.tsx b/packages/studio/src/components/editor/MotionPathOverlay.tsx index 4d322be490..4fab04c62a 100644 --- a/packages/studio/src/components/editor/MotionPathOverlay.tsx +++ b/packages/studio/src/components/editor/MotionPathOverlay.tsx @@ -277,7 +277,15 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({ const dragRef = useRef(null); // Park-on-click is debounced so a double-click cancels the seek (see onUp). const parkTimerRef = useRef | undefined>(undefined); - useEffect(() => () => clearTimeout(parkTimerRef.current), []); + // The animation id whose path is currently editable. Computed at hook level (not + // just in render, after the early returns) so the park-timer cleanup can key on + // it: a pending park seek belongs to the OLD animation, so firing it after the + // active animation changed would jump the playhead onto a stale keyframe. + const animId = editableAnimationId(selectedGsapAnimations ?? [], geometry?.kind ?? "linear"); + // Clear the debounced park timer on unmount AND whenever the active animation id + // changes — not unmount-only, or a queued seek from the previous selection still + // fires against the new one. + useEffect(() => () => clearTimeout(parkTimerRef.current), [animId]); // Create mode: a selected element with no positional motion. A double-click on // the canvas authors a new motionPath from the element to that point. Gated on @@ -375,7 +383,6 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({ const scale = rect.width / compositionSize.width; const nodeR = NODE_PX / scale; - const animId = editableAnimationId(selectedGsapAnimations ?? [], geometry.kind); const interactive = Boolean(animId) && !isPlaying; // The × "quick remove" badge applies to non-cubic motionPath arcs only (cubic // anchors carry control points we don't synthesize; keyframe paths remove via diff --git a/packages/studio/src/components/editor/manualEditingAvailability.test.ts b/packages/studio/src/components/editor/manualEditingAvailability.test.ts index 4f86a20d96..4c2b57e3bd 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.test.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.test.ts @@ -24,11 +24,6 @@ describe("manual editing availability", () => { expect(availability.STUDIO_INSPECTOR_PANELS_ENABLED).toBe(true); }); - it("enables GSAP drag intercept by default", async () => { - const availability = await loadAvailabilityWithEnv({}); - expect(availability.STUDIO_GSAP_DRAG_INTERCEPT_ENABLED).toBe(true); - }); - it("keeps color grading off by default", async () => { const availability = await loadAvailabilityWithEnv({}); expect(availability.STUDIO_COLOR_GRADING_ENABLED).toBe(false); @@ -41,13 +36,6 @@ describe("manual editing availability", () => { expect(availability.STUDIO_COLOR_GRADING_ENABLED).toBe(true); }); - it("disables GSAP drag intercept when env var is false", async () => { - const availability = await loadAvailabilityWithEnv({ - VITE_STUDIO_ENABLE_GSAP_DRAG_INTERCEPT: "false", - }); - expect(availability.STUDIO_GSAP_DRAG_INTERCEPT_ENABLED).toBe(false); - }); - it("disables preview selection when the inspector panel flag is explicitly off", async () => { const availability = await loadAvailabilityWithEnv({ VITE_STUDIO_ENABLE_INSPECTOR_PANELS: "0", diff --git a/packages/studio/src/components/editor/manualEditingAvailability.ts b/packages/studio/src/components/editor/manualEditingAvailability.ts index fc2132aa82..020de0b853 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.ts @@ -92,16 +92,6 @@ export const STUDIO_STORYBOARD_ENABLED = resolveStudioBooleanEnvFlag( false, ); -// When disabled (the default), drag/resize/rotate commits always take the CSS -// persist path instead of being intercepted into GSAP script keyframe -// mutations. The keyframe intercept rewrites timeline tweens from drag -// gestures and is opt-in until its recording path is hardened. -export const STUDIO_GSAP_DRAG_INTERCEPT_ENABLED = resolveStudioBooleanEnvFlag( - env, - ["VITE_STUDIO_ENABLE_GSAP_DRAG_INTERCEPT", "VITE_STUDIO_GSAP_DRAG_INTERCEPT_ENABLED"], - true, -); - export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED; // Stage 7 Step 3c: SDK cutover — routes inline-style ops through SDK dispatch diff --git a/packages/studio/src/hooks/gsapDragCommit.ts b/packages/studio/src/hooks/gsapDragCommit.ts index 0b7ff5b728..b201d5f7a8 100644 --- a/packages/studio/src/hooks/gsapDragCommit.ts +++ b/packages/studio/src/hooks/gsapDragCommit.ts @@ -178,6 +178,57 @@ async function commitKeyframedPosition( } } +// Minimal GSAP runtime surface the start-value read needs — narrowed from the +// iframe window so the read path isn't a blanket `as any` ladder. +interface DragRuntimeGsap { + getProperty: (target: Element, key: string) => unknown; + set: (target: Element, vars: Record) => void; +} +interface DragRuntimeTimeline { + seek: (time: number) => void; +} +interface DragRuntime { + gsapLib: DragRuntimeGsap; + el: Element; + mainTl: DragRuntimeTimeline; +} + +/** + * Resolve the iframe's GSAP lib, the target element, and the main timeline for a + * drag start-value read. Returns null (caller falls back to identity values) + * when any piece is missing — a legitimate "runtime not ready" case, distinct + * from a read that throws mid-flight (which the caller surfaces). + */ +function resolveDragRuntime( + iframe: HTMLIFrameElement | null | undefined, + selector: string | undefined, +): DragRuntime | null { + if (!iframe || !selector) return null; + const win = iframe.contentWindow as + | (Window & { + gsap?: Partial; + __timelines?: Record>; + }) + | null; + const gsap = win?.gsap; + if (typeof gsap?.getProperty !== "function" || typeof gsap.set !== "function") return null; + let el: Element | null = null; + try { + el = iframe.contentDocument?.querySelector(selector) ?? null; + } catch { + return null; // cross-origin / detached document + } + if (!el) return null; + const timelines = win?.__timelines; + const mainTl = timelines ? Object.values(timelines)[0] : undefined; + if (typeof mainTl?.seek !== "function") return null; + return { + gsapLib: gsap as DragRuntimeGsap, + el, + mainTl: mainTl as DragRuntimeTimeline, + }; +} + /** * For flat to()/set() tweens, convert to keyframes first so we can place the * drag position at the current percentage. @@ -206,44 +257,48 @@ async function commitFlatViaKeyframes( // captures the actual interpolated value (e.g. x=300 after a preceding slide), // not the identity value (x=0) that a blind convert would produce. const resolvedFromValues: Record = {}; - if (iframe && selector && ts !== null) { + const runtime = resolveDragRuntime(iframe, selector); + if (runtime && ts !== null) { + const { gsapLib, el, mainTl } = runtime; + // Snapshot the live drag's gsap overrides BEFORE clearing them. The clear + // below is only needed to read the tween's start values cleanly; if the + // commit that follows later fails, we must put the dragged pose back so the + // element isn't left with its overrides cleared and nothing applied (a + // visible snap to the base pose with the drag silently lost). + const draggedValues: Record = {}; + for (const key of Object.keys(properties)) { + const v = Number(gsapLib.getProperty(el, key)); + if (Number.isFinite(v)) draggedValues[key] = v; + } try { - const iframeWin = iframe.contentWindow as any; - const gsapLib = iframeWin?.gsap; - const el = iframe.contentDocument?.querySelector(selector); - const timelines = iframeWin?.__timelines; - const mainTl = timelines ? (Object.values(timelines)[0] as any) : null; - if (gsapLib && el && mainTl?.seek) { - // Snapshot the live drag's gsap overrides BEFORE clearing them. The clear - // below is only needed to read the tween's start values cleanly; if the - // commit that follows later fails, we must put the dragged pose back so the - // element isn't left with its overrides cleared and nothing applied (a - // visible snap to the base pose with the drag silently lost). - const draggedValues: Record = {}; - for (const key of Object.keys(properties)) { - const v = Number(gsapLib.getProperty(el, key)); - if (Number.isFinite(v)) draggedValues[key] = v; - } - // Clear the live drag's gsap overrides first. Otherwise a property the - // tween doesn't animate (e.g. `y` on a flat `to({x})`) keeps the dragged - // value through the seek and pollutes the 0% keyframe (it would start at - // the dropped position instead of animating there). After clearing, the - // seek reapplies the timeline's real interpolated values for animated - // props, and untweened props fall back to their base (0). - gsapLib.set(el, { clearProps: Object.keys(properties).join(",") }); - mainTl.seek(ts); - for (const key of Object.keys(properties)) { - const v = Number(gsapLib.getProperty(el, key)); - if (Number.isFinite(v)) resolvedFromValues[key] = roundTo3(v); - } - mainTl.seek(ct); - // Re-apply the dragged overrides. On a successful commit the soft-reload - // re-seek overwrites these with the persisted keyframe values; on a failed - // commit they keep the element showing where the user dropped it. - if (Object.keys(draggedValues).length > 0) gsapLib.set(el, draggedValues); + // Clear the live drag's gsap overrides first. Otherwise a property the + // tween doesn't animate (e.g. `y` on a flat `to({x})`) keeps the dragged + // value through the seek and pollutes the 0% keyframe (it would start at + // the dropped position instead of animating there). After clearing, the + // seek reapplies the timeline's real interpolated values for animated + // props, and untweened props fall back to their base (0). + gsapLib.set(el, { clearProps: Object.keys(properties).join(",") }); + mainTl.seek(ts); + for (const key of Object.keys(properties)) { + const v = Number(gsapLib.getProperty(el, key)); + if (Number.isFinite(v)) resolvedFromValues[key] = roundTo3(v); } - } catch { - /* iframe access failed — fall back to identity values */ + mainTl.seek(ct); + } catch (err) { + // A read/seek failure here is NOT routine — it means resolvedFromValues is + // incomplete and the 0% keyframe would silently capture identity (x=0) + // instead of the real interpolated start. Drop the partial reads so the + // caller falls back to identity deliberately (not on a phantom value), and + // surface the failure rather than swallowing it. + console.warn("[gsap-drag] start-value read failed; using identity from values", err); + for (const key of Object.keys(resolvedFromValues)) delete resolvedFromValues[key]; + } finally { + // Re-apply the dragged overrides. On a successful commit the soft-reload + // re-seek overwrites these with the persisted keyframe values; on a failed + // commit they keep the element showing where the user dropped it. Runs even + // if the seek threw, so a partial clear doesn't leave the element snapped to + // its base pose with the drag lost. + if (Object.keys(draggedValues).length > 0) gsapLib.set(el, draggedValues); } } diff --git a/packages/studio/src/hooks/gsapTargetCache.ts b/packages/studio/src/hooks/gsapTargetCache.ts index 68d2c3bc2d..a5ba2b8387 100644 --- a/packages/studio/src/hooks/gsapTargetCache.ts +++ b/packages/studio/src/hooks/gsapTargetCache.ts @@ -1,5 +1,3 @@ -import { STUDIO_GSAP_DRAG_INTERCEPT_ENABLED } from "../components/editor/manualEditingAvailability"; - type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> }; let _gsapCachedTimelines: Record | undefined; @@ -50,7 +48,6 @@ export function isElementGsapTargeted( iframe: HTMLIFrameElement | null, element: HTMLElement, ): boolean { - if (!STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) return false; const timelines = readTimelines(iframe); if (!timelines) return false; diff --git a/packages/studio/src/hooks/useGestureCommit.ts b/packages/studio/src/hooks/useGestureCommit.ts index 775296feef..b039145044 100644 --- a/packages/studio/src/hooks/useGestureCommit.ts +++ b/packages/studio/src/hooks/useGestureCommit.ts @@ -11,6 +11,46 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { roundTo3 } from "../utils/rounding"; import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser"; +type RecordedKeyframe = { percentage: number; properties: Record }; + +/** + * Split recorded keyframes into one keyframe-set per property group (position / + * scale / rotation / …), each keyframe carrying only that group's props. + * + * A mixed-prop gesture (e.g. x/y + opacity) emitted as ONE add-with-keyframes + * mutation parses back as an untagged legacy mixed tween, which breaks the + * position-only drag intercept (it can't find a pure position tween to edit). + * Emitting one tween per group keeps the position tween tagged and editable. + * Keyframes with no prop in a group are dropped from that group's set. + */ +function partitionKeyframesByGroup(keyframes: RecordedKeyframe[]): RecordedKeyframe[][] { + // Preserve first-seen group order for deterministic, stable mutation ordering. + const groupOrder: string[] = []; + const byGroup = new Map(); + for (const kf of keyframes) { + const perGroup = new Map>(); + for (const [key, value] of Object.entries(kf.properties)) { + const group = classifyPropertyGroup(key); + let props = perGroup.get(group); + if (!props) { + props = {}; + perGroup.set(group, props); + } + props[key] = value; + } + for (const [group, props] of perGroup) { + let set = byGroup.get(group); + if (!set) { + set = []; + byGroup.set(group, set); + groupOrder.push(group); + } + set.push({ percentage: kf.percentage, properties: props }); + } + } + return groupOrder.map((group) => byGroup.get(group)!); +} + // Minimal subset of the session used by gesture commit interface GestureSessionRef { domEditSelection: DomEditSelection | null; @@ -184,29 +224,37 @@ export function useGestureCommit({ { label: "Gesture recording (merge)", softReload: true }, ); } else { - await liveSession.commitMutation( - { - type: "add-with-keyframes", - targetSelector: selector, - position: roundTo3(recStart), - duration: roundTo3(duration), - keyframes, - }, - { label: "Gesture recording (new range)", softReload: true }, - ); + // Emit one tween per property group so a mixed-prop gesture (e.g. + // x/y + opacity) doesn't collapse into an untagged legacy mixed + // tween that the position-only drag intercept can't edit. + for (const groupKfs of partitionKeyframesByGroup(keyframes)) { + await liveSession.commitMutation( + { + type: "add-with-keyframes", + targetSelector: selector, + position: roundTo3(recStart), + duration: roundTo3(duration), + keyframes: groupKfs, + }, + { label: "Gesture recording (new range)", softReload: true }, + ); + } } } } else { - await liveSession.commitMutation( - { - type: "add-with-keyframes", - targetSelector: selector, - position: roundTo3(recStart), - duration: roundTo3(duration), - keyframes, - }, - { label: "Gesture recording", softReload: true }, - ); + // No existing tween — same per-group split as the new-range branch above. + for (const groupKfs of partitionKeyframesByGroup(keyframes)) { + await liveSession.commitMutation( + { + type: "add-with-keyframes", + targetSelector: selector, + position: roundTo3(recStart), + duration: roundTo3(duration), + keyframes: groupKfs, + }, + { label: "Gesture recording", softReload: true }, + ); + } } } showToast(`Recorded ${sortedPcts.length} keyframes`, "info"); diff --git a/packages/studio/src/hooks/useGsapAwareEditing.ts b/packages/studio/src/hooks/useGsapAwareEditing.ts index 18aa84ca20..cc037b525f 100644 --- a/packages/studio/src/hooks/useGsapAwareEditing.ts +++ b/packages/studio/src/hooks/useGsapAwareEditing.ts @@ -10,8 +10,6 @@ import { useCallback } from "react"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; -import { STUDIO_GSAP_DRAG_INTERCEPT_ENABLED } from "../components/editor/manualEditingAvailability"; -import { GSAP_CSS_FALLBACK_BLOCKED_MESSAGE } from "./useDomGeometryCommits"; import { tryGsapDragIntercept, tryGsapResizeIntercept, @@ -94,12 +92,7 @@ export function useGsapAwareEditing({ const handleGsapAwarePathOffsetCommit = useCallback( async (selection: DomEditSelection, next: { x: number; y: number }) => { - const hasGsapAnims = selectedGsapAnimations.length > 0; - if (hasGsapAnims && !STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) { - showToast(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE, "error"); - throw new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE); - } - if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) { + if (gsapCommitMutation) { try { // The GSAP timeline is the single source of truth for element position — // for the top-level composition AND subcompositions. tryGsapDragIntercept @@ -126,13 +119,12 @@ export function useGsapAwareEditing({ previewIframeRef, makeFetchFallback, trackGsapInteractionFailure, - showToast, ], ); const handleGsapAwareBoxSizeCommit = useCallback( async (selection: DomEditSelection, next: { width: number; height: number }) => { - if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) { + if (gsapCommitMutation) { try { const handled = await tryGsapResizeIntercept( selection, @@ -162,7 +154,7 @@ export function useGsapAwareEditing({ const handleGsapAwareRotationCommit = useCallback( async (selection: DomEditSelection, next: { angle: number }) => { - if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) { + if (gsapCommitMutation) { try { // Single source of truth for rotation too: tryGsapRotationIntercept handles // tweened elements (keyframes) and static ones (a tl.set), so there's no diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 422b8621b5..6752351f4f 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -84,7 +84,9 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra options.beforeReload?.(); let applied: "soft" | "full" = "full"; if (options.softReload && result.scriptText) { - applied = applySoftReload(previewIframeRef.current, result.scriptText) ? "soft" : "full"; + applied = applySoftReload(previewIframeRef.current, result.scriptText, reloadPreview) + ? "soft" + : "full"; if (applied === "full") reloadPreview(); } else { reloadPreview(); @@ -112,7 +114,9 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra const sdkRefresh = useCallback( (after: string) => { const script = extractGsapScriptText(after); - if (!(script && applySoftReload(previewIframeRef.current, script))) reloadPreview(); + if (!(script && applySoftReload(previewIframeRef.current, script, reloadPreview))) { + reloadPreview(); + } onCacheInvalidate(); }, [previewIframeRef, reloadPreview, onCacheInvalidate], diff --git a/packages/studio/src/utils/gsapSoftReload.ts b/packages/studio/src/utils/gsapSoftReload.ts index e0b7a80965..321510a6dc 100644 --- a/packages/studio/src/utils/gsapSoftReload.ts +++ b/packages/studio/src/utils/gsapSoftReload.ts @@ -4,6 +4,10 @@ type IframeWindow = Window & { __hfForceTimelineRebind?: () => void; __hfSuppressSceneMutations?: (fn: () => T) => T; __hfStudioManualEditsApply?: () => void; + // Set while a MotionPathPlugin