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