fix: mobile terminal scroll via capture-phase touch listeners

- Attach touch listeners to container wrapper in CAPTURE phase so they run
  before xterm.js internals stop propagation
- Add e.stopPropagation() in touchmove after handling scroll to prevent
  xterm.js from conflicting with our scroll
- Add wheel event fallback for mobile browsers that synthesize wheel from touch
- Remove touch-action: none CSS which was blocking native xterm viewport scroll
This commit is contained in:
Alex Blank
2026-05-29 19:35:42 +02:00
parent 87a938fe58
commit cddb3f8ccf
2 changed files with 45 additions and 11 deletions
+45 -10
View File
@@ -429,10 +429,11 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
}; };
document.addEventListener("visibilitychange", handleVisibilityChange); document.addEventListener("visibilitychange", handleVisibilityChange);
// Mobile touch scrolling — translate vertical swipe to terminal scroll // Mobile touch scrolling — translate vertical swipe to terminal scroll.
// Attach to term.element (xterm root) rather than container wrapper, // We attach to the container (wrapper) in CAPTURE phase so we run before
// since xterm internals may intercept events before they bubble up. // xterm.js internals stop propagation. We also listen for wheel events
const touchTarget = term.element || container; // so that mobile browsers that translate touch-pan into synthetic wheel
// events will still scroll the buffer.
let touchStartY = 0; let touchStartY = 0;
let touchStartX = 0; let touchStartX = 0;
let isTouchScrolling = false; let isTouchScrolling = false;
@@ -456,6 +457,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
if (Math.abs(deltaY) > deltaX && Math.abs(deltaY) > 6) { if (Math.abs(deltaY) > deltaX && Math.abs(deltaY) > 6) {
if (!isTouchScrolling) isTouchScrolling = true; if (!isTouchScrolling) isTouchScrolling = true;
e.preventDefault(); e.preventDefault();
e.stopPropagation();
accumulatedDeltaY += deltaY; accumulatedDeltaY += deltaY;
touchStartY = touch.clientY; touchStartY = touch.clientY;
const pxPerLine = 16; const pxPerLine = 16;
@@ -473,14 +475,38 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
accumulatedDeltaY = 0; accumulatedDeltaY = 0;
}; };
const handleWheel = (e: WheelEvent) => {
if (!termRef.current) return;
// On mobile some browsers translate vertical pan into wheel events.
// xterm.js already handles wheel natively on desktop, but on mobile
// the synthetic wheel may not reach xterm because of our layout.
// We manually forward vertical wheel deltas to scrollLines.
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
e.preventDefault();
const lines = Math.round(e.deltaY / 16);
if (lines !== 0) {
termRef.current.scrollLines(lines);
}
}
};
if (isMobile) { if (isMobile) {
touchTarget.addEventListener("touchstart", handleTouchStart, { // Capture phase runs before xterm.js handlers on the child elements
container.addEventListener("touchstart", handleTouchStart, {
passive: true, passive: true,
capture: true,
}); });
touchTarget.addEventListener("touchmove", handleTouchMove, { container.addEventListener("touchmove", handleTouchMove, {
passive: false, passive: false,
capture: true,
});
container.addEventListener("touchend", handleTouchEnd, {
capture: true,
});
container.addEventListener("wheel", handleWheel, {
passive: false,
capture: true,
}); });
touchTarget.addEventListener("touchend", handleTouchEnd);
} }
return () => { return () => {
@@ -495,9 +521,18 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
handleVisibilityChange, handleVisibilityChange,
); );
if (isMobile) { if (isMobile) {
touchTarget.removeEventListener("touchstart", handleTouchStart); container.removeEventListener("touchstart", handleTouchStart, {
touchTarget.removeEventListener("touchmove", handleTouchMove); capture: true,
touchTarget.removeEventListener("touchend", handleTouchEnd); });
container.removeEventListener("touchmove", handleTouchMove, {
capture: true,
});
container.removeEventListener("touchend", handleTouchEnd, {
capture: true,
});
container.removeEventListener("wheel", handleWheel, {
capture: true,
});
} }
if (ws) { if (ws) {
ws.close(1000, "Component unmounting"); ws.close(1000, "Component unmounting");
-1
View File
@@ -3703,7 +3703,6 @@ 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 */