Add mobile touch and keybar improvements
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "Building frontend..."
|
||||
make build
|
||||
|
||||
echo "Building Go binary..."
|
||||
make build-go
|
||||
|
||||
echo "Installing binary..."
|
||||
cp bin/webterm ~/go/bin/webterm
|
||||
|
||||
echo "Restarting service..."
|
||||
systemctl --user restart webterm.service
|
||||
|
||||
echo "Done. Status:"
|
||||
systemctl --user status webterm.service --no-pager
|
||||
File diff suppressed because one or more lines are too long
+211
-14
@@ -1174,8 +1174,22 @@ class WebTerminal {
|
||||
const canvas = this.element.querySelector("canvas");
|
||||
if (!canvas) return;
|
||||
|
||||
const LONG_PRESS_MS = 300;
|
||||
const MOVE_THRESHOLD = 10;
|
||||
const SCROLL_SPEED = 1.5;
|
||||
|
||||
let mode: "undecided" | "scroll" | "select" = "undecided";
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let lastY = 0;
|
||||
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let scrollRemainder = 0;
|
||||
// Momentum state
|
||||
let velocityY = 0;
|
||||
let lastMoveTime = 0;
|
||||
let momentumFrame: number | null = null;
|
||||
|
||||
const dispatchMouse = (type: "mousedown" | "mousemove" | "mouseup", touch: Touch) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const event = new MouseEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
@@ -1187,13 +1201,71 @@ class WebTerminal {
|
||||
canvas.dispatchEvent(event);
|
||||
};
|
||||
|
||||
const cancelLongPress = () => {
|
||||
if (longPressTimer !== null) {
|
||||
clearTimeout(longPressTimer);
|
||||
longPressTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const stopMomentum = () => {
|
||||
if (momentumFrame !== null) {
|
||||
cancelAnimationFrame(momentumFrame);
|
||||
momentumFrame = null;
|
||||
}
|
||||
velocityY = 0;
|
||||
};
|
||||
|
||||
const lineHeight = () => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const rows = this.terminal.rows;
|
||||
return rows > 0 ? rect.height / rows : 16;
|
||||
};
|
||||
|
||||
const doMomentumScroll = () => {
|
||||
const FRICTION = 0.92;
|
||||
const MIN_VELOCITY = 0.5;
|
||||
|
||||
velocityY *= FRICTION;
|
||||
if (Math.abs(velocityY) < MIN_VELOCITY) {
|
||||
momentumFrame = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const lh = lineHeight();
|
||||
scrollRemainder += (velocityY * SCROLL_SPEED) / lh;
|
||||
const lines = Math.trunc(scrollRemainder);
|
||||
if (lines !== 0) {
|
||||
scrollRemainder -= lines;
|
||||
this.terminal.scrollLines(lines);
|
||||
}
|
||||
momentumFrame = requestAnimationFrame(doMomentumScroll);
|
||||
};
|
||||
|
||||
this.addTrackedListener(
|
||||
canvas,
|
||||
"touchstart",
|
||||
((e: TouchEvent) => {
|
||||
if (e.touches.length !== 1) return;
|
||||
dispatchMouse("mousedown", e.touches[0]);
|
||||
e.preventDefault();
|
||||
stopMomentum();
|
||||
|
||||
const touch = e.touches[0];
|
||||
startX = touch.clientX;
|
||||
startY = touch.clientY;
|
||||
lastY = touch.clientY;
|
||||
lastMoveTime = performance.now();
|
||||
velocityY = 0;
|
||||
scrollRemainder = 0;
|
||||
mode = "undecided";
|
||||
|
||||
longPressTimer = setTimeout(() => {
|
||||
longPressTimer = null;
|
||||
if (mode === "undecided") {
|
||||
mode = "select";
|
||||
dispatchMouse("mousedown", touch);
|
||||
}
|
||||
}, LONG_PRESS_MS);
|
||||
}) as EventListener,
|
||||
{ passive: false }
|
||||
);
|
||||
@@ -1203,8 +1275,40 @@ class WebTerminal {
|
||||
"touchmove",
|
||||
((e: TouchEvent) => {
|
||||
if (e.touches.length !== 1) return;
|
||||
dispatchMouse("mousemove", e.touches[0]);
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
const now = performance.now();
|
||||
|
||||
if (mode === "undecided") {
|
||||
const dx = touch.clientX - startX;
|
||||
const dy = touch.clientY - startY;
|
||||
if (Math.abs(dy) > MOVE_THRESHOLD || Math.abs(dx) > MOVE_THRESHOLD) {
|
||||
cancelLongPress();
|
||||
mode = "scroll";
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === "scroll") {
|
||||
const deltaY = lastY - touch.clientY;
|
||||
const dt = now - lastMoveTime;
|
||||
if (dt > 0) {
|
||||
velocityY = deltaY / dt * 16; // normalize to ~per-frame
|
||||
}
|
||||
lastY = touch.clientY;
|
||||
lastMoveTime = now;
|
||||
|
||||
const lh = lineHeight();
|
||||
scrollRemainder += (deltaY * SCROLL_SPEED) / lh;
|
||||
const lines = Math.trunc(scrollRemainder);
|
||||
if (lines !== 0) {
|
||||
scrollRemainder -= lines;
|
||||
this.terminal.scrollLines(lines);
|
||||
}
|
||||
} else if (mode === "select") {
|
||||
dispatchMouse("mousemove", touch);
|
||||
}
|
||||
}) as EventListener,
|
||||
{ passive: false }
|
||||
);
|
||||
@@ -1215,18 +1319,35 @@ class WebTerminal {
|
||||
((e: TouchEvent) => {
|
||||
const touch = e.changedTouches[0];
|
||||
if (!touch) return;
|
||||
dispatchMouse("mouseup", touch);
|
||||
e.preventDefault();
|
||||
cancelLongPress();
|
||||
|
||||
if (mode === "select") {
|
||||
dispatchMouse("mouseup", touch);
|
||||
} else if (mode === "scroll") {
|
||||
// Kick off momentum scrolling
|
||||
if (Math.abs(velocityY) > 0.5) {
|
||||
momentumFrame = requestAnimationFrame(doMomentumScroll);
|
||||
}
|
||||
}
|
||||
// If still "undecided", it was a tap — no action needed (focus handled elsewhere)
|
||||
|
||||
mode = "undecided";
|
||||
}) as EventListener,
|
||||
{ passive: false }
|
||||
);
|
||||
}
|
||||
|
||||
private keybarButtonHeight = 44;
|
||||
|
||||
/** Setup bottom-docked mobile extended keyboard bar */
|
||||
private setupMobileKeybar(): void {
|
||||
const keybar = document.createElement("div");
|
||||
keybar.className = "mobile-keybar";
|
||||
keybar.innerHTML = `
|
||||
|
||||
const keysPanel = document.createElement("div");
|
||||
keysPanel.className = "keybar-panel keybar-keys";
|
||||
keysPanel.innerHTML = `
|
||||
<button data-key="\\x1b" title="Escape">Esc</button>
|
||||
<button data-modifier="ctrl" title="Ctrl modifier">Ctrl</button>
|
||||
<button data-modifier="alt" title="Alt modifier">Alt</button>
|
||||
@@ -1237,27 +1358,45 @@ class WebTerminal {
|
||||
<button data-key="\\x1b[B" title="Down">↓</button>
|
||||
<button data-key="\\x1b[D" title="Left">←</button>
|
||||
<button data-key="\\x1b[C" title="Right">→</button>
|
||||
<button data-key="\\x0d" title="Return">⏎</button>
|
||||
<button class="keybar-settings" title="Settings">⚙</button>
|
||||
`;
|
||||
|
||||
const settingsPanel = document.createElement("div");
|
||||
settingsPanel.className = "keybar-panel keybar-settings-panel";
|
||||
settingsPanel.style.display = "none";
|
||||
settingsPanel.innerHTML = `
|
||||
<button class="keybar-back" title="Back">◀</button>
|
||||
<span class="keybar-label">Bar</span>
|
||||
<button class="keybar-bar-shrink" title="Shrink bar">−</button>
|
||||
<button class="keybar-bar-grow" title="Grow bar">+</button>
|
||||
<span class="keybar-label">Font</span>
|
||||
<button class="keybar-font-shrink" title="Smaller font">A−</button>
|
||||
<button class="keybar-font-grow" title="Larger font">A+</button>
|
||||
`;
|
||||
|
||||
keybar.appendChild(keysPanel);
|
||||
keybar.appendChild(settingsPanel);
|
||||
|
||||
// Inject styles
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
.mobile-keybar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
background: rgba(40, 40, 40, 0.95);
|
||||
flex-shrink: 0;
|
||||
background: rgba(40, 40, 40, 0.95);
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.keybar-panel {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
}
|
||||
.mobile-keybar button {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
height: 44px;
|
||||
height: ${this.keybarButtonHeight}px;
|
||||
padding: 0 4px;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
@@ -1275,14 +1414,72 @@ class WebTerminal {
|
||||
background: #0066cc;
|
||||
border-color: #0088ff;
|
||||
}
|
||||
.keybar-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
font-family: system-ui, sans-serif;
|
||||
padding: 0 4px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
this.mobileKeybarStyle = style;
|
||||
document.body.appendChild(keybar);
|
||||
this.mobileKeybar = keybar;
|
||||
|
||||
// Toggle between keys and settings panels
|
||||
const showPanel = (panel: "keys" | "settings") => {
|
||||
keysPanel.style.display = panel === "keys" ? "" : "none";
|
||||
settingsPanel.style.display = panel === "settings" ? "" : "none";
|
||||
};
|
||||
|
||||
keysPanel.querySelector(".keybar-settings")!.addEventListener("touchstart", (e) => {
|
||||
e.preventDefault();
|
||||
showPanel("settings");
|
||||
});
|
||||
|
||||
settingsPanel.querySelector(".keybar-back")!.addEventListener("touchstart", (e) => {
|
||||
e.preventDefault();
|
||||
showPanel("keys");
|
||||
});
|
||||
|
||||
// Bar size controls
|
||||
const updateButtonHeight = (delta: number) => {
|
||||
this.keybarButtonHeight = Math.max(28, Math.min(72, this.keybarButtonHeight + delta));
|
||||
keybar.querySelectorAll("button").forEach((btn) => {
|
||||
(btn as HTMLElement).style.height = `${this.keybarButtonHeight}px`;
|
||||
});
|
||||
this.fit();
|
||||
};
|
||||
|
||||
settingsPanel.querySelector(".keybar-bar-shrink")!.addEventListener("touchstart", (e) => {
|
||||
e.preventDefault();
|
||||
updateButtonHeight(-4);
|
||||
});
|
||||
|
||||
settingsPanel.querySelector(".keybar-bar-grow")!.addEventListener("touchstart", (e) => {
|
||||
e.preventDefault();
|
||||
updateButtonHeight(4);
|
||||
});
|
||||
|
||||
// Font size controls
|
||||
settingsPanel.querySelector(".keybar-font-shrink")!.addEventListener("touchstart", (e) => {
|
||||
e.preventDefault();
|
||||
this.fontSize = Math.max(8, this.fontSize - 1);
|
||||
this.terminal.options.fontSize = this.fontSize;
|
||||
this.fit();
|
||||
});
|
||||
|
||||
settingsPanel.querySelector(".keybar-font-grow")!.addEventListener("touchstart", (e) => {
|
||||
e.preventDefault();
|
||||
this.fontSize = Math.max(8, this.fontSize + 1);
|
||||
this.terminal.options.fontSize = this.fontSize;
|
||||
this.fit();
|
||||
});
|
||||
|
||||
// Handle key button presses
|
||||
keybar.querySelectorAll("button[data-key]").forEach((btn) => {
|
||||
keysPanel.querySelectorAll("button[data-key]").forEach((btn) => {
|
||||
btn.addEventListener("touchstart", (e) => {
|
||||
e.preventDefault();
|
||||
let key = (btn as HTMLElement).dataset.key || "";
|
||||
@@ -1334,7 +1531,7 @@ class WebTerminal {
|
||||
});
|
||||
|
||||
// Handle modifier toggles
|
||||
keybar.querySelectorAll("button[data-modifier]").forEach((btn) => {
|
||||
keysPanel.querySelectorAll("button[data-modifier]").forEach((btn) => {
|
||||
btn.addEventListener("touchstart", (e) => {
|
||||
e.preventDefault();
|
||||
const modifier = (btn as HTMLElement).dataset.modifier;
|
||||
|
||||
Reference in New Issue
Block a user