UNPKG

detect-tab

Version:

A comprehensive tab detection and management library for web applications

465 lines (398 loc) 12.2 kB
import { TabState, TabEvent, DetectTabOptions, TabInfo, TabEventCallback, TabStateChangeCallback, TabFocusCallback } from './types'; import { isVisibilityAPISupported, isBrowserSupported, getVisibilityProperties, debounce, now, formatDuration, safeJSONParse, isStorageAvailable } from './utils'; /** * Main DetectTab class for tab detection and management */ export class DetectTab { private options: Required<DetectTabOptions>; private listeners: Map<string, Set<TabEventCallback>> = new Map(); private stateChangeListeners: Set<TabStateChangeCallback> = new Set(); private focusListeners: Set<TabFocusCallback> = new Set(); private info: TabInfo; private startTime: number; private lastStateChange: number; private visibilityProperties: { hidden: string; visibilityChange: string }; private isActive: boolean = false; constructor(options: DetectTabOptions = {}) { if (!isBrowserSupported()) { throw new Error('DetectTab requires a browser environment'); } this.options = { autoStart: true, debounceTime: 100, debug: false, storageKey: 'detectTab_data', persistent: false, ...options }; this.visibilityProperties = getVisibilityProperties(); this.startTime = now(); this.lastStateChange = this.startTime; // Initialize tab info this.info = this.createInitialTabInfo(); // Load persisted data if enabled if (this.options.persistent) { this.loadPersistedData(); } // Auto-start if enabled if (this.options.autoStart) { this.start(); } this.log('DetectTab initialized', this.info); } /** * Start tab detection */ public start(): void { if (this.isActive) { this.log('Already active'); return; } this.isActive = true; this.attachEventListeners(); this.log('Tab detection started'); } /** * Stop tab detection */ public stop(): void { if (!this.isActive) { this.log('Already inactive'); return; } this.isActive = false; this.detachEventListeners(); this.log('Tab detection stopped'); } /** * Get current tab information */ public getTabInfo(): TabInfo { this.updateTimeInState(); return { ...this.info }; } /** * Check if tab is currently visible */ public isVisible(): boolean { return this.info.visible; } /** * Check if tab is currently focused */ public isFocused(): boolean { return this.info.focused; } /** * Get current tab state */ public getState(): TabState { return this.info.state; } /** * Get formatted time statistics */ public getTimeStats(): { totalVisibleTime: string; totalHiddenTime: string; timeInCurrentState: string; } { this.updateTimeInState(); return { totalVisibleTime: formatDuration(this.info.totalVisibleTime), totalHiddenTime: formatDuration(this.info.totalHiddenTime), timeInCurrentState: formatDuration(this.info.timeInState) }; } /** * Add event listener for specific tab events */ public on(event: TabEvent, callback: TabEventCallback): void { if (!this.listeners.has(event)) { this.listeners.set(event, new Set()); } this.listeners.get(event)!.add(callback); this.log(`Added listener for ${event}`); } /** * Remove event listener */ public off(event: TabEvent, callback: TabEventCallback): void { const eventListeners = this.listeners.get(event); if (eventListeners) { eventListeners.delete(callback); if (eventListeners.size === 0) { this.listeners.delete(event); } this.log(`Removed listener for ${event}`); } } /** * Add state change listener */ public onStateChange(callback: TabStateChangeCallback): void { this.stateChangeListeners.add(callback); this.log('Added state change listener'); } /** * Remove state change listener */ public offStateChange(callback: TabStateChangeCallback): void { this.stateChangeListeners.delete(callback); this.log('Removed state change listener'); } /** * Add focus change listener */ public onFocusChange(callback: TabFocusCallback): void { this.focusListeners.add(callback); this.log('Added focus change listener'); } /** * Remove focus change listener */ public offFocusChange(callback: TabFocusCallback): void { this.focusListeners.delete(callback); this.log('Removed focus change listener'); } /** * Clear all persisted data */ public clearPersistedData(): void { if (isStorageAvailable()) { localStorage.removeItem(this.options.storageKey); this.log('Cleared persisted data'); } } /** * Reset all statistics */ public reset(): void { this.startTime = now(); this.lastStateChange = this.startTime; this.info = this.createInitialTabInfo(); this.log('Statistics reset'); } /** * Destroy the instance and clean up */ public destroy(): void { this.stop(); this.listeners.clear(); this.stateChangeListeners.clear(); this.focusListeners.clear(); if (this.options.persistent) { this.savePersistedData(); } this.log('DetectTab destroyed'); } private createInitialTabInfo(): TabInfo { const currentState = this.getCurrentState(); const isFocused = this.getCurrentFocus(); return { state: currentState, focused: isFocused, visible: currentState === TabState.VISIBLE, lastChanged: this.startTime, timeInState: 0, totalVisibleTime: 0, totalHiddenTime: 0, visibilityChanges: 0 }; } private getCurrentState(): TabState { if (!isVisibilityAPISupported()) { return TabState.VISIBLE; } switch (document.visibilityState) { case 'visible': return TabState.VISIBLE; case 'hidden': return TabState.HIDDEN; default: return document.visibilityState === 'prerender' ? TabState.PRERENDER : TabState.VISIBLE; } } private getCurrentFocus(): boolean { return document.hasFocus(); } private attachEventListeners(): void { if (isVisibilityAPISupported()) { document.addEventListener( this.visibilityProperties.visibilityChange, this.handleVisibilityChange ); } window.addEventListener('focus', this.handleFocus); window.addEventListener('blur', this.handleBlur); window.addEventListener('beforeunload', this.handleBeforeUnload); window.addEventListener('unload', this.handleUnload); window.addEventListener('pageshow', this.handlePageShow); window.addEventListener('pagehide', this.handlePageHide); } private detachEventListeners(): void { if (isVisibilityAPISupported()) { document.removeEventListener( this.visibilityProperties.visibilityChange, this.handleVisibilityChange ); } window.removeEventListener('focus', this.handleFocus); window.removeEventListener('blur', this.handleBlur); window.removeEventListener('beforeunload', this.handleBeforeUnload); window.removeEventListener('unload', this.handleUnload); window.removeEventListener('pageshow', this.handlePageShow); window.removeEventListener('pagehide', this.handlePageHide); } private handleVisibilityChange = (): void => { const debouncedHandler = debounce((): void => { const newState = this.getCurrentState(); this.updateState(newState); this.emitEvent(TabEvent.VISIBILITY_CHANGE); }, this.options.debounceTime); debouncedHandler(); }; private handleFocus = (): void => { const debouncedHandler = debounce((): void => { this.updateFocus(true); this.emitEvent(TabEvent.FOCUS); }, this.options.debounceTime); debouncedHandler(); }; private handleBlur = (): void => { const debouncedHandler = debounce((): void => { this.updateFocus(false); this.emitEvent(TabEvent.BLUR); }, this.options.debounceTime); debouncedHandler(); }; private handleBeforeUnload = (): void => { this.emitEvent(TabEvent.BEFORE_UNLOAD); if (this.options.persistent) { this.savePersistedData(); } }; private handleUnload = (): void => { this.emitEvent(TabEvent.UNLOAD); }; private handlePageShow = (): void => { this.emitEvent(TabEvent.PAGE_SHOW); }; private handlePageHide = (): void => { this.emitEvent(TabEvent.PAGE_HIDE); if (this.options.persistent) { this.savePersistedData(); } }; private updateState(newState: TabState): void { if (newState === this.info.state) return; this.updateTimeInState(); const oldState = this.info.state; this.info.state = newState; this.info.visible = newState === TabState.VISIBLE; this.info.lastChanged = now(); this.info.visibilityChanges++; this.lastStateChange = this.info.lastChanged; this.stateChangeListeners.forEach(callback => { try { callback(newState, this.getTabInfo()); } catch (error) { this.log('Error in state change callback:', error); } }); this.log(`State changed: ${oldState} -> ${newState}`); } private updateFocus(focused: boolean): void { if (focused === this.info.focused) return; this.info.focused = focused; this.info.lastChanged = now(); this.focusListeners.forEach(callback => { try { callback(focused, this.getTabInfo()); } catch (error) { this.log('Error in focus change callback:', error); } }); this.log(`Focus changed: ${focused}`); } private updateTimeInState(): void { const currentTime = now(); const timeDiff = currentTime - this.lastStateChange; this.info.timeInState = timeDiff; if (this.info.visible) { this.info.totalVisibleTime += timeDiff; } else { this.info.totalHiddenTime += timeDiff; } this.lastStateChange = currentTime; } private emitEvent(eventType: TabEvent): void { const eventListeners = this.listeners.get(eventType); if (!eventListeners) return; const tabInfo = this.getTabInfo(); eventListeners.forEach(callback => { try { callback(tabInfo); } catch (error) { this.log(`Error in ${eventType} callback:`, error); } }); } private savePersistedData(): void { if (!isStorageAvailable()) return; try { const data = { totalVisibleTime: this.info.totalVisibleTime, totalHiddenTime: this.info.totalHiddenTime, visibilityChanges: this.info.visibilityChanges, lastSaved: now() }; localStorage.setItem(this.options.storageKey, JSON.stringify(data)); this.log('Data persisted to localStorage'); } catch (error) { this.log('Failed to persist data:', error); } } private loadPersistedData(): void { if (!isStorageAvailable()) return; try { const stored = localStorage.getItem(this.options.storageKey); if (stored) { const data = safeJSONParse(stored, { totalVisibleTime: 0, totalHiddenTime: 0, visibilityChanges: 0, lastSaved: 0 }); this.info.totalVisibleTime = data.totalVisibleTime || 0; this.info.totalHiddenTime = data.totalHiddenTime || 0; this.info.visibilityChanges = data.visibilityChanges || 0; this.log('Loaded persisted data:', data); } } catch (error) { this.log('Failed to load persisted data:', error); } } private log(...args: any[]): void { if (this.options.debug) { console.log('[DetectTab]', ...args); } } }