Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@
"twoslash": "^0.3.4",
"y-partykit": "^0.0.25",
"yjs": "^13.6.27",
"zod": "^3.25.76"
"zod": "^3.25.76",
"@floating-ui/react": "^0.27.16"
},
"devDependencies": {
"@blocknote/ariakit": "workspace:*",
Expand Down
11 changes: 11 additions & 0 deletions examples/07-collaboration/09-versioning/.bnexample.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"playground": true,
"docs": true,
"author": "matthewlipski",
"tags": ["Advanced", "Development", "Collaboration"],
"dependencies": {
"@floating-ui/react": "^0.27.16",
"react-icons": "^5.2.1",
"yjs": "^13.6.27"
}
}
15 changes: 15 additions & 0 deletions examples/07-collaboration/09-versioning/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# 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:**

- [Editor Setup](/docs/getting-started/editor-setup)
- [Comments](/docs/features/collaboration/comments)
- [Real-time collaboration](/docs/features/collaboration)
14 changes: 14 additions & 0 deletions examples/07-collaboration/09-versioning/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Collaborative Editing Features Showcase</title>
<script>
<!-- AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY -->
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
11 changes: 11 additions & 0 deletions examples/07-collaboration/09-versioning/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./src/App.jsx";

const root = createRoot(document.getElementById("root")!);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
34 changes: 34 additions & 0 deletions examples/07-collaboration/09-versioning/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"name": "@blocknote/example-collaboration-versioning",
"description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
"type": "module",
"private": true,
"version": "0.12.4",
"scripts": {
"start": "vite",
"dev": "vite",
"build:prod": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@blocknote/ariakit": "latest",
"@blocknote/core": "latest",
"@blocknote/mantine": "latest",
"@blocknote/react": "latest",
"@blocknote/shadcn": "latest",
"@mantine/core": "^8.3.4",
"@mantine/hooks": "^8.3.4",
"@mantine/utils": "^6.0.22",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"@floating-ui/react": "^0.27.16",
"react-icons": "^5.2.1",
"yjs": "^13.6.27"
},
"devDependencies": {
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^4.7.0",
"vite": "^5.4.20"
}
}
204 changes: 204 additions & 0 deletions examples/07-collaboration/09-versioning/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
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>
);
}
65 changes: 65 additions & 0 deletions examples/07-collaboration/09-versioning/src/CommentsSidebar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
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>
);
};
24 changes: 24 additions & 0 deletions examples/07-collaboration/09-versioning/src/SettingsSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
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>
);
};
Loading
Loading