Input Handling & IME

Comprehensive guide to handling user input, IME composition, keyboard events, and text processing in model-based editors.

Overview

Input handling is one of the most complex aspects of building a contenteditable editor. It involves coordinating between browser events, IME composition states, keyboard shortcuts, and your document model. This guide covers the challenges and solutions for robust input handling.

Key challenges:

  • IME composition events don't fire on iOS Safari for Korean IME
  • Keyboard handlers must allow browser default during composition
  • Paste operations can occur during active composition
  • Mobile keyboards have different behavior than desktop
  • Text prediction and autocorrect interfere with custom handlers

IME Composition Handling

Input Method Editors (IME) allow users to input complex characters (Korean, Japanese, Chinese, etc.) by composing them from simpler components. Handling IME composition correctly is critical for supporting international users.

Composition Events

The standard composition event lifecycle:

class CompositionManager {
  #isComposing = false;
  #compositionData = '';

  constructor(editor) {
    this.#editor = editor;
    this.#setupCompositionHandlers();
  }

  #setupCompositionHandlers() {
    const element = this.#editor.element;

    element.addEventListener('compositionstart', (e) => {
      this.#isComposing = true;
      this.#compositionData = '';
      // Prevent custom keyboard handlers during composition
      this.#editor.setCompositionMode(true);
    });

    element.addEventListener('compositionupdate', (e) => {
      this.#compositionData = e.data;
      // Update model with composition text
      this.#updateCompositionText(e.data);
    });

    element.addEventListener('compositionend', (e) => {
      this.#isComposing = false;
      this.#compositionData = '';
      // Commit composition to model
      this.#commitComposition(e.data);
      this.#editor.setCompositionMode(false);
    });
  }

  #updateCompositionText(data) {
    // Update model with temporary composition text
    // This text may change or be cancelled
  }

  #commitComposition(data) {
    // Finalize composition text in model
    // Replace temporary composition text with final text
  }

  get isComposing() {
    return this.#isComposing;
  }
}

iOS Safari Issue: Composition events do NOT fire for Korean IME on iOS Safari. The isComposing flag is always false.

Composition State Management

Track composition state to prevent custom handlers from interfering:

class Editor {
  #isComposing = false;
  #compositionMode = false;

  setCompositionMode(enabled) {
    this.#compositionMode = enabled;
  }

  handleKeyDown(e) {
    // Always allow browser default during composition
    if (this.#isComposing || this.#compositionMode) {
      return; // Don't prevent default
    }

    // Custom keyboard handlers
    if (e.key === 'Enter') {
      e.preventDefault();
      this.#handleEnter();
    } else if (e.key === 'Backspace') {
      e.preventDefault();
      this.#handleBackspace();
    }
  }

  // iOS Safari workaround: Always allow default for certain keys
  handleKeyDownIOS(e) {
    // For iOS Safari, always allow default for Enter/Backspace/Delete
    // because isComposing is unreliable
    if (this.#isIOS() && ['Enter', 'Backspace', 'Delete'].includes(e.key)) {
      return; // Allow browser default
    }

    // Custom handlers for other keys
    this.handleKeyDown(e);
  }
}

iOS Safari Special Cases

iOS Safari requires special handling because composition events don't fire for Korean IME:

class IOSCompositionDetector {
  #isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
  #isSafari = /Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent);
  #lastInputTime = 0;
  #inputPattern = /[가-힣]/; // Korean character pattern

  detectComposition(inputText) {
    if (!this.#isIOS || !this.#isSafari) {
      return false; // Use standard composition events
    }

    // Heuristic: If input contains Korean characters and
    // input events are firing rapidly, likely composition
    const hasKorean = this.#inputPattern.test(inputText);
    const timeSinceLastInput = Date.now() - this.#lastInputTime;
    this.#lastInputTime = Date.now();

    // If Korean input with rapid events (< 100ms), likely composition
    return hasKorean && timeSinceLastInput < 100;
  }

  // Alternative: Always allow browser default on iOS Safari
  shouldAllowDefault(key) {
    if (this.#isIOS && this.#isSafari) {
      // Always allow default for Enter/Backspace/Delete
      return ['Enter', 'Backspace', 'Delete'].includes(key);
    }
    return false;
  }
}

Keyboard Event Handling

Keyboard events must be handled carefully to support both custom shortcuts and browser default behavior during composition.

beforeinput API

The beforeinput event provides structured information about input operations:

element.addEventListener('beforeinput', (e) => {
  // Check if composition is active
  if (e.isComposing) {
    // Allow browser default during composition
    return;
  }

  // Handle different input types
  switch (e.inputType) {
    case 'insertText':
      e.preventDefault();
      this.#handleInsertText(e.data);
      break;

    case 'insertParagraph':
      e.preventDefault();
      this.#handleInsertParagraph();
      break;

    case 'deleteContentBackward':
      e.preventDefault();
      this.#handleDeleteBackward();
      break;

    case 'formatBold':
      e.preventDefault();
      this.#handleFormatBold();
      break;

    // ... other input types
  }
});

