tldraw 评论工具包实战:用 commentSchemaRecords、CommentTool 与 CanvasComments 在画布上落地评论线程
本文以 tldraw 官方示例 commenting 为主体,讲清如何在基于 tldraw SDK 的 React 应用中接入画布评论:评论线程如何作为 store 记录实现持久化与同步、四步集成流程(注册 commentSchemaRecords、注册 CommentTool、挂载 commentToolOverrides、渲染 CanvasComments)如何完整落地,以及如何配置区域锚点、@ 提及与许可门控。读完后,你既能复制一套可运行的评论集成代码,也能从源码层面理解评论工具的状态机、锚点数据模型与 CommentingOptions 各项配置的真实含义。
功能概览与许可前提
@tldraw/commenting 是 tldraw 的评论工具包(commenting toolkit):它把"在画布上发起讨论"实现为一组画布覆盖层 + 一个状态工具。按照示例 README 的原话,集成全部就三件事:
- 评论线程是编辑器 store 中的记录,所以它们的持久化和同步方式与形状(shapes)完全一致;
- 在 schema 上注册
commentSchemaRecords,给编辑器加上CommentTool和commentToolOverrides; - 在
InFrontOfTheCanvas插槽中渲染<CanvasComments />。
需要特别注意:评论是一个许可(licensed)功能。本地开发时一切功能默认开启,但部署后的应用需要包含 commenting(或包含它的 collaboration 总许可)的 license key。这一行为在源码中由许可门控 hook 实现:
// packages/commenting/src/canvas/license.ts
export function useCommentingEnabled(): boolean {
return useLicenseFeatureFlag(useMaybeLicenseManager(), 'commenting')
}
从源码注释看,该 hook 是响应式的:许可校验完成前返回 false,因此 CanvasComments、CanvasCommentsSidebar 以及评论工具的 Quick Action 会在校验通过前保持隐藏。如果你有自定义评论 UI,也可以直接用它做同样的门控。
数据模型:评论就是 store 里的三类记录
"评论和形状一样持久化、一样同步"这句话的底层依据,是 TLComment.ts 中定义的三类文档记录。它们不在默认 schema 中,需要显式注册:
| 记录类型 | 说明 | 关键字段 |
|---|---|---|
comment-thread |
线程,持有锚点与解析状态 | pageId、anchor、createdBy、createdAt、resolved、isDeleted |
comment |
线程内的一条消息 | threadId、pageId(冗余存储免去 join)、authorId、body(富文本)、editedAt、isDeleted |
comment-reaction |
某用户对某条评论的一个 emoji 反应 | commentId、threadId、pageId、userId、emoji |
统一通过 commentSchemaRecords 注册,源码定义在 TLComment.ts#L304-L308:
export const commentSchemaRecords = {
'comment-thread': commentThreadRecordConfig,
comment: commentRecordConfig,
'comment-reaction': commentReactionRecordConfig,
}
源码注释强调:三种类型要成对一起注册——客户端和服务端必须一致,只注册其中一个会导致 schema 校验在连接的一侧失败。
锚点:区分联合(discriminated union)设计
线程"钉"在画布哪里,由 TLCommentAnchor 描述,是一个按 type 区分的联合类型(TLComment.ts#L24-L38),对应示例 README 中提到的三种交互方式,外加一种页级线程:
export type TLCommentAnchor =
| { type: 'shape'; shapeId: TLShapeId; x: number; y: number; isPrecise: boolean }
| { type: 'point'; x: number; y: number }
| { type: 'region'; x: number; y: number; w: number; h: number; pinX?: number; pinY?: number }
| { type: 'page' }
shape:钉在某个形状上。x/y是形状边界内的归一化坐标(0–1),所以形状移动、缩放、旋转时钉点位置不失真;isPrecise表示是否钉在精确点击位置,false时钉点落在消费方定义的固定位置(默认右上角)。point:钉在页面的固定坐标上,对应"点击空白处开一个线程"。region:钉在页面矩形区域内,对应"用评论工具拖出一个区域"(见下文enableRegions);pinX/pinY记录拖拽释放的角。page:无空间锚点的页级线程。
采用区分联合而非单一结构,意味着后续可以新增锚点类型而不破坏已有线程——这一点在记录的迁移序列中也能看到:com.tldraw.comment-thread/2 迁移为 shape 锚点补齐归一化 x/y 与 isPrecise,/3 为 region 锚点增加了 pinX/pinY(TLComment.ts#L204-L238)。
两条值得记住的设计约定
- 删除是软删除。
comment-thread和comment都用isDeleted标志位表达删除,客户端置位但保留记录。源码注释说明:同步服务端应把它当作 write-once 且仅创建者可执行,拒绝客户端硬删除,并在后续房间加载时丢弃已标记线程(TLComment.ts#L66-L71)。 - 评论变更刻意不进撤销栈。
TLComment的注释明确建议以{ history: 'ignore' }创建/编辑评论,避免多人协作中的意外——比如撤销一个删除操作,会把协作者已经删掉的评论"复活"。这也解释了CommentingOptions中history选项的默认值(见后文配置节)。 - 反应是独立记录而非评论字段。因为评论记录的更新权限属于作者(owner-only),把共享字段写在评论上会让并发反应互相覆盖;且反应记录的 id 由
(commentId, userId, emoji)三元组派生(createCommentReactionId,各部分先 URI 编码再拼接,保证单射),同一三元组永远指向同一条记录——两个标签页并发点同一个 emoji 会收敛到同一记录而不是竞态出两条。
快速集成:从 README 到可运行的完整代码
下面以示例 CommentingExample.tsx 为准,给出完整可复制的集成代码,并逐段说明。示例位于 apps/examples 应用内(priority: 1,即协作类示例中的第一个)。
import {
CanvasComments,
CommentAuthor,
CommentTool,
commentToolOverrides,
filterMentionMembers,
MentionMember,
} from '@tldraw/commenting'
import { getLicenseKey } from '@tldraw/dotcom-shared'
import { useMemo } from 'react'
import { commentSchemaRecords, createTLSchema, createTLStore, TLComponents, Tldraw } from 'tldraw'
import '@tldraw/commenting/commenting.css'
import 'tldraw/tldraw.css'
// 1. 本地"作者目录":真实应用应从自己的身份系统解析
const MEMBERS: MentionMember[] = [
{ id: 'me', name: 'You', color: '#EC5E41', you: true },
{ id: 'ada', name: 'Ada Lovelace', color: '#0E9F6E', image: ADA_AVATAR },
{ id: 'grace', name: 'Grace Hopper', color: '#4465E9' },
{ id: 'alan', name: 'Alan Turing', color: '#9C1FBE' },
]
const AUTHORS: Record<string, CommentAuthor> = Object.fromEntries(MEMBERS.map((m) => [m.id, m]))
const resolveAuthor = (id: string): CommentAuthor => AUTHORS[id] ?? { name: id }
// 2. 区域评论默认关闭;这里显式开启:拖拽评论工具会画出矩形区域锚点
const COMMENT_TOOLS = [CommentTool.configure({ enableRegions: true })]
export default function CommentingExample() {
// 3. 评论存为 comment-thread / comment 记录。
// 在 schema 上注册 commentSchemaRecords 即完成持久化与同步,
// 无需额外后端——本示例整体在内存中运行。
const store = useMemo(
() => createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
[]
)
// 4. CanvasComments 响应式读取这些记录,画出钉点、线程与输入框。
// 把它挂在画布前面就是整个 UI 层。
const components = useMemo<TLComponents>(
() => ({
InFrontOfTheCanvas: () => (
<CanvasComments
currentUserId="me"
resolveAuthor={resolveAuthor}
getMentionSuggestions={(query) => filterMentionMembers(MEMBERS, query)}
/>
),
}),
[]
)
return (
<div className="tldraw__editor">
<Tldraw
// 评论是许可功能:本地开发全开;部署需要包含 commenting 的 key
licenseKey={getLicenseKey()}
store={store}
tools={COMMENT_TOOLS}
overrides={[commentToolOverrides]}
components={components}
/>
</div>
)
}
四个要点逐一拆解:
(1)store 与 schema 注册。 createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }) 就是把三类评论记录纳入文档 schema。示例是纯内存 store,所以无需后端;接入真实协作时,在同步服务端注册同样的 commentSchemaRecords 即可——按 TLComment.ts 的注释,线程和评论是文档记录,但设计上走同步服务端的 object-store 通道:受会话的 objectAccess(而非 isReadonly)门控,这样"可评论但不可编辑"这种权限就能表达出来,且它们会被排除在文档快照和服务端 .tldr 导出之外,持久化在主文档之外的独立通道。
(2)工具与 UI 覆盖注册。 tools={[CommentTool.configure({ enableRegions: true })]} 注册评论工具并开启区域评论(详见下文);overrides={[commentToolOverrides]} 把工具暴露到 UI 层。commentToolOverrides 的实现只有短短几行(comment-tool.tsx#L283-L294):
export const commentToolOverrides: TLUiOverrides = {
tools(editor, tools) {
tools.comment = {
id: 'comment',
icon: 'comment',
label: 'Comment',
kbd: 'c',
onSelect: () => editor.setCurrentTool('comment'),
}
return tools
},
}
注册之后,tldraw 的 DefaultQuickActionsContent 会自动显示评论按钮,kbd: 'c' 正是 README 里"按 c 键选择评论工具"的快捷键来源。
(3)InFrontOfTheCanvas 插槽 + <CanvasComments />。 这是整个 UI 层的唯一挂载点。示例传入的 props:
currentUserId="me":当前用户 id,帖子会用它作为作者/创建者;发帖也要求它存在;resolveAuthor:把 id 解析为展示信息(名字、颜色、头像)。CommentAuthor直接复用MentionMember的结构,解析不到时示例降级为{ name: id };getMentionSuggestions:输入框里打@后的建议来源。示例用工具包导出的filterMentionMembers(MEMBERS, query)对成员列表做前缀过滤。
(4)许可 key。 示例用 getLicenseKey()(来自 @tldraw/dotcom-shared,示例仓库的取 key 便捷函数)传入 licenseKey;实际部署时替换为自己的、包含 commenting 的 key。
别忘了两条样式导入:'@tldraw/commenting/commenting.css' 与 'tldraw/tldraw.css'。
交互细节:点选、形状锚点与区域拖拽
README 用一句话概括了交互:按 c 或选中评论工具后,点击任意处开一个点锚线程,点击形状把线程挂到该形状上,拖拽则评论一个区域。这句话背后是 comment-tool.tsx 中的一个三状态机 StateNode:
CommentTool (id: 'comment')
├── CommentIdle 悬停:更新"将要锚定的形状"提示
├── CommentPointing 按下即开输入框,输入框跟随指针(像放便利贴)
└── CommentDragging 超过拖拽阈值且开启 enableRegions 时切换,画区域矩形
几个从源码可以确认的行为细节:
- 按下即开输入框:
CommentPointing.onEnter立刻在按下点创建pendingComment(此时锚点是裸点),拖拽过程中输入框跟随指针移动——"放置评论"的手感因此类似放置便利贴。 - 松手时才落锚点:
onPointerUp用与锚点解析相同的命中测试commentTargetShapeAt判断指针下有没有形状;有则解析为shape锚点(并调用shouldBePrecise决定是否精确钉位),没有则落为point。放置动作本身不创建任何记录——记录在评论发出时才写入。 - 区域评论默认关闭:
CommentPointing.onPointerMove中,只有getCommentingOptions(editor).enableRegions为真且超过拖拽阈值时才进入CommentDragging绘制区域矩形;关闭时拖拽只是让输入框跟随。拖拽释放的角决定钉点角(pin的 x/y 取 0/1),区域矩形以虚线框呈现。 - 输入框打开期间工具保持激活:发布后回到 select 工具;点击别处会重新放置输入框。按 Escape 会像内建工具一样离开工具(
onCancel切回 select),工具退出时清理pendingComment与区域草稿,但已输入的草稿文字保存在 comment draft store 中。 - 悬停提示:
CommentIdle用setHintingShapes给出"落在这里会锚定到哪个形状"的轮廓提示;注意若当前开着区域输入框则不提示形状锚点,因为区域永远不会用形状锚点。
CommentingOptions:CommentTool.configure 的完整配置表
CommentTool.configure({...}) 仿照 ShapeUtil.configure,返回一个配置了 options 的子类,可以链式调用(components 是逐槽合并而非整体替换,见 comment-tool.tsx#L24-L38)。完整选项与默认值定义在 options.ts#L121-L233:
| 选项 | 默认值 | 说明 |
|---|---|---|
history |
'ignore' |
评论写入与撤销栈的关系。默认不进撤销栈,源码注释指出 'record' 在多人协作下是陷阱(撤销删除会复活协作者已删的线程),仅单人安全 |
dragHistory |
undefined(即 history) |
专门针对"拖动钉点重锚"这一空间编辑的历史模式,可与形状移动一起撤销 |
enableClustering |
true |
相机缩小远时把邻近钉点折叠成计数徽章 |
allowMultipleReactions |
true |
Slack 模型(每个 emoji 独立开关);false 为单选,新 emoji 替换已有反应。注意这是客户端策略,服务端两种都接受 |
isAllowedReaction |
isAllowedReactionEmoji |
校验 token 是否可作为反应写入,防止脚本客户端写入选择器不会提供的脏值;自定义 ReactionPalette 时应配合覆盖 |
enableRegions |
false |
拖拽评论工具是否创建区域锚点(示例中显式开启) |
canComment |
undefined |
整体参与权限门控(发帖、编辑、删除、解决、移动钉点)。不设置时,currentUserId 非空即可参与;回调抛异常按 false 处理并打日志 |
canModifyComment |
undefined |
针对具体记录的具体写入(edit-comment/delete-comment/delete-thread)的细粒度权限。不设置时用 defaultCanModifyComment:只能改/删自己的评论、删自己发起的线程 |
impreciseShapeAnchor |
{ x: 1, y: 0 } |
非精确形状钉点的归一化落点,默认右上角 |
shouldBePrecise |
() => true |
落在形状上的评论是钉精确点击点还是钉整个形状;只影响新放置,已有锚点按存储渲染 |
components |
{} |
组件覆盖槽位,见下文 |
官方文档中给出的一个典型用法(options.ts#L189-L197):
CommentTool.configure({
canModifyComment: (ctx) =>
// 管理员可以删任何人的评论,其余仍归记录主人
(ctx.action !== 'edit-comment' && isModerator(ctx.currentUserId)) ||
defaultCanModifyComment(ctx),
})
权限检查的执行时机值得注意:canComment/canModifyComment 在渲染期间通过 useCanComment/useCanModifyComment 调用,所以回调里读取的信号(signals)变化会触发重算;getCommentingOptions(editor) 则可以从任何持有 Editor 的地方(包括没有 React 上下文的工具状态)读到合并后的配置,未注册评论工具时回退到 defaultCommentingOptions。
components 槽位(CommentingComponents)可以整体或部分替换内建件:CommentBody(默认富文本渲染)、PinContent(钉点内容,默认作者首字母)、ThreadPreview/ThreadRow(侧栏行)、ThreadActions(线程头部附加按钮)、ReactionContent/ReactionPalette/ReactionTooltip(反应视觉与选择器)、ComposerFallback(查看者无发帖权限时显示的内容,如"只读"提示)。
权限、许可与 @ 提及
三层控制叠在一起:
- 许可层:
useCommentingEnabled()(基于 license feature flag,commenting特性)决定内建评论组件是否渲染; - 参与层:
canComment(或默认的"有currentUserId")决定能否发帖、编辑、删除、解决、移动钉点。返回true但没有currentUserId时,源码注释说明会得到一个"发送按钮保持禁用"的输入框; - 记录层:
canModifyComment决定对某条具体记录能否做具体写入,返回false时对应 UI 直接不渲染。两个回调都是"抛异常即拒绝"的防御式设计——代价只是一个被隐藏的按钮,而不是整个评论层崩溃,这与一个强制同样规则的服务端会做出的拒绝一致。
@ 提及(mention)由 @tldraw/mentions 包实现,@tldraw/commenting 的入口 index.ts 把其中的 Mention、MentionList、Avatar、MentionMember、filterMentionMembers、createMentionSuggestion 等原样再导出,所以评论包的公开 API 不变。集成时只需:准备一个成员列表(MentionMember[],含 id/name/color,可选 image/you),把 getMentionSuggestions={(query) => filterMentionMembers(MEMBERS, query)} 传给 CanvasComments,输入框在 @ 之后按已输入内容过滤该列表。成员的 id 要与 resolveAuthor 目录中的 id 对齐,这样 mention pill 与头像解析同一套身份。
进一步扩展:工具包还导出什么
除了示例用到的四件套,@tldraw/commenting 的公开出口(index.ts)还分成两层:
- 与 tldraw 无耦合的展示组件(可用于构建完全自定义的评论 UI):
CommentCard、CommentComposer、CommentThread、CommentPin、CommentsList/CommentListItem、Byline、CountBadge、EmojiPicker、Reaction/Reactions/ReactionPicker、SendButton、EmptyState、formatRelativeTime/formatFullDateTime等; - 与编辑器耦合的层:
CanvasComments、CanvasCommentsSidebar(带sortSidebarRows、SidebarFilters/DEFAULT_SIDEBAR_FILTERS)、状态 atoms(commentsHidden、commentsSidebarOpen、openThreadId、sidebarFilters及对应use*hooks)、响应式 hooks(useComments、useCommentThreads、useThreadComments)、写入辅助(putCommentRecords、editComment、deleteComment、deleteThread、resolveThread、reopenThread、toggleCommentReaction、summarizeReactions)、CommentsFilterMenu、CommentsVisibilityToggle、CommentsOverflowMenu、anchorPagePoint/focusThread/shapeAnchorAt等。
同一批协作示例里还有若干评论进阶用法可作延伸阅读:comment-regions、comment-anchors、comment-clustering、comment-history、comment-notifications、comment-drawing-reactions、comment-shape-precision、commenting-mobile、commenting-sidebar。对应的单测(如 comment-tool.test.ts、comment-mutations.test.ts、hooks.test.ts)全部基于 createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }) 构建内存 store,是验证行为最快的方式。
小结
- 评论线程是
comment-thread/comment/comment-reaction三类 store 记录,通过createTLSchema({ records: commentSchemaRecords })注册(服务端与客户端必须一致),持久化与同步复用 tldraw 既有的文档记录通道; - 集成只需四步:注册
commentSchemaRecords→tools={[CommentTool.configure({...})]}→overrides={[commentToolOverrides]}→ 在InFrontOfTheCanvas渲染<CanvasComments currentUserId resolveAuthor getMentionSuggestions />; - 交互模型是"点 = 点锚、点形状 = 形状锚、拖 = 区域锚(
enableRegions: true才生效)",由CommentTool的 idle/pointing/dragging 状态机驱动,松手才解析锚点、发帖才写记录; - 行为细节由
CommentingOptions控制(历史、聚簇、多反应、区域、双层权限回调、组件槽位),锚点数据结构以区分联合支持四种锚定并留有扩展空间; - 本地开发开箱即用,生产部署必须携带包含 commenting 的 license key,可用
useCommentingEnabled()对自定义 UI 做同样的门控。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00