Welcome to Tiptap Comments
Tiptap Comments allows you to annotate and discuss your Tiptap documents via Threads.
Feel free to try it out in this demo by selecting a slice of content and clicking on the Add comment button.
Comments features
- Add inline, document, or sidebar comments
- Comments on text, nodes, custom nodes, or across a selection of nodes
- Rich text support within comments (e.g., bold, emojis)
- Resolve, edit, or delete comments
- Offline commenting support
- Handle overlapping comments
- Mention users directly within comments
- Webhooks to trigger custom notifications services when users are mentioned
- Programmatically manage comments using the Comments API
import './styles.scss'
import { Collaboration } from '@tiptap/extension-collaboration'
import { CollaborationCaret } from '@tiptap/extension-collaboration-caret'
import Image from '@tiptap/extension-image'
import Placeholder from '@tiptap/extension-placeholder'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { CommentsKit, hoverOffThread, hoverThread } from '@tiptap-pro/extension-comments'
import { TiptapCollabProvider } from '@tiptap-pro/provider'
import { useCallback, useRef, useState } from 'react'
import { v4 as uuid } from 'uuid'
import * as Y from 'yjs'
import { fromBase64String } from '../demo-setup.ts'
import { initialContent } from '../initialContent.ts'
import { ThreadsList } from './components/ThreadsList.jsx'
import { ThreadsProvider } from './context.jsx'
import { NodeViewExtension } from './extensions.jsx'
import { useThreads } from './hooks/useThreads.jsx'
import { useUser } from './hooks/useUser.jsx'
const doc = new Y.Doc()
const isDev = import.meta.env.MODE === 'development'
const id = isDev ? 'dev' : uuid()
const provider = new TiptapCollabProvider({
appId: '7j9y6m10',
name: `tiptap-comments-demo/${id}`,
document: doc,
})
// apply initial content
const initialBinary = fromBase64String(initialContent)
Y.applyUpdate(provider.document, initialBinary)
export default () => {
const [showUnresolved, setShowUnresolved] = useState(true)
const [selectedThread, setSelectedThread] = useState(null)
const threadsRef = useRef([])
const [selection, setSelection] = useState(null)
const user = useUser()
const editor = useEditor({
onSelectionUpdate: ({ editor: currentEditor }) => setSelection(currentEditor.state.selection),
extensions: [
StarterKit.configure({
undoRedo: false,
}),
Image,
Collaboration.configure({
document: doc,
}),
CollaborationCaret.configure({
provider,
user: {
name: user.name,
color: user.color,
},
}),
CommentsKit.configure({
provider,
onClickThread: threadId => {
const isResolved = threadsRef.current.find(t => t.id === threadId)?.resolvedAt
if (!threadId || isResolved) {
setSelectedThread(null)
editor.chain().unselectThread().run()
return
}
setSelectedThread(threadId)
editor.chain().selectThread({ id: threadId, updateSelection: false }).run()
},
}),
Placeholder.configure({
placeholder: 'Write a text to add comments …',
}),
NodeViewExtension,
],
})
const { threads = [], createThread } = useThreads(provider, editor, user)
threadsRef.current = threads
const selectThreadInEditor = useCallback(
threadId => {
editor.chain().selectThread({ id: threadId }).run()
},
[editor],
)
const deleteThread = useCallback(
threadId => {
provider.deleteThread(threadId)
editor.commands.removeThread({ id: threadId })
},
[editor],
)
const resolveThread = useCallback(
threadId => {
editor.commands.resolveThread({ id: threadId })
},
[editor],
)
const unresolveThread = useCallback(
threadId => {
editor.commands.unresolveThread({ id: threadId })
},
[editor],
)
const updateComment = useCallback(
(threadId, commentId, content, metaData) => {
editor.commands.updateComment({
threadId,
id: commentId,
content,
data: metaData,
})
},
[editor],
)
const onHoverThread = useCallback(
threadId => {
hoverThread(editor, [threadId])
},
[editor],
)
const onLeaveThread = useCallback(() => {
hoverOffThread(editor)
}, [editor])
if (!editor) {
return null
}
const filteredThreads = threads.filter(t => (showUnresolved ? !t.resolvedAt : !!t.resolvedAt))
return (
<ThreadsProvider
onClickThread={selectThreadInEditor}
onDeleteThread={deleteThread}
onHoverThread={onHoverThread}
onLeaveThread={onLeaveThread}
onResolveThread={resolveThread}
onUpdateComment={updateComment}
onUnresolveThread={unresolveThread}
selectedThreads={editor.storage.comments.focusedThreads}
selectedThread={selectedThread}
setSelectedThread={setSelectedThread}
threads={threads}
>
<div className="col-group" data-viewmode={showUnresolved ? 'open' : 'resolved'}>
<div className="main">
<div className="control-group">
<div className="button-group">
<button type="button" onClick={createThread} disabled={!selection || selection.empty}>
Add comment
</button>
<button
type="button"
onClick={() => editor.chain().focus().setImage({ src: 'https://placehold.co/800x500' }).run()}
>
Add image
</button>
<button type="button" onClick={() => editor.chain().focus().insertNodeView().run()}>
Add node view
</button>
</div>
</div>
<EditorContent editor={editor} />
</div>
<div className="sidebar">
<div className="sidebar-options">
<div className="option-group">
<div className="label-large">Comments</div>
<div className="switch-group">
<label>
<input
type="radio"
name="thread-state"
onChange={() => setShowUnresolved(true)}
checked={showUnresolved}
/>
Open
</label>
<label>
<input
type="radio"
name="thread-state"
onChange={() => setShowUnresolved(false)}
checked={!showUnresolved}
/>
Resolved
</label>
</div>
</div>
<ThreadsList provider={provider} threads={filteredThreads} />
</div>
</div>
</div>
</ThreadsProvider>
)
}