From 4e3c2cc40ea13adbfe1a805e4f5b5a2c3d5db824 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 30 Sep 2026 20:44:04 -0700 Subject: [PATCH 1/4] fix(studio): the properties panel no longer hands GSAP an element it does not position --- .../components/editor/gsapLivePreview.test.ts | 41 +++++++++++++++++++ .../components/editor/propertyPanelHelpers.ts | 3 ++ .../studio/src/hooks/gsapRuntimeKeyframes.ts | 8 ++++ 3 files changed, 52 insertions(+) diff --git a/packages/studio/src/components/editor/gsapLivePreview.test.ts b/packages/studio/src/components/editor/gsapLivePreview.test.ts index 60e6485d98..3b51ec7035 100644 --- a/packages/studio/src/components/editor/gsapLivePreview.test.ts +++ b/packages/studio/src/components/editor/gsapLivePreview.test.ts @@ -61,3 +61,44 @@ it("the panel reads GSAP values off the node the live preview moves", () => { }); expect(getProperty.mock.calls[0]?.[0]).toBe(root); }); + +it.each([ + ["fades it", { opacity: 0 }, ["opacity"]], + [ + "moves it", + { x: 100 }, + [ + "x", + "y", + "rotation", + "rotationX", + "rotationY", + "rotationZ", + "z", + "scale", + "transformPerspective", + "opacity", + ], + ], +])( + "the panel reads GSAP's transform only off an element GSAP positions: a tween that %s", + (_, vars, read) => { + document.body.innerHTML = '
'; + const card = document.querySelector("#card"); + const tween = { targets: () => [card], vars, duration: () => 1 }; + const getProperty = vi.fn(() => 1); + const contentWindow = Object.assign(window, { + gsap: { getProperty }, + __timelines: { main: { getChildren: () => [tween] } }, + }); + const selection = { id: "card", sourceFile: "index.html", element: card } as DomEditSelection; + const animations = [{ properties: vars }] as unknown as GsapAnimation[]; + readGsapRuntimeValuesForPanel("anim", animations, selection, { + current: { contentWindow, contentDocument: document } as unknown as HTMLIFrameElement, + }); + const props = getProperty.mock.calls.map((call) => (call as unknown[])[1]); + expect(props).toEqual(read); + delete (window as { gsap?: unknown }).gsap; + delete (window as { __timelines?: unknown }).__timelines; + }, +); diff --git a/packages/studio/src/components/editor/propertyPanelHelpers.ts b/packages/studio/src/components/editor/propertyPanelHelpers.ts index 8dcbd8014d..c97ee0b3bd 100644 --- a/packages/studio/src/components/editor/propertyPanelHelpers.ts +++ b/packages/studio/src/components/editor/propertyPanelHelpers.ts @@ -9,6 +9,7 @@ import { playbackStartAttributeForElement, readPlaybackStartAttributes, } from "../../player/lib/timelineElementHelpers"; +import { GSAP_TRANSFORM_KEYS, gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes"; export type { BackgroundRemovalProgress, @@ -496,8 +497,10 @@ export function readGsapRuntimeValuesForPanel( const el = findPreviewNode(iframe.contentDocument, element); if (!el) return null; const propKeys = collectPanelPropKeys(gsapAnimations); + const readsTransform = gsapWritesPosition(el); const result: Record = {}; for (const prop of propKeys) { + if (!readsTransform && GSAP_TRANSFORM_KEYS.has(prop)) continue; const v = Number(gsap.getProperty(el, prop)); if (Number.isFinite(v)) result[prop] = roundToCenti(v); } diff --git a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts index 09b1a2c089..1569340108 100644 --- a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts +++ b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts @@ -419,6 +419,14 @@ export const POSITION_CHANNELS: string[] = [ ]; const MOVE_CHANNELS = [...POSITION_CHANNELS, "motionPath"]; +/** CSSPlugin's transform channels. Reading one folds the element's CSS translate, rotate and + * scale into GSAP's own transform. */ +export const GSAP_TRANSFORM_KEYS = new Set( + "x,y,z,scale,scaleX,scaleY,xPercent,yPercent,rotation,rotationX,rotationY,rotationZ,rotate,rotateX,rotateY,rotateZ,skewX,skewY,translateX,translateY,translateZ,transformPerspective".split( + ",", + ), +); + /** Whether a live timeline tween or hold writes any of `channels` on `el`. Sync, no fetch. */ function gsapWritesChannels(el: Element, channels: string[]): boolean { const win = el.ownerDocument.defaultView as { __timelines?: Record }; From 8740a069d73c9c0aa71ba1f5e2e427f4b646db8d Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 30 Sep 2026 21:26:51 -0700 Subject: [PATCH 2/4] refactor(studio): drop the comment the comment-share ratchet counts --- packages/studio/src/hooks/gsapRuntimeKeyframes.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts index 1569340108..dd84f3ef44 100644 --- a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts +++ b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts @@ -419,8 +419,6 @@ export const POSITION_CHANNELS: string[] = [ ]; const MOVE_CHANNELS = [...POSITION_CHANNELS, "motionPath"]; -/** CSSPlugin's transform channels. Reading one folds the element's CSS translate, rotate and - * scale into GSAP's own transform. */ export const GSAP_TRANSFORM_KEYS = new Set( "x,y,z,scale,scaleX,scaleY,xPercent,yPercent,rotation,rotationX,rotationY,rotationZ,rotate,rotateX,rotateY,rotateZ,skewX,skewY,translateX,translateY,translateZ,transformPerspective".split( ",", From 405134cc627f21b526ae039e71a0cfc251de9aab Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 30 Sep 2026 22:01:45 -0700 Subject: [PATCH 3/4] fix(studio): the panel also skips GSAP's transform-origin channels on an element GSAP does not position --- .../src/components/editor/gsapLivePreview.test.ts | 10 +++++++--- packages/studio/src/hooks/gsapRuntimeKeyframes.ts | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/studio/src/components/editor/gsapLivePreview.test.ts b/packages/studio/src/components/editor/gsapLivePreview.test.ts index 3b51ec7035..fef0fe4587 100644 --- a/packages/studio/src/components/editor/gsapLivePreview.test.ts +++ b/packages/studio/src/components/editor/gsapLivePreview.test.ts @@ -1,10 +1,15 @@ // @vitest-environment happy-dom -import { expect, it, vi } from "vitest"; +import { afterEach, expect, it, vi } from "vitest"; import { createGsapLivePreview } from "./gsapLivePreview"; import type { DomEditSelection } from "./domEditingTypes"; import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser"; import { readGsapRuntimeValuesForPanel } from "./propertyPanelHelpers"; +afterEach(() => { + delete (window as { gsap?: unknown }).gsap; + delete (window as { __timelines?: unknown }).__timelines; +}); + it("previews on the selected element, not an earlier same-id copy in a sub-composition", () => { document.body.innerHTML = '
' + @@ -64,6 +69,7 @@ it("the panel reads GSAP values off the node the live preview moves", () => { it.each([ ["fades it", { opacity: 0 }, ["opacity"]], + ["fades it about its corner", { opacity: 0, transformOrigin: "0 0" }, ["opacity"]], [ "moves it", { x: 100 }, @@ -98,7 +104,5 @@ it.each([ }); const props = getProperty.mock.calls.map((call) => (call as unknown[])[1]); expect(props).toEqual(read); - delete (window as { gsap?: unknown }).gsap; - delete (window as { __timelines?: unknown }).__timelines; }, ); diff --git a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts index dd84f3ef44..466f078afd 100644 --- a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts +++ b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts @@ -420,7 +420,7 @@ export const POSITION_CHANNELS: string[] = [ const MOVE_CHANNELS = [...POSITION_CHANNELS, "motionPath"]; export const GSAP_TRANSFORM_KEYS = new Set( - "x,y,z,scale,scaleX,scaleY,xPercent,yPercent,rotation,rotationX,rotationY,rotationZ,rotate,rotateX,rotateY,rotateZ,skewX,skewY,translateX,translateY,translateZ,transformPerspective".split( + "x,y,z,scale,scaleX,scaleY,xPercent,yPercent,rotation,rotationX,rotationY,skewX,skewY,transformOrigin,svgOrigin,force3D,smoothOrigin,transformPerspective,translateX,translateY,translateZ,rotate,rotationZ,rotateZ,rotateX,rotateY".split( ",", ), ); From 0657fe2789f8a48fc3a82295fc198b5bc894035d Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 30 Sep 2026 22:16:33 -0700 Subject: [PATCH 4/4] refactor(studio): delete the unused second list of GSAP transform channels --- .../components/editor/gsapAnimatesProperty.ts | 29 ------------------- 1 file changed, 29 deletions(-) diff --git a/packages/studio/src/components/editor/gsapAnimatesProperty.ts b/packages/studio/src/components/editor/gsapAnimatesProperty.ts index 9485c410c0..54e09dae70 100644 --- a/packages/studio/src/components/editor/gsapAnimatesProperty.ts +++ b/packages/studio/src/components/editor/gsapAnimatesProperty.ts @@ -1,32 +1,3 @@ -// GSAP's CSSPlugin takes ownership of the element's entire transform stack -// when it tweens ANY of these — it bakes the CSS `translate` longhand into -// style.transform at init and writes `translate: none` every tick. Position -// reapply/strip logic must therefore stand down for all of them, not just x/y. -const GSAP_TRANSFORM_PROPS = [ - "x", - "y", - "xPercent", - "yPercent", - "scale", - "scaleX", - "scaleY", - "rotation", - "rotate", - "rotationX", - "rotationY", - "skewX", - "skewY", - "transform", -]; - -/** - * True when GSAP animates any transform-affecting property on the element, - * meaning GSAP owns `style.transform` and has neutralized CSS `translate`. - */ -export function gsapAnimatesTransform(el: HTMLElement): boolean { - return gsapAnimatesProperty(el, ...GSAP_TRANSFORM_PROPS); -} - /** * Checks whether GSAP actively animates one or more CSS/GSAP properties on * the given element by inspecting all registered `__timelines`.