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 */