feat(terminal): improve touch scroll handling on mobile
This commit is contained in:
@@ -9,14 +9,6 @@ import type { TerminalChunk } from '@/stores/useTerminalStore';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
|
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
|
||||||
|
|
||||||
type TerminalWithCore = Terminal & {
|
|
||||||
_core?: {
|
|
||||||
dimensions?: {
|
|
||||||
actualCellHeight?: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type TerminalController = {
|
type TerminalController = {
|
||||||
focus: () => void;
|
focus: () => void;
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
@@ -135,83 +127,288 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
}
|
}
|
||||||
|
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
const terminal = terminalRef.current;
|
if (!container) {
|
||||||
if (!container || !terminal) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const viewport = container.querySelector('.xterm-viewport') as HTMLElement | null;
|
||||||
|
if (!viewport) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseScrollMultiplier = 2.2;
|
||||||
|
const maxScrollBoost = 2.8;
|
||||||
|
const boostDenominator = 25;
|
||||||
|
const velocityAlpha = 0.25;
|
||||||
|
const maxVelocity = 8;
|
||||||
|
const minVelocity = 0.05;
|
||||||
|
const deceleration = 0.015;
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
lastY: null as number | null,
|
lastY: null as number | null,
|
||||||
remainder: 0,
|
lastTime: null as number | null,
|
||||||
|
velocity: 0,
|
||||||
|
rafId: null as number | null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const internalTerminal = terminal as TerminalWithCore;
|
const nowMs = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
||||||
const measuredLineHeight =
|
|
||||||
internalTerminal._core?.dimensions?.actualCellHeight ?? null;
|
|
||||||
const lineHeightEstimate =
|
|
||||||
(typeof measuredLineHeight === 'number' && measuredLineHeight > 0
|
|
||||||
? measuredLineHeight
|
|
||||||
: Math.max(fontSize + 6, fontSize * 1.6));
|
|
||||||
|
|
||||||
const handleTouchStart = (event: TouchEvent) => {
|
const getMaxScrollTop = () => Math.max(0, viewport.scrollHeight - viewport.clientHeight);
|
||||||
if (event.touches.length !== 1) {
|
|
||||||
return;
|
const setScrollTop = (nextScrollTop: number) => {
|
||||||
}
|
const maxScrollTop = getMaxScrollTop();
|
||||||
state.lastY = event.touches[0].clientY;
|
viewport.scrollTop = Math.max(0, Math.min(maxScrollTop, nextScrollTop));
|
||||||
state.remainder = 0;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTouchMove = (event: TouchEvent) => {
|
const scrollByPixels = (deltaPixels: number) => {
|
||||||
if (event.touches.length !== 1) {
|
if (!deltaPixels) {
|
||||||
state.lastY = null;
|
return;
|
||||||
state.remainder = 0;
|
}
|
||||||
|
const previous = viewport.scrollTop;
|
||||||
|
setScrollTop(previous + deltaPixels);
|
||||||
|
return viewport.scrollTop !== previous;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopKinetic = () => {
|
||||||
|
if (state.rafId !== null && typeof window !== 'undefined') {
|
||||||
|
window.cancelAnimationFrame(state.rafId);
|
||||||
|
}
|
||||||
|
state.rafId = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const listenerOptions: AddEventListenerOptions = { passive: false, capture: true };
|
||||||
|
const supportsPointerEvents = typeof window !== 'undefined' && 'PointerEvent' in window;
|
||||||
|
|
||||||
|
if (supportsPointerEvents) {
|
||||||
|
const stateWithPointerId = Object.assign(state, { pointerId: null as number | null });
|
||||||
|
|
||||||
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
|
if (event.pointerType !== 'touch') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stopKinetic();
|
||||||
|
stateWithPointerId.pointerId = event.pointerId;
|
||||||
|
stateWithPointerId.lastY = event.clientY;
|
||||||
|
stateWithPointerId.lastTime = nowMs();
|
||||||
|
stateWithPointerId.velocity = 0;
|
||||||
|
try {
|
||||||
|
container.setPointerCapture(event.pointerId);
|
||||||
|
} catch { /* ignored */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerMove = (event: PointerEvent) => {
|
||||||
|
if (event.pointerType !== 'touch' || stateWithPointerId.pointerId !== event.pointerId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.lastY === null) {
|
if (stateWithPointerId.lastY === null) {
|
||||||
state.lastY = event.touches[0].clientY;
|
stateWithPointerId.lastY = event.clientY;
|
||||||
|
stateWithPointerId.lastTime = nowMs();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentY = event.touches[0].clientY;
|
const previousY = stateWithPointerId.lastY;
|
||||||
const delta = state.lastY - currentY;
|
const previousTime = stateWithPointerId.lastTime ?? nowMs();
|
||||||
state.lastY = currentY;
|
const currentTime = nowMs();
|
||||||
|
stateWithPointerId.lastY = event.clientY;
|
||||||
|
stateWithPointerId.lastTime = currentTime;
|
||||||
|
|
||||||
const aggregate = state.remainder + delta / lineHeightEstimate;
|
const deltaY = previousY - event.clientY;
|
||||||
const lines =
|
if (Math.abs(deltaY) < 1) {
|
||||||
aggregate > 0 ? Math.floor(aggregate) : aggregate < 0 ? Math.ceil(aggregate) : 0;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
state.remainder = aggregate - lines;
|
const dt = Math.max(currentTime - previousTime, 8);
|
||||||
|
const scrollMultiplier = baseScrollMultiplier + Math.min(maxScrollBoost, Math.abs(deltaY) / boostDenominator);
|
||||||
|
const deltaPixels = deltaY * scrollMultiplier;
|
||||||
|
const instantVelocity = deltaPixels / dt;
|
||||||
|
stateWithPointerId.velocity = stateWithPointerId.velocity * (1 - velocityAlpha) + instantVelocity * velocityAlpha;
|
||||||
|
|
||||||
if (lines !== 0) {
|
if (stateWithPointerId.velocity > maxVelocity) {
|
||||||
|
stateWithPointerId.velocity = maxVelocity;
|
||||||
|
} else if (stateWithPointerId.velocity < -maxVelocity) {
|
||||||
|
stateWithPointerId.velocity = -maxVelocity;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.cancelable) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
|
||||||
terminal.scrollLines(lines);
|
|
||||||
}
|
}
|
||||||
|
event.stopPropagation();
|
||||||
|
scrollByPixels(deltaPixels);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTouchEnd = () => {
|
const handlePointerUp = (event: PointerEvent) => {
|
||||||
state.lastY = null;
|
if (event.pointerType !== 'touch' || stateWithPointerId.pointerId !== event.pointerId) {
|
||||||
state.remainder = 0;
|
return;
|
||||||
|
}
|
||||||
|
stateWithPointerId.pointerId = null;
|
||||||
|
stateWithPointerId.lastY = null;
|
||||||
|
stateWithPointerId.lastTime = null;
|
||||||
|
try {
|
||||||
|
container.releasePointerCapture(event.pointerId);
|
||||||
|
} catch { /* ignored */ }
|
||||||
|
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.abs(stateWithPointerId.velocity) < minVelocity) {
|
||||||
|
stateWithPointerId.velocity = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastFrame = nowMs();
|
||||||
|
const step = () => {
|
||||||
|
const frameTime = nowMs();
|
||||||
|
const dt = Math.max(frameTime - lastFrame, 8);
|
||||||
|
lastFrame = frameTime;
|
||||||
|
|
||||||
|
const moved = scrollByPixels(stateWithPointerId.velocity * dt) ?? false;
|
||||||
|
|
||||||
|
const sign = Math.sign(stateWithPointerId.velocity);
|
||||||
|
const nextMagnitude = Math.max(0, Math.abs(stateWithPointerId.velocity) - deceleration * dt);
|
||||||
|
stateWithPointerId.velocity = nextMagnitude * sign;
|
||||||
|
|
||||||
|
if (!moved || nextMagnitude <= minVelocity) {
|
||||||
|
stopKinetic();
|
||||||
|
stateWithPointerId.velocity = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stateWithPointerId.rafId = window.requestAnimationFrame(step);
|
||||||
};
|
};
|
||||||
|
|
||||||
const listenerOptions: AddEventListenerOptions = { passive: false };
|
stateWithPointerId.rafId = window.requestAnimationFrame(step);
|
||||||
container.addEventListener('touchstart', handleTouchStart, listenerOptions);
|
};
|
||||||
container.addEventListener('touchmove', handleTouchMove, listenerOptions);
|
|
||||||
container.addEventListener('touchend', handleTouchEnd);
|
container.addEventListener('pointerdown', handlePointerDown, listenerOptions);
|
||||||
container.addEventListener('touchcancel', handleTouchEnd);
|
container.addEventListener('pointermove', handlePointerMove, listenerOptions);
|
||||||
|
container.addEventListener('pointerup', handlePointerUp, listenerOptions);
|
||||||
|
container.addEventListener('pointercancel', handlePointerUp, listenerOptions);
|
||||||
|
|
||||||
const previousTouchAction = container.style.touchAction;
|
const previousTouchAction = container.style.touchAction;
|
||||||
container.style.touchAction = 'none';
|
container.style.touchAction = 'none';
|
||||||
|
|
||||||
touchScrollCleanupRef.current = () => {
|
touchScrollCleanupRef.current = () => {
|
||||||
container.removeEventListener('touchstart', handleTouchStart);
|
stopKinetic();
|
||||||
container.removeEventListener('touchmove', handleTouchMove);
|
container.removeEventListener('pointerdown', handlePointerDown, listenerOptions);
|
||||||
container.removeEventListener('touchend', handleTouchEnd);
|
container.removeEventListener('pointermove', handlePointerMove, listenerOptions);
|
||||||
container.removeEventListener('touchcancel', handleTouchEnd);
|
container.removeEventListener('pointerup', handlePointerUp, listenerOptions);
|
||||||
|
container.removeEventListener('pointercancel', handlePointerUp, listenerOptions);
|
||||||
container.style.touchAction = previousTouchAction;
|
container.style.touchAction = previousTouchAction;
|
||||||
};
|
};
|
||||||
}, [enableTouchScroll, fontSize]);
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTouchStart = (event: TouchEvent) => {
|
||||||
|
if (event.touches.length !== 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stopKinetic();
|
||||||
|
state.lastY = event.touches[0].clientY;
|
||||||
|
state.lastTime = nowMs();
|
||||||
|
state.velocity = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (event: TouchEvent) => {
|
||||||
|
if (event.touches.length !== 1) {
|
||||||
|
state.lastY = null;
|
||||||
|
state.lastTime = null;
|
||||||
|
state.velocity = 0;
|
||||||
|
stopKinetic();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentY = event.touches[0].clientY;
|
||||||
|
if (state.lastY === null) {
|
||||||
|
state.lastY = currentY;
|
||||||
|
state.lastTime = nowMs();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousY = state.lastY;
|
||||||
|
const previousTime = state.lastTime ?? nowMs();
|
||||||
|
const currentTime = nowMs();
|
||||||
|
state.lastY = currentY;
|
||||||
|
state.lastTime = currentTime;
|
||||||
|
|
||||||
|
const deltaY = previousY - currentY;
|
||||||
|
if (Math.abs(deltaY) < 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dt = Math.max(currentTime - previousTime, 8);
|
||||||
|
const scrollMultiplier = baseScrollMultiplier + Math.min(maxScrollBoost, Math.abs(deltaY) / boostDenominator);
|
||||||
|
const deltaPixels = deltaY * scrollMultiplier;
|
||||||
|
const instantVelocity = deltaPixels / dt;
|
||||||
|
state.velocity = state.velocity * (1 - velocityAlpha) + instantVelocity * velocityAlpha;
|
||||||
|
|
||||||
|
if (state.velocity > maxVelocity) {
|
||||||
|
state.velocity = maxVelocity;
|
||||||
|
} else if (state.velocity < -maxVelocity) {
|
||||||
|
state.velocity = -maxVelocity;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
scrollByPixels(deltaPixels);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
state.lastY = null;
|
||||||
|
state.lastTime = null;
|
||||||
|
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.abs(state.velocity) < minVelocity) {
|
||||||
|
state.velocity = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastFrame = nowMs();
|
||||||
|
const step = () => {
|
||||||
|
const frameTime = nowMs();
|
||||||
|
const dt = Math.max(frameTime - lastFrame, 8);
|
||||||
|
lastFrame = frameTime;
|
||||||
|
|
||||||
|
const moved = scrollByPixels(state.velocity * dt) ?? false;
|
||||||
|
|
||||||
|
const sign = Math.sign(state.velocity);
|
||||||
|
const nextMagnitude = Math.max(0, Math.abs(state.velocity) - deceleration * dt);
|
||||||
|
state.velocity = nextMagnitude * sign;
|
||||||
|
|
||||||
|
if (!moved || nextMagnitude <= minVelocity) {
|
||||||
|
stopKinetic();
|
||||||
|
state.velocity = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.rafId = window.requestAnimationFrame(step);
|
||||||
|
};
|
||||||
|
|
||||||
|
state.rafId = window.requestAnimationFrame(step);
|
||||||
|
};
|
||||||
|
|
||||||
|
container.addEventListener('touchstart', handleTouchStart, listenerOptions);
|
||||||
|
container.addEventListener('touchmove', handleTouchMove, listenerOptions);
|
||||||
|
container.addEventListener('touchend', handleTouchEnd, listenerOptions);
|
||||||
|
container.addEventListener('touchcancel', handleTouchEnd, listenerOptions);
|
||||||
|
|
||||||
|
const previousTouchAction = container.style.touchAction;
|
||||||
|
container.style.touchAction = 'none';
|
||||||
|
|
||||||
|
touchScrollCleanupRef.current = () => {
|
||||||
|
stopKinetic();
|
||||||
|
container.removeEventListener('touchstart', handleTouchStart, listenerOptions);
|
||||||
|
container.removeEventListener('touchmove', handleTouchMove, listenerOptions);
|
||||||
|
container.removeEventListener('touchend', handleTouchEnd, listenerOptions);
|
||||||
|
container.removeEventListener('touchcancel', handleTouchEnd, listenerOptions);
|
||||||
|
container.style.touchAction = previousTouchAction;
|
||||||
|
};
|
||||||
|
}, [enableTouchScroll]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const terminal = new Terminal(getTerminalOptions(fontFamily, fontSize, theme));
|
const terminal = new Terminal(getTerminalOptions(fontFamily, fontSize, theme));
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export const TerminalView: React.FC = () => {
|
|||||||
const { terminal } = useRuntimeAPIs();
|
const { terminal } = useRuntimeAPIs();
|
||||||
const { currentTheme } = useThemeSystem();
|
const { currentTheme } = useThemeSystem();
|
||||||
const { monoFont } = useFontPreferences();
|
const { monoFont } = useFontPreferences();
|
||||||
const { isMobile } = useDeviceInfo();
|
const { isMobile, hasTouchInput } = useDeviceInfo();
|
||||||
|
|
||||||
const { currentSessionId, sessions, worktreeMetadata: worktreeMap } = useSessionStore();
|
const { currentSessionId, sessions, worktreeMetadata: worktreeMap } = useSessionStore();
|
||||||
const worktreeMetadata = currentSessionId ? worktreeMap.get(currentSessionId) ?? undefined : undefined;
|
const worktreeMetadata = currentSessionId ? worktreeMap.get(currentSessionId) ?? undefined : undefined;
|
||||||
@@ -807,6 +807,22 @@ export const TerminalView: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<div className="h-full w-full box-border px-3 pt-3 pb-4">
|
<div className="h-full w-full box-border px-3 pt-3 pb-4">
|
||||||
{isTerminalActive ? (
|
{isTerminalActive ? (
|
||||||
|
isMobile ? (
|
||||||
|
<TerminalViewport
|
||||||
|
key={terminalSessionKey}
|
||||||
|
ref={(controller) => {
|
||||||
|
terminalControllerRef.current = controller;
|
||||||
|
}}
|
||||||
|
sessionKey={terminalSessionKey}
|
||||||
|
chunks={bufferChunks}
|
||||||
|
onInput={handleViewportInput}
|
||||||
|
onResize={handleViewportResize}
|
||||||
|
theme={xtermTheme}
|
||||||
|
fontFamily={resolvedFontStack}
|
||||||
|
fontSize={TERMINAL_FONT_SIZE}
|
||||||
|
enableTouchScroll={hasTouchInput}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<ScrollableOverlay outerClassName="h-full" className="h-full w-full" disableHorizontal>
|
<ScrollableOverlay outerClassName="h-full" className="h-full w-full" disableHorizontal>
|
||||||
<TerminalViewport
|
<TerminalViewport
|
||||||
key={terminalSessionKey}
|
key={terminalSessionKey}
|
||||||
@@ -820,9 +836,10 @@ export const TerminalView: React.FC = () => {
|
|||||||
theme={xtermTheme}
|
theme={xtermTheme}
|
||||||
fontFamily={resolvedFontStack}
|
fontFamily={resolvedFontStack}
|
||||||
fontSize={TERMINAL_FONT_SIZE}
|
fontSize={TERMINAL_FONT_SIZE}
|
||||||
enableTouchScroll={isMobile}
|
enableTouchScroll={hasTouchInput}
|
||||||
/>
|
/>
|
||||||
</ScrollableOverlay>
|
</ScrollableOverlay>
|
||||||
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{connectionError && (
|
{connectionError && (
|
||||||
|
|||||||
Reference in New Issue
Block a user