feat: Android-only rAF-batched touch scroll for terminal
Android batches touchmove events irregularly — firing one WheelEvent per touchmove caused burst delivery (5 events → 5 WheelEvents → visible jump). Fix: accumulate delta in touchmove, dispatch exactly one WheelEvent per requestAnimationFrame tick (≤60fps). Remainder carries over to next frame. UA-gated (/Android/i) — iOS and macOS are completely unaffected. WheelEvent path → xterm.js mouse reporting → tmux (correct inside path). e.preventDefault() blocks outer-container scroll on Android.
This commit is contained in:
@@ -247,4 +247,70 @@ function closeTerminal() {
|
|||||||
window._openTerminal = openTerminal;
|
window._openTerminal = openTerminal;
|
||||||
window._closeTerminal = closeTerminal;
|
window._closeTerminal = closeTerminal;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Android touch scroll — rAF-batched WheelEvent dispatch
|
||||||
|
// Android batches touchmove events irregularly; dispatching one WheelEvent
|
||||||
|
// per frame (via requestAnimationFrame) smooths over burst delivery.
|
||||||
|
// UA-gated: iOS and macOS are unaffected (they use mouse wheel natively).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
;(function initAndroidTerminalScroll() {
|
||||||
|
if (!/Android/i.test(navigator.userAgent)) return;
|
||||||
|
|
||||||
|
var container = document.getElementById('terminal-container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
var _lastY = 0;
|
||||||
|
var _accumulated = 0; // pixel debt between rAF ticks
|
||||||
|
var _rafId = null;
|
||||||
|
var SCROLL_PX = 20; // pixels of touch movement = one WheelEvent dispatch
|
||||||
|
|
||||||
|
function flushScroll() {
|
||||||
|
_rafId = null;
|
||||||
|
if (!_term || Math.abs(_accumulated) < SCROLL_PX) return;
|
||||||
|
|
||||||
|
var viewport = container.querySelector('.xterm-viewport');
|
||||||
|
if (!viewport) { _accumulated = 0; return; }
|
||||||
|
|
||||||
|
// One WheelEvent per frame — dir * 120 = one standard scroll click
|
||||||
|
var dir = _accumulated > 0 ? 1 : -1;
|
||||||
|
viewport.dispatchEvent(new WheelEvent('wheel', {
|
||||||
|
deltaY: dir * 120,
|
||||||
|
deltaMode: WheelEvent.DOM_DELTA_PIXEL,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}));
|
||||||
|
_accumulated -= dir * SCROLL_PX;
|
||||||
|
|
||||||
|
// Self-schedule until remainder is consumed
|
||||||
|
if (Math.abs(_accumulated) >= SCROLL_PX) {
|
||||||
|
_rafId = requestAnimationFrame(flushScroll);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
container.addEventListener('touchstart', function (e) {
|
||||||
|
_lastY = e.touches[0].clientY;
|
||||||
|
_accumulated = 0;
|
||||||
|
if (_rafId) { cancelAnimationFrame(_rafId); _rafId = null; }
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
container.addEventListener('touchmove', function (e) {
|
||||||
|
if (!_term) return;
|
||||||
|
e.preventDefault(); // block outer-container scroll
|
||||||
|
|
||||||
|
var y = e.touches[0].clientY;
|
||||||
|
_accumulated += _lastY - y; // positive = swipe up = newer content
|
||||||
|
_lastY = y;
|
||||||
|
|
||||||
|
if (!_rafId) {
|
||||||
|
_rafId = requestAnimationFrame(flushScroll);
|
||||||
|
}
|
||||||
|
}, { passive: false }); // passive:false required for preventDefault
|
||||||
|
|
||||||
|
container.addEventListener('touchend', function () {
|
||||||
|
_lastY = 0;
|
||||||
|
_accumulated = 0;
|
||||||
|
if (_rafId) { cancelAnimationFrame(_rafId); _rafId = null; }
|
||||||
|
}, { 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);
|
||||||
@@ -718,4 +719,18 @@ test('terminal is auto-focused when WebSocket opens', () => {
|
|||||||
'_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');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Android touch scroll ---------------------------------------------------
|
||||||
|
|
||||||
|
test('terminal.js Android touch scroll is UA-gated', () => {
|
||||||
|
const source = fs.readFileSync(
|
||||||
|
new URL('../terminal.js', import.meta.url), 'utf8'
|
||||||
|
);
|
||||||
|
assert.ok(source.includes('Android'), 'must UA-detect Android before adding handlers');
|
||||||
|
assert.ok(source.includes('requestAnimationFrame'), 'must use rAF to batch scroll dispatch');
|
||||||
|
assert.ok(source.includes('e.preventDefault'), 'touchmove must preventDefault to block outer scroll');
|
||||||
|
assert.ok(source.includes('WheelEvent'), 'must dispatch WheelEvent to xterm viewport');
|
||||||
|
assert.ok(source.includes('passive: false'), 'touchmove must be non-passive');
|
||||||
|
assert.ok(!source.includes('scrollLines'), 'must NOT use scrollLines (scrolls local buffer not PTY)');
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user