Decorations: new content appearing *before* widget with side: -1

I’m experimenting with a plugin to add *markdown* /style/ annotations around bold and italics (but only for the paragraph with the selection)

I am using PM via Tiptap, but assume (hope!) that’s not relevant to this issue.

Here’s my decorations method:

decorations(state) {
  const selection = state.selection;
  const decorations = [];

  state.doc.nodesBetween(selection.from, selection.to, (node, pos) => {
    if (node.isBlock) {
      decorations.push(Decoration.node(pos, pos + node.nodeSize, {class: 'selected'}));

      node.forEach((n, p) => {
        for (const m of n.marks) {
          switch(m.type.name) {
            case "italic": {
              decorations.push(Decoration.widget(pos + p + 1, () => el`<span>/</span>`, {side: -1}))
              decorations.push(Decoration.widget(pos + p + n.nodeSize + 1, () => el`<span>/</span>`, {side: 1}))
              break
            }
            // Similar for bold...
          }
        }
      })
    }
  });

  return DecorationSet.create(state.doc, decorations);
}

When the cursor is in this position…

image

…and I type a character, it appears before the decoration:

image

About the side option, the docs say:

Controls which side of the document position this widget is associated with. When negative, it is drawn before a cursor at its position, and content inserted at that position ends up after the widget.

Am I doing something wrong?

p.s. am very new to PM, so any general comments on that plugin code would be welcome!

Following up myself having learned a bit more.

I had understood that the start and end of a node count as 1, in position counting, but I’ve now discovered that does not apply to leaf nodes. e.g. paragraph like this…

A bold word

…using a made-up [...] syntax to represent nodes, looks like:

[[A ][bold][ word]]

(where [bold] has a mark)

Adding a * to indicate a position, this is position 2

[[A* ][bold][ word]]

this is 3

[[A *][bold][ word]]

and this is 4

[[A ][b*old][ word]]

So, this position is not addressable at all:

[[A ][*bold][ word]]

Given the transaction API is position based, it seems it’s actually impossible to insert text at the start of a marked node, even programatically. The use of integer positions is a beautiful simplification but this is kind of surprising!

It looks like I can hack this using a ranged selection and re-inserting the existing first character. E.g. to add “A” to the start of “bold” I could insertText("Ab", 3, 4), resulting in

[[A ][Abold][ word]]

So, for inserting text at least, PM seems to interpret 3…4 as

[[A ][*bo*ld][ word]]

In principle I could script my editor to intercept transactions that look like regular typing just before a marked node, and move them inside the node using this hack. My questions are:

  • Is this a reasonable idea, or a terrible idea??
  • How would one go about that? I see plugins have the apply hook, but it doesn’t look like they are allowed to make changes to the transaction other than adding metadata. What about dispatchTransaction on the view?

TBH I may drop the whole idea, if it goes against the grain to much, but right now it’s serving as a great learning opportunity so I’m going to keep going.

Thanks!

Tom

Oh no, it gets weirder : )

I just discovered that, if * is my caret

[[A ][b*old][ word]

And I press backspace, the selection position is 3, but typing goes here:

[[A ][*bold][ word]

So it turns out position 3 can be either

[[A *][bold][ word] or [[A ][*bold][ word]

This is obviously bumping into the mismatch between the browser’s more complex selection, and PM’s integer-position based selection.

So, going back to my original goal (add markdown-like sigils as PM decorators) I could potentially pick side: -1 or side: 1 by checking where the browser’s selection is. This is proving to be a very fruitful one sided conversation :rofl:

Marks aren’t nodes, they are metadata added to nodes. So to add bold text to the start of that bold range, you’d add text with the bold mark at position 3.

Again, there’s no separate positions here. To align with how other wysiwyg editors work, when you delete marked content the marks are stored and applied to text typed immediately after that.

Yes I do get that marks are not nodes, but the node is still there for the bolded word. I guess I am not “thinking Prosemirror” yes - the solution is to forget about the leaf nodes, insert the text with the required mark, and presumably PM will normalise so there’s not two adjacent nodes with the same marks.

But I’ve still got this odd behaviour, (which does seem to contradict what the docs say):

Assuming the second word here is bolded, and the * chars are not content but widget decorations (with side -1 and +1 respectively)

A bold word

If I cursor or click to just before the ‘b’ and type, it appears to the left of the ‘*’ and not bolded

If I cursor to just after the ‘b’, backspace, and then type, it appears to the right of the ‘*’ and bolded.

I am investigating whether the browser selection API can indicate if the new text is going to be bolded or not, and so far it appears not :frowning:

Can you reduce this to a simple example script? Inserting at the point of a side = -1 widget should indeed leave the widget before the inserted text (and does seem to, when I try it).

Thanks for investigating. I realise the issue is that I’m dynamically reinserting the widget at the boundary of the bolded text (per code at the start of this thread).

I’ve just discovered that the behaviour to make new text bolded after a backspace is actually prosemirror, not the browser, which gives me an idea for a solution to my problem: I can watch the selection, and add a matching storedMark if the caret moves to the start of a bold or italic region.

This worked beautifully. Posting the code in case anyone else is interested

(note onSelectionUpdate is a Tiptap event handler)

onSelectionUpdate({editor}) {
  const {state, view} = editor
  const {selection, doc} = state
  if (selection.empty) {
    // nodeAt gives the node *after* the passed pos, which is what we want
    const marks = doc.nodeAt(selection.from).marks
    if (marks.length > 0) {
      view.dispatch(state.tr.setStoredMarks(marks))
    }
  }
}