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.
This commit is contained in:
261
src/mobileViewportKeyboardFocus.ts
Normal file
261
src/mobileViewportKeyboardFocus.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user