fix: mobile terminal touch scrolling direction and target

- Attach touch listeners to term.element (xterm root) instead of wrapper
- Fix scroll direction: swipe up now scrolls up (shows older buffer)
- Remove RAF indirection; scroll applied synchronously in touchmove
- Accumulate delta between events for smoother scrolling
- Lower threshold to 6px and px-per-line to 16 for better responsiveness
- Add touch-action: none to terminal container on mobile
This commit is contained in:
Alex Blank
2026-05-29 19:29:29 +02:00
parent 9157694412
commit 87a938fe58
2 changed files with 30 additions and 24 deletions
+29 -24
View File
@@ -430,16 +430,20 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
document.addEventListener("visibilitychange", handleVisibilityChange); document.addEventListener("visibilitychange", handleVisibilityChange);
// Mobile touch scrolling — translate vertical swipe to terminal scroll // 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;
let touchStartY = 0; let touchStartY = 0;
let touchStartX = 0; let touchStartX = 0;
let isTouchScrolling = false; let isTouchScrolling = false;
let touchScrollRaf: number | null = null; let accumulatedDeltaY = 0;
const handleTouchStart = (e: TouchEvent) => { const handleTouchStart = (e: TouchEvent) => {
if (e.touches.length === 1) { if (e.touches.length === 1) {
touchStartY = e.touches[0].clientY; touchStartY = e.touches[0].clientY;
touchStartX = e.touches[0].clientX; touchStartX = e.touches[0].clientX;
isTouchScrolling = false; isTouchScrolling = false;
accumulatedDeltaY = 0;
} }
}; };
@@ -449,34 +453,34 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
const deltaY = touchStartY - touch.clientY; const deltaY = touchStartY - touch.clientY;
const deltaX = Math.abs(touchStartX - touch.clientX); const deltaX = Math.abs(touchStartX - touch.clientX);
// If vertical movement dominates and exceeds threshold, scroll terminal buffer // If vertical movement dominates and exceeds threshold, scroll terminal buffer
if (Math.abs(deltaY) > deltaX && Math.abs(deltaY) > 10) { if (Math.abs(deltaY) > deltaX && Math.abs(deltaY) > 6) {
if (!isTouchScrolling) isTouchScrolling = true; if (!isTouchScrolling) isTouchScrolling = true;
e.preventDefault(); e.preventDefault();
if (touchScrollRaf) cancelAnimationFrame(touchScrollRaf); accumulatedDeltaY += deltaY;
touchScrollRaf = requestAnimationFrame(() => { touchStartY = touch.clientY;
if (!termRef.current) return; const pxPerLine = 16;
const lines = Math.round(deltaY / 30); const lines = Math.round(accumulatedDeltaY / pxPerLine);
if (lines !== 0) { if (lines !== 0) {
termRef.current.scrollLines(lines); // Negative scrollLines = scroll up (show older buffer content)
touchStartY = touch.clientY; termRef.current.scrollLines(-lines);
} accumulatedDeltaY = 0;
touchScrollRaf = null; }
});
} }
}; };
const handleTouchEnd = () => { const handleTouchEnd = () => {
isTouchScrolling = false; isTouchScrolling = false;
if (touchScrollRaf) { accumulatedDeltaY = 0;
cancelAnimationFrame(touchScrollRaf);
touchScrollRaf = null;
}
}; };
if (isMobile) { if (isMobile) {
container.addEventListener("touchstart", handleTouchStart, { passive: true }); touchTarget.addEventListener("touchstart", handleTouchStart, {
container.addEventListener("touchmove", handleTouchMove, { passive: false }); passive: true,
container.addEventListener("touchend", handleTouchEnd); });
touchTarget.addEventListener("touchmove", handleTouchMove, {
passive: false,
});
touchTarget.addEventListener("touchend", handleTouchEnd);
} }
return () => { return () => {
@@ -491,11 +495,10 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
handleVisibilityChange, handleVisibilityChange,
); );
if (isMobile) { if (isMobile) {
container.removeEventListener("touchstart", handleTouchStart); touchTarget.removeEventListener("touchstart", handleTouchStart);
container.removeEventListener("touchmove", handleTouchMove); touchTarget.removeEventListener("touchmove", handleTouchMove);
container.removeEventListener("touchend", handleTouchEnd); touchTarget.removeEventListener("touchend", handleTouchEnd);
} }
if (touchScrollRaf) cancelAnimationFrame(touchScrollRaf);
if (ws) { if (ws) {
ws.close(1000, "Component unmounting"); ws.close(1000, "Component unmounting");
} }
@@ -624,7 +627,9 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
}; };
return ( return (
<div className={`terminal-wrapper ${isMobile ? "mobile" : ""} ${!showControls ? "no-controls" : ""}`}> <div
className={`terminal-wrapper ${isMobile ? "mobile" : ""} ${!showControls ? "no-controls" : ""}`}
>
{showControls && ( {showControls && (
<div className="terminal-header"> <div className="terminal-header">
<div className="terminal-header-left"> <div className="terminal-header-left">
+1
View File
@@ -3703,6 +3703,7 @@ a.nav-item,
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
touch-action: none;
} }
/* xterm.js manages its own sizing */ /* xterm.js manages its own sizing */