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

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

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

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

256 lines
6.9 KiB
TypeScript

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);
}