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