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:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "textual-webterm"
|
name = "textual-webterm"
|
||||||
version = "0.5.6"
|
version = "0.5.7"
|
||||||
description = "Serve terminal sessions over the web"
|
description = "Serve terminal sessions over the web"
|
||||||
authors = ["Will McGugan <will@textualize.io>"]
|
authors = ["Will McGugan <will@textualize.io>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
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.
|
* 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 {
|
class WebTerminal {
|
||||||
private terminal: Terminal;
|
private terminal: Terminal;
|
||||||
private fitAddon: FitAddon;
|
private fitAddon: FitAddon;
|
||||||
@@ -293,6 +303,9 @@ class WebTerminal {
|
|||||||
private messageQueue: [string, unknown][] = [];
|
private messageQueue: [string, unknown][] = [];
|
||||||
private lastValidSize: { cols: number; rows: number } | null = null;
|
private lastValidSize: { cols: number; rows: number } | null = null;
|
||||||
private mobileInput: HTMLTextAreaElement | null = null;
|
private mobileInput: HTMLTextAreaElement | null = null;
|
||||||
|
private mobileKeybar: HTMLElement | null = null;
|
||||||
|
private ctrlActive = false;
|
||||||
|
private shiftActive = false;
|
||||||
|
|
||||||
private constructor(
|
private constructor(
|
||||||
container: HTMLElement,
|
container: HTMLElement,
|
||||||
@@ -369,6 +382,11 @@ class WebTerminal {
|
|||||||
// Setup mobile keyboard support
|
// Setup mobile keyboard support
|
||||||
this.setupMobileKeyboard();
|
this.setupMobileKeyboard();
|
||||||
|
|
||||||
|
// Setup mobile extended keybar (only on mobile devices)
|
||||||
|
if (isMobileDevice()) {
|
||||||
|
this.setupMobileKeybar();
|
||||||
|
}
|
||||||
|
|
||||||
// Connect WebSocket
|
// Connect WebSocket
|
||||||
this.connect();
|
this.connect();
|
||||||
}
|
}
|
||||||
@@ -407,7 +425,27 @@ class WebTerminal {
|
|||||||
this.element.appendChild(textarea);
|
this.element.appendChild(textarea);
|
||||||
this.mobileInput = 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", () => {
|
textarea.addEventListener("input", () => {
|
||||||
const value = textarea.value;
|
const value = textarea.value;
|
||||||
if (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) => {
|
textarea.addEventListener("keydown", (e) => {
|
||||||
let seq: string | null = null;
|
let seq: string | null = null;
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case "Enter":
|
|
||||||
seq = "\r";
|
|
||||||
break;
|
|
||||||
case "Backspace":
|
|
||||||
seq = "\x7f";
|
|
||||||
break;
|
|
||||||
case "Escape":
|
case "Escape":
|
||||||
seq = "\x1b";
|
seq = "\x1b";
|
||||||
break;
|
break;
|
||||||
@@ -463,6 +495,196 @@ class WebTerminal {
|
|||||||
this.element.addEventListener("click", focusTextarea);
|
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 */
|
/** Focus the mobile input to show keyboard */
|
||||||
private focusMobileInput(): void {
|
private focusMobileInput(): void {
|
||||||
// For programmatic focus (not from user gesture), this may not show keyboard on iOS
|
// 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.remove();
|
||||||
this.mobileInput = null;
|
this.mobileInput = null;
|
||||||
}
|
}
|
||||||
|
if (this.mobileKeybar) {
|
||||||
|
this.mobileKeybar.remove();
|
||||||
|
this.mobileKeybar = null;
|
||||||
|
}
|
||||||
this.fitAddon.dispose();
|
this.fitAddon.dispose();
|
||||||
this.terminal.dispose();
|
this.terminal.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user