fix(mobile): add tap threshold to terminal touch handler

touchmove fires on any 1px finger drift, marking didScroll=true and
skipping the tap handler (which refocuses terminal/CJK input). On
iPad's large touch surface and phones with imprecise taps, this makes
terminal tap unreliable — cjkActive gets stuck true, blocking all
input (CJK and paste).

Add 8px TAP_THRESHOLD: finger movement under 8px is still a tap.
Also add touch-action:none on .touch-device .terminal-container
so the browser doesn't consume touch events before our JS handler.
This commit is contained in:
Teigen
2026-06-17 23:40:21 +08:00
parent 47359e4002
commit b1c4330680
3 changed files with 35 additions and 10 deletions
+8 -2
View File
@@ -162,6 +162,10 @@ const CjkInput = (() => {
if (!_textarea.classList.contains('cjk-input-visible')) {
window.cjkActive = false;
}
// Reset composing state — some IMEs fire compositionstart without a
// matching compositionend, leaving _composing stuck true and blocking
// all subsequent input events.
_composing = false;
};
_textarea.addEventListener('mousedown', _listeners.mousedown);
_textarea.addEventListener('focus', _listeners.focus);
@@ -214,8 +218,10 @@ const CjkInput = (() => {
return;
}
// Below: only when NOT composing (composing keystrokes belong to IME)
if (_composing) return;
// Below: only when NOT composing (composing keystrokes belong to IME).
// Also check isComposing/keyCode 229 — the first keydown of a CJK
// sequence arrives BEFORE compositionstart, so _composing is still false.
if (_composing || e.isComposing || e.keyCode === 229) return;
// Backspace: forward to PTY when no real text in textarea
if (e.key === 'Backspace' && _isEffectivelyEmpty()) {
+10 -2
View File
@@ -996,6 +996,12 @@ body {
display: none !important;
}
/* Redraw terminal header button: hidden by default (opt-in via App Settings →
Display → "Header Displays"). Pure client-side toggle. */
.btn-redraw-terminal--hidden {
display: none !important;
}
.btn-icon-header.btn-settings {
width: 30px;
height: 30px;
@@ -2229,8 +2235,10 @@ body.solo-mode .btn-lifecycle-log {
background: transparent !important;
}
/* Touch devices: JS touch handler owns scroll/tap — prevent browser
from consuming touch events before touchmove preventDefault fires. */
/* Touch devices: prevent browser from claiming the touch gesture before
our JS touchmove handler fires. Without this, the browser starts native
scrolling during the first few px of finger travel and ignores our
subsequent preventDefault() — breaking custom terminal scroll. */
.touch-device .terminal-container,
.touch-device .terminal-container .xterm,
.touch-device .terminal-container .xterm-viewport,
+17 -6
View File
@@ -365,11 +365,14 @@ Object.assign(CodemanApp.prototype, {
let pixelAccum = 0;
let didScroll = false; // track whether touchmove fired (tap vs scroll)
let touchStartY = 0;
const TAP_THRESHOLD = 8; // px — ignore micro-drift to distinguish tap from scroll
container.addEventListener(
'touchstart',
(ev) => {
if (ev.touches.length === 1) {
touchLastY = ev.touches[0].clientY;
touchStartY = touchLastY;
velocity = 0;
pixelAccum = 0;
isTouching = true;
@@ -388,9 +391,15 @@ Object.assign(CodemanApp.prototype, {
'touchmove',
(ev) => {
if (ev.touches.length === 1 && isTouching) {
ev.preventDefault();
didScroll = true;
const touchY = ev.touches[0].clientY;
if (!didScroll && Math.abs(touchY - touchStartY) >= TAP_THRESHOLD) {
didScroll = true;
}
// Only preventDefault once it's a real scroll — preventing micro-drift
// touchmove kills click synthesis, which iOS needs to show the keyboard.
if (didScroll) {
ev.preventDefault();
}
const delta = touchLastY - touchY; // positive = scroll down
pixelAccum += delta;
velocity = delta * 1.2;
@@ -410,19 +419,21 @@ Object.assign(CodemanApp.prototype, {
container.addEventListener(
'touchend',
() => {
(ev) => {
isTouching = false;
if (!scrollFrame && Math.abs(velocity) > 0.3) {
scrollFrame = requestAnimationFrame(scrollLoop);
}
// Tap (no scroll): refocus xterm's hidden textarea so keyboard input
// routes back to the terminal. Without this, a tap on the terminal area
// consumes the touch event but xterm's textarea never regains focus.
if (!didScroll && this.terminal) {
const cjkInput = document.getElementById('cjkInput');
if (cjkInput?.classList.contains('cjk-input-visible')) {
cjkInput.focus();
} else {
// Don't synthesize mousedown — the browser does it naturally
// because we don't preventDefault() for taps (< TAP_THRESHOLD).
// touch-action:none blocks native scroll but NOT click synthesis.
// The browser's mousedown carries detail:1 + correct coordinates,
// so xterm's SelectionService._handleSingleClick picks it up.
this._syncMobileHelperTextareaToCursor();
this.terminal.focus();
}