Annotator skips fully contained existing annotations when annotating a selection
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):
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());
});
}
});- Annotate a paragraph (annotation A).
- Select text that starts before that paragraph and ends after it, so the whole annotation span A lies inside the selection.
- 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:
<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:
<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
Existingbranch has been present since the Annotator API was introduced (5.0) - Worked in version: none known
Source: tinymce/tinymce