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
90 changes: 90 additions & 0 deletions packages/core/src/runtime/adapters/gsap.steps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,96 @@ describe("gsap adapter on a step", () => {
});

describe("gsap adapter at a tween's start", () => {
// Issue #5122: a render seeks every frame in order; only the from-vars set transformOrigin.
it("keeps a fromTo's from-only values from the frame it starts on, with immediateRender off", () => {
const box = document.body.appendChild(document.createElement("div"));
const timeline = gsap.timeline({ paused: true });
timeline.fromTo(
box,
{ scale: 1.5, transformOrigin: "0% 0%" },
{ scale: 1, duration: 0.45, ease: "none", immediateRender: false },
0.1,
);
timeline.set({}, {}, 1);
const adapter = createGsapAdapter({
getTimeline: () => timeline as unknown as RuntimeTimelineLike,
});
const origins = [2, 3, 4, 5].map((frame) => {
adapter.seek({ time: frame / 30 });
return box.style.transformOrigin;
});
expect(origins).toEqual(["", "0% 0%", "0% 0%", "0% 0%"]);
expect(gsap.getProperty(box, "scaleX")).toBeCloseTo(1.4259, 3);
});

it("keeps a keyframed fromTo's from-only values over an earlier tween's on the same element", () => {
const box = document.body.appendChild(document.createElement("div"));
const timeline = gsap.timeline({ paused: true });
const fromTo = (origin: string, to: gsap.TweenVars, at: number) =>
timeline.fromTo(
box,
{ scale: 1.5, transformOrigin: origin },
{ scale: 1, immediateRender: false, ...to },
at,
);
fromTo("0% 0%", { duration: 0.1 }, 0);
fromTo("100% 100%", { duration: 0.1 }, 0.1);
fromTo("0% 0%", { keyframes: { y: [0, 40] }, duration: 0.5 }, 0.2);
timeline.set({}, {}, 1);
const adapter = createGsapAdapter({
getTimeline: () => timeline as unknown as RuntimeTimelineLike,
});
const origins = [0, 2 / 30, 0.1, 4 / 30, 0.2, 7 / 30].map((time) => {
adapter.seek({ time });
return box.style.transformOrigin;
});
expect(origins.slice(1)).toEqual(["0% 0%", "100% 100%", "100% 100%", "0% 0%", "0% 0%"]);
});

it("keeps each staggered target's from-only values from the frame it starts on", () => {
const film = () => {
const boxes = [0, 1, 2].map(() => document.body.appendChild(document.createElement("div")));
const timeline = gsap.timeline({ paused: true });
timeline.fromTo(
boxes,
{ scale: 1.5, transformOrigin: "0% 0%" },
{ scale: 1, duration: 0.3, stagger: 0.1, ease: "none", immediateRender: false },
0.1,
);
timeline.set({}, {}, 1);
return { boxes, timeline };
};
const played = film();
const seeked = film();
const adapter = createGsapAdapter({
getTimeline: () => seeked.timeline as unknown as RuntimeTimelineLike,
});
for (let frame = 0; frame <= 12; frame++) {
played.timeline.totalTime(frame / 30, false);
adapter.seek({ time: frame / 30 });
const origins = (film: { boxes: HTMLElement[] }) =>
film.boxes.map((box) => box.style.transformOrigin);
expect(origins(seeked), `frame ${frame}`).toEqual(origins(played));
}
});

it("still lets a set authored later on the tween's start win over its from-values", () => {
const box = document.body.appendChild(document.createElement("div"));
const timeline = gsap.timeline({ paused: true });
timeline.fromTo(
box,
{ opacity: 0, transformOrigin: "0% 0%" },
{ opacity: 1, duration: 0.5, immediateRender: false },
0.1,
);
timeline.set(box, { opacity: 0.3, transformOrigin: "100% 100%" }, 0.1);
timeline.set({}, {}, 1);
createGsapAdapter({ getTimeline: () => timeline as unknown as RuntimeTimelineLike }).seek({
time: 0.1,
});
expect([box.style.opacity, box.style.transformOrigin]).toEqual(["0.3", "100% 100%"]);
});

// An edit at 2 s turns the later tween into keyframes; its first keyframe must win over the from() end.
it.each([0, 1, 2, 2.5, 3])(
"shows the tween that starts at the seek time, seeking from %s s",
Expand Down
28 changes: 20 additions & 8 deletions packages/core/src/runtime/adapters/gsap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ type GsapAdapterDeps = {

/**
* Re-renders a timeline already at `t`, silently, from just below (above at 0) so same-time steps apply in authored
* order. That step skips a keyframed tween already at its start, so each one is first moved across its start alone.
* order. That step skips a keyframed tween at its start and undoes a tween's from-values there, so both are redone.
*/
export function rerenderGsapTimelineAt(
timeline: {
Expand All @@ -33,7 +33,7 @@ export function rerenderGsapTimelineAt(
for (const [child] of skipped) child._ts = 0;
try {
timeline.totalTime(t >= 0.001 ? t - 0.001 : t + 0.001, true);
primeKeyframedTweensStartingAt(children, t);
primeTweensStartingAt(children, t);
timeline.totalTime(t, true);
} finally {
for (const [child, timeScale] of skipped) child._ts = timeScale;
Expand Down Expand Up @@ -96,7 +96,11 @@ type GsapAnimation = {
paused: () => boolean;
render: (totalTime: number, suppressEvents: boolean) => unknown;
vars?: { keyframes?: unknown };
_startAt?:
| 0
| { render: (totalTime: number, suppressEvents: boolean, force: boolean) => unknown };
getChildren?: (nested: boolean, tweens: boolean, timelines: boolean) => unknown[];
timeline?: Pick<GsapAnimation, "getChildren">;
};

const BELOW_GSAP_TIME_RESOLUTION = 2e-8;
Expand All @@ -113,14 +117,22 @@ const playsForward = (value: unknown): value is GsapAnimation => {
);
};

function primeKeyframedTweensStartingAt(children: unknown[], time: number): void {
function primedAtItsStart(tween: GsapAnimation): boolean {
if (tween.vars?.keyframes) {
tween.render(BELOW_GSAP_TIME_RESOLUTION, true);
tween.render(-BELOW_GSAP_TIME_RESOLUTION, true);
}
if (tween._startAt) tween._startAt.render(BELOW_GSAP_TIME_RESOLUTION, true, true);
return Boolean(tween.vars?.keyframes || tween._startAt);
}

function primeTweensStartingAt(children: unknown[], time: number): void {
for (const child of children.filter(playsForward)) {
const local = (time - child.startTime()) * child.timeScale();
if (Math.abs(local) < 1e-9 && child.vars?.keyframes) {
child.render(BELOW_GSAP_TIME_RESOLUTION, true);
child.render(-BELOW_GSAP_TIME_RESOLUTION, true);
} else if (child.getChildren && local > 0 && local <= child.totalDuration()) {
primeKeyframedTweensStartingAt(child.getChildren(false, true, true), local);
if (Math.abs(local) < 1e-9 && primedAtItsStart(child)) continue;
const nested = child.getChildren ? child : child.timeline;
if (nested?.getChildren && local > 0 && local <= child.totalDuration()) {
primeTweensStartingAt(nested.getChildren(false, true, true), local);
}
}
}
Expand Down
Loading