Files
Genarrative/src/mobileViewportKeyboardFocus.ts
高物 74fd9a33ac Increase VectorEngine timeouts and add image UI
Add VectorEngine image generation config and raise request timeouts (env + scripts) from 180000 to 1000000ms. Introduce a reusable CreativeImageInputPanel component with tests and wire up mobile keyboard-focus helpers; update generation views and related tests (CustomWorldGenerationView, BarkBattle editor, Match3D, Puzzle flows). Improve API error handling / VectorEngine request guidance (packages/shared http.ts and docs), and apply multiple backend/frontend fixes for puzzle/match3d/prompt handling. Also include extensive docs and decision-log updates describing UI/UX decisions and verification steps.
2026-05-15 02:40:59 +08:00

262 lines
7.1 KiB
TypeScript

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