首页
/ tldraw 评论通知实战:基于 Commenting SDK 的已读回调构建评论通知收件箱

tldraw 评论通知实战:基于 Commenting SDK 的已读回调构建评论通知收件箱

2026-09-06 15:33:46作者:侯霆垣

本文以 tldraw 官方示例 comment-notifications 为主线,讲清如何借助 @tldraw/commenting 提供的三个宿主回调(isCommentUnreadonCommentsReadrevealThread)派生出一套完整的评论通知流:追踪未读评论、记录已读回执、从画布外任意位置跳转到目标线程,并让被打开的线程自动标记为已读。读完本文,你将掌握“通知 feed 派生而非存储”的设计模式,并能把该方案迁移到自己的 React 协作应用中。

设计原则:SDK 不替你决定“未读”和“该通知谁”

tldraw 的评论层(@tldraw/commenting)刻意把两件事留给宿主应用:一条评论是否未读,以及一条评论值不值得通知某人。SDK 只负责画布上的评论呈现与交互,已读状态与通知策略都是“宿主数据”。

这个边界在 SDK 源码中有明确的体现。所有评论表面(画布图层、侧边栏等)共享一个“实时上下文”接口 CommentingContext,其中与已读/通知直接相关的字段就是下面这几个(见 context.ts):

export interface CommentingContext {
	currentUserId: string | null
	resolveAuthor(id: string): CommentAuthor | undefined
	/** 任何评论(新线程首条或回复)发布后被调用 */
	onPostComment?(comment: TLComment): void
	/** 该评论对当前用户是否未读(未读返回 true) */
	isCommentUnread?(commentId: TLCommentId): boolean
	/**
	 * 以打开的线程弹层中展示给用户的全部未读评论调用,
	 * 按每次上报批量传入,宿主可据此记录已读回执而无需逐条写入。
	 * 需要 isCommentUnread 来判断什么是未读的。
	 */
	onCommentsRead?(commentIds: TLCommentId[]): void
	getMentionSuggestions?(query: string): MentionMember[] | Promise<MentionMember[]>
	// ...
}

isCommentUnread 是纯查询:宿主回答“这条我读没读”;onCommentsRead 是事件上报:线程视图把“我刚展示给你的未读评论”批量告诉宿主,由宿主去落库。二者组合,就形成了本文示例的整条已读链路。

已读回执的数据形态:用宿主自己的存储存

示例源码 CommentNotificationsExample.tsx 用一个本地 atom 模拟真实应用中的“已读回执表”:

const ME = 'me'

const readComments = atom<ReadreadonlySet<TLCommentId>>('read comments', new Set())

const isCommentUnread = (commentId: TLCommentId) => !readComments.get().has(commentId)

const onCommentsRead = (commentIds: TLCommentId[]) =>
	readComments.update((read) => {
		const unread = commentIds.filter((id) => !read.has(id))
		if (unread.length === 0) return read
		const next = new Set(read)
		for (const id of unread) next.add(id)
		return next
	})

示例注释里点出了这条设计在真实应用中的含义:评论记录只描述“谁在何时写了什么”,而“谁看过”是 (评论, 读者) 维度的一行数据,应该放在宿主自己的数据库里,这样未读计数才能在刷新后存活、跨设备跟随用户。示例用 atom 只是为了让整个流程跑在内存中。

有两处工程细节值得注意:

  1. 为什么用 atom 而不是 React stateisCommentUnreadonCommentsRead 这两个回调只传给 CanvasComments 一次。保持它们引用稳定,可以避免每次已读状态变化都重建整个评论图层;面板侧则用 useValue(readComments) 对同一份集合做响应式读取。
  2. 自己的评论立即视为已读:示例中 onPostComment = (comment) => onCommentsRead([comment.id])——“自己的评论对自己永远不算新闻”,不必等线程视图上报,发布瞬间就标记为已读。这防止了刚发出去的评论在自己的通知流里显示未读点。

线程视图如何触发 onCommentsRead

“打开即已读”的机制在 SDK 侧由线程视图实现。见 thread-view.tsx

// isCommentUnread to false, so re-runs find nothing to report.
if (!isCommentUnread || !onCommentsRead) return
const unreadIds = comments.filter((comment) => isCommentUnread(comment.id)).map((c) => c.id)
onCommentsRead(unreadIds)

