首页
/ tldraw 评论工具包实战:用 commentSchemaRecords、CommentTool 与 CanvasComments 在画布上落地评论线程

tldraw 评论工具包实战:用 commentSchemaRecords、CommentTool 与 CanvasComments 在画布上落地评论线程

2026-09-06 17:34:53作者:傅爽业Veleda

本文以 tldraw 官方示例 commenting 为主体,讲清如何在基于 tldraw SDK 的 React 应用中接入画布评论:评论线程如何作为 store 记录实现持久化与同步、四步集成流程(注册 commentSchemaRecords、注册 CommentTool、挂载 commentToolOverrides、渲染 CanvasComments)如何完整落地,以及如何配置区域锚点、@ 提及与许可门控。读完后,你既能复制一套可运行的评论集成代码,也能从源码层面理解评论工具的状态机、锚点数据模型与 CommentingOptions 各项配置的真实含义。

功能概览与许可前提

@tldraw/commenting 是 tldraw 的评论工具包(commenting toolkit):它把"在画布上发起讨论"实现为一组画布覆盖层 + 一个状态工具。按照示例 README 的原话,集成全部就三件事:

  1. 评论线程是编辑器 store 中的记录,所以它们的持久化和同步方式与形状(shapes)完全一致;
  2. 在 schema 上注册 commentSchemaRecords,给编辑器加上 CommentToolcommentToolOverrides
  3. InFrontOfTheCanvas 插槽中渲染 <CanvasComments />

需要特别注意:评论是一个许可(licensed)功能。本地开发时一切功能默认开启,但部署后的应用需要包含 commenting(或包含它的 collaboration 总许可)的 license key。这一行为在源码中由许可门控 hook 实现:

// packages/commenting/src/canvas/license.ts
export function useCommentingEnabled(): boolean {
	return useLicenseFeatureFlag(useMaybeLicenseManager(), 'commenting')
}

从源码注释看,该 hook 是响应式的:许可校验完成前返回 false,因此 CanvasCommentsCanvasCommentsSidebar 以及评论工具的 Quick Action 会在校验通过前保持隐藏。如果你有自定义评论 UI,也可以直接用它做同样的门控。

数据模型:评论就是 store 里的三类记录

"评论和形状一样持久化、一样同步"这句话的底层依据,是 TLComment.ts 中定义的三类文档记录。它们不在默认 schema 中,需要显式注册:

记录类型 说明 关键字段
comment-thread 线程,持有锚点与解析状态 pageIdanchorcreatedBycreatedAtresolvedisDeleted
comment 线程内的一条消息 threadIdpageId(冗余存储免去 join)、authorIdbody(富文本)、editedAtisDeleted
comment-reaction 某用户对某条评论的一个 emoji 反应 commentIdthreadIdpageIduserIdemoji

统一通过 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/yisPrecise/3 为 region 锚点增加了 pinX/pinYTLComment.ts#L204-L238)。

