UNPKG

@yhattav/react-component-cursor

Version:

A lightweight, TypeScript-first React library for creating beautiful custom cursors with SSR support, smooth animations, and zero dependencies. Perfect for interactive websites, games, and creative applications.

150 lines (122 loc) 4.27 kB
import { isSSR } from './ssr'; import { MouseTracker } from './MouseTracker'; export interface CursorSubscription { id: string; onPositionChange: (position: { x: number; y: number }) => void; throttleMs?: number; } interface CoordinatorSubscriberState { subscription: CursorSubscription; lastCallTime: number; timeoutId: NodeJS.Timeout | null; } class CursorCoordinator { private static instance: CursorCoordinator | null = null; private subscribers = new Map<string, CoordinatorSubscriberState>(); private mouseTracker: MouseTracker; private currentGlobalPosition: { x: number; y: number } | null = null; private isListening = false; private rafId: number | null = null; private constructor() { this.mouseTracker = MouseTracker.getInstance(); this.handleLayoutChange = this.handleLayoutChange.bind(this); } static getInstance(): CursorCoordinator { if (!CursorCoordinator.instance) { CursorCoordinator.instance = new CursorCoordinator(); } return CursorCoordinator.instance; } subscribe(subscription: CursorSubscription): () => void { if (isSSR()) { // eslint-disable-next-line @typescript-eslint/no-empty-function return () => {}; } const subscriberState: CoordinatorSubscriberState = { subscription, lastCallTime: 0, timeoutId: null, }; this.subscribers.set(subscription.id, subscriberState); // Start listening if this is the first subscriber if (!this.isListening) { this.startListening(); } // Subscribe to mouse tracker for position updates const mouseUnsubscribe = this.mouseTracker.subscribe({ id: `coordinator-${subscription.id}`, callback: (position) => { this.updateGlobalPosition(position); this.notifyPositionChange(subscription.id, position); }, throttleMs: subscription.throttleMs, }); // Return combined unsubscribe function return () => { mouseUnsubscribe(); this.unsubscribe(subscription.id); }; } private unsubscribe(id: string): void { const subscriberState = this.subscribers.get(id); if (subscriberState?.timeoutId) { clearTimeout(subscriberState.timeoutId); } this.subscribers.delete(id); // Stop listening if no more subscribers if (this.subscribers.size === 0) { this.stopListening(); } } private startListening(): void { if (this.isListening || isSSR()) return; // Listen for layout-changing events document.addEventListener('scroll', this.handleLayoutChange, true); window.addEventListener('resize', this.handleLayoutChange); this.isListening = true; } private stopListening(): void { if (!this.isListening) return; document.removeEventListener('scroll', this.handleLayoutChange, true); window.removeEventListener('resize', this.handleLayoutChange); this.isListening = false; if (this.rafId) { cancelAnimationFrame(this.rafId); this.rafId = null; } } private handleLayoutChange(): void { // Batch layout change notifications using RAF if (this.rafId === null) { this.rafId = requestAnimationFrame(() => { this.notifyAllSubscribersWithCurrentPosition(); this.rafId = null; }); } } private updateGlobalPosition(position: { x: number; y: number }): void { this.currentGlobalPosition = position; } private notifyPositionChange(subscriptionId: string, position: { x: number; y: number }): void { const subscriberState = this.subscribers.get(subscriptionId); if (!subscriberState) return; subscriberState.subscription.onPositionChange(position); } private notifyAllSubscribersWithCurrentPosition(): void { if (!this.currentGlobalPosition) return; this.subscribers.forEach((subscriberState) => { subscriberState.subscription.onPositionChange(this.currentGlobalPosition!); }); } getSubscriberCount(): number { return this.subscribers.size; } // Cleanup method for testing static resetInstance(): void { if (CursorCoordinator.instance) { CursorCoordinator.instance.stopListening(); CursorCoordinator.instance = null; } } } export { CursorCoordinator };