feat(mobile): terminal text selection, copy, and paste (#1553)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
49b250a0f3
commit
cc14a0ade3
|
|
@ -1,4 +1,6 @@
|
|||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Animated, AppState, type AppStateStatus } from 'react-native'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
|
|
@ -19,9 +21,16 @@ import type { RpcClient } from '../../../../src/transport/rpc-client'
|
|||
import { loadHosts } from '../../../../src/transport/host-store'
|
||||
import { useHostClient } from '../../../../src/transport/client-context'
|
||||
import type { ConnectionState, RpcSuccess } from '../../../../src/transport/types'
|
||||
import { triggerMediumImpact } from '../../../../src/platform/haptics'
|
||||
import {
|
||||
triggerMediumImpact,
|
||||
triggerSelection,
|
||||
triggerSuccess,
|
||||
triggerError,
|
||||
triggerEdgeBump
|
||||
} from '../../../../src/platform/haptics'
|
||||
import {
|
||||
TerminalWebView,
|
||||
type TerminalModes,
|
||||
type TerminalWebViewHandle
|
||||
} from '../../../../src/terminal/TerminalWebView'
|
||||
import { StatusDot } from '../../../../src/components/StatusDot'
|
||||
|
|
@ -49,15 +58,22 @@ type TerminalCreateResult = {
|
|||
|
||||
type MobileDisplayMode = 'auto' | 'phone' | 'desktop'
|
||||
|
||||
type AccessoryKey = { label: string; bytes: string; accessibilityLabel?: string }
|
||||
type AccessoryKey = {
|
||||
label: string
|
||||
bytes: string
|
||||
accessibilityLabel?: string
|
||||
repeatable?: boolean
|
||||
}
|
||||
|
||||
const ACCESSORY_KEYS: AccessoryKey[] = [
|
||||
{ label: 'Esc', bytes: '\x1b' },
|
||||
{ label: 'Tab', bytes: '\t' },
|
||||
{ label: '↑', bytes: '\x1b[A' },
|
||||
{ label: '↓', bytes: '\x1b[B' },
|
||||
{ label: '←', bytes: '\x1b[D' },
|
||||
{ label: '→', bytes: '\x1b[C' },
|
||||
{ label: '⌫', bytes: '\x7f', accessibilityLabel: 'Backspace', repeatable: true },
|
||||
{ label: 'Del', bytes: '\x1b[3~', accessibilityLabel: 'Forward delete', repeatable: true },
|
||||
{ label: '↑', bytes: '\x1b[A', repeatable: true },
|
||||
{ label: '↓', bytes: '\x1b[B', repeatable: true },
|
||||
{ label: '←', bytes: '\x1b[D', repeatable: true },
|
||||
{ label: '→', bytes: '\x1b[C', repeatable: true },
|
||||
{ label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: 'Interrupt terminal' },
|
||||
{ label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' },
|
||||
{ label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: 'Clear screen' },
|
||||
|
|
@ -82,12 +98,22 @@ function TerminalPaneView({
|
|||
handle,
|
||||
active,
|
||||
onRef,
|
||||
onWebReady
|
||||
onWebReady,
|
||||
onSelectionMode,
|
||||
onSelectionCopy,
|
||||
onSelectionEvicted,
|
||||
onModesChanged,
|
||||
onHaptic
|
||||
}: {
|
||||
handle: string
|
||||
active: boolean
|
||||
onRef: (handle: string, ref: TerminalWebViewHandle | null) => void
|
||||
onWebReady: (handle: string) => void
|
||||
onSelectionMode: (handle: string, active: boolean) => void
|
||||
onSelectionCopy: (handle: string, text: string) => void
|
||||
onSelectionEvicted: (handle: string) => void
|
||||
onModesChanged: (handle: string, modes: TerminalModes) => void
|
||||
onHaptic: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
|
||||
}) {
|
||||
const setRef = useCallback(
|
||||
(ref: TerminalWebViewHandle | null) => {
|
||||
|
|
@ -105,6 +131,11 @@ function TerminalPaneView({
|
|||
ref={setRef}
|
||||
style={styles.terminalWebView}
|
||||
onWebReady={() => onWebReady(handle)}
|
||||
onSelectionMode={(a) => onSelectionMode(handle, a)}
|
||||
onSelectionCopy={(t) => onSelectionCopy(handle, t)}
|
||||
onSelectionEvicted={() => onSelectionEvicted(handle)}
|
||||
onModesChanged={(m) => onModesChanged(handle, m)}
|
||||
onHaptic={onHaptic}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
|
|
@ -146,6 +177,14 @@ export default function SessionScreen() {
|
|||
// Why: server-authoritative display mode per terminal. The runtime is the
|
||||
// single source of truth — this state is populated from subscribe responses.
|
||||
const [terminalModes, setTerminalModes] = useState<Map<string, MobileDisplayMode>>(new Map())
|
||||
const [selectModeActive, setSelectModeActive] = useState(false)
|
||||
const [canPaste, setCanPaste] = useState(false)
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||
const toastOpacityRef = useRef(new Animated.Value(0))
|
||||
// Why: WebView pushes terminal modes (bracketed-paste, alt-screen) on every
|
||||
// change so paste reads a synchronous snapshot — no round-trip required.
|
||||
const ptyModesRef = useRef<Map<string, TerminalModes>>(new Map())
|
||||
const initialModesSeenRef = useRef<Set<string>>(new Set())
|
||||
const deviceTokenRef = useRef<string | null>(null)
|
||||
const clientRef = useRef<RpcClient | null>(null)
|
||||
// Why: measured once from TerminalWebView on mount, then passed with every
|
||||
|
|
@ -845,6 +884,174 @@ export default function SessionScreen() {
|
|||
}
|
||||
}
|
||||
|
||||
// Why: press-and-hold key repeat for keys flagged repeatable (arrows,
|
||||
// backspace, forward-delete). Matches iOS keyboard cadence: instant first
|
||||
// fire, then ~400ms before the second, then ~45ms between subsequent
|
||||
// repeats. Non-repeatable keys (Tab, Esc, Ctrl-*) intentionally fire once
|
||||
// because holding them is destructive or meaningless.
|
||||
const repeatTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const repeatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const stopAccessoryRepeat = useCallback(() => {
|
||||
if (repeatTimeoutRef.current) {
|
||||
clearTimeout(repeatTimeoutRef.current)
|
||||
repeatTimeoutRef.current = null
|
||||
}
|
||||
if (repeatIntervalRef.current) {
|
||||
clearInterval(repeatIntervalRef.current)
|
||||
repeatIntervalRef.current = null
|
||||
}
|
||||
}, [])
|
||||
const startAccessoryRepeat = useCallback(
|
||||
(bytes: string) => {
|
||||
stopAccessoryRepeat()
|
||||
repeatTimeoutRef.current = setTimeout(() => {
|
||||
repeatIntervalRef.current = setInterval(() => {
|
||||
void handleAccessoryKey(bytes)
|
||||
}, 45)
|
||||
}, 400)
|
||||
},
|
||||
[stopAccessoryRepeat]
|
||||
)
|
||||
useEffect(() => {
|
||||
return () => stopAccessoryRepeat()
|
||||
}, [stopAccessoryRepeat])
|
||||
|
||||
const showToast = useCallback((message: string, durationMs = 1200) => {
|
||||
setToastMessage(message)
|
||||
Animated.timing(toastOpacityRef.current, {
|
||||
toValue: 1,
|
||||
duration: 150,
|
||||
useNativeDriver: true
|
||||
}).start(() => {
|
||||
setTimeout(() => {
|
||||
Animated.timing(toastOpacityRef.current, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true
|
||||
}).start(() => setToastMessage(null))
|
||||
}, durationMs)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleSelectionMode = useCallback((handle: string, active: boolean) => {
|
||||
if (handle !== activeHandleRef.current) return
|
||||
setSelectModeActive(active)
|
||||
if (active) Keyboard.dismiss()
|
||||
}, [])
|
||||
|
||||
const handleSelectionCopy = useCallback(
|
||||
async (handle: string, text: string) => {
|
||||
if (handle !== activeHandleRef.current) return
|
||||
if (!text || text.length === 0) {
|
||||
terminalRefs.current.get(handle)?.cancelSelect()
|
||||
return
|
||||
}
|
||||
try {
|
||||
await Clipboard.setStringAsync(text)
|
||||
triggerSuccess()
|
||||
// Why: Android 13+ shows its own system "Copied to clipboard" toast on
|
||||
// every clipboard write, so our toast would be redundant; iOS shows
|
||||
// nothing on copy (it only banners on paste), so the in-app toast is
|
||||
// the only success signal there.
|
||||
if (Platform.OS === 'ios') showToast('Copied')
|
||||
terminalRefs.current.get(handle)?.cancelSelect()
|
||||
} catch (e) {
|
||||
triggerError()
|
||||
const err = e as { name?: string; message?: string }
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[mobile-clip] setString failed', {
|
||||
name: err.name,
|
||||
message: err.message
|
||||
})
|
||||
showToast("Couldn't copy", 1500)
|
||||
}
|
||||
},
|
||||
[showToast]
|
||||
)
|
||||
|
||||
const handleSelectionEvicted = useCallback(
|
||||
(handle: string) => {
|
||||
if (handle !== activeHandleRef.current) return
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[mobile-clip] selection evicted')
|
||||
showToast('Selection cleared (scrolled out of buffer)', 1500)
|
||||
setSelectModeActive(false)
|
||||
},
|
||||
[showToast]
|
||||
)
|
||||
|
||||
const handleModesChanged = useCallback((handle: string, modes: TerminalModes) => {
|
||||
ptyModesRef.current.set(handle, modes)
|
||||
initialModesSeenRef.current.add(handle)
|
||||
}, [])
|
||||
|
||||
const handleHaptic = useCallback((kind: 'selection' | 'success' | 'error' | 'edge-bump') => {
|
||||
if (kind === 'selection') triggerSelection()
|
||||
else if (kind === 'success') triggerSuccess()
|
||||
else if (kind === 'error') triggerError()
|
||||
else if (kind === 'edge-bump') triggerEdgeBump()
|
||||
}, [])
|
||||
|
||||
const handlePaste = useCallback(async () => {
|
||||
if (!client || !activeHandle || !canSend) return
|
||||
try {
|
||||
const text = await Clipboard.getStringAsync()
|
||||
if (text.length === 0) return
|
||||
const modes = ptyModesRef.current.get(activeHandle) || {
|
||||
bracketedPasteMode: false,
|
||||
altScreen: false
|
||||
}
|
||||
const wrap = modes.bracketedPasteMode && !modes.altScreen
|
||||
const payload = wrap ? `\x1b[200~${text}\x1b[201~` : text
|
||||
const wrappedBytes = new TextEncoder().encode(payload).byteLength
|
||||
if (wrappedBytes > 256 * 1024) {
|
||||
triggerError()
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[mobile-clip] paste oversized', { wrappedBytes })
|
||||
showToast('Paste too large (max 256 KiB)', 1500)
|
||||
return
|
||||
}
|
||||
await client.sendRequest('terminal.send', {
|
||||
terminal: activeHandle,
|
||||
text: payload,
|
||||
enter: false,
|
||||
...(deviceTokenRef.current
|
||||
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
|
||||
: {})
|
||||
})
|
||||
triggerSelection()
|
||||
void Clipboard.hasStringAsync().then(setCanPaste)
|
||||
} catch (e) {
|
||||
triggerError()
|
||||
const err = e as { name?: string; message?: string }
|
||||
const isDisconnected = connState !== 'connected'
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[mobile-clip] paste failed', { name: err.name, message: err.message })
|
||||
if (isDisconnected) showToast('Paste failed (disconnected)', 1500)
|
||||
}
|
||||
}, [client, activeHandle, canSend, connState, showToast])
|
||||
|
||||
// Why: refresh canPaste on mount, AppState active, after paste.
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
const refresh = () => {
|
||||
void Clipboard.hasStringAsync().then((has) => {
|
||||
if (mounted) setCanPaste(has)
|
||||
})
|
||||
}
|
||||
refresh()
|
||||
const sub = AppState.addEventListener('change', (s: AppStateStatus) => {
|
||||
if (s === 'active') refresh()
|
||||
else if (selectModeActive && activeHandleRef.current) {
|
||||
terminalRefs.current.get(activeHandleRef.current)?.cancelSelect()
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
mounted = false
|
||||
sub.remove()
|
||||
}
|
||||
}, [selectModeActive])
|
||||
|
||||
async function handleCreateTerminal() {
|
||||
if (!client || creating) return
|
||||
|
||||
|
|
@ -1078,8 +1285,21 @@ export default function SessionScreen() {
|
|||
active={terminal.handle === activeHandle}
|
||||
onRef={setTerminalWebViewRef}
|
||||
onWebReady={handleTerminalWebReady}
|
||||
onSelectionMode={handleSelectionMode}
|
||||
onSelectionCopy={handleSelectionCopy}
|
||||
onSelectionEvicted={handleSelectionEvicted}
|
||||
onModesChanged={handleModesChanged}
|
||||
onHaptic={handleHaptic}
|
||||
/>
|
||||
))}
|
||||
{toastMessage && (
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
style={[styles.toast, { opacity: toastOpacityRef.current }]}
|
||||
>
|
||||
<Text style={styles.toastText}>{toastMessage}</Text>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
|
|
@ -1115,6 +1335,24 @@ export default function SessionScreen() {
|
|||
<Smartphone size={14} color={canSend ? colors.textSecondary : colors.textMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
{canPaste && (
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.accessoryKey,
|
||||
pressed && styles.accessoryKeyPressed,
|
||||
!canSend && styles.accessoryKeyDisabled
|
||||
]}
|
||||
disabled={!canSend}
|
||||
onPress={() => void handlePaste()}
|
||||
accessibilityLabel="Paste from clipboard"
|
||||
>
|
||||
<Text
|
||||
style={[styles.accessoryKeyText, !canSend && styles.accessoryKeyTextDisabled]}
|
||||
>
|
||||
Paste
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{ACCESSORY_KEYS.map((key) => (
|
||||
<Pressable
|
||||
key={key.label}
|
||||
|
|
@ -1124,7 +1362,18 @@ export default function SessionScreen() {
|
|||
!canSend && styles.accessoryKeyDisabled
|
||||
]}
|
||||
disabled={!canSend}
|
||||
onPress={() => void handleAccessoryKey(key.bytes)}
|
||||
onPressIn={() => {
|
||||
if (!key.repeatable) return
|
||||
void handleAccessoryKey(key.bytes)
|
||||
startAccessoryRepeat(key.bytes)
|
||||
}}
|
||||
onPressOut={() => {
|
||||
if (key.repeatable) stopAccessoryRepeat()
|
||||
}}
|
||||
onPress={() => {
|
||||
if (key.repeatable) return
|
||||
void handleAccessoryKey(key.bytes)
|
||||
}}
|
||||
accessibilityLabel={key.accessibilityLabel ?? `Send ${key.label}`}
|
||||
>
|
||||
<Text
|
||||
|
|
@ -1391,6 +1640,23 @@ const styles = StyleSheet.create({
|
|||
terminalWebView: {
|
||||
flex: 1
|
||||
},
|
||||
toast: {
|
||||
position: 'absolute',
|
||||
bottom: spacing.lg,
|
||||
alignSelf: 'center',
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center'
|
||||
},
|
||||
toastText: {
|
||||
backgroundColor: 'rgba(20, 22, 39, 0.92)',
|
||||
color: colors.textPrimary,
|
||||
fontSize: 13,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radii.button,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
emptyState: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"expo": "^55.0.23",
|
||||
"expo-build-properties": "^55.0.13",
|
||||
"expo-camera": "^55.0.18",
|
||||
"expo-clipboard": "^55.0.13",
|
||||
"expo-constants": "^55.0.16",
|
||||
"expo-crypto": "^55.0.14",
|
||||
"expo-haptics": "^55.0.14",
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ importers:
|
|||
expo-camera:
|
||||
specifier: ^55.0.18
|
||||
version: 55.0.18(@types/emscripten@1.41.5)(expo@55.0.23)(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
|
||||
expo-clipboard:
|
||||
specifier: ^55.0.13
|
||||
version: 55.0.13(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
|
||||
expo-constants:
|
||||
specifier: ^55.0.16
|
||||
version: 55.0.16(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))
|
||||
|
|
@ -3077,6 +3080,13 @@ packages:
|
|||
react-native-web:
|
||||
optional: true
|
||||
|
||||
expo-clipboard@55.0.13:
|
||||
resolution: {integrity: sha512-PrOmmuVsGW4bAkNQmGKtxMXj3invsfN+jfIKmQxHwE/dn7ODqwFWviUTa+PMUjP3XZmYCDLyu/i0GLeu7HF9Ew==}
|
||||
peerDependencies:
|
||||
expo: '*'
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
expo-constants@55.0.16:
|
||||
resolution: {integrity: sha512-Z15/No94UHoogD+pulxjudGAeOHTEIWZgb/vnX48Wx5D+apWTeCbnKxQZZtGQlosvduYL5kaic2/W8U+NHfBQQ==}
|
||||
peerDependencies:
|
||||
|
|
@ -9262,6 +9272,12 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- '@types/emscripten'
|
||||
|
||||
expo-clipboard@55.0.13(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6):
|
||||
dependencies:
|
||||
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
|
||||
react: 19.2.6
|
||||
react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
|
||||
|
||||
expo-constants@55.0.16(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)):
|
||||
dependencies:
|
||||
'@expo/env': 2.1.2
|
||||
|
|
|
|||
|
|
@ -11,3 +11,35 @@ export function triggerMediumImpact(): void {
|
|||
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
export function triggerSelection(): void {
|
||||
if (Platform.OS === 'android') {
|
||||
void Haptics.performAndroidHapticsAsync(Haptics.AndroidHaptics.Gesture_Start).catch(() => {})
|
||||
} else {
|
||||
void Haptics.selectionAsync().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
export function triggerSuccess(): void {
|
||||
if (Platform.OS === 'android') {
|
||||
void Haptics.performAndroidHapticsAsync(Haptics.AndroidHaptics.Confirm).catch(() => {})
|
||||
} else {
|
||||
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
export function triggerError(): void {
|
||||
if (Platform.OS === 'android') {
|
||||
void Haptics.performAndroidHapticsAsync(Haptics.AndroidHaptics.Reject).catch(() => {})
|
||||
} else {
|
||||
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
export function triggerEdgeBump(): void {
|
||||
if (Platform.OS === 'android') {
|
||||
void Haptics.performAndroidHapticsAsync(Haptics.AndroidHaptics.Clock_Tick).catch(() => {})
|
||||
} else {
|
||||
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,27 @@ import { WebView } from 'react-native-webview'
|
|||
import type { WebViewMessageEvent } from 'react-native-webview'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
|
||||
export type TerminalModes = {
|
||||
bracketedPasteMode: boolean
|
||||
altScreen: boolean
|
||||
}
|
||||
|
||||
export type TerminalSelectionEvents = {
|
||||
onSelectionMode?: (active: boolean) => void
|
||||
onSelectionCopy?: (text: string) => void
|
||||
onSelectionEvicted?: () => void
|
||||
onModesChanged?: (modes: TerminalModes) => void
|
||||
onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
|
||||
}
|
||||
|
||||
export type TerminalWebViewHandle = {
|
||||
write: (data: string) => void
|
||||
init: (cols: number, rows: number, initialData?: string) => void
|
||||
clear: () => void
|
||||
measureFitDimensions: (containerHeight?: number) => Promise<{ cols: number; rows: number } | null>
|
||||
resetZoom: () => void
|
||||
cancelSelect: () => void
|
||||
doSelectAll: () => void
|
||||
// Why: lets callers await the WebView-side `init` rAF chain (term.open
|
||||
// → renderService population → first paint) so a follow-up measure
|
||||
// doesn't race ahead and find term=null or cellWidth=0. Resolves on
|
||||
|
|
@ -20,7 +35,7 @@ export type TerminalWebViewHandle = {
|
|||
type Props = {
|
||||
style?: StyleProp<ViewStyle>
|
||||
onWebReady?: () => void
|
||||
}
|
||||
} & TerminalSelectionEvents
|
||||
|
||||
type TerminalMessage =
|
||||
| { type: 'write'; id?: number; data: string }
|
||||
|
|
@ -28,6 +43,8 @@ type TerminalMessage =
|
|||
| { type: 'clear'; id?: number }
|
||||
| { type: 'measure'; id?: number; containerHeight?: number }
|
||||
| { type: 'reset-zoom'; id?: number }
|
||||
| { type: 'cancel-select'; id?: number }
|
||||
| { type: 'do-select-all'; id?: number }
|
||||
|
||||
// Why: TUI apps (Claude Code / Ink) emit escape codes with absolute cursor
|
||||
// positioning designed for the desktop's terminal dimensions (~150+ cols).
|
||||
|
|
@ -63,12 +80,90 @@ const XTERM_HTML = `<!DOCTYPE html>
|
|||
display: inline-block;
|
||||
}
|
||||
.xterm { -webkit-user-select: none; user-select: none; }
|
||||
/* Why: selection overlay sits in unscaled viewport coords, above the
|
||||
transformed surface, so handle hit areas and Copy menu positions
|
||||
don't depend on getTotalScale() for their on-screen size. */
|
||||
#selection-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
display: none;
|
||||
}
|
||||
#selection-overlay.active { display: block; }
|
||||
.sel-handle {
|
||||
position: absolute;
|
||||
width: 44px; height: 44px;
|
||||
margin-left: -22px; margin-top: -22px;
|
||||
pointer-events: auto;
|
||||
background: transparent;
|
||||
}
|
||||
.sel-handle::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%; top: 22px;
|
||||
transform: translateX(-50%);
|
||||
width: 14px; height: 14px;
|
||||
background: #7aa2f7;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #c0caf5;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.sel-handle.start::before { top: 8px; }
|
||||
.sel-handle.start::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%; top: 22px;
|
||||
transform: translateX(-50%);
|
||||
width: 2px; height: 16px;
|
||||
background: #7aa2f7;
|
||||
}
|
||||
.sel-handle.end::before { top: 22px; }
|
||||
.sel-handle.end::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%; top: 6px;
|
||||
transform: translateX(-50%);
|
||||
width: 2px; height: 16px;
|
||||
background: #7aa2f7;
|
||||
}
|
||||
#sel-menu {
|
||||
position: absolute;
|
||||
pointer-events: auto;
|
||||
background: #2a2f4a;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
transform: translateY(-100%);
|
||||
margin-top: -12px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
#sel-menu button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #c0caf5;
|
||||
font: 600 13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
#sel-menu button:active { background: #414868; }
|
||||
#sel-menu button + button { border-left: 1px solid #414868; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="terminal-container">
|
||||
<div id="terminal-surface"></div>
|
||||
</div>
|
||||
<div id="selection-overlay">
|
||||
<div id="sel-handle-start" class="sel-handle start"></div>
|
||||
<div id="sel-handle-end" class="sel-handle end"></div>
|
||||
<div id="sel-menu">
|
||||
<button id="sel-menu-copy">Copy</button>
|
||||
<button id="sel-menu-all">Select All</button>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.1.0-beta.198/lib/xterm.min.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
|
|
@ -137,6 +232,7 @@ const XTERM_HTML = `<!DOCTYPE html>
|
|||
|
||||
function updateTransform() {
|
||||
surface.style.transform = 'translate(' + panX + 'px,' + panY + 'px) scale(' + getTotalScale() + ')';
|
||||
if (selMode === 'select') repositionOverlay();
|
||||
}
|
||||
|
||||
function getCellHeight() {
|
||||
|
|
@ -282,6 +378,7 @@ const XTERM_HTML = `<!DOCTYPE html>
|
|||
vpWidth: vpW
|
||||
});
|
||||
}
|
||||
repositionOverlay();
|
||||
}
|
||||
|
||||
function isAltScreenActive(data) {
|
||||
|
|
@ -378,6 +475,11 @@ const XTERM_HTML = `<!DOCTYPE html>
|
|||
writeQueue.push(replayData);
|
||||
}
|
||||
|
||||
// Why: reset eviction tracking + attach observers for the new term.
|
||||
resetEvictionCounter();
|
||||
cancelSelect();
|
||||
attachTermObservers();
|
||||
|
||||
requestAnimationFrame(function() {
|
||||
if (gen !== terminalGeneration) return;
|
||||
ready = true;
|
||||
|
|
@ -482,13 +584,496 @@ const XTERM_HTML = `<!DOCTYPE html>
|
|||
afterDrainCallbacks = [];
|
||||
writesDraining = false;
|
||||
if (term) { term.clear(); term.reset(); }
|
||||
resetEvictionCounter();
|
||||
if (selMode === 'select') {
|
||||
notify({ type: 'selection-evicted' });
|
||||
cancelSelect();
|
||||
}
|
||||
} else if (msg.type === 'measure') {
|
||||
measureFitDimensions(msg.containerHeight);
|
||||
} else if (msg.type === 'reset-zoom') {
|
||||
applyFitScale('reset-zoom-msg');
|
||||
} else if (msg.type === 'cancel-select') {
|
||||
if (selMode === 'select') cancelSelect();
|
||||
} else if (msg.type === 'do-select-all') {
|
||||
if (term) {
|
||||
try {
|
||||
term.selectAll();
|
||||
var b = term.buffer.active;
|
||||
if (selMode !== 'select') {
|
||||
selMode = 'select';
|
||||
selectionOverlay.classList.add('active');
|
||||
notify({ type: 'set-select-mode', enabled: true });
|
||||
}
|
||||
sel = {
|
||||
anchor: { col: 0, row: 0 },
|
||||
focus: { col: term.cols - 1, row: b.length - 1 },
|
||||
activeHandle: null
|
||||
};
|
||||
repositionOverlay();
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SELECTION MODE (long-press → handles → Copy)
|
||||
// ============================================================
|
||||
var WORD_RE = /[\\p{L}\\p{N}_./:@~+=?&#%-]/u;
|
||||
var LONG_PRESS_MS = 500;
|
||||
var LONG_PRESS_SLOP = 10;
|
||||
var EDGE_SCROLL_PX = 40;
|
||||
var EDGE_SCROLL_INTERVAL = 60;
|
||||
|
||||
var selectionOverlay = document.getElementById('selection-overlay');
|
||||
var handleStart = document.getElementById('sel-handle-start');
|
||||
var handleEnd = document.getElementById('sel-handle-end');
|
||||
var selMenu = document.getElementById('sel-menu');
|
||||
var btnCopy = document.getElementById('sel-menu-copy');
|
||||
var btnSelAll = document.getElementById('sel-menu-all');
|
||||
|
||||
// mode: 'navigate' | 'select'
|
||||
var selMode = 'navigate';
|
||||
var sel = null; // { anchor:{col,row}, focus:{col,row}, activeHandle:null|'start'|'end' }
|
||||
var longPressTimer = null;
|
||||
var longPressOrigin = null; // {x,y, identifier}
|
||||
var edgeScrollTimer = null;
|
||||
var edgeScrollDir = 0;
|
||||
|
||||
// Eviction watchdog: linesEverWritten counts onLineFeed since last init.
|
||||
// Once buffer is full, every onLineFeed evicts the top row in xterm and
|
||||
// we mirror that by decrementing stored absolute rows.
|
||||
var linesEverWritten = 0;
|
||||
|
||||
function resetEvictionCounter() { linesEverWritten = 0; }
|
||||
|
||||
function isBufferFull() {
|
||||
if (!term) return false;
|
||||
return linesEverWritten >= 5000 + (term.rows || 0);
|
||||
}
|
||||
|
||||
function checkEviction() {
|
||||
if (selMode !== 'select' || !sel) return;
|
||||
var oldest = Math.min(sel.anchor.row, sel.focus.row);
|
||||
if (oldest < 0) {
|
||||
notify({ type: 'selection-evicted' });
|
||||
cancelSelect();
|
||||
}
|
||||
}
|
||||
|
||||
function logFeedAndEvict() {
|
||||
linesEverWritten++;
|
||||
if (selMode === 'select' && sel && isBufferFull()) {
|
||||
sel.anchor.row -= 1;
|
||||
sel.focus.row -= 1;
|
||||
checkEviction();
|
||||
repositionOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
function emitModesIfChanged() {
|
||||
if (!term) return;
|
||||
var bp = !!(term.modes && term.modes.bracketedPasteMode);
|
||||
var alt = false;
|
||||
try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {}
|
||||
if (bp !== lastEmittedModes.bracketedPasteMode || alt !== lastEmittedModes.altScreen) {
|
||||
lastEmittedModes = { bracketedPasteMode: bp, altScreen: alt };
|
||||
notify({ type: 'modes', bracketedPasteMode: bp, altScreen: alt });
|
||||
}
|
||||
}
|
||||
var lastEmittedModes = { bracketedPasteMode: false, altScreen: false };
|
||||
|
||||
function attachTermObservers() {
|
||||
if (!term) return;
|
||||
try { term.onLineFeed(logFeedAndEvict); } catch (e) {}
|
||||
// Why: emit modes on every parsed write so RN's mirror stays current
|
||||
// without round-trip; covers \\x1b[?2004h/l and alt-screen toggles.
|
||||
try { term.onWriteParsed && term.onWriteParsed(emitModesIfChanged); } catch (e) {}
|
||||
// Initial emit once buffer settles.
|
||||
afterWritesDrained(function() { emitModesIfChanged(); });
|
||||
}
|
||||
|
||||
function viewportToCell(clientX, clientY) {
|
||||
if (!term) return null;
|
||||
var cellW = getCellWidth();
|
||||
var cellH = getCellHeight();
|
||||
if (cellW <= 0 || cellH <= 0) return null;
|
||||
var total = getTotalScale();
|
||||
if (total <= 0) total = 1;
|
||||
var sx = (clientX - panX) / total;
|
||||
var sy = (clientY - panY) / total;
|
||||
var col = Math.floor(sx / cellW);
|
||||
var viewportRow = Math.floor(sy / cellH);
|
||||
if (col < 0) col = 0;
|
||||
if (col > term.cols - 1) col = term.cols - 1;
|
||||
if (viewportRow < 0) viewportRow = 0;
|
||||
if (viewportRow > term.rows - 1) viewportRow = term.rows - 1;
|
||||
var viewportY = term.buffer.active.viewportY;
|
||||
return { col: col, row: viewportRow + viewportY };
|
||||
}
|
||||
|
||||
function cellToViewportPx(col, absRow) {
|
||||
if (!term) return { x: 0, y: 0 };
|
||||
var cellW = getCellWidth();
|
||||
var cellH = getCellHeight();
|
||||
var viewportRow = absRow - term.buffer.active.viewportY;
|
||||
var sx = col * cellW;
|
||||
var sy = viewportRow * cellH;
|
||||
var total = getTotalScale();
|
||||
return { x: sx * total + panX, y: sy * total + panY };
|
||||
}
|
||||
|
||||
function getLineText(absRow) {
|
||||
if (!term) return '';
|
||||
var line = term.buffer.active.getLine(absRow);
|
||||
if (!line) return '';
|
||||
return line.translateToString(false);
|
||||
}
|
||||
|
||||
function seedWordSelection(col, absRow) {
|
||||
var line = getLineText(absRow);
|
||||
if (!line) {
|
||||
sel = { anchor: { col: col, row: absRow }, focus: { col: col, row: absRow }, activeHandle: null };
|
||||
applyXtermSelection();
|
||||
return;
|
||||
}
|
||||
var s = col;
|
||||
var e = col;
|
||||
if (col >= 0 && col < line.length && WORD_RE.test(line[col])) {
|
||||
while (s > 0 && WORD_RE.test(line[s - 1])) s--;
|
||||
while (e < line.length - 1 && WORD_RE.test(line[e + 1])) e++;
|
||||
}
|
||||
sel = {
|
||||
anchor: { col: s, row: absRow },
|
||||
focus: { col: e, row: absRow },
|
||||
activeHandle: null
|
||||
};
|
||||
applyXtermSelection();
|
||||
}
|
||||
|
||||
function isStartFirst(a, b) {
|
||||
if (a.row !== b.row) return a.row < b.row;
|
||||
return a.col <= b.col;
|
||||
}
|
||||
|
||||
function selRange() {
|
||||
if (!sel) return null;
|
||||
if (isStartFirst(sel.anchor, sel.focus)) return { start: sel.anchor, end: sel.focus };
|
||||
return { start: sel.focus, end: sel.anchor };
|
||||
}
|
||||
|
||||
function applyXtermSelection() {
|
||||
if (!term || !sel) return;
|
||||
var r = selRange();
|
||||
if (!r) return;
|
||||
// Why: term.select(col, row, length) takes a buffer-absolute row,
|
||||
// not a viewport-relative one. Subtracting viewportY here drifts the
|
||||
// selection by the scrollback height — handles render where the user
|
||||
// pressed (their math is independent), but xterm highlights an
|
||||
// off-screen scrollback region and copies the wrong text.
|
||||
var length;
|
||||
if (r.start.row === r.end.row) {
|
||||
length = Math.max(1, r.end.col - r.start.col + 1);
|
||||
} else {
|
||||
var first = term.cols - r.start.col;
|
||||
var middle = Math.max(0, r.end.row - r.start.row - 1) * term.cols;
|
||||
var last = r.end.col + 1;
|
||||
length = first + middle + last;
|
||||
}
|
||||
try { term.select(r.start.col, r.start.row, length); } catch (e) {}
|
||||
}
|
||||
|
||||
function cancelSelect() {
|
||||
selMode = 'navigate';
|
||||
sel = null;
|
||||
stopEdgeScroll();
|
||||
if (term) {
|
||||
try { term.clearSelection(); } catch (e) {}
|
||||
// Why: some xterm renderers cache cells and skip repaint on
|
||||
// clearSelection alone, leaving the previously-highlighted cells
|
||||
// visually selected. Force a full refresh so the selection layer
|
||||
// actually clears on screen.
|
||||
try { term.refresh(0, term.rows - 1); } catch (e) {}
|
||||
}
|
||||
selectionOverlay.classList.remove('active');
|
||||
notify({ type: 'set-select-mode', enabled: false });
|
||||
}
|
||||
|
||||
function enterSelect(col, absRow) {
|
||||
selMode = 'select';
|
||||
seedWordSelection(col, absRow);
|
||||
selectionOverlay.classList.add('active');
|
||||
notify({ type: 'set-select-mode', enabled: true });
|
||||
notify({ type: 'haptic', kind: 'selection' });
|
||||
repositionOverlay();
|
||||
}
|
||||
|
||||
function repositionOverlay() {
|
||||
if (selMode !== 'select' || !sel || !term) return;
|
||||
var r = selRange();
|
||||
var sPx = cellToViewportPx(r.start.col, r.start.row);
|
||||
var ePx = cellToViewportPx(r.end.col + 1, r.end.row);
|
||||
var cellH = getCellHeight() * getTotalScale();
|
||||
// Why: native iOS pattern — start handle anchors at the TOP of the
|
||||
// first selected cell (dot above, stem covers the cell going down);
|
||||
// end handle anchors at the BOTTOM of the last selected cell (dot
|
||||
// below, stem covers the cell going up).
|
||||
handleStart.style.left = sPx.x + 'px';
|
||||
handleStart.style.top = sPx.y + 'px';
|
||||
handleEnd.style.left = ePx.x + 'px';
|
||||
handleEnd.style.top = (ePx.y + cellH) + 'px';
|
||||
var startVisible = sPx.y >= 0 && sPx.y <= window.innerHeight;
|
||||
var endVisible = ePx.y >= 0 && ePx.y <= window.innerHeight;
|
||||
handleStart.style.visibility = startVisible ? 'visible' : 'hidden';
|
||||
handleEnd.style.visibility = endVisible ? 'visible' : 'hidden';
|
||||
var menuCenterX, menuY, vTransform, marginTop;
|
||||
if (startVisible && sPx.y > 56) {
|
||||
menuCenterX = sPx.x; menuY = sPx.y;
|
||||
vTransform = 'translateY(-100%)';
|
||||
marginTop = '-12px';
|
||||
} else if (endVisible && ePx.y + cellH + 56 < window.innerHeight) {
|
||||
menuCenterX = ePx.x; menuY = ePx.y + cellH;
|
||||
vTransform = 'translateY(0)';
|
||||
marginTop = '12px';
|
||||
} else {
|
||||
// selection covers full viewport — pin to visible center
|
||||
menuCenterX = window.innerWidth / 2;
|
||||
menuY = window.innerHeight / 2;
|
||||
vTransform = 'translateY(-50%)';
|
||||
marginTop = '0';
|
||||
}
|
||||
// Why: clamp horizontally so the pill stays fully visible when the
|
||||
// selection sits near a screen edge. We position via plain left
|
||||
// (no horizontal translate) so the clamp math is straightforward.
|
||||
selMenu.style.transform = vTransform;
|
||||
selMenu.style.marginTop = marginTop;
|
||||
selMenu.style.top = menuY + 'px';
|
||||
selMenu.style.left = '0px';
|
||||
var EDGE_MARGIN = 8;
|
||||
var menuW = selMenu.offsetWidth || 0;
|
||||
var minLeft = EDGE_MARGIN;
|
||||
var maxLeft = Math.max(EDGE_MARGIN, window.innerWidth - menuW - EDGE_MARGIN);
|
||||
var desiredLeft = menuCenterX - menuW / 2;
|
||||
var clampedLeft = Math.max(minLeft, Math.min(maxLeft, desiredLeft));
|
||||
selMenu.style.left = clampedLeft + 'px';
|
||||
}
|
||||
|
||||
function startEdgeScroll(dir) {
|
||||
if (edgeScrollDir === dir) return;
|
||||
edgeScrollDir = dir;
|
||||
stopEdgeScroll();
|
||||
edgeScrollTimer = setInterval(function() {
|
||||
if (!term || edgeScrollDir === 0) return;
|
||||
var beforeY = term.buffer.active.viewportY;
|
||||
term.scrollLines(edgeScrollDir);
|
||||
var afterY = term.buffer.active.viewportY;
|
||||
if (beforeY === afterY) {
|
||||
notify({ type: 'haptic', kind: 'edge-bump' });
|
||||
stopEdgeScroll();
|
||||
return;
|
||||
}
|
||||
repositionOverlay();
|
||||
}, EDGE_SCROLL_INTERVAL);
|
||||
}
|
||||
|
||||
function stopEdgeScroll() {
|
||||
if (edgeScrollTimer) {
|
||||
clearInterval(edgeScrollTimer);
|
||||
edgeScrollTimer = null;
|
||||
}
|
||||
edgeScrollDir = 0;
|
||||
}
|
||||
|
||||
function handleDragMove(handle, clientX, clientY) {
|
||||
var c = viewportToCell(clientX, clientY);
|
||||
if (!c || !sel) return;
|
||||
if (handle === 'start') sel.anchor = c;
|
||||
else sel.focus = c;
|
||||
applyXtermSelection();
|
||||
repositionOverlay();
|
||||
if (clientY < EDGE_SCROLL_PX) startEdgeScroll(-1);
|
||||
else if (clientY > window.innerHeight - EDGE_SCROLL_PX) startEdgeScroll(1);
|
||||
else stopEdgeScroll();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// LATCHING TOUCH DISPATCHER (document-level)
|
||||
// ============================================================
|
||||
var dispatch = { mode: 'idle', touchId: null, touchIds: null, longPressFingerInsideOverlay: false };
|
||||
|
||||
function touchById(touches, id) {
|
||||
for (var i = 0; i < touches.length; i++) {
|
||||
if (touches[i].identifier === id) return touches[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function targetInside(target, el) {
|
||||
if (!target || !el) return false;
|
||||
return el.contains(target);
|
||||
}
|
||||
|
||||
function clearLongPress() {
|
||||
if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
|
||||
longPressOrigin = null;
|
||||
}
|
||||
|
||||
function armLongPress(touch) {
|
||||
longPressOrigin = { x: touch.clientX, y: touch.clientY, identifier: touch.identifier };
|
||||
longPressTimer = setTimeout(function() {
|
||||
longPressTimer = null;
|
||||
if (!longPressOrigin) return;
|
||||
var c = viewportToCell(longPressOrigin.x, longPressOrigin.y);
|
||||
if (!c) return;
|
||||
enterSelect(c.col, c.row);
|
||||
}, LONG_PRESS_MS);
|
||||
}
|
||||
|
||||
function touchSlopExceeded(t) {
|
||||
if (!longPressOrigin) return false;
|
||||
var dx = Math.abs(t.clientX - longPressOrigin.x);
|
||||
var dy = Math.abs(t.clientY - longPressOrigin.y);
|
||||
return (dx + dy) > LONG_PRESS_SLOP;
|
||||
}
|
||||
|
||||
// Why: existing surface handlers stay attached to surface but we wrap
|
||||
// their entry to no-op when the dispatcher latches into select-drag.
|
||||
function dispatcherShouldBlockSurface() {
|
||||
return dispatch.mode === 'select-drag';
|
||||
}
|
||||
|
||||
document.addEventListener('touchstart', function(e) {
|
||||
var t = e.touches[0];
|
||||
var target = e.target;
|
||||
var onHandle = target === handleStart || target === handleEnd;
|
||||
var inOverlay = targetInside(target, selectionOverlay);
|
||||
var inSurface = targetInside(target, surface);
|
||||
|
||||
if (e.touches.length === 2) {
|
||||
// pinch latch
|
||||
if (selMode === 'select') {
|
||||
notify({ type: 'mobile-clip-cancel-by-pinch' });
|
||||
cancelSelect();
|
||||
}
|
||||
dispatch.mode = 'pinch';
|
||||
dispatch.touchIds = [e.touches[0].identifier, e.touches[1].identifier];
|
||||
clearLongPress();
|
||||
return;
|
||||
}
|
||||
|
||||
if (onHandle && selMode === 'select') {
|
||||
// start handle drag
|
||||
var handleName = (target === handleStart) ? 'start' : 'end';
|
||||
sel.activeHandle = handleName;
|
||||
dispatch.mode = 'select-drag';
|
||||
dispatch.touchId = t.identifier;
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (inOverlay) {
|
||||
// tap on menu pill — let the buttons' own handlers fire
|
||||
return;
|
||||
}
|
||||
|
||||
if (inSurface && selMode === 'select') {
|
||||
// Why: tap-to-dismiss matches native iOS/Android — touching outside the
|
||||
// selection clears it. We cancel immediately and latch to 'surface' so
|
||||
// the same gesture still drives scroll/pan without a second touch.
|
||||
cancelSelect();
|
||||
dispatch.mode = 'surface';
|
||||
dispatch.touchId = t.identifier;
|
||||
return;
|
||||
}
|
||||
|
||||
if (inSurface) {
|
||||
dispatch.mode = 'surface';
|
||||
dispatch.touchId = t.identifier;
|
||||
armLongPress(t);
|
||||
}
|
||||
}, { capture: true, passive: false });
|
||||
|
||||
document.addEventListener('touchmove', function(e) {
|
||||
if (dispatch.mode === 'select-drag') {
|
||||
var t = touchById(e.touches, dispatch.touchId);
|
||||
if (!t || !sel || !sel.activeHandle) return;
|
||||
e.preventDefault();
|
||||
handleDragMove(sel.activeHandle, t.clientX, t.clientY);
|
||||
return;
|
||||
}
|
||||
if (dispatch.mode === 'surface' || dispatch.mode === 'pinch') {
|
||||
// long-press slop check
|
||||
if (longPressTimer && e.touches.length === 1) {
|
||||
if (touchSlopExceeded(e.touches[0])) clearLongPress();
|
||||
}
|
||||
// existing surface handler will run from its own listener
|
||||
}
|
||||
}, { capture: true, passive: false });
|
||||
|
||||
document.addEventListener('touchend', function(e) {
|
||||
if (dispatch.mode === 'select-drag') {
|
||||
if (sel) sel.activeHandle = null;
|
||||
stopEdgeScroll();
|
||||
dispatch.mode = 'idle';
|
||||
dispatch.touchId = null;
|
||||
return;
|
||||
}
|
||||
if (dispatch.mode === 'pinch') {
|
||||
if (e.touches.length < 2) {
|
||||
dispatch.mode = (e.touches.length === 1) ? 'surface' : 'idle';
|
||||
dispatch.touchIds = null;
|
||||
if (e.touches.length === 1) dispatch.touchId = e.touches[0].identifier;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (dispatch.mode === 'surface') {
|
||||
clearLongPress();
|
||||
if (e.touches.length === 0) {
|
||||
dispatch.mode = 'idle';
|
||||
dispatch.touchId = null;
|
||||
}
|
||||
}
|
||||
}, { capture: true, passive: true });
|
||||
|
||||
document.addEventListener('touchcancel', function() {
|
||||
clearLongPress();
|
||||
stopEdgeScroll();
|
||||
if (dispatch.mode === 'select-drag') {
|
||||
if (sel) sel.activeHandle = null;
|
||||
}
|
||||
dispatch.mode = 'idle';
|
||||
dispatch.touchId = null;
|
||||
dispatch.touchIds = null;
|
||||
}, { capture: true, passive: true });
|
||||
|
||||
btnCopy.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!term) return;
|
||||
var text = term.getSelection ? term.getSelection() : '';
|
||||
if (text && text.length > 0) {
|
||||
notify({ type: 'selection', text: text });
|
||||
} else {
|
||||
cancelSelect();
|
||||
}
|
||||
});
|
||||
|
||||
btnSelAll.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!term) return;
|
||||
try {
|
||||
term.selectAll();
|
||||
var b = term.buffer.active;
|
||||
sel = {
|
||||
anchor: { col: 0, row: 0 },
|
||||
focus: { col: term.cols - 1, row: b.length - 1 },
|
||||
activeHandle: null
|
||||
};
|
||||
repositionOverlay();
|
||||
} catch (err) {}
|
||||
});
|
||||
|
||||
// Why: event listeners are registered once here (not inside init()) so
|
||||
// they don't accumulate on re-init. They close over the mutable 'term'
|
||||
// variable, so they always reference the current terminal instance.
|
||||
|
|
@ -507,6 +1092,7 @@ const XTERM_HTML = `<!DOCTYPE html>
|
|||
}
|
||||
|
||||
surface.addEventListener('touchstart', function(e) {
|
||||
if (dispatcherShouldBlockSurface()) return;
|
||||
if (ts.momentumId) {
|
||||
cancelAnimationFrame(ts.momentumId);
|
||||
ts.momentumId = null;
|
||||
|
|
@ -531,6 +1117,7 @@ const XTERM_HTML = `<!DOCTYPE html>
|
|||
}, { capture: true, passive: true });
|
||||
|
||||
surface.addEventListener('touchmove', function(e) {
|
||||
if (dispatcherShouldBlockSurface()) return;
|
||||
if (!term) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
|
@ -579,6 +1166,7 @@ const XTERM_HTML = `<!DOCTYPE html>
|
|||
}, { capture: true, passive: false });
|
||||
|
||||
surface.addEventListener('touchend', function(e) {
|
||||
if (dispatcherShouldBlockSurface()) return;
|
||||
if (!term) return;
|
||||
|
||||
if (ts.isPinching && e.touches.length < 2) {
|
||||
|
|
@ -638,6 +1226,7 @@ const XTERM_HTML = `<!DOCTYPE html>
|
|||
// though there's now less vertical room and the fit ratio may differ.
|
||||
applyFitScale('window-resize');
|
||||
adjustRowsForViewport();
|
||||
repositionOverlay();
|
||||
clampPan();
|
||||
updateTransform();
|
||||
});
|
||||
|
|
@ -653,7 +1242,15 @@ const XTERM_HTML = `<!DOCTYPE html>
|
|||
</html>`
|
||||
|
||||
export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function TerminalWebView(
|
||||
{ style, onWebReady },
|
||||
{
|
||||
style,
|
||||
onWebReady,
|
||||
onSelectionMode,
|
||||
onSelectionCopy,
|
||||
onSelectionEvicted,
|
||||
onModesChanged,
|
||||
onHaptic
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const webViewRef = useRef<WebView>(null)
|
||||
|
|
@ -729,9 +1326,42 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
|||
const tag = typeof msg.tag === 'string' ? msg.tag : '[fit]'
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(tag, msg.payload)
|
||||
} else if (msg.type === 'set-select-mode') {
|
||||
onSelectionMode?.(!!msg.enabled)
|
||||
} else if (msg.type === 'selection') {
|
||||
const text = typeof msg.text === 'string' ? msg.text : ''
|
||||
onSelectionCopy?.(text)
|
||||
} else if (msg.type === 'selection-evicted') {
|
||||
onSelectionEvicted?.()
|
||||
} else if (msg.type === 'modes') {
|
||||
onModesChanged?.({
|
||||
bracketedPasteMode: !!msg.bracketedPasteMode,
|
||||
altScreen: !!msg.altScreen
|
||||
})
|
||||
} else if (msg.type === 'haptic') {
|
||||
const kind = msg.kind
|
||||
if (
|
||||
kind === 'selection' ||
|
||||
kind === 'success' ||
|
||||
kind === 'error' ||
|
||||
kind === 'edge-bump'
|
||||
) {
|
||||
onHaptic?.(kind)
|
||||
}
|
||||
} else if (msg.type === 'mobile-clip-cancel-by-pinch') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[mobile-clip] selection cancelled by pinch')
|
||||
}
|
||||
},
|
||||
[flushPendingMessages, onWebReady]
|
||||
[
|
||||
flushPendingMessages,
|
||||
onWebReady,
|
||||
onSelectionMode,
|
||||
onSelectionCopy,
|
||||
onSelectionEvicted,
|
||||
onModesChanged,
|
||||
onHaptic
|
||||
]
|
||||
)
|
||||
|
||||
const handleLoadStart = useCallback(() => {
|
||||
|
|
@ -780,6 +1410,12 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
|||
resetZoom() {
|
||||
postMessage({ type: 'reset-zoom' })
|
||||
},
|
||||
cancelSelect() {
|
||||
postMessage({ type: 'cancel-select' })
|
||||
},
|
||||
doSelectAll() {
|
||||
postMessage({ type: 'do-select-all' })
|
||||
},
|
||||
async awaitReady(): Promise<void> {
|
||||
// Why: returns the in-flight ready promise (set by init); resolves
|
||||
// immediately if no init is pending. Capped at 3s so a stuck
|
||||
|
|
|
|||
Loading…
Reference in New Issue