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.
213 lines
7.4 KiB
JavaScript
213 lines
7.4 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());
|
|
|
|
// Desktop: Enter = send, Shift+Enter / Cmd+Enter = newline.
|
|
// Mobile keeps the default (Enter = newline, send button submits).
|
|
this.textInput.addEventListener('keydown', (e) => {
|
|
if (e.key !== 'Enter') return;
|
|
if (window.innerWidth < 1024) return;
|
|
if (e.shiftKey || e.metaKey) 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'));
|
|
|
|
// 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;
|