VirtualKeyboard.tsx 6.5 KB

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