mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 15:39:41 +02:00
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.
261 lines
8.3 KiB
JavaScript
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);
|