mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-07 16:09:43 +02:00
Every message typed on an Android phone lost its last character. An Android soft keyboard commits the last typed character and sends the Enter key in ONE InputConnection transaction, so the committed-text `input` event and the Enter keydown are both processed before any zero-delay timer runs. The orphaned-input recovery from #388 resolved its candidate only on such a timer, and that lost the character twice over: * ORDER — xterm emits `\r` synchronously from the Enter keydown, and the local-echo composer submits `pendingText` right there. The recovered character arrived one macrotask too late to be part of the prompt. * LOSS — that same `\r` bumps the canonical counter, so by the time the candidate resolved, `canonicalCount > snapshot` read as "xterm spoke for this keystroke" and stood the recovery down. The character was not merely late, it was dropped. Drain pending candidates synchronously at the next keydown instead, from xterm's custom key handler, which runs before xterm processes that key. The counter then still holds the value it had while the candidate's own keystroke was current, so the stand-down decision is made against the right keystroke, and the recovered byte reaches the composer ahead of whatever the new key emits. The timer stays as the fallback for a keystroke with no key after it. Physical keyboards are unaffected: there the timer has already resolved the candidate long before the next key arrives. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
233 lines
9.7 KiB
JavaScript
233 lines
9.7 KiB
JavaScript
/**
|
|
* @fileoverview Orphaned-input forwarder for xterm's helper textarea.
|
|
*
|
|
* xterm's `CoreBrowserTerminal._inputEvent` only forwards an `insertText`
|
|
* input event while `(!ev.composed || !this._keyDownSeen)` holds. A soft
|
|
* keyboard that delivers a `composed: true` input event after a keydown fails
|
|
* that guard, so xterm returns without emitting and the committed character is
|
|
* silently dropped.
|
|
*
|
|
* ⚠ The gap is NARROWER than "keyCode 229", and assuming otherwise produces a
|
|
* controller that looks useful while doing nothing. For a keydown that really
|
|
* does report `keyCode: 229`, xterm ALREADY self-rescues: `CompositionHelper
|
|
* .keydown()` calls `_handleAnyTextareaChanges()`, which snapshots
|
|
* `textarea.value` and diffs it on a 0 ms timer, emitting the difference
|
|
* itself. Measured in headless chromium against a real terminal: for a 229
|
|
* keydown xterm emits and this controller correctly stands down. What is left
|
|
* unrescued is a refused `insertText` where NO 229 diff was scheduled — that is
|
|
* the case this module exists for, and the case its browser test asserts by
|
|
* checking WHO delivered the byte rather than merely that one arrived.
|
|
*
|
|
* The recovery never guesses the character: the `input` event already carries
|
|
* the real committed text in `ev.data`, which is exactly what xterm itself
|
|
* would have forwarded. We only decide WHETHER to forward it, by asking
|
|
* whether xterm produced any canonical data since the keydown that started the
|
|
* keystroke. That snapshot must be taken at KEYDOWN, not at the input event:
|
|
* xterm's `_keyPress` emits and sets `_keyPressHandled` before `input` fires,
|
|
* so a snapshot read at input time would already contain that emission and the
|
|
* character would be delivered twice.
|
|
*
|
|
* Listener registration is load-bearing, in BOTH phase and order. xterm
|
|
* registers its own `input` listener in `terminal.open()` with `capture:
|
|
* true`, and ours is added afterwards, so at-target it runs second. It must
|
|
* also be a CAPTURE listener; see the measured table at the addEventListener
|
|
* call below.
|
|
*
|
|
* @dependency none (standalone IIFE; consumed by terminal-ui.js)
|
|
* @loadorder 5.55 (before app.js/terminal-ui.js, which create the controller)
|
|
*/
|
|
(function (global) {
|
|
'use strict';
|
|
|
|
function create(options) {
|
|
const textarea = options?.textarea;
|
|
const emitRecovered = options?.emitRecovered;
|
|
if (!textarea?.addEventListener || !textarea?.removeEventListener || typeof emitRecovered !== 'function') {
|
|
return null;
|
|
}
|
|
|
|
const isScreenReaderMode = options.isScreenReaderMode;
|
|
const setTimer = options.setTimer || global.setTimeout.bind(global);
|
|
const clearTimer = options.clearTimer || global.clearTimeout.bind(global);
|
|
|
|
let destroyed = false;
|
|
// Number of canonical data events xterm has emitted, bumped by the caller's
|
|
// onData hook. Only its ORDER relative to a keydown matters.
|
|
let canonicalCount = 0;
|
|
// ⚠️ 0, never null. With `null` the `?? canonicalCount` fallback at the input
|
|
// event reads a count xterm has ALREADY bumped: on a fresh page load with no
|
|
// keydown yet (dictation, Android voice typing, any `insertText` with no key
|
|
// held) xterm's own capture listener runs first, forwards the text itself and
|
|
// bumps the counter, then this snapshot equals it, `count > snapshot` is false,
|
|
// and the text is emitted a SECOND time. A baseline of 0 makes that comparison
|
|
// true and stands the recovery down, which restores this file's invariant: a
|
|
// missed recovery is acceptable, a duplicated keystroke is not.
|
|
let keydownSnapshot = 0;
|
|
let composing = false;
|
|
const pending = [];
|
|
|
|
/**
|
|
* Resolve every candidate still pending, right now, instead of waiting for
|
|
* its zero-delay timer.
|
|
*
|
|
* Android soft keyboards commit the last character and send the Enter key
|
|
* in ONE InputConnection transaction: the `input` event and the Enter
|
|
* keydown are both processed before any timer runs. Left on its timer the
|
|
* candidate lost BOTH ways — xterm emits '\r' synchronously from the Enter
|
|
* keydown (so the local-echo composer submitted the prompt without the
|
|
* character), and that '\r' bumps `canonicalCount`, so the candidate then
|
|
* read "xterm spoke for this keystroke" and stood down, dropping the
|
|
* character outright. That is the "every message loses its last character"
|
|
* report from phones.
|
|
*
|
|
* Draining at the next keydown is correct on both counts: the counter still
|
|
* holds the value it had while this candidate's keystroke was current, and
|
|
* the byte reaches the composer ahead of whatever the new key emits.
|
|
*/
|
|
function flushPending() {
|
|
for (const candidate of pending.splice(0)) {
|
|
if (candidate.timer !== null) {
|
|
try {
|
|
clearTimer(candidate.timer);
|
|
} catch {
|
|
// A broken timer host must not break input handling.
|
|
}
|
|
candidate.timer = null;
|
|
}
|
|
resolveCandidate(candidate);
|
|
}
|
|
}
|
|
|
|
function cancelPending() {
|
|
for (const candidate of pending.splice(0)) {
|
|
candidate.active = false;
|
|
if (candidate.timer !== null) {
|
|
try {
|
|
clearTimer(candidate.timer);
|
|
} catch {
|
|
// A broken timer host must not break input handling.
|
|
}
|
|
candidate.timer = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
function resolveCandidate(candidate) {
|
|
const index = pending.indexOf(candidate);
|
|
if (index !== -1) pending.splice(index, 1);
|
|
candidate.timer = null;
|
|
if (!candidate.active || destroyed) return;
|
|
candidate.active = false;
|
|
// xterm (or its keypress path) spoke for this keystroke — it is already
|
|
// on its way to the PTY, so there is nothing to recover.
|
|
if (canonicalCount > candidate.snapshot) return;
|
|
try {
|
|
emitRecovered(candidate.data);
|
|
} catch {
|
|
// Recovery is best effort; a failed delivery must never throw into the
|
|
// browser's input handling.
|
|
}
|
|
}
|
|
|
|
/** Called from xterm's onData hook: xterm produced canonical data. */
|
|
function notifyCanonicalData() {
|
|
canonicalCount += 1;
|
|
}
|
|
|
|
/**
|
|
* Snapshot the canonical counter at every keydown. This deliberately reads
|
|
* NOTHING else off the event — not `key`, not `keyCode`. Gating it on
|
|
* keyCode 229 would make the recovery inert on exactly the devices it
|
|
* exists for, whose keydowns report `key: 'Unidentified'`. It is a single
|
|
* assignment, so running it for every keydown costs nothing.
|
|
*/
|
|
function handleKeyEvent(event) {
|
|
if (destroyed || event?.type !== 'keydown') return;
|
|
// Settle the PREVIOUS keystroke before this one can move the counter or
|
|
// reach the PTY — see flushPending(). This runs from xterm's custom key
|
|
// handler, i.e. before xterm processes the key, so a recovered character
|
|
// is always ordered ahead of the bytes this keydown produces.
|
|
flushPending();
|
|
keydownSnapshot = canonicalCount;
|
|
}
|
|
|
|
function onInput(event) {
|
|
if (destroyed || composing || event?.isComposing) return;
|
|
if (event.inputType !== 'insertText') return;
|
|
const data = event.data;
|
|
if (typeof data !== 'string' || data === '') return;
|
|
try {
|
|
if (isScreenReaderMode?.()) return;
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
const candidate = {
|
|
data,
|
|
snapshot: keydownSnapshot ?? canonicalCount,
|
|
active: true,
|
|
timer: null,
|
|
};
|
|
pending.push(candidate);
|
|
try {
|
|
candidate.timer = setTimer(() => resolveCandidate(candidate), 0);
|
|
} catch {
|
|
cancelPending();
|
|
}
|
|
}
|
|
|
|
function onCompositionStart() {
|
|
if (destroyed) return;
|
|
composing = true;
|
|
cancelPending();
|
|
}
|
|
|
|
function onCompositionEnd() {
|
|
if (destroyed) return;
|
|
composing = false;
|
|
}
|
|
|
|
function destroy() {
|
|
if (destroyed) return;
|
|
destroyed = true;
|
|
cancelPending();
|
|
try {
|
|
textarea.removeEventListener('input', onInput, true);
|
|
textarea.removeEventListener('compositionstart', onCompositionStart, true);
|
|
textarea.removeEventListener('compositionend', onCompositionEnd, true);
|
|
} catch {
|
|
// Teardown is best effort; the terminal is being replaced anyway.
|
|
}
|
|
}
|
|
|
|
// capture: true, not bubble. The target (the textarea) is visited TWICE in
|
|
// the event path, so a capture-phase listener on it calling
|
|
// stopPropagation() still stops later BUBBLE-phase listeners on that same
|
|
// target. xterm's `_inputEvent` calls `this.cancel(ev)` (preventDefault +
|
|
// stopPropagation) exactly in the branch where it HANDLED the input, so on
|
|
// bubble we would never see handled events — and whether we saw them at
|
|
// all would hang off xterm's `options.cancelEvents`, which Codeman does not
|
|
// set. Measured (jsdom and headless chromium agree):
|
|
//
|
|
// capture-then-BUBBLE, no stop: xterm -> ours
|
|
// capture-then-BUBBLE, stopPropagation: xterm (ours never fires)
|
|
// capture-then-CAPTURE, no stop: xterm -> ours
|
|
// capture-then-CAPTURE, stopPropagation: xterm -> ours (still fires)
|
|
//
|
|
// On capture we therefore observe EVERY input event uniformly, and the
|
|
// canonicalCount snapshot alone decides whether to forward.
|
|
try {
|
|
textarea.addEventListener('input', onInput, true);
|
|
textarea.addEventListener('compositionstart', onCompositionStart, true);
|
|
textarea.addEventListener('compositionend', onCompositionEnd, true);
|
|
} catch {
|
|
destroy();
|
|
return null;
|
|
}
|
|
|
|
return Object.freeze({ handleKeyEvent, notifyCanonicalData, destroy });
|
|
}
|
|
|
|
global.CodemanKeyCode229Recovery = Object.freeze({ create });
|
|
})(typeof window !== 'undefined' ? window : globalThis);
|