Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 0 additions & 29 deletions packages/studio/src/components/editor/gsapAnimatesProperty.ts
Original file line number Diff line number Diff line change
@@ -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`.
Expand Down
47 changes: 46 additions & 1 deletion packages/studio/src/components/editor/gsapLivePreview.test.ts
Original file line number Diff line number Diff line change
@@ -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 =
'<div data-composition-id="strip"><div id="card" data-hf-id="hf-inner"></div></div>' +
Expand Down Expand Up @@ -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 = '<div id="card"></div>';
const card = document.querySelector<HTMLElement>("#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);
},
);
3 changes: 3 additions & 0 deletions packages/studio/src/components/editor/propertyPanelHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
playbackStartAttributeForElement,
readPlaybackStartAttributes,
} from "../../player/lib/timelineElementHelpers";
import { GSAP_TRANSFORM_KEYS, gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";

export type {
BackgroundRemovalProgress,
Expand Down Expand Up @@ -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<string, number> = {};
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);
}
Expand Down
6 changes: 6 additions & 0 deletions packages/studio/src/hooks/gsapRuntimeKeyframes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, RuntimeTimeline> };
Expand Down
Loading