评论
BlockNote 原生支持评论、评论线程(回复)和表情反应。
要在编辑器中启用评论,你需要:
- 提供一个
resolveUsers
函数,使 BlockNote 能检索并显示用户信息(姓名和头像)。 - 提供一个
ThreadStore
,使 BlockNote 能存储和检索评论线程。 - 启用实时协作(参见 实时协作)
const editor = useCreateBlockNote({
resolveUsers: async (userIds: string[]) => {
// 返回给定 userIds 的用户信息(见下文)
},
comments: {
threadStore: yourThreadStore, // 见下文
},
// ...
collaboration: {
// ... // 参见实时协作文档
},
});
示例
"use client";
import {
DefaultThreadStoreAuth,
YjsThreadStore,
} from "@blocknote/core/comments";
import { BlockNoteView } from "@blocknote/mantine";
import "@blocknote/mantine/style.css";
import { useCreateBlockNote } from "@blocknote/react";
import { YDocProvider, useYDoc, useYjsProvider } from "@y-sweet/react";
import { useMemo, useState } from "react";
import { SettingsSelect } from "./SettingsSelect.js";
import { HARDCODED_USERS, MyUserType, getRandomColor } from "./userdata.js";
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));
}
// 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-commenting";
return (
<YDocProvider
docId={docId}
authEndpoint="https://demos.y-sweet.dev/api/auth"
>
<Document />
</YDocProvider>
);
}
function Document() {
const [activeUser, setActiveUser] = useState<MyUserType>(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(
// activeUser.id,
// provider,
// new DefaultThreadStoreAuth(activeUser.id, activeUser.role)
// );
return new YjsThreadStore(
activeUser.id,
doc.getMap("threads"),
new DefaultThreadStoreAuth(activeUser.id, activeUser.role),
);
}, [doc, activeUser]);
// setup the editor with comments and collaboration
const editor = useCreateBlockNote(
{
resolveUsers,
comments: {
threadStore,
},
collaboration: {
provider,
fragment: doc.getXmlFragment("blocknote"),
user: { color: getRandomColor(), name: activeUser.username },
},
},
[activeUser, threadStore],
);
return (
<BlockNoteView
className={"comments-main-container"}
editor={editor}
editable={activeUser.role === "editor"}
>
{/* We place user settings select within `BlockNoteView` as it uses
BlockNote UI components and needs the context for them. */}
<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,
}))}
/>
</div>
</BlockNoteView>
);
}
ThreadStores
ThreadStore 用于存储和检索评论线程。BlockNote 与后端无关,所以你可以使用任何数据库或后端来存储线程。
BlockNote 提供了几种内置的 ThreadStore 实现:
YjsThreadStore
YjsThreadStore
提供了基于 Yjs 的直接存储评论的功能,在线程数据直接存储于 Yjs 文档中。此实现适合所有用户拥有文档写权限的简单协作场景。
import { YjsThreadStore } from "@blocknote/core/comments";
const threadStore = new YjsThreadStore(
userId, // 当前用户 ID
yDoc.getMap("threads"), // 用于存储线程的 Y.Map
new DefaultThreadStoreAuth(userId, "editor"), // 授权信息,详见下文
);
注意:这是最简单的实现方式,但要求用户必须对 Yjs 文档有写权限才能发表评论。此外,如果没有适当的服务器端验证,任何用户理论上都可以修改其他用户的评论。
RESTYjsThreadStore
RESTYjsThreadStore
将 Yjs 存储与 REST API 后端结合,在保持实时协作的同时提供安全的评论管理。此实现适合有严格身份认证需求的场景,但配置稍复杂。
本实现通过 REST API 将数据写入 Yjs 文档,并由 REST API 处理访问控制。数据读取仍直接从 Yjs 文档获取(在 REST API 更新后),这样所有评论信息会自动通过已有协作提供者同步到客户端。
import {
RESTYjsThreadStore,
DefaultThreadStoreAuth,
} from "@blocknote/core/comments";
const threadStore = new RESTYjsThreadStore(
"https://api.example.com/comments", // REST API 基础 URL
{
Authorization: "Bearer your-token", // 请求可选添加的头部
},
yDoc.getMap("threads"), // 从该 Y.Map 获取评论数据
new DefaultThreadStoreAuth(userId, "editor"), // 授权规则(见下文)
);
REST API 的示例实现可见于示例仓库 (opens in a new tab)。
注意:由于写操作通过 REST API 执行,RESTYjsThreadStore
不适合本地优先应用,后者需要支持离线添加和编辑评论。
TiptapThreadStore
TiptapThreadStore
集成了 Tiptap 的协作提供者用于评论管理。此实现专门设计用于 Tiptap 协作编辑功能。
import {
TiptapThreadStore,
DefaultThreadStoreAuth,
} from "@blocknote/core/comments";
import { TiptapCollabProvider } from "@hocuspocus/provider";
// 创建一个 TiptapCollabProvider(你可能已经有了)
const provider = new TiptapCollabProvider({
name: "test",
baseUrl: "https://collab.yourdomain.com",
appId: "test",
document: doc,
});
// 创建一个 TiptapThreadStore
const threadStore = new TiptapThreadStore(
userId, // 当前用户 ID
provider, // Tiptap 协作提供者
new DefaultThreadStoreAuth(userId, "editor"), // 授权规则(见下文)
);
ThreadStoreAuth
ThreadStoreAuth
类定义了对评论操作的授权规则。每个 ThreadStore 实现都需要一个 ThreadStoreAuth
实例。BlockNote 使用该实例判断当前用户允许的操作(例如,是否可以创建新评论、编辑或删除评论等)。
DefaultThreadStoreAuth
类是 ThreadStoreAuth
的基础实现。它接受用户 ID 和角色("comment" 或 "editor")并实现对应规则。详情请参见源码 (opens in a new tab)。
注意:ThreadStoreAuth
只用于在 UI 中显示或隐藏操作选项。要确保评论相关数据安全,仍需实现自己的服务器端验证(例如结合 RESTYjsThreadStore
和安全的 REST API)。
resolveUsers
函数
当用户与评论交互时,数据会与激活用户 ID 一起存储于 ThreadStore(此 ID 由你初始化 ThreadStore 时指定)。
要显示评论,BlockNote 需要根据用户 ID 获取用户信息(例如用户名和头像),因此你需要在编辑器选项中提供一个 resolveUsers
函数。
该函数接收一个用户 ID 数组,应该返回对应顺序的 User
对象数组。
type User = {
id: string;
username: string;
avatarUrl: string;
};
async function myResolveUsers(userIds: string[]): Promise<User[]> {
// 从你的数据库或后端获取用户信息
// 并返回 User 对象数组
return await callYourBackend(userIds);
// 返回用户列表
return users;
}
侧边栏视图
BlockNote 还提供了另一种查看和操作评论的方式,通过侧边栏而非在编辑器中悬浮,使用 ThreadsSidebar
组件:
"use client";
import {
DefaultThreadStoreAuth,
YjsThreadStore,
} from "@blocknote/core/comments";
import { BlockNoteView } from "@blocknote/mantine";
import "@blocknote/mantine/style.css";
import {
BlockNoteViewEditor,
FloatingComposerController,
ThreadsSidebar,
useCreateBlockNote,
} from "@blocknote/react";
import { YDocProvider, useYDoc, useYjsProvider } from "@y-sweet/react";
import { useMemo, useState } from "react";
import { SettingsSelect } from "./SettingsSelect.js";
import { HARDCODED_USERS, MyUserType, getRandomColor } from "./userdata.js";
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));
}
// 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-2";
return (
<YDocProvider
docId={docId}
authEndpoint="https://demos.y-sweet.dev/api/auth"
>
<Document />
</YDocProvider>
);
}
function Document() {
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");
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(
// activeUser.id,
// provider,
// new DefaultThreadStoreAuth(activeUser.id, activeUser.role)
// );
return new YjsThreadStore(
activeUser.id,
doc.getMap("threads"),
new DefaultThreadStoreAuth(activeUser.id, activeUser.role),
);
}, [doc, activeUser]);
// setup the editor with comments and collaboration
const editor = useCreateBlockNote(
{
resolveUsers,
comments: {
threadStore,
},
collaboration: {
provider,
fragment: doc.getXmlFragment("blocknote"),
user: { color: getRandomColor(), name: activeUser.username },
},
},
[activeUser, threadStore],
);
return (
<BlockNoteView
className={"sidebar-comments-main-container"}
editor={editor}
editable={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={false}
>
{/* 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={"editor-section"}>
<h1>Editor</h1>
<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,
}))}
/>
</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 />
{/* 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. */}
<FloatingComposerController />
</div>
</div>
{/* We also place the `ThreadsSidebar` component in its own section,
along with settings for filtering and sorting. */}
<div className={"threads-sidebar-section"}>
<h1>Comments</h1>
<div className={"settings"}>
<SettingsSelect
label={"Filter"}
items={[
{
text: "All",
icon: null,
onClick: () => setCommentFilter("all"),
isSelected: commentFilter === "all",
},
{
text: "Open",
icon: null,
onClick: () => setCommentFilter("open"),
isSelected: commentFilter === "open",
},
{
text: "Resolved",
icon: null,
onClick: () => setCommentFilter("resolved"),
isSelected: commentFilter === "resolved",
},
]}
/>
<SettingsSelect
label={"Sort"}
items={[
{
text: "Position",
icon: null,
onClick: () => setCommentSort("position"),
isSelected: commentSort === "position",
},
{
text: "Recent activity",
icon: null,
onClick: () => setCommentSort("recent-activity"),
isSelected: commentSort === "recent-activity",
},
{
text: "Oldest",
icon: null,
onClick: () => setCommentSort("oldest"),
isSelected: commentSort === "oldest",
},
]}
/>
</div>
<ThreadsSidebar filter={commentFilter} sort={commentSort} />
</div>
</BlockNoteView>
);
}
ThreadsSidebar
唯一要求是必须放置于你的 BlockNoteView
内部,除此之外位置和样式均可自由定制。
ThreadsSidebar
接收 2 个属性:
filter
:过滤侧边栏中的评论。可传 "open"
、"resolved"
或 "all"
,分别只显示未解决、已解决或所有评论。默认值为 "all"
。
sort
:侧边栏评论排序。可传 "position"
、"recent-activity"
或 "oldest"
。按 "recent-activity"
使用最近添加的评论排序线程,"oldest"
使用线程创建时间排序。按 "position"
则根据评论在编辑器中参考文本的位置排序。默认值为 "position"
。
maxCommentsBeforeCollapse
:线程中文本回复数超过该值后自动折叠回复。默认值为 5。
ThreadsSidebar
组件的独立示例见此处 (opens in a new tab)。