fix: mobile terminal scroll in both normal mode and tmux

- Dual-mode touch scroll:
  - Normal mode: scroll .xterm-viewport directly when scrollHeight > clientHeight
  - Alternate screen (tmux/vim): send SGR 1006 mouse-wheel protocol data
    using cursor position so tmux knows which pane to scroll
- Add touch-action: none to .terminal-container to prevent browser gestures
- Lock both html and body overflow when terminal page is open on mobile
- Remove synthetic WheelEvent approach (xterm.js SmoothScrollableElement
  doesn't reliably handle synthetic events)
This commit is contained in:
Alex Blank
2026-05-29 20:35:28 +02:00
parent 98b9d612fa
commit 4814ec2363
3 changed files with 34 additions and 8 deletions
+29 -7
View File
@@ -313,12 +313,12 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
term.focus();
const ws = connectWebSocket();
// Mobile: xterm.js 5.x+ viewport is behind the canvas in z-order, so
// native browser touch scrolling never reaches it. We attach capture-
// phase listeners to the wrapper container (parent of xterm) so we
// intercept touches before they bubble up to the page. Vertical swipes
// adjust .xterm-viewport.scrollTop directly, which fires scroll events
// that xterm.js handles internally.
// Mobile touch scroll.
// In normal mode xterm.js has a scrollable viewport; in alternate
// screen (tmux/vim) there is no scrollback and the only way to
// scroll is to send mouse-wheel protocol sequences to the
// application. We detect which situation we're in by checking
// whether the viewport has scrollable height.
let touchCleanup: (() => void) | undefined;
if (isMobile) {
let startY = 0;
@@ -347,8 +347,30 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
const viewport = container.querySelector(
".xterm-viewport",
) as HTMLElement | null;
if (viewport) {
if (!viewport) return;
// If the viewport is scrollable, scroll it directly.
// Otherwise we are in alternate screen (tmux/vim) and must
// send SGR 1006 mouse-wheel protocol data.
const hasScrollback =
viewport.scrollHeight > viewport.clientHeight;
if (hasScrollback) {
viewport.scrollTop += deltaY;
} else {
const ws = wsRef.current;
if (
ws?.readyState === WebSocket.OPEN &&
termRef.current
) {
// Use the cursor position as the wheel location so
// tmux knows which pane to scroll.
const buf = termRef.current.buffer.active;
const col = buf.cursorX + 1;
const row = buf.cursorY + 1;
// SGR 1006: 64 = wheel-up, 65 = wheel-down
const btn = deltaY > 0 ? 64 : 65;
ws.send(`\x1b[<${btn};${col};${row}M`);
}
}
startY = touch.clientY;
}