fix: container-level capture touch with direct viewport.scrollTop manipulation

- Attach capture-phase touch listeners to .terminal-container (parent of xterm)
- On vertical swipe: e.preventDefault() blocks page scroll, then directly
  adjust .xterm-viewport.scrollTop by the swipe delta
- This bypasses term.scrollLines() API and directly manipulates the DOM
  element that xterm.js watches via its internal scroll handler
- Remove all CSS touch-action overrides — container handles it in JS
This commit is contained in:
Alex Blank
2026-05-29 20:23:19 +02:00
parent 874873541d
commit 98b9d612fa
2 changed files with 65 additions and 38 deletions
+65 -28
View File
@@ -311,38 +311,75 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
// Open xterm first (must happen before fit)
term.open(container);
term.focus();
const ws = connectWebSocket();
// 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.
// Mobile: xterm.js 5.x+ viewport is behind the canvas in z-order, so
// native browser touch scrolling never reaches it. We attach capture-
// phase listeners to the wrapper container (parent of xterm) so we
// intercept touches before they bubble up to the page. Vertical swipes
// adjust .xterm-viewport.scrollTop directly, which fires scroll events
// that xterm.js handles internally.
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);
};
}
}
let startY = 0;
let startX = 0;
let isScrolling = false;
const ws = connectWebSocket();
const onTouchStart = (e: TouchEvent) => {
if (e.touches.length === 1) {
startY = e.touches[0].clientY;
startX = e.touches[0].clientX;
isScrolling = false;
}
};
const onTouchMove = (e: TouchEvent) => {
if (e.touches.length !== 1) return;
const touch = e.touches[0];
const deltaY = startY - touch.clientY;
const deltaX = Math.abs(startX - touch.clientX);
if (!isScrolling) {
if (Math.abs(deltaY) > deltaX && Math.abs(deltaY) > 4) {
isScrolling = true;
}
}
if (isScrolling) {
e.preventDefault();
const viewport = container.querySelector(
".xterm-viewport",
) as HTMLElement | null;
if (viewport) {
viewport.scrollTop += deltaY;
}
startY = touch.clientY;
}
};
const onTouchEnd = () => {
isScrolling = false;
};
container.addEventListener("touchstart", onTouchStart, {
passive: true,
capture: true,
});
container.addEventListener("touchmove", onTouchMove, {
passive: false,
capture: true,
});
container.addEventListener("touchend", onTouchEnd, {
capture: true,
});
touchCleanup = () => {
container.removeEventListener("touchstart", onTouchStart, {
capture: true,
});
container.removeEventListener("touchmove", onTouchMove, {
capture: true,
});
container.removeEventListener("touchend", onTouchEnd, {
capture: true,
});
};
}
// Initial fit after layout settles (terminal must be opened first)
let fitAttempts = 0;
-10
View File
@@ -3713,16 +3713,6 @@ a.nav-item,
/* xterm.js manages its own scrolling and viewport dimensions */
/* Mobile: allow native vertical touch panning on xterm viewport */
.terminal-wrapper.mobile .xterm .xterm-viewport {
touch-action: pan-y;
overflow-y: scroll !important;
-webkit-overflow-scrolling: touch !important;
overscroll-behavior-y: contain;
transform: translate3d(0, 0, 0);
scroll-behavior: smooth;
}
/* Special Keys Strip */
.special-keys-strip {
flex-shrink: 0;