mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
refactor(mobile): extract the terminal tap-dispatch group into a module
The tenth named group, and the heaviest reader of shared state: the selection, its elements, its thresholds and both press origins are all declared by the overlay slice, which is still document text, so all of them move onto the scope with their declarations left where they are. Counts: qualified 49, scope declarations 0, rebindings 15, braced bodies 11, unbound catches 0, number properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -36,9 +36,6 @@ export declare function updateScrollIndicator(visible: boolean): void
|
||||
/** `runtime-state-and-text-scaling`: one diagnostic line, forwarded to the host. */
|
||||
export declare function flog(name: string, detail: Record<string, unknown>): void
|
||||
|
||||
/** `surface-touch-gestures`: whether a dispatcher above the surface is swallowing input. */
|
||||
export declare function dispatcherShouldBlockSurface(): boolean
|
||||
|
||||
/** `mouse-report-and-scroll-routing`: whether scrolling should reach the TUI as input. */
|
||||
export declare function shouldRouteScrollToTerminalInput(): boolean
|
||||
|
||||
@@ -68,3 +65,22 @@ export declare function getLineText(row: number): string
|
||||
|
||||
/** `selection-overlay`: the string index a cell column lands on, wide characters included. */
|
||||
export declare function cellColToStringIndex(row: number, col: number): number
|
||||
|
||||
/** `selection-overlay`: starts a selection at a cell. */
|
||||
export declare function enterSelect(col: number, row: number): void
|
||||
|
||||
/** `selection-overlay`: clears the selection and leaves select mode. */
|
||||
export declare function cancelSelect(): void
|
||||
|
||||
/** `selection-overlay`: moves one selection handle to a viewport point. */
|
||||
export declare function handleDragMove(handle: string, clientX: number, clientY: number): void
|
||||
|
||||
/** `selection-overlay`: stops the edge-scroll a handle drag may have started. */
|
||||
export declare function stopEdgeScroll(): void
|
||||
|
||||
/** `surface-touch-gestures`: reports a surface tap to the host. */
|
||||
export declare function notifyTerminalSurfaceTap(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
fromTouch: boolean
|
||||
): void
|
||||
|
||||
@@ -98,6 +98,30 @@ export type TerminalDocumentScope = {
|
||||
terminalTheme: TerminalDocumentTheme
|
||||
/** `terminal-theme`: the contrast floor in force, published or derived from the background. */
|
||||
terminalMinimumContrastRatio: number
|
||||
/** `selection-overlay`: the press duration that starts a selection, in milliseconds. */
|
||||
LONG_PRESS_MS: number
|
||||
/** `selection-overlay`: the travel that cancels a pending long press, in pixels. */
|
||||
LONG_PRESS_SLOP: number
|
||||
/** `selection-overlay`: the travel that disqualifies a tap, in pixels. */
|
||||
TAP_SLOP: number
|
||||
/** `selection-overlay`: the longest press still counted as a tap, in milliseconds. */
|
||||
TAP_MAX_MS: number
|
||||
/** `selection-overlay`: the overlay element that carries the handles and the menu pill. */
|
||||
selectionOverlay: HTMLElement | null
|
||||
/** `selection-overlay`: the selection's leading handle element. */
|
||||
handleStart: HTMLElement | null
|
||||
/** `selection-overlay`: the selection's trailing handle element. */
|
||||
handleEnd: HTMLElement | null
|
||||
/** `selection-overlay`: `navigate` or `select`. */
|
||||
selMode: string
|
||||
/** `selection-overlay`: the live selection, or null when there is none. */
|
||||
sel: TerminalDocumentSelection | null
|
||||
/** `selection-overlay`: the pending long-press timer. */
|
||||
longPressTimer: ReturnType<typeof setTimeout> | null
|
||||
/** `selection-overlay`: where the pending long press started. */
|
||||
longPressOrigin: TerminalDocumentTouchOrigin | null
|
||||
/** `selection-overlay`: the touch that may still resolve as a tap. */
|
||||
tapCandidate: TerminalDocumentTapCandidate | null
|
||||
/** `surface-swap`: the element xterm is currently mounted on. */
|
||||
surface: HTMLElement | null
|
||||
/** `surface-swap`: the terminal of a hidden replacement surface that has not committed. */
|
||||
@@ -105,6 +129,15 @@ export type TerminalDocumentScope = {
|
||||
}
|
||||
|
||||
/** An xterm listener handle, as the document disposes of one. */
|
||||
/** The live selection; only the dragged handle is read outside the overlay slice. */
|
||||
export type TerminalDocumentSelection = { activeHandle: string | null }
|
||||
|
||||
/** Where a press began, and which finger began it. */
|
||||
export type TerminalDocumentTouchOrigin = { x: number; y: number; identifier: number }
|
||||
|
||||
/** A touch that may still resolve as a tap: its origin, its start time and its finger. */
|
||||
export type TerminalDocumentTapCandidate = TerminalDocumentTouchOrigin & { t: number }
|
||||
|
||||
export type TerminalDocumentDisposable = { dispose?: () => void }
|
||||
|
||||
/** xterm's WebGL addon, as the document loads, repaints and disposes of it. */
|
||||
@@ -134,6 +167,18 @@ export function createTerminalDocumentScope(): TerminalDocumentScope {
|
||||
defaultTheme: DEFAULT_TERMINAL_THEME,
|
||||
terminalTheme: DEFAULT_TERMINAL_THEME,
|
||||
terminalMinimumContrastRatio: 3,
|
||||
LONG_PRESS_MS: 500,
|
||||
LONG_PRESS_SLOP: 10,
|
||||
TAP_SLOP: 24,
|
||||
TAP_MAX_MS: 700,
|
||||
selectionOverlay: null,
|
||||
handleStart: null,
|
||||
handleEnd: null,
|
||||
selMode: 'navigate',
|
||||
sel: null,
|
||||
longPressTimer: null,
|
||||
longPressOrigin: null,
|
||||
tapCandidate: null,
|
||||
wheelAccumDeltaY: 0,
|
||||
surface: null,
|
||||
pendingTerm: null
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { emitTerminalDocumentModule } from '../../../scripts/build-terminal-document-script.mjs'
|
||||
import { TERMINAL_TAP_DISPATCH_JS } from '../terminal-webview-tap-dispatch-injected'
|
||||
import { compareTerminalDocumentScripts } from './terminal-document-equivalence.test-support'
|
||||
|
||||
const modulePath = fileURLToPath(new URL('./tap-dispatch.ts', import.meta.url))
|
||||
|
||||
describe('the tap-dispatch module', () => {
|
||||
it('emits the script the document carries, modulo the five normalisations', async () => {
|
||||
const emitted = await emitTerminalDocumentModule(modulePath)
|
||||
expect(compareTerminalDocumentScripts(TERMINAL_TAP_DISPATCH_JS, emitted, 'scope')).toEqual({
|
||||
equivalent: true,
|
||||
normalisations: {
|
||||
// The heaviest reader of shared state so far: the selection, its elements, its thresholds
|
||||
// and both press origins are all declared by the overlay slice, which is still document text.
|
||||
qualifiedReferences: 49,
|
||||
scopeFieldDeclarations: 0,
|
||||
rebindings: 15,
|
||||
bracedBodies: 11,
|
||||
unboundCatches: 0,
|
||||
numberProperties: 0
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,253 @@
|
||||
import {
|
||||
cancelSelect,
|
||||
enterSelect,
|
||||
handleDragMove,
|
||||
notify,
|
||||
notifyTerminalSurfaceTap,
|
||||
stopEdgeScroll,
|
||||
viewportToCell
|
||||
} from './document-externals'
|
||||
import { scope } from './document-scope'
|
||||
|
||||
// ============================================================
|
||||
// LATCHING TOUCH DISPATCHER (document-level)
|
||||
// ============================================================
|
||||
|
||||
/** What the dispatcher has latched onto, and the fingers it is tracking. */
|
||||
export type TerminalTouchDispatch = {
|
||||
mode: string
|
||||
touchId: number | null
|
||||
touchIds: number[] | null
|
||||
longPressFingerInsideOverlay: boolean
|
||||
}
|
||||
|
||||
/** An element a target can be tested against; a method so a real element satisfies it. */
|
||||
type TerminalDocumentTargetContainer = { contains(other: EventTarget | null): boolean }
|
||||
|
||||
const dispatch: TerminalTouchDispatch = {
|
||||
mode: 'idle',
|
||||
touchId: null,
|
||||
touchIds: null,
|
||||
longPressFingerInsideOverlay: false
|
||||
}
|
||||
|
||||
export function touchById(touches: TouchList, id: number | null) {
|
||||
for (let i = 0; i < touches.length; i++) {
|
||||
if (touches[i].identifier === id) {
|
||||
return touches[i]
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function targetInside(
|
||||
target: EventTarget | null,
|
||||
el: TerminalDocumentTargetContainer | null
|
||||
) {
|
||||
if (!target || !el) {
|
||||
return false
|
||||
}
|
||||
return el.contains(target)
|
||||
}
|
||||
|
||||
export function clearLongPress() {
|
||||
if (scope.longPressTimer) {
|
||||
clearTimeout(scope.longPressTimer)
|
||||
scope.longPressTimer = null
|
||||
}
|
||||
scope.longPressOrigin = null
|
||||
}
|
||||
|
||||
export function armLongPress(touch: Touch) {
|
||||
scope.longPressOrigin = { x: touch.clientX, y: touch.clientY, identifier: touch.identifier }
|
||||
scope.longPressTimer = setTimeout(function () {
|
||||
scope.longPressTimer = null
|
||||
if (!scope.longPressOrigin) {
|
||||
return
|
||||
}
|
||||
const c = viewportToCell(scope.longPressOrigin.x, scope.longPressOrigin.y)
|
||||
if (!c) {
|
||||
return
|
||||
}
|
||||
enterSelect(c.col, c.row)
|
||||
}, scope.LONG_PRESS_MS)
|
||||
}
|
||||
|
||||
export function touchSlopExceeded(t: Touch) {
|
||||
if (!scope.longPressOrigin) {
|
||||
return false
|
||||
}
|
||||
const dx = Math.abs(t.clientX - scope.longPressOrigin.x)
|
||||
const dy = Math.abs(t.clientY - scope.longPressOrigin.y)
|
||||
return dx + dy > scope.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.
|
||||
export function dispatcherShouldBlockSurface() {
|
||||
return dispatch.mode === 'select-drag'
|
||||
}
|
||||
|
||||
document.addEventListener(
|
||||
'touchstart',
|
||||
function (e) {
|
||||
const t = e.touches[0]
|
||||
const target = e.target
|
||||
const onHandle = target === scope.handleStart || target === scope.handleEnd
|
||||
const inOverlay = targetInside(target, scope.selectionOverlay)
|
||||
const inSurface = targetInside(target, scope.surface)
|
||||
// Why: clear any stale tap candidate up front; only a fresh single-finger
|
||||
// surface touch (below) re-arms it, so handle drags / pinches / dismiss
|
||||
// taps never resolve as a link tap on touchend.
|
||||
scope.tapCandidate = null
|
||||
|
||||
if (e.touches.length === 2) {
|
||||
// pinch latch
|
||||
if (scope.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 && scope.selMode === 'select') {
|
||||
// start handle drag
|
||||
const handleName = target === scope.handleStart ? 'start' : 'end'
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion -- SAFETY: a handle only exists while a selection does.
|
||||
scope.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 && scope.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
|
||||
scope.tapCandidate = { x: t.clientX, y: t.clientY, t: Date.now(), identifier: t.identifier }
|
||||
armLongPress(t)
|
||||
}
|
||||
},
|
||||
{ capture: true, passive: false }
|
||||
)
|
||||
|
||||
document.addEventListener(
|
||||
'touchmove',
|
||||
function (e) {
|
||||
if (dispatch.mode === 'select-drag') {
|
||||
const t = touchById(e.touches, dispatch.touchId)
|
||||
if (!t || !scope.sel || !scope.sel.activeHandle) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
handleDragMove(scope.sel.activeHandle, t.clientX, t.clientY)
|
||||
return
|
||||
}
|
||||
if (dispatch.mode === 'surface' || dispatch.mode === 'pinch') {
|
||||
// long-press slop check
|
||||
if (scope.longPressTimer && e.touches.length === 1) {
|
||||
if (touchSlopExceeded(e.touches[0])) {
|
||||
clearLongPress()
|
||||
}
|
||||
}
|
||||
// Why: disqualify the tap only once the finger travels past TAP_SLOP
|
||||
// (a scroll/pan), independent of the long-press timer — so a tap that
|
||||
// jitters under TAP_SLOP still opens the link/path under the finger.
|
||||
if (scope.tapCandidate && e.touches.length === 1) {
|
||||
const mt = e.touches[0]
|
||||
if (mt.identifier === scope.tapCandidate.identifier) {
|
||||
const dx = Math.abs(mt.clientX - scope.tapCandidate.x)
|
||||
const dy = Math.abs(mt.clientY - scope.tapCandidate.y)
|
||||
if (dx + dy > scope.TAP_SLOP) {
|
||||
scope.tapCandidate = null
|
||||
}
|
||||
}
|
||||
} else if (e.touches.length !== 1) {
|
||||
scope.tapCandidate = null
|
||||
}
|
||||
// existing surface handler will run from its own listener
|
||||
}
|
||||
},
|
||||
{ capture: true, passive: false }
|
||||
)
|
||||
|
||||
document.addEventListener(
|
||||
'touchend',
|
||||
function (e) {
|
||||
if (dispatch.mode === 'select-drag') {
|
||||
if (scope.sel) {
|
||||
scope.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') {
|
||||
// Why: fire the tap from the tap-candidate origin (survives jitter under
|
||||
// TAP_SLOP) rather than longPressOrigin, which the press-to-select slop
|
||||
// can null mid-tap — that was dropping URL/file taps that moved a few px.
|
||||
if (
|
||||
e.touches.length === 0 &&
|
||||
scope.tapCandidate &&
|
||||
scope.selMode !== 'select' &&
|
||||
Date.now() - scope.tapCandidate.t <= scope.TAP_MAX_MS
|
||||
) {
|
||||
notifyTerminalSurfaceTap(scope.tapCandidate.x, scope.tapCandidate.y, true)
|
||||
}
|
||||
clearLongPress()
|
||||
scope.tapCandidate = null
|
||||
if (e.touches.length === 0) {
|
||||
dispatch.mode = 'idle'
|
||||
dispatch.touchId = null
|
||||
}
|
||||
}
|
||||
},
|
||||
{ capture: true, passive: true }
|
||||
)
|
||||
|
||||
document.addEventListener(
|
||||
'touchcancel',
|
||||
function () {
|
||||
clearLongPress()
|
||||
scope.tapCandidate = null
|
||||
stopEdgeScroll()
|
||||
if (dispatch.mode === 'select-drag') {
|
||||
if (scope.sel) {
|
||||
scope.sel.activeHandle = null
|
||||
}
|
||||
}
|
||||
dispatch.mode = 'idle'
|
||||
dispatch.touchId = null
|
||||
dispatch.touchIds = null
|
||||
},
|
||||
{ capture: true, passive: true }
|
||||
)
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
dispatcherShouldBlockSurface,
|
||||
enqueueNormalBufferScrollDelta,
|
||||
getCellHeight,
|
||||
getTotalScale,
|
||||
@@ -7,6 +6,7 @@ import {
|
||||
routeScrollLines,
|
||||
shouldRouteScrollToTerminalInput
|
||||
} from './document-externals'
|
||||
import { dispatcherShouldBlockSurface } from './tap-dispatch'
|
||||
import { scope } from './document-scope'
|
||||
|
||||
scope.wheelAccumDeltaY = 0
|
||||
|
||||
Reference in New Issue
Block a user