From 98b9d612facf176bf9db33de03dac1ef44622f52 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Fri, 29 May 2026 20:23:19 +0200 Subject: [PATCH] fix: container-level capture touch with direct viewport.scrollTop manipulation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/web/src/components/terminal.tsx | 93 +++++++++++++++++++--------- apps/web/src/styles.css | 10 --- 2 files changed, 65 insertions(+), 38 deletions(-) diff --git a/apps/web/src/components/terminal.tsx b/apps/web/src/components/terminal.tsx index 980afa4..e85e5a9 100644 --- a/apps/web/src/components/terminal.tsx +++ b/apps/web/src/components/terminal.tsx @@ -311,38 +311,75 @@ export const TerminalComponent = React.forwardRef( // 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; diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 9a0ec76..e2872a9 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -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;