feat: touch scroll for terminal and sidebar on mobile
Terminal: add touchstart/touchmove/touchend IIFE on #terminal-container. Vertical swipe delta translated to _term.scrollLines(n). touchmove is non-passive (required for e.preventDefault to block page scroll while swiping inside terminal). mouse wheel scroll unaffected (separate wheel event path in xterm.js). Sidebar: add touch-action:pan-y + overscroll-behavior:contain to .sidebar-list. Tells browser explicitly that vertical panning is allowed and prevents scroll chaining when reaching list ends.
This commit is contained in:
@@ -427,6 +427,8 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
touch-action: pan-y; /* tell browser vertical panning is allowed on mobile */
|
||||||
|
overscroll-behavior: contain; /* prevent scroll chaining to non-scrollable parent */
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-collapse-btn {
|
.sidebar-collapse-btn {
|
||||||
|
|||||||
@@ -246,3 +246,42 @@ function closeTerminal() {
|
|||||||
// ─── Expose to app.js ─────────────────────────────────────────────────────────
|
// ─── Expose to app.js ─────────────────────────────────────────────────────────
|
||||||
window._openTerminal = openTerminal;
|
window._openTerminal = openTerminal;
|
||||||
window._closeTerminal = closeTerminal;
|
window._closeTerminal = closeTerminal;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Touch scroll — translates vertical swipe into xterm.js scrollback
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Runs once after DOM is ready. Mouse wheel scroll is unaffected (separate
|
||||||
|
// `wheel` event path handled natively by xterm.js).
|
||||||
|
;(function initTerminalTouchScroll() {
|
||||||
|
var container = document.getElementById('terminal-container');
|
||||||
|
if (!container || typeof container.addEventListener !== 'function') return;
|
||||||
|
|
||||||
|
var _touchLastY = 0;
|
||||||
|
|
||||||
|
container.addEventListener('touchstart', function (e) {
|
||||||
|
_touchLastY = e.touches[0].clientY;
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
container.addEventListener('touchmove', function (e) {
|
||||||
|
if (!_term) return;
|
||||||
|
e.preventDefault(); // prevent page scroll while swiping inside terminal
|
||||||
|
|
||||||
|
var y = e.touches[0].clientY;
|
||||||
|
var deltaY = _touchLastY - y; // positive = swipe up = scroll toward newer content
|
||||||
|
_touchLastY = y;
|
||||||
|
|
||||||
|
// Convert pixels to lines. xterm.js fontSize is set per device (12 mobile, 14 desktop).
|
||||||
|
// lineHeight ratio is 1.2 — gives ~14-17px per line.
|
||||||
|
var pxPerLine = (_term.options.fontSize || 14) * 1.2;
|
||||||
|
var lines = deltaY / pxPerLine;
|
||||||
|
|
||||||
|
// Only fire if movement is meaningful (avoids micro-jitter)
|
||||||
|
if (Math.abs(lines) >= 0.3) {
|
||||||
|
_term.scrollLines(Math.round(lines));
|
||||||
|
}
|
||||||
|
}, { passive: false }); // passive: false required to allow e.preventDefault()
|
||||||
|
|
||||||
|
container.addEventListener('touchend', function () {
|
||||||
|
_touchLastY = 0;
|
||||||
|
}, { passive: true });
|
||||||
|
})();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { test } from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
@@ -717,3 +718,31 @@ test('terminal is auto-focused when WebSocket opens', () => {
|
|||||||
assert.strictEqual(t.focusCallCount, 1,
|
assert.strictEqual(t.focusCallCount, 1,
|
||||||
'_term.focus() should be called exactly once when the WebSocket open event fires');
|
'_term.focus() should be called exactly once when the WebSocket open event fires');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Touch scroll source-inspection tests ---
|
||||||
|
|
||||||
|
const terminalSrc = fs.readFileSync(
|
||||||
|
new URL('../terminal.js', import.meta.url), 'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
test('terminal.js registers touchmove handler on terminal-container', () => {
|
||||||
|
// Source inspection: verify the touch scroll IIFE is present and uses
|
||||||
|
// e.preventDefault() (passive: false is required for prevent default)
|
||||||
|
assert.ok(terminalSrc.includes('touchmove'),
|
||||||
|
'touchmove listener must be present');
|
||||||
|
assert.ok(terminalSrc.includes('e.preventDefault'),
|
||||||
|
'must call preventDefault to prevent page scroll while swiping inside terminal');
|
||||||
|
assert.ok(terminalSrc.includes('scrollLines'),
|
||||||
|
'must call _term.scrollLines to scroll terminal');
|
||||||
|
assert.ok(terminalSrc.includes('passive: false'),
|
||||||
|
'touchmove must be non-passive to allow preventDefault');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('terminal.js touchstart and touchend are passive', () => {
|
||||||
|
// touchstart and touchend should be passive: true for performance
|
||||||
|
// (they don't need preventDefault)
|
||||||
|
const touchstartPassive = terminalSrc.match(/touchstart[\s\S]*?passive:\s*(true|false)/);
|
||||||
|
const touchendPassive = terminalSrc.match(/touchend[\s\S]*?passive:\s*(true|false)/);
|
||||||
|
assert.ok(touchstartPassive, 'touchstart listener must declare passive');
|
||||||
|
assert.ok(touchendPassive, 'touchend listener must declare passive');
|
||||||
|
});
|
||||||
|
|||||||
@@ -587,3 +587,20 @@ def test_mobile_active_tier_targets_tile_body_pre_not_tile_pre():
|
|||||||
assert ".session-tile--tier-active .tile-pre" not in css, (
|
assert ".session-tile--tier-active .tile-pre" not in css, (
|
||||||
".tile-pre is a dead class — selector must be removed"
|
".tile-pre is a dead class — selector must be removed"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sidebar_list_has_touch_action_pan_y():
|
||||||
|
"""Regression: without touch-action:pan-y .sidebar-list won't scroll on mobile touch.
|
||||||
|
|
||||||
|
Mobile browsers need an explicit touch-action:pan-y declaration to allow
|
||||||
|
vertical panning on an overflow-y:auto element when adjacent content (like
|
||||||
|
xterm.js canvas) may be consuming touch events.
|
||||||
|
"""
|
||||||
|
css = read_css()
|
||||||
|
block = _extract_rule_block(css, ".sidebar-list {")
|
||||||
|
assert "touch-action: pan-y" in block, (
|
||||||
|
".sidebar-list must have touch-action:pan-y for mobile vertical scroll"
|
||||||
|
)
|
||||||
|
assert "overscroll-behavior: contain" in block, (
|
||||||
|
".sidebar-list must have overscroll-behavior:contain to prevent scroll chaining"
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user