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 };