Skip to content

Commit 3db9771

Browse files
fix: Complete Korean IME support with proper focus handling
- Remove contenteditable from container (causes IME to insert text as DOM nodes) - Set container tabindex="-1" so it's not focusable - Add focus redirection from container to textarea - Queue composition-ending key (space, period) to process after compositionend - This ensures correct character order: "세요 " instead of "세 요" Key changes: 1. input-handler.ts: Add pendingKeyAfterComposition to queue the terminating key 2. terminal.ts: Remove contenteditable, set tabindex="-1", add focus redirection Fixes Korean, Chinese, and Japanese IME input. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent efeacec commit 3db9771

2 files changed

Lines changed: 52 additions & 27 deletions

File tree

lib/input-handler.ts

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ export class InputHandler {
196196
private wheelListener: ((e: WheelEvent) => void) | null = null;
197197
private isComposing = false;
198198
private compositionJustEnded = false; // Block keydown briefly after composition ends
199+
private pendingKeyAfterComposition: string | null = null; // Key to output after composition
199200
private isDisposed = false;
200201
private mouseButtonsPressed = 0; // Track which buttons are pressed for motion reporting
201202
private lastKeyDownData: string | null = null;
@@ -371,12 +372,21 @@ export class InputHandler {
371372

372373
// Ignore keydown events during composition
373374
// Note: Some browsers send keyCode 229 for all keys during composition
374-
if (this.isComposing || event.isComposing || event.keyCode === 229) {
375+
if (event.isComposing || event.keyCode === 229) {
375376
return;
376377
}
377378

378-
// Block the key that triggered composition end (e.g., space, period)
379-
// This fixes the "세요" -> "세 요" bug where keydown fires before compositionend
379+
// If we're still in composition (our flag) but browser says composition ended,
380+
// this is the key that ended the composition (space, period, etc.).
381+
// Queue it to be processed after compositionend to maintain correct order.
382+
if (this.isComposing) {
383+
// Store the key to be processed after composition ends
384+
this.pendingKeyAfterComposition = event.key;
385+
event.preventDefault();
386+
return;
387+
}
388+
389+
// Block the key that triggered composition end if we just processed a pending key
380390
if (this.compositionJustEnded) {
381391
this.compositionJustEnded = false;
382392
return;
@@ -698,25 +708,35 @@ export class InputHandler {
698708
if (this.isDisposed) return;
699709
this.isComposing = false;
700710

701-
// Set flag to block the next keydown event (the key that triggered composition end)
702-
// This prevents "세요" becoming "세 요" when space/period is pressed
703-
this.compositionJustEnded = true;
704-
// Clear flag after a short delay to allow normal keydown processing
705-
setTimeout(() => {
706-
this.compositionJustEnded = false;
707-
}, 10);
708-
709711
const data = event.data;
710712
if (data && data.length > 0) {
711713
if (this.shouldIgnoreCompositionEnd(data)) {
712714
this.cleanupCompositionTextNodes();
715+
// Still process pending key even if composition data is ignored
716+
this.processPendingKeyAfterComposition();
713717
return;
714718
}
715719
this.onDataCallback(data);
716720
this.recordCompositionData(data);
717721
}
718722

719723
this.cleanupCompositionTextNodes();
724+
725+
// Process the key that ended composition (space, period, etc.)
726+
// This ensures correct order: composed text first, then the terminating key
727+
this.processPendingKeyAfterComposition();
728+
}
729+
730+
/**
731+
* Process the pending key that was queued during composition
732+
*/
733+
private processPendingKeyAfterComposition(): void {
734+
if (this.pendingKeyAfterComposition) {
735+
const key = this.pendingKeyAfterComposition;
736+
this.pendingKeyAfterComposition = null;
737+
// Output the key that ended composition
738+
this.onDataCallback(key);
739+
}
720740
}
721741

722742
/**

lib/terminal.ts

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -349,20 +349,15 @@ export class Terminal implements ITerminalCore {
349349
this.isOpen = true;
350350

351351
try {
352-
// Make parent focusable if it isn't already
353-
if (!parent.hasAttribute('tabindex')) {
354-
parent.setAttribute('tabindex', '0');
355-
}
352+
// Set tabindex="-1" on parent so it's not focusable via click/tab.
353+
// We want all focus to go to the hidden textarea for proper IME handling.
354+
// The textarea will handle keyboard input and composition events.
355+
parent.setAttribute('tabindex', '-1');
356356

357-
// Mark as contenteditable so browser extensions (Vimium, etc.) recognize
358-
// this as an input element and don't intercept keyboard events.
359-
parent.setAttribute('contenteditable', 'true');
360-
// Prevent actual content editing - we handle input ourselves
361-
parent.addEventListener('beforeinput', (e) => {
362-
if (e.target === parent) {
363-
e.preventDefault();
364-
}
365-
});
357+
// Note: We intentionally do NOT set contenteditable on the parent container.
358+
// Setting contenteditable causes IME (Korean, Chinese, Japanese) input to be
359+
// inserted directly into the container as text nodes, bypassing our textarea.
360+
// Instead, we use the hidden textarea for all keyboard/IME input.
366361

367362
// Add accessibility attributes for screen readers and extensions
368363
parent.setAttribute('role', 'textbox');
@@ -429,7 +424,7 @@ export class Terminal implements ITerminalCore {
429424

430425
// Focus textarea on interaction - preventDefault before focus
431426
const textarea = this.textarea;
432-
// Desktop: mousedown
427+
// Desktop: mousedown on canvas
433428
this.canvas.addEventListener('mousedown', (ev) => {
434429
ev.preventDefault();
435430
textarea.focus();
@@ -439,6 +434,17 @@ export class Terminal implements ITerminalCore {
439434
ev.preventDefault();
440435
textarea.focus();
441436
});
437+
// Redirect focus from parent container to textarea
438+
// This ensures IME composition events always go to the textarea
439+
parent.addEventListener('mousedown', (ev) => {
440+
if (ev.target === parent) {
441+
ev.preventDefault();
442+
textarea.focus();
443+
}
444+
});
445+
parent.addEventListener('focus', () => {
446+
textarea.focus();
447+
});
442448

443449
// Create renderer
444450
this.renderer = new CanvasRenderer(this.canvas, {
@@ -1242,8 +1248,7 @@ export class Terminal implements ITerminalCore {
12421248
this.element.removeEventListener('mouseleave', this.handleMouseLeave);
12431249
this.element.removeEventListener('click', this.handleClick);
12441250

1245-
// Remove contenteditable and accessibility attributes added in open()
1246-
this.element.removeAttribute('contenteditable');
1251+
// Remove accessibility attributes added in open()
12471252
this.element.removeAttribute('role');
12481253
this.element.removeAttribute('aria-label');
12491254
this.element.removeAttribute('aria-multiline');

0 commit comments

Comments
 (0)