diff --git a/packages/app/src/components/terminal-emulator.native.tsx b/packages/app/src/components/terminal-emulator.native.tsx index 8742c8361..1de6dda36 100644 --- a/packages/app/src/components/terminal-emulator.native.tsx +++ b/packages/app/src/components/terminal-emulator.native.tsx @@ -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; +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(null); const bridgeReadyTimeoutRef = useRef | null>(null); const rendererReadyTimeoutRef = useRef | null>(null); + const pendingTapRef = useRef(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>( () => [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} diff --git a/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts b/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts index edc9b68b3..866bad956 100644 --- a/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts +++ b/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts @@ -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(); diff --git a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts b/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts index 1b61188b7..f771d7e31 100644 --- a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts +++ b/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts @@ -1,4 +1,5 @@ // Generated by packages/app/scripts/build-terminal-webview-html.mjs. // Do not edit by hand. -export const terminalEmulatorWebViewHtml = "\n\n \n \n \n \n \n \n \n"; +export const terminalEmulatorWebViewHtml = + '\n\n \n \n \n \n \n \n \n';