From 197fb9fd9bf41aa1313ca6e71a672192e257cef5 Mon Sep 17 00:00:00 2001 From: mkurtic Date: Wed, 28 Jan 2026 11:33:25 +0100 Subject: [PATCH] refacto: EventEmitter as orchestrator --- README.md | 1 - .../active-breakpoint.test.ts | 79 ++++++++----------- .../active-breakpoint/active-breakpoint.ts | 28 +++---- .../src/canvas-render/canvas-render.test.ts | 9 +++ .../core/src/canvas-render/canvas-render.ts | 9 ++- .../src/frame-loader/frame-loader.test.ts | 31 ++++++-- .../core/src/frame-loader/frame-loader.ts | 17 ++-- packages/core/src/index.ts | 73 ++++++++++------- .../src/reduce-motion/reduce-motion.test.ts | 18 +++-- .../core/src/reduce-motion/reduce-motion.ts | 14 +++- packages/core/src/types/scrollSequence.ts | 3 + packages/core/src/utils/emitter/emitter.ts | 26 ++++++ 12 files changed, 184 insertions(+), 124 deletions(-) create mode 100644 packages/core/src/utils/emitter/emitter.ts diff --git a/README.md b/README.md index 36a2035..e26617f 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,6 @@ You can control how frames are loaded (e.g., eager vs lazy, retry logic) using t | `maxRetries` | `number` | `3` | Attempts to retry failed frames. | | `retryDelay` | `number` | `200` | Delay (ms) between retries. | | `preloadCount` | `number` | (calculated) | Number of frames to force-load initially to be always ahead of the current scroll position of the users. | -| `onFrameLoaded` | `function` | - | Callback `(stat) => void` for tracking load status. | ### Loading Strategy 1. **Sequential (Passive)**: Initial & progressive loading happens one-by-one to save bandwidth. diff --git a/packages/core/src/active-breakpoint/active-breakpoint.test.ts b/packages/core/src/active-breakpoint/active-breakpoint.test.ts index 0031c51..56da32a 100644 --- a/packages/core/src/active-breakpoint/active-breakpoint.test.ts +++ b/packages/core/src/active-breakpoint/active-breakpoint.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { ActiveBreakpoint } from "./active-breakpoint"; +import { Emitter } from "../utils/emitter/emitter"; import type { BreakpointConfig } from "../types/scrollSequence"; describe("ActiveBreakpoint", () => { @@ -39,7 +40,7 @@ describe("ActiveBreakpoint", () => { }); it("should throw error if breakpoints array is empty", () => { - expect(() => new ActiveBreakpoint([])).toThrow("ActiveBreakpoint requires at least one breakpoint"); + expect(() => new ActiveBreakpoint([], new Emitter())).toThrow("ActiveBreakpoint requires at least one breakpoint"); }); it("should throw error if breakpoints overlap", () => { @@ -47,22 +48,22 @@ describe("ActiveBreakpoint", () => { { name: "small", breakpointMin: 0, breakpointMax: 600, frames: [], frameDigits: 4, url: "" }, { name: "large", breakpointMin: 500, breakpointMax: 1000, frames: [], frameDigits: 4, url: "" } ]; - expect(() => new ActiveBreakpoint(overlappingBreakpoints)).toThrow("Breakpoints overlap"); + expect(() => new ActiveBreakpoint(overlappingBreakpoints, new Emitter())).toThrow("Breakpoints overlap"); }); it("should initialize with the correct breakpoint based on window width", () => { vi.stubGlobal("innerWidth", 500); - const manager = new ActiveBreakpoint(breakpoints); + const manager = new ActiveBreakpoint(breakpoints, new Emitter()); manager.init(); expect(manager.getActive().name).toBe("mobile"); vi.stubGlobal("innerWidth", 800); - const manager2 = new ActiveBreakpoint(breakpoints); + const manager2 = new ActiveBreakpoint(breakpoints, new Emitter()); manager2.init(); expect(manager2.getActive().name).toBe("tablet"); vi.stubGlobal("innerWidth", 1200); - const manager3 = new ActiveBreakpoint(breakpoints); + const manager3 = new ActiveBreakpoint(breakpoints, new Emitter()); manager3.init(); expect(manager3.getActive().name).toBe("desktop"); }); @@ -75,7 +76,7 @@ describe("ActiveBreakpoint", () => { ]; vi.stubGlobal("innerWidth", 550); // In the gap - const manager = new ActiveBreakpoint(gapBreakpoints); + const manager = new ActiveBreakpoint(gapBreakpoints, new Emitter()); // Should throw error expect(() => manager.init()).toThrow("No breakpoint found for width 550"); }); @@ -83,7 +84,7 @@ describe("ActiveBreakpoint", () => { it("should update active breakpoint on window resize", () => { vi.useFakeTimers(); vi.stubGlobal("innerWidth", 500); - const manager = new ActiveBreakpoint(breakpoints); + const manager = new ActiveBreakpoint(breakpoints, new Emitter()); manager.init(); expect(manager.getActive().name).toBe("mobile"); @@ -100,14 +101,12 @@ describe("ActiveBreakpoint", () => { it("should notify subscribers when breakpoint changes", () => { vi.useFakeTimers(); vi.stubGlobal("innerWidth", 500); - const manager = new ActiveBreakpoint(breakpoints); + const emitter = new Emitter(); + const emitSpy = vi.spyOn(emitter, 'emit'); + const manager = new ActiveBreakpoint(breakpoints, emitter); manager.init(); - const listener = vi.fn(); - manager.subscribe(listener); - - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenLastCalledWith(expect.objectContaining({ name: "mobile" })); + expect(emitSpy).toHaveBeenCalledWith("breakpointChanged", expect.objectContaining({ name: "mobile" })); // Resize to tablet vi.stubGlobal("innerWidth", 800); @@ -115,19 +114,21 @@ describe("ActiveBreakpoint", () => { vi.advanceTimersByTime(150); - expect(listener).toHaveBeenCalledTimes(2); - expect(listener).toHaveBeenLastCalledWith(expect.objectContaining({ name: "tablet" })); + expect(emitSpy).toHaveBeenCalledWith("breakpointChanged", expect.objectContaining({ name: "tablet" })); vi.useRealTimers(); }); it("should debounce resize events", () => { vi.useFakeTimers(); vi.stubGlobal("innerWidth", 500); - const manager = new ActiveBreakpoint(breakpoints); + const emitter = new Emitter(); + const emitSpy = vi.spyOn(emitter, 'emit'); + const manager = new ActiveBreakpoint(breakpoints, emitter); manager.init(); - const listener = vi.fn(); - manager.subscribe(listener); + // Initial emit + expect(emitSpy).toHaveBeenCalledTimes(1); + emitSpy.mockClear(); // Resize multiple times quickly vi.stubGlobal("innerWidth", 800); @@ -138,14 +139,14 @@ describe("ActiveBreakpoint", () => { window.dispatchEvent(new Event("resize")); // Should not have triggered update yet - // Initial subscribe calls listener once - expect(listener).toHaveBeenCalledTimes(1); + expect(emitSpy).not.toHaveBeenCalled(); // Fast forward time vi.advanceTimersByTime(200); // Assuming 200ms debounce or similar - // Now it should have updated - expect(listener).toHaveBeenCalledTimes(2); + // Now it should have updated once + expect(emitSpy).toHaveBeenCalledTimes(1); + expect(emitSpy).toHaveBeenCalledWith("breakpointChanged", expect.anything()); vi.useRealTimers(); }); @@ -153,11 +154,14 @@ describe("ActiveBreakpoint", () => { it("should NOT notify subscribers if resize doesn't change active breakpoint", () => { vi.useFakeTimers(); vi.stubGlobal("innerWidth", 500); - const manager = new ActiveBreakpoint(breakpoints); + const emitter = new Emitter(); + const emitSpy = vi.spyOn(emitter, 'emit'); + const manager = new ActiveBreakpoint(breakpoints, emitter); manager.init(); - const listener = vi.fn(); - manager.subscribe(listener); // +1 call + // Initial emit + expect(emitSpy).toHaveBeenCalledTimes(1); + emitSpy.mockClear(); // Resize to mobile vi.stubGlobal("innerWidth", 600); @@ -165,33 +169,12 @@ describe("ActiveBreakpoint", () => { vi.advanceTimersByTime(150); - expect(listener).toHaveBeenCalledTimes(1); + expect(emitSpy).not.toHaveBeenCalled(); vi.useRealTimers(); }); - it("should unsubscribe correctly", () => { - vi.useFakeTimers(); - vi.stubGlobal("innerWidth", 500); - const manager = new ActiveBreakpoint(breakpoints); - manager.init(); - - const listener = vi.fn(); - const unsubscribe = manager.subscribe(listener); - - unsubscribe(); - - // Resize - vi.stubGlobal("innerWidth", 800); - window.dispatchEvent(new Event("resize")); - - vi.advanceTimersByTime(150); - - expect(listener).toHaveBeenCalledTimes(1); // Only the initial call - vi.useRealTimers(); - }); - it("should clean up listeners on destroy", () => { - const manager = new ActiveBreakpoint(breakpoints); + const manager = new ActiveBreakpoint(breakpoints, new Emitter()); manager.init(); const removeSpy = vi.spyOn(window, 'removeEventListener'); diff --git a/packages/core/src/active-breakpoint/active-breakpoint.ts b/packages/core/src/active-breakpoint/active-breakpoint.ts index aab88bd..3986253 100644 --- a/packages/core/src/active-breakpoint/active-breakpoint.ts +++ b/packages/core/src/active-breakpoint/active-breakpoint.ts @@ -1,14 +1,15 @@ import type { BreakpointConfig } from "../types/scrollSequence"; +import type { Emitter } from "../utils/emitter/emitter"; -type BreakpointListener = (active: T) => void; export class ActiveBreakpoint { private breakpoints: T[]; private active: T; - private listeners = new Set>(); private resizeHandler: () => void; + private emitter: Emitter; - constructor(breakpoints: T[]) { + constructor(breakpoints: T[], emitter: Emitter) { + this.emitter = emitter; if (breakpoints.length === 0) { throw new Error("ActiveBreakpoint requires at least one breakpoint"); } @@ -41,29 +42,20 @@ export class ActiveBreakpoint { init() { this.update(); - if (typeof window !== "undefined") { - window.addEventListener("resize", this.resizeHandler); - } + if(typeof window == "undefined") return; + window.addEventListener("resize", this.resizeHandler); + this.emitter.emit("breakpointChanged", this.active); } destroy() { - if (typeof window !== "undefined") { - window.removeEventListener("resize", this.resizeHandler); - } - this.listeners.clear(); + if (typeof window == "undefined") return; + window.removeEventListener("resize", this.resizeHandler); } getActive(): T { return this.active; } - subscribe(listener: BreakpointListener) { - this.listeners.add(listener); - listener(this.active); - return () => { - this.listeners.delete(listener); - }; - } private update() { if (typeof window === "undefined") return; @@ -81,7 +73,7 @@ export class ActiveBreakpoint { if (next !== this.active) { this.active = next; - this.listeners.forEach((l) => l(this.active)); + this.emitter.emit("breakpointChanged", this.active); } } } diff --git a/packages/core/src/canvas-render/canvas-render.test.ts b/packages/core/src/canvas-render/canvas-render.test.ts index f0d99af..d943a16 100644 --- a/packages/core/src/canvas-render/canvas-render.test.ts +++ b/packages/core/src/canvas-render/canvas-render.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { CanvasRender } from "./canvas-render"; +import { Emitter } from "../utils/emitter/emitter"; import * as scaleToCoverModule from "../utils/canvas/scaleToCover"; import * as scaleToContainModule from "../utils/canvas/scaleToContain"; @@ -42,6 +43,7 @@ describe("CanvasRender", () => { it("should initialize with provided configuration", () => { const render = new CanvasRender({ + emitter: new Emitter(), canvas, container, dpr, @@ -52,6 +54,7 @@ describe("CanvasRender", () => { it("should resize canvas based on container and DPR on first draw", () => { const render = new CanvasRender({ + emitter: new Emitter(), canvas, container, dpr, @@ -68,6 +71,7 @@ describe("CanvasRender", () => { it("should use scaleToCover when drawMode is 'cover'", () => { const render = new CanvasRender({ + emitter: new Emitter(), canvas, container, dpr, @@ -82,6 +86,7 @@ describe("CanvasRender", () => { it("should use scaleToContain when drawMode is 'contain' or undefined", () => { const render = new CanvasRender({ + emitter: new Emitter(), canvas, container, dpr, @@ -96,6 +101,7 @@ describe("CanvasRender", () => { it("should update canvas size if container size changes", () => { const render = new CanvasRender({ + emitter: new Emitter(), canvas, container, dpr, @@ -117,6 +123,7 @@ describe("CanvasRender", () => { it("should store lastDrawnFrame and use it if no new frame is provided", () => { const render = new CanvasRender({ + emitter: new Emitter(), canvas, container, dpr, @@ -138,6 +145,7 @@ describe("CanvasRender", () => { it("should use fallback if no frame and no lastDrawnFrame", () => { const render = new CanvasRender({ + emitter: new Emitter(), canvas, container, dpr, @@ -152,6 +160,7 @@ describe("CanvasRender", () => { it("should not draw anything if no inputs provided", () => { const render = new CanvasRender({ + emitter: new Emitter(), canvas, container, dpr, diff --git a/packages/core/src/canvas-render/canvas-render.ts b/packages/core/src/canvas-render/canvas-render.ts index 6c5dec8..ad2fe1c 100644 --- a/packages/core/src/canvas-render/canvas-render.ts +++ b/packages/core/src/canvas-render/canvas-render.ts @@ -1,6 +1,7 @@ import type { DrawMode } from "../types/scrollSequence"; import { scaleToCover } from "../utils/canvas/scaleToCover"; import { scaleToContain } from "../utils/canvas/scaleToContain"; +import type { Emitter } from "../utils/emitter/emitter"; class CanvasRender { private dpr: number; @@ -9,11 +10,17 @@ class CanvasRender { private container: HTMLElement; private lastDrawnFrame: HTMLImageElement | null = null; private canvasSize: { width: number; height: number } = { width: 0, height: 0 }; - constructor(config: { canvas: HTMLCanvasElement; container: HTMLElement; dpr: number; drawMode: DrawMode | undefined }) { + private emitter: Emitter; + constructor(config: {emitter: Emitter, canvas: HTMLCanvasElement; container: HTMLElement; dpr: number; drawMode: DrawMode | undefined }) { + this.emitter = config.emitter; this.dpr = config.dpr; this.drawMode = config.drawMode; this.canvas = config.canvas; this.container = config.container; + + this.emitter.subscribe("drawFrame", (frame: HTMLImageElement | null, fallback: HTMLImageElement | null | undefined) => { + this.drawFrame(frame, fallback); + }); } private renderImage(image: HTMLImageElement, canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) { diff --git a/packages/core/src/frame-loader/frame-loader.test.ts b/packages/core/src/frame-loader/frame-loader.test.ts index 1120e3d..5e14030 100644 --- a/packages/core/src/frame-loader/frame-loader.test.ts +++ b/packages/core/src/frame-loader/frame-loader.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 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: { @@ -51,6 +54,7 @@ describe("FrameLoader", () => { describe("Sequential Loading Mode", () => { beforeEach(() => { frameLoader = new FrameLoader({ + emitter: new Emitter(), activeBreakpoint: mockBreakpoint, firstFrame: 1, lastFrame: 10, @@ -98,14 +102,15 @@ describe("FrameLoader", () => { }); frameLoader = new FrameLoader({ + emitter: new Emitter(), activeBreakpoint: mockBreakpoint, firstFrame: 1, lastFrame: 10, preloadCount: 5, maxRetries: 3, retryDelay: 200, - onFrameLoaded, }); + frameLoader['emitter'].subscribe('frameLoaded', onFrameLoaded); // multiple frames => queued const promises = [ @@ -125,14 +130,15 @@ describe("FrameLoader", () => { const onFrameLoaded = vi.fn(); frameLoader = new FrameLoader({ + emitter: new Emitter(), activeBreakpoint: mockBreakpoint, firstFrame: 1, lastFrame: 10, preloadCount: 5, maxRetries: 3, retryDelay: 200, - onFrameLoaded, }); + frameLoader['emitter'].subscribe('frameLoaded', onFrameLoaded); // same frame twice const promise1 = frameLoader.loadFrame(1, "sequential"); @@ -151,6 +157,7 @@ describe("FrameLoader", () => { describe("Parallel Loading Mode", () => { beforeEach(() => { frameLoader = new FrameLoader({ + emitter: new Emitter(), activeBreakpoint: mockBreakpoint, firstFrame: 1, lastFrame: 10, @@ -193,6 +200,7 @@ describe("FrameLoader", () => { // Reset mockBreakpoint.frames = new Array(10).fill(null); const sequentialLoader = new FrameLoader({ + emitter: new Emitter(), activeBreakpoint: mockBreakpoint, firstFrame: 1, lastFrame: 10, @@ -221,14 +229,15 @@ describe("FrameLoader", () => { const onFrameLoaded = vi.fn(); frameLoader = new FrameLoader({ + emitter: new Emitter(), activeBreakpoint: mockBreakpoint, firstFrame: 1, lastFrame: 10, preloadCount: 5, maxRetries: 3, retryDelay: 200, - onFrameLoaded, }); + frameLoader['emitter'].subscribe('frameLoaded', onFrameLoaded); // same frame parallel const promises = [ @@ -250,14 +259,15 @@ describe("FrameLoader", () => { const onFrameLoaded = vi.fn(); frameLoader = new FrameLoader({ + emitter: new Emitter(), activeBreakpoint: mockBreakpoint, firstFrame: 1, lastFrame: 10, preloadCount: 5, maxRetries: 3, retryDelay: 200, - onFrameLoaded, }); + frameLoader['emitter'].subscribe('frameLoaded', onFrameLoaded); // Mix of sequential and parallel const promises = [ @@ -289,14 +299,15 @@ describe("FrameLoader", () => { vi.useRealTimers(); frameLoader = new FrameLoader({ + emitter: new Emitter(), activeBreakpoint: mockBreakpoint, firstFrame: 1, lastFrame: 10, preloadCount: 5, maxRetries: 3, retryDelay: 200, - onFrameLoaded, }); + frameLoader['emitter'].subscribe('frameLoaded', onFrameLoaded); await frameLoader.loadFrame(1, "parallel"); @@ -339,14 +350,15 @@ describe("FrameLoader", () => { } as any; frameLoader = new FrameLoader({ + emitter: new Emitter(), activeBreakpoint: mockBreakpoint, firstFrame: 1, lastFrame: 10, preloadCount: 5, maxRetries: 3, retryDelay: 100, - onFrameLoaded, }); + frameLoader['emitter'].subscribe('frameFailed', onFrameLoaded); const loadPromise = frameLoader.loadFrame(1, "parallel"); @@ -395,14 +407,15 @@ describe("FrameLoader", () => { } as any; frameLoader = new FrameLoader({ + emitter: new Emitter(), activeBreakpoint: mockBreakpoint, firstFrame: 1, lastFrame: 10, preloadCount: 5, maxRetries: 3, retryDelay: 10, - onFrameLoaded, }); + frameLoader['emitter'].subscribe('frameLoaded', onFrameLoaded); const loadPromise = frameLoader.loadFrame(1, "parallel"); @@ -425,14 +438,15 @@ describe("FrameLoader", () => { it("should init ScrollTrigger and load neighbors on update", async () => { const onFrameLoaded = vi.fn(); frameLoader = new FrameLoader({ + emitter: new Emitter(), activeBreakpoint: mockBreakpoint, firstFrame: 1, lastFrame: 10, preloadCount: 0, maxRetries: 1, retryDelay: 0, - onFrameLoaded }); + frameLoader['emitter'].subscribe('frameLoaded', onFrameLoaded); // Spy on loadFrame const loadFrameSpy = vi.spyOn(frameLoader, 'loadFrame'); @@ -460,6 +474,7 @@ describe("FrameLoader", () => { describe("Preloading", () => { it("should preload the specified number of frames", async () => { frameLoader = new FrameLoader({ + emitter: new Emitter(), activeBreakpoint: mockBreakpoint, firstFrame: 1, lastFrame: 10, diff --git a/packages/core/src/frame-loader/frame-loader.ts b/packages/core/src/frame-loader/frame-loader.ts index bd02983..532c43a 100644 --- a/packages/core/src/frame-loader/frame-loader.ts +++ b/packages/core/src/frame-loader/frame-loader.ts @@ -2,10 +2,12 @@ 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); class FrameLoader { private firstFrameLoaded: boolean = false; + private emitter: Emitter; private activeBreakpoint: BreakpointConfig; private firstFrame: number; private lastFrame: number; @@ -13,18 +15,17 @@ class FrameLoader { private networkPolicy: NetworkPolicy | undefined; private loadingFrames: Set = new Set(); private lazyLoadingTL: ScrollTrigger | null = null; - private onFrameLoaded?: (stat: Frame) => void; private maxRetries: number; private retryDelay: number; private queue: { frameNumber: number; resolve: () => void; reject: (err: unknown) => void }[] = []; private isProcessing: boolean = false; constructor(config: FrameLoaderProps) { + this.emitter = config.emitter; this.activeBreakpoint = config.activeBreakpoint; this.firstFrame = config.firstFrame; this.lastFrame = config.lastFrame; this.preloadCount = config.preloadCount; this.networkPolicy = config.networkPolicy; - this.onFrameLoaded = config.onFrameLoaded; this.maxRetries = config.maxRetries; this.retryDelay = config.retryDelay; } @@ -90,12 +91,11 @@ class FrameLoader { url: src, image: img, attempts: attempt, + index: index, }; - if (this.onFrameLoaded) this.onFrameLoaded(stat); - console.log("Frame loaded:", stat); - this.activeBreakpoint.frames[index] = stat; this.loadingFrames.delete(index); + this.emitter.emit("frameLoaded", stat); return; } catch (error) { lastError = error; @@ -110,9 +110,8 @@ class FrameLoader { url: src, image: null, attempts: attempt, + index: index, }; - console.warn(`Frame load failed (attempt ${attempt}/${this.maxRetries}):`, stat); - await new Promise((resolve) => setTimeout(resolve, this.retryDelay)); continue; } @@ -130,9 +129,9 @@ class FrameLoader { url: src, image: null, attempts: this.maxRetries, + index: index, }; - if (this.onFrameLoaded) this.onFrameLoaded(stat); - console.error("Frame failed to load:", stat); + this.emitter.emit("frameFailed", stat); this.activeBreakpoint.frames[index] = stat; this.loadingFrames.delete(index); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4df492d..27ab0f3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,6 +8,7 @@ import { FrameLoader } from "./frame-loader/frame-loader"; import gsap from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; import { CanvasRender } from "./canvas-render/canvas-render"; +import { Emitter } from "./utils/emitter/emitter"; gsap.registerPlugin(ScrollTrigger); export class ScrollSequenceEngine { @@ -30,15 +31,23 @@ export class ScrollSequenceEngine { private dpr: number = 1; private resizeObserver: ResizeObserver | null = null; private clearCacheOnBreakpointChange: boolean = false; + private emitter: Emitter; constructor(config: ScrollSequenceProps) { + this.emitter = new Emitter(); this.config = config; this.breakpoints = []; - this.prefersReducedMotion = new PrefersReducedMotion(); + this.prefersReducedMotion = new PrefersReducedMotion(this.emitter); + this.prefersReducedMotion.init(); + + this.emitter.subscribe("motionPreferenceChanged", (isReduced: boolean) => { + this.initFramesLoadings(); + }); this.clearCacheOnBreakpointChange = config.clearCacheOnBreakpointChange ?? false; this.dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1; const canvasRenderProps = { + emitter: this.emitter, canvas: this.config.canvas, container: this.config.container, dpr: this.dpr, @@ -60,7 +69,7 @@ export class ScrollSequenceEngine { if (this.activeBreakpoint) { const first = this.activeBreakpoint.frames[0]?.image || this.activeBreakpoint.fallbackFrame || null; - this.canvasRender.drawFrame(first, this.activeBreakpoint.fallbackFrame || null); + this.emitter.emit("drawFrame", first, this.activeBreakpoint.fallbackFrame || null); } if (this.loadingConfig?.loadingMode === "lazy" && !fallbackOnly) { @@ -115,13 +124,15 @@ export class ScrollSequenceEngine { lazyLoadAroundFrame: fallbackOnly ? undefined : () => {}, }); - if (!this.config.container) return; + this.resizeObserver = new ResizeObserver(() => { + this.resize(); + }); + this.resizeObserver.observe(this.config.container); - if (this.config.container) { - this.resizeObserver = new ResizeObserver(() => { - this.resize(); + if(this.emitter){ + this.emitter.subscribe("frameLoaded", (frame: Frame) => { + this.loadingConfig?.onFrameLoaded?.(frame); }); - this.resizeObserver.observe(this.config.container); } }; @@ -130,33 +141,38 @@ export class ScrollSequenceEngine { throw new Error("ScrollSequence: assetsConfig is required and must be an array. Please check your options."); } this.breakpoints = this.normalizeBreakpoints(this.config.assetsConfig); - this.activeBreakpointManager = new ActiveBreakpoint(this.breakpoints); - this.activeBreakpointManager.init(); - this.activeBreakpoint = this.activeBreakpointManager.getActive(); - this.activeBreakpointManager.subscribe(async (breakpoint) => { + this.activeBreakpointManager = new ActiveBreakpoint(this.breakpoints, this.emitter); + + this.emitter.subscribe("breakpointChanged",async (breakpoint: BreakpointConfig) => { this.activeBreakpoint = breakpoint; this.normalizeFramesRange(this.activeBreakpoint); this.initFramesLoadingManager(); await this.initFramesLoadings(); if(this.clearCacheOnBreakpointChange){ - this.breakpoints.forEach((breakpoint) => { - if (breakpoint.name !== this.activeBreakpoint?.name) { - breakpoint.frames.forEach((frame) => { - if (!frame || !frame.image) return; - - if (frame.image.src.startsWith("blob:")) { - URL.revokeObjectURL(frame.image.src); - } - - frame.image.src = ""; - frame.image.onload = null; - frame.image.onerror = null; - frame.image = null; - }); - breakpoint.frames = []; + this.clearUnactiveBreakpoints(); + } + }); + this.activeBreakpointManager.init(); + }; + + clearUnactiveBreakpoints = () => { + if(!this.activeBreakpoint) return; + this.breakpoints.forEach((breakpoint) => { + if (breakpoint.name !== this.activeBreakpoint?.name) { + breakpoint.frames.forEach((frame) => { + if (!frame || !frame.image) return; + + if (frame.image.src.startsWith("blob:")) { + URL.revokeObjectURL(frame.image.src); } + + frame.image.src = ""; + frame.image.onload = null; + frame.image.onerror = null; + frame.image = null; }); + breakpoint.frames = []; } }); }; @@ -217,7 +233,6 @@ export class ScrollSequenceEngine { trigger: normalizedTrigger, start: loadingConfig?.start ?? "top top", markers: loadingConfig?.markers ?? false, - onFrameLoaded: loadingConfig?.onFrameLoaded, maxRetries: loadingConfig?.maxRetries ?? 3, retryDelay: loadingConfig?.retryDelay ?? 200, }; @@ -226,12 +241,12 @@ export class ScrollSequenceEngine { initFramesLoadingManager = () => { if (!this.activeBreakpoint) return; this.frameLoaderManager = new FrameLoader({ + emitter: this.emitter, activeBreakpoint: this.activeBreakpoint, firstFrame: this.firstFrame, lastFrame: this.lastFrame, preloadCount: this.loadingConfig?.preloadCount ?? this.minFramesToPreload, networkPolicy: this.config.networkPolicy, - onFrameLoaded: this.loadingConfig?.onFrameLoaded, maxRetries: this.loadingConfig?.maxRetries ?? 3, retryDelay: this.loadingConfig?.retryDelay ?? 200, }); @@ -246,7 +261,7 @@ export class ScrollSequenceEngine { const fallback = this.activeBreakpoint?.fallbackFrame; if (!frame?.image && !fallback) return; // nothing to draw yet - this.canvasRender.drawFrame(frame?.image || null, this.activeBreakpoint.fallbackFrame); + this.emitter.emit("drawFrame", frame?.image || null, this.activeBreakpoint.fallbackFrame); }; resize = () => { diff --git a/packages/core/src/reduce-motion/reduce-motion.test.ts b/packages/core/src/reduce-motion/reduce-motion.test.ts index 3cf0de1..33960c7 100644 --- a/packages/core/src/reduce-motion/reduce-motion.test.ts +++ b/packages/core/src/reduce-motion/reduce-motion.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { PrefersReducedMotion } from "./reduce-motion"; +import { Emitter } from "../utils/emitter/emitter"; describe("PrefersReducedMotion", () => { let matchMediaMock: any; @@ -44,42 +45,43 @@ describe("PrefersReducedMotion", () => { it("should initialize with false if matchMedia returns false", () => { matches = false; - const motion = new PrefersReducedMotion(); + const motion = new PrefersReducedMotion(new Emitter()); expect(motion.value).toBe(false); }); it("should initialize with true if matchMedia returns true", () => { matches = true; - const motion = new PrefersReducedMotion(); + const motion = new PrefersReducedMotion(new Emitter()); expect(motion.value).toBe(true); }); it("should set up listener on init", () => { - new PrefersReducedMotion(); + new PrefersReducedMotion(new Emitter()); expect(matchMediaMock).toHaveBeenCalledWith("(prefers-reduced-motion: reduce)"); expect(addEventListenerMock).toHaveBeenCalledWith("change", expect.any(Function)); }); it("should call onChange when media query changes", () => { - const onChange = vi.fn(); - new PrefersReducedMotion(onChange); + const emitter = new Emitter(); + const emitSpy = vi.spyOn(emitter, 'emit'); + new PrefersReducedMotion(emitter); if (changeHandler) { changeHandler({ matches: true } as MediaQueryListEvent); } - expect(onChange).toHaveBeenCalledWith(true); + expect(emitSpy).toHaveBeenCalledWith("motionPreferenceChanged", true); }); it("should remove listener on destroy", () => { - const motion = new PrefersReducedMotion(); + const motion = new PrefersReducedMotion(new Emitter()); motion.destroy(); expect(removeEventListenerMock).toHaveBeenCalledWith("change", expect.any(Function)); }); it("should handle SSR, no window", () => { vi.stubGlobal("window", undefined); - const motion = new PrefersReducedMotion(); + const motion = new PrefersReducedMotion(new Emitter()); expect(motion.value).toBe(false); // Should not crash }); diff --git a/packages/core/src/reduce-motion/reduce-motion.ts b/packages/core/src/reduce-motion/reduce-motion.ts index cf5af37..022cf24 100644 --- a/packages/core/src/reduce-motion/reduce-motion.ts +++ b/packages/core/src/reduce-motion/reduce-motion.ts @@ -1,19 +1,29 @@ +import type { Emitter } from "../utils/emitter/emitter"; + export class PrefersReducedMotion { private mediaQuery: MediaQueryList | null = null; private listener?: (event: MediaQueryListEvent) => void; + private emitter: Emitter; - constructor(private onChange?: (value: boolean) => void) { + constructor(emitter: Emitter) { + this.emitter = emitter; if (typeof window === "undefined") return; this.mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); this.listener = (event) => { - this.onChange?.(event.matches); + this.emitter.emit("motionPreferenceChanged", event.matches); }; this.mediaQuery.addEventListener("change", this.listener); } + init() { + if (this.mediaQuery) { + this.emitter.emit("motionPreferenceChanged", this.mediaQuery.matches); + } + } + get value(): boolean { return this.mediaQuery?.matches ?? false; } diff --git a/packages/core/src/types/scrollSequence.ts b/packages/core/src/types/scrollSequence.ts index e28605c..e30abdb 100644 --- a/packages/core/src/types/scrollSequence.ts +++ b/packages/core/src/types/scrollSequence.ts @@ -1,3 +1,4 @@ +import { Emitter } from "../utils/emitter/emitter"; export type DrawMode = "cover" | "contain"; export type NetworkPolicy = "adaptive" | "fallback-only"; @@ -10,6 +11,7 @@ export interface Frame { url: string; image: HTMLImageElement | null; attempts: number; + index: number; } export interface AssetConfig { @@ -178,6 +180,7 @@ export interface BreakpointConfig { export type BreakpointsConfigs = BreakpointConfig[]; export interface FrameLoaderProps { + emitter: Emitter; activeBreakpoint: BreakpointConfig; firstFrame: number; lastFrame: number; diff --git a/packages/core/src/utils/emitter/emitter.ts b/packages/core/src/utils/emitter/emitter.ts new file mode 100644 index 0000000..e55803c --- /dev/null +++ b/packages/core/src/utils/emitter/emitter.ts @@ -0,0 +1,26 @@ +export class Emitter { + private events: { [key: string]: Function[] } = {}; + + subscribe(name: string, cb: Function) { + if (!this.events[name]) { + this.events[name] = []; + } + this.events[name].push(cb); + } + + unsubscribe(name: string, cb: Function) { + if (this.events[name]) { + this.events[name] = this.events[name].filter((fn) => fn !== cb); + } + } + + emit(name: string, ...args: any[]) { + if (this.events[name]) { + this.events[name].forEach((cb) => cb(...args)); + } + } + + destroy() { + this.events = {}; + } +}