mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
fix(mobile): restore terminal input focus from blank-area taps
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { TERMINAL_TAP_DISPATCH_JS } from './terminal-webview-tap-dispatch-injected'
|
||||
|
||||
function boot(selecting = false) {
|
||||
const document = globalThis.document.implementation.createHTMLDocument()
|
||||
document.body.innerHTML =
|
||||
'<div id="terminal-container"><div id="surface"></div></div><div id="overlay"></div>'
|
||||
const container = document.getElementById('terminal-container')!
|
||||
const surface = document.getElementById('surface')!
|
||||
const posted = vi.fn()
|
||||
const cellTap = vi.fn()
|
||||
const select = vi.fn()
|
||||
const cancel = vi.fn()
|
||||
new Function(
|
||||
'document',
|
||||
'notify',
|
||||
'notifyTerminalSurfaceTap',
|
||||
'enterSelect',
|
||||
'onCancel',
|
||||
`
|
||||
var surface = document.getElementById('surface');
|
||||
var selectionOverlay = document.getElementById('overlay');
|
||||
var handleStart = {}, handleEnd = {}, sel = {}, selMode = ${JSON.stringify(selecting ? 'select' : 'idle')};
|
||||
var tapCandidate = null, longPressTimer = null, longPressOrigin = null;
|
||||
var LONG_PRESS_MS = 500, LONG_PRESS_SLOP = 10, TAP_SLOP = 24, TAP_MAX_MS = 700;
|
||||
function viewportToCell() { throw new Error('Blank space has no cell'); }
|
||||
function cancelSelect() { selMode = 'idle'; onCancel(); }
|
||||
function handleDragMove() {}
|
||||
function stopEdgeScroll() {}
|
||||
${TERMINAL_TAP_DISPATCH_JS}
|
||||
`
|
||||
)(document, posted, cellTap, select, cancel)
|
||||
const touch = (type: string, points: number[][], target = container) => {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(event, 'target', { value: target })
|
||||
Object.defineProperty(event, 'touches', {
|
||||
value: points.map(([clientX, clientY], identifier) => ({ identifier, clientX, clientY }))
|
||||
})
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
return { touch, posted, cellTap, select, cancel, surface }
|
||||
}
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('restores focus on blank container taps without resolving cells or links', () => {
|
||||
const h = boot()
|
||||
h.touch('touchstart', [[20, 300]])
|
||||
h.touch('touchend', [])
|
||||
expect(h.posted.mock.calls).toEqual([[{ type: 'terminal-tap' }]])
|
||||
expect(h.cellTap).not.toHaveBeenCalled()
|
||||
expect(h.select).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['move', 'cancel', 'pinch', 'three-finger pinch', 'hold'])(
|
||||
'does not focus for a blank-space %s',
|
||||
(kind) => {
|
||||
vi.useFakeTimers()
|
||||
const h = boot()
|
||||
h.touch('touchstart', [[20, 300]])
|
||||
if (kind === 'move') {
|
||||
h.touch('touchmove', [[20, 340]])
|
||||
}
|
||||
if (kind === 'cancel') {
|
||||
h.touch('touchcancel', [])
|
||||
}
|
||||
if (kind === 'pinch' || kind === 'three-finger pinch') {
|
||||
h.touch('touchstart', [
|
||||
[20, 300],
|
||||
[40, 300]
|
||||
])
|
||||
}
|
||||
if (kind === 'three-finger pinch') {
|
||||
h.touch('touchstart', [
|
||||
[20, 300],
|
||||
[40, 300],
|
||||
[60, 300]
|
||||
])
|
||||
}
|
||||
if (kind === 'hold') {
|
||||
vi.advanceTimersByTime(550)
|
||||
}
|
||||
h.touch('touchend', [])
|
||||
expect(h.posted).not.toHaveBeenCalled()
|
||||
expect(h.cellTap).not.toHaveBeenCalled()
|
||||
expect(h.select).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('dismisses selection first and only focuses on the next blank tap', () => {
|
||||
const h = boot(true)
|
||||
h.touch('touchstart', [[20, 300]])
|
||||
h.touch('touchend', [])
|
||||
expect(h.cancel).toHaveBeenCalledOnce()
|
||||
expect(h.posted).not.toHaveBeenCalled()
|
||||
h.touch('touchstart', [[20, 300]])
|
||||
h.touch('touchend', [])
|
||||
expect(h.posted).toHaveBeenCalledWith({ type: 'terminal-tap' })
|
||||
})
|
||||
@@ -6,8 +6,8 @@ import { XTERM_HTML } from './terminal-webview-html'
|
||||
// uncovered region ships silently. A diff here means the emitted WebView source changed —
|
||||
// update these values only when that change is deliberate, and only after checking the
|
||||
// document still runs. Refactors that merely move slice boundaries must leave them alone.
|
||||
const EXPECTED_SHA256 = '42cc000faddc3b58b8fd4855f848c7878f0cd6166c613f66d733645e8e1b9608'
|
||||
const EXPECTED_LENGTH = 729776
|
||||
const EXPECTED_SHA256 = 'cb9d1d764ad765e1933ffce87f546ad8589928613da36bbf688992bb82b15eb8'
|
||||
const EXPECTED_LENGTH = 730168
|
||||
|
||||
describe('terminal WebView payload', () => {
|
||||
it('composes the expected document', () => {
|
||||
|
||||
@@ -57,12 +57,13 @@ export const TERMINAL_TAP_DISPATCH_JS = `
|
||||
var onHandle = target === handleStart || target === handleEnd;
|
||||
var inOverlay = targetInside(target, selectionOverlay);
|
||||
var inSurface = targetInside(target, surface);
|
||||
var inBlankContainer = target === document.getElementById('terminal-container');
|
||||
// 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.
|
||||
tapCandidate = null;
|
||||
|
||||
if (e.touches.length === 2) {
|
||||
if (e.touches.length >= 2) {
|
||||
// pinch latch
|
||||
if (selMode === 'select') {
|
||||
notify({ type: 'mobile-clip-cancel-by-pinch' });
|
||||
@@ -89,7 +90,7 @@ export const TERMINAL_TAP_DISPATCH_JS = `
|
||||
return;
|
||||
}
|
||||
|
||||
if (inSurface && selMode === 'select') {
|
||||
if ((inSurface || inBlankContainer) && 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.
|
||||
@@ -99,11 +100,11 @@ export const TERMINAL_TAP_DISPATCH_JS = `
|
||||
return;
|
||||
}
|
||||
|
||||
if (inSurface) {
|
||||
if (inSurface || inBlankContainer) {
|
||||
dispatch.mode = 'surface';
|
||||
dispatch.touchId = t.identifier;
|
||||
tapCandidate = { x: t.clientX, y: t.clientY, t: Date.now(), identifier: t.identifier };
|
||||
armLongPress(t);
|
||||
tapCandidate = { x: t.clientX, y: t.clientY, t: Date.now(), identifier: t.identifier, blank: inBlankContainer };
|
||||
if (inSurface) armLongPress(t);
|
||||
}
|
||||
}, { capture: true, passive: false });
|
||||
|
||||
@@ -163,7 +164,12 @@ export const TERMINAL_TAP_DISPATCH_JS = `
|
||||
selMode !== 'select' &&
|
||||
Date.now() - tapCandidate.t <= TAP_MAX_MS
|
||||
) {
|
||||
notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y, true);
|
||||
if (tapCandidate.blank) {
|
||||
// Blank space has no terminal cell, link, or mouse target.
|
||||
if (Date.now() - tapCandidate.t < LONG_PRESS_MS) notify({ type: 'terminal-tap' });
|
||||
} else {
|
||||
notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y, true);
|
||||
}
|
||||
}
|
||||
clearLongPress();
|
||||
tapCandidate = null;
|
||||
|
||||
Reference in New Issue
Block a user