From c1e16f2163caa15fc825e0c4493870a4faf352bf Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Fri, 29 May 2026 19:57:48 +0200 Subject: [PATCH] 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 --- apps/web/src/components/terminal.tsx | 88 ++++++++++++++++++++++++---- apps/web/src/pages/terminal.tsx | 10 ++++ apps/web/src/styles.css | 12 ++-- 3 files changed, 91 insertions(+), 19 deletions(-) diff --git a/apps/web/src/components/terminal.tsx b/apps/web/src/components/terminal.tsx index c7d9cef..ceceb81 100644 --- a/apps/web/src/components/terminal.tsx +++ b/apps/web/src/components/terminal.tsx @@ -429,18 +429,73 @@ export const TerminalComponent = React.forwardRef( }; 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( "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"); } diff --git a/apps/web/src/pages/terminal.tsx b/apps/web/src/pages/terminal.tsx index cb21217..7bf5898 100644 --- a/apps/web/src/pages/terminal.tsx +++ b/apps/web/src/pages/terminal.tsx @@ -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) => { diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 92c24be..e2872a9 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -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;