Compare commits

...

1 Commits

Author SHA1 Message Date
Developer 346b32236a debug: synthesize wheel events for mobile terminal touch scrolling
Replace manual scrollLines/SGR injection with synthetic WheelEvent
instances dispatched into xterm.js's .xterm-viewport. Keep the debug
overlay so we can verify whether the gesture is recognized and whether
the wheel event reaches xterm.js.

Quality gates: typecheck, lint clean.
2026-06-13 21:04:50 +00:00
@@ -363,34 +363,32 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
let startY = 0; let startY = 0;
let startX = 0; let startX = 0;
let isScrolling = false; let isScrolling = false;
let scrollPending = 0; let debugOverlay: HTMLDivElement | null = null;
const WHEEL_DISTANCE = 12; const showDebug = (msg: string) => {
if (!debugOverlay) {
const flushScroll = (force = false) => { debugOverlay = document.createElement("div");
if (scrollPending === 0) return; debugOverlay.style.position = "fixed";
if (!force && Math.abs(scrollPending) < WHEEL_DISTANCE) return; debugOverlay.style.bottom = "120px";
const direction = Math.sign(scrollPending); debugOverlay.style.left = "8px";
const steps = Math.max( debugOverlay.style.right = "8px";
1, debugOverlay.style.zIndex = "9999";
Math.floor(Math.abs(scrollPending) / WHEEL_DISTANCE), debugOverlay.style.background = "rgba(0,0,0,0.8)";
); debugOverlay.style.color = "#0f0";
const ws = wsRef.current; debugOverlay.style.fontFamily = "monospace";
if (ws?.readyState === WebSocket.OPEN && termRef.current) { debugOverlay.style.fontSize = "11px";
const buf = termRef.current.buffer.active; debugOverlay.style.padding = "8px";
const col = buf.cursorX + 1; debugOverlay.style.borderRadius = "6px";
const row = buf.cursorY + 1; debugOverlay.style.pointerEvents = "none";
const btn = direction > 0 ? 64 : 65; debugOverlay.style.whiteSpace = "pre-wrap";
for (let i = 0; i < steps; i++) { debugOverlay.style.maxHeight = "120px";
ws.send(`\x1b[<${btn};${col};${row}M`); debugOverlay.style.overflow = "auto";
} document.body.appendChild(debugOverlay);
} }
scrollPending = 0; const line = `${new Date().toLocaleTimeString()} ${msg}`;
}; debugOverlay.textContent =
line +
const isAlternateScreen = () => { "\n" +
const term = termRef.current; debugOverlay.textContent?.split("\n").slice(0, 8).join("\n");
if (!term) return false;
return term.buffer.active === term.buffer.alternate;
}; };
const onTouchStart = (e: TouchEvent) => { const onTouchStart = (e: TouchEvent) => {
@@ -398,7 +396,6 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
startY = e.touches[0].clientY; startY = e.touches[0].clientY;
startX = e.touches[0].clientX; startX = e.touches[0].clientX;
isScrolling = false; isScrolling = false;
scrollPending = 0;
} }
}; };
const onTouchMove = (e: TouchEvent) => { const onTouchMove = (e: TouchEvent) => {
@@ -406,6 +403,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
const touch = e.touches[0]; const touch = e.touches[0];
const deltaY = startY - touch.clientY; const deltaY = startY - touch.clientY;
const deltaX = Math.abs(startX - touch.clientX); const deltaX = Math.abs(startX - touch.clientX);
showDebug(`move dy=${deltaY.toFixed(0)} dx=${deltaX.toFixed(0)} scrolling=${isScrolling}`);
// Decide early whether this is a vertical scroll gesture. // Decide early whether this is a vertical scroll gesture.
if (!isScrolling) { if (!isScrolling) {
@@ -415,32 +413,34 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
} }
if (!isScrolling) return; if (!isScrolling) return;
// Take over the gesture so the page/toolbar never scrolls. // Take over the gesture so the browser never scrolls the
// page or the terminal canvas. We synthesize wheel events
// and dispatch them into xterm.js, which already handles
// both normal buffer scrolling and alternate-screen apps
// (tmux/vim) correctly.
e.preventDefault(); e.preventDefault();
if (isAlternateScreen()) { const target = container.querySelector(".xterm-viewport") as
// Alternate screen (tmux/vim): accumulate the swipe and | HTMLElement
// send SGR 1006 mouse-wheel events in steps. | null;
scrollPending += deltaY; if (target) {
flushScroll(); showDebug(`dispatch wheel deltaY=${deltaY.toFixed(0)}`);
} else if (termRef.current) { const wheel = new WheelEvent("wheel", {
// Normal buffer: let xterm.js scroll its own viewport by deltaY,
// the number of lines corresponding to the swipe distance. deltaX: 0,
const lineHeight = deltaZ: 0,
termRef.current.options.fontSize != null clientX: touch.clientX,
? (termRef.current.options.fontSize as number) * 1.2 clientY: touch.clientY,
: 10; screenX: touch.screenX,
const lines = Math.round(deltaY / lineHeight); screenY: touch.screenY,
if (lines !== 0) { bubbles: true,
termRef.current.scrollLines(lines); cancelable: true,
} });
target.dispatchEvent(wheel);
} }
startY = touch.clientY; startY = touch.clientY;
}; };
const onTouchEnd = () => { const onTouchEnd = () => {
if (isScrolling) {
flushScroll(true);
}
isScrolling = false; isScrolling = false;
}; };