From cddb3f8ccf33520b847cf464f0cc16d01fd539e4 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Fri, 29 May 2026 19:35:42 +0200 Subject: [PATCH] fix: mobile terminal scroll via capture-phase touch listeners - Attach touch listeners to container wrapper in CAPTURE phase so they run before xterm.js internals stop propagation - Add e.stopPropagation() in touchmove after handling scroll to prevent xterm.js from conflicting with our scroll - Add wheel event fallback for mobile browsers that synthesize wheel from touch - Remove touch-action: none CSS which was blocking native xterm viewport scroll --- apps/web/src/components/terminal.tsx | 55 +++++++++++++++++++++++----- apps/web/src/styles.css | 1 - 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/terminal.tsx b/apps/web/src/components/terminal.tsx index 0fea4c0..7673ea1 100644 --- a/apps/web/src/components/terminal.tsx +++ b/apps/web/src/components/terminal.tsx @@ -429,10 +429,11 @@ export const TerminalComponent = React.forwardRef( }; document.addEventListener("visibilitychange", handleVisibilityChange); - // Mobile touch scrolling — translate vertical swipe to terminal scroll - // Attach to term.element (xterm root) rather than container wrapper, - // since xterm internals may intercept events before they bubble up. - const touchTarget = term.element || container; + // 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; @@ -456,6 +457,7 @@ export const TerminalComponent = React.forwardRef( 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; @@ -473,14 +475,38 @@ export const TerminalComponent = React.forwardRef( 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); + } + } + }; + if (isMobile) { - touchTarget.addEventListener("touchstart", handleTouchStart, { + // Capture phase runs before xterm.js handlers on the child elements + container.addEventListener("touchstart", handleTouchStart, { passive: true, + capture: true, }); - touchTarget.addEventListener("touchmove", handleTouchMove, { + container.addEventListener("touchmove", handleTouchMove, { passive: false, + capture: true, + }); + container.addEventListener("touchend", handleTouchEnd, { + capture: true, + }); + container.addEventListener("wheel", handleWheel, { + passive: false, + capture: true, }); - touchTarget.addEventListener("touchend", handleTouchEnd); } return () => { @@ -495,9 +521,18 @@ export const TerminalComponent = React.forwardRef( handleVisibilityChange, ); if (isMobile) { - touchTarget.removeEventListener("touchstart", handleTouchStart); - touchTarget.removeEventListener("touchmove", handleTouchMove); - touchTarget.removeEventListener("touchend", handleTouchEnd); + 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"); diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 85448f6..edd203a 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -3703,7 +3703,6 @@ a.nav-item, padding: 0; overflow: hidden; position: relative; - touch-action: none; } /* xterm.js manages its own sizing */