Note: isComposing in beforeinput is unreliable on iOS Safari for Korean IME. Always check composition state separately.

Keyboard Shortcuts

Handle keyboard shortcuts while respecting composition state:

class KeyboardShortcutHandler {
  handleKeyDown(e) {
    // Check for modifier keys
    const isModifier = e.ctrlKey || e.metaKey || e.altKey;

    // Don't handle shortcuts during composition
    if (this.#isComposing) {
      return;
    }

    // Handle shortcuts
    if (isModifier && e.key === 'b') {
      e.preventDefault();
      this.#toggleBold();
    } else if (isModifier && e.key === 'i') {
      e.preventDefault();
      this.#toggleItalic();
    } else if (isModifier && e.key === 'z') {
      e.preventDefault();
      if (e.shiftKey) {
        this.#redo();
      } else {
        this.#undo();
      }
    }
  }
}

Special Key Handling

Special keys (Enter, Backspace, Delete, Tab) require careful handling:

class SpecialKeyHandler {
  handleEnter(e) {
    if (this.#isComposing) {
      return; // Allow browser default
    }

    e.preventDefault();
    
    // Check if Shift is pressed
    if (e.shiftKey) {
      this.#insertLineBreak();
    } else {
      this.#insertParagraph();
    }
  }

  handleBackspace(e) {
    if (this.#isComposing) {
      return; // Allow browser default
    }

    e.preventDefault();
    
    const selection = this.#getSelection();
    if (selection.isCollapsed) {
      // Delete character before cursor
      this.#deleteBackward();
    } else {
      // Delete selection
      this.#deleteSelection();
    }
  }

  handleTab(e) {
    if (this.#isComposing) {
      return; // Allow browser default (may be used for IME)
    }

    e.preventDefault();
    
    if (e.shiftKey) {
      this.#outdent();
    } else {
      this.#indent();
    }
  }
}

Text Input Processing

Process text input and normalize it according to your document model requirements.

Input Type Detection

Different input types require different handling:

class InputProcessor {
  processInput(e) {
    switch (e.inputType) {
      case 'insertText':
        this.#handleInsertText(e.data);
        break;

      case 'insertCompositionText':
        // IME composition text
        this.#handleCompositionText(e.data);
        break;

      case 'insertFromPaste':
        // Paste operation
        this.#handlePaste();
        break;

      case 'insertFromDrop':
        // Drag and drop
        this.#handleDrop();
        break;

      case 'insertFromPredictiveText':
        // Mobile text prediction
        this.#handlePredictiveText(e.data);
        break;

      default:
        // Unknown input type
        this.#handleUnknownInput(e);
    }
  }
}

Text Normalization

Normalize text input to match your model's requirements:

class TextNormalizer {
  normalize(text) {
    // Remove zero-width characters
    text = text.replace(/[​-‍]/g, '');

    // Normalize line breaks
    text = text.replace(/\r\n/g, '\n');
    text = text.replace(/\r/g, '\n');

    // Normalize whitespace (optional, depends on requirements)
    // text = text.replace(/[ \t]+/g, ' ');

    // Remove control characters (except newline, tab)
    text = text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');

    return text;
  }

  // Handle invisible characters that browsers insert
  cleanInvisibleChars(text) {
    // Zero-width non-breaking space
    text = text.replace(/\uFEFF/g, '');
    
    // Zero-width space
    text = text.replace(/\u200B/g, '');
    
    // Zero-width joiner/non-joiner
    text = text.replace(/[\u200C\u200D]/g, '');

    return text;
  }
}

Paste Operation Handling

Paste operations can include rich formatting, images, and other content that needs to be converted to your document model.

Paste Events

Handle paste events and extract clipboard data:

class PasteHandler {
  constructor(editor) {
    this.#editor = editor;
    this.#setupPasteHandlers();
  }

  #setupPasteHandlers() {
    this.#editor.element.addEventListener('paste', async (e) => {
      e.preventDefault();

      // Check if composition is active
      if (this.#editor.isComposing) {
        // Wait for composition to end
        await this.#waitForCompositionEnd();
      }

      // Get clipboard data
      const clipboardData = e.clipboardData || window.clipboardData;
      const items = clipboardData.items;

      // Process different data types
      for (const item of items) {
        if (item.type.startsWith('text/')) {
          const text = await this.#getTextFromItem(item);
          this.#handleTextPaste(text);
        } else if (item.type.startsWith('image/')) {
          const file = item.getAsFile();
          this.#handleImagePaste(file);
        }
      }
    });
  }

  async #getTextFromItem(item) {
    return new Promise((resolve) => {
      item.getAsString(resolve);
    });
  }

  #handleTextPaste(text) {
    // Convert HTML/text to model
    const model = this.#parsePastedContent(text);
    this.#editor.insertModel(model);
  }
}

Paste Filtering

Filter and sanitize pasted content:

