You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

203 lines
7.1 KiB
JavaScript

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.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 — Enter inserts newline (textarea default); only send button submits.
// Height is fixed via CSS so the keyboard doesn't shift; multi-line scrolls internally.
// mousedown.preventDefault keeps focus on the textarea so the soft keyboard
// stays up; the click event still fires. Without this, on mobile the first
// tap blurs the textarea / dismisses the keyboard and the viewport reflow
// can swallow the click — the user has to tap a second time to submit.
this.sendBtn.addEventListener('mousedown', (e) => e.preventDefault());
this.sendBtn.addEventListener('click', () => 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'));
// Text shortcut keys (e.g. /compact) — type the text, then send Enter
// separately so slash-command menus have a tick to populate before submit.
document.querySelectorAll('.key[data-text]').forEach((btn) => {
btn.addEventListener('click', () => {
this._haptic();
this.onSendText(btn.dataset.text);
setTimeout(() => this.onSendKey('Enter'), 50);
});
});
// 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) {
// Send the text WITHOUT a trailing newline, then a discrete Enter key
// event. cmux delivers send_text as a bracketed paste, so a trailing
// "\n" becomes a literal newline in the TUI input box (Claude Code /
// Codex) instead of submitting — that's why a single press only filled
// the box and a second (empty) press was needed to actually send.
// This replicates that two-press sequence in one press. The delay lets
// the paste land before Enter; larger for multi-line/big pastes that
// take longer to render than the short data-text shortcut keys.
this.onSendText(text);
this.textInput.value = '';
setTimeout(() => this.onSendKey('Enter'), 100);
} 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;