diff --git a/docs/components/example/styles.css b/docs/components/example/styles.css index 55a24cbf7c..798f06ae4c 100644 --- a/docs/components/example/styles.css +++ b/docs/components/example/styles.css @@ -1,34 +1,38 @@ :focus-visible { - box-shadow: unset !important; + box-shadow: unset !important; } .demo .nextra-code-block pre { - background-color: transparent !important; - margin: 0 !important; - padding: 0 !important; + background-color: transparent !important; + margin: 0 !important; + padding: 0 !important; } .demo .nextra-code-block code > span { - padding: 0 !important; + padding: 0 !important; } -.demo .bn-container, +.demo .bn-container:not(.bn-comment-editor), .demo .bn-editor { - height: 100%; + height: 100%; +} + +.demo .bn-container:not(.bn-comment-editor) .bn-editor { + height: 100%; } .demo .bn-editor { - overflow: auto; - padding-block: 1rem; + overflow: auto; + padding-block: 1rem; } .demo-contents a { - color: revert; - text-decoration: revert; + color: revert; + text-decoration: revert; } .demo code.bn-inline-content { - font-size: 1em; - line-height: 1.5; - display: block; -} \ No newline at end of file + font-size: 1em; + line-height: 1.5; + display: block; +} diff --git a/docs/next.config.mjs b/docs/next.config.mjs index 092a11b5f0..b62735bbb2 100644 --- a/docs/next.config.mjs +++ b/docs/next.config.mjs @@ -123,6 +123,11 @@ const nextConfig = withAnalyzer( destination: "/examples/basic/default-blocks", permanent: true, }, + { + source: "/docs/advanced/real-time-collaboration", + destination: "/docs/collaboration", + permanent: true, + }, ], experimental: { externalDir: true, diff --git a/docs/pages/docs/_meta.json b/docs/pages/docs/_meta.json index 4b8c99d576..6be6575dc1 100644 --- a/docs/pages/docs/_meta.json +++ b/docs/pages/docs/_meta.json @@ -6,6 +6,7 @@ "styling-theming": "Styling & Theming", "ui-components": "UI Components", "custom-schemas": "Custom Schemas", + "collaboration": "Collaboration", "advanced": "Advanced", "discord-link": { "title": "Community ↗", diff --git a/docs/pages/docs/collaboration.mdx b/docs/pages/docs/collaboration.mdx new file mode 100644 index 0000000000..c506198434 --- /dev/null +++ b/docs/pages/docs/collaboration.mdx @@ -0,0 +1,11 @@ +--- +title: Collaboration +description: Learn how to create multiplayer experiences with BlockNote +--- + +# Collaboration (advanced) + +BlockNote supports multi-user collaborative document editing. + +- [Real-time collaboration](/docs/collaboration/real-time-collaboration) +- [Comments](/docs/collaboration/comments) diff --git a/docs/pages/docs/collaboration/_meta.json b/docs/pages/docs/collaboration/_meta.json new file mode 100644 index 0000000000..fde47be129 --- /dev/null +++ b/docs/pages/docs/collaboration/_meta.json @@ -0,0 +1,4 @@ +{ + "real-time-collaboration": "Real-time collaboration", + "comments": "Comments" +} diff --git a/docs/pages/docs/collaboration/comments.mdx b/docs/pages/docs/collaboration/comments.mdx new file mode 100644 index 0000000000..0d9210f114 --- /dev/null +++ b/docs/pages/docs/collaboration/comments.mdx @@ -0,0 +1,138 @@ +--- +title: Comments +description: Learn how to enable comments in your BlockNote editor +imageTitle: Comments +--- + +import { Example } from "@/components/example"; + +# Comments + +BlockNote supports Comments, Comment Threads (replies) and emoji reactions out of the box. + +To enable comments in your editor, you need to: + +- provide a `resolveUsers` so BlockNote can retrieve and display user information (names and avatars). +- provide a `ThreadStore` so BlockNote can store and retrieve comment threads. +- enable real-time collaboration (see [Real-time collaboration](/docs/collaboration/real-time-collaboration)) + +```tsx +const editor = useCreateBlockNote({ + resolveUsers: async (userIds: string[]) => { + // return user information for the given userIds (see below) + }, + comments: { + threadStore: yourThreadStore, // see below + }, + // ... + collaboration: { + // ... // see real-time collaboration docs + }, +}); +``` + +**Demo** + + + +## ThreadStores + +A ThreadStore is used to store and retrieve comment threads. BlockNote is backend agnostic, so you can use any database or backend to store the threads. +BlockNote comes with several built-in ThreadStore implementations: + +### `YjsThreadStore` + +The `YjsThreadStore` provides direct Yjs-based storage for comments, storing thread data directly in the Yjs document. This implementation is ideal for simple collaborative setups where all users have write access to the document. + +```tsx +import { YjsThreadStore } from "@blocknote/core"; + +const threadStore = new YjsThreadStore( + userId, // The active user's ID + yDoc.getMap("threads"), // Y.Map to store threads + new DefaultThreadStoreAuth(userId, "editor"), // Authorization information, see below +); +``` + +_Note: While this is the easiest to implement, it requires users to have write access to the Yjs document to leave comments. Also, without proper server-side validation, any user could technically modify other users' comments._ + +### `RESTYjsThreadStore` + +The `RESTYjsThreadStore` combines Yjs storage with a REST API backend, providing secure comment management while maintaining real-time collaboration. This implementation is ideal when you have strong authentication requirements, but is a little more work to set up. + +In this implementation, data is written to the Yjs document via a REST API which can handle access control. Data is still retrieved from the Yjs document directly (after it's been updated by the REST API), this way all comment information automatically syncs between clients using the existing collaboration provider. + +```tsx +import { RESTYjsThreadStore, DefaultThreadStoreAuth } from "@blocknote/core"; + +const threadStore = new RESTYjsThreadStore( + "https://api.example.com/comments", // Base URL for the REST API + { + Authorization: "Bearer your-token", // Optional headers to add to requests + }, + yDoc.getMap("threads"), // Y.Map to retrieve commend data from + new DefaultThreadStoreAuth(userId, "editor"), // Authorization rules (see below) +); +``` + +An example implementation of the REST API can be found in the [example repository](https://github.com/TypeCellOS/BlockNote-demo-nextjs-hocuspocus). + +_Note: Because writes are executed via a REST API, the `RESTYjsThreadStore` is not suitable for local-first applications that should be able to add and edit comments offline._ + +### `TiptapThreadStore` + +The `TiptapThreadStore` integrates with Tiptap's collaboration provider for comment management. This implementation is designed specifically for use with Tiptap's collaborative editing features. + +```tsx +import { TiptapThreadStore, DefaultThreadStoreAuth } from "@blocknote/core"; +import { TiptapCollabProvider } from "@hocuspocus/provider"; + +// Create a TiptapCollabProvider (you probably have this already) +const provider = new TiptapCollabProvider({ + name: "test", + baseUrl: "https://collab.yourdomain.com", + appId: "test", + document: doc, +}); + +// Create a TiptapThreadStore +const threadStore = new TiptapThreadStore( + userId, // The active user's ID + provider, // Tiptap collaboration provider + new DefaultThreadStoreAuth(userId, "editor"), // Authorization rules (see below) +); +``` + +### ThreadStoreAuth + +The `ThreadStoreAuth` class defines the authorization rules for interacting with comments. Every ThreadStore implementation requires a `ThreadStoreAuth` instance. BlockNote uses the `ThreadStoreAuth` instance to deterine which interactions are allowed for the current user (for example, whether they can create a new comment, edit or delete a comment, etc.). + +The `DefaultThreadStoreAuth` class provides a basic implementation of the `ThreadStoreAuth` class. It takes a user ID and a role ("comment" or "editor") and implements the rules. See the [source code](https://github.com/TypeCellOS/BlockNote/blob/main/packages/core/src/extensions/Comments/threadstore/DefaultThreadStoreAuth.ts) for more details. + +_Note: The `ThreadStoreAuth` only used to show / hide options in the UI. To secure comment related data, you still need to implement your own server-side validation (e.g. using `RESTYjsThreadStore` and a secure REST API)._ + +## `resolveUsers` function + +When a user interacts with a comment, the data is stored in the ThreadStore, along with the active user ID (as specified when initiating the ThreadStore). + +To display comments, BlockNote needs to retrieve user information (such as the username and avatar) based on the user ID. To do this, you need to provide a `resolveUsers` function in the editor options. + +This function is called with an array of user IDs, and should return an array of `User` objects in the same order. + +```tsx +type User = { + id: string; + username: string; + avatarUrl: string; +}; + +async function myResolveUsers(userIds: string[]): Promise { + // fetch user information from your database / backend + // and return an array of User objects + + return await callYourBackend(userIds); // + + // Return a list of users + return users; +} +``` diff --git a/docs/pages/docs/advanced/real-time-collaboration.mdx b/docs/pages/docs/collaboration/real-time-collaboration.mdx similarity index 100% rename from docs/pages/docs/advanced/real-time-collaboration.mdx rename to docs/pages/docs/collaboration/real-time-collaboration.mdx diff --git a/docs/pages/docs/editor-basics/setup.mdx b/docs/pages/docs/editor-basics/setup.mdx index d60ebdfc9b..a1a155e541 100644 --- a/docs/pages/docs/editor-basics/setup.mdx +++ b/docs/pages/docs/editor-basics/setup.mdx @@ -21,6 +21,7 @@ function useCreateBlockNote( type BlockNoteEditorOptions = { animations?: boolean; collaboration?: CollaborationOptions; + comments?: CommentsConfig; defaultStyles?: boolean; dictionary?: Dictionary; disableExtensions?: string[]; @@ -50,6 +51,8 @@ The hook takes two optional parameters: `collaboration`: Options for enabling real-time collaboration. See [Real-time Collaboration](/docs/advanced/real-time-collaboration) for more info. +`comments`: Configuration for the comments feature, requires a `threadStore`. See [Comments](/docs/collaboration/comments) for more. + `defaultStyles`: Whether to use the default font and reset the styles of `

`, `

  • `, `

    `, etc. elements that are used in BlockNote. Defaults to true if undefined. `dictionary`: Provide strings for localization. See the [Localization / i18n example](/examples/basic/localization) and [Custom Placeholders](/examples/basic/custom-placeholder). @@ -62,7 +65,9 @@ The hook takes two optional parameters: `initialContent:` The content that should be in the editor when it's created, represented as an array of [Partial Blocks](/docs/manipulating-blocks#partial-blocks). -`resolveFileUrl:` An async function that fetches the download URL of a file from an initial URL. +`resolveFileUrl:` Function to resolve file URLs for display/download. Useful for creating authenticated URLs or implementing custom protocols. + +`resolveUsers`: Function to resolve user information for comments. See [Comments](/docs/collaboration/comments) for more. `schema`: The editor schema if you want to extend your editor with custom blocks, styles, or inline content [Custom Schemas](/docs/custom-schemas). @@ -70,7 +75,7 @@ The hook takes two optional parameters: `sideMenuDetection`: Determines whether the mouse cursor position is locked to the editor bounding box for showing the [Block Side Menu](/docs/ui-components/side-menu) and block drag & drop. When set to `viewport`, the Side Menu will be shown next to the nearest block to the cursor, regardless of where it is in the viewport. Dropping blocks will also be locked to the editor bounding box. Otherwise, the Side Menu will only be shown when the cursor is within the editor bounds, and blocks can only be dropped when hovering the editor. In order to use multiple editors, must be set to `editor`. Defaults to `viewport`. -`tabBehavior`: Determines whether pressing the tab key should navigate the UI for keyboard accessibility or indent the current block. Defaults to `prefer-navigate-ui`. +`tabBehavior`: Determines whether pressing the tab key should navigate toolbars for keyboard accessibility. When set to `"prefer-navigate-ui`, the user can navigate toolbars using Tab. Pressing Escape re-focuses the editor, and Tab now indents blocks. `"prefer-indent"` causes Tab to always indent blocks. Defaults to `prefer-navigate-ui`. `trailingBlock`: An option which user can pass with `false` value to disable the automatic creation of a trailing new block on the next line when the user types or edits any block. Defaults to `true` if undefined. @@ -125,6 +130,7 @@ export type BlockNoteViewProps = { emojiPicker?: boolean; filePanel?: boolean; tableHandles?: boolean; + comments?: boolean; children?: } & HTMLAttributes; ``` @@ -153,6 +159,8 @@ export type BlockNoteViewProps = { `tableHandles`: Whether the Table Handles should be enabled. +`comments`: Whether the default comments UI feature should be enabled. + `children`: Pass child elements to the `BlockNoteView` to create or customize toolbars, menus, or other UI components. See [UI Components](/docs/ui-components) for more. Additional props passed are forwarded to the HTML `div` element BlockNote renders internally. diff --git a/examples/01-basic/01-minimal/App.tsx b/examples/01-basic/01-minimal/App.tsx index a3b92bafd2..d4fd6f2e12 100644 --- a/examples/01-basic/01-minimal/App.tsx +++ b/examples/01-basic/01-minimal/App.tsx @@ -5,7 +5,7 @@ import { useCreateBlockNote } from "@blocknote/react"; export default function App() { // Creates a new editor instance. - const editor = useCreateBlockNote(); + const editor = useCreateBlockNote({}); // Renders the editor instance using a React component. return ; diff --git a/examples/01-basic/02-block-objects/App.tsx b/examples/01-basic/02-block-objects/App.tsx index 846df5d180..b33a9561e7 100644 --- a/examples/01-basic/02-block-objects/App.tsx +++ b/examples/01-basic/02-block-objects/App.tsx @@ -1,8 +1,8 @@ import { Block } from "@blocknote/core"; import "@blocknote/core/fonts/inter.css"; -import { useCreateBlockNote } from "@blocknote/react"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; +import { useCreateBlockNote } from "@blocknote/react"; import { useState } from "react"; import "./styles.css"; diff --git a/examples/07-collaboration/01-partykit/README.md b/examples/07-collaboration/01-partykit/README.md index c7eeccd037..6480c3fef5 100644 --- a/examples/07-collaboration/01-partykit/README.md +++ b/examples/07-collaboration/01-partykit/README.md @@ -6,5 +6,5 @@ In this example, we use PartyKit to let multiple users collaborate on a single B **Relevant Docs:** -- [PartyKit](/docs/advanced/real-time-collaboration#partykit) - [Editor Setup](/docs/editor-basics/setup) +- [PartyKit](/docs/collaboration/real-time-collaboration#partykit) diff --git a/examples/07-collaboration/02-liveblocks/App.tsx b/examples/07-collaboration/02-liveblocks/App.tsx index 854b6b1091..6a64dc8c29 100644 --- a/examples/07-collaboration/02-liveblocks/App.tsx +++ b/examples/07-collaboration/02-liveblocks/App.tsx @@ -1,7 +1,7 @@ import "@blocknote/core/fonts/inter.css"; -import { useCreateBlockNote } from "@blocknote/react"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; +import { useCreateBlockNote } from "@blocknote/react"; import { createClient } from "@liveblocks/client"; import LiveblocksProvider from "@liveblocks/yjs"; import * as Y from "yjs"; diff --git a/examples/07-collaboration/02-liveblocks/README.md b/examples/07-collaboration/02-liveblocks/README.md index 2f0d6a6bab..62652c386e 100644 --- a/examples/07-collaboration/02-liveblocks/README.md +++ b/examples/07-collaboration/02-liveblocks/README.md @@ -6,5 +6,5 @@ In this example, we use Liveblocks to let multiple users collaborate on a single **Relevant Docs:** -- [Liveblocks](/docs/advanced/real-time-collaboration#liveblocks) - [Editor Setup](/docs/editor-basics/setup) +- [Liveblocks](/docs/collaboration/real-time-collaboration#liveblocks) diff --git a/examples/07-collaboration/03-y-sweet/README.md b/examples/07-collaboration/03-y-sweet/README.md index 1c7334cce4..97e8961a12 100644 --- a/examples/07-collaboration/03-y-sweet/README.md +++ b/examples/07-collaboration/03-y-sweet/README.md @@ -6,5 +6,6 @@ In this example, we use Y-Sweet to let multiple users collaborate on a single Bl **Relevant Docs:** -- [Y-Sweet on Jamsocket](https://docs.jamsocket.com/y-sweet/tutorials/blocknote) - [Editor Setup](/docs/editor-basics/setup) +- [Real-time collaboration](/docs/collaboration/real-time-collaboration) +- [Y-Sweet on Jamsocket](https://docs.jamsocket.com/y-sweet/tutorials/blocknote) diff --git a/examples/07-collaboration/04-comments/.bnexample.json b/examples/07-collaboration/04-comments/.bnexample.json new file mode 100644 index 0000000000..da901b915b --- /dev/null +++ b/examples/07-collaboration/04-comments/.bnexample.json @@ -0,0 +1,10 @@ +{ + "playground": true, + "docs": true, + "author": "yousefed", + "tags": ["Advanced", "Comments", "Collaboration"], + "dependencies": { + "@y-sweet/react": "^0.6.3", + "@mantine/core": "^7.10.1" + } +} diff --git a/examples/07-collaboration/04-comments/App.tsx b/examples/07-collaboration/04-comments/App.tsx new file mode 100644 index 0000000000..13278351f4 --- /dev/null +++ b/examples/07-collaboration/04-comments/App.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { DefaultThreadStoreAuth, YjsThreadStore } from "@blocknote/core"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { useCreateBlockNote } from "@blocknote/react"; +import { MantineProvider, Select } from "@mantine/core"; +import { YDocProvider, useYDoc, useYjsProvider } from "@y-sweet/react"; +import { useMemo, useState } from "react"; +import { HARDCODED_USERS, MyUserType, getRandomColor } from "./userdata"; + +// 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)); +} + +// 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 docId = "my-blocknote-document-with-comments"; + + return ( + + + + + + ); +} + +function Document() { + const [user, setUser] = useState(HARDCODED_USERS[0]); + const provider = useYjsProvider(); + + // take the Y.Doc collaborative document from Y-Sweet + const doc = useYDoc(); + + // 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( + // user.id, + // provider, + // new DefaultThreadStoreAuth(user.id, user.role) + // ); + return new YjsThreadStore( + user.id, + doc.getMap("threads"), + new DefaultThreadStoreAuth(user.id, user.role) + ); + }, [doc, user]); + + // setup the editor with comments and collaboration + const editor = useCreateBlockNote( + { + resolveUsers, + comments: { + threadStore, + }, + collaboration: { + provider, + fragment: doc.getXmlFragment("blocknote"), + user: { color: getRandomColor(), name: user.username }, + }, + }, + [user, threadStore] + ); + + return ( +
    + {/* This is a simple user selector to switch between users, for demo purposes */} +