78609c005e
* 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>
185 lines
6.4 KiB
TypeScript
185 lines
6.4 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { attachTouchScroll, computeScrollLines } from "./touch-scroll";
|
|
|
|
afterEach(() => {
|
|
// Each test appends a fresh container to document.body. Without this, jsdom
|
|
// accumulates orphan divs across the suite and a future test that queries
|
|
// document.body could pick up containers from prior runs.
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
describe("computeScrollLines", () => {
|
|
it("returns negative line count for a downward drag (reveal older)", () => {
|
|
// 30px down with 10px per row → finger moved 3 rows → scroll 3 lines up.
|
|
expect(computeScrollLines(30, 10)).toBe(-3);
|
|
});
|
|
|
|
it("returns positive line count for an upward drag (reveal newer)", () => {
|
|
expect(computeScrollLines(-25, 10)).toBe(2);
|
|
});
|
|
|
|
it("rounds sub-row drags toward zero", () => {
|
|
expect(computeScrollLines(9, 10)).toBe(0);
|
|
expect(computeScrollLines(-9, 10)).toBe(0);
|
|
});
|
|
|
|
it("returns 0 for non-finite or non-positive row heights", () => {
|
|
expect(computeScrollLines(50, 0)).toBe(0);
|
|
expect(computeScrollLines(50, -10)).toBe(0);
|
|
expect(computeScrollLines(Number.NaN, 10)).toBe(0);
|
|
expect(computeScrollLines(10, Number.NaN)).toBe(0);
|
|
});
|
|
});
|
|
|
|
type TerminalStub = { scrollLines: ReturnType<typeof vi.fn>; rows: number };
|
|
|
|
function makeContainer(): HTMLElement {
|
|
const el = document.createElement("div");
|
|
document.body.appendChild(el);
|
|
return el;
|
|
}
|
|
|
|
function makeTouch(clientX: number, clientY: number): Touch {
|
|
// jsdom doesn't ship a Touch constructor; the structural shape is enough
|
|
// for the handler, which only reads clientX/clientY/identifier.
|
|
return { clientX, clientY, identifier: 1 } as unknown as Touch;
|
|
}
|
|
|
|
function makeTouchEvent(type: string, touches: Touch[]): TouchEvent {
|
|
const event = new Event(type, { bubbles: true, cancelable: true }) as TouchEvent;
|
|
Object.defineProperty(event, "touches", { value: touches, configurable: true });
|
|
Object.defineProperty(event, "changedTouches", { value: touches, configurable: true });
|
|
return event;
|
|
}
|
|
|
|
function fire(container: HTMLElement, type: string, touches: Touch[]): TouchEvent {
|
|
const event = makeTouchEvent(type, touches);
|
|
container.dispatchEvent(event);
|
|
return event;
|
|
}
|
|
|
|
describe("attachTouchScroll", () => {
|
|
it("does not scroll or preventDefault for a single-finger drag below threshold", () => {
|
|
const container = makeContainer();
|
|
const terminal: TerminalStub = { scrollLines: vi.fn(), rows: 24 };
|
|
const cleanup = attachTouchScroll(container, terminal, {
|
|
threshold: 10,
|
|
rowHeightFn: () => 10,
|
|
});
|
|
|
|
fire(container, "touchstart", [makeTouch(50, 100)]);
|
|
const move = fire(container, "touchmove", [makeTouch(50, 104)]);
|
|
|
|
expect(terminal.scrollLines).not.toHaveBeenCalled();
|
|
expect(move.defaultPrevented).toBe(false);
|
|
|
|
cleanup();
|
|
});
|
|
|
|
it("scrolls into scrollback (negative lines) for a downward drag past threshold", () => {
|
|
const container = makeContainer();
|
|
const terminal: TerminalStub = { scrollLines: vi.fn(), rows: 24 };
|
|
const cleanup = attachTouchScroll(container, terminal, {
|
|
threshold: 6,
|
|
rowHeightFn: () => 10,
|
|
});
|
|
|
|
fire(container, "touchstart", [makeTouch(50, 100)]);
|
|
// Drag 30px down — should scroll 3 lines into scrollback (-3).
|
|
const move = fire(container, "touchmove", [makeTouch(50, 130)]);
|
|
|
|
expect(terminal.scrollLines).toHaveBeenCalledTimes(1);
|
|
expect(terminal.scrollLines).toHaveBeenCalledWith(-3);
|
|
expect(move.defaultPrevented).toBe(true);
|
|
|
|
cleanup();
|
|
});
|
|
|
|
it("scrolls toward newer output (positive lines) for an upward drag", () => {
|
|
const container = makeContainer();
|
|
const terminal: TerminalStub = { scrollLines: vi.fn(), rows: 24 };
|
|
const cleanup = attachTouchScroll(container, terminal, {
|
|
threshold: 6,
|
|
rowHeightFn: () => 10,
|
|
});
|
|
|
|
fire(container, "touchstart", [makeTouch(50, 200)]);
|
|
fire(container, "touchmove", [makeTouch(50, 170)]);
|
|
|
|
expect(terminal.scrollLines).toHaveBeenCalledWith(3);
|
|
|
|
cleanup();
|
|
});
|
|
|
|
it("accumulates partial-row motion across successive move events", () => {
|
|
const container = makeContainer();
|
|
const terminal: TerminalStub = { scrollLines: vi.fn(), rows: 24 };
|
|
const cleanup = attachTouchScroll(container, terminal, {
|
|
threshold: 1,
|
|
rowHeightFn: () => 10,
|
|
});
|
|
|
|
fire(container, "touchstart", [makeTouch(50, 100)]);
|
|
fire(container, "touchmove", [makeTouch(50, 115)]); // dy=15 → -1 line (5 px residual)
|
|
fire(container, "touchmove", [makeTouch(50, 121)]); // 6 + 5 residual = 11 → -1 line
|
|
fire(container, "touchmove", [makeTouch(50, 124)]); // residual + 3 → 4 → 0
|
|
|
|
expect(terminal.scrollLines).toHaveBeenNthCalledWith(1, -1);
|
|
expect(terminal.scrollLines).toHaveBeenNthCalledWith(2, -1);
|
|
expect(terminal.scrollLines).toHaveBeenCalledTimes(2);
|
|
|
|
cleanup();
|
|
});
|
|
|
|
it("ignores multi-touch (pinch) gestures", () => {
|
|
const container = makeContainer();
|
|
const terminal: TerminalStub = { scrollLines: vi.fn(), rows: 24 };
|
|
const cleanup = attachTouchScroll(container, terminal, {
|
|
threshold: 6,
|
|
rowHeightFn: () => 10,
|
|
});
|
|
|
|
fire(container, "touchstart", [makeTouch(50, 100), makeTouch(80, 100)]);
|
|
const move = fire(container, "touchmove", [makeTouch(50, 130), makeTouch(80, 130)]);
|
|
|
|
expect(terminal.scrollLines).not.toHaveBeenCalled();
|
|
expect(move.defaultPrevented).toBe(false);
|
|
|
|
cleanup();
|
|
});
|
|
|
|
it("yields to horizontal-dominant drags", () => {
|
|
const container = makeContainer();
|
|
const terminal: TerminalStub = { scrollLines: vi.fn(), rows: 24 };
|
|
const cleanup = attachTouchScroll(container, terminal, {
|
|
threshold: 6,
|
|
rowHeightFn: () => 10,
|
|
});
|
|
|
|
fire(container, "touchstart", [makeTouch(50, 100)]);
|
|
// |dx|=40, |dy|=10 — horizontal dominates; no scroll, no preventDefault.
|
|
const move = fire(container, "touchmove", [makeTouch(90, 110)]);
|
|
|
|
expect(terminal.scrollLines).not.toHaveBeenCalled();
|
|
expect(move.defaultPrevented).toBe(false);
|
|
|
|
cleanup();
|
|
});
|
|
|
|
it("cleanup removes the listeners", () => {
|
|
const container = makeContainer();
|
|
const terminal: TerminalStub = { scrollLines: vi.fn(), rows: 24 };
|
|
const cleanup = attachTouchScroll(container, terminal, {
|
|
threshold: 6,
|
|
rowHeightFn: () => 10,
|
|
});
|
|
|
|
cleanup();
|
|
|
|
fire(container, "touchstart", [makeTouch(50, 100)]);
|
|
fire(container, "touchmove", [makeTouch(50, 200)]);
|
|
|
|
expect(terminal.scrollLines).not.toHaveBeenCalled();
|
|
});
|
|
});
|