class VirtualKeyboard { constructor(onSendText, onSendKey) { this.onSendText = onSendText; this.onSendKey = onSendKey; this.ctrlActive = false; this.ctrlLocked = false; this.altActive = false; this.altLocked = false; this.shiftActive = false; this.shiftLocked = false; this._repeatTimer = null; this._repeatInterval = null; this._composingEnterPending = false; this.textInput = document.getElementById('text-input'); this.sendBtn = document.getElementById('send-btn'); this.ctrlKey = document.getElementById('ctrl-key'); this.altKey = document.getElementById('alt-key'); this.shiftKey = document.getElementById('shift-key'); this.init(); } static get REPEAT_KEYS() { return new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'PageUp', 'PageDown']); } static get REPEAT_INITIAL_DELAY() { return 400; } static get REPEAT_INTERVAL() { return 80; } init() { // Text input + send this.sendBtn.addEventListener('click', () => this.submitText()); this.textInput.addEventListener('compositionend', () => { if (this._composingEnterPending) { this._composingEnterPending = false; setTimeout(() => this.submitText(), 0); } }); this.textInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { if (e.isComposing || e.keyCode === 229) { this._composingEnterPending = true; return; } e.preventDefault(); this.submitText(); } }); // Ctrl modifier this.ctrlKey.addEventListener('click', () => this.toggleModifier('ctrl')); this.ctrlKey.addEventListener('dblclick', () => this.lockModifier('ctrl')); // Alt modifier this.altKey.addEventListener('click', () => this.toggleModifier('alt')); this.altKey.addEventListener('dblclick', () => this.lockModifier('alt')); // Shift modifier this.shiftKey.addEventListener('click', () => this.toggleModifier('shift')); this.shiftKey.addEventListener('dblclick', () => this.lockModifier('shift')); // Special keys document.querySelectorAll('.key[data-key]').forEach((btn) => { if (btn.classList.contains('modifier')) return; const key = btn.dataset.key; if (VirtualKeyboard.REPEAT_KEYS.has(key)) { const startRepeat = () => { this._clearRepeat(); this.handleKeyPress(key); this._repeatTimer = setTimeout(() => { this._repeatInterval = setInterval(() => { this.handleKeyPress(key); }, VirtualKeyboard.REPEAT_INTERVAL); }, VirtualKeyboard.REPEAT_INITIAL_DELAY); }; btn.addEventListener('mousedown', startRepeat); btn.addEventListener('touchstart', startRepeat, { passive: true }); btn.addEventListener('mouseup', () => this._clearRepeat()); btn.addEventListener('mouseleave', () => this._clearRepeat()); btn.addEventListener('touchend', () => this._clearRepeat()); btn.addEventListener('touchcancel', () => this._clearRepeat()); } else { btn.addEventListener('click', () => this.handleKeyPress(key)); } }); } _haptic() { if (navigator.vibrate) navigator.vibrate(8); } _clearRepeat() { clearTimeout(this._repeatTimer); clearInterval(this._repeatInterval); this._repeatTimer = null; this._repeatInterval = null; } submitText() { this._haptic(); const text = this.textInput.value; if (text) { this.onSendText(text + '\n'); this.textInput.value = ''; } else { // Empty submit = Enter key this.onSendKey('Enter'); } this.textInput.focus(); } handleKeyPress(key) { this._haptic(); // Check for pre-built shortcut keys like Ctrl-c if (key.startsWith('Ctrl-') || key.startsWith('Alt-') || key.startsWith('Shift-')) { this.onSendKey(key); return; } // Apply active modifiers let finalKey = key; if (this.shiftActive) { finalKey = `Shift-${finalKey}`; if (!this.shiftLocked) this.deactivateModifier('shift'); } if (this.ctrlActive) { finalKey = `Ctrl-${finalKey}`; if (!this.ctrlLocked) this.deactivateModifier('ctrl'); } if (this.altActive) { finalKey = `Alt-${finalKey}`; if (!this.altLocked) this.deactivateModifier('alt'); } this.onSendKey(finalKey); } toggleModifier(mod) { this._haptic(); if (mod === 'ctrl') { if (this.ctrlLocked) { this.deactivateModifier('ctrl'); return; } this.ctrlActive = !this.ctrlActive; this.ctrlKey.classList.toggle('active', this.ctrlActive); } else if (mod === 'alt') { if (this.altLocked) { this.deactivateModifier('alt'); return; } this.altActive = !this.altActive; this.altKey.classList.toggle('active', this.altActive); } else if (mod === 'shift') { if (this.shiftLocked) { this.deactivateModifier('shift'); return; } this.shiftActive = !this.shiftActive; this.shiftKey.classList.toggle('active', this.shiftActive); } } lockModifier(mod) { if (mod === 'ctrl') { this.ctrlActive = true; this.ctrlLocked = true; this.ctrlKey.classList.add('active', 'locked'); } else if (mod === 'alt') { this.altActive = true; this.altLocked = true; this.altKey.classList.add('active', 'locked'); } else if (mod === 'shift') { this.shiftActive = true; this.shiftLocked = true; this.shiftKey.classList.add('active', 'locked'); } } deactivateModifier(mod) { if (mod === 'ctrl') { this.ctrlActive = false; this.ctrlLocked = false; this.ctrlKey.classList.remove('active', 'locked'); } else if (mod === 'alt') { this.altActive = false; this.altLocked = false; this.altKey.classList.remove('active', 'locked'); } else if (mod === 'shift') { this.shiftActive = false; this.shiftLocked = false; this.shiftKey.classList.remove('active', 'locked'); } } focus() { this.textInput.focus(); } } window.VirtualKeyboard = VirtualKeyboard;