mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 23:44:41 +00:00
fix(app): scroll web terminal history with a finger swipe
A phone browser claims the vertical drag and drops it, because the canvas covers the scrollable viewport and the page itself does not scroll. Keep the gesture in the touch handler and move one line per cell.
This commit is contained in:
@@ -22,6 +22,7 @@ import type { ITheme } from "@xterm/xterm";
|
||||
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
|
||||
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
||||
import {
|
||||
TERMINAL_SURFACE_TOUCH_ACTION,
|
||||
TerminalEmulatorRuntime,
|
||||
type TerminalOutputData,
|
||||
} from "../terminal/runtime/terminal-emulator-runtime";
|
||||
@@ -577,7 +578,7 @@ export default function TerminalEmulator({
|
||||
backgroundColor: xtermTheme.background ?? "#0b0b0b",
|
||||
overflow: "hidden",
|
||||
overscrollBehavior: "none",
|
||||
touchAction: "pan-y",
|
||||
touchAction: TERMINAL_SURFACE_TOUCH_ACTION,
|
||||
}),
|
||||
[xtermTheme.background],
|
||||
);
|
||||
|
||||
@@ -223,6 +223,56 @@ function getBrowserTerminal(): BrowserTerminal {
|
||||
return terminal;
|
||||
}
|
||||
|
||||
interface ScrollbackTerminal {
|
||||
rows: number;
|
||||
buffer: {
|
||||
active: {
|
||||
viewportY: number;
|
||||
baseY: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function getScrollbackTerminal(): ScrollbackTerminal {
|
||||
const terminal = window.__paseoTerminal as ScrollbackTerminal | undefined;
|
||||
if (!terminal?.buffer?.active) {
|
||||
throw new Error("Expected xterm scrollback buffer");
|
||||
}
|
||||
return terminal;
|
||||
}
|
||||
|
||||
function dispatchTouch(input: {
|
||||
target: HTMLElement;
|
||||
type: "touchstart" | "touchmove" | "touchend";
|
||||
x: number;
|
||||
y: number;
|
||||
}): void {
|
||||
const touch = new Touch({
|
||||
identifier: 1,
|
||||
target: input.target,
|
||||
clientX: input.x,
|
||||
clientY: input.y,
|
||||
pageX: input.x,
|
||||
pageY: input.y,
|
||||
screenX: input.x,
|
||||
screenY: input.y,
|
||||
radiusX: 1,
|
||||
radiusY: 1,
|
||||
rotationAngle: 0,
|
||||
force: 0.5,
|
||||
});
|
||||
const ended = input.type === "touchend";
|
||||
input.target.dispatchEvent(
|
||||
new TouchEvent(input.type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
touches: ended ? [] : [touch],
|
||||
targetTouches: ended ? [] : [touch],
|
||||
changedTouches: [touch],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
interface DispatchTerminalKeyInput {
|
||||
host: HTMLElement;
|
||||
key: string;
|
||||
@@ -326,6 +376,59 @@ describe("terminal emulator runtime in a real browser", () => {
|
||||
expect(window.__paseoTerminal?.options.scrollback).toBe(42_000);
|
||||
});
|
||||
|
||||
it("scrolls terminal history when a finger drags downward", async () => {
|
||||
await page.viewport(900, 600);
|
||||
const mounted = createTerminalHost({ width: 720, height: 360, scrollback: 1_000 });
|
||||
|
||||
await waitFor({
|
||||
predicate: () => mounted.sizes.length > 0 && (window.__paseoTerminal?.rows ?? 0) > 0,
|
||||
});
|
||||
await writeLines(
|
||||
mounted,
|
||||
Array.from({ length: 80 }, (_, index) => `history-line-${index}`),
|
||||
);
|
||||
|
||||
const screen = mounted.host.querySelector<HTMLElement>(".xterm-screen");
|
||||
const viewport = mounted.host.querySelector<HTMLElement>(".xterm-viewport");
|
||||
if (!screen || !viewport) {
|
||||
throw new Error("Expected xterm screen and viewport");
|
||||
}
|
||||
await waitFor({
|
||||
predicate: () => screen.getBoundingClientRect().height > 0,
|
||||
});
|
||||
|
||||
expect(mounted.root.style.touchAction).toBe("none");
|
||||
expect(screen.style.touchAction).toBe("none");
|
||||
expect(viewport.style.touchAction).toBe("none");
|
||||
|
||||
const terminal = getScrollbackTerminal();
|
||||
const before = terminal.buffer.active.viewportY;
|
||||
expect(before).toBe(terminal.buffer.active.baseY);
|
||||
|
||||
const rect = screen.getBoundingClientRect();
|
||||
const lineHeight = rect.height / terminal.rows;
|
||||
const startX = rect.left + rect.width / 2;
|
||||
const startY = rect.top + rect.height / 2;
|
||||
const draggedLines = 4;
|
||||
dispatchTouch({ target: screen, type: "touchstart", x: startX, y: startY });
|
||||
dispatchTouch({
|
||||
target: screen,
|
||||
type: "touchmove",
|
||||
x: startX,
|
||||
y: startY + lineHeight * draggedLines,
|
||||
});
|
||||
dispatchTouch({
|
||||
target: screen,
|
||||
type: "touchend",
|
||||
x: startX,
|
||||
y: startY + lineHeight * draggedLines,
|
||||
});
|
||||
|
||||
const scrolledLines = before - terminal.buffer.active.viewportY;
|
||||
expect(scrolledLines).toBeGreaterThanOrEqual(draggedLines - 1);
|
||||
expect(scrolledLines).toBeLessThanOrEqual(draggedLines);
|
||||
});
|
||||
|
||||
it("does not claim PTY ownership from passive mount refits", async () => {
|
||||
await page.viewport(900, 600);
|
||||
const mounted = createTerminalHost({ width: 720, height: 360 });
|
||||
|
||||
@@ -156,6 +156,11 @@ const isAppleHandheld =
|
||||
});
|
||||
|
||||
const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18;
|
||||
// The screen canvas covers the scrollable viewport, and the page itself does
|
||||
// not scroll. `pan-y` lets the browser claim a vertical drag and then drop it,
|
||||
// so a finger swipe never reaches the scrollback. `none` keeps the gesture in
|
||||
// the touch handler below.
|
||||
export const TERMINAL_SURFACE_TOUCH_ACTION = "none";
|
||||
const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
|
||||
const OUTPUT_OPERATION_TIMEOUT_MS = 5_000;
|
||||
const EMPTY_TERMINAL_OUTPUT = new Uint8Array(0);
|
||||
@@ -685,6 +690,7 @@ export class TerminalEmulatorRuntime {
|
||||
root: input.root,
|
||||
});
|
||||
const restoreViewportStyles = this.applyViewportTouchStyles({
|
||||
root: input.root,
|
||||
host: input.host,
|
||||
});
|
||||
|
||||
@@ -1387,9 +1393,15 @@ export class TerminalEmulatorRuntime {
|
||||
};
|
||||
}
|
||||
|
||||
private applyViewportTouchStyles(input: { host: HTMLDivElement }): () => void {
|
||||
private applyViewportTouchStyles(input: {
|
||||
root: HTMLDivElement;
|
||||
host: HTMLDivElement;
|
||||
}): () => void {
|
||||
const viewportElement = input.host.querySelector<HTMLElement>(".xterm-viewport");
|
||||
const screenElement = input.host.querySelector<HTMLElement>(".xterm-screen");
|
||||
|
||||
const previousRootTouchAction = input.root.style.touchAction;
|
||||
const previousScreenTouchAction = screenElement?.style.touchAction ?? "";
|
||||
const previousViewportOverscroll = viewportElement?.style.overscrollBehavior ?? "";
|
||||
const previousViewportTouchAction = viewportElement?.style.touchAction ?? "";
|
||||
const previousViewportOverflowY = viewportElement?.style.overflowY ?? "";
|
||||
@@ -1397,9 +1409,14 @@ export class TerminalEmulatorRuntime {
|
||||
const previousViewportPointerEvents = viewportElement?.style.pointerEvents ?? "";
|
||||
const previousViewportWebkitOverflowScrolling =
|
||||
viewportElement?.style.getPropertyValue("-webkit-overflow-scrolling") ?? "";
|
||||
|
||||
input.root.style.touchAction = TERMINAL_SURFACE_TOUCH_ACTION;
|
||||
if (screenElement) {
|
||||
screenElement.style.touchAction = TERMINAL_SURFACE_TOUCH_ACTION;
|
||||
}
|
||||
if (viewportElement) {
|
||||
viewportElement.style.overscrollBehavior = "none";
|
||||
viewportElement.style.touchAction = "pan-y";
|
||||
viewportElement.style.touchAction = TERMINAL_SURFACE_TOUCH_ACTION;
|
||||
viewportElement.style.overflowY = "auto";
|
||||
viewportElement.style.overflowX = "hidden";
|
||||
viewportElement.style.pointerEvents = "auto";
|
||||
@@ -1407,6 +1424,10 @@ export class TerminalEmulatorRuntime {
|
||||
}
|
||||
|
||||
return () => {
|
||||
input.root.style.touchAction = previousRootTouchAction;
|
||||
if (screenElement) {
|
||||
screenElement.style.touchAction = previousScreenTouchAction;
|
||||
}
|
||||
if (viewportElement) {
|
||||
viewportElement.style.overscrollBehavior = previousViewportOverscroll;
|
||||
viewportElement.style.touchAction = previousViewportTouchAction;
|
||||
@@ -1421,17 +1442,25 @@ export class TerminalEmulatorRuntime {
|
||||
};
|
||||
}
|
||||
|
||||
private touchScrollLineHeightPx(input: { host: HTMLDivElement; terminal: Terminal }): number {
|
||||
const screen = input.host.querySelector<HTMLElement>(".xterm-screen");
|
||||
const rows = input.terminal.rows;
|
||||
if (screen && rows > 0) {
|
||||
const height = screen.getBoundingClientRect().height / rows;
|
||||
if (height > 0) {
|
||||
return height;
|
||||
}
|
||||
}
|
||||
return DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX;
|
||||
}
|
||||
|
||||
private setupTouchScrollHandlers(input: {
|
||||
root: HTMLDivElement;
|
||||
host: HTMLDivElement;
|
||||
terminal: Terminal;
|
||||
}): () => void {
|
||||
let touchScrollRemainderPx = 0;
|
||||
const measuredLineHeight =
|
||||
input.host.querySelector<HTMLElement>(".xterm-rows > div")?.getBoundingClientRect().height ??
|
||||
0;
|
||||
const touchScrollLineHeightPx =
|
||||
measuredLineHeight > 0 ? measuredLineHeight : DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX;
|
||||
let touchScrollLineHeightPx = this.touchScrollLineHeightPx(input);
|
||||
|
||||
const activeTouch = {
|
||||
identifier: -1,
|
||||
@@ -1465,6 +1494,7 @@ export class TerminalEmulatorRuntime {
|
||||
activeTouch.lastY = touch.clientY;
|
||||
activeTouch.mode = null;
|
||||
touchScrollRemainderPx = 0;
|
||||
touchScrollLineHeightPx = this.touchScrollLineHeightPx(input);
|
||||
};
|
||||
|
||||
const touchMoveHandler = (event: TouchEvent) => {
|
||||
@@ -1504,7 +1534,10 @@ export class TerminalEmulatorRuntime {
|
||||
touchScrollRemainderPx -= lineDelta * touchScrollLineHeightPx;
|
||||
}
|
||||
|
||||
// xterm also turns this touch into a scroll on document. Handling it
|
||||
// twice jumps about two lines per cell.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const touchEndHandler = (event: TouchEvent) => {
|
||||
|
||||
Reference in New Issue
Block a user