From 37ac1380418961f4bee0c582cb585d2b28796d46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 25 Jun 2026 18:54:06 -0400 Subject: [PATCH] feat(studio): draggable 3D-transform cube in the design panel (#1710) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(studio): draggable 3D-transform cube in the design panel Add a Figma-style draggable cube to the 3D Transform section so users can set an element's 3D orientation by dragging instead of typing degrees. Drag tilts the element (rotationX/Y); Shift-drag rolls it (rotationZ); a recenter button resets the 3D transform to identity. The cube previews the orientation live and commits on release. It's an input affordance over the existing keyframe-aware commit path (commitAnimatedProperty) — a drag at the playhead writes/updates keyframes just like the numeric fields, no new mutation infra. - transform3dProjection.ts: pure unit-cube projection with back-face culling and painter ordering (no 3D dependency), unit-tested. - Transform3DCube.tsx: the SVG drag widget (pointer-capture, draft→commit). - Surface the two missing numeric fields (RotZ, Perspective). Perspective drives the new editable `transformPerspective` prop (per-element depth) rather than CSS `perspective` (which only affects children). * feat(studio): polish 3D cube — collapsed by default, compact lit cube, live drag preview Address review of the first cut: - 3D Transform section is now collapsible and collapsed by default (it was tall and ate panel space). - Redesign the cube: compact and centered (was full-width), resting isometric camera so it reads as a 3D cube at identity instead of a flat square, directional per-face lighting, gradient backdrop + grounding shadow. - Live element preview while dragging: onLivePreviewProps gsap.sets the live transform on the preview element so it moves WITH the cube; release still commits via the keyframe-aware path. - Extract Cube3dControl to keep the panel component under the complexity gate. * fix(studio): persist static 3D transform + refine cube edges The cube (and the RotX/RotY numeric fields) didn't stick on an element whose only tween is a position 'set' — commitAnimatedProperty tried to convert the zero-duration hold into keyframes, so the rotation was never written and the cube snapped back. Handle the static-set case: merge the property into the set (update-property) so a static 3D rotation/perspective persists, and the cube reads it back from runtime. Also refine the cube rendering: muted teal lit faces with edges that brighten with how front-facing each face is (crisp bevels, not flat neon outlines), a soft halo glow, and a stronger grounding shadow. * feat(studio): 3D transform — keyframe diamonds, flash-free commits, in-cube perspective - Keyframe diamonds: RotX/RotY/RotZ + Perspective (and Z/Scale) now each carry a KeyframeNavigation diamond, so 3D transforms can be keyframed like Layout X/Y. Refactored the six fields onto a shared Transform3dField. - Flash-free: static-set 3D commits now use instantPatch (in-place runtime patch, no soft reload), and the set fast-path was widened to the 3D channels (rotationX/Y/Z, z, transformPerspective) — dragging the cube / scrubbing a 3D field no longer flashes. - In-cube perspective: a Persp slider lives in the cube widget and the cube's foreshortening reflects transformPerspective live. * feat(studio): 3D cube X/Y/Z axis gizmo + gated flash-diagnostic logs - Axis gizmo: render the rotated X (red) / Y (green) / Z (blue) vectors from the cube center — away-facing axes dimmed behind the cube, toward-facing on top with a tip dot + label — so orientation is readable at a glance. - Flash diagnostics: add a gated, JSON-stringified [hf-3d:*] logger (on in dev or via window.__hfDebug). Instruments the commit path (which branch + picked tween), the cube pose/axis commits, and — the key signal — applyPreviewSync's instant-patch-vs-soft-reload decision (a soft reload IS the flash). Reproduce with the console open to pinpoint any remaining flash to a specific commit. * fix(studio): make the 3D cube mirror the element's orientation 1:1 The resting isometric camera made the cube always look tilted, so at rotation 0/0/0 the cube showed a 3D pose while the element was flat — the cube didn't represent the element. Drop the decorative camera (VIEW_RX/RY = 0): the cube now faces front at identity, exactly matching the un-rotated element, and tilts to match as the element rotates. The X/Y/Z axis gizmo keeps the flat-at-rest state readable. Flash status (from the gated [hf-3d:*] logs): every commit now reports 'instant (no flash)' via instantPatch — the soft-reload flashes are resolved. * fix(studio): stop design-panel flicker — read transform channels live Each 3D commit bumps the gsap cache; the panel then re-read runtime values, but readGsapRuntimeValuesForPanel only included props already present in the parsed gsapAnimations. A just-set rotationX isn't in the parse yet, so for that window the cube + fields dropped it and flickered to 0. Always read the core transform channels (x/y/rotation/rotationX/Y/Z/z/scale/transformPerspective/opacity) directly via gsap.getProperty — which reflects the in-place instant patch — so the panel shows the true current value with no flicker. * refactor(studio): extract collectPanelPropKeys to keep panel reader under complexity gate * feat(studio): keyframable 3D transforms — convert a static set to keyframes The cube/3D fields stored rotation as a static 'set', and convert-to-keyframes flatly refused to convert a set (gsapParser.ts) — so two 3D 'keyframes' just overwrote the same static value with no interpolation. Now a set converts to an animatable to(): resolveConversionProps emits both endpoints from the set's value (visual unchanged until edited), and both writers flip set→to, drop the immediateRender hold, and add a duration. The element's clip duration is threaded through the convert chain (3D field → handler → convertToKeyframes → route → parser) so the keyframes span the whole clip and land in range at any playhead. Click a 3D field's diamond to convert, then edit at different playheads to animate. Acorn writer mirrored; recast round-trip test added. * feat(studio): keyframe toggle on the 3D cube The cube had no keyframe affordance, so dragging it only ever wrote the static set (logs showed every rotation commit as path:static-set) and nothing interpolated — converting required clicking a numeric field's diamond, which isn't discoverable while driving the cube. Add a keyframe diamond button to the cube widget: it converts the 3D ('other'-group) static set to keyframes spanning the element's clip, and lights up when the transform is already keyframed. Once keyframed, cube drags + numeric edits add keyframes at the playhead and the 3D rotation interpolates. * feat(studio): auto-keyframe 3D transforms on animated elements + stop AssetsTab 404 loop 3D transforms now auto-keyframe like drag/resize/rotate: when the element is already animated (its clip has keyframes), editing a 3D prop converts the static set to keyframes so edits at other playheads interpolate — no manual keyframe toggle needed. Purely static elements still write a static set (and the cube's keyframe button remains a manual opt-in for them). Also fix the AssetsTab media-manifest fetch: it was keyed on the assets array reference (new each render) so it re-fetched the (usually missing) manifest on every re-render — spamming 404s and churning the left sidebar during cube drags. Key on a stable join and cache the 404 so a missing manifest is fetched once. * fix(studio): cube writes one keyframe per drag (no duplicate keyframes) The cube committed rotationX/Y/Z as separate add-keyframe mutations; the first axis's auto-keyframe convert shifted the tween so the second axis computed a slightly different percentage → two adjacent keyframes instead of one. Add a batched commitAnimatedProperties that writes all changed props into ONE keyframe, and route the cube through it (commitAnimatedProperty is now a thin single-prop wrapper). Threaded through the panel chain; numeric fields keep the single-prop path. Set-path and keyframe-path extracted to helpers to stay under the complexity gate. * refactor(studio): extract AudioRow from AssetsTab to satisfy file-size check The manifest-404 fix touched AssetsTab.tsx, which was already over the 600-line cap (702). Move the self-contained AudioRow sub-component to its own file, bringing AssetsTab to 493 lines. * fix(studio): self-heal stale animationId on 3D property commit A 3D property edit (cube drag / field) picks its target from the panel's selectedGsapAnimations cache. When keyframes were just removed or the script changed underneath, that id is gone server-side and the commit POST 404s ('animation not found'). The raw commitMutation already toasts but rethrows, so the rejection escaped as an uncaught promise. Catch it in commitAnimatedProperties and bump the cache so the panel re-syncs and the next edit self-heals. * fix(studio): batch the 3D reset into one commit (was six flashes) Reset 3D orientation looped six props (rotationX/Y/Z, z, scale, transformPerspective) through the single-property commit, so one click triggered six separate soft-reloads — six preview flashes. Batch them into one onCommitAnimatedProperties call (one keyframe, one reload), matching the cube-drag path. * fix(studio): 3D-edit a static element writes a set, not keyframes Editing the 3D transform of an element with no keyframes created a keyframed tween (Case 3 made a tl.to() + convert, a flat tween converted to keyframes). A static element should stay static — same as manual drag / resize / rotate, which tl.set() it. Route no-keyframe elements to a set: update an existing one in place, or create a dedicated tl.set carrying all axes in ONE add mutation. The single mutation also avoids the per-axis id race (a flat tween's group-derived id shifts after the first prop, 404-ing the next and polluting an unrelated tween). * feat(studio): instant 3D keyframe edits via in-place tween rebuild Dragging the cube on an animated element soft-reloaded the iframe on every edit (a flash). GSAP compiles object-form keyframes ({ "0%": {...} }) into sub-tweens at creation and ignores later vars.keyframes mutations, so the value can't be patched the way a tl.set can. Instead REBUILD the tween in place: kill it and recreate it on the same parent timeline at the same position with the edited keyframe merged and all other vars preserved, then re-seek — no iframe reload, no flash. Resolution is now channel-aware for keyframe tweens too, so a rotation edit lands on the rotation tween, never a co-located position tween. Declines (→ soft reload) for array-form, motionPath, or dynamic values. * feat(studio): static 3D transform persists as off-timeline gsap.set (no 0% keyframe) Adjusting a 3D transform on an element with no keyframes created a tl.set(...,0), which the timeline renders as a 0% keyframe diamond — even though it's a static hold, not animated. Persist a newly-created static 3D hold as a base gsap.set(...) instead: it runs immediately, sits OFF the timeline, and shows no keyframe marker (matching the manual-drag UX). - Model: GsapAnimation.global marks a base gsap.set vs an on-timeline tl.set. - Parser (recast + acorn): parse a STRING-LITERAL gsap.set("#sel", {...}) as an editable global set so it round-trips and re-edits in place; variable-target gsap.set(el, ...) holds stay opaque surrounding source (unchanged). - Serializer + writers: emit gsap.set(sel, props) (no timeline var, no position) when global; in-place updates keep it a gsap.set. - add mutation gains global; commitStaticSet sends it when creating a holder. * fix(studio): static manual drag persists as off-timeline gsap.set, instant (no flash/diamond) After keyframes are removed, manually dragging a now-static element wrote a tl.set(...,0) — an on-timeline hold that shows a 0% keyframe diamond and soft-reloaded on the first nudge (a flash/teleport between the overlay and the committed position). Make the static position/rotation drag persist as a base gsap.set (off-timeline, no marker), like the 3D path. A gsap.set has no runtime tween to patch, so add a 'global-set' instant-patch that applies the value straight to the element (gsap.set(el, props)) — the element is static on these channels, so it reflects instantly with no soft reload. Existing tl.set holds keep the tween 'set' patch; only global sets use global-set. Create now carries the instant patch too, so the first nudge is flash-free. * fix(studio): a base gsap.set shows no keyframe diamond (timeline + panel) A base gsap.set is parsed as an editable set (for idempotent re-edits), but synthesizeFlatTweenKeyframes turned it into a synthetic 0% keyframe, so the timeline track and the panel field showed a phantom keyframe diamond for a static, non-animated value. Return null for a global set so it contributes no keyframes — it's an off-timeline static hold, not a keyframe. * fix(studio): a static set never shows a keyframe diamond (timeline + panel) A set (gsap.set OR tl.set) is a static hold — a value applied at one point, not an animated keyframe — so it must not synthesize a phantom keyframe. The prior fix only skipped GLOBAL gsap.set; on-timeline tl.set holds (and ones a split/conversion produced) still showed a diamond. Skip every set, which also aligns the AST keyframe cache with the runtime scan (it already drops every zero-duration set). * fix(studio): batch set-property edits (reset 3D no longer 404s) Reset 3D fires 6 props (rotationX/Y/Z, z, scale, perspective) at a set; commitSetProps updated them one at a time. A set's id is GROUP-derived, so the moment scale lands on a rotation set its id shifts (-other -> mixed), 404-ing the next prop (perspective never got set). Add an update-properties mutation (merge many props in one call) and have commitSetProps/commitStaticSet use it — one round-trip, no mid-loop id shift. * style(studio): fix format + trim 3D-patch helper complexity oxfmt the runtime-patch file (the failing Format/Preflight check) and reduce the complexity of the new helpers: flatten keyframeVarsCarryChannel with .some, extract finiteNumericProps from applyGlobalSet, suppress the inherently-defensive rebuildKeyframeTween guard chain. * chore(studio): remove [hf-3d:*] debug logs (3D transform verified working) Strip the log3d call sites + the debug3d util now that the 3D transform / static-set / keyframe-rebuild paths are confirmed working. * chore(studio): strategic [hf-pos:*] logs for position-commit path audit Temporary DEV-gated logs to confirm which path each drag takes: single drag → GSAP code path (single-gsap), multi-select/group drag → DEPRECATED CSS-var path (group-css, applyStudioPathOffset → --hf-studio-offset), and the single CSS fallback (single-css). To be removed once group drag is routed through GSAP. * fix(studio): route multi-select group drag through GSAP code path Group drag committed positions via the deprecated --hf-studio-offset CSS var (applyStudioPathOffset) and outright blocked GSAP-animated elements. Single drag already routes through tryGsapDragIntercept (tl.set / keyframes / gsap.set); group drag now does the same per element, so a multi-select move writes real GSAP code with no CSS-var fallback. Removed the now-dead CSS group commit. * feat(studio): live candidate highlight while marquee-selecting The marquee only revealed what it selected on mouse-up, so it was easy to grab too much or too little. Now each element the marquee box currently intersects is outlined live (studio-accent) as you drag, before release — so you can see the selection forming. Shares one synchronous OBB/SAT intersection pass between the live highlight and the commit; the async source-probe still runs only once, on mouse-up. * chore(studio): remove temporary [hf-pos:*] position-path debug logs Investigation done — group drag now routes through the GSAP code path, so the CSS-vs-GSAP path-audit scaffolding (logPos / debugPos) is no longer needed. Removes the util and its imports/calls. * fix(studio): marquee selects/highlights elements at their real positions The marquee derived element boxes from elementObbCorners, whose non-identity-transform branch reconstructed the box from offsetLeft/offsetTop plus the element's own transform matrix — ignoring the matrix translate (m.e/m.f) and any ancestor transforms. Mid-GSAP-animation (elements carry a translate() transform), that put boxes at their pre-translate layout position, so the marquee highlighted/selected the wrong elements vs. the box shown when you click an element directly. Route the marquee through the same toOverlayRect basis the selection and group boxes use (a getBoundingClientRect-based AABB). Now highlight == selection-commit == the click-selection box, at the element's real on-screen position. Drops the buggy OBB/SAT path (elementObbCorners, marqueeIntersectsObb); AABB matches the selection box, which never rotated. Adds dev-only [hf-marquee:*] tracing (per-element rect + intersect + skip reason, JSON) to debug what the marquee sees; stripped from prod builds. * fix(studio): off-canvas elements no longer render a selection-style border OffCanvasIndicators drew two layers per partly-off-screen element: a dashed sliver on the protruding part, plus a solid studio-accent border (with the selection box-shadow) over the on-canvas portion. That solid border only ever draws for UNSELECTED elements (selected ones get a real selection box via the filter), so an unselected off-canvas element looked selected. Removed the solid inside layer — the dashed protruding sliver stays as the off-canvas hint. * chore(studio): remove [hf-marquee:*] debug logs Marquee position fix is verified; strip the dev-only tracing scaffolding (logMarquee/debugLabel/debug param) back to the lean intersection loop. * fix(studio): convert a global gsap.set to a seekable timeline tween + review cleanups Primary fix: converting a global `gsap.set` to keyframes flipped only the method (set->to), leaving the callee object `gsap` — emitting `gsap.to(...)`, an off-timeline tween that fires once at load and isn't on the paused master `window.__timelines` (the engine can't seek/render it). Reachable from the cube's keyframe toggle + maybeAutoKeyframeSet on the global sets commitStaticSet creates. Now re-roots onto the timeline var and adds the position arg, in both the recast and acorn writers; covered by a convert test seeded from gsap.set in each path. Review cleanups: drop dead confirmDelete/ in AudioRow; drop the always-zero viewRx/viewRy camera params from the 3D projection; un-export four internal-only symbols (clears fallow unused-exports); re-add the collectMarqueeHits complexity suppression dropped with the debug scaffolding. * chore(studio): green the CI gate + 3D panel expanded by default - File-size: extract the marquee/candidate render into MarqueeOverlay so DomEditOverlay drops back under the 600-line cap. - Fallow complexity: suppress the 8 accepted-complexity findings from the 3D/ runtime work (resolveRuntimeTween, readRuntimeKeyframes, hasNonHoldTweenForElement, commitKeyframeProps, scored, ImageCard, selectionShapeStyles, off-canvas effect) with the bare directive the linter recognizes. - 3D transform panel now defaults to expanded (the cube gizmo is the headline). --- packages/core/src/parsers/gsapConstants.ts | 1 + packages/core/src/parsers/gsapParser.test.ts | 95 +++++ packages/core/src/parsers/gsapParser.ts | 53 ++- packages/core/src/parsers/gsapParserAcorn.ts | 18 +- packages/core/src/parsers/gsapSerialize.ts | 16 +- .../core/src/parsers/gsapWriter.acorn.test.ts | 20 + packages/core/src/parsers/gsapWriterAcorn.ts | 45 +- packages/core/src/runtime/init.test.ts | 43 ++ packages/core/src/runtime/init.ts | 83 +++- packages/core/src/studio-api/routes/files.ts | 30 ++ .../src/components/StudioRightPanel.tsx | 6 +- .../src/components/editor/DomEditOverlay.tsx | 16 +- .../src/components/editor/MarqueeOverlay.tsx | 40 ++ .../components/editor/OffCanvasIndicators.tsx | 9 +- .../src/components/editor/PropertyPanel.tsx | 12 + .../src/components/editor/Transform3DCube.tsx | 313 ++++++++++++++ .../editor/gsapAnimationConstants.ts | 5 + .../src/components/editor/marqueeCommit.ts | 83 +++- .../editor/propertyPanel3dTransform.tsx | 403 ++++++++++++++---- .../components/editor/propertyPanelHelpers.ts | 64 ++- .../editor/transform3dProjection.test.ts | 99 +++++ .../editor/transform3dProjection.ts | 172 ++++++++ .../src/components/sidebar/AssetsTab.tsx | 236 +--------- .../src/components/sidebar/AudioRow.tsx | 202 +++++++++ .../studio/src/contexts/DomEditContext.tsx | 4 + .../studio/src/hooks/gsapDragCommit.test.ts | 14 +- packages/studio/src/hooks/gsapDragCommit.ts | 37 +- .../studio/src/hooks/gsapRuntimeKeyframes.ts | 55 ++- packages/studio/src/hooks/gsapRuntimePatch.ts | 195 +++++++-- .../src/hooks/useAnimatedPropertyCommit.ts | 336 +++++++++++---- .../studio/src/hooks/useDomEditCommits.ts | 2 - .../studio/src/hooks/useDomEditSession.ts | 6 +- .../studio/src/hooks/useDomGeometryCommits.ts | 34 +- .../studio/src/hooks/useGsapAwareEditing.ts | 32 +- .../studio/src/hooks/useGsapKeyframeOps.ts | 5 +- .../src/hooks/useGsapSelectionHandlers.ts | 21 +- .../studio/src/hooks/useGsapTweenCache.ts | 10 +- .../studio/src/utils/marqueeGeometry.test.ts | 113 +---- packages/studio/src/utils/marqueeGeometry.ts | 159 +------ 39 files changed, 2269 insertions(+), 818 deletions(-) create mode 100644 packages/studio/src/components/editor/MarqueeOverlay.tsx create mode 100644 packages/studio/src/components/editor/Transform3DCube.tsx create mode 100644 packages/studio/src/components/editor/transform3dProjection.test.ts create mode 100644 packages/studio/src/components/editor/transform3dProjection.ts create mode 100644 packages/studio/src/components/sidebar/AudioRow.tsx diff --git a/packages/core/src/parsers/gsapConstants.ts b/packages/core/src/parsers/gsapConstants.ts index 5976e4b66..8bea681c7 100644 --- a/packages/core/src/parsers/gsapConstants.ts +++ b/packages/core/src/parsers/gsapConstants.ts @@ -21,6 +21,7 @@ export const SUPPORTED_PROPS = [ "rotationY", "rotationZ", "perspective", + "transformPerspective", "transformOrigin", // Visibility "opacity", diff --git a/packages/core/src/parsers/gsapParser.test.ts b/packages/core/src/parsers/gsapParser.test.ts index 71a5b3899..1be171227 100644 --- a/packages/core/src/parsers/gsapParser.test.ts +++ b/packages/core/src/parsers/gsapParser.test.ts @@ -578,6 +578,47 @@ describe("stagger/yoyo/repeat round-trip", () => { expect(updatedScript).toContain("opacity: 0.5"); }); + it("converts a static set into a keyframed to() with a duration (keyframable 3D)", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.set("#card", { rotationX: 50, rotationY: 20, immediateRender: true }, 0); + `; + const parsed = parseGsapScript(script); + const animId = parsed.animations[0].id; + const result = convertToKeyframesInScript(script, animId, undefined, 4); + // Flips set → to, drops the hold marker, gains a duration + keyframes. + expect(result).toContain('tl.to("#card"'); + expect(result).not.toContain("immediateRender"); + expect(result).toContain("duration: 4"); + expect(result).toContain("keyframes:"); + // Both endpoints start at the set's value (visual unchanged until edited). + const reparsed = parseGsapScript(result).animations[0]; + expect(reparsed.keyframes).toBeTruthy(); + expect(reparsed.keyframes!.keyframes[0]!.properties.rotationX).toBe(50); + expect(reparsed.keyframes!.keyframes.at(-1)!.properties.rotationX).toBe(50); + }); + + it("converts a GLOBAL gsap.set into a timeline-rooted to() (seekable, not gsap.to)", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + gsap.set("#card", { rotationX: 50, rotationY: 20 }); + `; + const parsed = parseGsapScript(script); + const animId = parsed.animations[0].id; + expect(parsed.animations[0].global).toBe(true); + const result = convertToKeyframesInScript(script, animId, undefined, 4); + // Must re-root onto the master timeline (tl.to), NOT emit an off-timeline + // gsap.to that fires once at load and can't be seeked/rendered. + expect(result).toMatch(/tl\.to\(\s*"#card"/); + expect(result).not.toMatch(/gsap\.to\(/); + expect(result).toContain("duration: 4"); + expect(result).toContain("keyframes:"); + // Re-parsed tween is a real timeline keyframe tween, no longer global. + const reparsed = parseGsapScript(result).animations[0]; + expect(reparsed.keyframes).toBeTruthy(); + expect(reparsed.global).toBeFalsy(); + }); + it("apply-to-all (resetKeyframeEases) sets easeEach and strips every per-keyframe ease", () => { const script = ` const tl = gsap.timeline({ paused: true }); @@ -2812,3 +2853,57 @@ tl.to("#el", { y: 50, duration: 1 }, "+=0.5");`; expect(parsed.animations[1].position).toBe("+=0.5"); }); }); + +describe("base gsap.set (off-timeline global hold)", () => { + const SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + gsap.set("#box", { rotationX: 17, rotationY: 93 }); + tl.to("#box", { x: 260, duration: 1 }, 0.3); + window.__timelines = { main: tl }; + `; + + it("parses a string-literal gsap.set as a global set animation", () => { + const anims = parseGsapScript(SCRIPT).animations.filter((a) => a.targetSelector === "#box"); + const set = anims.find((a) => a.method === "set"); + expect(set?.global).toBe(true); + expect(set?.properties).toEqual({ rotationX: 17, rotationY: 93 }); + expect(anims.find((a) => a.method === "to")?.global).toBeUndefined(); + }); + + it("creates a base gsap.set (not tl.set) when global is set", () => { + const base = `const tl = gsap.timeline({ paused: true });\ntl.to("#box", { x: 1, duration: 1 }, 0);\nwindow.__timelines = { main: tl };`; + const { script } = addAnimationToScript(base, { + targetSelector: "#box", + method: "set", + position: 0, + properties: { rotationX: 30 }, + global: true, + }); + expect(script).toContain('gsap.set("#box"'); + expect(script).not.toContain('tl.set("#box"'); + }); + + it("updates a global set in place, keeping it gsap.set", () => { + const set = parseGsapScript(SCRIPT).animations.find( + (a) => a.targetSelector === "#box" && a.method === "set", + )!; + const updated = updateAnimationInScript(SCRIPT, set.id, { + properties: { rotationX: 99, rotationY: 93 }, + }); + expect(updated).toContain('gsap.set("#box"'); + expect(updated).toContain("99"); + expect(updated).not.toContain('tl.set("#box"'); + }); + + it("leaves a VARIABLE-target gsap.set as surrounding source (not parsed)", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + const el = document.querySelector("#box"); + gsap.set(el, { rotationX: 5 }); + tl.to("#box", { x: 10, duration: 1 }, 0); + window.__timelines = { main: tl }; + `; + const sets = parseGsapScript(script).animations.filter((a) => a.method === "set"); + expect(sets).toHaveLength(0); + }); +}); diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts index e6941f2f6..927f59e55 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -440,6 +440,8 @@ interface TweenCallInfo { varsArg: AstNode; fromArg?: AstNode; positionArg?: AstNode; + /** True for a base `gsap.set(...)` (off-timeline) rather than `tl.set(...)`. */ + global?: boolean; } /** @@ -465,10 +467,24 @@ function findAllTweenCalls( visitCallExpression(path: AstPath) { const node = path.node; const callee = node.callee; + // A base `gsap.set("#sel", props)` is an off-timeline static hold (no position, + // no keyframe marker). Treat it as an editable `set` animation so a static + // value (e.g. a 3D transform) round-trips and re-edits in place. Restricted to + // a STRING-LITERAL selector: variable-target `gsap.set(el, ...)` holds stay + // opaque surrounding source (editing them by selector would be ambiguous). + const gsapSetArg = node.arguments?.[0]; + const isGlobalSet = + callee?.type === "MemberExpression" && + callee.object?.type === "Identifier" && + callee.object.name === "gsap" && + callee.property?.type === "Identifier" && + callee.property.name === "set" && + (gsapSetArg?.type === "StringLiteral" || + (gsapSetArg?.type === "Literal" && typeof gsapSetArg.value === "string")); if ( callee?.type === "MemberExpression" && callee.property?.type === "Identifier" && - isTimelineRootedCall(node, timelineVar) + (isTimelineRootedCall(node, timelineVar) || isGlobalSet) ) { const method = callee.property.name; if (!GSAP_METHODS.has(method)) { @@ -501,6 +517,7 @@ function findAllTweenCalls( selector: selectorValue, varsArg: args[1], positionArg: args[2], + ...(isGlobalSet ? { global: true } : {}), }); } } @@ -968,6 +985,7 @@ function tweenCallToAnimation( group = classifyTweenPropertyGroup(kfProps); } if (group) anim.propertyGroup = group; + if (call.global) anim.global = true; if (Object.keys(extras).length > 0) anim.extras = extras; if (keyframesData) anim.keyframes = keyframesData; if (motionPathResult) anim.arcPath = motionPathResult.arcPath; @@ -1306,8 +1324,9 @@ function buildTweenStatementCode(timelineVar: string, anim: Omit `${safeKey(k)}: ${valueToCode(v)}`); // immediateRender forces GSAP to apply the set when added to the timeline, // not on the first seek — without it, tl.set at position 0 on a paused - // timeline is invisible until the playhead moves past 0. - if (anim.method === "set") entries.push("immediateRender: true"); + // timeline is invisible until the playhead moves past 0. A base `gsap.set` + // already runs immediately, so it doesn't need (or get) the flag. + if (anim.method === "set" && !anim.global) entries.push("immediateRender: true"); if (anim.extras) { for (const [k, v] of Object.entries(anim.extras)) { entries.push(`${safeKey(k)}: ${valueToCode(v as number | string)}`); @@ -1324,6 +1343,10 @@ function buildTweenStatementCode(timelineVar: string, anim: Omit, + setDuration = 1, ): string { let loc = locateAnimationWithFallback(script, animationId); if (!loc) return script; const anim = loc.target.animation; - if (anim.keyframes || anim.method === "set") return script; + if (anim.keyframes) return script; const { fromProps, toProps } = resolveConversionProps(anim, resolvedFromValues); const varsArg = loc.target.call.varsArg; @@ -2326,6 +2350,27 @@ export function convertToKeyframesInScript( if (anim.method === "fromTo") loc.target.call.node.arguments.splice(1, 1); } + // A static `set` becomes an animatable `to`: flip the method, drop the + // immediateRender hold marker, and give it a real duration so the keyframes + // span time. This is what makes a static 3D transform keyframeable. + if (anim.method === "set") { + // A GLOBAL `gsap.set(...)` is off-timeline; flipping only the method would + // emit `gsap.to(...)`, which fires once at load and is NOT on the paused + // master timeline (the engine can't seek/render it). Re-root it onto the + // timeline var and add the position arg (a gsap.set has none) so the + // converted tween is seekable. A `tl.set` already has the right object. + const calleeObj = loc.target.call.node.callee.object; + if (anim.global && calleeObj?.type === "Identifier") { + calleeObj.name = loc.parsed.timelineVar; + if (loc.target.call.node.arguments.length < 3) { + loc.target.call.node.arguments.push(parseExpr("0")); + } + } + loc.target.call.node.callee.property.name = "to"; + removeVarsKey(varsArg, "immediateRender"); + setVarsKey(varsArg, "duration", Math.max(0.001, setDuration)); + } + return recast.print(loc.parsed.ast).code; } diff --git a/packages/core/src/parsers/gsapParserAcorn.ts b/packages/core/src/parsers/gsapParserAcorn.ts index 34dadaeb1..7775d3763 100644 --- a/packages/core/src/parsers/gsapParserAcorn.ts +++ b/packages/core/src/parsers/gsapParserAcorn.ts @@ -443,6 +443,8 @@ export interface TweenCallInfo { varsArg: any; fromArg?: any; positionArg?: any; + /** True for a base `gsap.set(...)` (off-timeline) rather than `tl.set(...)`. */ + global?: boolean; } /** True when callee chain is rooted at the timeline variable. */ @@ -477,10 +479,22 @@ function findAllTweenCalls( // Fire BEFORE children (pre-order) so chained outer calls come first. if (node.type === "CallExpression") { const callee = node.callee; + // A base `gsap.set("#sel", props)` is an off-timeline static hold — parse it as + // an editable global `set` so a static value round-trips and re-edits in place. + // STRING-LITERAL selectors only: variable-target holds stay surrounding source. + const gsapSetArg = node.arguments?.[0]; + const isGlobalSet = + callee?.type === "MemberExpression" && + callee.object?.type === "Identifier" && + callee.object.name === "gsap" && + callee.property?.type === "Identifier" && + callee.property.name === "set" && + (gsapSetArg?.type === "StringLiteral" || + (gsapSetArg?.type === "Literal" && typeof gsapSetArg.value === "string")); if ( callee?.type === "MemberExpression" && callee.property?.type === "Identifier" && - isTimelineRootedCall(node, timelineVar) && + (isTimelineRootedCall(node, timelineVar) || isGlobalSet) && GSAP_METHODS.has(callee.property.name) ) { const method = callee.property.name; @@ -509,6 +523,7 @@ function findAllTweenCalls( selector: selectorValue, varsArg: args[1], positionArg: args[2], + ...(isGlobalSet ? { global: true } : {}), }); } } @@ -923,6 +938,7 @@ function tweenCallToAnimation( group = classifyTweenPropertyGroup(kfProps); } if (group) anim.propertyGroup = group; + if (call.global) anim.global = true; if (Object.keys(extras).length > 0) anim.extras = extras; if (keyframesData) anim.keyframes = keyframesData; if (motionPathResult) anim.arcPath = motionPathResult.arcPath; diff --git a/packages/core/src/parsers/gsapSerialize.ts b/packages/core/src/parsers/gsapSerialize.ts index af11a1b81..c5832c715 100644 --- a/packages/core/src/parsers/gsapSerialize.ts +++ b/packages/core/src/parsers/gsapSerialize.ts @@ -73,6 +73,11 @@ export interface GsapAnimation { /** Which property group this tween belongs to (position, scale, size, rotation, visual, other). * Undefined for legacy mixed tweens that bundle multiple groups. */ propertyGroup?: PropertyGroupName; + /** True for a base `gsap.set(...)` (a static hold that runs immediately, OFF the + * timeline) rather than `tl.set(...)`. Carries no timeline position and shows no + * keyframe marker — used to persist a static value (e.g. a 3D transform) without + * introducing a 0% keyframe. */ + global?: boolean; /** How this tween was constructed in source. Absent ⇒ literal. */ provenance?: GsapProvenance; } @@ -202,7 +207,10 @@ export function serializeGsapAnimations( const posStr = typeof anim.position === "string" ? `"${anim.position}"` : anim.position; switch (anim.method) { case "set": - return ` ${timelineVar}.set(${selector}, ${propsStr}, ${posStr});`; + // A global set is a base `gsap.set` — off the timeline, no position arg. + return anim.global + ? ` gsap.set(${selector}, ${propsStr});` + : ` ${timelineVar}.set(${selector}, ${propsStr}, ${posStr});`; case "to": return ` ${timelineVar}.to(${selector}, ${propsStr}, ${posStr});`; case "from": @@ -476,6 +484,12 @@ export function resolveConversionProps( anim: GsapAnimation, resolvedFromValues?: Record, ): { fromProps: Record; toProps: Record } { + if (anim.method === "set") { + // A static hold becomes a keyframed `to` whose 0% and 100% both start at the + // set's value — the visual is unchanged until the user edits a keyframe to + // animate it. (The caller flips the call from `set` to `to` + adds a duration.) + return { fromProps: { ...anim.properties }, toProps: { ...anim.properties } }; + } if (anim.method === "to") { const identity = buildIdentityMap(anim.properties); const fromProps = resolvedFromValues ? { ...identity, ...resolvedFromValues } : identity; diff --git a/packages/core/src/parsers/gsapWriter.acorn.test.ts b/packages/core/src/parsers/gsapWriter.acorn.test.ts index c2b520e29..1c40a9364 100644 --- a/packages/core/src/parsers/gsapWriter.acorn.test.ts +++ b/packages/core/src/parsers/gsapWriter.acorn.test.ts @@ -9,6 +9,7 @@ import { describe, expect, it } from "vitest"; import { addAnimationToScript, addKeyframeToScript, + convertToKeyframesFromScript, removeAnimationFromScript, removeKeyframeFromScript, updateAnimationInScript, @@ -347,3 +348,22 @@ describe("T6c — keyframe write ops", () => { expect(result).toBe(SCRIPT_D); }); }); + +describe("T6c — convertToKeyframesFromScript: global gsap.set", () => { + const SCRIPT_GLOBAL_SET = `\ +var tl = gsap.timeline({ paused: true }); +gsap.set("#card", { rotationX: 50, rotationY: 20 }); +window.__timelines["t"] = tl;`; + + it("re-roots a global gsap.set onto the timeline (tl.to + position), not gsap.to", () => { + const animId = parseGsapScript(SCRIPT_GLOBAL_SET).animations[0].id; + const result = convertToKeyframesFromScript(SCRIPT_GLOBAL_SET, animId, undefined, 4); + // Off-timeline gsap.to would fire once at load and be unseekable; must be tl.to. + expect(result).toMatch(/tl\.to\(\s*"#card"/); + expect(result).not.toMatch(/gsap\.to\(/); + expect(result).toContain("keyframes:"); + const reparsed = parseGsapScript(result).animations[0]; + expect(reparsed.keyframes).toBeTruthy(); + expect(reparsed.global).toBeFalsy(); + }); +}); diff --git a/packages/core/src/parsers/gsapWriterAcorn.ts b/packages/core/src/parsers/gsapWriterAcorn.ts index 9a7245c38..3d9bf7002 100644 --- a/packages/core/src/parsers/gsapWriterAcorn.ts +++ b/packages/core/src/parsers/gsapWriterAcorn.ts @@ -72,6 +72,10 @@ function buildTweenStatementCode(timelineVar: string, anim: Omit, varsNode: Node, source: string, + setDuration?: number, ): string { const fromEntries = Object.entries(fromProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); const toEntries = Object.entries(toProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); @@ -1215,7 +1220,14 @@ function buildKeyframesVarsCode( // Preserve every non-editable key (duration/delay/callbacks/stagger/yoyo/…) // verbatim from source — rebuilding from the animation object alone dropped // `delay` (not a GsapAnimation field), shifting the tween's start time. - const parts: string[] = [`keyframes: ${kfCode}`, ...preservedVarsEntries(varsNode, source)]; + let preserved = preservedVarsEntries(varsNode, source); + // Converting a static `set` → drop its hold markers and give it a real duration + // so the keyframes span time. + if (setDuration !== undefined) { + preserved = preserved.filter((e) => !/^\s*(immediateRender|data|duration)\s*:/.test(e)); + } + const parts: string[] = [`keyframes: ${kfCode}`, ...preserved]; + if (setDuration !== undefined) parts.push(`duration: ${Math.max(0.001, setDuration)}`); if (animation.ease) parts.push(`ease: "none"`); return `{ ${parts.join(", ")} }`; } @@ -1229,18 +1241,36 @@ export function convertToKeyframesFromScript( script: string, animationId: string, resolvedFromValues?: Record, + setDuration = 1, ): string { const parsed = parseGsapScriptAcornForWrite(script); if (!parsed) return script; const target = parsed.located.find((l) => l.id === animationId); if (!target) return script; const { animation, call } = target; - if (animation.keyframes || call.method === "set") return script; + if (animation.keyframes) return script; + const isSet = call.method === "set"; const { fromProps, toProps } = resolveConversionProps(animation, resolvedFromValues); const ms = new MagicString(script); - if (call.method === "from" || call.method === "fromTo") { + // A GLOBAL `gsap.set(...)` is off-timeline; rewriting only the method emits + // `gsap.to(...)`, which fires once at load and isn't on the paused master + // timeline (the engine can't seek/render it). Re-root onto the timeline var + // and add the position arg the set lacks so the converted tween is seekable. + if (isSet && animation.global) { + const calleeObj = call.node.callee.object; + if (calleeObj?.type === "Identifier") { + ms.overwrite(calleeObj.start, calleeObj.end, parsed.timelineVar); + } + const args = call.node.arguments; + if (args.length > 0 && args.length < 3) { + ms.appendLeft(args[args.length - 1].end, ", 0"); + } + } + + // set/from/fromTo all become `to`; fromTo also drops its `from` argument. + if (call.method === "from" || call.method === "fromTo" || isSet) { ms.overwrite(call.node.callee.property.start, call.node.callee.property.end, "to"); } if (call.method === "fromTo" && call.fromArg) { @@ -1249,7 +1279,14 @@ export function convertToKeyframesFromScript( overwriteVarsArg( ms, call, - buildKeyframesVarsCode(animation, fromProps, toProps, call.varsArg, script), + buildKeyframesVarsCode( + animation, + fromProps, + toProps, + call.varsArg, + script, + isSet ? setDuration : undefined, + ), ); return ms.toString(); diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 215c5cada..1b272e794 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -371,6 +371,49 @@ describe("initSandboxRuntimeModular", () => { expect(window.__player?.getDuration()).toBe(12); }); + // #6: a single timeline registered under a key that does NOT match the root's + // data-composition-id must still bind (sole-timeline fallback) instead of + // silently rendering the frozen t=0 DOM. + it("binds the sole registered timeline when its key does not match the root id", () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-root", "true"); + root.setAttribute("data-start", "0"); + root.setAttribute("data-width", "1920"); + root.setAttribute("data-height", "1080"); + document.body.appendChild(root); + + // Registered under "wrong-key", not "main". + window.__timelines = { + "wrong-key": createMockTimeline(7), + }; + + initSandboxRuntimeModular(); + + expect(window.__player?.getDuration()).toBe(7); + }); + + // #6: when the root id is missing AND two timelines are registered, the + // fallback is ambiguous, so nothing is bound (the loud warning fires instead). + it("does not bind any timeline when the root id is unmatched and multiple are registered", () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-root", "true"); + root.setAttribute("data-start", "0"); + root.setAttribute("data-width", "1920"); + root.setAttribute("data-height", "1080"); + document.body.appendChild(root); + + window.__timelines = { + "wrong-key-a": createMockTimeline(7), + "wrong-key-b": createMockTimeline(9), + }; + + initSandboxRuntimeModular(); + + expect(window.__player?.getDuration()).toBe(0); + }); + it("pauses nested media that is outside the timed-media cache after a seek", () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 599e89c31..1c5e5f82a 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -639,6 +639,29 @@ export function initSandboxRuntimeModular(): void { const resolveRootTimelineFromDocument = (): TimelineResolution => { const timelines = (window.__timelines ?? {}) as Record; + // DX fallback (#6): when the root timeline cannot be resolved by id but + // EXACTLY ONE usable timeline is registered, bind it rather than silently + // rendering the frozen t=0 DOM. Safe because with a single registered + // timeline there is no ambiguity about which one is the composition's + // root. Multiple registered → ambiguous, so we still return null and let + // the loud warning fire. + const resolveSoleTimelineFallback = (reason: string): TimelineResolution => { + const usable = Object.entries(timelines).filter( + (entry): entry is [string, RuntimeTimelineLike] => + !!entry[1] && typeof entry[1].play === "function" && typeof entry[1].pause === "function", + ); + if (usable.length !== 1) return { timeline: null }; + const [soleId, soleTimeline] = usable[0]; + return { + timeline: soleTimeline, + selectedTimelineIds: [soleId], + selectedDurationSeconds: getTimelineDurationSeconds(soleTimeline), + diagnostics: { + code: "root_timeline_sole_registered_fallback", + details: { reason, soleTimelineId: soleId }, + }, + }; + }; const startResolver = createRuntimeStartTimeResolver({ timelineRegistry: timelines, includeAuthoredTimingAttrs: true, @@ -740,7 +763,7 @@ export function initSandboxRuntimeModular(): void { const rootCompositionNode = resolveRootCompositionElement(); const rootCompositionId = rootCompositionNode?.getAttribute("data-composition-id") ?? null; if (!rootCompositionId) { - return { timeline: null }; + return resolveSoleTimelineFallback("root_missing_composition_id"); } const rootTimeline = timelines[rootCompositionId] ?? null; const collectRootChildCandidates = (): Array<{ @@ -1003,7 +1026,7 @@ export function initSandboxRuntimeModular(): void { }; } } - return { timeline: null }; + return resolveSoleTimelineFallback("root_composition_id_unmatched_in_registry"); }; // Track whether child composition timelines have been added to the root. @@ -2102,6 +2125,39 @@ export function initSandboxRuntimeModular(): void { clock.setDuration(boundDuration); } runAdapters("discover", state.currentTime); + // Loud, specific diagnostic for the #1 "looks fine, ships broken" trap: + // a root timeline never bound even though timelines ARE registered. Without + // this the render silently proceeds on the static build-time DOM (frozen at + // t=0). Only warn when GSAP timelines exist (CSS/WAAPI/Lottie-only + // compositions legitimately bind no GSAP timeline and use adapters). + if (!state.capturedTimeline) { + const registry = (window.__timelines ?? {}) as Record; + const registeredKeys = Object.keys(registry).filter((k) => registry[k]); + if (registeredKeys.length > 0) { + const rootEl = resolveRootCompositionElement(); + const rootCompositionId = rootEl?.getAttribute("data-composition-id") ?? null; + postRuntimeDiagnosticOnce( + "root_timeline_unbound_registry_present", + { + reason: rootCompositionId + ? "root data-composition-id has no matching key in window.__timelines" + : "root composition element has no data-composition-id attribute", + rootCompositionId, + registeredTimelineKeys: registeredKeys, + }, + "root_timeline_unbound_registry_present", + ); + // eslint-disable-next-line no-console -- loud author-facing warning; this render would otherwise freeze at t=0 + console.warn( + `[hyperframes] Root timeline not bound — render will freeze at t=0. ` + + (rootCompositionId + ? `Root data-composition-id is "${rootCompositionId}" but window.__timelines has no such key. ` + : `Root composition element has no data-composition-id. `) + + `Registered timeline keys: [${registeredKeys.join(", ")}]. ` + + `Register the root timeline under its data-composition-id (window.__timelines["${rootCompositionId ?? ""}"] = tl).`, + ); + } + } // __renderReady = timeline binding attempted, safe for deterministic seeking. // Set after any GSAP batching has completed. renderSeek works with or // without a GSAP timeline (CSS/WAAPI/Lottie compositions use adapters only). @@ -2232,11 +2288,30 @@ export function initSandboxRuntimeModular(): void { if (opts?.activateChildren) { activateSiblingTimelines(tl); } + // #10: when data-duration exceeds the timeline's intrinsic length the + // engine requests frames past the last tween. Seeking a paused GSAP + // timeline past its end can revert from()-tweens to their empty initial + // state, blanking the final poster. Clamp the MASTER seek to the + // timeline's full extent so it holds the final computed frame instead. + // Adapters still receive the raw `t` (their media may run longer). + // totalDuration() includes repeats; Infinity (infinite repeat) → no clamp. + const tlWithTotal = tl as RuntimeTimelineLike & { totalDuration?: () => number }; + let tlSeekTime = t; + if (typeof tlWithTotal.totalDuration === "function") { + try { + const total = Number(tlWithTotal.totalDuration()); + if (Number.isFinite(total) && total > 0 && t > total) { + tlSeekTime = total; + } + } catch (err) { + swallow("runtime.init.transport.clampDuration", err); + } + } try { if (typeof tl.totalTime === "function") { - tl.totalTime(t, false); + tl.totalTime(tlSeekTime, false); } else { - tl.seek(t, false); + tl.seek(tlSeekTime, false); } } catch (err) { swallow("runtime.init.transport.seek", err); diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index df5cde235..08d4fdd20 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/routes/files.ts @@ -428,6 +428,14 @@ type GsapMutationRequest = property: string; value: number | string; } + | { + // Merge MULTIPLE properties into an animation in ONE call. A per-property + // loop on a `set` can shift its group-derived id mid-way (e.g. adding `scale` + // to a rotation set), 404-ing the next update; this lands them all at once. + type: "update-properties"; + animationId: string; + properties: Record; + } | { type: "update-from-property"; animationId: string; @@ -454,6 +462,8 @@ type GsapMutationRequest = ease?: string; properties: Record; fromProperties?: Record; + /** Emit a base `gsap.set` (off-timeline, no keyframe marker) instead of `tl.set`. */ + global?: boolean; } | { type: "delete"; animationId: string; stripStudioEdits?: boolean } | { @@ -490,6 +500,8 @@ type GsapMutationRequest = type: "convert-to-keyframes"; animationId: string; resolvedFromValues?: Record; + /** Duration (s) to give a converted static `set`, which has none. */ + duration?: number; } | { type: "remove-all-keyframes"; animationId: string } | { @@ -697,6 +709,13 @@ function executeGsapMutationAcorn( properties: { ...r.anim.properties, [body.property]: val }, }); } + case "update-properties": { + const r = requireAnimation(block.scriptText, body.animationId); + if ("err" in r) return r.err; + return updateAnimationInScript(block.scriptText, body.animationId, { + properties: { ...r.anim.properties, ...body.properties }, + }); + } case "update-from-property": case "add-from-property": { const r = requireFromToAnimation(block.scriptText, body.animationId); @@ -721,6 +740,7 @@ function executeGsapMutationAcorn( ease: body.ease, properties: body.properties, fromProperties: body.fromProperties, + ...(body.global ? { global: true } : {}), }); return result.script; } @@ -788,6 +808,7 @@ function executeGsapMutationAcorn( block.scriptText, body.animationId, body.resolvedFromValues, + body.duration, ); } case "remove-all-keyframes": { @@ -979,6 +1000,13 @@ async function executeGsapMutationRecast( properties: { ...r.anim.properties, [body.property]: val }, }); } + case "update-properties": { + const r = requireAnimation(block.scriptText, body.animationId); + if ("err" in r) return r.err; + return updateAnimationInScript(block.scriptText, body.animationId, { + properties: { ...r.anim.properties, ...body.properties }, + }); + } case "update-from-property": case "add-from-property": { const r = requireFromToAnimation(block.scriptText, body.animationId); @@ -1014,6 +1042,7 @@ async function executeGsapMutationRecast( ease: body.ease, properties: body.properties, fromProperties: body.fromProperties, + ...(body.global ? { global: true } : {}), }); return result.script; } @@ -1081,6 +1110,7 @@ async function executeGsapMutationRecast( block.scriptText, body.animationId, body.resolvedFromValues, + body.duration, ); } case "remove-all-keyframes": { diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index aa0f02834..07df9b84f 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -118,6 +118,7 @@ export function StudioRightPanel({ handleGsapAddFromProperty, handleGsapRemoveFromProperty, commitAnimatedProperty, + commitAnimatedProperties, handleSetArcPath, handleUpdateArcSegment, handleUnroll, @@ -269,9 +270,12 @@ export function StudioRightPanel({ onRemoveGsapFromProperty={handleGsapRemoveFromProperty} onAddGsapAnimation={handleGsapAddAnimation} onCommitAnimatedProperty={commitAnimatedProperty} + onCommitAnimatedProperties={commitAnimatedProperties} onAddKeyframe={handleGsapAddKeyframe} onRemoveKeyframe={handleGsapRemoveKeyframe} - onConvertToKeyframes={handleGsapConvertToKeyframes} + onConvertToKeyframes={(animId, duration) => + handleGsapConvertToKeyframes(animId, undefined, duration) + } onSeekToTime={(t) => usePlayerStore.getState().requestSeek(t)} onSetArcPath={handleSetArcPath} onUpdateArcSegment={handleUpdateArcSegment} diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index 188d474dd..bc1394afa 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -2,6 +2,7 @@ import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "reac import { useMountEffect } from "../../hooks/useMountEffect"; import { type DomEditSelection } from "./domEditing"; import { useMarqueeGestures } from "./marqueeCommit"; +import { MarqueeOverlay } from "./MarqueeOverlay"; import { resolveDomEditGroupOverlayRect, toOverlayRect } from "./domEditOverlayGeometry"; import { collectDomEditLayerItems } from "./domEditingLayers"; import { isElementComputedVisible } from "./domEditingElement"; @@ -101,6 +102,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({ const onMarqueeSelectRef = useRef(onMarqueeSelect); onMarqueeSelectRef.current = onMarqueeSelect; + // fallow-ignore-next-line complexity const selectionShapeStyles = (() => { const fallback = { borderRadius: 8 as string | number, @@ -226,6 +228,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({ // outside the composition bounds so users can find them. const offCanvasElementsRef = useRef>(new Map()); const [offCanvasRects, setOffCanvasRects] = useState([]); + // fallow-ignore-next-line complexity useEffect(() => { const iframe = iframeRef.current; const overlay = overlayRef.current; @@ -568,18 +571,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({ activeCompositionPathRef={activeCompositionPathRef} onSelectionChangeRef={onSelectionChangeRef} /> - {marquee.marqueeRect && ( -