How I implemented Google docs like commenting in Tiptap
Published: 2022-04-03 10:53:46

Tiptap has [a GitHub issue](https://github.com/ueberdosis/tiptap/issues/819) with the list of all the community extensions in the repo where people share extensions that they create. [Someone mentioned](https://github.com/ueberdosis/tiptap/issues/819#issuecomment-875207116) 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. 

{% embed https://github.com/sereneinserenade/tiptap-comment-extension %}

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](https://github.com/sereneinserenade/tiptap-comment-extension/issues/2) there's also an [implementation in React](https://github.com/sereneinserenade/tiptap-comment-extension-react). If you want to check it out before we jump into the code, here's [a demo.](https://tiptap-comment-extension.vercel.app/)

_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](https://discuss.prosemirror.net/t/inlining-a-node-for-a-comment-plugin-or-best-to-use-marks/2391), 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](https://github.com/sereneinserenade/tiptap-comment-extension/blob/main/src/components/extension/comment.ts).

```ts
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];
  },
});
```

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.

```ts
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
})
```

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.

```ts
// ... 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
```

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](https://github.com/sereneinserenade/tiptap-comment-extension/blob/main/src/components/Tiptap.vue).

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

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

const allComments = ref<any[]>([]) // to store all comments
```

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

```ts
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',
    },
  },
})
```

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.

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

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](https://github.com/sereneinserenade/tiptap-comment-extension/blob/main/src/components/OuterComment.vue) can be found in the repo.

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