@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.
207 lines (168 loc) • 5.72 kB
text/typescript
import { isSSR } from './ssr';
// Types for the mouse tracker
export interface MouseSubscription {
id: string;
callback: (position: { x: number; y: number }) => void;
throttleMs?: number;
}
interface SubscriberState {
subscription: MouseSubscription;
lastCallTime: number;
timeoutId: NodeJS.Timeout | null;
}
class MouseTracker {
private static instance: MouseTracker | null = null;
private subscribers = new Map<string, SubscriberState>();
private currentPosition: { x: number; y: number } | null = null;
private isListening = false;
private rafId: number | null = null;
private constructor() {
this.handleMouseMove = this.handleMouseMove.bind(this);
}
static getInstance(): MouseTracker {
if (!MouseTracker.instance) {
MouseTracker.instance = new MouseTracker();
}
return MouseTracker.instance;
}
subscribe(subscription: MouseSubscription): () => void {
// Skip subscription during SSR
if (isSSR()) {
// eslint-disable-next-line @typescript-eslint/no-empty-function
return () => {}; // No-op cleanup
}
const subscriberState: SubscriberState = {
subscription,
lastCallTime: 0,
timeoutId: null,
};
this.subscribers.set(subscription.id, subscriberState);
// Start listening if this is the first subscriber
if (!this.isListening) {
this.startListening();
}
// Immediately notify new subscriber if we have a current position
if (this.currentPosition) {
// Use setTimeout to avoid synchronous callback during subscription
setTimeout(() => {
if (this.subscribers.has(subscription.id)) {
this.callSubscriber(subscriberState);
}
}, 0);
}
// Return unsubscribe function
return () => {
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;
document.addEventListener('mousemove', this.handleMouseMove);
this.isListening = true;
// Store position globally for persistence across navigation
this.loadGlobalPosition();
}
private stopListening(): void {
if (!this.isListening) return;
document.removeEventListener('mousemove', this.handleMouseMove);
this.isListening = false;
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
}
private handleMouseMove(event: MouseEvent): void {
const newPosition = { x: event.clientX, y: event.clientY };
this.currentPosition = newPosition;
this.saveGlobalPosition();
// Use RAF to batch notifications for better performance
if (this.rafId === null) {
this.rafId = requestAnimationFrame(() => {
this.notifySubscribers();
this.rafId = null;
});
}
}
private notifySubscribers(): void {
// Don't notify if we don't have a position yet
if (!this.currentPosition) return;
const currentTime = Date.now();
this.subscribers.forEach((subscriberState) => {
const { subscription, lastCallTime, timeoutId } = subscriberState;
const { throttleMs = 0 } = subscription;
// Apply throttling if specified
if (throttleMs > 0) {
const timeSinceLastCall = currentTime - lastCallTime;
if (timeSinceLastCall >= throttleMs) {
// Call immediately
this.callSubscriber(subscriberState);
} else {
// Schedule delayed call
if (timeoutId) {
clearTimeout(timeoutId);
}
subscriberState.timeoutId = setTimeout(() => {
this.callSubscriber(subscriberState);
subscriberState.timeoutId = null;
}, throttleMs - timeSinceLastCall);
}
} else {
// No throttling, call immediately
this.callSubscriber(subscriberState);
}
});
}
private callSubscriber(subscriberState: SubscriberState): void {
if (!this.currentPosition) return;
subscriberState.subscription.callback(this.currentPosition);
subscriberState.lastCallTime = Date.now();
}
private loadGlobalPosition(): void {
// Try to load position from global storage for persistence across navigation
try {
const stored = (window as any).__mouseTrackerPosition__;
if (stored && typeof stored.x === 'number' && typeof stored.y === 'number') {
this.currentPosition = stored;
}
} catch {
// Ignore errors when accessing global position
}
}
private saveGlobalPosition(): void {
// Save current position globally for persistence
if (this.currentPosition) {
try {
(window as any).__mouseTrackerPosition__ = { ...this.currentPosition };
} catch {
// Ignore errors when saving global position
}
}
}
// Public method to get current position (useful for initial positioning)
getCurrentPosition(): { x: number; y: number } | null {
return this.currentPosition ? { ...this.currentPosition } : null;
}
// For testing/debugging
getSubscriberCount(): number {
return this.subscribers.size;
}
// Cleanup method for testing
static resetInstance(): void {
if (MouseTracker.instance) {
MouseTracker.instance.stopListening();
MouseTracker.instance = null;
}
}
}
export { MouseTracker };