Focus mobile terminal only on tap

This commit is contained in:
Mohamed Boudra
2026-05-25 19:27:45 +07:00
parent 9806a893ed
commit e312e0d6f3
3 changed files with 113 additions and 5 deletions
@@ -8,7 +8,14 @@ import {
type ComponentProps,
type Ref,
} from "react";
import { Keyboard, StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
import {
Keyboard,
StyleSheet,
View,
type GestureResponderEvent,
type StyleProp,
type ViewStyle,
} from "react-native";
import { WebView, type WebViewMessageEvent } from "react-native-webview";
import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages";
@@ -131,7 +138,13 @@ const TERMINAL_WEBVIEW_SOURCE = { html: terminalEmulatorWebViewHtml };
const TERMINAL_WEBVIEW_ORIGIN_WHITELIST = ["*"];
const BRIDGE_READY_TIMEOUT_MS = 2_500;
const RENDERER_READY_TIMEOUT_MS = 2_500;
const TERMINAL_TAP_MOVE_TOLERANCE_PX = 8;
type WebViewProps = ComponentProps<typeof WebView>;
interface PendingTerminalTap {
startX: number;
startY: number;
moved: boolean;
}
function buildThemeKey(theme: ITheme): string {
return JSON.stringify(theme);
@@ -197,6 +210,7 @@ export default function TerminalEmulator({
const mountedStreamKeyRef = useRef<string | null>(null);
const bridgeReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const rendererReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingTapRef = useRef<PendingTerminalTap | null>(null);
const mountConfigRef = useRef({
streamKey,
initialSnapshot,
@@ -550,11 +564,41 @@ export default function TerminalEmulator({
resetWebViewDocument();
}, [resetWebViewDocument]);
const handleWebViewTouchStart = useCallback(() => {
const handleWebViewTouchStart = useCallback((event: GestureResponderEvent) => {
pendingTapRef.current = {
startX: event.nativeEvent.pageX,
startY: event.nativeEvent.pageY,
moved: false,
};
}, []);
const handleWebViewTouchMove = useCallback((event: GestureResponderEvent) => {
const pendingTap = pendingTapRef.current;
if (!pendingTap) return;
const dx = event.nativeEvent.pageX - pendingTap.startX;
const dy = event.nativeEvent.pageY - pendingTap.startY;
if (
Math.abs(dx) > TERMINAL_TAP_MOVE_TOLERANCE_PX ||
Math.abs(dy) > TERMINAL_TAP_MOVE_TOLERANCE_PX
) {
pendingTap.moved = true;
}
}, []);
const handleWebViewTouchEnd = useCallback(() => {
const pendingTap = pendingTapRef.current;
pendingTapRef.current = null;
if (!pendingTap || pendingTap.moved) {
return;
}
webViewRef.current?.requestFocus();
sendToWebView({ type: "focus", streamKey, forceRefocus: true });
}, [sendToWebView, streamKey]);
const handleWebViewTouchCancel = useCallback(() => {
pendingTapRef.current = null;
}, []);
const backgroundColor = xtermTheme.background ?? "#0b0b0b";
const rootStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.root, { backgroundColor }],
@@ -592,6 +636,9 @@ export default function TerminalEmulator({
setDisplayZoomControls={false}
textZoom={100}
onTouchStart={handleWebViewTouchStart}
onTouchMove={handleWebViewTouchMove}
onTouchEnd={handleWebViewTouchEnd}
onTouchCancel={handleWebViewTouchCancel}
onMessage={handleMessage}
onLoadStart={handleLoadStart}
onContentProcessDidTerminate={handleContentProcessDidTerminate}
@@ -91,6 +91,7 @@ const sendToNative = (message: OutboundMessage): void => {
const TERMINAL_BACKGROUND_CSS_VAR = "--paseo-terminal-background";
const DEFAULT_TERMINAL_BACKGROUND = "#0b0b0b";
const TERMINAL_TAP_MOVE_TOLERANCE_PX = 8;
function getTerminalBackground(theme: ITheme): string {
return theme.background ?? DEFAULT_TERMINAL_BACKGROUND;
@@ -170,6 +171,10 @@ class TerminalWebViewBridge {
private startX = 0;
private startY = 0;
private firedSwipe = false;
private tapTouchIdentifier: number | null = null;
private tapStartX = 0;
private tapStartY = 0;
private tapMoved = false;
constructor(
private readonly root: HTMLDivElement,
@@ -177,6 +182,9 @@ class TerminalWebViewBridge {
) {
this.root.addEventListener("pointerdown", this.handlePointerDown, { passive: true });
this.root.addEventListener("touchstart", this.handleTouchStart, { passive: true });
this.root.addEventListener("touchmove", this.handleTouchMove, { passive: true });
this.root.addEventListener("touchend", this.handleTouchEnd, { passive: true });
this.root.addEventListener("touchcancel", this.handleTouchCancel, { passive: true });
this.root.addEventListener("pointermove", this.handlePointerMove, { passive: false });
this.root.addEventListener("pointerup", this.handlePointerUp, { passive: true });
this.root.addEventListener("pointercancel", this.handlePointerUp, { passive: true });
@@ -370,7 +378,6 @@ class TerminalWebViewBridge {
if (!event.isPrimary) {
return;
}
this.runtime?.focus({ forceRefocus: true });
if (!this.swipeGesturesEnabled) {
return;
}
@@ -382,11 +389,44 @@ class TerminalWebViewBridge {
};
private handleTouchStart = (event: TouchEvent): void => {
if (event.touches.length === 1) {
if (event.touches.length !== 1) {
this.resetTap();
return;
}
const touch = event.touches[0];
this.tapTouchIdentifier = touch.identifier;
this.tapStartX = touch.clientX;
this.tapStartY = touch.clientY;
this.tapMoved = false;
};
private handleTouchMove = (event: TouchEvent): void => {
const touch = this.findTrackedTouch(event.touches);
if (!touch) {
return;
}
const dx = touch.clientX - this.tapStartX;
const dy = touch.clientY - this.tapStartY;
if (
Math.abs(dx) > TERMINAL_TAP_MOVE_TOLERANCE_PX ||
Math.abs(dy) > TERMINAL_TAP_MOVE_TOLERANCE_PX
) {
this.tapMoved = true;
}
};
private handleTouchEnd = (event: TouchEvent): void => {
const completedTap = Boolean(this.findTrackedTouch(event.changedTouches) && !this.tapMoved);
this.resetTap();
if (completedTap) {
this.runtime?.focus({ forceRefocus: true });
}
};
private handleTouchCancel = (): void => {
this.resetTap();
};
private handlePointerMove = (event: PointerEvent): void => {
if (!this.trackingSwipe || this.firedSwipe || !this.streamKey) {
return;
@@ -426,6 +466,26 @@ class TerminalWebViewBridge {
this.startY = 0;
this.firedSwipe = false;
}
private findTrackedTouch(touches: TouchList): Touch | null {
if (this.tapTouchIdentifier === null) {
return null;
}
for (let index = 0; index < touches.length; index += 1) {
const touch = touches.item(index);
if (touch?.identifier === this.tapTouchIdentifier) {
return touch;
}
}
return null;
}
private resetTap(): void {
this.tapTouchIdentifier = null;
this.tapStartX = 0;
this.tapStartY = 0;
this.tapMoved = false;
}
}
installStyles();
File diff suppressed because one or more lines are too long