feat: add draggable mobile extended keyboard bar
- Add floating keybar with Esc, Ctrl, Tab, and arrow keys - Only appears on mobile/touch devices - Draggable via grip handle to reposition anywhere - Ctrl modifier toggles and auto-deactivates after use - Bump version to 0.5.7
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -281,6 +281,16 @@ function getWasmPath(): string {
|
||||
/**
|
||||
* WebTerminal - wraps ghostty-web with WebSocket communication.
|
||||
*/
|
||||
/** Detect if running on a mobile/touch device */
|
||||
function isMobileDevice(): boolean {
|
||||
return (
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||
navigator.userAgent
|
||||
) ||
|
||||
("ontouchstart" in window && navigator.maxTouchPoints > 0)
|
||||
);
|
||||
}
|
||||
|
||||
class WebTerminal {
|
||||
private terminal: Terminal;
|
||||
private fitAddon: FitAddon;
|
||||
@@ -293,6 +303,9 @@ class WebTerminal {
|
||||
private messageQueue: [string, unknown][] = [];
|
||||
private lastValidSize: { cols: number; rows: number } | null = null;
|
||||
private mobileInput: HTMLTextAreaElement | null = null;
|
||||
private mobileKeybar: HTMLElement | null = null;
|
||||
private ctrlActive = false;
|
||||
private shiftActive = false;
|
||||
|
||||
private constructor(
|
||||
container: HTMLElement,
|
||||
@@ -369,6 +382,11 @@ class WebTerminal {
|
||||
// Setup mobile keyboard support
|
||||
this.setupMobileKeyboard();
|
||||
|
||||
// Setup mobile extended keybar (only on mobile devices)
|
||||
if (isMobileDevice()) {
|
||||
this.setupMobileKeybar();
|
||||
}
|
||||
|
||||
// Connect WebSocket
|
||||
this.connect();
|
||||
}
|
||||
@@ -407,7 +425,27 @@ class WebTerminal {
|
||||
this.element.appendChild(textarea);
|
||||
this.mobileInput = textarea;
|
||||
|
||||
// Handle input from mobile keyboard
|
||||
// Handle special keys via beforeinput to intercept before browser modifies textarea
|
||||
textarea.addEventListener("beforeinput", (e) => {
|
||||
let seq: string | null = null;
|
||||
switch (e.inputType) {
|
||||
case "insertLineBreak": // Enter key
|
||||
seq = "\r";
|
||||
break;
|
||||
case "deleteContentBackward": // Backspace
|
||||
seq = "\x7f";
|
||||
break;
|
||||
case "deleteContentForward": // Delete
|
||||
seq = "\x1b[3~";
|
||||
break;
|
||||
}
|
||||
if (seq) {
|
||||
e.preventDefault();
|
||||
this.send(["stdin", seq]);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle input from mobile keyboard (regular text only, special keys handled above)
|
||||
textarea.addEventListener("input", () => {
|
||||
const value = textarea.value;
|
||||
if (value) {
|
||||
@@ -416,16 +454,10 @@ class WebTerminal {
|
||||
}
|
||||
});
|
||||
|
||||
// Handle special keys via keydown
|
||||
// Handle special navigation keys via keydown (not covered by beforeinput)
|
||||
textarea.addEventListener("keydown", (e) => {
|
||||
let seq: string | null = null;
|
||||
switch (e.key) {
|
||||
case "Enter":
|
||||
seq = "\r";
|
||||
break;
|
||||
case "Backspace":
|
||||
seq = "\x7f";
|
||||
break;
|
||||
case "Escape":
|
||||
seq = "\x1b";
|
||||
break;
|
||||
@@ -463,6 +495,196 @@ class WebTerminal {
|
||||
this.element.addEventListener("click", focusTextarea);
|
||||
}
|
||||
|
||||
/** Setup draggable mobile extended keyboard bar */
|
||||
private setupMobileKeybar(): void {
|
||||
const keybar = document.createElement("div");
|
||||
keybar.className = "mobile-keybar";
|
||||
keybar.innerHTML = `
|
||||
<button class="keybar-drag" title="Drag to move">⋮⋮</button>
|
||||
<button data-key="\\x1b" title="Escape">Esc</button>
|
||||
<button data-modifier="ctrl" title="Ctrl modifier">Ctrl</button>
|
||||
<button data-modifier="shift" title="Shift modifier">⇧</button>
|
||||
<button data-key="\\x09" title="Tab">Tab</button>
|
||||
<button data-key="\\x1b[A" title="Up">↑</button>
|
||||
<button data-key="\\x1b[B" title="Down">↓</button>
|
||||
<button data-key="\\x1b[D" title="Left">←</button>
|
||||
<button data-key="\\x1b[C" title="Right">→</button>
|
||||
`;
|
||||
|
||||
// Inject styles
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
.mobile-keybar {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
background: rgba(40, 40, 40, 0.95);
|
||||
border-radius: 8px 0 0 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||
z-index: 10000;
|
||||
touch-action: none;
|
||||
max-width: 200px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.mobile-keybar button {
|
||||
min-width: 36px;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
background: #333;
|
||||
color: #eee;
|
||||
font-size: 13px;
|
||||
font-family: system-ui, sans-serif;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.mobile-keybar button:active {
|
||||
background: #555;
|
||||
}
|
||||
.mobile-keybar button.active {
|
||||
background: #0066cc;
|
||||
border-color: #0088ff;
|
||||
}
|
||||
.mobile-keybar .keybar-drag {
|
||||
min-width: 24px;
|
||||
padding: 0 4px;
|
||||
cursor: grab;
|
||||
color: #888;
|
||||
}
|
||||
.mobile-keybar .keybar-drag:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
document.body.appendChild(keybar);
|
||||
this.mobileKeybar = keybar;
|
||||
|
||||
// Handle key button presses
|
||||
keybar.querySelectorAll("button[data-key]").forEach((btn) => {
|
||||
btn.addEventListener("touchstart", (e) => {
|
||||
e.preventDefault();
|
||||
let key = (btn as HTMLElement).dataset.key || "";
|
||||
// Unescape the key sequences
|
||||
key = key.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex) =>
|
||||
String.fromCharCode(parseInt(hex, 16))
|
||||
);
|
||||
key = key.replace(/\\x1b/g, "\x1b");
|
||||
|
||||
// Handle Shift+Tab -> Back-Tab (CSI Z)
|
||||
if (this.shiftActive && key === "\x09") {
|
||||
key = "\x1b[Z";
|
||||
}
|
||||
// Handle Shift+Arrow keys (CSI 1;2 X)
|
||||
else if (this.shiftActive && key.startsWith("\x1b[") && key.length === 3) {
|
||||
const dir = key[2]; // A, B, C, or D
|
||||
key = `\x1b[1;2${dir}`;
|
||||
}
|
||||
// Handle Ctrl+Arrow keys (CSI 1;5 X)
|
||||
else if (this.ctrlActive && key.startsWith("\x1b[") && key.length === 3) {
|
||||
const dir = key[2];
|
||||
key = `\x1b[1;5${dir}`;
|
||||
}
|
||||
// Handle Ctrl+Shift+Arrow keys (CSI 1;6 X)
|
||||
else if (this.ctrlActive && this.shiftActive && key.startsWith("\x1b[") && key.length === 3) {
|
||||
const dir = key[2];
|
||||
key = `\x1b[1;6${dir}`;
|
||||
}
|
||||
// Apply Ctrl modifier to letters
|
||||
else if (this.ctrlActive && key.length === 1) {
|
||||
const code = key.toUpperCase().charCodeAt(0);
|
||||
if (code >= 65 && code <= 90) {
|
||||
key = String.fromCharCode(code - 64); // Ctrl+A = 0x01, etc.
|
||||
}
|
||||
}
|
||||
|
||||
this.send(["stdin", key]);
|
||||
this.deactivateModifiers();
|
||||
});
|
||||
});
|
||||
|
||||
// Handle modifier toggles
|
||||
keybar.querySelectorAll("button[data-modifier]").forEach((btn) => {
|
||||
btn.addEventListener("touchstart", (e) => {
|
||||
e.preventDefault();
|
||||
const modifier = (btn as HTMLElement).dataset.modifier;
|
||||
if (modifier === "ctrl") {
|
||||
this.ctrlActive = !this.ctrlActive;
|
||||
btn.classList.toggle("active", this.ctrlActive);
|
||||
} else if (modifier === "shift") {
|
||||
this.shiftActive = !this.shiftActive;
|
||||
btn.classList.toggle("active", this.shiftActive);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Setup drag functionality
|
||||
this.setupKeybarDrag(keybar);
|
||||
}
|
||||
|
||||
/** Make the keybar draggable */
|
||||
private setupKeybarDrag(keybar: HTMLElement): void {
|
||||
const dragHandle = keybar.querySelector(".keybar-drag") as HTMLElement;
|
||||
if (!dragHandle) return;
|
||||
|
||||
let isDragging = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let startRight = 0;
|
||||
let startBottom = 0;
|
||||
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
if (e.touches.length !== 1) return;
|
||||
isDragging = true;
|
||||
const touch = e.touches[0];
|
||||
startX = touch.clientX;
|
||||
startY = touch.clientY;
|
||||
|
||||
const rect = keybar.getBoundingClientRect();
|
||||
startRight = window.innerWidth - rect.right;
|
||||
startBottom = window.innerHeight - rect.bottom;
|
||||
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
if (!isDragging || e.touches.length !== 1) return;
|
||||
const touch = e.touches[0];
|
||||
const deltaX = startX - touch.clientX;
|
||||
const deltaY = startY - touch.clientY;
|
||||
|
||||
const newRight = Math.max(0, Math.min(window.innerWidth - 100, startRight + deltaX));
|
||||
const newBottom = Math.max(0, Math.min(window.innerHeight - 50, startBottom + deltaY));
|
||||
|
||||
keybar.style.right = `${newRight}px`;
|
||||
keybar.style.bottom = `${newBottom}px`;
|
||||
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const onTouchEnd = () => {
|
||||
isDragging = false;
|
||||
};
|
||||
|
||||
dragHandle.addEventListener("touchstart", onTouchStart, { passive: false });
|
||||
document.addEventListener("touchmove", onTouchMove, { passive: false });
|
||||
document.addEventListener("touchend", onTouchEnd);
|
||||
}
|
||||
|
||||
/** Deactivate all modifiers */
|
||||
private deactivateModifiers(): void {
|
||||
this.ctrlActive = false;
|
||||
this.shiftActive = false;
|
||||
this.mobileKeybar?.querySelectorAll("button[data-modifier]").forEach((btn) => {
|
||||
btn.classList.remove("active");
|
||||
});
|
||||
}
|
||||
|
||||
/** Focus the mobile input to show keyboard */
|
||||
private focusMobileInput(): void {
|
||||
// For programmatic focus (not from user gesture), this may not show keyboard on iOS
|
||||
@@ -708,6 +930,10 @@ class WebTerminal {
|
||||
this.mobileInput.remove();
|
||||
this.mobileInput = null;
|
||||
}
|
||||
if (this.mobileKeybar) {
|
||||
this.mobileKeybar.remove();
|
||||
this.mobileKeybar = null;
|
||||
}
|
||||
this.fitAddon.dispose();
|
||||
this.terminal.dispose();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user