feat: redesign mobile keyboard

This commit is contained in:
2026-05-12 03:34:49 -04:00
parent ca870cc293
commit 541e0e1fe8
2 changed files with 621 additions and 211 deletions
File diff suppressed because one or more lines are too long
+586 -155
View File
@@ -752,7 +752,7 @@ type VirtualKeyboardKey = {
value?: string;
seq?: string;
width?: number;
actionId?: "mode-alpha" | "mode-symbol";
actionId?: VirtualKeyboardActionId;
};
const VIRTUAL_KEYBOARD_ALPHA_LAYOUT: VirtualKeyboardKey[][] = [
@@ -780,7 +780,7 @@ const VIRTUAL_KEYBOARD_ALPHA_LAYOUT: VirtualKeyboardKey[][] = [
{ kind: "char", label: "l", shiftLabel: "L" },
],
[
{ kind: "modifier", label: "⇧", modifier: "shift", width: 1.35 },
{ kind: "modifier", label: "⇧", modifier: "shift" },
{ kind: "char", label: "z", shiftLabel: "Z" },
{ kind: "char", label: "x", shiftLabel: "X" },
{ kind: "char", label: "c", shiftLabel: "C" },
@@ -788,13 +788,13 @@ const VIRTUAL_KEYBOARD_ALPHA_LAYOUT: VirtualKeyboardKey[][] = [
{ kind: "char", label: "b", shiftLabel: "B" },
{ kind: "char", label: "n", shiftLabel: "N" },
{ kind: "char", label: "m", shiftLabel: "M" },
{ kind: "action", label: "⌫", seq: "\x7f", width: 1.35 },
{ kind: "action", label: "⌫", value: "Backspace", seq: "\x7f" },
],
[
{ kind: "action", label: "✂︎", seq: "\x1b", width: 1.1 },
{ kind: "action", label: "123", actionId: "mode-symbol" },
{ kind: "space", label: "␣", value: " ", width: 4 },
{ kind: "action", label: "⏎", seq: "\r", width: 1.35 },
{ kind: "action", label: "🌐", actionId: "mode-symbol", width: 1.1 },
{ kind: "action", label: "⏎", seq: "\r" },
{ kind: "action", label: "✂︎", actionId: "mode-selection" },
],
];
@@ -824,7 +824,7 @@ const VIRTUAL_KEYBOARD_SYMBOL_LAYOUT: VirtualKeyboardKey[][] = [
{ kind: "char", label: "\"", value: "\"" },
],
[
{ kind: "action", label: "ABC", actionId: "mode-alpha", width: 1.35 },
{ kind: "action", label: "Esc", seq: "\x1b", width: 1.35 },
{ kind: "char", label: ".", value: "." },
{ kind: "char", label: ",", value: "," },
{ kind: "char", label: "?", value: "?" },
@@ -832,23 +832,64 @@ const VIRTUAL_KEYBOARD_SYMBOL_LAYOUT: VirtualKeyboardKey[][] = [
{ kind: "char", label: "'", value: "'" },
{ kind: "char", label: "[", value: "[" },
{ kind: "char", label: "]", value: "]" },
{ kind: "action", label: "⌫", seq: "\x7f", width: 1.35 },
{ kind: "action", label: "⌫", value: "Backspace", seq: "\x7f", width: 1.35 },
],
[
{ kind: "action", label: "Esc", seq: "\x1b", width: 1.1 },
{ kind: "action", label: "ABC", actionId: "mode-alpha", width: 1.1 },
{ kind: "modifier", label: "Ctrl", modifier: "ctrl", width: 1.1 },
{ kind: "modifier", label: "Alt", modifier: "alt", width: 1.1 },
{ kind: "space", label: "␣", value: " ", width: 4 },
{ kind: "action", label: "⏎", seq: "\r", width: 1.35 },
],
];
const VIRTUAL_KEYBOARD_SELECTION_LAYOUT: VirtualKeyboardKey[][] = [
[
{ kind: "arrow", label: "", seq: "\x1b[D", width: 1.1 },
{ kind: "arrow", label: "", seq: "\x1b[B", width: 1.1 },
{ kind: "arrow", label: "", seq: "\x1b[A", width: 1.1 },
{ kind: "arrow", label: "→", seq: "\x1b[C", width: 1.1 },
{ kind: "action", label: "Select", actionId: "toggle-selection-adjust", width: 1.4 },
{ kind: "action", label: "←", value: "ArrowLeft", seq: "\x1b[D" },
{ kind: "action", label: "", value: "ArrowRight", seq: "\x1b[C" },
],
[
{ kind: "action", label: "Copy", actionId: "copy-selection", width: 1.4 },
{ kind: "action", label: "Paste", actionId: "paste-selection", width: 1.4 },
{ kind: "action", label: "Cut", actionId: "cut-selection", width: 1.2 },
{ kind: "action", label: "⌫", value: "Backspace", seq: "\x7f" },
],
[
{ kind: "action", label: "✂︎", actionId: "mode-alpha" },
{ kind: "space", label: "␣", value: " ", width: 2.8 },
{ kind: "action", label: "⏎", seq: "\r" },
],
];
type VirtualKeyboardActionId =
| "mode-alpha"
| "mode-selection"
| "mode-symbol"
| "toggle-selection-adjust"
| "copy-selection"
| "paste-selection"
| "cut-selection";
type VirtualKeyboardKeyBounds = {
x: number;
y: number;
w: number;
h: number;
rowIndex: number;
colIndex: number;
key: VirtualKeyboardKey;
label: string;
value: string;
};
type ActiveVirtualKeyboardPress = {
pointerId: number;
keyIndex: number;
key: VirtualKeyboardKeyBounds | null;
repeatTimeout?: number;
};
const FN_NORMAL_KEYS = [
"\x1bOP",
"\x1bOQ",
@@ -972,9 +1013,14 @@ class WebTerminal {
private mobileInput: HTMLTextAreaElement | null = null;
private mobileKeybar: HTMLElement | null = null;
private mobileVirtualKeyboardHost: HTMLDivElement | null = null;
private mobileVirtualKeyboard: HTMLDivElement | null = null;
private mobileVirtualKeyboard: HTMLCanvasElement | null = null;
private mobileKeyboardVisible = false;
private mobileKeyboardMode: "alpha" | "symbol" = "alpha";
private mobileSelectionMode = false;
private mobileSelectionAdjusting = false;
private mobileVirtualKeyboardBounds: VirtualKeyboardKeyBounds[] = [];
private mobileVirtualKeyboardActivePresses = new Map<number, ActiveVirtualKeyboardPress>();
private mobileVirtualKeyboardDrawFrame: number | null = null;
private lastMobileKeyboardTouchAt = 0;
private ctrlActive = false;
private altActive = false;
@@ -2252,7 +2298,7 @@ class WebTerminal {
}
private keybarButtonHeight = 44;
private virtualKeyboardButtonHeight = 110;
private virtualKeyboardBaseHeight = 500;
private usesVirtualKeyboard(): boolean {
return isMobileDevice();
@@ -2266,6 +2312,10 @@ class WebTerminal {
if (this.mobileKeybar) {
this.mobileKeybar.style.display = visible ? "" : "none";
}
if (visible) {
this.updateVirtualKeyboardHostHeight();
this.requestMobileVirtualKeyboardDraw();
}
}
private toggleMobileKeyboard(): void {
@@ -2372,108 +2422,486 @@ class WebTerminal {
}
private syncVirtualKeyboardState(): void {
if (!this.mobileVirtualKeyboard) {
return;
}
this.mobileVirtualKeyboard.querySelectorAll<HTMLElement>("button[data-vk-modifier]").forEach((btn) => {
const modifier = btn.dataset.vkModifier;
const active =
modifier === "ctrl"
? this.ctrlActive
: modifier === "alt"
? this.altActive
: modifier === "shift"
? this.shiftActive
: modifier === "fn"
? this.fnActive
: false;
btn.classList.toggle("webterm-vk-active", active);
});
const useShift = this.shiftActive;
this.mobileVirtualKeyboard.querySelectorAll<HTMLElement>("button[data-vk-default]").forEach((btn) => {
const label = useShift && btn.dataset.vkShiftLabel ? btn.dataset.vkShiftLabel : btn.dataset.vkDefault;
const value = useShift && btn.dataset.vkShiftValue ? btn.dataset.vkShiftValue : btn.dataset.vkValue;
btn.textContent = label || "";
if (value) {
btn.dataset.vkCurrent = value;
}
});
this.requestMobileVirtualKeyboardDraw();
}
private currentVirtualKeyboardLayout(): VirtualKeyboardKey[][] {
if (this.mobileSelectionMode) {
return VIRTUAL_KEYBOARD_SELECTION_LAYOUT;
}
return this.mobileKeyboardMode === "symbol"
? VIRTUAL_KEYBOARD_SYMBOL_LAYOUT
: VIRTUAL_KEYBOARD_ALPHA_LAYOUT;
}
private renderVirtualKeyboardRows(): void {
private getVirtualKeyboardScale(width: number): number {
return width < 390 ? 390 / width : 1;
}
private updateVirtualKeyboardHostHeight(): void {
if (!this.mobileVirtualKeyboardHost) {
return;
}
const width = this.mobileVirtualKeyboardHost.getBoundingClientRect().width;
if (!width) {
return;
}
const scale = this.getVirtualKeyboardScale(width);
this.mobileVirtualKeyboardHost.style.height = `${Math.round(this.virtualKeyboardBaseHeight / scale)}px`;
}
private getVirtualKeyboardRect(): DOMRect | null {
return this.mobileVirtualKeyboard?.getBoundingClientRect() ?? null;
}
private getVirtualKeyboardLabel(key: VirtualKeyboardKey): string {
if (key.kind === "char") {
return this.shiftActive && key.shiftLabel ? key.shiftLabel : key.label;
}
return key.label;
}
private getVirtualKeyboardValue(key: VirtualKeyboardKey): string {
if (key.kind === "char") {
if (this.shiftActive && key.shiftValue) {
return key.shiftValue;
}
return key.value ?? key.label;
}
if (key.kind === "space") {
return key.value ?? " ";
}
return key.value ?? key.label;
}
private calculateVirtualKeyboardLayout(): VirtualKeyboardKeyBounds[] {
const rect = this.getVirtualKeyboardRect();
if (!rect) {
return [];
}
const layout = this.currentVirtualKeyboardLayout();
const scale = this.getVirtualKeyboardScale(rect.width);
const compactRatio = Math.max(0, Math.min(1, (rect.width - 260) / 140));
const padding = (2 + (10 - 2) * compactRatio) / scale;
const gap = (2 + (8 - 2) * compactRatio) / scale;
const contentW = rect.width - padding * 2;
const contentH = rect.height - padding * 2;
const rowHeight = (contentH - (layout.length - 1) * gap) / layout.length;
const bounds: VirtualKeyboardKeyBounds[] = [];
let currentY = padding;
layout.forEach((row, rowIndex) => {
const totalGapW = (row.length - 1) * gap;
const totalFlexGrow = row.reduce((sum, key) => sum + (key.width ?? 1), 0);
const unitW = (contentW - totalGapW) / totalFlexGrow;
let currentX = padding;
row.forEach((key, colIndex) => {
const width = unitW * (key.width ?? 1);
bounds.push({
x: currentX,
y: currentY,
w: width,
h: rowHeight,
rowIndex,
colIndex,
key,
label: this.getVirtualKeyboardLabel(key),
value: this.getVirtualKeyboardValue(key),
});
currentX += width + gap;
});
currentY += rowHeight + gap;
});
return bounds;
}
private isVirtualKeyboardModifierActive(key: VirtualKeyboardKeyBounds): boolean {
if (key.key.modifier === "shift") {
return this.shiftActive;
}
if (key.key.modifier === "ctrl") {
return this.ctrlActive;
}
if (key.key.modifier === "alt") {
return this.altActive;
}
if (key.key.modifier === "fn") {
return this.fnActive;
}
if (key.key.actionId === "toggle-selection-adjust") {
return this.mobileSelectionAdjusting;
}
if (key.key.actionId === "mode-selection" || key.key.actionId === "mode-alpha") {
return this.mobileSelectionMode;
}
if (key.key.actionId === "mode-symbol") {
return this.mobileKeyboardMode === "symbol";
}
return false;
}
private drawVirtualKeyboardKey(
ctx: CanvasRenderingContext2D,
key: VirtualKeyboardKeyBounds,
pressed: boolean,
active: boolean,
scale: number,
): void {
let radius = 10 / scale;
if (key.w < radius * 2) {
radius = key.w / 2;
}
if (key.h < radius * 2) {
radius = key.h / 2;
}
const colors = {
shadow: "#0f172a",
keyFace: pressed ? "#334155" : "#1e293b",
keyText: "#f8fafc",
actionFace: pressed ? "#475569" : "#334155",
activeFace: pressed ? "#f8fafc" : "#e2e8f0",
activeText: "#0f172a",
};
const isAction = key.key.kind === "action" || key.key.kind === "arrow" || key.key.kind === "modifier";
const faceColor = active ? colors.activeFace : isAction ? colors.actionFace : colors.keyFace;
const textColor = active ? colors.activeText : colors.keyText;
const depth = 4 / scale;
const pressOffset = pressed ? depth * 0.6 : 0;
ctx.beginPath();
ctx.moveTo(key.x + radius, key.y + depth);
ctx.arcTo(key.x + key.w, key.y + depth, key.x + key.w, key.y + key.h + depth, radius);
ctx.arcTo(key.x + key.w, key.y + key.h + depth, key.x, key.y + key.h + depth, radius);
ctx.arcTo(key.x, key.y + key.h + depth, key.x, key.y + depth, radius);
ctx.arcTo(key.x, key.y + depth, key.x + key.w, key.y + depth, radius);
ctx.closePath();
ctx.fillStyle = colors.shadow;
ctx.fill();
ctx.beginPath();
ctx.moveTo(key.x + radius, key.y + pressOffset);
ctx.arcTo(key.x + key.w, key.y + pressOffset, key.x + key.w, key.y + key.h + pressOffset, radius);
ctx.arcTo(key.x + key.w, key.y + key.h + pressOffset, key.x, key.y + key.h + pressOffset, radius);
ctx.arcTo(key.x, key.y + key.h + pressOffset, key.x, key.y + pressOffset, radius);
ctx.arcTo(key.x, key.y + pressOffset, key.x + key.w, key.y + pressOffset, radius);
ctx.closePath();
ctx.fillStyle = faceColor;
ctx.fill();
let fontSize = key.h * 0.34;
if (key.label.length > 1) {
fontSize = key.h * 0.24;
}
if (key.label.length > 5) {
fontSize = key.h * 0.2;
}
fontSize = Math.max(fontSize, 14 / scale);
ctx.fillStyle = textColor;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = `500 ${fontSize}px Inter, system-ui, sans-serif`;
ctx.fillText(key.label, key.x + key.w / 2, key.y + key.h / 2 + pressOffset);
}
private drawMobileVirtualKeyboard(): void {
const canvas = this.mobileVirtualKeyboard;
if (!canvas || !this.mobileKeyboardVisible) {
return;
}
const ctx = canvas.getContext("2d", { alpha: false });
if (!ctx) {
return;
}
const rect = canvas.getBoundingClientRect();
if (!rect.width || !rect.height) {
return;
}
const dpr = window.devicePixelRatio || 1;
const width = Math.round(rect.width * dpr);
const height = Math.round(rect.height * dpr);
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = "#0f172a";
ctx.fillRect(0, 0, rect.width, rect.height);
this.mobileVirtualKeyboardBounds = this.calculateVirtualKeyboardLayout();
const scale = this.getVirtualKeyboardScale(rect.width);
this.mobileVirtualKeyboardBounds.forEach((key) => {
let pressed = false;
for (const press of this.mobileVirtualKeyboardActivePresses.values()) {
const keyIndex = key.rowIndex * 100 + key.colIndex;
if (press.keyIndex === keyIndex) {
pressed = true;
break;
}
}
this.drawVirtualKeyboardKey(
ctx,
key,
pressed,
this.isVirtualKeyboardModifierActive(key),
scale,
);
});
}
private requestMobileVirtualKeyboardDraw(): void {
if (this.mobileVirtualKeyboardDrawFrame !== null) {
return;
}
this.mobileVirtualKeyboardDrawFrame = window.requestAnimationFrame(() => {
this.mobileVirtualKeyboardDrawFrame = null;
this.drawMobileVirtualKeyboard();
});
}
private clearVirtualKeyboardRepeat(press: ActiveVirtualKeyboardPress | undefined): void {
if (press?.repeatTimeout) {
window.clearTimeout(press.repeatTimeout);
press.repeatTimeout = undefined;
}
}
private findVirtualKeyboardKey(clientX: number, clientY: number): VirtualKeyboardKeyBounds | null {
const rect = this.getVirtualKeyboardRect();
if (!rect) {
return null;
}
const x = clientX - rect.left;
const y = clientY - rect.top;
return this.mobileVirtualKeyboardBounds.find((key) =>
x >= key.x && x <= key.x + key.w && y >= key.y && y <= key.y + key.h,
) ?? null;
}
private startVirtualKeyboardRepeat(press: ActiveVirtualKeyboardPress): void {
const value = press.key?.value ?? "";
const repeatable =
press.key?.key.kind === "char" ||
press.key?.key.kind === "space" ||
value === "Backspace" ||
value === "Delete" ||
value === "ArrowLeft" ||
value === "ArrowRight";
if (!repeatable) {
return;
}
const tick = () => {
const activePress = this.mobileVirtualKeyboardActivePresses.get(press.pointerId);
if (!activePress?.key) {
return;
}
void this.dispatchVirtualKeyboardKey(activePress.key);
activePress.repeatTimeout = window.setTimeout(tick, 60);
this.mobileVirtualKeyboardActivePresses.set(press.pointerId, activePress);
};
press.repeatTimeout = window.setTimeout(tick, 500);
this.mobileVirtualKeyboardActivePresses.set(press.pointerId, press);
}
private async copyTerminalSelection(): Promise<void> {
const terminal = this.terminal as unknown as {
getSelection?: () => string;
hasSelection?: () => boolean;
clearSelection?: () => void;
};
const selectedText =
(terminal.hasSelection?.() ? terminal.getSelection?.() : "") ||
document.getSelection()?.toString() ||
"";
if (!selectedText) {
return;
}
await navigator.clipboard?.writeText(selectedText);
}
private async pasteFromClipboard(): Promise<void> {
const text = await navigator.clipboard?.readText();
if (text) {
this.sendStdin(text);
}
}
private async cutTerminalSelection(): Promise<void> {
const terminal = this.terminal as unknown as {
clearSelection?: () => void;
};
await this.copyTerminalSelection();
terminal.clearSelection?.();
}
private sendSelectionArrow(seq: "\x1b[D" | "\x1b[C"): void {
const { useCtrl, useAlt, useFn } = this.getMobileModifierState();
const useShift = this.shiftActive || this.pendingShift || this.mobileSelectionAdjusting;
let output: string = seq;
const dir = output[2];
if (useCtrl && useShift) {
output = `\x1b[1;6${dir}`;
} else if (useCtrl) {
output = `\x1b[1;5${dir}`;
} else if (useShift) {
output = `\x1b[1;2${dir}`;
}
if (useAlt) {
output = applyAltModifier(output);
}
if (useFn && output.length === 1) {
const fnApplied = applyFnModifier(output, useShift);
if (fnApplied) {
output = fnApplied;
}
}
this.sendStdin(output);
this.deactivateModifiers();
this.syncVirtualKeyboardState();
}
private async dispatchVirtualKeyboardKey(key: VirtualKeyboardKeyBounds): Promise<void> {
const actionId = key.key.actionId;
if (actionId === "mode-alpha") {
this.mobileSelectionMode = false;
this.mobileKeyboardMode = "alpha";
this.mobileSelectionAdjusting = false;
this.syncVirtualKeyboardState();
return;
}
if (actionId === "mode-selection") {
this.mobileSelectionMode = true;
this.mobileSelectionAdjusting = false;
this.syncVirtualKeyboardState();
return;
}
if (actionId === "mode-symbol") {
this.mobileSelectionMode = false;
this.mobileKeyboardMode = this.mobileKeyboardMode === "symbol" ? "alpha" : "symbol";
this.syncVirtualKeyboardState();
return;
}
if (actionId === "toggle-selection-adjust") {
this.mobileSelectionAdjusting = !this.mobileSelectionAdjusting;
this.syncVirtualKeyboardState();
return;
}
if (actionId === "copy-selection") {
await this.copyTerminalSelection().catch(() => undefined);
return;
}
if (actionId === "paste-selection") {
await this.pasteFromClipboard().catch(() => undefined);
return;
}
if (actionId === "cut-selection") {
await this.cutTerminalSelection().catch(() => undefined);
return;
}
if (key.key.modifier) {
this.toggleModifierState(key.key.modifier);
return;
}
if (key.value === "ArrowLeft") {
this.sendSelectionArrow("\x1b[D");
return;
}
if (key.value === "ArrowRight") {
this.sendSelectionArrow("\x1b[C");
return;
}
if (key.key.seq) {
this.sendMobileSequence(key.key.seq);
return;
}
this.sendMobileText(key.value);
}
private handleVirtualKeyboardPointerDown = (event: PointerEvent): void => {
event.preventDefault();
event.stopPropagation();
if (!this.mobileVirtualKeyboard) {
return;
}
this.mobileVirtualKeyboard.replaceChildren();
this.currentVirtualKeyboardLayout().forEach((row) => {
const rowEl = document.createElement("div");
rowEl.className = "webterm-vk-row";
rowEl.dataset.rowSize = String(row.length);
row.forEach((key) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = `webterm-vk-button webterm-vk-${key.kind}`;
btn.textContent = key.label;
btn.style.flexGrow = String(key.width ?? 1);
btn.style.flexBasis = "0";
if (key.kind === "modifier" && key.modifier) {
btn.dataset.vkModifier = key.modifier;
}
if (key.kind === "char" || key.kind === "space") {
btn.dataset.vkDefault = key.label;
btn.dataset.vkValue = key.value ?? key.label;
if (key.shiftLabel) {
btn.dataset.vkShiftLabel = key.shiftLabel;
btn.dataset.vkShiftValue = key.shiftValue ?? key.shiftLabel;
}
btn.dataset.vkCurrent = key.value ?? key.label;
}
if (key.seq) {
btn.dataset.vkSeq = key.seq;
}
const handlePress = (event: Event) => {
event.preventDefault();
event.stopPropagation();
if (key.actionId === "mode-alpha") {
this.mobileKeyboardMode = "alpha";
this.renderVirtualKeyboardRows();
this.syncVirtualKeyboardState();
return;
}
if (key.actionId === "mode-symbol") {
this.mobileKeyboardMode = "symbol";
this.renderVirtualKeyboardRows();
this.syncVirtualKeyboardState();
return;
}
if (key.kind === "modifier" && key.modifier) {
this.toggleModifierState(key.modifier);
return;
}
if (key.seq) {
this.sendMobileSequence(key.seq);
return;
}
const value = btn.dataset.vkCurrent || key.value || key.label;
this.sendMobileText(value);
};
btn.addEventListener("touchstart", handlePress, { passive: false });
btn.addEventListener("click", handlePress);
rowEl.appendChild(btn);
});
this.mobileVirtualKeyboard!.appendChild(rowEl);
this.mobileVirtualKeyboard.setPointerCapture(event.pointerId);
if (!this.mobileVirtualKeyboardBounds.length) {
this.mobileVirtualKeyboardBounds = this.calculateVirtualKeyboardLayout();
}
this.mobileVirtualKeyboardActivePresses.forEach((press) => {
this.clearVirtualKeyboardRepeat(press);
});
}
const hitKey = this.findVirtualKeyboardKey(event.clientX, event.clientY);
if (hitKey) {
const press: ActiveVirtualKeyboardPress = {
pointerId: event.pointerId,
keyIndex: hitKey.rowIndex * 100 + hitKey.colIndex,
key: hitKey,
};
void this.dispatchVirtualKeyboardKey(hitKey);
this.mobileVirtualKeyboardActivePresses.set(event.pointerId, press);
this.startVirtualKeyboardRepeat(press);
} else {
this.mobileVirtualKeyboardActivePresses.set(event.pointerId, {
pointerId: event.pointerId,
keyIndex: -1,
key: null,
});
}
this.requestMobileVirtualKeyboardDraw();
};
private handleVirtualKeyboardPointerMove = (event: PointerEvent): void => {
event.preventDefault();
const press = this.mobileVirtualKeyboardActivePresses.get(event.pointerId);
if (!press) {
return;
}
const hitKey = this.findVirtualKeyboardKey(event.clientX, event.clientY);
if (hitKey) {
const newKeyIndex = hitKey.rowIndex * 100 + hitKey.colIndex;
if (newKeyIndex !== press.keyIndex) {
this.clearVirtualKeyboardRepeat(press);
const nextPress: ActiveVirtualKeyboardPress = {
pointerId: event.pointerId,
keyIndex: newKeyIndex,
key: hitKey,
};
void this.dispatchVirtualKeyboardKey(hitKey);
this.mobileVirtualKeyboardActivePresses.set(event.pointerId, nextPress);
this.startVirtualKeyboardRepeat(nextPress);
}
} else if (press.keyIndex !== -1) {
this.clearVirtualKeyboardRepeat(press);
this.mobileVirtualKeyboardActivePresses.set(event.pointerId, {
pointerId: event.pointerId,
keyIndex: -1,
key: null,
});
}
this.requestMobileVirtualKeyboardDraw();
};
private handleVirtualKeyboardPointerUp = (event: PointerEvent): void => {
event.preventDefault();
const press = this.mobileVirtualKeyboardActivePresses.get(event.pointerId);
if (press) {
this.clearVirtualKeyboardRepeat(press);
this.mobileVirtualKeyboardActivePresses.delete(event.pointerId);
}
if (this.mobileVirtualKeyboard?.hasPointerCapture(event.pointerId)) {
this.mobileVirtualKeyboard.releasePointerCapture(event.pointerId);
}
this.requestMobileVirtualKeyboardDraw();
};
private setupVirtualKeyboard(): void {
if (this.mobileVirtualKeyboard || this.mobileVirtualKeyboardHost || !document.body) {
@@ -2482,13 +2910,42 @@ class WebTerminal {
const keyboardHost = document.createElement("div");
keyboardHost.className = "mobile-virtual-keyboard";
keyboardHost.style.cssText = `
flex-shrink: 0;
background: #0f172a;
padding: 0;
touch-action: none;
user-select: none;
-webkit-user-select: none;
`;
const canvas = document.createElement("canvas");
canvas.style.cssText = `
display: block;
width: 100%;
height: 100%;
touch-action: none;
`;
canvas.addEventListener("pointerdown", this.handleVirtualKeyboardPointerDown, { passive: false });
canvas.addEventListener("pointermove", this.handleVirtualKeyboardPointerMove, { passive: false });
canvas.addEventListener("pointerup", this.handleVirtualKeyboardPointerUp, { passive: false });
canvas.addEventListener("pointercancel", this.handleVirtualKeyboardPointerUp, { passive: false });
canvas.addEventListener("pointerleave", this.handleVirtualKeyboardPointerUp, { passive: false });
keyboardHost.appendChild(canvas);
document.body.appendChild(keyboardHost);
this.mobileVirtualKeyboardHost = keyboardHost;
this.mobileVirtualKeyboard = keyboardHost;
this.renderVirtualKeyboardRows();
this.syncVirtualKeyboardState();
this.mobileVirtualKeyboard = canvas;
this.mobileVirtualKeyboardBounds = [];
this.updateVirtualKeyboardHostHeight();
this.addTrackedListener(window, "resize", () => {
this.updateVirtualKeyboardHostHeight();
this.mobileVirtualKeyboardBounds = [];
this.requestMobileVirtualKeyboardDraw();
});
this.setMobileKeyboardVisible(false);
this.clearUserError();
this.requestMobileVirtualKeyboardDraw();
}
/** Setup bottom-docked mobile extended keyboard bar */
@@ -2499,11 +2956,15 @@ class WebTerminal {
const keysPanel = document.createElement("div");
keysPanel.className = "keybar-panel keybar-keys";
keysPanel.innerHTML = `
<button class="keybar-hide" title="Hide keyboard">⌄</button>
<button data-modifier="fn" title="Fn modifier">Fn</button>
<button data-key="\\x09" title="Tab">Tab</button>
<button data-key="\\x1b[D" title="Left arrow">←</button>
<button data-key="\\x1b[B" title="Down arrow">↓</button>
<button data-key="\\x1b[A" title="Up arrow">↑</button>
<button data-key="\\x1b[C" title="Right arrow">→</button>
<span class="keybar-label keybar-label-grow">Bar</span>
<button class="keybar-settings" title="Settings">⚙</button>
<button class="keybar-hide" title="Hide keyboard">⌄</button>
`;
const settingsPanel = document.createElement("div");
@@ -2536,50 +2997,13 @@ class WebTerminal {
.mobile-virtual-keyboard {
flex-shrink: 0;
background: #0f172a;
padding: 10px 10px 8px;
border-top: 1px solid rgba(148, 163, 184, 0.18);
}
.mobile-virtual-keyboard {
font-family: system-ui, sans-serif;
}
.mobile-virtual-keyboard .webterm-vk-row {
display: flex;
gap: 6px;
margin-bottom: 6px;
}
.mobile-virtual-keyboard .webterm-vk-row[data-row-size="9"] {
padding-inline: 4%;
}
.mobile-virtual-keyboard .webterm-vk-row[data-row-size="4"] {
padding-inline: 10%;
}
.mobile-virtual-keyboard .webterm-vk-button {
align-items: center;
background: #1e293b;
border: 0;
border-radius: 10px;
box-shadow: 0 4px 0 #020617;
color: #f8fafc;
display: flex;
font-size: 24px;
font-weight: 500;
height: ${this.virtualKeyboardButtonHeight}px;
justify-content: center;
min-width: 0;
padding: 0 8px;
}
.mobile-virtual-keyboard .webterm-vk-button:active {
background: #334155;
transform: translateY(2px);
box-shadow: 0 2px 0 #020617;
}
.mobile-virtual-keyboard .webterm-vk-button.webterm-vk-action,
.mobile-virtual-keyboard .webterm-vk-button.webterm-vk-arrow {
background: #334155;
}
.mobile-virtual-keyboard .webterm-vk-button.webterm-vk-active {
background: #e2e8f0;
color: #0f172a;
box-shadow: 0 4px 0 #94a3b8;
.mobile-virtual-keyboard canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.keybar-panel {
display: flex;
@@ -2664,9 +3088,7 @@ class WebTerminal {
keybar.querySelectorAll("button").forEach((btn) => {
(btn as HTMLElement).style.height = `${this.keybarButtonHeight}px`;
});
this.mobileVirtualKeyboardHost?.querySelectorAll(".webterm-vk-button").forEach((btn) => {
(btn as HTMLElement).style.height = `${this.virtualKeyboardButtonHeight}px`;
});
this.requestMobileVirtualKeyboardDraw();
this.fit();
};
@@ -3164,6 +3586,14 @@ class WebTerminal {
clearTimeout(this.pendingStdinTimer);
this.pendingStdinTimer = undefined;
}
if (this.mobileVirtualKeyboardDrawFrame !== null) {
cancelAnimationFrame(this.mobileVirtualKeyboardDrawFrame);
this.mobileVirtualKeyboardDrawFrame = null;
}
this.mobileVirtualKeyboardActivePresses.forEach((press) => {
this.clearVirtualKeyboardRepeat(press);
});
this.mobileVirtualKeyboardActivePresses.clear();
this.pendingStdin = "";
if (this.resizeDebounceTimer) {
clearTimeout(this.resizeDebounceTimer);
@@ -3200,6 +3630,7 @@ class WebTerminal {
this.mobileVirtualKeyboardHost.remove();
this.mobileVirtualKeyboardHost = null;
}
this.mobileVirtualKeyboardBounds = [];
if (this.mobileKeybarStyle) {
this.mobileKeybarStyle.remove();
this.mobileKeybarStyle = null;