| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187 |
- import { useEffect, useRef, useState, useCallback } from 'react';
- import Keyboard from 'react-simple-keyboard';
- import 'react-simple-keyboard/build/css/index.css';
- import './VirtualKeyboard.css';
- const FOCUSABLE_TYPES = new Set(['text', 'password', 'email', 'search', 'url']);
- /**
- * Set value on a controlled React input using the native setter,
- * then dispatch an input event so React picks up the change.
- */
- function setNativeValue(input: HTMLInputElement | HTMLTextAreaElement, value: string) {
- const setter =
- Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set ??
- Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
- setter?.call(input, value);
- input.dispatchEvent(new Event('input', { bubbles: true }));
- }
- export function VirtualKeyboard() {
- const [visible, setVisible] = useState(false);
- const [closing, setClosing] = useState(false);
- const closingRef = useRef(false);
- const [layoutName, setLayoutName] = useState('default');
- const activeInput = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
- const keyboardRef = useRef<ReturnType<typeof Keyboard> | null>(null);
- const containerRef = useRef<HTMLDivElement>(null);
- const handleFocusIn = useCallback((e: FocusEvent) => {
- if (closingRef.current) return;
- const target = e.target as HTMLElement;
- // Skip inputs that opt out (e.g. SpoolBuddySettingsPage numpad field)
- if (target.closest('[data-vkb="false"]')) return;
- if (target instanceof HTMLInputElement) {
- if (!FOCUSABLE_TYPES.has(target.type)) return;
- } else if (!(target instanceof HTMLTextAreaElement)) {
- return;
- }
- activeInput.current = target as HTMLInputElement | HTMLTextAreaElement;
- setVisible(true);
- setLayoutName('default');
- // Sync keyboard display with current value
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (keyboardRef.current as any)?.setInput?.(activeInput.current.value);
- // Scroll input into view above the keyboard
- setTimeout(() => {
- target.scrollIntoView({ behavior: 'smooth', block: 'center' });
- }, 100);
- }, []);
- const handleFocusOut = useCallback(() => {
- // Delay to allow click on keyboard buttons to register
- setTimeout(() => {
- const active = document.activeElement;
- // Keep visible if focus moved to keyboard or back to same input
- if (
- active &&
- (containerRef.current?.contains(active) || active === activeInput.current)
- ) {
- return;
- }
- setVisible(false);
- activeInput.current = null;
- }, 150);
- }, []);
- useEffect(() => {
- document.addEventListener('focusin', handleFocusIn);
- document.addEventListener('focusout', handleFocusOut);
- return () => {
- document.removeEventListener('focusin', handleFocusIn);
- document.removeEventListener('focusout', handleFocusOut);
- };
- }, [handleFocusIn, handleFocusOut]);
- // Two-phase close: hide the keyboard immediately but keep the backdrop
- // alive for 400ms to absorb the ghost click that touch devices synthesize.
- const dismiss = useCallback(() => {
- closingRef.current = true;
- setClosing(true);
- activeInput.current?.blur();
- activeInput.current = null;
- setTimeout(() => {
- setVisible(false);
- setClosing(false);
- closingRef.current = false;
- }, 400);
- }, []);
- const onKeyPress = useCallback((button: string) => {
- const input = activeInput.current;
- if (!input) return;
- if (button === '{shift}') {
- setLayoutName(prev => prev === 'default' ? 'shift' : 'default');
- return;
- }
- if (button === '{lock}') {
- setLayoutName(prev => prev === 'default' ? 'shift' : 'default');
- return;
- }
- if (button === '{close}') {
- dismiss();
- return;
- }
- if (button === '{bksp}') {
- setNativeValue(input, input.value.slice(0, -1));
- } else if (button === '{space}') {
- setNativeValue(input, input.value + ' ');
- } else {
- setNativeValue(input, input.value + button);
- // Auto-unshift after typing one character (like mobile keyboards)
- if (layoutName === 'shift') {
- setLayoutName('default');
- }
- }
- // Keep focus on the input
- input.focus();
- // Sync keyboard internal state
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (keyboardRef.current as any)?.setInput?.(input.value);
- }, [layoutName, dismiss]);
- if (!visible) return null;
- return (
- <>
- {/* Backdrop: absorbs taps so they don't reach elements under the keyboard.
- Stays alive during closing phase to catch ghost clicks. */}
- <div
- className="fixed inset-0 z-[9998] bg-transparent"
- onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); if (!closing) dismiss(); }}
- onTouchStart={(e) => { e.preventDefault(); e.stopPropagation(); if (!closing) dismiss(); }}
- onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
- />
- {!closing && (
- <div
- ref={containerRef}
- className="fixed bottom-0 left-0 right-0 z-[9999]"
- onMouseDown={(e) => e.preventDefault()}
- onTouchStart={(e) => {
- // Prevent focus loss but allow button interaction
- if (!(e.target as HTMLElement).closest('.hg-button')) {
- e.preventDefault();
- }
- }}
- >
- <Keyboard
- keyboardRef={(r: ReturnType<typeof Keyboard>) => { keyboardRef.current = r; }}
- layoutName={layoutName}
- onKeyPress={onKeyPress}
- theme="simple-keyboard vkb-theme"
- layout={{
- default: [
- '1 2 3 4 5 6 7 8 9 0 {bksp}',
- 'q w e r t y u i o p',
- '{lock} a s d f g h j k l',
- '{shift} z x c v b n m . @',
- '{space} {close}',
- ],
- shift: [
- '! @ # $ % ^ & * ( ) {bksp}',
- 'Q W E R T Y U I O P',
- '{lock} A S D F G H J K L',
- '{shift} Z X C V B N M , _',
- '{space} {close}',
- ],
- }}
- display={{
- '{bksp}': '\u232B',
- '{close}': '\u2715 Close',
- '{shift}': '\u21E7',
- '{lock}': '\u21EA',
- '{space}': ' ',
- }}
- />
- </div>
- )}
- </>
- );
- }
|