From 6f58ddc848952207d955339707fadaad4d8dc2f6 Mon Sep 17 00:00:00 2001 From: mkurtic Date: Mon, 2 Mar 2026 16:07:27 +0100 Subject: [PATCH] refactor(core): replace GSAP/ScrollTrigger with custom ScrollScrub - Remove GSAP dependency; replace with lightweight custom scroll engine - Add scroll-scrub-ticker.ts: shared rAF-based active/inactive tick loop - Add scroll-trigger.ts: ScrollScrub class + OffsetKeyword/OffsetToken/ScrollOffset types - Update scroll-engine.ts and frame-loader.ts to use ScrollScrub - Improve scroll offset typings with OffsetKeyword, OffsetToken, ScrollOffset - Remove gsap from root dependencies and all keyword lists - Add 'test' script to packages/core/package.json - Fix vitest.config.mts setupFiles path to use absolute URL (works from any CWD) --- package.json | 5 +- packages/core/package.json | 10 +- .../src/frame-loader/frame-loader.test.ts | 47 ++++---- .../core/src/frame-loader/frame-loader.ts | 24 ++-- packages/core/src/index.ts | 38 ++++--- .../core/src/scroll-engine/scroll-engine.ts | 51 ++++----- .../src/scroll-engine/scroll-scrub-ticker.ts | 50 +++++++++ .../core/src/scroll-engine/scroll-trigger.ts | 105 ++++++++++++++++++ packages/core/vitest.config.mts | 6 +- 9 files changed, 243 insertions(+), 93 deletions(-) create mode 100644 packages/core/src/scroll-engine/scroll-scrub-ticker.ts create mode 100644 packages/core/src/scroll-engine/scroll-trigger.ts diff --git a/package.json b/package.json index 99f68a3..a84d334 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,6 @@ "canvas", "animation", "scroll-trigger", - "gsap", "image-sequence", "performance" ], @@ -51,7 +50,5 @@ "vite-plugin-dts": "^4.5.4", "vitest": "^4.0.16" }, - "dependencies": { - "gsap": "^3.14.2" - } + "dependencies": {} } diff --git a/packages/core/package.json b/packages/core/package.json index f9df2cb..6fb748c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -18,8 +18,7 @@ "engine", "canvas", "animation", - "scroll-trigger", - "gsap" + "scroll-trigger" ], "publishConfig": { "access": "public" @@ -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", diff --git a/packages/core/src/frame-loader/frame-loader.test.ts b/packages/core/src/frame-loader/frame-loader.test.ts index 5e14030..1c683fe 100644 --- a/packages/core/src/frame-loader/frame-loader.test.ts +++ b/packages/core/src/frame-loader/frame-loader.test.ts @@ -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; @@ -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(); }); diff --git a/packages/core/src/frame-loader/frame-loader.ts b/packages/core/src/frame-loader/frame-loader.ts index 532c43a..d259226 100644 --- a/packages/core/src/frame-loader/frame-loader.ts +++ b/packages/core/src/frame-loader/frame-loader.ts @@ -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; @@ -14,7 +12,7 @@ class FrameLoader { private preloadCount: number; private networkPolicy: NetworkPolicy | undefined; private loadingFrames: Set = 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 }[] = []; @@ -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(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 { @@ -225,7 +225,7 @@ class FrameLoader { destroy(): void { if (this.lazyLoadingTL) { - this.lazyLoadingTL.kill(); + this.lazyLoadingTL.destroy(); this.lazyLoadingTL = null; } this.loadingFrames.clear(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 92547ce..4f5e43b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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; @@ -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; @@ -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(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, @@ -268,7 +276,7 @@ export class ScrollSequenceEngine { }; destroy = () => { - this.tlPreloadFirstChunk?.kill(); + this.tlPreloadFirstChunk?.destroy(); this.scrollEngine?.destroy(); this.frameLoaderManager?.destroy(); diff --git a/packages/core/src/scroll-engine/scroll-engine.ts b/packages/core/src/scroll-engine/scroll-engine.ts index 9af2947..1fc3d0c 100644 --- a/packages/core/src/scroll-engine/scroll-engine.ts +++ b/packages/core/src/scroll-engine/scroll-engine.ts @@ -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; @@ -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; } } } diff --git a/packages/core/src/scroll-engine/scroll-scrub-ticker.ts b/packages/core/src/scroll-engine/scroll-scrub-ticker.ts new file mode 100644 index 0000000..eb3be3b --- /dev/null +++ b/packages/core/src/scroll-engine/scroll-scrub-ticker.ts @@ -0,0 +1,50 @@ +type TickCallback = () => void; + +class Ticker { + private callbacks: Set = new Set(); + private activeCallbacks: Set = 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(); diff --git a/packages/core/src/scroll-engine/scroll-trigger.ts b/packages/core/src/scroll-engine/scroll-trigger.ts new file mode 100644 index 0000000..c23fe4e --- /dev/null +++ b/packages/core/src/scroll-engine/scroll-trigger.ts @@ -0,0 +1,105 @@ +import { scrollScrubTicker } from "./scroll-scrub-ticker"; + +export type ScrollScrubCallback = { + progress: number; +}; + +export type OffsetKeyword = "top" | "center" | "bottom"; +export type OffsetToken = OffsetKeyword | `${number}%` | `${number}px`; +export type ScrollOffset = `${OffsetToken} ${OffsetToken}` | (string & {}); + +export type ScrollScrubProps = { + trigger: HTMLElement; + start?: ScrollOffset; + end?: ScrollOffset; + scrub?: boolean; + onUpdate: (self: ScrollScrubCallback) => void; + onEnter?: () => void; + onLeave?: () => void; + onEnterBack?: () => void; + onLeaveBack?: () => void; +}; + +function parseOffset(positionStr: string = "top top", elementSize: number, viewportSize: number): number { + const [elementSide, viewportSide] = positionStr.trim().split(/\s+/); + + const resolveValue = (token: string = "top", size: number): number => { + if (token === "top") return 0; + if (token === "center") return size / 2; + if (token === "bottom") return size; + if (token.endsWith("%")) return (parseFloat(token) / 100) * size; + if (token.endsWith("px")) return parseFloat(token); + return 0; + }; + + return resolveValue(elementSide, elementSize) - resolveValue(viewportSide, viewportSize); +} + +export class ScrollScrub { + private props: ScrollScrubProps; + private observer: IntersectionObserver | null = null; + private lastProgress: number = -1; + + private readonly tick: () => void; + + constructor(props: ScrollScrubProps) { + this.props = props; + this.tick = this.update.bind(this); // Bind once so the same reference is used for register/unregister + } + + init = (): void => { + scrollScrubTicker.register(this.tick); + + this.observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (entry.isIntersecting) { + scrollScrubTicker.activate(this.tick); + } else { + this.update(); + scrollScrubTicker.deactivate(this.tick); + } + }, + { rootMargin: "10px 0px 10px 0px" } + ); + + this.observer.observe(this.props.trigger); + }; + + update = (): void => { + const { trigger, start = "top top", end = "bottom top", onUpdate, onEnter, onLeave, onEnterBack, onLeaveBack } = this.props; + + const rect = trigger.getBoundingClientRect(); + const vh = window.innerHeight; + const elementHeight = rect.height; + const elementTop = rect.top + window.scrollY; + + const startOffset = parseOffset(start, elementHeight, vh); + const endOffset = parseOffset(end, elementHeight, vh); + const animationLength = (elementTop + endOffset) - (elementTop + startOffset); + + if (animationLength <= 0) return; + + const rawProgress = (window.scrollY - (elementTop + startOffset)) / animationLength; + const progress = Math.min(1, Math.max(0, rawProgress)); + + // Directional callbacks — fire only at boundary crossings + if (progress > 0 && this.lastProgress <= 0) onEnter?.(); + if (progress >= 1 && this.lastProgress < 1) onLeave?.(); + if (progress < 1 && this.lastProgress >= 1) onEnterBack?.(); + if (progress <= 0 && this.lastProgress > 0) onLeaveBack?.(); + + if (progress !== this.lastProgress) { + this.lastProgress = progress; + onUpdate({ progress }); + } + }; + + destroy = (): void => { + scrollScrubTicker.unregister(this.tick); + if (this.observer) { + this.observer.disconnect(); + this.observer = null; + } + }; +} \ No newline at end of file diff --git a/packages/core/vitest.config.mts b/packages/core/vitest.config.mts index 88d1f6b..c2e7441 100644 --- a/packages/core/vitest.config.mts +++ b/packages/core/vitest.config.mts @@ -1,8 +1,12 @@ import { defineConfig } from "vitest/config"; +import { resolve } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = fileURLToPath(new URL(".", import.meta.url)); export default defineConfig({ test: { environment: "jsdom", - setupFiles: ["packages/core/src/test-setup.ts"], + setupFiles: [resolve(__dirname, "src/test-setup.ts")], }, });