import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { SIMULATOR_CONFIG } from '../data';
const { zIndexKeyboard, keyboardHeight } = SIMULATOR_CONFIG.framework;
import { KeyboardService, type KeyboardMode } from '../keyboard/KeyboardService';
import { getZhCandidates } from '../keyboard/pinyinIme';
import { realNow } from '../TimeService';
type EditableEl = HTMLInputElement | HTMLTextAreaElement | HTMLElement;
const NON_TEXT_INPUT_TYPES = new Set([
'checkbox', 'radio', 'range', 'color', 'file',
'button', 'submit', 'reset', 'image', 'hidden',
]);
function isEditableElement(el: any): el is EditableEl {
if (!el || !(el instanceof HTMLElement)) return false;
if (el instanceof HTMLInputElement)
return !el.disabled && !el.readOnly && !NON_TEXT_INPUT_TYPES.has(el.type) && el.inputMode !== 'none';
if (el instanceof HTMLTextAreaElement) return !el.disabled && !el.readOnly;
return el.isContentEditable === true;
}
function getActiveEditable(): EditableEl | null {
const el = document.activeElement as any;
return isEditableElement(el) ? el : null;
}
function setNativeValue(el: HTMLInputElement | HTMLTextAreaElement, value: string) {
let proto: any = Object.getPrototypeOf(el);
while (proto) {
const desc = Object.getOwnPropertyDescriptor(proto, 'value');
if (desc && typeof desc.set === 'function') {
desc.set.call(el, value);
return;
}
proto = Object.getPrototypeOf(proto);
}
(el as any).value = value;
}
function dispatchInput(el: HTMLElement) {
try {
el.dispatchEvent(new Event('input', { bubbles: true }));
} catch {
// ignore
}
}
function insertText(text: string) {
const el = getActiveEditable();
if (!el) return;
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
const value = String(el.value ?? '');
const start = typeof el.selectionStart === 'number' ? el.selectionStart : value.length;
const end = typeof el.selectionEnd === 'number' ? el.selectionEnd : start;
const next = value.slice(0, start) + text + value.slice(end);
setNativeValue(el, next);
try {
const caret = start + text.length;
el.setSelectionRange(caret, caret);
} catch {
// ignore
}
dispatchInput(el);
return;
}
try {
document.execCommand('insertText', false, text);
} catch {
el.textContent = (el.textContent || '') + text;
dispatchInput(el);
}
}
function deleteBackward() {
const el = getActiveEditable();
if (!el) return;
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
const value = String(el.value ?? '');
const start = typeof el.selectionStart === 'number' ? el.selectionStart : value.length;
const end = typeof el.selectionEnd === 'number' ? el.selectionEnd : start;
if (start !== end) {
const next = value.slice(0, start) + value.slice(end);
setNativeValue(el, next);
try {
el.setSelectionRange(start, start);
} catch {
// ignore
}
dispatchInput(el);
return;
}
if (start <= 0) return;
const next = value.slice(0, start - 1) + value.slice(start);
setNativeValue(el, next);
try {
el.setSelectionRange(start - 1, start - 1);
} catch {
// ignore
}
dispatchInput(el);
return;
}
try {
document.execCommand('delete', false);
} catch {
const t = el.textContent || '';
el.textContent = t.slice(0, -1);
dispatchInput(el);
}
}
/**
* 向上查找最近的可滚动容器
*/
function findScrollContainer(element: HTMLElement): HTMLElement | null {
let current: HTMLElement | null = element.parentElement;
while (current) {
const style = window.getComputedStyle(current);
const overflowY = style.overflowY;
if (overflowY === 'auto' || overflowY === 'scroll') {
return current;
}
// 也检查 data-scroll-container 属性
if (current.dataset.scrollContainer) {
return current;
}
current = current.parentElement;
}
return null;
}
/**
* 执行智能滚动:模拟 Android ScrollView.requestChildRectangleOnScreen()。
* 最小滚动量,刚好让焦点输入框在可见区域底部露出。
* 优先使用 adjustResize wrapper 的 bounds(准确反映可见区域),
* 回退到 window.innerHeight - keyboardHeight。
*/
function doSmartScroll(target: HTMLElement, keyboardHeight: number) {
const rect = target.getBoundingClientRect();
const wrapper = target.closest('[data-adjust-resize]') as HTMLElement | null;
const visibleBottom = wrapper
? wrapper.getBoundingClientRect().bottom
: window.innerHeight - keyboardHeight;
if (rect.bottom > visibleBottom) {
const scrollContainer = findScrollContainer(target);
if (scrollContainer) {
const scrollAmount = rect.bottom - visibleBottom + 10;
scrollContainer.scrollBy({ top: scrollAmount, behavior: 'smooth' });
}
}
}
/**
* 检查元素是否应该跳过自动滚动
* - data-keep-keyboard: 元素使用布局模式处理键盘,不需要滚动
* - data-keyboard-scroll="none": 显式禁用自动滚动
*/
function shouldSkipAutoScroll(element: HTMLElement): boolean {
return !!(
element.closest?.('[data-keep-keyboard]') ||
element.closest?.('[data-keyboard-scroll="none"]')
);
}
/**
* 延迟两帧执行滚动,确保 adjustResize wrapper 已完成布局
*/
function deferredSmartScroll(target: HTMLElement, keyboardHeight: number) {
requestAnimationFrame(() =>
requestAnimationFrame(() => doSmartScroll(target, keyboardHeight)),
);
}
/**
* 智能滚动输入框到可见区域
* - 如果输入框会被键盘遮挡,滚动到刚好在键盘上方
* - 如果空间足够,不滚动
* - 如果元素使用布局模式(data-keep-keyboard),不滚动
* - 等待键盘高度更新 + adjustResize 布局完成后再判断
*/
function safeScrollIntoView(target: HTMLElement) {
try {
if (shouldSkipAutoScroll(target)) {
return;
}
const currentHeight = KeyboardService.getHeight();
if (currentHeight > 0) {
deferredSmartScroll(target, currentHeight);
return;
}
let attempts = 0;
const maxAttempts = 10;
const checkAndScroll = () => {
attempts++;
const height = KeyboardService.getHeight();
if (height > 0) {
deferredSmartScroll(target, height);
} else if (attempts < maxAttempts) {
requestAnimationFrame(checkAndScroll);
}
};
requestAnimationFrame(checkAndScroll);
} catch {
// ignore
}
}
// Key layout with secondary characters
const KEY_ROW_1 = [
{ main: 'Q', sub: '1' },
{ main: 'W', sub: '2' },
{ main: 'E', sub: '3' },
{ main: 'R', sub: '4' },
{ main: 'T', sub: '5' },
{ main: 'Y', sub: '6' },
{ main: 'U', sub: '7' },
{ main: 'I', sub: '8' },
{ main: 'O', sub: '9' },
{ main: 'P', sub: '0' },
];
const KEY_ROW_2 = [
{ main: 'A', sub: '~' },
{ main: 'S', sub: '!' },
{ main: 'D', sub: '@' },
{ main: 'F', sub: '#' },
{ main: 'G', sub: '%' },
{ main: 'H', sub: '"' },
{ main: 'J', sub: '"' },
{ main: 'K', sub: '*' },
{ main: 'L', sub: '?' },
];
const KEY_ROW_3 = [
{ main: 'Z', sub: '(' },
{ main: 'X', sub: ')' },
{ main: 'C', sub: '-' },
{ main: 'V', sub: ':' },
{ main: 'B', sub: ';' },
{ main: 'N', sub: '/' },
{ main: 'M', sub: "'" },
];
// Number/Symbol rows
const NUM_ROW_1 = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'];
const NUM_ROW_2 = ['-', '/', ':', ';', '(', ')', '¥', '@', '"'];
const NUM_ROW_3 = ['。', ',', '、', '?', '!', '.', ',', '?', '!'];
const SYM_ROW_1 = ['[', ']', '{', '}', '#', '%', '^', '*', '+', '='];
const SYM_ROW_2 = ['_', '—', '\\', '|', '~', '《', '》', '€', '&'];
const SYM_ROW_3 = ['…', '·', '「', '」', '『', '』', '【', '】', "'"];
type KeyboardPage = 'alpha' | 'num' | 'sym';
// Shared mutable container: tracks timestamp until which synthetic mouse events
// should be ignored (Chrome DevTools touch emulation ghost-click prevention).
const _ignoreMouseUntil = { current: 0 };
const Key = React.memo(function Key({ children, onTap, flex, dark, className = '' }: {
children: React.ReactNode;
onTap: () => void;
flex?: number;
dark?: boolean;
className?: string;
}) {
const tapped = useRef(false);
return (
{
if (e.pointerType === 'mouse' && realNow() < _ignoreMouseUntil.current) return;
e.preventDefault();
if (tapped.current) return;
tapped.current = true;
onTap();
setTimeout(() => { tapped.current = false; }, 80);
}}
onMouseDown={(e) => {
if (realNow() < _ignoreMouseUntil.current) {
e.preventDefault();
e.stopPropagation();
}
}}
onMouseUp={(e) => {
if (realNow() < _ignoreMouseUntil.current) {
e.preventDefault();
e.stopPropagation();
}
}}
onClick={(e) => {
if (realNow() < _ignoreMouseUntil.current) {
e.preventDefault();
e.stopPropagation();
}
}}
>
{children}
);
});
const LetterKey = React.memo(function LetterKey({ main, sub, lowercase, onTap }: {
main: string;
sub?: string;
lowercase?: boolean;
onTap: () => void;
}) {
const tapped = useRef(false);
const display = lowercase ? main.toLowerCase() : main;
return (
{
if (e.pointerType === 'mouse' && realNow() < _ignoreMouseUntil.current) return;
e.preventDefault();
if (tapped.current) return;
tapped.current = true;
onTap();
setTimeout(() => { tapped.current = false; }, 80);
}}
onMouseDown={(e) => {
if (realNow() < _ignoreMouseUntil.current) {
e.preventDefault();
e.stopPropagation();
}
}}
onMouseUp={(e) => {
if (realNow() < _ignoreMouseUntil.current) {
e.preventDefault();
e.stopPropagation();
}
}}
onClick={(e) => {
if (realNow() < _ignoreMouseUntil.current) {
e.preventDefault();
e.stopPropagation();
}
}}
>
{sub && (
{sub}
)}
{display}
);
});
const CandidateItem = React.memo(function CandidateItem({ text, onSelect }: {
text: string;
onSelect: (text: string) => void;
}) {
const startX = useRef(0);
const moved = useRef(false);
return (
{
startX.current = e.clientX;
moved.current = false;
}}
onPointerMove={(e) => {
if (Math.abs(e.clientX - startX.current) > 5) moved.current = true;
}}
onPointerUp={(e) => {
e.preventDefault();
if (!moved.current) onSelect(text);
}}
>
{text}
);
});
const ToolbarIcon = React.memo(function ToolbarIcon({ children, onTap, className = '' }: {
children: React.ReactNode;
onTap?: () => void;
className?: string;
}) {
return (
{
e.preventDefault();
e.stopPropagation();
try {
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
} catch {
// ignore
}
}}
onPointerUp={(e) => {
e.preventDefault();
e.stopPropagation();
onTap?.();
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{children}
);
});
export const KeyboardOverlay: React.FC = () => {
const rootRef = useRef(null);
const [visible, setVisible] = useState(KeyboardService.getState().visible);
const [mode, setMode] = useState(KeyboardService.getState().mode);
const [page, setPage] = useState('alpha');
const [pinyinRaw, setPinyinRaw] = useState('');
const { candidates, pinyinDisplay } = useMemo(() => {
if (mode !== 'zh' || !pinyinRaw) {
return { candidates: [] as string[], pinyinDisplay: '' };
}
const result = getZhCandidates(pinyinRaw, 20);
return { candidates: result.candidates, pinyinDisplay: result.display };
}, [pinyinRaw, mode]);
useEffect(() => {
return KeyboardService.subscribe(s => {
setVisible(s.visible);
setMode(s.mode);
});
}, []);
// show()/hide() already set height atomically — no separate setHeight needed
useEffect(() => {
if (!visible) {
setPinyinRaw('');
setPage('alpha');
}
}, [visible]);
useEffect(() => {
if (mode === 'en') {
setPinyinRaw('');
}
}, [mode]);
// 记录当前聚焦的输入元素
const focusedElementRef = useRef(null);
useEffect(() => {
const onFocusIn = (e: FocusEvent) => {
const t = e.target as any;
if (!isEditableElement(t)) return;
focusedElementRef.current = t;
KeyboardService.show();
safeScrollIntoView(t);
};
const onFocusOut = (e: FocusEvent) => {
setTimeout(() => {
const active = document.activeElement as any;
if (!isEditableElement(active)) {
// 检查原始焦点元素是否在 data-keep-keyboard 容器内
const target = e.target as HTMLElement;
if (target?.closest?.('[data-keep-keyboard]')) {
// 在 keep-keyboard 容器内,不自动关闭键盘
return;
}
focusedElementRef.current = null;
KeyboardService.hide();
}
}, 100);
};
document.addEventListener('focusin', onFocusIn, true);
document.addEventListener('focusout', onFocusOut, true);
return () => {
document.removeEventListener('focusin', onFocusIn, true);
document.removeEventListener('focusout', onFocusOut, true);
};
}, []);
// 系统级:监听 DOM 变化,如果聚焦的输入元素被移除(如路由切换),自动关闭键盘
useEffect(() => {
if (!visible) return;
const focused = focusedElementRef.current;
if (!focused) return;
const observeRoot = focused.closest('[data-adjust-resize]') ?? document.body;
const observer = new MutationObserver(() => {
if (focused && !document.body.contains(focused)) {
focusedElementRef.current = null;
KeyboardService.hide();
}
});
observer.observe(observeRoot, {
childList: true,
subtree: true,
});
return () => observer.disconnect();
}, [visible]);
// If an input is already focused, tapping it again may not fire focusin.
// Use click-capture to show keyboard WITHOUT interfering with pointer/touch sequences.
// This avoids (1) blur + immediate hide and (2) click-through onto keyboard keys.
useEffect(() => {
const onDocClickCapture = (e: MouseEvent) => {
const t = e.target as any;
if (!isEditableElement(t)) return;
// 如果键盘已显示,仍然执行滚动检查(用户可能滚动了页面)
if (KeyboardService.isVisible()) {
safeScrollIntoView(t);
return;
}
// Arm a short ignore window for synthetic mouse pointer events (touch emulation).
try {
const coarse = window.matchMedia?.('(pointer: coarse)')?.matches;
const hasTouch = (navigator as any).maxTouchPoints > 0;
const touchLike = coarse || hasTouch || 'ontouchstart' in window;
if (touchLike) {
_ignoreMouseUntil.current = realNow() + 200;
}
} catch {
// ignore
}
KeyboardService.show();
safeScrollIntoView(t);
};
document.addEventListener('click', onDocClickCapture, true);
return () => document.removeEventListener('click', onDocClickCapture, true);
}, []);
useEffect(() => {
const onDocPointerDown = (e: PointerEvent) => {
if (!KeyboardService.isVisible()) return;
const t = e.target as HTMLElement;
if (rootRef.current && t && rootRef.current.contains(t)) return;
if (isEditableElement(t)) return;
// Allow apps to mark UI (e.g., chat input bar) as "keyboard accessory" that should not dismiss the keyboard.
if ((t as HTMLElement).closest?.('[data-keep-keyboard]')) return;
if ((t as HTMLElement).closest?.('[data-gesture-bar]')) return;
KeyboardService.hide();
};
document.addEventListener('pointerdown', onDocPointerDown, true);
return () => document.removeEventListener('pointerdown', onDocPointerDown, true);
}, []);
const commitCandidate = useCallback((text: string) => {
insertText(text);
setPinyinRaw('');
}, []);
const handleSegKey = useCallback(() => {
if (mode !== 'zh') return;
setPinyinRaw(prev => {
if (!prev) return prev;
if (prev.endsWith("'")) return prev.slice(0, -1);
return `${prev}'`;
});
}, [mode]);
const handleLetter = useCallback((ch: string) => {
if (mode === 'zh') {
setPinyinRaw(prev => (prev + ch.toLowerCase()).slice(0, 32));
return;
}
insertText(ch.toLowerCase());
}, [mode]);
const handleSymbol = useCallback((sym: string) => {
insertText(sym);
}, []);
const handleSpace = useCallback(() => {
if (mode === 'zh' && pinyinRaw) {
const best = candidates[0];
if (best) {
commitCandidate(best);
} else {
insertText(' ');
setPinyinRaw('');
}
return;
}
insertText(' ');
}, [mode, pinyinRaw, candidates, commitCandidate]);
const handleBackspace = useCallback(() => {
if (mode === 'zh' && pinyinRaw) {
setPinyinRaw(prev => prev.slice(0, -1));
return;
}
deleteBackward();
}, [mode, pinyinRaw]);
const handleEnter = useCallback(() => {
const el = getActiveEditable();
if (mode === 'zh' && pinyinRaw) {
insertText(pinyinRaw);
setPinyinRaw('');
return;
}
if (el instanceof HTMLTextAreaElement || (el instanceof HTMLElement && el.isContentEditable)) {
insertText('\n');
return;
}
// For regular input elements, dispatch Enter key events to trigger form submission/search
if (el) {
const eventInit: KeyboardEventInit = {
key: 'Enter',
code: 'Enter',
keyCode: 13,
which: 13,
bubbles: true,
cancelable: true,
};
const keydownEvent = new KeyboardEvent('keydown', eventInit);
const keypressEvent = new KeyboardEvent('keypress', eventInit);
const keyupEvent = new KeyboardEvent('keyup', eventInit);
el.dispatchEvent(keydownEvent);
el.dispatchEvent(keypressEvent);
el.dispatchEvent(keyupEvent);
}
KeyboardService.hide();
}, [mode, pinyinRaw]);
const zIndex = zIndexKeyboard;
if (!visible) return null;
// Show candidate bar whenever there's pinyin input (even if no candidates yet)
const showCandidates = mode === 'zh' && pinyinRaw;
const isEnglish = mode === 'en';
return (
{/* Top toolbar / Candidate bar */}
{showCandidates ? (
<>
{/* Pinyin display */}
{pinyinDisplay || pinyinRaw}
{/* Candidates */}
e.preventDefault()}
>
{candidates.map((c, idx) => (
))}
>
) : (
<>
{/* Toolbar icons when no candidates - spread evenly */}
KeyboardService.hide()}>
>
)}
{/* Keyboard body */}
e.preventDefault()}>
{page === 'alpha' ? (
<>
{/* Row 1 */}
{KEY_ROW_1.map(k => (
handleLetter(k.main)} />
))}
{/* Row 2 */}
{KEY_ROW_2.map(k => (
handleLetter(k.main)} />
))}
{/* Row 3 */}
{isEnglish ? (
) : (
分词
)}
{KEY_ROW_3.map(k => (
handleLetter(k.main)} />
))}
{/* Row 4 */}
setPage('sym')}>
符
setPage('num')}>
123
handleSymbol(isEnglish ? ',' : ',')}>
{isEnglish ? ',' : ','}
{isEnglish && 📋}
handleSymbol(isEnglish ? '.' : '。')}>
{isEnglish ? '.' : '。'}
{ KeyboardService.toggleMode(); setPinyinRaw(''); }}>
中
英
>
) : page === 'num' ? (
<>
{/* Number Row 1 */}
{NUM_ROW_1.map(k => (
handleSymbol(k)}>
{k}
))}
{/* Number Row 2 */}
{NUM_ROW_2.map(k => (
handleSymbol(k)}>
{k}
))}
{/* Number Row 3 */}
setPage('sym')}>
#+=
{NUM_ROW_3.map(k => (
handleSymbol(k)}>
{k}
))}
{/* Number Row 4 */}
setPage('alpha')}>
ABC
handleSymbol(',')}>
,
空格
handleSymbol('。')}>
。
>
) : (
<>
{/* Symbol Row 1 */}
{SYM_ROW_1.map(k => (
handleSymbol(k)}>
{k}
))}
{/* Symbol Row 2 */}
{SYM_ROW_2.map(k => (
handleSymbol(k)}>
{k}
))}
{/* Symbol Row 3 */}
setPage('num')}>
123
{SYM_ROW_3.map(k => (
handleSymbol(k)}>
{k}
))}
{/* Symbol Row 4 */}
setPage('alpha')}>
ABC
handleSymbol(',')}>
,
空格
handleSymbol('。')}>
。
>
)}
{/* Bottom toolbar */}
{/* Left: Hide keyboard button */}
{
e.preventDefault();
e.stopPropagation();
}}
onPointerUp={(e) => {
e.preventDefault();
e.stopPropagation();
KeyboardService.hide();
}}
>
{/* Right: Keyboard icon (placeholder) */}
);
};