VirtualKeyboard.tsx 7.5 KB

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