Files
Genarrative/src/mobileViewportKeyboardFocus.ts
kdletters 665f09f047 修复移动端软键盘页面弹跳黑底
移除 H5 软键盘打开时平台壳全局 transform 位移,避免浏览器原生避让后再次弹跳。

保留键盘打开状态、底部 dock 隐藏和浅色根背景兜底,避免短表单露出黑色宿主底色。

补充小程序 web-view 原生 page 浅色背景和对应样式测试。

更新统一创作页与平台键盘适配文档,沉淀不再全局上移平台壳的约束。
2026-06-07 18:17:23 +08:00

256 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}