@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
text/typescript
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 };