🚀 BlockNote AI is here! Access the early preview.

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 { ThreadsSidebar } from "@blocknote/react";
import { useState } from "react";

import { SettingsSelect } from "./SettingsSelect";

export const CommentsSidebar = () => {
  const [filter, setFilter] = useState<"open" | "resolved" | "all">("open");
  const [sort, setSort] = useState<"position" | "recent-activity" | "oldest">(
    "position",
  );

  return (
    <div className={"sidebar-section"}>
      <div className={"settings"}>
        <SettingsSelect
          label={"Filter"}
          items={[
            {
              text: "All",
              icon: null,
              onClick: () => setFilter("all"),
              isSelected: filter === "all",
            },
            {
              text: "Open",
              icon: null,
              onClick: () => setFilter("open"),
              isSelected: filter === "open",
            },
            {
              text: "Resolved",
              icon: null,
              onClick: () => setFilter("resolved"),
              isSelected: filter === "resolved",
            },
          ]}
        />
        <SettingsSelect
          label={"Sort"}
          items={[
            {
              text: "Position",
              icon: null,
              onClick: () => setSort("position"),
              isSelected: sort === "position",
            },
            {
              text: "Recent activity",
              icon: null,
              onClick: () => setSort("recent-activity"),
              isSelected: sort === "recent-activity",
            },
            {
              text: "Oldest",
              icon: null,
              onClick: () => setSort("oldest"),
              isSelected: sort === "oldest",
            },
          ]}
        />
      </div>
      <ThreadsSidebar filter={filter} sort={sort} />
    </div>
  );
};
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>
  );
};
import { VersioningSidebar } from "@blocknote/react";
import { useState } from "react";

import { SettingsSelect } from "./SettingsSelect";

export const VersionHistorySidebar = () => {
  const [filter, setFilter] = useState<"named" | "all">("all");

  return (
    <div className={"sidebar-section"}>
      <div className={"settings"}>
        <SettingsSelect
          label={"Filter"}
          items={[
            {
              text: "All",
              icon: null,
              onClick: () => setFilter("all"),
              isSelected: filter === "all",
            },
            {
              text: "Named",
              icon: null,
              onClick: () => setFilter("named"),
              isSelected: filter === "named",
            },
          ]}
        />
      </div>
      <VersioningSidebar filter={filter} />
    </div>
  );
};
.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",
  },
];