mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-27 23:05:30 +08:00
fix(shell): add inertial scrolling to mobile terminal
xterm scrolls the viewport 1:1 with the finger and prevents native scrolling, so the terminal stopped the instant the finger lifted with no momentum — making it tedious to scroll long output on mobile. Track finger velocity during a one-finger drag and, on release, keep scrolling the viewport with friction until it slows to a stop or hits the buffer bounds. Inertia is cancelled on a new touch, selection, pinch, or dispose so it never fights the user. Coasting behaviour is tunable via the SCROLL_INERTIA_* constants.
This commit is contained in:
@@ -48,6 +48,13 @@ const CONTEXT_MENU_EDGE_PADDING_PX = 8;
|
||||
const ZOOM_THROTTLE_MS = 50;
|
||||
const DEFAULT_MIN_FONT_SIZE = 8;
|
||||
const DEFAULT_MAX_FONT_SIZE = 48;
|
||||
// xterm scrolls the viewport 1:1 with the finger and never coasts, so we add
|
||||
// our own inertial (fling) scrolling: track finger velocity during the drag and
|
||||
// keep scrolling with friction after release.
|
||||
const SCROLL_INERTIA_FRICTION = 0.95; // velocity multiplier per ~16ms frame
|
||||
const SCROLL_INERTIA_MIN_VELOCITY = 0.02; // px/ms below which coasting stops
|
||||
const SCROLL_INERTIA_MAX_VELOCITY = 5; // px/ms cap to avoid runaway flings
|
||||
const SCROLL_INERTIA_MAX_IDLE_MS = 90; // ignore a flick if the finger paused before lifting
|
||||
|
||||
type ContextMenuItem = {
|
||||
label: string;
|
||||
@@ -111,6 +118,12 @@ class ShellMobileSelectionCore implements MobileTerminalSelectionManager {
|
||||
private initialFontSize = 0;
|
||||
private lastZoomTime = 0;
|
||||
|
||||
private viewportElement: HTMLElement | null = null;
|
||||
private lastScrollTouchY: number | null = null;
|
||||
private lastScrollTouchTime = 0;
|
||||
private scrollVelocity = 0;
|
||||
private inertiaFrame: number | null = null;
|
||||
|
||||
constructor(
|
||||
terminal: Terminal,
|
||||
terminalContent: HTMLElement,
|
||||
@@ -290,6 +303,9 @@ class ShellMobileSelectionCore implements MobileTerminalSelectionManager {
|
||||
}
|
||||
|
||||
private onTerminalTouchStart = (event: TouchEvent): void => {
|
||||
this.cancelInertia();
|
||||
this.resetScrollTracking();
|
||||
|
||||
if (event.touches.length === 2) {
|
||||
event.preventDefault();
|
||||
this.startPinchZoom(event);
|
||||
@@ -354,7 +370,12 @@ class ShellMobileSelectionCore implements MobileTerminalSelectionManager {
|
||||
if (this.isSelecting && !this.isHandleDragging) {
|
||||
event.preventDefault();
|
||||
this.extendSelection(touch);
|
||||
return;
|
||||
}
|
||||
|
||||
// Plain one-finger scrolling: xterm moves the viewport itself; we only
|
||||
// record the finger velocity so we can add inertia when the touch ends.
|
||||
this.recordScrollSample(touch);
|
||||
};
|
||||
|
||||
private onTerminalTouchEnd = (): void => {
|
||||
@@ -367,6 +388,7 @@ class ShellMobileSelectionCore implements MobileTerminalSelectionManager {
|
||||
this.touchStart = null;
|
||||
|
||||
if (!this.pendingClearTouch) {
|
||||
this.maybeStartInertia();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -700,6 +722,102 @@ class ShellMobileSelectionCore implements MobileTerminalSelectionManager {
|
||||
return Math.hypot(second.clientX - first.clientX, second.clientY - first.clientY);
|
||||
}
|
||||
|
||||
private getViewportElement(): HTMLElement | null {
|
||||
if (this.viewportElement?.isConnected) {
|
||||
return this.viewportElement;
|
||||
}
|
||||
|
||||
this.viewportElement =
|
||||
this.terminal.element?.querySelector<HTMLElement>('.xterm-viewport') ?? null;
|
||||
return this.viewportElement;
|
||||
}
|
||||
|
||||
private resetScrollTracking(): void {
|
||||
this.lastScrollTouchY = null;
|
||||
this.lastScrollTouchTime = 0;
|
||||
this.scrollVelocity = 0;
|
||||
}
|
||||
|
||||
private recordScrollSample(touch: TouchCoords): void {
|
||||
const now = performance.now();
|
||||
|
||||
if (this.lastScrollTouchY !== null) {
|
||||
const dt = now - this.lastScrollTouchTime;
|
||||
if (dt > 0) {
|
||||
// Positive when the finger moves up, matching how xterm increases
|
||||
// scrollTop, so the inertia continues in the same direction.
|
||||
const velocity = (this.lastScrollTouchY - touch.clientY) / dt;
|
||||
this.scrollVelocity = this.scrollVelocity * 0.4 + velocity * 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
this.lastScrollTouchY = touch.clientY;
|
||||
this.lastScrollTouchTime = now;
|
||||
}
|
||||
|
||||
private maybeStartInertia(): void {
|
||||
if (this.isSelecting || this.isHandleDragging || this.isPinching) {
|
||||
return;
|
||||
}
|
||||
|
||||
const idle = performance.now() - this.lastScrollTouchTime;
|
||||
if (idle > SCROLL_INERTIA_MAX_IDLE_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Math.abs(this.scrollVelocity) < SCROLL_INERTIA_MIN_VELOCITY) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.startInertia(this.scrollVelocity);
|
||||
}
|
||||
|
||||
private startInertia(initialVelocity: number): void {
|
||||
const viewport = this.getViewportElement();
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cancelInertia();
|
||||
|
||||
let velocity = clamp(
|
||||
initialVelocity,
|
||||
-SCROLL_INERTIA_MAX_VELOCITY,
|
||||
SCROLL_INERTIA_MAX_VELOCITY,
|
||||
);
|
||||
let lastFrame = performance.now();
|
||||
|
||||
const step = (now: number): void => {
|
||||
const dt = Math.max(1, now - lastFrame);
|
||||
lastFrame = now;
|
||||
velocity *= Math.pow(SCROLL_INERTIA_FRICTION, dt / 16);
|
||||
|
||||
if (Math.abs(velocity) < SCROLL_INERTIA_MIN_VELOCITY) {
|
||||
this.inertiaFrame = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const before = viewport.scrollTop;
|
||||
viewport.scrollTop = before + velocity * dt;
|
||||
if (viewport.scrollTop === before) {
|
||||
// Reached the top or bottom of the buffer.
|
||||
this.inertiaFrame = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.inertiaFrame = window.requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
this.inertiaFrame = window.requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
private cancelInertia(): void {
|
||||
if (this.inertiaFrame !== null) {
|
||||
window.cancelAnimationFrame(this.inertiaFrame);
|
||||
this.inertiaFrame = null;
|
||||
}
|
||||
}
|
||||
|
||||
updateHandles(): void {
|
||||
if (this.isContextMenuVisible) {
|
||||
this.positionContextMenu();
|
||||
@@ -884,6 +1002,7 @@ class ShellMobileSelectionCore implements MobileTerminalSelectionManager {
|
||||
|
||||
this.isDestroyed = true;
|
||||
this.clearTapHoldTimeout();
|
||||
this.cancelInertia();
|
||||
|
||||
this.terminal.element?.removeEventListener('touchstart', this.onTerminalTouchStart);
|
||||
this.terminal.element?.removeEventListener('touchmove', this.onTerminalTouchMove);
|
||||
|
||||
Reference in New Issue
Block a user