/** * @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ĩ */ /** 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); }; }