项目文件夹

文件
wehub-resource-sync 2114b14ee0
Sync main into demo / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:26 +08:00

953 行
33 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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 (
<div
className={`
flex items-center justify-center rounded-[5px] select-none active:opacity-70
${dark ? 'bg-[#a8abb3]' : 'bg-white'}
${className}
`}
style={{ flex: flex || 1, height: 48 }}
onPointerDown={(e) => {
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}
</div>
);
});
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 (
<div
className="flex-1 flex flex-col items-center justify-center rounded-[5px] bg-white select-none active:opacity-70 relative"
style={{ height: 48 }}
onPointerDown={(e) => {
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 && (
<span className="absolute top-[2px] left-[6px] text-[10px] text-gray-400">{sub}</span>
)}
<span className="text-[20px] text-black">{display}</span>
</div>
);
});
const CandidateItem = React.memo(function CandidateItem({ text, onSelect }: {
text: string;
onSelect: (text: string) => void;
}) {
const startX = useRef(0);
const moved = useRef(false);
return (
<div
className="px-3 h-full flex items-center text-[17px] whitespace-nowrap cursor-pointer active:bg-gray-100"
onPointerDown={(e) => {
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}
</div>
);
});
const ToolbarIcon = React.memo(function ToolbarIcon({ children, onTap, className = '' }: {
children: React.ReactNode;
onTap?: () => void;
className?: string;
}) {
return (
<div
className={`flex-1 h-12 flex items-center justify-center text-gray-500 active:bg-gray-200 ${className}`}
onPointerDown={(e) => {
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}
</div>
);
});
export const KeyboardOverlay: React.FC = () => {
const rootRef = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(KeyboardService.getState().visible);
const [mode, setMode] = useState<KeyboardMode>(KeyboardService.getState().mode);
const [page, setPage] = useState<KeyboardPage>('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<HTMLElement | null>(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 (
<div
ref={rootRef}
data-keyboard="true"
className="fixed left-0 right-0 select-none"
style={{ zIndex, bottom: 0, height: keyboardHeight, overflow: 'hidden', background: '#d1d4db' }}
>
{/* Top toolbar / Candidate bar */}
<div className="h-12 bg-[#d1d4db] flex items-center">
{showCandidates ? (
<>
{/* Pinyin display */}
<div className="px-3 text-[15px] text-blue-500 shrink-0">
{pinyinDisplay || pinyinRaw}
</div>
{/* Candidates */}
<div
className="flex-1 h-full overflow-x-auto flex items-center no-scrollbar"
style={{ WebkitOverflowScrolling: 'touch' }}
onPointerDown={(e) => e.preventDefault()}
>
{candidates.map((c, idx) => (
<CandidateItem key={`${idx}-${c}`} text={c} onSelect={commitCandidate} />
))}
</div>
</>
) : (
<>
{/* Toolbar icons when no candidates - spread evenly */}
<ToolbarIcon>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M3 10h18M9 4v18" />
</svg>
</ToolbarIcon>
<ToolbarIcon>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="2" y="6" width="20" height="12" rx="2" />
<path d="M6 10h2M10 10h4M16 10h2M5 14h3M9 14h6M16 14h3" />
</svg>
</ToolbarIcon>
<ToolbarIcon>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18M3 9h6M3 15h6" />
</svg>
</ToolbarIcon>
<ToolbarIcon>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M12 2a3 3 0 00-3 3v7a3 3 0 006 0V5a3 3 0 00-3-3z" />
<path d="M19 10v2a7 7 0 01-14 0v-2M12 19v3M8 22h8" />
</svg>
</ToolbarIcon>
<ToolbarIcon>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="12" r="10" />
<path d="M8 14s1.5 2 4 2 4-2 4-2M9 9h.01M15 9h.01" />
</svg>
</ToolbarIcon>
<ToolbarIcon onTap={() => KeyboardService.hide()}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M6 9l6 6 6-6" />
</svg>
</ToolbarIcon>
</>
)}
</div>
{/* Keyboard body */}
<div className="flex flex-col gap-[6px] p-[3px]" onPointerDown={(e) => e.preventDefault()}>
{page === 'alpha' ? (
<>
{/* Row 1 */}
<div className="flex gap-[5px]">
{KEY_ROW_1.map(k => (
<LetterKey key={k.main} main={k.main} sub={k.sub} lowercase={isEnglish} onTap={() => handleLetter(k.main)} />
))}
</div>
{/* Row 2 */}
<div className="flex gap-[5px] px-[14px]">
{KEY_ROW_2.map(k => (
<LetterKey key={k.main} main={k.main} sub={k.sub} lowercase={isEnglish} onTap={() => handleLetter(k.main)} />
))}
</div>
{/* Row 3 */}
<div className="flex gap-[5px]">
<Key flex={1.2} dark onTap={handleSegKey}>
{isEnglish ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 4L4 12h5v7h6v-7h5L12 4z" />
</svg>
) : (
<span className="text-[14px] text-black">分词</span>
)}
</Key>
{KEY_ROW_3.map(k => (
<LetterKey key={k.main} main={k.main} sub={k.sub} lowercase={isEnglish} onTap={() => handleLetter(k.main)} />
))}
<Key flex={1.2} dark onTap={handleBackspace}>
<svg width="22" height="16" viewBox="0 0 22 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M7 1h13a1 1 0 011 1v12a1 1 0 01-1 1H7l-6-7 6-7z" />
<path d="M10 5l5 5M15 5l-5 5" strokeLinecap="round" />
</svg>
</Key>
</div>
{/* Row 4 */}
<div className="flex gap-[5px]">
<Key flex={1} dark onTap={() => setPage('sym')}>
<span className="text-[15px] text-black"></span>
</Key>
<Key flex={1.2} dark onTap={() => setPage('num')}>
<span className="text-[15px] text-black">123</span>
</Key>
<Key flex={0.9} onTap={() => handleSymbol(isEnglish ? ',' : ',')}>
<div className="relative w-full h-full flex items-center justify-center">
<span className="text-[20px] text-black">{isEnglish ? ',' : ','}</span>
{isEnglish && <span className="absolute top-[2px] right-[6px] text-[10px] text-blue-500">📋</span>}
</div>
</Key>
<Key flex={3} onTap={handleSpace}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#999" strokeWidth="1.5">
<path d="M12 2a3 3 0 00-3 3v7a3 3 0 006 0V5a3 3 0 00-3-3z" />
<path d="M19 10v2a7 7 0 01-14 0v-2M12 19v3" />
</svg>
</Key>
<Key flex={0.9} onTap={() => handleSymbol(isEnglish ? '.' : '。')}>
<span className="text-[20px] text-black">{isEnglish ? '.' : '。'}</span>
</Key>
<Key flex={1.2} dark onTap={() => { KeyboardService.toggleMode(); setPinyinRaw(''); }}>
<span className="text-[13px] leading-tight text-center">
<span className={isEnglish ? 'text-gray-400' : 'text-black font-medium'}></span>
<br/>
<span className={isEnglish ? 'text-black font-medium' : 'text-gray-400'}></span>
</span>
</Key>
<Key flex={1.2} dark onTap={handleEnter}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 17l-5-5 5-5M4 12h16v-5" />
</svg>
</Key>
</div>
</>
) : page === 'num' ? (
<>
{/* Number Row 1 */}
<div className="flex gap-[5px]">
{NUM_ROW_1.map(k => (
<Key key={k} onTap={() => handleSymbol(k)}>
<span className="text-[20px] text-black">{k}</span>
</Key>
))}
</div>
{/* Number Row 2 */}
<div className="flex gap-[5px] px-[14px]">
{NUM_ROW_2.map(k => (
<Key key={k} onTap={() => handleSymbol(k)}>
<span className="text-[20px] text-black">{k}</span>
</Key>
))}
</div>
{/* Number Row 3 */}
<div className="flex gap-[5px]">
<Key flex={1.2} dark onTap={() => setPage('sym')}>
<span className="text-[14px] text-black">#+=</span>
</Key>
{NUM_ROW_3.map(k => (
<Key key={k} onTap={() => handleSymbol(k)}>
<span className="text-[18px] text-black">{k}</span>
</Key>
))}
<Key flex={1.2} dark onTap={handleBackspace}>
<svg width="22" height="16" viewBox="0 0 22 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M7 1h13a1 1 0 011 1v12a1 1 0 01-1 1H7l-6-7 6-7z" />
<path d="M10 5l5 5M15 5l-5 5" strokeLinecap="round" />
</svg>
</Key>
</div>
{/* Number Row 4 */}
<div className="flex gap-[5px]">
<Key flex={1.5} dark onTap={() => setPage('alpha')}>
<span className="text-[15px] text-black">ABC</span>
</Key>
<Key flex={1} onTap={() => handleSymbol(',')}>
<span className="text-[20px] text-black"></span>
</Key>
<Key flex={3} onTap={handleSpace}>
<span className="text-[16px] text-gray-400">空格</span>
</Key>
<Key flex={1} onTap={() => handleSymbol('。')}>
<span className="text-[20px] text-black"></span>
</Key>
<Key flex={1.5} dark onTap={handleEnter}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 17l-5-5 5-5M4 12h16v-5" />
</svg>
</Key>
</div>
</>
) : (
<>
{/* Symbol Row 1 */}
<div className="flex gap-[5px]">
{SYM_ROW_1.map(k => (
<Key key={k} onTap={() => handleSymbol(k)}>
<span className="text-[18px] text-black">{k}</span>
</Key>
))}
</div>
{/* Symbol Row 2 */}
<div className="flex gap-[5px] px-[14px]">
{SYM_ROW_2.map(k => (
<Key key={k} onTap={() => handleSymbol(k)}>
<span className="text-[18px] text-black">{k}</span>
</Key>
))}
</div>
{/* Symbol Row 3 */}
<div className="flex gap-[5px]">
<Key flex={1.2} dark onTap={() => setPage('num')}>
<span className="text-[14px] text-black">123</span>
</Key>
{SYM_ROW_3.map(k => (
<Key key={k} onTap={() => handleSymbol(k)}>
<span className="text-[18px] text-black">{k}</span>
</Key>
))}
<Key flex={1.2} dark onTap={handleBackspace}>
<svg width="22" height="16" viewBox="0 0 22 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M7 1h13a1 1 0 011 1v12a1 1 0 01-1 1H7l-6-7 6-7z" />
<path d="M10 5l5 5M15 5l-5 5" strokeLinecap="round" />
</svg>
</Key>
</div>
{/* Symbol Row 4 */}
<div className="flex gap-[5px]">
<Key flex={1.5} dark onTap={() => setPage('alpha')}>
<span className="text-[15px] text-black">ABC</span>
</Key>
<Key flex={1} onTap={() => handleSymbol(',')}>
<span className="text-[20px] text-black"></span>
</Key>
<Key flex={3} onTap={handleSpace}>
<span className="text-[16px] text-gray-400">空格</span>
</Key>
<Key flex={1} onTap={() => handleSymbol('。')}>
<span className="text-[20px] text-black"></span>
</Key>
<Key flex={1.5} dark onTap={handleEnter}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 17l-5-5 5-5M4 12h16v-5" />
</svg>
</Key>
</div>
</>
)}
</div>
{/* Bottom toolbar */}
<div className="h-12 flex items-center justify-between px-4">
{/* Left: Hide keyboard button */}
<div
className="w-10 h-10 flex items-center justify-center text-gray-500 active:bg-gray-300 rounded-lg"
onPointerDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onPointerUp={(e) => {
e.preventDefault();
e.stopPropagation();
KeyboardService.hide();
}}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M6 9l6 6 6-6" />
</svg>
</div>
{/* Right: Keyboard icon (placeholder) */}
<div className="w-10 h-10 flex items-center justify-center text-gray-500">
<svg width="24" height="18" viewBox="0 0 24 18" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="1" y="1" width="22" height="16" rx="2" />
<rect x="4" y="4" width="2" height="2" rx="0.5" fill="currentColor" stroke="none" />
<rect x="8" y="4" width="2" height="2" rx="0.5" fill="currentColor" stroke="none" />
<rect x="12" y="4" width="2" height="2" rx="0.5" fill="currentColor" stroke="none" />
<rect x="16" y="4" width="2" height="2" rx="0.5" fill="currentColor" stroke="none" />
<rect x="4" y="8" width="2" height="2" rx="0.5" fill="currentColor" stroke="none" />
<rect x="8" y="8" width="2" height="2" rx="0.5" fill="currentColor" stroke="none" />
<rect x="12" y="8" width="2" height="2" rx="0.5" fill="currentColor" stroke="none" />
<rect x="16" y="8" width="2" height="2" rx="0.5" fill="currentColor" stroke="none" />
<rect x="6" y="12" width="10" height="2" rx="0.5" fill="currentColor" stroke="none" />
</svg>
</div>
</div>
</div>
);
};