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
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
79 changes: 31 additions & 48 deletions packages/core/src/active-breakpoint/active-breakpoint.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -39,30 +40,30 @@ 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", () => {
const overlappingBreakpoints: BreakpointConfig[] = [
{ 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");
});
Expand All @@ -75,15 +76,15 @@ 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");
});

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");

Expand All @@ -100,34 +101,34 @@ 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);
window.dispatchEvent(new Event("resize"));

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);
Expand All @@ -138,60 +139,42 @@ 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();
});

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);
window.dispatchEvent(new Event("resize"));

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');
Expand Down
28 changes: 10 additions & 18 deletions packages/core/src/active-breakpoint/active-breakpoint.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import type { BreakpointConfig } from "../types/scrollSequence";
import type { Emitter } from "../utils/emitter/emitter";

type BreakpointListener<T> = (active: T) => void;

export class ActiveBreakpoint<T extends BreakpointConfig> {
private breakpoints: T[];
private active: T;
private listeners = new Set<BreakpointListener<T>>();
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");
}
Expand Down Expand Up @@ -41,29 +42,20 @@ export class ActiveBreakpoint<T extends BreakpointConfig> {

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<T>) {
this.listeners.add(listener);
listener(this.active);
return () => {
this.listeners.delete(listener);
};
}

private update() {
if (typeof window === "undefined") return;
Expand All @@ -81,7 +73,7 @@ export class ActiveBreakpoint<T extends BreakpointConfig> {

if (next !== this.active) {
this.active = next;
this.listeners.forEach((l) => l(this.active));
this.emitter.emit("breakpointChanged", this.active);
}
}
}
9 changes: 9 additions & 0 deletions packages/core/src/canvas-render/canvas-render.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -42,6 +43,7 @@ describe("CanvasRender", () => {

it("should initialize with provided configuration", () => {
const render = new CanvasRender({
emitter: new Emitter(),
canvas,
container,
dpr,
Expand All @@ -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,
Expand All @@ -68,6 +71,7 @@ describe("CanvasRender", () => {

it("should use scaleToCover when drawMode is 'cover'", () => {
const render = new CanvasRender({
emitter: new Emitter(),
canvas,
container,
dpr,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/canvas-render/canvas-render.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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) {
Expand Down
Loading