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
Related Pages
Editor Architecture
Overview of editor architecture patterns
Model-DOM Synchronization
Synchronizing model and DOM
Plugin Development
Plugin development guide
Testing Strategies
Testing strategies for editors
Debugging Techniques
Debugging strategies
Mobile Support
Mobile support guide
Accessibility
Accessibility best practices