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.

5 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.

4 Likes

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

1 Like

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.

2 Likes

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?

3 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.

3 Likes

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.

@Marcel Iā€™m working on commenting plugin feature recently, so which solution did you choose finally, marks or decoration based solution?

Decoration. No move / copy / paste solution yet

The same, here.

Good to know, thanks :slight_smile:

Good to know, how about combination of marks and decoration? so copy/paste can also works ?

@sworddish These two approaches seem to be at odds end. The complexity of managing both is probably greater than managing whether a clipboard operation has grabbed content that has decoration(s) and then mapping their/the start & end position to wherever it was pasted (or if another document, migrating that decoration to a new editor state.)

I actually dont think this is going to be too difficult, I just havent done it yet.

How to map decorationā€™s from/to if use y-prosemirror? the y-prosemirror will cause tr.mapping.map failure :joy:

1 Like

As you can read in this thread, ā€œevery synced change ends up replacing the whole documentā€. Thatā€™s also the reason I didnā€™t use y-prosemirror, yet (though the author said he started to adapt the code to use Prosemirror transforms at the beginning of this year).

This is awesome! Have you considered making this a plugin? I would love to use it in my project and would contribute to it

2 Likes

If someone is looking for a mark based comment solution, Iā€™ve made it on top of tiptap. Hereā€™s the link GitHub - sereneinserenade/tiptap-comment-extension: Google-Docs šŸ“„šŸ”„ like commenting šŸ’¬ solution for Tiptap 2(https://tiptap.dev) and hereā€™s a demo. For more details, take a look at the repo

7 Likes