mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 00:49:41 +02:00
fix(mobile): prevent terminal tap selection
This commit is contained in:
@@ -2246,6 +2246,23 @@ body.solo-mode .btn-lifecycle-log {
|
|||||||
touch-action: none;
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile terminal taps are cursor-positioning gestures, not text-selection gestures.
|
||||||
|
xterm re-enables selectable DOM text in its accessibility tree, so force the
|
||||||
|
whole terminal subtree back to non-selectable on touch devices. Synthetic mouse
|
||||||
|
events for tap-to-position still dispatch normally through JavaScript. */
|
||||||
|
body.touch-device .terminal-container .xterm,
|
||||||
|
body.touch-device .terminal-container .xterm .xterm-viewport,
|
||||||
|
body.touch-device .terminal-container .xterm .xterm-screen,
|
||||||
|
body.touch-device .terminal-container .xterm .xterm-rows,
|
||||||
|
body.touch-device .terminal-container .xterm .xterm-accessibility-tree,
|
||||||
|
body.touch-device .terminal-container .xterm .xterm-accessibility-tree *,
|
||||||
|
body.touch-device .terminal-container .xterm .xterm-selection,
|
||||||
|
body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||||
|
user-select: none !important;
|
||||||
|
-webkit-user-select: none !important;
|
||||||
|
-webkit-touch-callout: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Welcome Overlay */
|
/* Welcome Overlay */
|
||||||
.welcome-overlay {
|
.welcome-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -19,6 +19,10 @@
|
|||||||
// suppressed, so high-frequency Codex status redraws don't snap the viewport
|
// suppressed, so high-frequency Codex status redraws don't snap the viewport
|
||||||
// back to the bottom while the user is inspecting earlier output.
|
// back to the bottom while the user is inspecting earlier output.
|
||||||
const USER_SCROLL_STICKY_SUPPRESS_MS = 1500;
|
const USER_SCROLL_STICKY_SUPPRESS_MS = 1500;
|
||||||
|
// Mobile browsers synthesize trusted mouse events after touchend. During this
|
||||||
|
// short window, only the app's synthetic tap-to-position mouse event should
|
||||||
|
// reach xterm.
|
||||||
|
const TOUCH_COMPAT_MOUSE_SUPPRESS_MS = 450;
|
||||||
|
|
||||||
function isTerminalQueryResponse(data) {
|
function isTerminalQueryResponse(data) {
|
||||||
return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data);
|
return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data);
|
||||||
@@ -46,6 +50,7 @@
|
|||||||
isTerminalQueryResponse,
|
isTerminalQueryResponse,
|
||||||
shouldSuppressTerminalQueryResponse,
|
shouldSuppressTerminalQueryResponse,
|
||||||
USER_SCROLL_STICKY_SUPPRESS_MS,
|
USER_SCROLL_STICKY_SUPPRESS_MS,
|
||||||
|
TOUCH_COMPAT_MOUSE_SUPPRESS_MS,
|
||||||
};
|
};
|
||||||
global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES;
|
global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES;
|
||||||
global.codemanCurrentXtermTheme = currentXtermTheme;
|
global.codemanCurrentXtermTheme = currentXtermTheme;
|
||||||
@@ -107,6 +112,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
const container = document.getElementById('terminalContainer');
|
const container = document.getElementById('terminalContainer');
|
||||||
this.terminal.open(container);
|
this.terminal.open(container);
|
||||||
|
this._installMobileTapMouseGuard();
|
||||||
|
|
||||||
// Suppress xterm key handling during CJK IME composition.
|
// Suppress xterm key handling during CJK IME composition.
|
||||||
// Without this, xterm processes raw keyDown events (e.g., "Process" key)
|
// Without this, xterm processes raw keyDown events (e.g., "Process" key)
|
||||||
@@ -425,16 +431,32 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
scrollFrame = requestAnimationFrame(scrollLoop);
|
scrollFrame = requestAnimationFrame(scrollLoop);
|
||||||
}
|
}
|
||||||
if (!didScroll && this.terminal) {
|
if (!didScroll && this.terminal) {
|
||||||
|
// ── Tap-to-position cursor ──────────────────────────────────
|
||||||
|
// Synthesize a click from the real touch point so the foreground app
|
||||||
|
// moves its cursor to the tapped cell (iOS doesn't reliably do this
|
||||||
|
// itself under touch-action:none). CRITICAL: only when mouse tracking
|
||||||
|
// is ON. xterm disables its local SelectionService while mouse events
|
||||||
|
// are active, so the synthetic click is forwarded to the PTY as an SGR
|
||||||
|
// report (cursor moves). But when tracking is OFF, that same click
|
||||||
|
// drives xterm's LOCAL selection (detail 1/2/3 → char/word/line) — a
|
||||||
|
// tap on CJK text would select & copy it instead of positioning. So
|
||||||
|
// gate strictly on the live mouse-tracking mode.
|
||||||
|
const touch = ev.changedTouches && ev.changedTouches[0];
|
||||||
|
const mouseMode = this.terminal.modes?.mouseTrackingMode;
|
||||||
|
const mouseTrackingOn = !!mouseMode && mouseMode !== 'none';
|
||||||
|
if (touch) {
|
||||||
|
this._suppressTrustedTapMouseEvents();
|
||||||
|
}
|
||||||
|
if (touch && mouseTrackingOn) {
|
||||||
|
this._dispatchSyntheticTerminalClick(touch.clientX, touch.clientY);
|
||||||
|
}
|
||||||
|
this._syncMobileHelperTextareaToCursor();
|
||||||
|
// Route subsequent typing to the right place: keep the CJK input
|
||||||
|
// field focused when Chinese input is on, otherwise the terminal.
|
||||||
const cjkInput = document.getElementById('cjkInput');
|
const cjkInput = document.getElementById('cjkInput');
|
||||||
if (cjkInput?.classList.contains('cjk-input-visible')) {
|
if (cjkInput?.classList.contains('cjk-input-visible')) {
|
||||||
cjkInput.focus();
|
cjkInput.focus();
|
||||||
} else {
|
} 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();
|
this.terminal.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -596,8 +618,13 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// survives tab switches and reconnects.
|
// survives tab switches and reconnects.
|
||||||
|
|
||||||
this.terminal.onData((data) => {
|
this.terminal.onData((data) => {
|
||||||
// CJK input has focus — block xterm from sending to PTY
|
// Mouse SGR reports (tap-to-position) are NOT IME input — they must reach
|
||||||
if (window.cjkActive || document.activeElement?.id === 'cjkInput') return;
|
// the PTY even while the CJK input field owns focus. Without this exception
|
||||||
|
// tapping to move the cursor silently does nothing whenever Chinese input
|
||||||
|
// is on, because cjkActive stays true the whole time the field is visible.
|
||||||
|
const isMouseReport = /^\x1b\[<\d+;\d+;\d+[Mm]$/.test(data);
|
||||||
|
// CJK input has focus — block xterm from sending keystrokes to PTY
|
||||||
|
if (!isMouseReport && (window.cjkActive || document.activeElement?.id === 'cjkInput')) return;
|
||||||
if (this.activeSessionId) {
|
if (this.activeSessionId) {
|
||||||
// Filter terminal query replies generated by xterm.js itself.
|
// Filter terminal query replies generated by xterm.js itself.
|
||||||
// Forwarding them through the WebSocket injects DA/DSR/CPR replies
|
// Forwarding them through the WebSocket injects DA/DSR/CPR replies
|
||||||
@@ -2005,6 +2032,62 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
} catch {}
|
} catch {}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// Synthetic tap → mouse report
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// Dispatch a mousedown+mouseup pair at viewport coords (clientX/clientY) to
|
||||||
|
// xterm's root element. xterm's mouse-reporting handler reads the event's
|
||||||
|
// client coords, maps them to a terminal cell relative to .xterm-screen, and
|
||||||
|
// — when the foreground app has mouse tracking active (DECSET 1000/1002/1006,
|
||||||
|
// which Claude's input enables) — encodes an SGR mouse report to the PTY.
|
||||||
|
// That is the same path a real desktop click takes; on touch devices the
|
||||||
|
// browser's own compatibility-event synthesis is unreliable (and suppressed
|
||||||
|
// by touch-action:none), so we drive it explicitly. With mouse tracking off
|
||||||
|
// it degrades to a harmless zero-length click (no drag → no text selection).
|
||||||
|
_dispatchSyntheticTerminalClick(clientX, clientY) {
|
||||||
|
const el = this.terminal?.element;
|
||||||
|
if (!el || !Number.isFinite(clientX) || !Number.isFinite(clientY)) return;
|
||||||
|
// xterm registers its mouseup listener on document during mousedown, so a
|
||||||
|
// bubbling mouseup reaches it; dispatch both to the root element in order.
|
||||||
|
const base = {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
view: window,
|
||||||
|
clientX,
|
||||||
|
clientY,
|
||||||
|
screenX: clientX,
|
||||||
|
screenY: clientY,
|
||||||
|
button: 0,
|
||||||
|
detail: 1,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
el.dispatchEvent(new MouseEvent('mousedown', { ...base, buttons: 1 }));
|
||||||
|
el.dispatchEvent(new MouseEvent('mouseup', { ...base, buttons: 0 }));
|
||||||
|
} catch {
|
||||||
|
/* MouseEvent constructor unavailable — tap-to-position simply no-ops */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_installMobileTapMouseGuard() {
|
||||||
|
const el = this.terminal?.element;
|
||||||
|
if (!el || el._codemanTapMouseGuardInstalled) return;
|
||||||
|
if (typeof MobileDetection !== 'undefined' && MobileDetection.isTouchDevice && !MobileDetection.isTouchDevice()) return;
|
||||||
|
el._codemanTapMouseGuardInstalled = true;
|
||||||
|
const suppressTrustedCompatMouse = (ev) => {
|
||||||
|
const suppressUntil = this._trustedTapMouseSuppressUntil || 0;
|
||||||
|
if (!ev.isTrusted || performance.now() > suppressUntil) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopImmediatePropagation();
|
||||||
|
};
|
||||||
|
el.addEventListener('mousedown', suppressTrustedCompatMouse, true);
|
||||||
|
el.addEventListener('mouseup', suppressTrustedCompatMouse, true);
|
||||||
|
},
|
||||||
|
|
||||||
|
_suppressTrustedTapMouseEvents() {
|
||||||
|
const ms = window.CodemanTerminalInput?.TOUCH_COMPAT_MOUSE_SUPPRESS_MS || 450;
|
||||||
|
this._trustedTapMouseSuppressUntil = performance.now() + ms;
|
||||||
|
},
|
||||||
|
|
||||||
increaseFontSize() {
|
increaseFontSize() {
|
||||||
const current = this.terminal.options.fontSize || 14;
|
const current = this.terminal.options.fontSize || 14;
|
||||||
this.setFontSize(Math.min(current + 2, 24));
|
this.setFontSize(Math.min(current + 2, 24));
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
function loadTerminalUiHarness() {
|
||||||
|
const CodemanApp = function CodemanApp(this: any) {};
|
||||||
|
let now = 1_000;
|
||||||
|
const context = vm.createContext({
|
||||||
|
window: {},
|
||||||
|
CodemanApp,
|
||||||
|
console: { warn: vi.fn(), log: vi.fn() },
|
||||||
|
_crashDiag: { log: vi.fn() },
|
||||||
|
performance: { now: () => now },
|
||||||
|
requestAnimationFrame: (_fn: () => void) => 1,
|
||||||
|
setTimeout: (_fn: () => void) => 1,
|
||||||
|
Blob: function Blob() {},
|
||||||
|
URL: {
|
||||||
|
createObjectURL: () => 'blob:yield',
|
||||||
|
revokeObjectURL: () => {},
|
||||||
|
},
|
||||||
|
Worker: function Worker(this: any) {
|
||||||
|
this.postMessage = () => {};
|
||||||
|
},
|
||||||
|
MobileDetection: {
|
||||||
|
isTouchDevice: () => true,
|
||||||
|
},
|
||||||
|
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
||||||
|
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
||||||
|
});
|
||||||
|
|
||||||
|
const code = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
|
||||||
|
vm.runInContext(code, context, { filename: 'terminal-ui.js' });
|
||||||
|
|
||||||
|
const app = new (CodemanApp as any)();
|
||||||
|
return {
|
||||||
|
app,
|
||||||
|
setNow: (value: number) => {
|
||||||
|
now = value;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createElementHarness() {
|
||||||
|
const listeners = new Map<string, (ev: any) => void>();
|
||||||
|
return {
|
||||||
|
element: {
|
||||||
|
addEventListener: vi.fn((type: string, listener: (ev: any) => void) => {
|
||||||
|
listeners.set(type, listener);
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
dispatch(type: string, event: any) {
|
||||||
|
listeners.get(type)?.(event);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('terminal touch tap mouse guard', () => {
|
||||||
|
it('suppresses browser trusted compatibility mouse events during the tap window', () => {
|
||||||
|
const { app } = loadTerminalUiHarness();
|
||||||
|
const { element, dispatch } = createElementHarness();
|
||||||
|
app.terminal = { element };
|
||||||
|
|
||||||
|
app._installMobileTapMouseGuard();
|
||||||
|
app._suppressTrustedTapMouseEvents();
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
isTrusted: true,
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
stopImmediatePropagation: vi.fn(),
|
||||||
|
};
|
||||||
|
dispatch('mousedown', event);
|
||||||
|
|
||||||
|
expect(event.preventDefault).toHaveBeenCalledOnce();
|
||||||
|
expect(event.stopImmediatePropagation).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows the app synthetic mouse event through the tap window', () => {
|
||||||
|
const { app } = loadTerminalUiHarness();
|
||||||
|
const { element, dispatch } = createElementHarness();
|
||||||
|
app.terminal = { element };
|
||||||
|
|
||||||
|
app._installMobileTapMouseGuard();
|
||||||
|
app._suppressTrustedTapMouseEvents();
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
isTrusted: false,
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
stopImmediatePropagation: vi.fn(),
|
||||||
|
};
|
||||||
|
dispatch('mousedown', event);
|
||||||
|
|
||||||
|
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||||
|
expect(event.stopImmediatePropagation).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows trusted mouse events after the tap window expires', () => {
|
||||||
|
const { app, setNow } = loadTerminalUiHarness();
|
||||||
|
const { element, dispatch } = createElementHarness();
|
||||||
|
app.terminal = { element };
|
||||||
|
|
||||||
|
app._installMobileTapMouseGuard();
|
||||||
|
app._suppressTrustedTapMouseEvents();
|
||||||
|
setNow(2_000);
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
isTrusted: true,
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
stopImmediatePropagation: vi.fn(),
|
||||||
|
};
|
||||||
|
dispatch('mousedown', event);
|
||||||
|
|
||||||
|
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||||
|
expect(event.stopImmediatePropagation).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user