Collaborative Editing Features Showcase
In this example, you can play with all of the collaboration features BlockNote has to offer:
Comments: Add comments to parts of the document - other users can then view, reply to, react to, and resolve them.
Versioning: Save snapshots of the document - later preview saved snapshots and restore them to ensure work is never lost.
Suggestions: Suggest changes directly in the editor - users can choose to then apply or reject those changes.
Relevant Docs:
import "@blocknote/core/fonts/inter.css";
import {
localStorageEndpoints,
SuggestionsExtension,
VersioningExtension,
} from "@blocknote/core/extensions";
import {
BlockNoteViewEditor,
FloatingComposerController,
useCreateBlockNote,
useEditorState,
useExtension,
useExtensionState,
} from "@blocknote/react";
import { BlockNoteView } from "@blocknote/mantine";
import "@blocknote/mantine/style.css";
import { useEffect, useMemo, useState } from "react";
import { RiChat3Line, RiHistoryLine } from "react-icons/ri";
import * as Y from "yjs";
import { getRandomColor, HARDCODED_USERS, MyUserType } from "./userdata";
import { SettingsSelect } from "./SettingsSelect";
import "./style.css";
import {
YjsThreadStore,
DefaultThreadStoreAuth,
CommentsExtension,
} from "@blocknote/core/comments";
import { CommentsSidebar } from "./CommentsSidebar";
import { VersionHistorySidebar } from "./VersionHistorySidebar";
import { SuggestionActions } from "./SuggestionActions";
import { SuggestionActionsPopup } from "./SuggestionActionsPopup";
const doc = new Y.Doc();
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));
}
export default function App() {
const [activeUser, setActiveUser] = useState<MyUserType>(HARDCODED_USERS[0]);
const threadStore = useMemo(() => {
return new YjsThreadStore(
activeUser.id,
doc.getMap("threads"),
new DefaultThreadStoreAuth(activeUser.id, activeUser.role),
);
}, [doc, activeUser]);
const editor = useCreateBlockNote({
collaboration: {
fragment: doc.getXmlFragment(),
user: { color: getRandomColor(), name: activeUser.username },
},
extensions: [
CommentsExtension({ threadStore, resolveUsers }),
SuggestionsExtension(),
VersioningExtension({
endpoints: localStorageEndpoints,
fragment: doc.getXmlFragment(),
}),
],
});
const { enableSuggestions, disableSuggestions, checkUnresolvedSuggestions } =
useExtension(SuggestionsExtension, { editor });
const hasUnresolvedSuggestions = useEditorState({
selector: () => checkUnresolvedSuggestions(),
editor,
});
const { selectSnapshot } = useExtension(VersioningExtension, { editor });
const { selectedSnapshotId } = useExtensionState(VersioningExtension, {
editor,
});
const [editingMode, setEditingMode] = useState<"editing" | "suggestions">(
"editing",
);
useEffect(() => {
setEditingMode("editing");
}, [selectedSnapshotId]);
const [sidebar, setSidebar] = useState<
"comments" | "versionHistory" | "none"
>("none");
return (
<BlockNoteView
className={"full-collaboration"}
editor={editor}
editable={
(sidebar !== "versionHistory" || selectedSnapshotId === undefined) &&
activeUser.role === "editor"
}
// In other examples, `BlockNoteView` renders both editor element itself,
// and the container element which contains the necessary context for
// BlockNote UI components. However, in this example, we want more control
// over the rendering of the editor, so we set `renderEditor` to `false`.
// Now, `BlockNoteView` will only render the container element, and we can
// render the editor element anywhere we want using `BlockNoteEditorView`.
renderEditor={false}
// We also disable the default rendering of comments in the editor, as we
// want to render them in the `ThreadsSidebar` component instead.
comments={sidebar !== "comments"}
>
<div className="full-collaboration-main-container">
{/* We place the editor, the sidebar, and any settings selects within
`BlockNoteView` as they use BlockNote UI components and need the context
for them. */}
<div className={"editor-layout-wrapper"}>
<div className="sidebar-selectors">
<div
className={`sidebar-selector ${sidebar === "versionHistory" ? "selected" : ""}`}
onClick={() => {
setSidebar((sidebar) =>
sidebar !== "versionHistory" ? "versionHistory" : "none",
);
selectSnapshot(undefined);
}}
>
<RiHistoryLine />
<span>Version History</span>
</div>
<div
className={`sidebar-selector ${sidebar === "comments" ? "selected" : ""}`}
onClick={() =>
setSidebar((sidebar) =>
sidebar !== "comments" ? "comments" : "none",
)
}
>
<RiChat3Line />
<span>Comments</span>
</div>
</div>
<div className={"editor-section"}>
{/* <h1>Editor</h1> */}
{selectedSnapshotId === undefined && (
<div className={"settings"}>
<SettingsSelect
label={"User"}
items={HARDCODED_USERS.map((user) => ({
text: `${user.username} (${
user.role === "editor" ? "Editor" : "Commenter"
})`,
icon: null,
onClick: () => {
setActiveUser(user);
},
isSelected: user.id === activeUser.id,
}))}
/>
{activeUser.role === "editor" && (
<SettingsSelect
label={"Mode"}
items={[
{
text: "Editing",
icon: null,
onClick: () => {
disableSuggestions();
setEditingMode("editing");
},
isSelected: editingMode === "editing",
},
{
text: "Suggestions",
icon: null,
onClick: () => {
enableSuggestions();
setEditingMode("suggestions");
},
isSelected: editingMode === "suggestions",
},
]}
/>
)}
{activeUser.role === "editor" &&
editingMode === "suggestions" &&
hasUnresolvedSuggestions && <SuggestionActions />}
</div>
)}
{/* Because we set `renderEditor` to false, we can now manually place
`BlockNoteViewEditor` (the actual editor component) in its own
section below the user settings select. */}
<BlockNoteViewEditor />
<SuggestionActionsPopup />
{/* Since we disabled rendering of comments with `comments={false}`,
we need to re-add the floating composer, which is the UI element that
appears when creating new threads. */}
{sidebar === "comments" && <FloatingComposerController />}
</div>
</div>
{sidebar === "comments" && <CommentsSidebar />}
{sidebar === "versionHistory" && <VersionHistorySidebar />}
</div>
</BlockNoteView>
);
}
import { ComponentProps, useComponentsContext } from "@blocknote/react";
// This component is used to display a selection dropdown with a label. By using
// the useComponentsContext hook, we can create it out of existing components
// within the same UI library that `BlockNoteView` uses (Mantine, Ariakit, or
// ShadCN), to match the design of the editor.
export const SettingsSelect = (props: {
label: string;
items: ComponentProps["FormattingToolbar"]["Select"]["items"];
}) => {
const Components = useComponentsContext()!;
return (
<div className={"settings-select"}>
<Components.Generic.Toolbar.Root className={"bn-toolbar"}>
<h2>{props.label + ":"}</h2>
<Components.Generic.Toolbar.Select
className={"bn-select"}
items={props.items}
/>
</Components.Generic.Toolbar.Root>
</div>
);
};
import { SuggestionsExtension } from "@blocknote/core/extensions";
import { useComponentsContext, useExtension } from "@blocknote/react";
import { RiArrowGoBackLine, RiCheckLine } from "react-icons/ri";
export const SuggestionActions = () => {
const Components = useComponentsContext()!;
const { applyAllSuggestions, revertAllSuggestions } =
useExtension(SuggestionsExtension);
return (
<Components.Generic.Toolbar.Root className={"bn-toolbar"}>
<Components.Generic.Toolbar.Button
label="Apply All Changes"
icon={<RiCheckLine />}
onClick={() => applyAllSuggestions()}
mainTooltip="Apply All Changes"
>
{/* Apply All Changes */}
</Components.Generic.Toolbar.Button>
<Components.Generic.Toolbar.Button
label="Revert All Changes"
icon={<RiArrowGoBackLine />}
onClick={() => revertAllSuggestions()}
mainTooltip="Revert All Changes"
>
{/* Revert All Changes */}
</Components.Generic.Toolbar.Button>
</Components.Generic.Toolbar.Root>
);
};
import { SuggestionsExtension } from "@blocknote/core/extensions";
import {
FloatingUIOptions,
GenericPopover,
GenericPopoverReference,
useBlockNoteEditor,
useComponentsContext,
useExtension,
} from "@blocknote/react";
import { flip, offset, safePolygon } from "@floating-ui/react";
import { useEffect, useMemo, useState } from "react";
import { RiArrowGoBackLine, RiCheckLine } from "react-icons/ri";
export const SuggestionActionsPopup = () => {
const Components = useComponentsContext()!;
const editor = useBlockNoteEditor<any, any, any>();
const [toolbarOpen, setToolbarOpen] = useState(false);
const {
applySuggestion,
getSuggestionAtCoords,
getSuggestionAtSelection,
getSuggestionElementAtPos,
revertSuggestion,
} = useExtension(SuggestionsExtension);
const [suggestion, setSuggestion] = useState<
| {
cursorType: "text" | "mouse";
id: string;
element: HTMLElement;
}
| undefined
>(undefined);
useEffect(() => {
const textCursorCallback = () => {
const textCursorSuggestion = getSuggestionAtSelection();
if (!textCursorSuggestion) {
setSuggestion(undefined);
setToolbarOpen(false);
return;
}
setSuggestion({
cursorType: "text",
id: textCursorSuggestion.mark.attrs.id as string,
element: getSuggestionElementAtPos(textCursorSuggestion.range.from)!,
});
setToolbarOpen(true);
};
const mouseCursorCallback = (event: MouseEvent) => {
if (suggestion !== undefined && suggestion.cursorType === "text") {
return;
}
if (!(event.target instanceof HTMLElement)) {
return;
}
const mouseCursorSuggestion = getSuggestionAtCoords({
left: event.clientX,
top: event.clientY,
});
if (!mouseCursorSuggestion) {
return;
}
const element = getSuggestionElementAtPos(
mouseCursorSuggestion.range.from,
)!;
if (element === suggestion?.element) {
return;
}
setSuggestion({
cursorType: "mouse",
id: mouseCursorSuggestion.mark.attrs.id as string,
element: getSuggestionElementAtPos(mouseCursorSuggestion.range.from)!,
});
};
const destroyOnChangeHandler = editor.onChange(textCursorCallback);
const destroyOnSelectionChangeHandler =
editor.onSelectionChange(textCursorCallback);
editor.domElement?.addEventListener("mousemove", mouseCursorCallback);
return () => {
destroyOnChangeHandler();
destroyOnSelectionChangeHandler();
editor.domElement?.removeEventListener("mousemove", mouseCursorCallback);
};
}, [editor.domElement, suggestion]);
const floatingUIOptions = useMemo<FloatingUIOptions>(
() => ({
useFloatingOptions: {
open: toolbarOpen,
onOpenChange: (open, _event, reason) => {
if (
suggestion !== undefined &&
suggestion.cursorType === "text" &&
reason === "hover"
) {
return;
}
if (reason === "escape-key") {
editor.focus();
}
setToolbarOpen(open);
},
placement: "top-start",
middleware: [offset(10), flip()],
},
useHoverProps: {
enabled: suggestion !== undefined && suggestion.cursorType === "mouse",
delay: {
open: 250,
close: 250,
},
handleClose: safePolygon({
blockPointerEvents: true,
}),
},
elementProps: {
style: {
zIndex: 50,
},
},
}),
[editor, suggestion, toolbarOpen],
);
const reference = useMemo<GenericPopoverReference | undefined>(
() => (suggestion?.element ? { element: suggestion.element } : undefined),
[suggestion?.element],
);
if (!editor.isEditable) {
return null;
}
return (
<GenericPopover reference={reference} {...floatingUIOptions}>
{suggestion && (
<Components.Generic.Toolbar.Root className={"bn-toolbar"}>
<Components.Generic.Toolbar.Button
label="Apply Change"
icon={<RiCheckLine />}
onClick={() => applySuggestion(suggestion.id)}
mainTooltip="Apply Change"
>
{/* Apply Change */}
</Components.Generic.Toolbar.Button>
<Components.Generic.Toolbar.Button
label="Revert Change"
icon={<RiArrowGoBackLine />}
onClick={() => revertSuggestion(suggestion.id)}
mainTooltip="Revert Change"
>
{/* Revert Change */}
</Components.Generic.Toolbar.Button>
</Components.Generic.Toolbar.Root>
)}
</GenericPopover>
);
};
.full-collaboration {
align-items: flex-end;
background-color: var(--bn-colors-disabled-background);
display: flex;
flex-direction: column;
gap: 10px;
height: 100%;
max-width: none;
overflow: auto;
padding: 10px;
}
.full-collaboration .full-collaboration-main-container {
display: flex;
gap: 10px;
height: 100%;
max-width: none;
width: 100%;
}
.full-collaboration .editor-layout-wrapper {
align-items: center;
display: flex;
flex: 2;
flex-direction: column;
gap: 10px;
justify-content: center;
width: 100%;
}
.full-collaboration .sidebar-selectors {
align-items: center;
display: flex;
flex-direction: row;
gap: 10px;
justify-content: space-between;
max-width: 700px;
width: 100%;
}
.full-collaboration .sidebar-selector {
align-items: center;
background-color: var(--bn-colors-menu-background);
border-radius: var(--bn-border-radius-medium);
box-shadow: var(--bn-shadow-medium);
color: var(--bn-colors-menu-text);
cursor: pointer;
display: flex;
flex-direction: row;
font-family: var(--bn-font-family);
font-weight: 600;
gap: 8px;
justify-content: center;
padding: 10px;
user-select: none;
width: 100%;
}
.full-collaboration .sidebar-selector:hover {
background-color: var(--bn-colors-hovered-background);
color: var(--bn-colors-hovered-text);
}
.full-collaboration .sidebar-selector.selected {
background-color: var(--bn-colors-selected-background);
color: var(--bn-colors-selected-text);
}
.full-collaboration .editor-section,
.full-collaboration .sidebar-section {
border-radius: var(--bn-border-radius-large);
box-shadow: var(--bn-shadow-medium);
display: flex;
flex-direction: column;
max-height: 100%;
min-width: 350px;
width: 100%;
}
.full-collaboration .editor-section h1,
.full-collaboration .sidebar-section h1 {
color: var(--bn-colors-menu-text);
margin: 0;
font-size: 32px;
}
.full-collaboration .bn-editor,
.full-collaboration .bn-threads-sidebar,
.full-collaboration .bn-versioning-sidebar {
border-radius: var(--bn-border-radius-medium);
display: flex;
flex-direction: column;
gap: 10px;
height: 100%;
overflow: auto;
}
.full-collaboration .editor-section {
background-color: var(--bn-colors-editor-background);
border-radius: var(--bn-border-radius-large);
flex: 1;
gap: 16px;
max-width: 700px;
padding-block: 16px;
}
.full-collaboration .editor-section .settings {
padding-inline: 54px;
}
.full-collaboration .sidebar-section {
background-color: var(--bn-colors-editor-background);
border-radius: var(--bn-border-radius-large);
width: 350px;
}
.full-collaboration .sidebar-section .settings {
padding-block: 16px;
padding-inline: 16px;
}
.full-collaboration .bn-threads-sidebar,
.full-collaboration .bn-versioning-sidebar {
padding-inline: 16px;
}
.full-collaboration .settings {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.full-collaboration .settings-select {
display: flex;
gap: 10px;
}
.full-collaboration .settings-select .bn-toolbar {
align-items: center;
}
.full-collaboration .settings-select h2 {
color: var(--bn-colors-menu-text);
margin: 0;
font-size: 12px;
line-height: 12px;
padding-left: 14px;
}
.full-collaboration .bn-threads-sidebar > .bn-thread {
box-shadow: var(--bn-shadow-medium) !important;
min-width: auto;
}
.full-collaboration .bn-snapshot {
background-color: var(--bn-colors-menu-background);
border: var(--bn-border);
border-radius: var(--bn-border-radius-medium);
box-shadow: var(--bn-shadow-medium);
color: var(--bn-colors-menu-text);
cursor: pointer;
flex-direction: column;
gap: 16px;
display: flex;
overflow: visible;
padding: 16px 32px;
width: 100%;
}
.full-collaboration .bn-snapshot-name {
background: transparent;
border: none;
color: var(--bn-colors-menu-text);
font-size: 16px;
font-weight: 600;
padding: 0;
width: 100%;
}
.full-collaboration .bn-snapshot-name:focus {
outline: none;
}
.full-collaboration .bn-snapshot-body {
display: flex;
flex-direction: column;
font-size: 12px;
gap: 4px;
}
.full-collaboration .bn-snapshot-button {
background-color: #4da3ff;
border: none;
border-radius: 4px;
color: var(--bn-colors-selected-text);
cursor: pointer;
font-size: 12px;
font-weight: 600;
padding: 0 8px;
width: fit-content;
}
.full-collaboration.dark .bn-snapshot-button {
background-color: #0070e8;
}
.full-collaboration .bn-snapshot-button:hover {
background-color: #73b7ff;
}
.full-collaboration.dark .bn-snapshot-button:hover {
background-color: #3785d8;
}
.full-collaboration .bn-versioning-sidebar .bn-snapshot.selected {
background-color: #f5f9fd;
border: 2px solid #c2dcf8;
}
.full-collaboration.dark .bn-versioning-sidebar .bn-snapshot.selected {
background-color: #20242a;
border: 2px solid #23405b;
}
.full-collaboration ins {
background-color: hsl(120 100 90);
color: hsl(120 100 30);
}
.dark.full-collaboration ins {
background-color: hsl(120 100 10);
color: hsl(120 80 70);
}
.full-collaboration del {
background-color: hsl(0 100 90);
color: hsl(0 100 30);
}
.dark.full-collaboration del {
background-color: hsl(0 100 10);
color: hsl(0 80 70);
}
import type { User } from "@blocknote/core/comments";
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",
},
];