Files
kandev/apps/web/lib/terminal/touch-scroll.ts
T
João Salavisa 78609c005e feat(terminal): scroll mobile passthrough terminal scrollback via touch (#1046)
* feat(terminal): add touch-scroll helper for xterm scrollback

Pure helper that translates single-finger vertical drags into
`terminal.scrollLines(n)` calls. Handles threshold gating so taps still
reach xterm, ignores multi-touch, yields to horizontal-dominant drags.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(terminal): wire mobile touch scrollback into passthrough terminal

Adds an `enableTouchScroll` prop on PassthroughTerminal and a thin hook
that calls `attachTouchScroll` while the terminal is mounted. The two
mobile mount points (terminal panel + chat-panel passthrough embed) opt
in; desktop callers are unaffected. Fixes the bug where xterm.js's
canvas absorbed touch gestures and left the scrollback unreachable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(terminal): mobile e2e for passthrough touch-scroll

Pixel 5-emulated Playwright spec: produce 200 lines of output, simulate
a single-finger down-swipe, assert `terminal.buffer.active.viewportY`
moved into the scrollback, then swipe back to return. Extends the e2e
buffer reader to expose viewportY alongside `__xtermReadBuffer`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(terminal): relax mobile swipe-back assertion against late shell output

Replace the exact `.toBe(bottomViewportY)` check with
`.toBeGreaterThanOrEqual` so the test doesn't flake if shell echo
arrives between the snapshot and the post-swipe read. xterm clamps at
the buffer boundary, so overshooting back-to-bottom is impossible.

Addresses PR #1046 Claude review thread 3293258085.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(terminal): document useTouchScroll's isTerminalReady invariant

Adds a comment next to the eslint-disable explaining why the deps
array can safely omit terminalRef/xtermRef: useTerminalInit only flips
isTerminalReady false→true once per mount. A future refactor that
recreates the terminal mid-mount must add a new dep.

Addresses PR #1046 Claude review thread 3293258175.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore(terminal): clear all exposed buffer-reader fields on dispose

clearBufferReader now nulls `__xtermGetFontFamily` and
`__xtermGetFontSize` alongside the buffer readers. The two predated
this PR and were never cleaned up; keeping the teardown symmetric with
exposeBufferReader prevents stale references if the container is ever
reused.

Addresses PR #1046 Claude review thread 3293274674.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(terminal): reset document.body between touch-scroll cases

Adds an afterEach to wipe jsdom's body so orphan divs from one test
don't bleed into the next. Avoids cross-test pollution if a future
case queries document.body for containers.

Addresses PR #1046 Claude review thread 3293274769.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 19:27:49 +01:00

144 lines
5.1 KiB
TypeScript

import type { Terminal } from "@xterm/xterm";
/**
* Translate touch swipes on an xterm.js container into scrollback navigation.
*
* xterm.js renders to a canvas under `.xterm-screen`; that canvas has the
* default `pointer-events: auto` and absorbs touchstart/touchmove before they
* can bubble to any ancestor scroll container. xterm itself does not wire
* touch events to its scrollback API (`scrollLines`), so on a mobile viewport
* the user has no way to review prior output.
*
* This module bridges that gap with a small touch handler that maps single-
* finger vertical drag distance onto `terminal.scrollLines(n)`.
*/
export type AttachTouchScrollOptions = {
/** Minimum vertical drag (px) before we start treating the gesture as a
* scroll. Below this, taps still reach xterm (focus / selection). */
threshold?: number;
/** Override row-height resolution for tests. Defaults to
* container.clientHeight / terminal.rows. */
rowHeightFn?: () => number;
};
const DEFAULT_THRESHOLD_PX = 6;
/**
* Compute the number of lines to scroll for a given vertical drag delta.
*
* Sign convention matches xterm's `scrollLines`: positive = scroll down toward
* newer output, negative = scroll up into scrollback. A downward finger drag
* (deltaY positive) reveals older lines, so we return a negative count.
*
* Sub-row drags round to 0 so the viewport doesn't flicker on micro-motion.
*/
export function computeScrollLines(deltaY: number, rowHeight: number): number {
if (!Number.isFinite(deltaY) || !Number.isFinite(rowHeight) || rowHeight <= 0) {
return 0;
}
// Drag down (positive deltaY) → reveal older lines → negative scrollLines.
// `|| 0` normalises -0 from `-Math.trunc(0.x)` to a plain 0 so equality
// checks (and callers' `lines !== 0` short-circuits) behave intuitively.
return -Math.trunc(deltaY / rowHeight) || 0;
}
type TouchScrollMinimalTerminal = Pick<Terminal, "scrollLines" | "rows">;
function defaultRowHeight(container: HTMLElement, terminal: TouchScrollMinimalTerminal): number {
const rows = terminal.rows;
if (!rows || rows <= 0) return 0;
return container.clientHeight / rows;
}
/**
* Attach touch listeners to `container` that scroll `terminal` on vertical
* drag. Returns a cleanup function.
*
* Behavior:
* - Single-finger drag only — multi-touch (pinch/zoom) is ignored.
* - Vertical-dominant only — if |dx| > |dy| we yield to the surrounding UI
* (e.g. horizontal swipes on a sibling key-bar).
* - `preventDefault` fires only after the drag passes `threshold` so a pure
* tap still focuses xterm (via xterm's own click handler).
* - Drag distance is quantized to whole rows; intermediate motion accumulates
* in `lastY` so a slow drag still scrolls eventually.
*/
export function attachTouchScroll(
container: HTMLElement,
terminal: TouchScrollMinimalTerminal,
opts: AttachTouchScrollOptions = {},
): () => void {
const threshold = opts.threshold ?? DEFAULT_THRESHOLD_PX;
const rowHeightFn = opts.rowHeightFn ?? (() => defaultRowHeight(container, terminal));
let startX = 0;
let startY = 0;
let lastY = 0;
let active = false;
let crossedThreshold = false;
const onTouchStart = (event: TouchEvent) => {
if (event.touches.length !== 1) {
active = false;
return;
}
const touch = event.touches[0];
startX = touch.clientX;
startY = touch.clientY;
lastY = touch.clientY;
active = true;
crossedThreshold = false;
};
const onTouchMove = (event: TouchEvent) => {
if (!active) return;
if (event.touches.length !== 1) {
active = false;
return;
}
const touch = event.touches[0];
const dx = touch.clientX - startX;
const dy = touch.clientY - startY;
if (!crossedThreshold) {
if (Math.abs(dy) < threshold) return;
// Horizontal-dominant — yield to sibling gestures (e.g. key-bar swipes).
if (Math.abs(dx) > Math.abs(dy)) {
active = false;
return;
}
crossedThreshold = true;
}
event.preventDefault();
const rowHeight = rowHeightFn();
const lines = computeScrollLines(touch.clientY - lastY, rowHeight);
if (lines !== 0) {
terminal.scrollLines(lines);
// Consume the portion of the drag we just applied so the next move
// event is relative to a fresh row boundary. We scrolled `lines` xterm
// rows, which corresponds to `-lines * rowHeight` px of finger motion
// (downward drag → negative lines → positive px consumed).
lastY -= lines * rowHeight;
}
};
const onTouchEnd = () => {
active = false;
crossedThreshold = false;
};
container.addEventListener("touchstart", onTouchStart, { passive: true });
container.addEventListener("touchmove", onTouchMove, { passive: false });
container.addEventListener("touchend", onTouchEnd, { passive: true });
container.addEventListener("touchcancel", onTouchEnd, { passive: true });
return () => {
container.removeEventListener("touchstart", onTouchStart);
container.removeEventListener("touchmove", onTouchMove);
container.removeEventListener("touchend", onTouchEnd);
container.removeEventListener("touchcancel", onTouchEnd);
};
}