移除 H5 软键盘打开时平台壳全局 transform 位移,避免浏览器原生避让后再次弹跳。 保留键盘打开状态、底部 dock 隐藏和浅色根背景兜底,避免短表单露出黑色宿主底色。 补充小程序 web-view 原生 page 浅色背景和对应样式测试。 更新统一创作页与平台键盘适配文档,沉淀不再全局上移平台壳的约束。
256 lines
6.9 KiB
TypeScript
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);
|
||
}
|