fix: document-level capture touch listeners for mobile terminal scroll

- Attach touch listeners to document with capture:true instead of container
- Check if touch target is inside terminal container before handling
- This runs before xterm.js internal handlers, giving us full control
- Add touch-action: none to terminal container to prevent browser gestures
- Lower threshold to 3px, 20px per line for responsive scrolling
This commit is contained in:
Alex Blank
2026-05-29 20:03:11 +02:00
parent c1e16f2163
commit ca9db195de
2 changed files with 24 additions and 23 deletions
+23 -23
View File
@@ -430,32 +430,36 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
document.addEventListener("visibilitychange", handleVisibilityChange);
// Mobile touch scroll forwarding.
// xterm.js intercepts touch events for selection, which blocks the
// browser from scrolling the viewport. We listen on the container in
// capture phase, decide early if the gesture is vertical, and then
// preventDefault + stopPropagation so xterm.js never sees the event.
// The scroll is forwarded to term.scrollLines().
// xterm.js intercepts touch events for selection, blocking native scroll.
// We attach to *document* in capture phase so we run before xterm.js
// even on child elements. We only act when the touch target is inside
// this terminal container.
let touchStartY = 0;
let touchStartX = 0;
let isVerticalScroll = false;
let accumulatedDeltaY = 0;
const isInThisTerminal = (target: EventTarget | null): boolean => {
if (!(target instanceof Node)) return false;
return container.contains(target);
};
const handleTouchStart = (e: TouchEvent) => {
if (e.touches.length === 1) {
touchStartY = e.touches[0].clientY;
touchStartX = e.touches[0].clientX;
isVerticalScroll = false;
accumulatedDeltaY = 0;
}
if (e.touches.length !== 1) return;
if (!isInThisTerminal(e.target)) return;
touchStartY = e.touches[0].clientY;
touchStartX = e.touches[0].clientX;
isVerticalScroll = false;
accumulatedDeltaY = 0;
};
const handleTouchMove = (e: TouchEvent) => {
if (e.touches.length !== 1 || !termRef.current) return;
if (!isInThisTerminal(e.target)) return;
const touch = e.touches[0];
const deltaY = touchStartY - touch.clientY;
const deltaX = Math.abs(touchStartX - touch.clientX);
// Decide scroll direction on the first meaningful movement (3px)
if (!isVerticalScroll) {
if (Math.abs(deltaY) > deltaX && Math.abs(deltaY) > 3) {
isVerticalScroll = true;
@@ -463,16 +467,12 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
}
if (isVerticalScroll) {
// preventDefault MUST be called on the first matching touchmove
// or the browser compositor will already have committed to page
// scroll before our JS runs.
e.preventDefault();
e.stopPropagation();
accumulatedDeltaY += deltaY;
touchStartY = touch.clientY;
const lines = Math.round(accumulatedDeltaY / 24);
const lines = Math.round(accumulatedDeltaY / 20);
if (lines !== 0) {
// Negative = scroll up (show older buffer content)
termRef.current.scrollLines(-lines);
accumulatedDeltaY = 0;
}
@@ -485,15 +485,15 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
};
if (isMobile) {
container.addEventListener("touchstart", handleTouchStart, {
document.addEventListener("touchstart", handleTouchStart, {
passive: true,
capture: true,
});
container.addEventListener("touchmove", handleTouchMove, {
document.addEventListener("touchmove", handleTouchMove, {
passive: false,
capture: true,
});
container.addEventListener("touchend", handleTouchEnd, {
document.addEventListener("touchend", handleTouchEnd, {
capture: true,
});
}
@@ -510,13 +510,13 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
handleVisibilityChange,
);
if (isMobile) {
container.removeEventListener("touchstart", handleTouchStart, {
document.removeEventListener("touchstart", handleTouchStart, {
capture: true,
});
container.removeEventListener("touchmove", handleTouchMove, {
document.removeEventListener("touchmove", handleTouchMove, {
capture: true,
});
container.removeEventListener("touchend", handleTouchEnd, {
document.removeEventListener("touchend", handleTouchEnd, {
capture: true,
});
}
+1
View File
@@ -3707,6 +3707,7 @@ a.nav-item,
padding: 0;
overflow: hidden;
position: relative;
touch-action: none;
}
/* xterm.js manages its own sizing */