Skip to content
Merged
154 changes: 154 additions & 0 deletions packages/core/src/runtime/adapters/gsap.steps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,157 @@ describe("gsap adapter on a step", () => {
expect(gsap.getProperty(box, "x")).toBe(10);
});
});

describe("gsap adapter at a tween's start", () => {
// 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",
(from) => {
const box = document.body.appendChild(document.createElement("div"));
const timeline = gsap.timeline({ paused: true });
timeline.from(box, { x: -60, duration: 2, ease: "none" }, 0);
timeline.to(box, { keyframes: { "0%": { x: 5 }, "100%": { x: 60 } }, duration: 1 }, 2);
timeline.totalTime(from, true);
createGsapAdapter({ getTimeline: () => timeline as unknown as RuntimeTimelineLike }).seek({
time: 2,
});
expect(gsap.getProperty(box, "x")).toBe(5);
},
);

it("leaves a tween that starts later alone after a seek past the end", () => {
const box = document.body.appendChild(document.createElement("div"));
const timeline = gsap.timeline({ paused: true });
timeline.to(box, { x: -400, duration: 0.4 }, 4.8);
timeline.to(box, { x: 0, duration: 0.4 }, 16);
const adapter = createGsapAdapter({
getTimeline: () => timeline as unknown as RuntimeTimelineLike,
});
for (const time of [20, 0, 4]) adapter.seek({ time });
expect(gsap.getProperty(box, "x")).toBe(0);
});

it.each([
{ order: "keyframes, then a set", x: 42 },
{ order: "a set, then keyframes", x: 5 },
])("keeps the authored order of $order at the seek time", ({ order, x }) => {
const box = document.body.appendChild(document.createElement("div"));
const timeline = gsap.timeline({ paused: true });
timeline.from(box, { x: -60, duration: 2, ease: "none" }, 0);
const keyframes = () =>
timeline.to(box, { keyframes: { "0%": { x: 5 }, "100%": { x: 60 } }, duration: 1 }, 2);
const set = () => timeline.set(box, { x: 42 }, 2);
if (order.startsWith("keyframes")) {
keyframes();
set();
} else {
set();
keyframes();
}
const adapter = createGsapAdapter({
getTimeline: () => timeline as unknown as RuntimeTimelineLike,
});
for (const from of [0, 1, 2, 2.5, 3]) {
timeline.totalTime(from, true);
adapter.seek({ time: 2 });
expect(gsap.getProperty(box, "x")).toBe(x);
}
});

it("shows a keyframed tween's start inside a nested timeline, seeking onto it twice", () => {
const box = document.body.appendChild(document.createElement("div"));
const timeline = gsap.timeline({ paused: true });
const scene = gsap.timeline();
scene.from(box, { x: -60, duration: 2, ease: "none" }, 0);
scene.to(box, { keyframes: { "0%": { x: 5 }, "100%": { x: 60 } }, duration: 1 }, 2);
timeline.add(scene, 0.5);
const adapter = createGsapAdapter({
getTimeline: () => timeline as unknown as RuntimeTimelineLike,
});
adapter.seek({ time: 2.5 });
adapter.seek({ time: 2.5 });
expect(gsap.getProperty(box, "x")).toBe(5);
});

it("does not start a tween that begins just after the seek time", () => {
const box = document.body.appendChild(document.createElement("div"));
const timeline = gsap.timeline({ paused: true });
timeline.to(box, { x: 100, duration: 10, ease: "none" }, 0);
timeline.to(box, { x: 200, duration: 1, ease: "none", overwrite: "auto" }, 2.0005);
const adapter = createGsapAdapter({
getTimeline: () => timeline as unknown as RuntimeTimelineLike,
});
adapter.seek({ time: 2 });
adapter.seek({ time: 1 });
expect(gsap.getProperty(box, "x")).toBe(10);
});

it("keeps a relative repeatRefresh tween on its iteration at a repeat boundary", () => {
const box = document.body.appendChild(document.createElement("div"));
const timeline = gsap.timeline({ paused: true });
timeline.to(box, { x: "+=10", duration: 1, repeat: 1, repeatRefresh: true, ease: "none" }, 0);
const adapter = createGsapAdapter({
getTimeline: () => timeline as unknown as RuntimeTimelineLike,
});
adapter.seek({ time: 1 });
adapter.seek({ time: 1 });
expect(gsap.getProperty(box, "x")).toBe(10);
});

it.each([
{
shape: "a stagger whose next target starts 0.1 us later with overwrite auto",
build: (timeline: gsap.core.Timeline, o: { x: number }) => {
timeline.to(o, { x: 100, duration: 10, ease: "none" }, 0);
timeline.to([{ x: 0 }, o], { x: 200, duration: 1, stagger: 1e-7, overwrite: "auto" }, 2);
},
seeks: [2, 1],
x: 10,
},
{
shape: "a reversed keyframed tween",
build: (timeline: gsap.core.Timeline, o: { x: number }) => {
const tween = gsap.to(o, { keyframes: { "0%": { x: 5 }, "100%": { x: 60 } }, duration: 1 });
timeline.add(tween, 1);
tween.timeScale(-1);
},
seeks: [1],
x: 60,
},
{
shape: "a paused keyframed tween",
build: (timeline: gsap.core.Timeline, o: { x: number }) => {
const tween = gsap.to(o, {
keyframes: { "0%": { x: 5 }, "100%": { x: 60 } },
duration: 1,
ease: "none",
});
timeline.add(tween, 1);
tween.totalTime(0.5).pause();
},
seeks: [1, 1],
x: 32.5,
},
{
shape: "a 0.4 us repeatRefresh keyframed tween",
build: (timeline: gsap.core.Timeline, o: { x: number }) => {
const keyframes = [
{ x: "+=10", duration: 2e-7 },
{ x: "+=10", duration: 2e-7 },
];
timeline.to(o, { keyframes, repeat: 2, repeatRefresh: true, ease: "none" }, 2);
},
seeks: [2, 2],
x: 0,
},
])("leaves $shape as GSAP renders it at its start", ({ build, seeks, x }) => {
const o = { x: 0 };
const timeline = gsap.timeline({ paused: true });
build(timeline, o);
const adapter = createGsapAdapter({
getTimeline: () => timeline as unknown as RuntimeTimelineLike,
});
for (const time of seeks) adapter.seek({ time });
expect(o.x).toBeCloseTo(x, 6);
});
});
54 changes: 50 additions & 4 deletions packages/core/src/runtime/adapters/gsap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,57 @@ type GsapAdapterDeps = {
};

