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); document.addEventListener("visibilitychange", handleVisibilityChange);
// Mobile touch scroll forwarding. // Mobile touch scroll forwarding.
// xterm.js intercepts touch events for selection, which blocks the // xterm.js intercepts touch events for selection, blocking native scroll.
// browser from scrolling the viewport. We listen on the container in // We attach to *document* in capture phase so we run before xterm.js
// capture phase, decide early if the gesture is vertical, and then // even on child elements. We only act when the touch target is inside
// preventDefault + stopPropagation so xterm.js never sees the event. // this terminal container.
// The scroll is forwarded to term.scrollLines().
let touchStartY = 0; let touchStartY = 0;
let touchStartX = 0; let touchStartX = 0;
let isVerticalScroll = false; let isVerticalScroll = false;
let accumulatedDeltaY = 0; let accumulatedDeltaY = 0;
const isInThisTerminal = (target: EventTarget | null): boolean => {
if (!(target instanceof Node)) return false;
return container.contains(target);
};
const handleTouchStart = (e: TouchEvent) => { const handleTouchStart = (e: TouchEvent) => {
if (e.touches.length === 1) { if (e.touches.length !== 1) return;
touchStartY = e.touches[0].clientY; if (!isInThisTerminal(e.target)) return;
touchStartX = e.touches[0].clientX; touchStartY = e.touches[0].clientY;
isVerticalScroll = false; touchStartX = e.touches[0].clientX;
accumulatedDeltaY = 0; isVerticalScroll = false;
} accumulatedDeltaY = 0;
}; };
const handleTouchMove = (e: TouchEvent) => { const handleTouchMove = (e: TouchEvent) => {
if (e.touches.length !== 1 || !termRef.current) return; if (e.touches.length !== 1 || !termRef.current) return;
if (!isInThisTerminal(e.target)) return;
const touch = e.touches[0]; const touch = e.touches[0];
const deltaY = touchStartY - touch.clientY; const deltaY = touchStartY - touch.clientY;
const deltaX = Math.abs(touchStartX - touch.clientX); const deltaX = Math.abs(touchStartX - touch.clientX);
// Decide scroll direction on the first meaningful movement (3px)
if (!isVerticalScroll) { if (!isVerticalScroll) {
if (Math.abs(deltaY) > deltaX && Math.abs(deltaY) > 3) { if (Math.abs(deltaY) > deltaX && Math.abs(deltaY) > 3) {
isVerticalScroll = true; isVerticalScroll = true;
@@ -463,16 +467,12 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
} }
if (isVerticalScroll) { 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.preventDefault();
e.stopPropagation(); e.stopPropagation();
accumulatedDeltaY += deltaY; accumulatedDeltaY += deltaY;
touchStartY = touch.clientY; touchStartY = touch.clientY;
const lines = Math.round(accumulatedDeltaY / 24); const lines = Math.round(accumulatedDeltaY / 20);
if (lines !== 0) { if (lines !== 0) {
// Negative = scroll up (show older buffer content)
termRef.current.scrollLines(-lines); termRef.current.scrollLines(-lines);
accumulatedDeltaY = 0; accumulatedDeltaY = 0;
} }
@@ -485,15 +485,15 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
}; };
if (isMobile) { if (isMobile) {
container.addEventListener("touchstart", handleTouchStart, { document.addEventListener("touchstart", handleTouchStart, {
passive: true, passive: true,
capture: true, capture: true,
}); });
container.addEventListener("touchmove", handleTouchMove, { document.addEventListener("touchmove", handleTouchMove, {
passive: false, passive: false,
capture: true, capture: true,
}); });
container.addEventListener("touchend", handleTouchEnd, { document.addEventListener("touchend", handleTouchEnd, {
capture: true, capture: true,
}); });
} }
@@ -510,13 +510,13 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
handleVisibilityChange, handleVisibilityChange,
); );
if (isMobile) { if (isMobile) {
container.removeEventListener("touchstart", handleTouchStart, { document.removeEventListener("touchstart", handleTouchStart, {
capture: true, capture: true,
}); });
container.removeEventListener("touchmove", handleTouchMove, { document.removeEventListener("touchmove", handleTouchMove, {
capture: true, capture: true,
}); });
container.removeEventListener("touchend", handleTouchEnd, { document.removeEventListener("touchend", handleTouchEnd, {
capture: true, capture: true,
}); });
} }
+1
View File
@@ -3707,6 +3707,7 @@ a.nav-item,
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
touch-action: none;
} }
/* xterm.js manages its own sizing */ /* xterm.js manages its own sizing */