UNPKG

@jager-ai/holy-editor

Version:

Rich text editor with Bible verse slash commands and PWA keyboard tracking, extracted from Holy Habit project

382 lines (316 loc) 11.5 kB
/** * PWA Keyboard Tracker * * Advanced keyboard tracking for PWA environments * Extracted from Holy Habit holy-editor-pro.js */ import { PWAEnvironment, KeyboardTrackingSettings, PlatformInfo, KeyboardOffsetData } from '../types/Editor'; export class PWAKeyboardTracker { private toolbar: HTMLElement | null = null; private editorRoot: HTMLElement | null = null; private settings: KeyboardTrackingSettings; private lastKeyboardHeight = 0; private updateTimeout: NodeJS.Timeout | null = null; private isActive = false; constructor(settings?: Partial<KeyboardTrackingSettings>) { this.settings = { threshold: 10, keyboardMin: 150, debounceTime: 0, ...settings }; } /** * Initialize keyboard tracking */ public initialize(editorRoot: HTMLElement, toolbarSelector: string = '.vertical-toolbar, .mobile-toolbar, .toolbar'): void { this.editorRoot = editorRoot; this.toolbar = document.querySelector(toolbarSelector); if (!this.toolbar) { console.warn('⚠️ Toolbar element not found for keyboard tracking'); return; } console.log('📱 PWA keyboard tracking initialization'); // Log environment information const env = this.getEnvironmentInfo(); console.log('📱 Environment info:', env); // Setup tracking based on Visual Viewport API support if (this.hasVisualViewport()) { this.setupEnhancedKeyboardTracking(); } else { this.setupFallbackKeyboardTracking(); } this.isActive = true; } /** * Destroy keyboard tracking */ public destroy(): void { this.isActive = false; if (this.updateTimeout) { clearTimeout(this.updateTimeout); this.updateTimeout = null; } // Remove event listeners const events = ['resize', 'orientationchange', 'scroll']; events.forEach(evt => window.removeEventListener(evt, this.debouncedUpdate)); if (this.hasVisualViewport()) { window.visualViewport?.removeEventListener('resize', this.debouncedUpdate); window.visualViewport?.removeEventListener('scroll', this.debouncedUpdate); } // Reset toolbar position if (this.toolbar) { this.toolbar.style.transform = 'translateY(0)'; this.toolbar.style.webkitTransform = 'translateY(0)'; this.toolbar.classList.remove('keyboard-tracking'); } document.body.classList.remove('keyboard-active'); console.log('📱 PWA keyboard tracking destroyed'); } /** * Get current environment information */ public getEnvironmentInfo(): PWAEnvironment { return { isPWA: this.isPWA(), isIOS: this.isIOS(), isAndroid: this.isAndroid(), hasVisualViewport: this.hasVisualViewport(), isAndroidChrome: this.isAndroidChrome(), isSamsungInternet: this.isSamsungInternet() }; } /** * Update keyboard tracking settings */ public updateSettings(newSettings: Partial<KeyboardTrackingSettings>): void { this.settings = { ...this.settings, ...newSettings }; console.log('📱 Keyboard tracking settings updated:', this.settings); } // Platform detection methods public isPWA(): boolean { return window.matchMedia('(display-mode: standalone)').matches || (window.navigator as any).standalone === true || document.referrer.includes('android-app://'); } public isIOS(): boolean { return /iP(hone|od|ad)/.test(navigator.userAgent) && !(window as any).MSStream; } public isAndroid(): boolean { return /Android/.test(navigator.userAgent); } public isAndroidChrome(): boolean { return /Android/.test(navigator.userAgent) && /Chrome/.test(navigator.userAgent); } public isSamsungInternet(): boolean { return /SamsungBrowser/.test(navigator.userAgent); } public hasVisualViewport(): boolean { return 'visualViewport' in window; } // Enhanced keyboard tracking (Visual Viewport API) private setupEnhancedKeyboardTracking(): void { console.log('📱 Enhanced keyboard tracking enabled (Visual Viewport API)'); // Event listener registration const events = ['resize', 'orientationchange', 'scroll']; events.forEach(evt => window.addEventListener(evt, this.debouncedUpdate)); // Visual Viewport events if (window.visualViewport) { window.visualViewport.addEventListener('resize', this.debouncedUpdate); window.visualViewport.addEventListener('scroll', this.debouncedUpdate); } // Focus events document.body.addEventListener('focusin', this.debouncedUpdate); document.body.addEventListener('focusout', this.handleFocusOut); // Initial position setup setTimeout(() => this.updateToolbarPosition(), 100); } // Fallback keyboard tracking private setupFallbackKeyboardTracking(): void { console.log('📱 Fallback keyboard tracking enabled (height estimation)'); let isKeyboardVisible = false; let initialHeight = window.innerHeight; const showKeyboard = () => { if (!isKeyboardVisible && this.toolbar) { isKeyboardVisible = true; document.body.classList.add('keyboard-active'); this.toolbar.classList.add('keyboard-up'); // Estimated keyboard height const estimatedHeight = this.isIOS() ? 260 : 300; this.applyToolbarTransform(this.toolbar, estimatedHeight); console.log('📱 Keyboard shown (estimated):', estimatedHeight); } }; const hideKeyboard = () => { if (isKeyboardVisible && this.toolbar) { isKeyboardVisible = false; document.body.classList.remove('keyboard-active'); this.toolbar.classList.remove('keyboard-up'); this.toolbar.style.transform = 'translateY(0)'; this.toolbar.style.webkitTransform = 'translateY(0)'; console.log('📱 Keyboard hidden'); } }; // Height change detection const checkHeightChange = () => { const currentHeight = window.innerHeight; const heightDiff = initialHeight - currentHeight; if (heightDiff > 100) { showKeyboard(); } else if (heightDiff < 50) { hideKeyboard(); } }; // Editor focus events if (this.editorRoot) { this.editorRoot.addEventListener('focusin', () => { setTimeout(showKeyboard, 300); }); this.editorRoot.addEventListener('focusout', () => { setTimeout(() => { if (!this.isInputFocused()) { hideKeyboard(); } }, 200); }); } // Global focus events window.addEventListener('focusin', (e) => { if (e.target && (e.target as Element).matches('input, textarea, [contenteditable]')) { setTimeout(showKeyboard, 300); } }); window.addEventListener('focusout', () => { setTimeout(() => { if (!this.isInputFocused()) { hideKeyboard(); } }, 200); }); // Window resize for keyboard detection window.addEventListener('resize', () => { setTimeout(checkHeightChange, 100); }); // Orientation change window.addEventListener('orientationchange', () => { initialHeight = window.innerHeight; hideKeyboard(); }); } // Debounced update handler private debouncedUpdate = (): void => { if (!this.isActive) return; if (this.settings.debounceTime === 0) { requestAnimationFrame(() => this.updateToolbarPosition()); } else { if (this.updateTimeout) { clearTimeout(this.updateTimeout); } this.updateTimeout = setTimeout(() => this.updateToolbarPosition(), this.settings.debounceTime); } }; // Update toolbar position private updateToolbarPosition(): void { if (!this.toolbar || !this.isActive) return; const offset = this.calculateKeyboardOffset(); // Apply transform if offset changed significantly if (Math.abs(offset - this.lastKeyboardHeight) > this.settings.threshold) { this.applyToolbarTransform(this.toolbar, offset); this.lastKeyboardHeight = offset; console.log('📏 Toolbar position adjusted:', { offset: offset, visualHeight: window.visualViewport?.height, innerHeight: window.innerHeight }); } // Update keyboard state CSS classes this.updateKeyboardClass(offset); } // Calculate keyboard offset private calculateKeyboardOffset(): number { let offset = 0; if (this.hasVisualViewport() && window.visualViewport) { const vh = window.visualViewport.height; const keyboardHeight = window.innerHeight - vh; if (keyboardHeight > this.settings.keyboardMin) { offset = this.getPlatformSpecificOffset(keyboardHeight); } } else if (this.isInputFocused()) { // Visual Viewport API not supported, use estimation offset = 300; } return Math.max(0, offset); } // Platform-specific offset calculation private getPlatformSpecificOffset(keyboardHeight: number): number { // iOS PWA status bar height adjustment (20px) if (this.isIOS() && this.isPWA()) { return keyboardHeight - 20; } // Android PWA navigation bar height detection if (this.isAndroid() && this.isPWA()) { const navBarHeight = window.outerHeight - window.innerHeight; return keyboardHeight - navBarHeight; } // Regular web browser return keyboardHeight; } // Apply toolbar transform private applyToolbarTransform(toolbar: HTMLElement, offset: number): void { toolbar.style.transform = `translateY(-${offset}px)`; toolbar.style.webkitTransform = `translateY(-${offset}px)`; toolbar.style.transition = 'transform 0.3s ease-out'; } // Update keyboard CSS classes private updateKeyboardClass(offset: number): void { if (!this.toolbar) return; if (offset > 100) { document.body.classList.add('keyboard-active'); this.toolbar.classList.add('keyboard-tracking'); } else { document.body.classList.remove('keyboard-active'); this.toolbar.classList.remove('keyboard-tracking'); } } // Check if input field is focused private isInputFocused(): boolean { return document.activeElement && document.activeElement.matches('input, textarea, [contenteditable]'); } // Focus out handler private handleFocusOut = (): void => { setTimeout(() => { if (!this.isInputFocused() && this.toolbar) { this.toolbar.style.transform = 'translateY(0)'; this.toolbar.style.webkitTransform = 'translateY(0)'; document.body.classList.remove('keyboard-active'); this.toolbar.classList.remove('keyboard-tracking'); this.lastKeyboardHeight = 0; } }, 100); }; /** * Get current keyboard offset data */ public getKeyboardOffsetData(): KeyboardOffsetData { const offset = this.calculateKeyboardOffset(); return { offset, visualHeight: window.visualViewport?.height, innerHeight: window.innerHeight, keyboardHeight: window.innerHeight - (window.visualViewport?.height || window.innerHeight) }; } /** * Force toolbar position update */ public forceUpdate(): void { this.updateToolbarPosition(); } /** * Check if keyboard tracking is active */ public isTrackingActive(): boolean { return this.isActive; } }