Practical Patterns

Common patterns and code examples for implementing rich text editing features in contenteditable elements.

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() in beforeinput to 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.isComposing flag 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, formatItalic events 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 keydown events 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: compositionupdate may fire before beforeinput
  • Firefox: Different event ordering
  • Safari: May have unique composition patterns
  • Always check isComposing flag, 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 selectionchange more 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.

Related resources