From aa343141752e9a69fa9473f5732f19f1a917724b Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Fri, 29 May 2026 19:20:52 +0200 Subject: [PATCH] feat: touch swipe scrolling in terminal on mobile - Intercepts touch events on the terminal container when isMobile=true - Detects vertical swipe gestures (dominant over horizontal movement) - Translates swipe distance to xterm.js scrollLines() calls - Uses requestAnimationFrame for smooth scroll updates - Threshold of 10px before scroll kicks in; 30px per line - Touch listeners cleaned up on component unmount --- apps/web/src/components/terminal.tsx | 56 ++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/apps/web/src/components/terminal.tsx b/apps/web/src/components/terminal.tsx index 681a1e5..76f9d68 100644 --- a/apps/web/src/components/terminal.tsx +++ b/apps/web/src/components/terminal.tsx @@ -429,6 +429,56 @@ export const TerminalComponent = React.forwardRef( }; document.addEventListener("visibilitychange", handleVisibilityChange); + // Mobile touch scrolling — translate vertical swipe to terminal scroll + let touchStartY = 0; + let touchStartX = 0; + let isTouchScrolling = false; + let touchScrollRaf: number | null = null; + + const handleTouchStart = (e: TouchEvent) => { + if (e.touches.length === 1) { + touchStartY = e.touches[0].clientY; + touchStartX = e.touches[0].clientX; + isTouchScrolling = false; + } + }; + + 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); + // If vertical movement dominates and exceeds threshold, scroll terminal buffer + if (Math.abs(deltaY) > deltaX && Math.abs(deltaY) > 10) { + if (!isTouchScrolling) isTouchScrolling = true; + e.preventDefault(); + if (touchScrollRaf) cancelAnimationFrame(touchScrollRaf); + touchScrollRaf = requestAnimationFrame(() => { + if (!termRef.current) return; + const lines = Math.round(deltaY / 30); + if (lines !== 0) { + termRef.current.scrollLines(lines); + touchStartY = touch.clientY; + } + touchScrollRaf = null; + }); + } + }; + + const handleTouchEnd = () => { + isTouchScrolling = false; + if (touchScrollRaf) { + cancelAnimationFrame(touchScrollRaf); + touchScrollRaf = null; + } + }; + + if (isMobile) { + container.addEventListener("touchstart", handleTouchStart, { passive: true }); + container.addEventListener("touchmove", handleTouchMove, { passive: false }); + container.addEventListener("touchend", handleTouchEnd); + } + return () => { isUnmountingRef.current = true; clearTimeout(resizeTimeout); @@ -440,6 +490,12 @@ export const TerminalComponent = React.forwardRef( "visibilitychange", handleVisibilityChange, ); + if (isMobile) { + container.removeEventListener("touchstart", handleTouchStart); + container.removeEventListener("touchmove", handleTouchMove); + container.removeEventListener("touchend", handleTouchEnd); + } + if (touchScrollRaf) cancelAnimationFrame(touchScrollRaf); if (ws) { ws.close(1000, "Component unmounting"); }