detect-tab
Version:
A comprehensive tab detection and management library for web applications
465 lines (398 loc) • 12.2 kB
text/typescript
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);
}
}
}