/**
* Re-renders a timeline already moved to `t`, silently. It arrives from just below, since GSAP
* applies same-time zero-duration steps in authored order only going forward; at 0 it comes from above.
* 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.
*/
export function rerenderGsapTimelineAt(
timeline: { totalTime: (time: number, suppressEvents?: boolean) => unknown },
timeline: {
totalTime: (time: number, suppressEvents?: boolean) => unknown;
getChildren?: RuntimeTimelineLike["getChildren"];
},
t: number,
): void {
timeline.totalTime(t >= 0.001 ? t - 0.001 : t + 0.001, true);
primeKeyframedTweensStartingAt(timeline.getChildren?.(false, true, true) ?? [], t);
timeline.totalTime(t, true);
}

type GsapAnimation = {
startTime: () => number;
timeScale: () => number;
totalDuration: () => number;
paused: () => boolean;
render: (totalTime: number, suppressEvents: boolean) => unknown;
vars?: { keyframes?: unknown };
getChildren?: (nested: boolean, tweens: boolean, timelines: boolean) => unknown[];
};

const BELOW_GSAP_TIME_RESOLUTION = 2e-8;

const playsForward = (value: unknown): value is GsapAnimation => {
const animation = value as GsapAnimation | null;
return (
typeof animation?.render === "function" &&
typeof animation.startTime === "function" &&
typeof animation.paused === "function" &&
typeof animation.timeScale === "function" &&
animation.timeScale() > 0 &&
!animation.paused()
);
};

function primeKeyframedTweensStartingAt(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);
}
}
}

export function createGsapAdapter(deps: GsapAdapterDeps): RuntimeDeterministicAdapter {
return {
name: "gsap",
Expand All @@ -28,7 +68,13 @@ export function createGsapAdapter(deps: GsapAdapterDeps): RuntimeDeterministicAd
const suppressEvents = ctx.suppressEvents === true;
if (typeof timeline.totalTime === "function") {
timeline.totalTime(safeTime, suppressEvents);
rerenderGsapTimelineAt({ totalTime: timeline.totalTime.bind(timeline) }, safeTime);
rerenderGsapTimelineAt(
{
totalTime: timeline.totalTime.bind(timeline),
getChildren: timeline.getChildren?.bind(timeline),
},
safeTime,
);
} else {
timeline.seek(safeTime, suppressEvents);
}
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4361,7 +4361,10 @@ export function initSandboxRuntimeModular(): void {
tl.totalTime(tlSeekTime, suppressEvents);
if (!suppressEvents && !hasZeroDurationCallbackTween(tl)) {
// The first seek is the only eventful one; the re-render only refreshes styles.
rerenderGsapTimelineAt({ totalTime: tl.totalTime.bind(tl) }, tlSeekTime);
rerenderGsapTimelineAt(
{ totalTime: tl.totalTime.bind(tl), getChildren: tl.getChildren?.bind(tl) },
tlSeekTime,
);
}
} else {
tl.seek(tlSeekTime, suppressEvents);
Expand Down
Loading