class PasteFilter {
  filterHTML(html) {
    // Remove unwanted tags
    const allowedTags = ['p', 'br', 'strong', 'em', 'u', 'code'];
    const cleaned = this.#removeDisallowedTags(html, allowedTags);

    // Remove attributes
    const sanitized = this.#removeAttributes(cleaned);

    // Normalize structure
    const normalized = this.#normalizeStructure(sanitized);

    return normalized;
  }

  #removeDisallowedTags(html, allowed) {
    // Use DOMParser to safely parse and filter
    const parser = new DOMParser();
    const doc = parser.parseFromString(html, 'text/html');
    
    // Remove disallowed tags
    const allElements = doc.querySelectorAll('*');
    allElements.forEach((el) => {
      if (!allowed.includes(el.tagName.toLowerCase())) {
        // Replace with text content
        const text = document.createTextNode(el.textContent);
        el.parentNode?.replaceChild(text, el);
      }
    });

    return doc.body.innerHTML;
  }
}

Mobile Input Handling

Mobile devices present unique challenges with virtual keyboards, text prediction, and touch interactions.

Virtual Keyboard

Handle virtual keyboard appearance and viewport changes:

class MobileKeyboardHandler {
  constructor(editor) {
    this.#editor = editor;
    this.#setupKeyboardHandlers();
  }

  #setupKeyboardHandlers() {
    // Detect viewport resize (keyboard appearance)
    let viewportHeight = window.visualViewport?.height || window.innerHeight;
    
    window.visualViewport?.addEventListener('resize', () => {
      const newHeight = window.visualViewport.height;
      const heightDiff = viewportHeight - newHeight;

      if (heightDiff > 150) {
        // Keyboard appeared
        this.#onKeyboardShow();
      } else if (heightDiff < -150) {
        // Keyboard hidden
        this.#onKeyboardHide();
      }

      viewportHeight = newHeight;
    });
  }

  #onKeyboardShow() {
    // Scroll to keep cursor visible
    this.#scrollToCursor();
    
    // Adjust editor layout
    this.#adjustLayoutForKeyboard();
  }

  #onKeyboardHide() {
    // Restore layout
    this.#restoreLayout();
  }

  #scrollToCursor() {
    const selection = window.getSelection();
    if (selection.rangeCount === 0) return;

    const range = selection.getRangeAt(0);
    const rect = range.getBoundingClientRect();
    
    // Scroll if cursor is below visible area
    if (rect.bottom > window.visualViewport.height) {
      range.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
    }
  }
}

Text Prediction

Handle mobile text prediction and autocorrect:

class MobileTextPredictionHandler {
  handleInput(e) {
    if (e.inputType === 'insertFromPredictiveText') {
      // Mobile keyboard text prediction
      this.#handlePredictiveText(e.data);
    } else if (e.inputType === 'insertText') {
      // Check if this might be autocorrect
      if (this.#isLikelyAutocorrect(e.data)) {
        this.#handleAutocorrect(e.data);
      } else {
        this.#handleNormalInput(e.data);
      }
    }
  }

  #isLikelyAutocorrect(text) {
    // Heuristic: If text is very different from what was typed,
    // might be autocorrect
    // This is difficult to detect reliably
    return false;
  }

  // Disable autocorrect for code blocks
  disableAutocorrect(element) {
    element.setAttribute('autocorrect', 'off');
    element.setAttribute('autocapitalize', 'off');
    element.setAttribute('spellcheck', 'false');
  }
}

Edge Cases and Pitfalls

Common edge cases that can break input handling:

Composition During Paste

Paste can occur during active composition:

class CompositionPasteHandler {
  async handlePaste(e) {
    // Check if composition is active
    if (this.#isComposing) {
      // Cancel composition first
      await this.#cancelComposition();
    }

    // Then handle paste
    this.#processPaste(e);
  }

  async #cancelComposition() {
    // Force composition end
    // This may lose composition text, but paste takes priority
    const event = new CompositionEvent('compositionend', {
      bubbles: true,
      cancelable: true,
      data: ''
    });
    this.#editor.element.dispatchEvent(event);
    
    // Wait a tick for composition to fully end
    await new Promise(resolve => setTimeout(resolve, 0));
  }
}

Composition During Undo

Undo/redo can occur during composition:

class CompositionUndoHandler {
  handleUndo(e) {
    // Don't allow undo during composition
    if (this.#isComposing) {
      e.preventDefault();
      return;
    }

    // Normal undo handling
    this.#performUndo();
  }

  handleRedo(e) {
    // Don't allow redo during composition
    if (this.#isComposing) {
      e.preventDefault();
      return;
    }

    // Normal redo handling
    this.#performRedo();
  }
}

Best Practices

Key principles for robust input handling:

  • Always check composition state: Never prevent default during active composition
  • Handle iOS Safari specially: Composition events don't fire for Korean IME
  • Use beforeinput when possible: More reliable than keydown/keypress
  • Normalize text input: Remove invisible characters and normalize whitespace
  • Filter pasted content: Sanitize HTML and convert to your model format
  • Handle mobile separately: Virtual keyboards and text prediction require special handling
  • Test with real IMEs: Test with Korean, Japanese, Chinese IMEs on different platforms