Files
Codeman/src/web/public/mobile-ime-preview.js
T
Aamer Akhter e0542bb172 feat(terminal): preview IME composition text on iOS Safari
WebKit on iOS does not show text being composed by an IME inside the
terminal, so users type blind until it commits. Add mobile-ime-preview.js,
a visual-only controller that renders the composition in the xterm helper
layer and holds a committed chunk until local echo, parsed terminal output
or a 2s fallback shows it. Wire it into terminal-ui.js, the script order,
the build minify/hash lists and styles, with unit and wiring tests.
2026-09-26 17:06:11 -04:00

261 lines
8.3 KiB
JavaScript

/**
* @fileoverview In-terminal preview of IME composition text on iOS Safari.
*
* On iOS WebKit touch devices the text an IME is composing (Japanese, Chinese,
* Korean, dictation) is not visible inside the terminal until it commits, so
* the user types blind. The controller listens to the helper textarea's
* composition events and asks the caller to render the latest composition
* (`phase: 'provisional'`), coalesced to one render per animation frame and
* capped at 2048 characters. When xterm emits the committed text through
* onData, the caller hands it to `consumeTerminalData()`, which switches the
* preview to `phase: 'committed'` until something else shows the text: the
* local echo overlay or a prediction (`completeCommit`), authoritative
* terminal output (`noteAuthoritativeOutput`), or a 2 s fallback timer.
*
* VISUAL ONLY: the controller never sends, consumes or reorders input bytes,
* and every callback is wrapped so a failing render cannot block the wire.
* `isIosWebKitTouch()` gates creation; other platforms keep xterm's own
* composition view untouched.
*
* @dependency none (standalone IIFE; consumed by terminal-ui.js)
* @loadorder 5.52 (before app.js/terminal-ui.js, which create the controller)
*/
(function (global) {
'use strict';
const COMMITTED_VISUAL_TTL = 2000;
const PREVIEW_CAP = 2048;
const CONTROL_OR_LINE_BREAK = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
function isIosWebKitTouch(nav = navigator) {
const userAgent = String(nav && nav.userAgent ? nav.userAgent : '');
const platform = String(nav && nav.platform ? nav.platform : '');
const touchPoints = Number(nav && nav.maxTouchPoints ? nav.maxTouchPoints : 0);
const iosDevice = /iPhone|iPad|iPod/.test(userAgent);
const desktopIpad = platform === 'MacIntel' && touchPoints > 1;
return touchPoints > 0 && /AppleWebKit/.test(userAgent) && (iosDevice || desktopIpad);
}
function create(options) {
const textarea = options.textarea;
const render = typeof options.render === 'function' ? options.render : function () {};
const clear = typeof options.clear === 'function' ? options.clear : function () {};
const onCommit = typeof options.onCommit === 'function' ? options.onCommit : function () {};
const scheduleFrame = options.scheduleFrame || global.requestAnimationFrame.bind(global);
const cancelFrame = options.cancelFrame || global.cancelAnimationFrame.bind(global);
const setTimer = options.setTimer || global.setTimeout.bind(global);
const clearTimer = options.clearTimer || global.clearTimeout.bind(global);
let generation = 0;
let composing = false;
let awaitingCommit = false;
let committed = false;
let latestValue = '';
let renderPhase = null;
let frameToken = null;
let timerToken = null;
let finalizedByKeydown = false;
let destroyed = false;
let invokingClear = false;
function safely(callback, ...args) {
try {
return callback(...args);
} catch (_error) {
return undefined;
}
}
function cancelScheduledFrame() {
const token = frameToken;
frameToken = null;
if (token && token.id !== undefined) safely(cancelFrame, token.id);
}
function cancelCommittedTimer() {
const token = timerToken;
timerToken = null;
if (token && token.id !== undefined) safely(clearTimer, token.id);
}
function clearVisual() {
if (invokingClear) return;
invokingClear = true;
safely(clear);
invokingClear = false;
}
function cleanup() {
generation += 1;
cancelScheduledFrame();
cancelCommittedTimer();
composing = false;
awaitingCommit = false;
committed = false;
latestValue = '';
renderPhase = null;
finalizedByKeydown = false;
clearVisual();
}
function scheduleLatestPreview(phase) {
if (destroyed) return;
renderPhase = phase;
if (frameToken) return;
const token = { generation, id: undefined };
frameToken = token;
const callback = function () {
if (destroyed || frameToken !== token || token.generation !== generation || renderPhase === null) return;
frameToken = null;
const value = latestValue.slice(0, PREVIEW_CAP);
const phaseToRender = renderPhase;
safely(render, { text: value, phase: phaseToRender });
};
const id = safely(scheduleFrame, callback);
if (frameToken === token) {
if (id === undefined) frameToken = null;
else token.id = id;
}
}
function beginComposition() {
cleanup();
if (destroyed) return;
composing = true;
}
function updateComposition(event) {
if (!composing) return;
latestValue = event.data == null ? '' : String(event.data);
scheduleLatestPreview('provisional');
}
function onComposingInput(event) {
if (!event.isComposing) return;
updateComposition({ data: event.data == null ? textarea.value : event.data });
}
function finalizeComposition(value, fromKeydown) {
if (!composing) return;
composing = false;
awaitingCommit = true;
committed = false;
finalizedByKeydown = fromKeydown;
latestValue = value == null ? latestValue : String(value);
scheduleLatestPreview('provisional');
}
function onCompositionEnd(event) {
if (finalizedByKeydown) {
finalizedByKeydown = false;
return;
}
finalizeComposition(event.data, false);
}
function onKeydown(event) {
if (composing && event.isComposing === false && event.key !== 'Process' && event.key !== 'Unidentified') {
finalizeComposition(latestValue, true);
}
}
function reset() {
if (destroyed) return;
cleanup();
}
function consumeTerminalData(data) {
if (
destroyed ||
!awaitingCommit ||
typeof data !== 'string' ||
data.length === 0 ||
CONTROL_OR_LINE_BREAK.test(data)
) {
return false;
}
generation += 1;
const owner = generation;
cancelScheduledFrame();
cancelCommittedTimer();
composing = false;
awaitingCommit = false;
committed = true;
latestValue = data;
renderPhase = 'committed';
safely(onCommit, data);
if (destroyed || generation !== owner || !committed) return true;
scheduleLatestPreview('committed');
if (destroyed || generation !== owner || !committed) return true;
const token = { generation, id: undefined };
timerToken = token;
const callback = function () {
if (destroyed || timerToken !== token || token.generation !== generation || !committed) return;
timerToken = null;
cleanup();
};
const id = safely(setTimer, callback, COMMITTED_VISUAL_TTL);
if (timerToken === token) {
if (id === undefined) {
timerToken = null;
if (!destroyed && generation === owner && committed) cleanup();
} else {
token.id = id;
}
}
return true;
}
function completeCommit(result) {
if (destroyed || !result || result.predicted !== true || !committed) return;
cleanup();
}
function noteAuthoritativeOutput() {
if (destroyed || !committed) return;
cleanup();
}
const listeners = [
['compositionstart', beginComposition],
['compositionupdate', updateComposition],
['input', onComposingInput],
['compositionend', onCompositionEnd],
['keydown', onKeydown, true],
['blur', reset],
];
for (const [type, listener, capture] of listeners) textarea.addEventListener(type, listener, capture);
function destroy() {
if (destroyed) return;
destroyed = true;
for (const [type, listener, capture] of listeners) textarea.removeEventListener(type, listener, capture);
cleanup();
}
return {
consumeTerminalData,
completeCommit,
noteAuthoritativeOutput,
reset,
destroy,
get state() {
return {
generation,
composing,
awaitingCommit,
committed,
latest: latestValue,
framePending: frameToken !== null,
timerPending: timerToken !== null,
};
},
};
}
global.MobileImePreview = { create, isIosWebKitTouch };
})(globalThis);