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");
}
+10
View File
@@ -204,6 +204,16 @@ export const TerminalPage: React.FC = () => {
};
}, []);
// Lock body scroll on mobile terminal so swipes scroll the terminal buffer,
// not the page.
useEffect(() => {
if (!isMobile) return;
document.body.classList.add("terminal-page-open");
return () => {
document.body.classList.remove("terminal-page-open");
};
}, [isMobile]);
// Click outside terminal content/header to exit fullscreen
const handleFullscreenClick = useCallback(
(e: React.MouseEvent<HTMLElement>) => {
+4 -8
View File
@@ -77,6 +77,10 @@ body {
color: var(--ink);
}
body.terminal-page-open {
overflow: hidden;
}
[data-theme="dark"] body {
background: radial-gradient(circle at top right, #2a2520, var(--bg));
}
@@ -3709,14 +3713,6 @@ a.nav-item,
/* xterm.js manages its own scrolling and viewport dimensions */
/* Mobile: enable native touch scrolling on xterm viewport */
.terminal-wrapper.mobile .xterm .xterm-viewport {
touch-action: pan-y !important;
-webkit-overflow-scrolling: touch !important;
overflow-y: auto !important;
scrollbar-width: none;
}
/* Special Keys Strip */
.special-keys-strip {
flex-shrink: 0;