Tips / Fixing IME duplicate text in heading elements

Fixing IME duplicate text in heading elements

How to prevent duplicate Pinyin text when using IME composition in heading elements (H1-H6) in WebKit browsers

Difficulty: Intermediate
Category: ime
imecompositionheadingwebkitchineseduplicate-textsafari

Problem

When using IME (Input Method Editor) for Chinese or other CJK languages in heading elements (<h1>-<h6>) in WebKit browsers (Safari, Chrome), pressing Space to confirm composition causes both the raw Pinyin buffer (e.g., “nihao”) and the confirmed characters (e.g., “你好”) to appear together. This results in duplicate text like “nihao 你好” instead of just “你好”.

This issue only occurs in heading elements and does not affect paragraph or div elements. Firefox handles this correctly and does not exhibit the bug.

Solution

1. Clean Up DOM After Composition

Remove the Pinyin buffer after composition completes using the compositionend event:

const heading = document.querySelector('h2[contenteditable]');
let isComposing = false;

heading.addEventListener('compositionstart', () => {
  isComposing = true;
});

heading.addEventListener('compositionend', () => {
  isComposing = false;
  // Clean up Pinyin text after composition completes
  setTimeout(() => {
    const text = heading.textContent;
    // Remove lowercase letters (Pinyin buffer)
    const cleaned = text.replace(/[a-z]+\s*/g, '').trim();
    if (cleaned !== text) {
      heading.textContent = cleaned;
      // Restore cursor position
      const range = document.createRange();
      range.selectNodeContents(heading);
      range.collapse(false);
      const selection = window.getSelection();
      selection.removeAllRanges();
      selection.addRange(range);
    }
  }, 0);
});

2. Intercept Space Key During Composition

Prevent the Space key from being processed during composition to let the IME handle it naturally:

const heading = document.querySelector('h2[contenteditable]');
let isComposing = false;

heading.addEventListener('compositionstart', () => {
  isComposing = true;
});

heading.addEventListener('compositionend', () => {
  isComposing = false;
});

heading.addEventListener('keydown', (e) => {
  if (isComposing && e.key === ' ') {
    e.preventDefault();
    e.stopPropagation();
    // Let IME complete composition naturally
    return false;
  }
});

3. Use Paragraphs Instead of Headings

Replace semantic heading elements with styled paragraphs to avoid the WebKit bug entirely:

<!-- Instead of <h2> -->
<p class="heading-2" contenteditable="true">Type here</p>

<style>
.heading-2 {
  font-size: 1.5rem;
  font-weight: 700;
  margin: 1rem 0;
}
</style>

4. Combined Approach with Robust Cleanup

Combine multiple strategies for a more robust solution:

class HeadingIMEHandler {
  constructor(element) {
    this.element = element;
    this.isComposing = false;
    this.caretPosition = null;
    
    this.init();
  }
  
  init() {
    this.element.addEventListener('compositionstart', this.handleCompositionStart.bind(this));
    this.element.addEventListener('compositionupdate', this.handleCompositionUpdate.bind(this));
    this.element.addEventListener('compositionend', this.handleCompositionEnd.bind(this));
    this.element.addEventListener('keydown', this.handleKeyDown.bind(this));
  }
  
  handleCompositionStart() {
    this.isComposing = true;
    this.saveCaretPosition();
  }
  
  handleCompositionUpdate(e) {
    // Track composition state
  }
  
  handleCompositionEnd() {
    this.isComposing = false;
    // Clean up after a short delay to ensure DOM is updated
    setTimeout(() => {
      this.cleanupPinyin();
      this.restoreCaretPosition();
    }, 10);
  }
  
  handleKeyDown(e) {
    if (this.isComposing && e.key === ' ') {
      e.preventDefault();
      e.stopPropagation();
      return false;
    }
  }
  
  saveCaretPosition() {
    const selection = window.getSelection();
    if (selection.rangeCount > 0) {
      const range = selection.getRangeAt(0);
      this.caretPosition = {
        startContainer: range.startContainer,
        startOffset: range.startOffset
      };
    }
  }
  
  restoreCaretPosition() {
    if (!this.caretPosition) return;
    
    try {
      const range = document.createRange();
      range.setStart(this.caretPosition.startContainer, this.caretPosition.startOffset);
      range.collapse(true);
      
      const selection = window.getSelection();
      selection.removeAllRanges();
      selection.addRange(range);
    } catch (e) {
      // Fallback: move cursor to end
      const range = document.createRange();
      range.selectNodeContents(this.element);
      range.collapse(false);
      const selection = window.getSelection();
      selection.removeAllRanges();
      selection.addRange(range);
    }
  }
  
  cleanupPinyin() {
    const text = this.element.textContent;
    // Remove lowercase Pinyin patterns (e.g., "nihao", "wo", "ni")
    // Keep CJK characters and other content
    const cleaned = text.replace(/[a-z]+\s*/g, '').trim();
    
    if (cleaned !== text) {
      this.element.textContent = cleaned;
    }
  }
  
  dispose() {
    this.element.removeEventListener('compositionstart', this.handleCompositionStart);
    this.element.removeEventListener('compositionupdate', this.handleCompositionUpdate);
    this.element.removeEventListener('compositionend', this.handleCompositionEnd);
    this.element.removeEventListener('keydown', this.handleKeyDown);
  }
}

// Usage
const heading = document.querySelector('h2[contenteditable]');
const handler = new HeadingIMEHandler(heading);

Notes

  • This bug is specific to WebKit-based browsers (Safari, Chrome) and does not occur in Firefox
  • The issue only affects heading elements (<h1>-<h6>), not paragraph or div elements
  • The cleanup approach may interfere with legitimate use cases where you want to keep both Pinyin and characters
  • Using paragraphs instead of headings may impact SEO and accessibility, so consider using proper heading semantics with ARIA attributes if needed
  • Test thoroughly with different IMEs (Chinese, Japanese, Korean) as behavior may vary
  • The setTimeout delay in cleanup is necessary because the DOM update happens asynchronously after compositionend
Edit on GitHub