Fix Japanese input duplication on iOS web (GitHub #99) (#102)

Added comprehensive IME composition support to prevent duplication of Japanese, Chinese, and Korean text input on mobile devices.

Changes:
- DirectKeyboardManager: Added composition event handlers to wait for final text
- MobileInputOverlay: Added composition state tracking to prevent intermediate updates
- Fixed null safety issues and unused parameter warnings
- Comprehensive documentation explaining IME implementation

Bug Fixed:
Previously, intermediate composition characters were sent to terminal during Japanese typing, causing duplicated text. Now properly waits for composition completion before sending final text to terminal.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Helmut Januschka 2025-06-27 21:37:43 +02:00 committed by GitHub
parent b98a8d190e
commit 6e98fa9ce6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 127 additions and 4 deletions

View file

@ -2,7 +2,29 @@
* Direct Keyboard Input Manager
*
* Manages hidden input element and direct keyboard input for mobile devices.
* Handles focus management, input events, and quick key interactions.
* Handles focus management, input events, quick key interactions, and IME composition.
*
* ## IME Support for Japanese/CJK Input
*
* This manager now includes full support for Input Method Editor (IME) composition,
* which is essential for Japanese, Chinese, and Korean text input on mobile devices.
*
* **How IME Works:**
* 1. User types "konnichiwa" on Japanese keyboard
* 2. Browser shows composition UI: "こんにちは" (intermediate characters)
* 3. User selects final text from candidates
* 4. Browser fires `compositionend` with final text: "こんにちは"
*
* **Bug Fixed (GitHub #99):**
* Previously, intermediate composition characters were sent to terminal during typing,
* causing duplicated/garbled Japanese text. Now we properly wait for composition
* completion before sending any text to the terminal.
*
* **Implementation:**
* - `compositionstart`: Sets isComposing=true, prevents input events from sending text
* - `compositionupdate`: Tracks intermediate composition (for logging/debugging)
* - `compositionend`: Sends only the final composed text to terminal
* - `input`: Skipped entirely during composition, normal handling otherwise
*/
import { createLogger } from '../../utils/logger.js';
import type { InputManager } from './input-manager.js';
@ -37,6 +59,10 @@ export class DirectKeyboardManager {
private keyboardActivationTimeout: number | null = null;
private captureClickHandler: ((e: Event) => void) | null = null;
// IME composition state tracking for Japanese/CJK input
private isComposing = false;
private compositionBuffer = '';
constructor(instanceId: string) {
this.instanceId = instanceId;
}
@ -198,15 +224,56 @@ export class DirectKeyboardManager {
// Set initial position based on mode
this.updateHiddenInputPosition();
// Handle input events
// Handle IME composition events for Japanese/CJK input
this.hiddenInput.addEventListener('compositionstart', () => {
this.isComposing = true;
this.compositionBuffer = '';
});
this.hiddenInput.addEventListener('compositionupdate', (e) => {
const compositionEvent = e as CompositionEvent;
this.compositionBuffer = compositionEvent.data || '';
});
this.hiddenInput.addEventListener('compositionend', (e) => {
const compositionEvent = e as CompositionEvent;
this.isComposing = false;
// Get the final composed text
const finalText = compositionEvent.data || this.hiddenInput?.value || '';
if (finalText) {
// Don't send input to terminal if mobile input overlay or Ctrl overlay is visible
const showMobileInput = this.callbacks?.getShowMobileInput() ?? false;
const showCtrlAlpha = this.callbacks?.getShowCtrlAlpha() ?? false;
if (!showMobileInput && !showCtrlAlpha && this.inputManager) {
// Send the completed composition to terminal
this.inputManager.sendInputText(finalText);
}
}
// Clear the input and composition buffer
if (this.hiddenInput) {
this.hiddenInput.value = '';
}
this.compositionBuffer = '';
});
// Handle input events (non-composition)
this.hiddenInput.addEventListener('input', (e) => {
const input = e.target as HTMLInputElement;
// Skip processing if we're in the middle of IME composition
if (this.isComposing) {
return;
}
if (input.value) {
// Don't send input to terminal if mobile input overlay or Ctrl overlay is visible
const showMobileInput = this.callbacks?.getShowMobileInput() ?? false;
const showCtrlAlpha = this.callbacks?.getShowCtrlAlpha() ?? false;
if (!showMobileInput && !showCtrlAlpha && this.inputManager) {
// Send each character to terminal
// Send each character to terminal (only for non-IME input)
this.inputManager.sendInputText(input.value);
}
// Always clear the input to prevent buffer buildup

View file

@ -2,7 +2,23 @@
* Mobile Input Overlay Component
*
* Full-screen overlay for mobile text input with virtual keyboard support.
* Handles text input, command sending, and keyboard height adjustments.
* Handles text input, command sending, keyboard height adjustments, and IME composition.
*
* ## IME Support for Japanese/CJK Input
*
* This overlay includes full support for Input Method Editor (IME) composition
* for Japanese, Chinese, and Korean text input on mobile devices.
*
* **Bug Fixed (GitHub #99):**
* Added proper IME composition event handling to prevent duplication of
* Japanese text during typing. The overlay now waits for composition
* completion before updating the text state.
*
* **Implementation:**
* - `compositionstart`: Sets isComposing=true, prevents input change handling
* - `compositionupdate`: Tracks intermediate composition text
* - `compositionend`: Updates text state only with final composed text
* - `input`: Skipped during composition, normal handling otherwise
*/
import { html, LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js';
@ -28,6 +44,10 @@ export class MobileInputOverlay extends LitElement {
@property({ type: Function }) onTextChange?: (text: string) => void;
@property({ type: Function }) handleBack?: () => void;
// IME composition state tracking for Japanese/CJK input
private isComposing = false;
private compositionBuffer = '';
private touchStartHandler = (e: TouchEvent) => {
const touch = e.touches[0];
this.touchStartX = touch.clientX;
@ -54,12 +74,45 @@ export class MobileInputOverlay extends LitElement {
private handleMobileInputChange(e: Event) {
const textarea = e.target as HTMLTextAreaElement;
// Skip processing if we're in the middle of IME composition
if (this.isComposing) {
return;
}
this.mobileInputText = textarea.value;
this.onTextChange?.(textarea.value);
// Force update to ensure button states update
this.requestUpdate();
}
private handleCompositionStart = (_e: CompositionEvent) => {
this.isComposing = true;
this.compositionBuffer = '';
};
private handleCompositionUpdate = (e: CompositionEvent) => {
this.compositionBuffer = e.data || '';
};
private handleCompositionEnd = (e: CompositionEvent) => {
this.isComposing = false;
// Get the final composed text
const finalText = e.data || '';
// Update the mobile input text with the final composition
const textarea = e.target as HTMLTextAreaElement;
if (textarea && finalText) {
this.mobileInputText = textarea.value;
this.onTextChange?.(textarea.value);
this.requestUpdate();
}
// Clear composition buffer
this.compositionBuffer = '';
};
private focusMobileTextarea() {
const textarea = this.querySelector('#mobile-input-textarea') as HTMLTextAreaElement;
if (!textarea) return;
@ -187,6 +240,9 @@ export class MobileInputOverlay extends LitElement {
@focus=${this.handleFocus}
@blur=${this.handleBlur}
@keydown=${this.handleKeydown}
@compositionstart=${this.handleCompositionStart}
@compositionupdate=${this.handleCompositionUpdate}
@compositionend=${this.handleCompositionEnd}
style="height: 120px; background: black; color: #d4d4d4; border: none; padding: 12px;"
autocomplete="off"
autocorrect="off"