Decoration positions

I am trying to write a plugin that will put a decoration around all words (as a step to more complex matching)

I tried:

function wordHighlightPlugin() {
	let deco = DecorationSet.empty;

	return new Plugin({
		state: {
			init() {},
			apply(tr, prev, oldState, state) {
				if (tr.selection.empty) {
					let txt = tr.selection.$from.parent.textBetween(0, tr.selection.$from.parent.content.size, ' ');

					deco = deco.remove(deco.find( ????, ???? ));

					const startp = tr.selection.$from.start();
					const reg = /\w+/g
					let match = null;
					while ((match = reg.exec(txt)) != null) {
						deco = deco.add(state.doc, [Decoration.inline(startp + match.index, startp + reg.lastIndex, { class: 'highlight' })]);
				return tr;
		props: {
			decorations(state) {
				return deco }

The problem I have is removing preexisting decoration in the same range as the text I am parsing … I need the text of the currently changed word (if I get more text as is done above that’s fine), then I need to remove existing word decorations and parse.

DecorationSet.find receives absolute positions, how do I translate the positions used in let txt = tr.selection.$from.parent.textBetween(0, tr.selection.$from.parent.content.size);

to positions I can feed to deco.remove(deco.find( ????, ???? )); ?


I am starting to think that this is maybe a bug in DecorationSet.add

To demonstrate I wrote a very simple plugin that simply add decoration on a line:

function highlightPlugin() {
	let decos = DecorationSet.empty

	return new Plugin({
		state: {
			init() {},
			apply(tr, prev, oldState, state) {
				if (tr.selection.empty) {
					const $f = tr.selection.$from
					decos = decos.add(state.doc, [Decoration.inline($f.start(), $f.end(), { class: 'highlight' })]);
				return tr
		props: {
			decorations(state) { return decos }

I expect this plugin to simply highlight all written text

However if you write a few lines and then edit one of the lines above, the line below (or parts of it) will loose the highlight.

Why doesn’t all text stay highlighted ?

There’s a number of things wrong with this code.

  • $f.start() and $f.end() will be the same position when the cursor is on a node boundary, or the positions around the text node when it is inside a text node. They won’t help you get the range around inserted text.

  • Your state lives in a closed-over variable rather than in the actual state field

  • You’re returning a transaction from apply, which should return an updated state. Similarly, init should return an initial state.

  • You’re not mapping your decoration set forward in apply (decos =, tr.doc)), which is probably the reason you’re seeing strange effects.

thx, I am confused here, what is the right way to get the range here ?

yes, I find that easier, the only shortcoming I can see is that decos will not be available to code outside the plugin … is there any other issue with this ?

In the ref, here, the syntax shows -> T but the text reads producing a new field value so I’m confused which one is correct ?

ok thx I’ll try that

There’s no easy answer to that. You could inspect the step maps for the steps in the transaction and use forEach to get the ranges they inserted, but that would involve mapping ranges from previous steps forward through subsequent steps. I don’t have time to write the code out for you, though.

Also it completely defeats the purpose of having persistent data structures and will cause all instances of the editor state to share the same plugin state, and thus break whenever you use it in a non-linear way (create two state updates from a single start state).

In the ref, here, the syntax shows -> T

T here is the type parameter that stands for the plugin state’s type.


Thing is, I am only interested in the text word around the cursor position. If I get a bit more or not that’s ok, so it seems the implementation above works fine for that. I think $from and $to work ok in this case ?

I tried to adapt the code from the word highlighter gist


function rangeFromTransform(tr) {
    let from, to
    for (let i = 0; i < tr.steps.length; i++) {
        let step = tr.steps[i],
            map = step.getMap()
        let stepFrom = || step.pos, -1)
        let stepTo = || step.pos, 1)
        from = from ?, -1).pos.min(stepFrom) : stepFrom
        to = to ?, 1).pos.max(stepTo) : stepTo
    return { from, to }

let { from, to } = rangeFromTransform(tr)
if (from && to && tr.docChanged) {
   const $t = state.doc.resolve(to)
   let txt = $t.parent.textBetween(0, $t.end() - $t.start(), ' ');

Which seems to work fine so far