const MOBILE_POINTER_QUERY = '(pointer: coarse)'; const KEYBOARD_OPEN_THRESHOLD_PX = 96; const FOCUS_MARGIN_PX = 18; 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'; type KeyboardFocusShiftInput = { layoutHeight: number; visualTop: number; visualHeight: number; targetTop: number; targetBottom: number; currentShift: number; margin?: number; maxExtraShift?: number; }; function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } function readVisualViewport() { return typeof window !== 'undefined' ? window.visualViewport : undefined; } 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 calculateMobileKeyboardFocusShift({ layoutHeight, visualTop, visualHeight, targetTop, targetBottom, currentShift, margin = FOCUS_MARGIN_PX, maxExtraShift = FOCUS_MARGIN_PX, }: KeyboardFocusShiftInput) { const visualBottom = visualTop + visualHeight; const safeTop = visualTop + margin; const safeBottom = visualBottom - margin; const unshiftedTargetTop = targetTop + currentShift; const unshiftedTargetBottom = targetBottom + currentShift; let nextShift = currentShift; if (unshiftedTargetBottom - nextShift > safeBottom) { nextShift = unshiftedTargetBottom - safeBottom; } if (unshiftedTargetTop - nextShift < safeTop) { nextShift = Math.max(0, unshiftedTargetTop - safeTop); } const keyboardInset = Math.max(0, layoutHeight - visualBottom); const maxShift = keyboardInset + maxExtraShift; return Math.round(clamp(nextShift, 0, Math.max(0, maxShift))); } 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 currentShift = 0; 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.dataset.mobileKeyboardOpen = 'true'; } else { delete root.dataset.mobileKeyboardOpen; } root.style.setProperty( KEYBOARD_INSET_VAR, `${Math.max(0, Math.round(insetBottom))}px`, ); }; const setFocusShift = (nextShift: number) => { currentShift = Math.max(0, Math.round(nextShift)); root.style.setProperty(KEYBOARD_FOCUS_OFFSET_VAR, `${currentShift}px`); }; 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 visualBottom = visualTop + visualHeight; const keyboardInset = Math.max(0, stableLayoutHeight - visualBottom); const keyboardOpen = Boolean(activeTarget) && stableLayoutHeight - visualHeight > KEYBOARD_OPEN_THRESHOLD_PX; if (!keyboardOpen || !activeTarget) { setKeyboardState(false); setFocusShift(0); if (!activeTarget) { setLayoutHeight(readLayoutViewportHeight()); } return; } // 中文注释:先保持整页布局高度,再只移动画布,让输入框避开键盘。 const targetRect = activeTarget.getBoundingClientRect(); const nextShift = calculateMobileKeyboardFocusShift({ layoutHeight: stableLayoutHeight, visualTop, visualHeight, targetTop: targetRect.top, targetBottom: targetRect.bottom, currentShift, }); setKeyboardState(true, keyboardInset); setFocusShift(nextShift); }; 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); setFocusShift(0); document.addEventListener('focusin', scheduleKeyboardAnimationSync, true); document.addEventListener('focusout', scheduleKeyboardAnimationSync, true); window.addEventListener('resize', scheduleKeyboardAnimationSync); window.addEventListener('orientationchange', () => { setKeyboardState(false); setFocusShift(0); window.setTimeout(() => { setLayoutHeight(readLayoutViewportHeight()); scheduleSync(); }, 320); }); visualViewport?.addEventListener('resize', scheduleKeyboardAnimationSync); visualViewport?.addEventListener('scroll', scheduleKeyboardAnimationSync); }