即:每当打开的线程中评论集合变化(包括线程保持打开期间新到达的回复),视图会重新收集所有仍未读的评论 id,批量调用一次 onCommentsRead。这正是“批量按上报”语义的来源——宿主不需要为每条评论写一次回执。注意其幂等性依赖宿主的 isCommentUnread:回执写入后再次运行会找不到未读项,不会重复上报。

派生通知流:从评论记录中算出“谁该收到通知”

示例没有为“通知”建立任何独立的存储,而是把通知流当作派生数据:用 useComments 拿到全部评论记录,逐条打上“它为什么在这”的标签,再对照已读集合即可。相关 API 均定义在 hooks.ts

  • useComments(editor):全部未删除的评论(按创建时间升序),响应式;
  • useCommentThreads(editor):全部未删除的线程,响应式。

示例中“你的线程”定义为:你发起的线程 + 你评论过的线程:

const yourThreadIds = useMemo(() => {
	const ids = new Set<TLCommentThreadId>(
		threads.filter((thread) => thread.createdBy === ME).map((thread) => thread.id)
	)
	for (const comment of comments) {
		if (comment.authorId === ME) ids.add(comment.threadId)
	}
	return ids
}, [threads, comments])

识别 @提及:遍历富文本里的 mention 节点

一条 @ 提及在评论富文本 body 中是一个 { type: 'mention', attrs: { id } } 节点,attrs.id 是成员 id。示例用一个小递归遍历出所有被提及者:

function mentionedIds(body: TLRichText): string[] {
	const ids: string[] = []
	const visit = (node: any) => {
		if (!node || typeof node !== 'object') return
		if (node.type === 'mention' && typeof node.attrs?.id === 'string') ids.push(node.attrs.id)
		if (Array.isArray(node.content)) node.content.forEach(visit)
	}
	visit(body)
	return ids
}

组合出通知列表

通知判定规则示例采用了两条“无需服务端即可成立”的理由(产品决策应由宿主做,SDK 不做这个决定):

type NotificationReason = 'mention' | 'reply'

const notifications = useMemo(() => {
	const result: Notification[] = []
	for (const comment of comments) {
		// 通知永远关于别人的评论,不关于你自己的
		if (comment.authorId === ME) continue
		const reason: NotificationReason | null = mentionedIds(comment.body).includes(ME)
			? 'mention'
			: yourThreadIds.has(comment.threadId)
				? 'reply'
				: null
		if (!reason) continue
		result.push({ comment, reason, unread: !read.has(comment.id) })
	}
	return result.sort((a, b) => b.comment.createdAt - a.createdAt)
}, [comments, yourThreadIds, read])

规则要点:

  • 提及优先:提到你 > 在你参与的线程里回复;
  • 自己的评论被跳过:通知永远不是“你自己说了什么”;
  • 已读不剔除:已读评论仍留在列表中,清掉的是行上的未读点和头部徽标数字。若过滤成“仅未读”,feed 一被阅读就失去历史。

示例还演示了 bodyMentioning(before, memberId, after) 这种“像编辑器一样”构造带 mention 的富文本 body 的写法(空段落会被丢弃,因为零长度文本节点不是合法富文本),供“Ada 回复你 / Ada @你”两个演示按钮使用。

revealThread:从画布外任意位置跳转并打开线程

通知行点击后的跳转入口是 revealThread(editor, threadOrCommentId),它接受线程 id,或其中任意一条评论的 id。实现非常轻——见 state.ts

export function revealThread(editor: Editor, threadOrCommentId: string): void {
	revealThreadRequest.set(editor, threadOrCommentId)
}

它只是把一个“待处理揭示请求”写进按 editor 作用域的 EditorAtom,真正的执行由 CanvasComments 图层接管(comments-overlay.tsx)。这一间接层带来两个重要性质:

  1. 对时序宽容:可以在评论记录尚未同步到位时提前调用。图层会等待记录到达、切换页面、取消隐藏 pin,并在聚簇(clustering)开启时把镜头放大到足以把该线程从簇徽标中“分裂”出来再打开它(相关聚簇感知逻辑见 cluster-model.ts)。因此通知流、深链、邮件里的链接都能安全地调用它;
  2. 依赖图层挂载CanvasComments 未挂载时请求无人消费。SDK 还导出 getRevealThreadPending / useRevealThreadPendingstate.ts)供宿主侦测“一直没落地的揭示请求”——通常是深链指向了已删除的评论;由于请求在记录同步期间也会滞留,建议给一段宽限期后再复查。

