Inlining a node for a comment plugin or best to use marks?

Hello fellow prosemirror developers :slight_smile:

I am attempting to build a plugin to handle simple comment functionality. Ideally, something approaching how Microsoft Word or Google docs handle comments in their text editors. But I’m having great difficulty figuring out how the schema should be constructed. Hoping someone can point me in the right direction.

What I have so far are block nodes highlighting a selection and decorations to render comment copy in a sidebar. The problem with this approach is that the entire paragraph node gets wrapped (for example). Ideally only the text selection should be highlighted for a comment with a single decoration render to the sidebar.

Visually my comment looks like this:

I thought marks would be perfect for solving the limitations. And that does allow highlighting of selected text relatively easily BUT this approach breaks my decorations. For example, adding bold on a selection of text will split my comment mark into 3 and then it becomes tricky to render a comment with a decoration in the sidebar. I don’t want to render 3 identical comment decorations. A carriage return will also create a new paragraph and split the comments mark.

I’m unsure if am approaching the problem correctly. Maybe I am doing this entirely wrong? I’ve looked through the documentation and other examples but really struggling to get this working :frowning: Any advice is much appreciated.

The code for my comment node schema looks like this:

getNodes() {
  const user = this.user;
  return {
    comment: {
      isolating: true,
      group: "block",
      content: "block*",
      toDOM() { return ["comment", 0] },
      parseDOM: [
        {tag: "comment",
          getAttrs(dom) {
            return {
              copy: dom.getAttribute("data-copy"),
              type: dom.getAttribute("data-type"),
              user: dom.getAttribute("data-user"),
              timestamp: dom.getAttribute("data-timestamp")
            };
          }
        }
      ],
      attrs: {
        copy: {
          default: '',
          hasDefault: false
        },
        type: {
          default: '',
          hasDefault: true
        },
        user: {
          default: user,
          hasDefault: true
        },
        timestamp: {
          default: undefined,
          hasDefault: true
        }
      }
    }
  }
}

And my alternative mark based solution looks like this:

getMarks() {
  return {
    comment2: {
      attrs: {
        copy: {
          default: ' 123 ',
          hasDefault: true
        },
        type: {
          default: 'bar',
          hasDefault: true
        },
        user: {
          default: 'Joe Bloggs',
          hasDefault: true
        }
      },
      parseDOM: [{
        tag: "span.comment",
        getAttrs(dom) {
          return {
            copy: dom.getAttribute("data-copy"),
            type: dom.getAttribute("data-type"),
            user: dom.getAttribute("data-user")
          };
        }
      }],
      toDOM(node) {
        return [
          "span",
          {
            "class": "comment",
            "data-copy": node.attrs.copy,
            "data-type": node.attrs.type,
            "data-user": node.attrs.user,
            "title": `Created by: ${node.attrs.user}`}
        ]
      }
    }
  }
}

Many Thanks. Any advice much appreciated.

1 Like

Comments are usually not modeled as document nodes—rather, they are references to ranges, that are tracked separately, outside of the document. That avoids the awkwardness you describe.

1 Like

Thank you for the feedback marijn. I will try with references to ranges outside the document.

One downside is that it could be a bit trickier to preserving comments when users copy to clipboard (Although I don’t know if my organisation needs this functionality anyway).

I think I can use the transaction mapping to update the references as the document is modified.

state: {
      apply(transaction, currentValue, oldState, newState) {
        comments.forEach(comment => {
          comment.from = transaction.mapping.map(comment.from);
          comment.to = transaction.mapping.map(comment.to);
        })
      }
    }

Just a bit of background. We are building a text editor for highly structured pharmaceutical data. Hopefully can come back and show it off once it is done!

Thanks

That is absolutely true. I’m not aware of an actual good solution to that problem, though you can get pretty far by storing metadata to the side when copying and consulting that, comparing it to the pasted slice, when pasting.

Yes, that’s the idea (also used in the collab demo on the website). Though you can’t rely on states only being updated in a single chain (a given state, created with a transaction, might be discarded) so you should use persistent data, preferably in a state field, rather than mutate an external data structure.

1 Like