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`.
diff --git a/packages/studio/src/components/editor/gsapLivePreview.test.ts b/packages/studio/src/components/editor/gsapLivePreview.test.ts
index 60e6485d98..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 =
'
' +
@@ -61,3 +66,43 @@ 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"]],
+ ["fades it about its corner", { opacity: 0, transformOrigin: "0 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);
+ },
+);
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..466f078afd 100644
--- a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts
+++ b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts
@@ -419,6 +419,12 @@ 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,skewX,skewY,transformOrigin,svgOrigin,force3D,smoothOrigin,transformPerspective,translateX,translateY,translateZ,rotate,rotationZ,rotateZ,rotateX,rotateY".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 };