From 1421151724dbd2c456ddd2f5c740481c6f432dc3 Mon Sep 17 00:00:00 2001 From: johannesjo Date: Wed, 21 Jan 2026 17:45:14 +0100 Subject: [PATCH] fix(ios): prevent keyboard from overlapping inputs Use Capacitor's native WebView resize mode on iOS instead of CSS-based workarounds. When keyboard appears, the WebView itself shrinks so 100vh automatically fits above the keyboard. - Configure iOS to use `resize: 'native'` (Android keeps `resize: 'body'`) - Add scrollIntoViewIfNeeded() to scroll focused inputs into view - Add proper cleanup for keyboard event listeners - Improve flexbox shrinking in fullscreen markdown dialog --- capacitor.config.ts | 12 +++- src/app/core/theme/global-theme.service.ts | 61 ++++++++++++++++++- .../dialog-fullscreen-markdown.component.scss | 8 +++ 3 files changed, 76 insertions(+), 5 deletions(-) diff --git a/capacitor.config.ts b/capacitor.config.ts index b68adb08d..60d4865f9 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -13,9 +13,8 @@ const config: CapacitorConfig = { smallIcon: 'ic_stat_sp', }, Keyboard: { - // Resize the web view when keyboard appears (iOS) + // Default: resize body (Android) resize: 'body', - // Style keyboard accessory bar resizeOnFullScreen: true, }, StatusBar: { @@ -33,6 +32,15 @@ const config: CapacitorConfig = { allowsLinkPreview: true, // Scroll behavior scrollEnabled: true, + // iOS-specific plugin overrides + plugins: { + Keyboard: { + // Resize the native WebView when keyboard appears + // This shrinks the viewport so 100vh/100% automatically fits above keyboard + resize: 'native', + resizeOnFullScreen: true, + }, + }, }, }; diff --git a/src/app/core/theme/global-theme.service.ts b/src/app/core/theme/global-theme.service.ts index c8ddd303a..cd1a43b08 100644 --- a/src/app/core/theme/global-theme.service.ts +++ b/src/app/core/theme/global-theme.service.ts @@ -31,7 +31,8 @@ import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view'; import { androidInterface } from '../../features/android/android-interface'; import { HttpClient } from '@angular/common/http'; import { CapacitorPlatformService } from '../platform/capacitor-platform.service'; -import { Keyboard } from '@capacitor/keyboard'; +import { Keyboard, KeyboardInfo } from '@capacitor/keyboard'; +import { PluginListenerHandle } from '@capacitor/core'; import { StatusBar, Style } from '@capacitor/status-bar'; import { LS } from '../persistence/storage-keys.const'; import { CustomThemeService } from './custom-theme.service'; @@ -59,6 +60,8 @@ export class GlobalThemeService { private _environmentInjector = inject(EnvironmentInjector); private _destroyRef = inject(DestroyRef); private _hasInitialized = false; + private _keyboardListenerHandles: PluginListenerHandle[] = []; + private _focusinListener: ((event: FocusEvent) => void) | null = null; darkMode = signal( (localStorage.getItem(LS.DARK_MODE) as DarkModeCfg) || 'system', @@ -429,7 +432,7 @@ export class GlobalThemeService { * Adds/removes CSS classes when keyboard shows/hides. */ private _initIOSKeyboardHandling(): void { - Keyboard.addListener('keyboardWillShow', (info) => { + Keyboard.addListener('keyboardWillShow', (info: KeyboardInfo) => { Log.log('iOS keyboard will show', info); this.document.body.classList.add(BodyClass.isKeyboardVisible); // Set CSS variable for keyboard height to adjust layout @@ -437,15 +440,67 @@ export class GlobalThemeService { '--keyboard-height', `${info.keyboardHeight}px`, ); - }); + }).then((handle) => this._keyboardListenerHandles.push(handle)); + + // Use keyboardDidShow for scroll (after animation completes) + Keyboard.addListener('keyboardDidShow', () => { + this._scrollActiveInputIntoView(); + }).then((handle) => this._keyboardListenerHandles.push(handle)); Keyboard.addListener('keyboardWillHide', () => { Log.log('iOS keyboard will hide'); this.document.body.classList.remove(BodyClass.isKeyboardVisible); this.document.documentElement.style.setProperty('--keyboard-height', '0px'); + }).then((handle) => this._keyboardListenerHandles.push(handle)); + + // Also handle focus changes while keyboard is already visible + this._focusinListener = (event: FocusEvent): void => { + const target = event.target as HTMLElement; + if ( + this.document.body.classList.contains(BodyClass.isKeyboardVisible) && + this._isInputElement(target) + ) { + // Small delay to let CSS padding apply, validate element is still focused + setTimeout(() => { + if (this.document.activeElement === target) { + this._scrollActiveInputIntoView(); + } + }, 50); + } + }; + this.document.addEventListener('focusin', this._focusinListener, { passive: true }); + + // Cleanup listeners on destroy + this._destroyRef.onDestroy(() => { + this._keyboardListenerHandles.forEach((handle) => handle.remove()); + if (this._focusinListener) { + this.document.removeEventListener('focusin', this._focusinListener); + } }); } + private _isInputElement(el: HTMLElement): boolean { + const tagName = el.tagName.toLowerCase(); + return ( + tagName === 'input' || + tagName === 'textarea' || + tagName === 'select' || + el.isContentEditable + ); + } + + private _scrollActiveInputIntoView(): void { + const activeEl = this.document.activeElement as HTMLElement; + if (activeEl && this._isInputElement(activeEl)) { + // scrollIntoViewIfNeeded is non-standard but well-supported in iOS WebView + if ('scrollIntoViewIfNeeded' in activeEl) { + (activeEl as any).scrollIntoViewIfNeeded(true); + } else { + activeEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + } + } + /** * Initialize iOS status bar styling. * Syncs status bar style with app dark/light mode. diff --git a/src/app/ui/dialog-fullscreen-markdown/dialog-fullscreen-markdown.component.scss b/src/app/ui/dialog-fullscreen-markdown/dialog-fullscreen-markdown.component.scss index 8b48588e4..8f33ccad9 100644 --- a/src/app/ui/dialog-fullscreen-markdown/dialog-fullscreen-markdown.component.scss +++ b/src/app/ui/dialog-fullscreen-markdown/dialog-fullscreen-markdown.component.scss @@ -69,11 +69,17 @@ background-color: var(--bg-lightest); display: flex; flex-direction: column; + // Allow proper flex shrinking + min-height: 0; + overflow: hidden; .editor-section { display: flex; flex-direction: column; flex-grow: 1; + // Allow proper flex shrinking + min-height: 0; + overflow: hidden; textarea { flex-grow: 1; @@ -87,6 +93,8 @@ display: block; resize: none; font-size: 14px; + // Allow proper flex shrinking + min-height: 0; @include scrollY;