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 startX = 0;
let isScrolling = false;
let scrollPending = 0;
const WHEEL_DISTANCE = 12;
const flushScroll = (force = false) => {
if (scrollPending === 0) return;
if (!force && Math.abs(scrollPending) < WHEEL_DISTANCE) return;
const direction = Math.sign(scrollPending);
const steps = Math.max(
1,
Math.floor(Math.abs(scrollPending) / WHEEL_DISTANCE),
);
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN && termRef.current) {
const buf = termRef.current.buffer.active;
const col = buf.cursorX + 1;
const row = buf.cursorY + 1;
const btn = direction > 0 ? 64 : 65;
for (let i = 0; i < steps; i++) {
ws.send(`\x1b[<${btn};${col};${row}M`);
}
let debugOverlay: HTMLDivElement | null = null;
const showDebug = (msg: string) => {
if (!debugOverlay) {
debugOverlay = document.createElement("div");
debugOverlay.style.position = "fixed";
debugOverlay.style.bottom = "120px";
debugOverlay.style.left = "8px";
debugOverlay.style.right = "8px";
debugOverlay.style.zIndex = "9999";
debugOverlay.style.background = "rgba(0,0,0,0.8)";
debugOverlay.style.color = "#0f0";
debugOverlay.style.fontFamily = "monospace";
debugOverlay.style.fontSize = "11px";
debugOverlay.style.padding = "8px";
debugOverlay.style.borderRadius = "6px";
debugOverlay.style.pointerEvents = "none";
debugOverlay.style.whiteSpace = "pre-wrap";
debugOverlay.style.maxHeight = "120px";
debugOverlay.style.overflow = "auto";
document.body.appendChild(debugOverlay);
}
scrollPending = 0;
};
const isAlternateScreen = () => {
const term = termRef.current;
if (!term) return false;
return term.buffer.active === term.buffer.alternate;
const line = `${new Date().toLocaleTimeString()} ${msg}`;
debugOverlay.textContent =
line +
"\n" +
debugOverlay.textContent?.split("\n").slice(0, 8).join("\n");
};
const onTouchStart = (e: TouchEvent) => {
@@ -398,7 +396,6 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
startY = e.touches[0].clientY;
startX = e.touches[0].clientX;
isScrolling = false;
scrollPending = 0;
}
};
const onTouchMove = (e: TouchEvent) => {
@@ -406,6 +403,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
const touch = e.touches[0];
const deltaY = startY - touch.clientY;
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.
if (!isScrolling) {
@@ -415,32 +413,34 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
}
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();
if (isAlternateScreen()) {
// Alternate screen (tmux/vim): accumulate the swipe and
// send SGR 1006 mouse-wheel events in steps.
scrollPending += deltaY;
flushScroll();
} else if (termRef.current) {
// Normal buffer: let xterm.js scroll its own viewport by
// the number of lines corresponding to the swipe distance.
const lineHeight =
termRef.current.options.fontSize != null
? (termRef.current.options.fontSize as number) * 1.2
: 10;
const lines = Math.round(deltaY / lineHeight);
if (lines !== 0) {
termRef.current.scrollLines(lines);
}
const target = container.querySelector(".xterm-viewport") as
| HTMLElement
| null;
if (target) {
showDebug(`dispatch wheel deltaY=${deltaY.toFixed(0)}`);
const wheel = new WheelEvent("wheel", {
deltaY,
deltaX: 0,
deltaZ: 0,
clientX: touch.clientX,
clientY: touch.clientY,
screenX: touch.screenX,
screenY: touch.screenY,
bubbles: true,
cancelable: true,
});
target.dispatchEvent(wheel);
}
startY = touch.clientY;
};
const onTouchEnd = () => {
if (isScrolling) {
flushScroll(true);
}
isScrolling = false;
};