Overview
This guide provides practical code patterns for common rich text editing operations. These patterns handle edge cases, browser differences, and IME composition issues that you'll encounter when building contenteditable-based editors.
Format Toggle Pattern
Toggle formatting (bold, italic, etc.) on selected text. Handles both collapsed and non-collapsed selections.
// Toggle formatting (bold, italic, etc.)
function toggleFormat(inputType) {
const selection = window.getSelection();
if (selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
if (range.collapsed) {
// Collapsed selection: toggle state for next character
// Store formatting intent (implementation depends on your editor)
return;
}
// Non-collapsed: apply/remove formatting
const isFormatted = checkIfFormatted(range);
if (isFormatted) {
removeFormatting(range);
} else {
applyFormatting(range, inputType);
}
}
element.addEventListener('beforeinput', (e) => {
if (e.inputType === 'formatBold' || e.inputType === 'formatItalic') {
e.preventDefault();
toggleFormat(e.inputType);
}
});Key points:
- Check if selection is collapsed (cursor only) vs non-collapsed (text selected)
- For collapsed selections, you may want to toggle a "formatting state" for the next character
- For non-collapsed selections, apply or remove formatting from the selected range
- Always use
preventDefault()inbeforeinputto prevent browser's default behavior
Insert Text Pattern
Insert text at the current selection, replacing any selected content.
// Insert text at selection
function insertText(text) {
const selection = window.getSelection();
if (selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
// Delete selected content if any
if (!range.collapsed) {
range.deleteContents();
}
// Insert text node
const textNode = document.createTextNode(text);
range.insertNode(textNode);
// Move cursor after inserted text
range.setStartAfter(textNode);
range.collapse(true);
// Update selection
selection.removeAllRanges();
selection.addRange(range);
}
element.addEventListener('beforeinput', (e) => {
if (e.inputType === 'insertText' && e.data) {
e.preventDefault();
insertText(e.data);
}
});Wrap Selection Pattern
Wrap selected text in an element (e.g., wrap in <strong> or <a>).
// Wrap selected text in an element
function wrapSelection(tagName, attributes = {}) {
const selection = window.getSelection();
if (selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
if (range.collapsed) return; // Nothing to wrap
try {
// Extract selected content
const contents = range.extractContents();
// Create wrapper element
const wrapper = document.createElement(tagName);
Object.entries(attributes).forEach(([key, value]) => {
wrapper.setAttribute(key, value);
});
// Wrap content
wrapper.appendChild(contents);
// Insert wrapper
range.insertNode(wrapper);
// Update selection to wrapper
selection.removeAllRanges();
const newRange = document.createRange();
newRange.selectNode(wrapper);
selection.addRange(newRange);
} catch (e) {
console.error('Failed to wrap selection:', e);
}
}
// Usage: wrapSelection('strong') or wrapSelection('a', { href: 'https://example.com' })Error handling: Always wrap Range operations in try-catch blocks. Invalid ranges or DOM mutations can cause errors.
Normalize Selection Pattern
Normalize selection to avoid partial node selections that can cause issues.
// Normalize selection to avoid partial node selections
function normalizeSelection() {
const selection = window.getSelection();
if (selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
// If start is in middle of text node, expand to start of node
if (range.startContainer.nodeType === Node.TEXT_NODE) {
if (range.startOffset > 0) {
// Check if we should expand to include full node
// (This is a simplified example)
}
}
// If end is in middle of text node, expand to end of node
if (range.endContainer.nodeType === Node.TEXT_NODE) {
if (range.endOffset < range.endContainer.textContent.length) {
// Check if we should expand to include full node
}
}
}Selection normalization helps ensure consistent behavior when applying formatting or other operations. You may want to expand selections to include full nodes in some cases, or collapse to specific boundaries in others.
IME Composition Handling
Properly handle IME composition to avoid breaking user input during text composition.
// Handle IME composition properly
let compositionState = {
isComposing: false,
pendingFormat: null
};
element.addEventListener('compositionstart', () => {
compositionState.isComposing = true;
});
element.addEventListener('compositionend', () => {
compositionState.isComposing = false;
// Apply any pending formatting
if (compositionState.pendingFormat) {
applyFormatting(compositionState.pendingFormat);
compositionState.pendingFormat = null;
}
});
element.addEventListener('beforeinput', (e) => {
if (compositionState.isComposing) {
// During composition, some formatting may not work
if (e.inputType === 'formatBold' || e.inputType === 'formatItalic') {
e.preventDefault();
// Store intent to apply after composition
compositionState.pendingFormat = e.inputType;
}
return;
}
// Normal formatting handling
});⚠️ Critical: Composition State
Always check composition state:
- Formatting operations may not work during IME composition
- Store formatting intent and apply after composition ends
- Never modify DOM during active composition
- Check
e.isComposingflag in event handlers
Undo/Redo Implementation
Implement custom undo/redo stack when using preventDefault().
// Implement undo/redo stack
class UndoRedoStack {
constructor() {
this.undoStack = [];
this.redoStack = [];
this.maxSize = 50;
}
push(state) {
this.undoStack.push(state);
if (this.undoStack.length > this.maxSize) {
this.undoStack.shift();
}
this.redoStack = []; // Clear redo when new action
}
undo() {
if (this.undoStack.length === 0) return null;
const state = this.undoStack.pop();
this.redoStack.push(state);
return this.undoStack[this.undoStack.length - 1] || null;
}
redo() {
if (this.redoStack.length === 0) return null;
const state = this.redoStack.pop();
this.undoStack.push(state);
return state;
}
}
const undoRedo = new UndoRedoStack();
// Save state before changes
element.addEventListener('beforeinput', (e) => {
const currentState = element.innerHTML;
undoRedo.push(currentState);
});
// Handle undo/redo
element.addEventListener('beforeinput', (e) => {
if (e.inputType === 'historyUndo') {
e.preventDefault();
const state = undoRedo.undo();
if (state) {
element.innerHTML = state;
}
} else if (e.inputType === 'historyRedo') {
e.preventDefault();
const state = undoRedo.redo();
if (state) {
element.innerHTML = state;
}
}
});
When you use preventDefault(), the browser's native undo stack may not work correctly. You need to maintain your own undo/redo stack.
Paste Handler with Sanitization
Handle paste events with HTML sanitization to prevent XSS attacks.
// Handle paste with sanitization
element.addEventListener('paste', (e) => {
e.preventDefault();
const clipboardData = e.clipboardData || window.clipboardData;
const html = clipboardData.getData('text/html');
const text = clipboardData.getData('text/plain');
const selection = window.getSelection();
if (selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
// Delete selected content
if (!range.collapsed) {
range.deleteContents();
}
// Sanitize and insert HTML
if (html) {
const sanitized = sanitizeHTML(html);
const tempDiv = document.createElement('div');
tempDiv.innerHTML = sanitized;
const fragment = document.createDocumentFragment();
while (tempDiv.firstChild) {
fragment.appendChild(tempDiv.firstChild);
}
range.insertNode(fragment);
} else if (text) {
const textNode = document.createTextNode(text);
range.insertNode(textNode);
}
// Move cursor after inserted content
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
});⚠️ Security: Always Sanitize
Never trust pasted HTML: Always sanitize HTML content before inserting it into the DOM. Use a library like DOMPurify or implement your own sanitization logic.
Selection Change Tracking
Track selection changes to update UI (e.g., formatting toolbar) based on current selection.
// Track selection changes
let lastSelection = null;
document.addEventListener('selectionchange', () => {
const selection = window.getSelection();
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
// Only update if selection actually changed
if (!lastSelection ||
lastSelection.startContainer !== range.startContainer ||
lastSelection.startOffset !== range.startOffset ||
lastSelection.endContainer !== range.endContainer ||
lastSelection.endOffset !== range.endOffset) {
// Selection changed
onSelectionChange(range);
lastSelection = range.cloneRange();
}
} else {
lastSelection = null;
}
});
function onSelectionChange(range) {
// Update UI based on selection
// e.g., show formatting toolbar, update button states
} selectionchange fires frequently. Compare ranges to avoid unnecessary UI updates.
Cross-Browser Compatibility
Handle browser differences and provide fallbacks for older browsers.
// Cross-browser compatibility helper
function handleFormatting(inputType) {
// Check if beforeinput is supported
if (!('InputEvent' in window) ||
!InputEvent.prototype.hasOwnProperty('inputType')) {
// Fallback for older browsers
return handleFormattingLegacy(inputType);
}
// Modern browser
element.addEventListener('beforeinput', (e) => {
if (e.inputType === inputType) {
e.preventDefault();
applyFormatting(inputType);
}
});
}
function handleFormattingLegacy(inputType) {
// Use keydown events as fallback
element.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'b' && inputType === 'formatBold') {
e.preventDefault();
applyFormatting(inputType);
}
// ... other shortcuts
});
}Platform-Specific Considerations
The patterns described above may behave differently depending on browser, OS, device, and keyboard type. Here are platform-specific considerations for each pattern.
Format Toggle Pattern
⚠️ macOS + Korean IME: Formatting During Composition
macOS + Korean IME: Format toggle may not work during IME composition:
formatBold,formatItalicevents may not fire- Store formatting intent and apply after
compositionend - Different keyboard layouts (2벌식, 3벌식) may behave differently
⚠️ Safari: beforeinput Limitations
Safari: Some inputType values may not be supported:
- Use
keydownevents as fallback - Detect Safari and handle formatting differently
⚠️ Mobile: Touch Selection
Mobile devices: Touch-based selection may affect format toggle:
- Selection may be less precise than mouse selection
- Format toolbar may need different positioning logic
- Android vs iOS may handle selection differently
IME Composition Handling
⚠️ Browser Differences in Composition Events
Composition event timing varies:
- Chrome/Edge:
compositionupdatemay fire beforebeforeinput - Firefox: Different event ordering
- Safari: May have unique composition patterns
- Always check
isComposingflag, don't rely on event order
⚠️ OS & Keyboard Layout Differences
IME behavior varies by OS and keyboard:
- macOS: System-level IME, different timing than Windows
- Windows: IME behavior varies by Windows version and IME provider
- Korean IME: 2벌식, 3벌식, 390 자판 may fire events at different times
- Test with different keyboard layouts if supporting multiple input methods
Undo/Redo Implementation
⚠️ Browser Undo Stack Differences
Browser undo behavior varies:
- Chrome/Edge: May undo individual keystrokes vs larger operations
- Firefox: Different undo granularity
- Safari: Undo stack may be cleared when focus changes
- When using
preventDefault(), browser undo may not work - maintain your own stack
⚠️ Mobile: Undo/Redo Limitations
Mobile devices: Undo/redo may have limitations:
- Virtual keyboard may interfere with undo/redo shortcuts
- Mobile browsers may have different undo stack behavior
- Text prediction may affect undo stack
Paste Handler
⚠️ Mobile Keyboards: Paste Interference
Mobile keyboards with text prediction:
- Samsung Keyboard: Paste may trigger text prediction, causing multiple paste events
- Gboard: Clipboard suggestions may interfere
- iOS QuickType: May modify pasted content
- Handle multiple paste events and sanitize carefully
⚠️ Browser: HTML Format Variations
Pasted HTML format varies:
- Different applications (Word, Google Docs, etc.) paste different HTML structures
- Chrome vs Firefox vs Safari may normalize HTML differently
- Always sanitize and normalize pasted HTML
⚠️ OS: Clipboard Permissions
Clipboard access permissions:
- macOS: May require system-level permission prompts
- iOS: Clipboard access may show alerts
- Android: Browser or OS may restrict clipboard access
- Provide fallback using paste events with
clipboardData
Selection Change Tracking
⚠️ Mobile: Touch Selection Changes
Mobile devices: Selection change tracking may differ:
- Touch selection may fire
selectionchangemore frequently - Selection boundaries may be less precise
- Virtual keyboard may interfere with selection
- Debounce selection change handlers on mobile
⚠️ Browser: selectionchange Frequency
selectionchange fires frequently:
- Compare ranges to avoid unnecessary UI updates
- Some browsers may fire more events than others
- During IME composition, selection changes may be more frequent
Best Practices
- Always use try-catch: Range operations can throw errors. Always wrap them in try-catch blocks.
- Check selection state: Always verify selection exists and has ranges before operating on it.
- Handle edge cases: Collapsed selections, empty nodes, cross-element selections all need special handling.
- Test across platforms: Test with different browsers, OS, keyboards, and IMEs.
- Sanitize input: Always sanitize pasted HTML and user input to prevent XSS attacks.
- Maintain undo stack: If using preventDefault(), maintain your own undo/redo stack.
- Respect composition: Never modify DOM during active IME composition.