两条值得记住的设计约定

  1. 删除是软删除comment-threadcomment 都用 isDeleted 标志位表达删除,客户端置位但保留记录。源码注释说明:同步服务端应把它当作 write-once 且仅创建者可执行,拒绝客户端硬删除,并在后续房间加载时丢弃已标记线程(TLComment.ts#L66-L71)。
  2. 评论变更刻意不进撤销栈TLComment 的注释明确建议以 { history: 'ignore' } 创建/编辑评论,避免多人协作中的意外——比如撤销一个删除操作,会把协作者已经删掉的评论"复活"。这也解释了 CommentingOptionshistory 选项的默认值(见后文配置节)。
  3. 反应是独立记录而非评论字段。因为评论记录的更新权限属于作者(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 中。
  • 悬停提示CommentIdlesetHintingShapes 给出"落在这里会锚定到哪个形状"的轮廓提示;注意若当前开着区域输入框则不提示形状锚点,因为区域永远不会用形状锚点。

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(查看者无发帖权限时显示的内容,如"只读"提示)。

权限、许可与 @ 提及

三层控制叠在一起:

  1. 许可层useCommentingEnabled()(基于 license feature flag,commenting 特性)决定内建评论组件是否渲染;
  2. 参与层canComment(或默认的"有 currentUserId")决定能否发帖、编辑、删除、解决、移动钉点。返回 true 但没有 currentUserId 时,源码注释说明会得到一个"发送按钮保持禁用"的输入框;
  3. 记录层canModifyComment 决定对某条具体记录能否做具体写入,返回 false 时对应 UI 直接不渲染。两个回调都是"抛异常即拒绝"的防御式设计——代价只是一个被隐藏的按钮,而不是整个评论层崩溃,这与一个强制同样规则的服务端会做出的拒绝一致。

@ 提及(mention)由 @tldraw/mentions 包实现,@tldraw/commenting 的入口 index.ts 把其中的 MentionMentionListAvatarMentionMemberfilterMentionMemberscreateMentionSuggestion 等原样再导出,所以评论包的公开 API 不变。集成时只需:准备一个成员列表(MentionMember[],含 id/name/color,可选 image/you),把 getMentionSuggestions={(query) => filterMentionMembers(MEMBERS, query)} 传给 CanvasComments,输入框在 @ 之后按已输入内容过滤该列表。成员的 id 要与 resolveAuthor 目录中的 id 对齐,这样 mention pill 与头像解析同一套身份。

进一步扩展:工具包还导出什么

除了示例用到的四件套,@tldraw/commenting 的公开出口(index.ts)还分成两层:

  • 与 tldraw 无耦合的展示组件(可用于构建完全自定义的评论 UI):CommentCardCommentComposerCommentThreadCommentPinCommentsList/CommentListItemBylineCountBadgeEmojiPickerReaction/Reactions/ReactionPickerSendButtonEmptyStateformatRelativeTime/formatFullDateTime 等;
  • 与编辑器耦合的层CanvasCommentsCanvasCommentsSidebar(带 sortSidebarRowsSidebarFilters/DEFAULT_SIDEBAR_FILTERS)、状态 atoms(commentsHiddencommentsSidebarOpenopenThreadIdsidebarFilters 及对应 use* hooks)、响应式 hooks(useCommentsuseCommentThreadsuseThreadComments)、写入辅助(putCommentRecordseditCommentdeleteCommentdeleteThreadresolveThreadreopenThreadtoggleCommentReactionsummarizeReactions)、CommentsFilterMenuCommentsVisibilityToggleCommentsOverflowMenuanchorPagePoint/focusThread/shapeAnchorAt 等。

同一批协作示例里还有若干评论进阶用法可作延伸阅读:comment-regionscomment-anchorscomment-clusteringcomment-historycomment-notificationscomment-drawing-reactionscomment-shape-precisioncommenting-mobilecommenting-sidebar。对应的单测(如 comment-tool.test.tscomment-mutations.test.tshooks.test.ts)全部基于 createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }) 构建内存 store,是验证行为最快的方式。

小结

  • 评论线程是 comment-thread/comment/comment-reaction 三类 store 记录,通过 createTLSchema({ records: commentSchemaRecords }) 注册(服务端与客户端必须一致),持久化与同步复用 tldraw 既有的文档记录通道;
  • 集成只需四步:注册 commentSchemaRecordstools={[CommentTool.configure({...})]}overrides={[commentToolOverrides]} → 在 InFrontOfTheCanvas 渲染 <CanvasComments currentUserId resolveAuthor getMentionSuggestions />
  • 交互模型是"点 = 点锚、点形状 = 形状锚、拖 = 区域锚(enableRegions: true 才生效)",由 CommentTool 的 idle/pointing/dragging 状态机驱动,松手才解析锚点、发帖才写记录;
  • 行为细节由 CommentingOptions 控制(历史、聚簇、多反应、区域、双层权限回调、组件槽位),锚点数据结构以区分联合支持四种锚定并留有扩展空间;
  • 本地开发开箱即用,生产部署必须携带包含 commenting 的 license key,可用 useCommentingEnabled() 对自定义 UI 做同样的门控。
登录后查看全文
热门项目推荐
相关项目推荐