Threads Sidebar
In this example, you can add comments to the document while collaborating with others. You can also pick user accounts with different permissions, as well as react to, reply to, and resolve existing comments. The comments are displayed floating next to the text they refer to, and appear when selecting said text. The comments are shown in a separate sidebar using the ThreadsSidebar component.
Try it out: Click the "Add comment" button in the Formatting Toolbar to add a comment!
Relevant Docs:
"use client";import { createUserStore } from "@blocknote/core";import { DefaultThreadStoreAuth, CommentsExtension,} from "@blocknote/core/comments";import { withCollaboration, YjsThreadStore } from "@blocknote/core/yjs";import { BlockNoteView } from "@blocknote/mantine";import "@blocknote/mantine/style.css";import { FloatingComposerController, RenderInPortalElement, ThreadsSidebar, useCreateBlockNote,} from "@blocknote/react";import { useMemo, useState } from "react";import YPartyKitProvider from "y-partykit/provider";import * as Y from "yjs";import { SettingsSelect } from "./SettingsSelect";import { HARDCODED_USERS, MyUserType, getRandomColor } from "./userdata";import "./style.css";// The resolveUsers function fetches information about your users// (e.g. their name, avatar, etc.). Usually, you'd fetch this from your// own database or user management system.// Here, we just return the hardcoded users (from userdata.ts)async function resolveUsers(userIds: string[]) { // fake a (slow) network request await new Promise((resolve) => setTimeout(resolve, 1000)); return HARDCODED_USERS.filter((user) => userIds.includes(user.id));}// A single user store, shared between the comments and collaboration extensions// so they use one de-duped cache of resolved users.const userStore = createUserStore(resolveUsers);// Sets up Yjs document and PartyKit Yjs provider.const doc = new Y.Doc();const provider = new YPartyKitProvider( "blocknote-dev.yousefed.partykit.dev", // Use a unique name as a "room" for your application. "comments-with-sidebar", doc,);// This follows the Y-Sweet example to setup a collabotive editor// (but of course, you also use other collaboration providers// see the docs for more information)export default function App() { const [activeUser, setActiveUser] = useState<MyUserType>(HARDCODED_USERS[0]); const [commentFilter, setCommentFilter] = useState< "open" | "resolved" | "all" >("open"); const [commentSort, setCommentSort] = useState< "position" | "recent-activity" | "oldest" >("position"); // setup the thread store which stores / and syncs thread / comment data const threadStore = useMemo(() => { // (alternative, use TiptapCollabProvider) // const provider = new TiptapCollabProvider({ // name: "test", // baseUrl: "https://collab.yourdomain.com", // appId: "test", // document: doc, // }); // return new TiptapThreadStore( // activeUser.id, // provider, // new DefaultThreadStoreAuth(activeUser.id, activeUser.role) // ); return new YjsThreadStore( activeUser.id, doc.getMap("threads"), new DefaultThreadStoreAuth(activeUser.id, activeUser.role), ); }, [activeUser]); // setup the editor with comments and collaboration const editor = useCreateBlockNote( withCollaboration({ collaboration: { provider, fragment: doc.getXmlFragment("blocknote"), user: { color: getRandomColor(), name: activeUser.username }, resolveUsers: userStore, }, extensions: [CommentsExtension({ threadStore, resolveUsers: userStore })], }), [activeUser, threadStore], ); // The element in your layout that the comments sidebar is rendered into. const [sidebarElement, setSidebarElement] = useState<HTMLDivElement | null>( null, ); // The page layout is your application's own. BlockNote only renders the // editor (`BlockNoteView`) and, via `RenderInPortalElement`, the sidebar. return ( <div className={"sidebar-comments-main-container"}> <div className={"editor-layout-wrapper"}> <section className={"editor-section"}> <h1>Editor</h1> <div className={"settings"}> <SettingsSelect label={"User"} value={activeUser.id} options={HARDCODED_USERS.map((user) => ({ value: user.id, label: `${user.username} (${ user.role === "editor" ? "Editor" : "Commenter" })`, }))} onChange={(id) => { const user = HARDCODED_USERS.find((user) => user.id === id); if (user) { setActiveUser(user); } }} /> </div> <BlockNoteView editor={editor} editable={activeUser.role === "editor"} // Comments are shown in the sidebar instead of floating in the editor. comments={false} > {/* `comments={false}` also removes the floating composer, which creates new threads, so we add it back. */} <FloatingComposerController /> {/* `ThreadsSidebar` needs the editor's context, so it's rendered inside `BlockNoteView`, but `RenderInPortalElement` places it in the sidebar element of the layout below. */} {sidebarElement && ( <RenderInPortalElement target={sidebarElement}> <ThreadsSidebar filter={commentFilter} sort={commentSort} /> </RenderInPortalElement> )} </BlockNoteView> </section> </div> <aside className={"threads-sidebar-section"}> <h1>Comments</h1> <div className={"settings"}> <SettingsSelect label={"Filter"} value={commentFilter} options={[ { value: "all", label: "All" }, { value: "open", label: "Open" }, { value: "resolved", label: "Resolved" }, ]} onChange={setCommentFilter} /> <SettingsSelect label={"Sort"} value={commentSort} options={[ { value: "position", label: "Position" }, { value: "recent-activity", label: "Recent activity" }, { value: "oldest", label: "Oldest" }, ]} onChange={setCommentSort} /> </div> <div className={"threads-sidebar-slot"} ref={setSidebarElement} /> </aside> </div> );}// A plain select with a label. This is application UI, so it's built from your// own elements (or your app's component library) rather than BlockNote's// components, and can live anywhere in your layout.export function SettingsSelect<T extends string>(props: { label: string; value: T; options: { value: T; label: string }[]; onChange: (value: T) => void;}) { return ( <label className={"settings-select"}> {props.label + ":"} <select value={props.value} onChange={(event) => { const option = props.options.find( (option) => option.value === event.target.value, ); if (option) { props.onChange(option.value); } }} > {props.options.map((option) => ( <option key={option.value} value={option.value}> {option.label} </option> ))} </select> </label> );}/* The application's own layout and styling. BlockNote's `--bn-*` variables only exist inside BlockNote's (themed) elements, so the app chrome uses its own colors. */.sidebar-comments-main-container { background-color: #f0f0f0; color: #3f3f3f; display: flex; gap: 10px; height: 100%; padding: 10px; width: 100%;}@media (prefers-color-scheme: dark) { .sidebar-comments-main-container { background-color: #121212; color: #cfcfcf; }}.sidebar-comments-main-container .editor-layout-wrapper { display: flex; flex: 2; justify-content: center; width: 0;}.sidebar-comments-main-container .editor-section { flex: 1; max-width: 700px;}.sidebar-comments-main-container .editor-section,.sidebar-comments-main-container .threads-sidebar-section { display: flex; flex-direction: column; gap: 10px; max-height: 100%; min-width: 350px; width: 0;}/* A fixed-width column; the editor column takes the remaining space. */.sidebar-comments-main-container .threads-sidebar-section { flex: none; width: 360px;}.sidebar-comments-main-container h1 { font-size: 32px; margin: 0;}.sidebar-comments-main-container .settings { display: flex; flex-wrap: wrap; gap: 10px;}.sidebar-comments-main-container .settings-select { align-items: center; display: flex; font-size: 12px; font-weight: 600; gap: 8px;}.sidebar-comments-main-container .settings-select select { background-color: #ffffff; border: 1px solid #e0e0e0; border-radius: 6px; color: inherit; font: inherit; font-weight: 500; padding: 6px 8px;}@media (prefers-color-scheme: dark) { .sidebar-comments-main-container .settings-select select { background-color: #1f1f1f; border-color: #333333; }}/* The editor fills the rest of its section and scrolls on its own. */.sidebar-comments-main-container .editor-section .bn-container { display: flex; flex: 1; flex-direction: column; margin: 0; max-width: none; min-height: 0; padding: 0;}.sidebar-comments-main-container .editor-section .bn-editor { border-radius: 8px; flex: 1; overflow: auto;}/* The slot the sidebar is rendered into, and the themed root BlockNote creates inside it. */.sidebar-comments-main-container .threads-sidebar-slot,.sidebar-comments-main-container .threads-sidebar-slot > .bn-root { display: flex; flex: 1; flex-direction: column; min-height: 0;}.bn-threads-sidebar { display: flex; flex-direction: column; gap: 10px; height: 100%; overflow: auto;}import type { User } from "@blocknote/core";const colors = [ "#958DF1", "#F98181", "#FBBC88", "#FAF594", "#70CFF8", "#94FADB", "#B9F18D",];const getRandomElement = (list: any[]) => list[Math.floor(Math.random() * list.length)];export const getRandomColor = () => getRandomElement(colors);export type MyUserType = User & { role: "editor" | "comment";};export const HARDCODED_USERS: MyUserType[] = [ { id: "1", username: "John Doe", avatarUrl: "https://placehold.co/100x100?text=John", role: "editor", }, { id: "2", username: "Jane Doe", avatarUrl: "https://placehold.co/100x100?text=Jane", role: "editor", }, { id: "3", username: "Bob Smith", avatarUrl: "https://placehold.co/100x100?text=Bob", role: "comment", }, { id: "4", username: "Betty Smith", avatarUrl: "https://placehold.co/100x100?text=Betty", role: "comment", },];/// <reference types="vite-plus/client" />