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.
262 lines
7.1 KiB
TypeScript
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);
|
|
}
|