fix: lock body scroll and re-add programmatic terminal touch scroll

- Add body.terminal-page-open { overflow: hidden } to prevent page scroll
- TerminalPage adds/removes 'terminal-page-open' class on body when mounted
- Re-add capture-phase touch listeners in terminal.tsx with low 3px threshold
- Call e.preventDefault() immediately when vertical gesture is detected,
  before browser compositor commits to page scroll
- Remove CSS touch-action overrides on xterm viewport (now handled in JS)
- Scroll forwarded via term.scrollLines() with 24px per line sensitivity
This commit is contained in:
Alex Blank
2026-05-29 19:57:48 +02:00
parent 61d32fa00f
commit c1e16f2163
3 changed files with 91 additions and 19 deletions
+77 -11
View File
@@ -429,18 +429,73 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
};
document.addEventListener("visibilitychange", handleVisibilityChange);
// 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) {
const viewport = container.querySelector(
".xterm-viewport",
) as HTMLElement | null;
if (viewport) {
viewport.style.touchAction = "pan-y";
viewport.style.overscrollBehavior = "contain";
// Mobile touch scroll forwarding.
// xterm.js intercepts touch events for selection, which blocks the
// browser from scrolling the viewport. We listen on the container in
// capture phase, decide early if the gesture is vertical, and then
// preventDefault + stopPropagation so xterm.js never sees the event.
// The scroll is forwarded to term.scrollLines().
let touchStartY = 0;
let touchStartX = 0;
let isVerticalScroll = false;
let accumulatedDeltaY = 0;
const handleTouchStart = (e: TouchEvent) => {
if (e.touches.length === 1) {
touchStartY = e.touches[0].clientY;
touchStartX = e.touches[0].clientX;
isVerticalScroll = 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);
// Decide scroll direction on the first meaningful movement (3px)
if (!isVerticalScroll) {
if (Math.abs(deltaY) > deltaX && Math.abs(deltaY) > 3) {
isVerticalScroll = true;
}
}
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.stopPropagation();
accumulatedDeltaY += deltaY;
touchStartY = touch.clientY;
const lines = Math.round(accumulatedDeltaY / 24);
if (lines !== 0) {
// Negative = scroll up (show older buffer content)
termRef.current.scrollLines(-lines);
accumulatedDeltaY = 0;
}
}
};
const handleTouchEnd = () => {
isVerticalScroll = false;
accumulatedDeltaY = 0;
};
if (isMobile) {
container.addEventListener("touchstart", handleTouchStart, {
passive: true,
capture: true,
});
container.addEventListener("touchmove", handleTouchMove, {
passive: false,
capture: true,
});
container.addEventListener("touchend", handleTouchEnd, {
capture: true,
});
}
return () => {
@@ -454,6 +509,17 @@ 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,
});
}
if (ws) {
ws.close(1000, "Component unmounting");
}