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.

2 Likes

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

My apologies for necro-bumping an old topic, but I feel this belongs to this discussion.

Apart from creating the issue of ‘split’ comments, what are other arguments to not use marks for comments? E.g., in the footnote example you use a separate node type to render and keep track of footnotes, which are ideally preserved when copy-pasted. Comments as marks have the advantage of keeping the original text and its comments in the same document, without having to do the ‘bookkeeping’ manually.

For instance, in Oak, comments are simply a type of mark, according to the linked article. Do you know how they solved the problem of comment splitting when e.g. italicizing part of the text?

2 Likes

@marcel Two major points from my perspective:

  1. For decoration approach, there is clear separation of non-editable document with ability to comment in “read only” mode. The document can be locked / non-editable yet users with view access can still comment via adding start/to range in a separate data structure than the main document object. It can be an entirely separate field in a database for instance, managed/updated via dedicated endpoint with different authorization. The same is possible via mark approach if you have a centralized collab server and assert on transaction metadata, but this is again, more complicated and some don’t have a centralized collab server. If you don’t have a centralized collab server, you’ll probably (shouldn’t trust only client) need to diff the entire document JSON to ensure a user isn’t editing a document in non-authorized ways. That is, document edits other than just appending comment marks to blocks.

  2. The data structure for decoration comments are a more straightforward model defined solely by a single from/to. The mark approach will mark each discrete block that is contained in the user’s selection and add each to the doc JSON. So there may be more than one persisted mark for each comment which may have a solution to manage, but it’s not as easy to tame as one may think? The decoration solution will eventually produce DOM with multiple comment wrapper blocks (if necessary,) but they are a view concern rather than something that is persisted into a data model (And prosemirror handles all of that automatically via the decoration implementation DOM output)

That being said, decoration solution undo/redo indeed is difficult and I don’t have a working solution for that yet. But I eventually need to implement something of the sort.

Thanks for your reply. In the mean time, I realized that it’s probably best to use a (kind of) tried and tested method to add comments, rather then try an already dead(ish) end. You’re of course correct about the advantages of using decorations for comments.

No easy solution for undo/redo is fine for me, but do you (or someone else) know if it is doable to implement copy-pasting of comments? I’m sure that behaviour should work as expected for other users of my app, who aren’t technically savvy and get confused by disappearing comments.

The next few days I’ll try to implement this myself. I’ll let you know what I find and use.

My pleasure. I actually don’t yet have a system for copy/paste although that seems like the easier problem. What I did implement was a way to inspect transactions and pause them if a comment were to be completely removed by it. So basically, the transaction is not applied until the user confirms in a modal that he is aware a comment is going to be deleted.