VirtualKeyboard.tsx 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. import { useEffect, useRef, useState, useCallback } from 'react';
  2. import Keyboard from 'react-simple-keyboard';
  3. import 'react-simple-keyboard/build/css/index.css';
  4. import './VirtualKeyboard.css';
  5. const FOCUSABLE_TYPES = new Set(['text', 'password', 'email', 'search', 'url']);
  6. /**
  7. * Set value on a controlled React input using the native setter,
  8. * then dispatch an input event so React picks up the change.
  9. */
  10. function setNativeValue(input: HTMLInputElement | HTMLTextAreaElement, value: string) {
  11. const setter =
  12. Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set ??
  13. Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
  14. setter?.call(input, value);
  15. input.dispatchEvent(new Event('input', { bubbles: true }));
  16. }
  17. export function VirtualKeyboard() {
  18. const [visible, setVisible] = useState(false);
  19. const [closing, setClosing] = useState(false);
  20. const closingRef = useRef(false);
  21. const [layoutName, setLayoutName] = useState('default');
  22. const activeInput = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
  23. const keyboardRef = useRef<ReturnType<typeof Keyboard> | null>(null);
  24. const containerRef = useRef<HTMLDivElement>(null);
  25. const handleFocusIn = useCallback((e: FocusEvent) => {
  26. if (closingRef.current) return;
  27. const target = e.target as HTMLElement;
  28. // Skip inputs that opt out (e.g. SpoolBuddySettingsPage numpad field)
  29. if (target.closest('[data-vkb="false"]')) return;
  30. if (target instanceof HTMLInputElement) {
  31. if (!FOCUSABLE_TYPES.has(target.type)) return;
  32. } else if (!(target instanceof HTMLTextAreaElement)) {
  33. return;
  34. }
  35. activeInput.current = target as HTMLInputElement | HTMLTextAreaElement;
  36. setVisible(true);
  37. setLayoutName('default');
  38. // Sync keyboard display with current value
  39. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  40. (keyboardRef.current as any)?.setInput?.(activeInput.current.value);
  41. // Scroll input into view above the keyboard
  42. setTimeout(() => {
  43. target.scrollIntoView({ behavior: 'smooth', block: 'center' });
  44. }, 100);
  45. }, []);
  46. const handleFocusOut = useCallback(() => {
  47. // Delay to allow click on keyboard buttons to register
  48. setTimeout(() => {
  49. const active = document.activeElement;
  50. // Keep visible if focus moved to keyboard or back to same input
  51. if (
  52. active &&
  53. (containerRef.current?.contains(active) || active === activeInput.current)
  54. ) {
  55. return;
  56. }
  57. setVisible(false);
  58. activeInput.current = null;
  59. }, 150);
  60. }, []);
  61. useEffect(() => {
  62. document.addEventListener('focusin', handleFocusIn);
  63. document.addEventListener('focusout', handleFocusOut);
  64. return () => {
  65. document.removeEventListener('focusin', handleFocusIn);
  66. document.removeEventListener('focusout', handleFocusOut);
  67. };
  68. }, [handleFocusIn, handleFocusOut]);
  69. // Two-phase close: hide the keyboard immediately but keep the backdrop
  70. // alive for 400ms to absorb the ghost click that touch devices synthesize.
  71. const dismiss = useCallback(() => {
  72. closingRef.current = true;
  73. setClosing(true);
  74. activeInput.current?.blur();
  75. activeInput.current = null;
  76. setTimeout(() => {
  77. setVisible(false);
  78. setClosing(false);
  79. closingRef.current = false;
  80. }, 400);
  81. }, []);
  82. const onKeyPress = useCallback((button: string) => {
  83. const input = activeInput.current;
  84. if (!input) return;
  85. if (button === '{shift}') {
  86. setLayoutName(prev => prev === 'default' ? 'shift' : 'default');
  87. return;
  88. }
  89. if (button === '{lock}') {
  90. setLayoutName(prev => prev === 'default' ? 'shift' : 'default');
  91. return;
  92. }
  93. if (button === '{close}') {
  94. dismiss();
  95. return;
  96. }
  97. if (button === '{bksp}') {
  98. setNativeValue(input, input.value.slice(0, -1));
  99. } else if (button === '{space}') {
  100. setNativeValue(input, input.value + ' ');
  101. } else {
  102. setNativeValue(input, input.value + button);
  103. // Auto-unshift after typing one character (like mobile keyboards)
  104. if (layoutName === 'shift') {
  105. setLayoutName('default');
  106. }
  107. }
  108. // Keep focus on the input
  109. input.focus();
  110. // Sync keyboard internal state
  111. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  112. (keyboardRef.current as any)?.setInput?.(input.value);
  113. }, [layoutName, dismiss]);
  114. if (!visible) return null;
  115. return (
  116. <>
  117. {/* Backdrop: absorbs taps so they don't reach elements under the keyboard.
  118. Stays alive during closing phase to catch ghost clicks. */}
  119. <div
  120. className="fixed inset-0 z-[9998] bg-transparent"
  121. onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); if (!closing) dismiss(); }}
  122. onTouchStart={(e) => { e.preventDefault(); e.stopPropagation(); if (!closing) dismiss(); }}
  123. onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
  124. />
  125. {!closing && (
  126. <div
  127. ref={containerRef}
  128. className="fixed bottom-0 left-0 right-0 z-[9999]"
  129. onMouseDown={(e) => e.preventDefault()}
  130. onTouchStart={(e) => {
  131. // Prevent focus loss but allow button interaction
  132. if (!(e.target as HTMLElement).closest('.hg-button')) {
  133. e.preventDefault();
  134. }
  135. }}
  136. >
  137. <Keyboard
  138. keyboardRef={(r: ReturnType<typeof Keyboard>) => { keyboardRef.current = r; }}
  139. layoutName={layoutName}
  140. onKeyPress={onKeyPress}
  141. theme="simple-keyboard vkb-theme"
  142. layout={{
  143. default: [
  144. '1 2 3 4 5 6 7 8 9 0 {bksp}',
  145. 'q w e r t y u i o p',
  146. '{lock} a s d f g h j k l',
  147. '{shift} z x c v b n m . @',
  148. '{space} {close}',
  149. ],
  150. shift: [
  151. '! @ # $ % ^ & * ( ) {bksp}',
  152. 'Q W E R T Y U I O P',
  153. '{lock} A S D F G H J K L',
  154. '{shift} Z X C V B N M , _',
  155. '{space} {close}',
  156. ],
  157. }}
  158. display={{
  159. '{bksp}': '\u232B',
  160. '{close}': '\u2715 Close',
  161. '{shift}': '\u21E7',
  162. '{lock}': '\u21EA',
  163. '{space}': ' ',
  164. }}
  165. />
  166. </div>
  167. )}
  168. </>
  169. );
  170. }