fix: xterm.js mobile touch scrolling via viewport CSS and stopPropagation

- Add full mobile viewport CSS: overflow-y scroll, -webkit-overflow-scrolling
  touch, overscroll-behavior-y contain, translate3d hardware accel,
  scroll-behavior smooth, touch-action pan-y
- After term.open(), find .xterm-viewport and add passive touch listeners
  that call stopPropagation() (not preventDefault) — this lets the browser
  handle native touch scrolling while preventing xterm.js internal handlers
  from interfering
- Based on xterm.js known issue #5489 and SCROLLING_FIX.md approach
This commit is contained in:
Alex Blank
2026-05-29 20:16:12 +02:00
parent ef9ac76f06
commit 874873541d
2 changed files with 37 additions and 2 deletions
+32 -2
View File
@@ -311,6 +311,37 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
// Open xterm first (must happen before fit)
term.open(container);
term.focus();
// Mobile: allow native touch scroll on xterm viewport.
// xterm.js 5.x+ uses SmoothScrollableElement which can block native
// touch scrolling. We add passive touch listeners that call
// stopPropagation (not preventDefault) so the browser handles the
// gesture natively while xterm.js internal handlers don't fire.
let touchCleanup: (() => void) | undefined;
if (isMobile) {
const viewport = container.querySelector(
".xterm-viewport",
) as HTMLElement | null;
if (viewport) {
const onTouchStart = (e: TouchEvent) => {
e.stopPropagation();
};
const onTouchMove = (e: TouchEvent) => {
e.stopPropagation();
};
viewport.addEventListener("touchstart", onTouchStart, {
passive: true,
});
viewport.addEventListener("touchmove", onTouchMove, {
passive: true,
});
touchCleanup = () => {
viewport.removeEventListener("touchstart", onTouchStart);
viewport.removeEventListener("touchmove", onTouchMove);
};
}
}
const ws = connectWebSocket();
// Initial fit after layout settles (terminal must be opened first)
@@ -429,8 +460,6 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
isUnmountingRef.current = true;
clearTimeout(resizeTimeout);
@@ -442,6 +471,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
"visibilitychange",
handleVisibilityChange,
);
if (touchCleanup) touchCleanup();
if (ws) {
ws.close(1000, "Component unmounting");
}