const MOBILE_POINTER_QUERY = '(pointer: coarse)'; const KEYBOARD_OPEN_THRESHOLD_PX = 96; const MIN_LAYOUT_VIEWPORT_HEIGHT_PX = 320; const LAYOUT_HEIGHT_VAR = '--platform-layout-viewport-height'; const KEYBOARD_FOCUS_OFFSET_VAR = '--platform-keyboard-focus-offset'; const KEYBOARD_INSET_VAR = '--platform-keyboard-inset-bottom'; const KEYBOARD_EXPOSED_FILL_VAR = '--platform-keyboard-exposed-fill'; const KEYBOARD_EXPOSED_FILL_FALLBACK = 'linear-gradient(180deg, #fffdf9 0%, #fdf9f5 54%, #f8efe7 100%)'; type KeyboardFocusShiftInput = { layoutHeight: number; visualTop: number; visualHeight: number; hasEditableTarget: boolean; threshold?: number; }; function readVisualViewport() { return typeof window !== 'undefined' ? window.visualViewport : undefined; } export function resolveMobileKeyboardExposedFill() { if (typeof window === 'undefined' || typeof document === 'undefined') { return KEYBOARD_EXPOSED_FILL_FALLBACK; } const platformShell = document.querySelector('.platform-viewport-shell'); if (!(platformShell instanceof HTMLElement)) { return KEYBOARD_EXPOSED_FILL_FALLBACK; } const platformFill = window .getComputedStyle(platformShell) .getPropertyValue('--platform-body-fill') .trim(); return platformFill || KEYBOARD_EXPOSED_FILL_FALLBACK; } function readLayoutViewportHeight() { if (typeof window === 'undefined' || typeof document === 'undefined') { return MIN_LAYOUT_VIEWPORT_HEIGHT_PX; } const visualViewport = readVisualViewport(); return Math.max( window.innerHeight || 0, document.documentElement.clientHeight || 0, visualViewport?.height ?? 0, MIN_LAYOUT_VIEWPORT_HEIGHT_PX, ); } function shouldHandleMobileKeyboardFocus() { if (typeof window === 'undefined' || typeof navigator === 'undefined') { return false; } return ( navigator.maxTouchPoints > 0 || window.matchMedia(MOBILE_POINTER_QUERY).matches ); } function isDisabledEditableControl(element: HTMLElement) { return ( 'disabled' in element && Boolean((element as HTMLInputElement | HTMLTextAreaElement).disabled) ); } function isReadOnlyEditableControl(element: HTMLElement) { return ( 'readOnly' in element && Boolean((element as HTMLInputElement | HTMLTextAreaElement).readOnly) ); } /** * 中文注释:只把会唤起移动端输入法的控件纳入键盘聚焦处理。 * 文件、滑块、复选框等输入控件不需要挪动画布。 */ export function isEditableKeyboardTarget( element: Element | null, ): element is HTMLElement { if ( typeof HTMLElement === 'undefined' || !(element instanceof HTMLElement) ) { return false; } if (element.isContentEditable) { return true; } if (isDisabledEditableControl(element) || isReadOnlyEditableControl(element)) { return false; } if (element instanceof HTMLTextAreaElement) { return true; } if (!(element instanceof HTMLInputElement)) { return false; } const inputType = (element.type || 'text').toLowerCase(); return !new Set([ 'button', 'checkbox', 'color', 'file', 'hidden', 'image', 'radio', 'range', 'reset', 'submit', ]).has(inputType); } export function resolveMobileKeyboardState({ layoutHeight, visualTop, visualHeight, hasEditableTarget, threshold = KEYBOARD_OPEN_THRESHOLD_PX, }: KeyboardFocusShiftInput) { const visualBottom = visualTop + visualHeight; const insetBottom = Math.max(0, Math.round(layoutHeight - visualBottom)); const isOpen = hasEditableTarget && layoutHeight - visualHeight > threshold; return { isOpen, insetBottom: isOpen ? insetBottom : 0, }; } export function stabilizeMobileViewportKeyboardFocus() { if ( typeof document === 'undefined' || !shouldHandleMobileKeyboardFocus() || document.documentElement.dataset.mobileViewportKeyboardFocus === 'true' ) { return; } document.documentElement.dataset.mobileViewportKeyboardFocus = 'true'; const root = document.documentElement; const visualViewport = readVisualViewport(); let stableLayoutHeight = readLayoutViewportHeight(); let frameId = 0; const setLayoutHeight = (nextHeight: number) => { stableLayoutHeight = Math.max( MIN_LAYOUT_VIEWPORT_HEIGHT_PX, Math.round(nextHeight), ); root.style.setProperty(LAYOUT_HEIGHT_VAR, `${stableLayoutHeight}px`); }; const setKeyboardState = (isOpen: boolean, insetBottom = 0) => { if (isOpen) { root.style.setProperty( KEYBOARD_EXPOSED_FILL_VAR, resolveMobileKeyboardExposedFill(), ); root.dataset.mobileKeyboardOpen = 'true'; } else { delete root.dataset.mobileKeyboardOpen; } root.style.setProperty( KEYBOARD_INSET_VAR, `${Math.max(0, Math.round(insetBottom))}px`, ); }; const resetFocusShift = () => { root.style.setProperty(KEYBOARD_FOCUS_OFFSET_VAR, '0px'); }; const readActiveTarget = () => isEditableKeyboardTarget(document.activeElement) ? document.activeElement : null; const syncKeyboardFocus = () => { const activeTarget = readActiveTarget(); const viewport = readVisualViewport(); const visualTop = viewport?.offsetTop ?? 0; const visualHeight = viewport?.height ?? window.innerHeight; const keyboardState = resolveMobileKeyboardState({ layoutHeight: stableLayoutHeight, visualTop, visualHeight, hasEditableTarget: Boolean(activeTarget), }); if (!keyboardState.isOpen || !activeTarget) { setKeyboardState(false); resetFocusShift(); if (!activeTarget) { setLayoutHeight(readLayoutViewportHeight()); } return; } // 中文注释:H5 浏览器和小程序 web-view 已会自行处理输入框可见性。 // 这里只记录键盘状态、隐藏底部 dock,并给可能露出的宿主区域补浅色背景。 setKeyboardState(true, keyboardState.insetBottom); resetFocusShift(); }; const scheduleSync = () => { if (frameId) { window.cancelAnimationFrame(frameId); } frameId = window.requestAnimationFrame(() => { frameId = 0; syncKeyboardFocus(); }); }; const scheduleKeyboardAnimationSync = () => { scheduleSync(); window.setTimeout(scheduleSync, 90); window.setTimeout(scheduleSync, 260); }; setLayoutHeight(stableLayoutHeight); setKeyboardState(false); resetFocusShift(); document.addEventListener('focusin', scheduleKeyboardAnimationSync, true); document.addEventListener('focusout', scheduleKeyboardAnimationSync, true); window.addEventListener('resize', scheduleKeyboardAnimationSync); window.addEventListener('orientationchange', () => { setKeyboardState(false); resetFocusShift(); window.setTimeout(() => { setLayoutHeight(readLayoutViewportHeight()); scheduleSync(); }, 320); }); visualViewport?.addEventListener('resize', scheduleKeyboardAnimationSync); visualViewport?.addEventListener('scroll', scheduleKeyboardAnimationSync); }