69a2c37cd2
macOS's own Vietnamese Telex types into xterm's textarea with no composition events: a letter is an `insertText`, every later key of the word an `insertReplacementText`, each keydown (keyCode 229) arriving after the change. v2.7.4 forwarded the replacements, but a capital typed with Shift still vanished — xterm drops any `input` that lands while a key is held — and its 229 fallback could resend an edit. Replayed from a real Chrome trace, "Abc xin chào" reached the pane as " x c". lib/imeInput.ts now owns those edits: capture phase on xterm's element, each one sent as DELs plus the new tail, the IME's input and 229 keydowns hidden from xterm. Keys xterm sends from keypress (Space) and real compositions (CJK) stay xterm's. Verified against xterm 5.5 in headless Chrome: Telex → "Abc xin chào"; a plain keyboard sends the same bytes as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
91 lines
3.7 KiB
TypeScript
91 lines
3.7 KiB
TypeScript
/**
|
|
* @file imeInput.ts
|
|
* @description Typing through an input method that edits xterm's textarea
|
|
* without composition events — macOS's Vietnamese Telex, in Chrome and
|
|
* Safari, writes each letter with `insertText` and rewrites the whole word
|
|
* with `insertReplacementText` ("cha" → "chao" → "chào"), its keydown
|
|
* (keyCode 229) arriving only AFTER the change. xterm 5.5 drops every
|
|
* replacement (xterm.js #5887) and every `input` that lands while a key is
|
|
* held (Shift, for a capital), so "Abc xin chào" reached the pane as " x c".
|
|
* `attachImeInput` turns each such change into what a line editor needs — a
|
|
* backspace per replaced character, then the new tail — and hides those
|
|
* events from xterm so nothing is sent twice. A key xterm sends itself (its
|
|
* `keypress`, e.g. Space) and a real composition (CJK) stay xterm's.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
/** DEL, the byte a terminal's Backspace sends (xterm's too). */
|
|
const DEL = "\x7f";
|
|
|
|
/** The edits this module answers; anything else stays xterm's. */
|
|
const EDITS = new Set(["insertText", "insertReplacementText", "deleteContentBackward"]);
|
|
|
|
/** The backspaces and new tail that turn `before` into `after` — per
|
|
* character (code point), as the pane's line editor counts them. */
|
|
export function textEdit(before: string, after: string): string {
|
|
const a = Array.from(before);
|
|
const b = Array.from(after);
|
|
let same = 0;
|
|
while (same < a.length && same < b.length && a[same] === b[same]) same++;
|
|
return DEL.repeat(a.length - same) + b.slice(same).join("");
|
|
}
|
|
|
|
/** The parts of an xterm `Terminal` this needs. */
|
|
interface ImeTerminal {
|
|
element?: HTMLElement;
|
|
textarea?: HTMLTextAreaElement;
|
|
input: (data: string, wasUserInput?: boolean) => void;
|
|
}
|
|
|
|
/** Sends `term`'s non-composition textarea edits to the pane itself; returns
|
|
* its dispose. Listens in the capture phase on xterm's element, so it runs
|
|
* before xterm's own listeners on the textarea and can keep them from it. */
|
|
export function attachImeInput(term: ImeTerminal): () => void {
|
|
const root = term.element;
|
|
const ta = term.textarea;
|
|
if (!root || !ta) return () => {};
|
|
// The value before the edit in flight (from `beforeinput`).
|
|
let before: string | null = null;
|
|
// xterm sent this key from its keypress: the input event that follows is its.
|
|
let pressed = false;
|
|
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
if (e.target !== ta) return;
|
|
pressed = false;
|
|
// The IME's own key, its edit already in (or about to come as) an input
|
|
// event: xterm's fallback would diff the textarea and send it again.
|
|
if (e.keyCode === 229 && !e.isComposing) e.stopPropagation();
|
|
};
|
|
const onKeyPress = (e: KeyboardEvent) => {
|
|
if (e.target === ta) pressed = true;
|
|
};
|
|
const onBeforeInput = (e: Event) => {
|
|
if (e.target === ta) before = ta.value;
|
|
};
|
|
const onInput = (e: Event) => {
|
|
const ev = e as InputEvent;
|
|
if (ev.target !== ta) return;
|
|
const was = before;
|
|
before = null;
|
|
if (pressed) {
|
|
pressed = false;
|
|
return;
|
|
}
|
|
if (ev.isComposing || !EDITS.has(ev.inputType) || was === null) return;
|
|
e.stopPropagation();
|
|
const data = textEdit(was, ta.value);
|
|
if (data) term.input(data, true);
|
|
};
|
|
|
|
root.addEventListener("keydown", onKeyDown, true);
|
|
root.addEventListener("keypress", onKeyPress, true);
|
|
root.addEventListener("beforeinput", onBeforeInput, true);
|
|
root.addEventListener("input", onInput, true);
|
|
return () => {
|
|
root.removeEventListener("keydown", onKeyDown, true);
|
|
root.removeEventListener("keypress", onKeyPress, true);
|
|
root.removeEventListener("beforeinput", onBeforeInput, true);
|
|
root.removeEventListener("input", onInput, true);
|
|
};
|
|
}
|