fix: enable native touch scrolling on xterm.js viewport for mobile

- Remove all custom touch event interception code from terminal.tsx
- After term.open(), find the internal .xterm-viewport element and set
  touchAction=pan-y and overscrollBehavior=contain via inline styles
- Add CSS targeting .xterm-viewport on mobile with touch-action: pan-y,
  -webkit-overflow-scrolling: touch, and overflow-y: auto
- Let the browser handle vertical touch panning natively instead of
  trying to intercept and manually forward events
This commit is contained in:
Alex Blank
2026-05-29 19:41:23 +02:00
parent cddb3f8ccf
commit 61d32fa00f
2 changed files with 19 additions and 91 deletions
+11 -91
View File
@@ -429,84 +429,18 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
};
document.addEventListener("visibilitychange", handleVisibilityChange);
// Mobile touch scrolling — translate vertical swipe to terminal scroll.
// We attach to the container (wrapper) in CAPTURE phase so we run before
// xterm.js internals stop propagation. We also listen for wheel events
// so that mobile browsers that translate touch-pan into synthetic wheel
// events will still scroll the buffer.
let touchStartY = 0;
let touchStartX = 0;
let isTouchScrolling = false;
let accumulatedDeltaY = 0;
const handleTouchStart = (e: TouchEvent) => {
if (e.touches.length === 1) {
touchStartY = e.touches[0].clientY;
touchStartX = e.touches[0].clientX;
isTouchScrolling = false;
accumulatedDeltaY = 0;
}
};
const handleTouchMove = (e: TouchEvent) => {
if (e.touches.length !== 1 || !termRef.current) return;
const touch = e.touches[0];
const deltaY = touchStartY - touch.clientY;
const deltaX = Math.abs(touchStartX - touch.clientX);
// If vertical movement dominates and exceeds threshold, scroll terminal buffer
if (Math.abs(deltaY) > deltaX && Math.abs(deltaY) > 6) {
if (!isTouchScrolling) isTouchScrolling = true;
e.preventDefault();
e.stopPropagation();
accumulatedDeltaY += deltaY;
touchStartY = touch.clientY;
const pxPerLine = 16;
const lines = Math.round(accumulatedDeltaY / pxPerLine);
if (lines !== 0) {
// Negative scrollLines = scroll up (show older buffer content)
termRef.current.scrollLines(-lines);
accumulatedDeltaY = 0;
}
}
};
const handleTouchEnd = () => {
isTouchScrolling = false;
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);
}
}
};
// Enable native touch scrolling on xterm.js viewport for mobile.
// xterm.js creates an internal .xterm-viewport div that has overflow-y
// scroll but may disable touch-action. We override it so the browser
// handles vertical touch panning natively.
if (isMobile) {
// Capture phase runs before xterm.js handlers on the child elements
container.addEventListener("touchstart", handleTouchStart, {
passive: true,
capture: true,
});
container.addEventListener("touchmove", handleTouchMove, {
passive: false,
capture: true,
});
container.addEventListener("touchend", handleTouchEnd, {
capture: true,
});
container.addEventListener("wheel", handleWheel, {
passive: false,
capture: true,
});
const viewport = container.querySelector(
".xterm-viewport",
) as HTMLElement | null;
if (viewport) {
viewport.style.touchAction = "pan-y";
viewport.style.overscrollBehavior = "contain";
}
}
return () => {
@@ -520,20 +454,6 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
"visibilitychange",
handleVisibilityChange,
);
if (isMobile) {
container.removeEventListener("touchstart", handleTouchStart, {
capture: true,
});
container.removeEventListener("touchmove", handleTouchMove, {
capture: true,
});
container.removeEventListener("touchend", handleTouchEnd, {
capture: true,
});
container.removeEventListener("wheel", handleWheel, {
capture: true,
});
}
if (ws) {
ws.close(1000, "Component unmounting");
}