打开线程同时完成已读

revealThread 打开线程弹窗后,线程视图按前述机制把展示给你的未读评论批量上报给 onCommentsRead——这就是 README 中“点击一行跳到线程并看到它把自己标记为已读”的完整闭环:跳转与已读是同一动作的两个后果,宿主无需额外写任何标记逻辑。

组装到应用:挂载 CanvasComments 与初始化数据

整个示例应用的关键装配代码如下(取自 CommentNotificationsExample.tsx):

const components: TLComponents = {
	InFrontOfTheCanvas: () => (
		<CanvasComments
			currentUserId={ME}
			resolveAuthor={resolveAuthor}
			getMentionSuggestions={(query) => filterMentionMembers(MEMBERS, query)}
			isCommentUnread={isCommentUnread}
			onCommentsRead={onCommentsRead}
			onPostComment={onPostComment}
		/>
	),
}

export default function CommentNotificationsExample() {
	const store = useMemo(
		() => createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
		[]
	)
	return (
		<div className="tldraw__editor">
			<Tldraw
				// 评论是许可功能本地开发默认全部开启,
				// 部署的应用需要包含 commenting  license key
				licenseKey={getLicenseKey()}
				store={store}
				onMount={handleMount}
				tools={commentTools}
				overrides={[commentToolOverrides]}
				components={components}
			>
				<NotificationsPanel />
			</Tldraw>
		</div>
	)
}

装配要点:

  • CanvasComments 通过 TLComponentsInFrontOfTheCanvas 插槽挂到编辑器之上;传入的 CommentingContext 字段集合可以在多个评论表面间共享(构建一次、逐个展开);
  • store 必须包含评论 schema:createTLSchema({ records: commentSchemaRecords })
  • 写入评论走统一的变异入口 putCommentRecords(editor, records)comment-mutations.ts),创建记录则用 createComment / createCommentThread 工厂函数。示例中“Ada 发言”就是一段普通的记录写入,代替了真实应用的同步连接——用同步 store 开两个标签页,会看到同样的评论自己到达;
  • 评论功能受 license 保护:本地开发环境默认启用,部署到生产时需要包含 commenting 的 license key(示例源码注释明确提示将 getLicenseKey() 换成你自己的 key)。

演示数据:两种通知理由各一例

示例的 handleMount 在挂载时种入了两条演示数据(CommentNotificationsExample.tsx):

  1. reply 型:你发起的线程(createdBy: ME),Ada 45 分钟前回复了一条——命中“你参与的线程里有新回复”;
  2. mention 型:Ada 锚定在图形上的线程(anchor: { type: 'shape', ... }),首条评论用 bodyMentioning('', ME, ' can you swap in the final crop?') @ 了你——命中“被提及”。

页面还提供 “Ada replies” / “Ada mentions you” 两个按钮实时产生新通知,用于验证未读点、徽标计数、revealThread 跳转与自动已读的完整链路。

小结:把这套模式带回你的应用

关注点 谁负责 本文示例/SDK 中的对应物
评论记录(谁、何时、内容、锚点) SDK 记录 + 同步 store commentSchemaRecordsputCommentRecords
已读状态查询 宿主 isCommentUnread(commentId) 回调
已读回执写入 宿主 onCommentsRead(ids) 批量回调
通知规则(提及/回复/板主活动等) 宿主(产品决策) mention / reply 两种理由
从画布外跳转到线程 SDK revealThread(editor, id)
打开线程时自动已读 SDK 线程视图 + 宿主回执 thread-view.tsx 的批量上报

这套模式的收益在于职责清晰:SDK 提供记录、呈现、跳转与上报时机,宿主拥有通知策略与已读存储——把示例中的本地 atom 换成数据库或后端接口,其余结构(派生 feed、理由标签、未读点、revealThread 跳转)可以原样保留。

延伸阅读

登录后查看全文
热门项目推荐
相关项目推荐