#11248·tinymce

Annotator skips fully contained existing annotations when annotating a selection

Author: gidomandersCreated Sep 8, 2026Updated Sep 8, 2026
Labelstype: bug

Provide detailed reproduction steps (if any)

editor.annotator.annotate() does not annotate text that is already wrapped in an annotation span when that span lies entirely inside the selection. Only the partially covered parts of the selection receive the new annotation.

Minimal demo (paste into a fiddle, TinyMCE 8.8.2, no plugins needed):

javascript
tinymce.init({
  selector: 'textarea',
  setup: (editor) => {
    editor.on('init', () => {
      editor.annotator.register('demo', {
        persistent: true,
        decorate: (uid, data) => ({ attributes: { 'data-demo': data.name } })
      });
      editor.setContent('<p>First paragraph</p><p>Second paragraph</p><p>Third paragraph</p>');

      const select = (startNode, startOffset, endNode, endOffset) => {
        const rng = editor.dom.createRng();
        rng.setStart(startNode, startOffset);
        rng.setEnd(endNode, endOffset);
        editor.selection.setRng(rng);
      };
      const p = () => editor.getBody().querySelectorAll('p');

      // 1. Annotate the middle paragraph only.
      const mid = p()[1].firstChild;
      select(mid, 0, mid, mid.length);
      editor.annotator.annotate('demo', { name: 'A' });

      // 2. Select from the first character of paragraph 1 to the last character
      //    of paragraph 3 (as a mouse drag would) and annotate again.
      const first = p()[0].firstChild;
      const last = p()[2].firstChild;
      select(first, 0, last, last.length);
      editor.annotator.annotate('demo', { name: 'B' });

      console.log(editor.getContent());
    });
  }
});
  1. Annotate a paragraph (annotation A).
  2. Select text that starts before that paragraph and ends after it, so the whole annotation span A lies inside the selection.
  3. Call editor.annotator.annotate() again (annotation B).

The same happens with a mouse selection and a UI button that calls annotate(), and also when annotation A was split over several paragraphs by pressing Enter inside the annotated text and the user then selects all of those paragraphs.

✔️ Expected result

Annotation B covers the entire selection. The text of the middle paragraph, which already carries annotation A, is wrapped as well (nested), exactly as happens when the selection starts or ends inside an existing annotation span:

xml
<p><span class="mce-annotation" data-mce-annotation-uid="B" ...>First paragraph</span></p>
<p><span class="mce-annotation" data-mce-annotation-uid="A" ...><span class="mce-annotation" data-mce-annotation-uid="B" ...>Second paragraph</span></span></p>
<p><span class="mce-annotation" data-mce-annotation-uid="B" ...>Third paragraph</span></p>

❌ Actual result

The middle paragraph is skipped. Annotation B is applied to the first and third paragraph only, so annotator.getAll('demo') reports B as two spans with a gap:

xml
<p><span class="mce-annotation" data-mce-annotation-uid="B" data-mce-annotation="demo" data-demo="B">First paragraph</span></p>
<p><span class="mce-annotation" data-mce-annotation-uid="A" data-mce-annotation="demo" data-demo="A">Second paragraph</span></p>
<p><span class="mce-annotation" data-mce-annotation-uid="B" data-mce-annotation="demo" data-demo="B">Third paragraph</span></p>

Consequences for a comments-style plugin built on the API: the new comment does not cover all selected text, and activating it (data-mce-annotation-active) highlights an incomplete range. Once annotation A is removed or hidden, the gap becomes visible to the user.

❔ Possible solution

In core/main/ts/annotate/Wrapping.ts the range walk hands over an existing annotation span as a whole node when it lies inside the selection. context() classifies it as ChildContext.Existing, and processElement() has no case for that, so the node is silently dropped. A text node inside an existing span is classified Valid and gets wrapped, which is why partial overlap works.

Treating Existing like InvalidChild for a span that carries a different uid than the one being applied (finish the current wrapper, recurse into the children, finish again) would nest the new annotation inside the existing one and produce the same structure the partial-overlap path already produces. If overlapping annotations are intentionally unsupported, the documentation of annotator.annotate() should say so, since it currently silently succeeds for part of the selection.

️ Other details

  • Browser: Chromium/Webkit (Playwright 1.62), Chrome, Vivaldi, Edge; not browser-specific, the logic is in the range walk
  • OS: macOS 15, Windows 10
  • First affected version: reproduced on 8.8.2; the Existing branch has been present since the Annotator API was introduced (5.0)
  • Worked in version: none known