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
5 changes: 1 addition & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
"canvas",
"animation",
"scroll-trigger",
"gsap",
"image-sequence",
"performance"
],
Expand Down Expand Up @@ -51,7 +50,5 @@
"vite-plugin-dts": "^4.5.4",
"vitest": "^4.0.16"
},
"dependencies": {
"gsap": "^3.14.2"
}
"dependencies": {}
}
10 changes: 4 additions & 6 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@
"engine",
"canvas",
"animation",
"scroll-trigger",
"gsap"
"scroll-trigger"
],
"publishConfig": {
"access": "public"
Expand All @@ -35,12 +34,11 @@
"require": "./dist/index.cjs"
}
},
"dependencies": {
"gsap": "^3.14.2"
},
"dependencies": {},
"scripts": {
"build": "tsup",
"dev": "tsup --watch"
"dev": "tsup --watch",
"test": "vitest run"
},
"devDependencies": {
"jsdom": "^27.4.0",
Expand Down
47 changes: 24 additions & 23 deletions packages/core/src/frame-loader/frame-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@ import { FrameLoader } from "./frame-loader";
import type { BreakpointConfig } from "../types/scrollSequence";
import { Emitter } from "../utils/emitter/emitter";

import { ScrollTrigger } from "gsap/dist/ScrollTrigger";

vi.mock("gsap/dist/ScrollTrigger", () => ({
ScrollTrigger: {
create: vi.fn(),
},
const mockScrollScrubInstances: any[] = [];
vi.mock("../scroll-engine/scroll-trigger", () => ({
ScrollScrub: vi.fn().mockImplementation(function (this: any, props: any) {
this.init = vi.fn();
this.destroy = vi.fn();
this._props = props;
mockScrollScrubInstances.push(this);
}),
}));


describe("FrameLoader", () => {
let mockBreakpoint: BreakpointConfig;
let frameLoader: FrameLoader;
Expand Down Expand Up @@ -435,37 +438,35 @@ describe("FrameLoader", () => {
});

describe("Lazy Loading", () => {
it("should init ScrollTrigger and load neighbors on update", async () => {
const onFrameLoaded = vi.fn();
it("should init ScrollScrub and load neighbors on update", async () => {
const { ScrollScrub } = await import("../scroll-engine/scroll-trigger");
// Clear any instances from previous tests
mockScrollScrubInstances.length = 0;

frameLoader = new FrameLoader({
emitter: new Emitter(),
activeBreakpoint: mockBreakpoint,
firstFrame: 1,
lastFrame: 10,
preloadCount: 0,
preloadCount: 0,
maxRetries: 1,
retryDelay: 0,
});
frameLoader['emitter'].subscribe('frameLoaded', onFrameLoaded);

// Spy on loadFrame
const loadFrameSpy = vi.spyOn(frameLoader, 'loadFrame');

let onUpdateCallback: any;
(ScrollTrigger.create as any).mockImplementation((config: any) => {
onUpdateCallback = config.onUpdate;
return { kill: vi.fn() };
});
// Create a fake trigger element
const triggerEl = document.createElement("div");
frameLoader.initLazyLoading(triggerEl);

frameLoader.initLazyLoading("#test");
// ScrollScrub should have been instantiated
expect(ScrollScrub).toHaveBeenCalled();
expect(mockScrollScrubInstances.length).toBeGreaterThan(0);

expect(ScrollTrigger.create).toHaveBeenCalled();
expect(onUpdateCallback).toBeDefined();
// Get the instance and simulate a scroll update at 50%
const instance = mockScrollScrubInstances[mockScrollScrubInstances.length - 1];
instance._props.onUpdate({ progress: 0.5 });

// Simulate scroll to 50% (frame ~5)
// Total frames 10 (1-10). Index from 0-9. 0.5 * 9 = 4.5 -> floor = 4 -> Frame 5.
onUpdateCallback({ progress: 0.5 });

// Should trigger loadFrame for neighbors
expect(loadFrameSpy).toHaveBeenCalled();
});
Expand Down
24 changes: 12 additions & 12 deletions packages/core/src/frame-loader/frame-loader.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { getFrameHref } from "../utils/url-resolvers/getFrameHref";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/dist/ScrollTrigger";
import type { BreakpointConfig, FrameLoaderProps, NetworkPolicy, Frame } from "../types/scrollSequence";
import { Emitter } from "../utils/emitter/emitter";
gsap.registerPlugin(ScrollTrigger);
import { ScrollScrub } from "../scroll-engine/scroll-trigger";

class FrameLoader {
private firstFrameLoaded: boolean = false;
Expand All @@ -14,7 +12,7 @@ class FrameLoader {
private preloadCount: number;
private networkPolicy: NetworkPolicy | undefined;
private loadingFrames: Set<number> = new Set();
private lazyLoadingTL: ScrollTrigger | null = null;
private lazyLoadingTL: ScrollScrub | null = null;
private maxRetries: number;
private retryDelay: number;
private queue: { frameNumber: number; resolve: () => void; reject: (err: unknown) => void }[] = [];
Expand Down Expand Up @@ -197,21 +195,23 @@ class FrameLoader {
markers: boolean = false
): void => {
const totalFrames = this.lastFrame - this.firstFrame + 1;
if (!trigger) return;
this.lazyLoadingTL?.kill();
this.lazyLoadingTL = ScrollTrigger.create({
trigger: trigger,
const triggerEl = typeof trigger === "string" ? document.querySelector<HTMLElement>(trigger) : trigger;
if (!triggerEl) return;

this.lazyLoadingTL?.destroy();
this.lazyLoadingTL = new ScrollScrub({
trigger: triggerEl,
start,
end,
scrub,
markers,
onUpdate: (self) => {
const frameIndex = Math.floor(self.progress * (totalFrames - 1));
onUpdate: ({ progress }) => {
const frameIndex = Math.floor(progress * (totalFrames - 1));
if (this.lazyLoadAroundFrame) {
this.lazyLoadAroundFrame(frameIndex);
}
},
});
this.lazyLoadingTL.init();
};

async preloadInitialFrames(): Promise<void> {
Expand All @@ -225,7 +225,7 @@ class FrameLoader {

destroy(): void {
if (this.lazyLoadingTL) {
this.lazyLoadingTL.kill();
this.lazyLoadingTL.destroy();
this.lazyLoadingTL = null;
}
this.loadingFrames.clear();
Expand Down
38 changes: 23 additions & 15 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,9 @@ import { ScrollEngine } from "./scroll-engine/scroll-engine";
import { ActiveBreakpoint } from "./active-breakpoint/active-breakpoint";
import resolveFallbackFrameUrl from "./utils/url-resolvers/resolveFallbackUrls";
import { FrameLoader } from "./frame-loader/frame-loader";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { ScrollScrub } from "./scroll-engine/scroll-trigger";
import { CanvasRender } from "./canvas-render/canvas-render";
import { Emitter } from "./utils/emitter/emitter";
gsap.registerPlugin(ScrollTrigger);

export class ScrollSequenceEngine {
private scrollEngine: ScrollEngine | null = null;
Expand All @@ -26,7 +24,7 @@ export class ScrollSequenceEngine {
private lastFrame: number = 2;
private totalFrames: number = 1;
private frameLoaderManager: FrameLoader | null = null;
private tlPreloadFirstChunk: ScrollTrigger | null = null;
private tlPreloadFirstChunk: ScrollScrub | null = null;
private prefersReducedMotion: PrefersReducedMotion | null = null;
private dpr: number = 1;
private resizeObserver: ResizeObserver | null = null;
Expand Down Expand Up @@ -75,17 +73,27 @@ export class ScrollSequenceEngine {
if (this.loadingConfig?.loadingMode === "lazy" && !fallbackOnly) {
if (this.loadingConfig.trigger) {
if (this.tlPreloadFirstChunk) {
this.tlPreloadFirstChunk.kill();
this.tlPreloadFirstChunk.destroy();
}
const triggerEl =
typeof this.loadingConfig.trigger === "string"
? document.querySelector<HTMLElement>(this.loadingConfig.trigger)
: this.loadingConfig.trigger;
if (triggerEl) {
this.tlPreloadFirstChunk = new ScrollScrub({
trigger: triggerEl,
start: this.loadingConfig.start,
end: "100%",
onUpdate: () => {},
onEnter: async () => {
await this.frameLoaderManager?.preloadInitialFrames();
// One-shot: destroy after first fire
this.tlPreloadFirstChunk?.destroy();
this.tlPreloadFirstChunk = null;
},
});
this.tlPreloadFirstChunk.init();
}
this.tlPreloadFirstChunk = ScrollTrigger.create({
trigger: this.loadingConfig.trigger,
start: this.loadingConfig.start,
markers: this.loadingConfig.markers,
once: true,
onEnter: async () => {
await this.frameLoaderManager?.preloadInitialFrames();
},
});
this.frameLoaderManager.initLazyLoading(
this.loadingConfig.trigger,
this.loadingConfig.start,
Expand Down Expand Up @@ -268,7 +276,7 @@ export class ScrollSequenceEngine {
};

destroy = () => {
this.tlPreloadFirstChunk?.kill();
this.tlPreloadFirstChunk?.destroy();
this.scrollEngine?.destroy();
this.frameLoaderManager?.destroy();

Expand Down
51 changes: 19 additions & 32 deletions packages/core/src/scroll-engine/scroll-engine.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import gsap from "gsap";
import { ScrollTrigger } from "gsap/dist/ScrollTrigger";
import type { ScrollEngineProps } from "../types/scrollSequence";

gsap.registerPlugin(ScrollTrigger);
import { ScrollScrub } from "./scroll-trigger";

export class ScrollEngine {
private tl: gsap.core.Timeline | null = null;
private scrub: ScrollScrub | null = null;
private lastFrame: number = -1;
private props: ScrollEngineProps;

Expand All @@ -15,44 +12,34 @@ export class ScrollEngine {
}

public init(): void {
const { containerRef, totalFrames, onFrameChange, start = "top top", end = "100%", scrub = true, markers = false } = this.props;
const { containerRef, totalFrames, onFrameChange, start = "top top", end = "bottom top", scrub = true } = this.props;

const element = containerRef;
if (!element) return;

// Clean up
this.destroy();

this.tl = gsap.timeline({
scrollTrigger: {
trigger: element,
start,
end,
scrub,
markers,
onUpdate: (self) => {
const frameIndex = Math.floor(self.progress * (totalFrames - 1));
if (frameIndex !== this.lastFrame) {
this.lastFrame = frameIndex;
onFrameChange(frameIndex);
}
},
this.scrub = new ScrollScrub({
trigger: element,
start,
end,
scrub,
onUpdate: ({ progress }) => {
const frameIndex = Math.floor(progress * (totalFrames - 1));
if (frameIndex !== this.lastFrame) {
this.lastFrame = frameIndex;
onFrameChange(frameIndex);
}
},
});

this.scrub.init();
}

public destroy(): void {
if (this.tl) {
this.tl.kill();
this.tl = null;
}

if (this.props.containerRef) {
ScrollTrigger.getAll().forEach((t) => {
if (t.vars.trigger === this.props.containerRef) {
t.kill();
}
});
if (this.scrub) {
this.scrub.destroy();
this.scrub = null;
}
}
}
50 changes: 50 additions & 0 deletions packages/core/src/scroll-engine/scroll-scrub-ticker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
type TickCallback = () => void;

class Ticker {
private callbacks: Set<TickCallback> = new Set();
private activeCallbacks: Set<TickCallback> = new Set();
private rafId: number | null = null;

register(cb: TickCallback): void {
this.callbacks.add(cb);
}

unregister(cb: TickCallback): void {
this.callbacks.delete(cb);
this.activeCallbacks.delete(cb);
if (this.activeCallbacks.size === 0) {
this.stop();
}
}

activate(cb: TickCallback): void {
if (!this.callbacks.has(cb)) return;
this.activeCallbacks.add(cb);
this.start();
}

deactivate(cb: TickCallback): void {
this.activeCallbacks.delete(cb);
if (this.activeCallbacks.size === 0) {
this.stop();
}
}

private start(): void {
if (this.rafId !== null) return; // Already running
const loop = () => {
this.activeCallbacks.forEach((cb) => cb());
this.rafId = requestAnimationFrame(loop);
};
this.rafId = requestAnimationFrame(loop);
}

private stop(): void {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
}
}

export const scrollScrubTicker = new Ticker();
Loading