DEV Community

Cover image for How I implemented Google docs like commenting in Tiptap
Jeet Mandaliya
Jeet Mandaliya

Posted on

How I implemented Google docs like commenting in Tiptap

Tiptap has a GitHub issue with the list of all the community extensions in the repo where people share extensions that they create. Someone mentioned that they needed Commenting-feature in Tiptap that is similar to the commenting-feature in Google Docs.

So here's how I had my chance of creating the same.

GitHub logo sereneinserenade / tiptap-comment-extension

Google-Docs 📄🔥 like commenting 💬 solution for Tiptap 2(https://tiptap.dev)

Tiptap Comment Extension:

GitHub Sponsors GitHub Repo stars DM Me on Discord - sereneinserenade#4869

Tiptap Extension for having Google-Docs like pro-commenting in Tiptap.

A ⭐️ to the repo if you 👍 / ❤️ what I'm doing would be much appreciated. If you're using this extension and making money from it, it'd be very kind of you to ❤️ Sponsor me. If you're looking for a dev to work you on your project's Rich Text Editor with or as a frontend developer, DM me on Discord/Twitter/LinkedIn👨‍💻🤩.

I've made a bunch of extensions for Tiptap 2, some of them are Resiable Images And Videos, Search and Replace, LanguageTool integration with tiptap. You can check it our here https://github.com/sereneinserenade#a-glance-of-my-projects.

Demo:

Try live demo: https://sereneinserenade.github.io/tiptap-comment-extension/

tiptap-comment-extension.mp4

How to use

npm i @sereneinserenade/tiptap-comment-extension
Enter fullscreen mode Exit fullscreen mode
import StarterKit from "@tiptap/starter-kit";
import Comment from "@sereneinserenade/tiptap-comment-extension";
const extensions = [
  StarterKit,
  Comment.configure({
Enter fullscreen mode Exit fullscreen mode

Initially I created it just for Vue3 since it's only supposed to be an example implementation of a framework agnostic Tiptap extension. And by the request of community there's also an implementation in React. If you want to check it out before we jump into the code, here's a demo.

Okay, enough context, let's dive into how it's made.

When I started working on it, the first thing I did was to Google whether there were any discussions around it, and I found this thread, which at the time I couldn't comprehend fully because of my limited knowledge of Prosemirror. I got to know pros and cons of implementing comments as marks and the other solution, which mentioned storing ranges of comments in an external data structure, and highlighting the comments in the doc with Prosemirror decorations, but then we'd have to figure out how to transfer comments when copying the content from one doc and pasting to another doc.

After all, I decided to give a shot to Comments as marks since I was already familiar with marks. So I created a new Mark, here's the file.

export interface CommentOptions {
  HTMLAttributes: Record<string, any>,
}

export const Comment = Mark.create<CommentOptions>({
  name: 'comment',

  addOptions() {
    return {
      HTMLAttributes: {},
    };
  },

  parseHTML() {
    return [
      {
        tag: 'span',
      },
    ];
  },

  renderHTML({ HTMLAttributes }) {
    return ['span', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
  },
});
Enter fullscreen mode Exit fullscreen mode

Right now, we have created a comment mark, that'll just render a span, but we need to store the comments somewhere. That's when the data-comment attribute comes in.

export const Comment = Mark.create<CommentOptions>({
  // ... rest of code
  addAttributes() {
    return {
      comment: {
        default: null,
        parseHTML: (el) => (el as HTMLSpanElement).getAttribute('data-comment'),
        renderHTML: (attrs) => ({ 'data-comment': attrs.comment }),
      },
    };
  },

  parseHTML() {
    return [
      {
        tag: 'span[data-comment]',
        getAttrs: (el) => !!(el as HTMLSpanElement).getAttribute('data-comment')?.trim() && null,
      },
    ];
  },
  // ... rest of code
})
Enter fullscreen mode Exit fullscreen mode

Now, it would render a data-comment attribute with span element, and adding a Tiptap-Attribute will allow us to define the comment property of mark so we can access it programmatically instead of getting data-comment attribute by searching through DOM.

We need commands to be able to set comment mark.

// ... rest of code
  addCommands() {
    return {
      setComment: (comment: string) => ({ commands }) => commands.setMark('comment', { comment }),
      toggleComment: () => ({ commands }) => commands.toggleMark('comment'),
      unsetComment: () => ({ commands }) => commands.unsetMark('comment'),
    };
  },
// ... rest of code
Enter fullscreen mode Exit fullscreen mode

That's it for the extension part. Now we look at how we're going to structure the comments and store them, which leads us to the Vue part of things, which is implemented here.

interface CommentInstance {
  uuid?: string
  comments?: any[]
}

// to store currently active instance
const activeCommentsInstance = ref<CommentInstance>({})

const allComments = ref<any[]>([]) // to store all comments
Enter fullscreen mode Exit fullscreen mode

Loading all the comments when editor is created and updated, setting currently active comment when editor is updated.

const findCommentsAndStoreValues = (editor: Editor) => {
  const tempComments: any[] = []

  // to get the comments from comment mark.
  editor.state.doc.descendants((node, pos) => {
    const { marks } = node

    marks.forEach((mark) => {
      if (mark.type.name === 'comment') {
        const markComments = mark.attrs.comment; // accessing the comment attribute programmatically as mentioned before

        const jsonComments = markComments ? JSON.parse(markComments) : null;

        if (jsonComments !== null) {
          tempComments.push({
            node,
            jsonComments,
            from: pos,
            to: pos + (node.text?.length || 0),
            text: node.text,
          });
        }
      }
    })
  })

  allComments.value = tempComments
}

const setCurrentComment = (editor: Editor) => {
  const newVal = editor.isActive('comment')

  if (newVal) {
    setTimeout(() => (showCommentMenu.value = newVal), 50)

    showAddCommentSection.value = !editor.state.selection.empty

    const parsedComment = JSON.parse(editor.getAttributes('comment').comment)

    parsedComment.comment = typeof parsedComment.comments === 'string' ? JSON.parse(parsedComment.comments) : parsedComment.comments

    activeCommentsInstance.value = parsedComment
  } else {
    activeCommentsInstance.value = {}
  }
}

const tiptapEditor = useEditor({
  content: `
    <p> Comment here. </p>
  `,

  extensions: [StarterKit, Comment],

  onUpdate({ editor }) {
    findCommentsAndStoreValues(editor)

    setCurrentComment(editor)
  },

  onSelectionUpdate({ editor }) {
    setCurrentComment(editor)

    isTextSelected.value = !!editor.state.selection.content().size
  },

  onCreate({ editor }) {
    findCommentsAndStoreValues(editor)
  },

  editorProps: {
    attributes: {
      spellcheck: 'false',
    },
  },
})
Enter fullscreen mode Exit fullscreen mode

That was the core logic of how to get comments from Tiptap and store then in Vue so it can be use outside of the scope of editor.

Here's the structure of the comments that made most sense to me. However, since it's just going to be a string in the data-comment property, you can have any JSON or XML or YAML or whatever format you want, just make sure you parse it right.

{
  "uuid": "d1858137-e0d8-48ac-9f38-ae778b56c719",
  "comments": [
    {
      "userName": "sereneinserenade",
      "time": 1648338852939,
      "content": "First comment"
    },
    {
      "userName": "sereneinserenade",
      "time": 1648338857073,
      "content": "Following Comment"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

The other part of logic(which I consider to be not-core logic) , i.e. creating a new comment, toggling comment mode on/off and showing the comments outside can be found in the repo.

Enjoy the days, and if you have any questions/suggestions, I'll be in the comment section.

Top comments (9)

Collapse
 
markust profile image
MarkusTrasberg

Awesome work, thank you!

Any ideas on how to show the respective comment box on the same height as the line where the comment appears? Currently have tried getting the coordinate for the starting position and adding that to position the element, without much success.

const startPos = editor.view.posAtDOM(commentNode, 0);
const coords = editor.view.coordsAtPos(startPos);
comment.from = startPos;

Collapse
 
victorvianaom profile image
Victor Viana

Hi Jeet! many thanks!
How do you set the Mark' style?

Collapse
 
sereneinserenade profile image
Jeet Mandaliya

giving it an attribute and styling it works just fine github.com/sereneinserenade/tiptap...

Collapse
 
victorvianaom profile image
Victor Viana • Edited

Got it! Thank you so much!

Collapse
 
rini001 profile image
Renaissance

Hi Jeet, I am using Tiptap. The editor contain some existing text and images I want to style it . How can I do that?

Collapse
 
sereneinserenade profile image
Jeet Mandaliya • Edited

Hey @rini001, please have a look here at how tiptap styles it, it should be able to do something similar

Collapse
 
mark_meebs_20d6c53a3a6fba profile image
Mark Meebs

Thanks for writing this Jeet, it was super helpful for me!

Collapse
 
sereneinserenade profile image
Jeet Mandaliya

Glad it was helpful. Always welcome!

Collapse
 
adityasajoo profile image
Aditya • Edited

Hi Jeet, I was working on something similar with Tiptap and React. Is there a way to focus on and select the text in the editor when the user clicks a comment from the side panel?