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");
|
const canvas = this.element.querySelector("canvas");
|
||||||
if (!canvas) return;
|
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 dispatchMouse = (type: "mousedown" | "mousemove" | "mouseup", touch: Touch) => {
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const event = new MouseEvent(type, {
|
const event = new MouseEvent(type, {
|
||||||
bubbles: true,
|
bubbles: true,
|
||||||
cancelable: true,
|
cancelable: true,
|
||||||
@@ -1187,13 +1201,71 @@ class WebTerminal {
|
|||||||
canvas.dispatchEvent(event);
|
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(
|
this.addTrackedListener(
|
||||||
canvas,
|
canvas,
|
||||||
"touchstart",
|
"touchstart",
|
||||||
((e: TouchEvent) => {
|
((e: TouchEvent) => {
|
||||||
if (e.touches.length !== 1) return;
|
if (e.touches.length !== 1) return;
|
||||||
dispatchMouse("mousedown", e.touches[0]);
|
|
||||||
e.preventDefault();
|
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,
|
}) as EventListener,
|
||||||
{ passive: false }
|
{ passive: false }
|
||||||
);
|
);
|
||||||
@@ -1203,8 +1275,40 @@ class WebTerminal {
|
|||||||
"touchmove",
|
"touchmove",
|
||||||
((e: TouchEvent) => {
|
((e: TouchEvent) => {
|
||||||
if (e.touches.length !== 1) return;
|
if (e.touches.length !== 1) return;
|
||||||
dispatchMouse("mousemove", e.touches[0]);
|
|
||||||
e.preventDefault();
|
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,
|
}) as EventListener,
|
||||||
{ passive: false }
|
{ passive: false }
|
||||||
);
|
);
|
||||||
@@ -1215,18 +1319,35 @@ class WebTerminal {
|
|||||||
((e: TouchEvent) => {
|
((e: TouchEvent) => {
|
||||||
const touch = e.changedTouches[0];
|
const touch = e.changedTouches[0];
|
||||||
if (!touch) return;
|
if (!touch) return;
|
||||||
dispatchMouse("mouseup", touch);
|
|
||||||
e.preventDefault();
|
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,
|
}) as EventListener,
|
||||||
{ passive: false }
|
{ passive: false }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private keybarButtonHeight = 44;
|
||||||
|
|
||||||
/** Setup bottom-docked mobile extended keyboard bar */
|
/** Setup bottom-docked mobile extended keyboard bar */
|
||||||
private setupMobileKeybar(): void {
|
private setupMobileKeybar(): void {
|
||||||
const keybar = document.createElement("div");
|
const keybar = document.createElement("div");
|
||||||
keybar.className = "mobile-keybar";
|
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-key="\\x1b" title="Escape">Esc</button>
|
||||||
<button data-modifier="ctrl" title="Ctrl modifier">Ctrl</button>
|
<button data-modifier="ctrl" title="Ctrl modifier">Ctrl</button>
|
||||||
<button data-modifier="alt" title="Alt modifier">Alt</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[B" title="Down">↓</button>
|
||||||
<button data-key="\\x1b[D" title="Left">←</button>
|
<button data-key="\\x1b[D" title="Left">←</button>
|
||||||
<button data-key="\\x1b[C" title="Right">→</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
|
// Inject styles
|
||||||
const style = document.createElement("style");
|
const style = document.createElement("style");
|
||||||
style.textContent = `
|
style.textContent = `
|
||||||
.mobile-keybar {
|
.mobile-keybar {
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 6px;
|
|
||||||
background: rgba(40, 40, 40, 0.95);
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
background: rgba(40, 40, 40, 0.95);
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
.keybar-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
.mobile-keybar button {
|
.mobile-keybar button {
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 44px;
|
height: ${this.keybarButtonHeight}px;
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
border: 1px solid #555;
|
border: 1px solid #555;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -1275,14 +1414,72 @@ class WebTerminal {
|
|||||||
background: #0066cc;
|
background: #0066cc;
|
||||||
border-color: #0088ff;
|
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);
|
document.head.appendChild(style);
|
||||||
this.mobileKeybarStyle = style;
|
this.mobileKeybarStyle = style;
|
||||||
document.body.appendChild(keybar);
|
document.body.appendChild(keybar);
|
||||||
this.mobileKeybar = 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
|
// Handle key button presses
|
||||||
keybar.querySelectorAll("button[data-key]").forEach((btn) => {
|
keysPanel.querySelectorAll("button[data-key]").forEach((btn) => {
|
||||||
btn.addEventListener("touchstart", (e) => {
|
btn.addEventListener("touchstart", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
let key = (btn as HTMLElement).dataset.key || "";
|
let key = (btn as HTMLElement).dataset.key || "";
|
||||||
@@ -1334,7 +1531,7 @@ class WebTerminal {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Handle modifier toggles
|
// Handle modifier toggles
|
||||||
keybar.querySelectorAll("button[data-modifier]").forEach((btn) => {
|
keysPanel.querySelectorAll("button[data-modifier]").forEach((btn) => {
|
||||||
btn.addEventListener("touchstart", (e) => {
|
btn.addEventListener("touchstart", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const modifier = (btn as HTMLElement).dataset.modifier;
|
const modifier = (btn as HTMLElement).dataset.modifier;
|
||||||
|
|||||||
Reference in New Issue
Block a user