首页
/ tldraw 评论历史机制:CommentingOptions 的 history 与 dragHistory 如何决定评论写入是否可撤销

tldraw 评论历史机制:CommentingOptions 的 history 与 dragHistory 如何决定评论写入是否可撤销

2026-09-06 15:09:45作者:霍妲思

本文基于 tldraw 官方示例 collaboration/comment-history 展开,讲解 @tldraw/commenting 中两个关键配置项 historydragHistory 的工作原理:它们分别控制评论写入(发布、回复、编辑、解决)和图钉拖拽重定位是否进入编辑器的撤销栈。读完后你将能根据协作场景正确配置评论的 undo/redo 行为,并理解为什么 tldraw 默认让评论写入对撤销"不可见"。

1. 示例要回答的问题:评论写入是否进入 undo 栈

示例的 README(README.md)用一句话点明主题:"Decide whether comment writes land on the editor's undo stack."——即决定评论写入是否落在编辑器的撤销栈上。

它的核心结论是:

  • CommentingOptions.history 治理所有评论写入——发布(posting)、回复(replying)、编辑(editing)、解决(resolving)、删除(deleting)——默认值为 'ignore'。在共享文档中,一个可撤销的"删除"会把协作者已经移除的讨论串复活(an undoable delete would resurrect a thread a collaborator already removed),因此默认不记录。
  • 图钉拖拽(pin drags)是例外:重新锚定一条评论本质上是空间编辑,合理地应该和形状移动一起被撤销。dragHistory 仅针对拖拽覆盖 history 的取值。
  • 示例的操作路径是:移动形状、发布一条评论、拖动它的图钉,然后按 undo,观察计数变化。

实现主体是 CommentHistoryExample.tsx,配套样式在 comment-history.css

2. 配置项的类型定义与默认值

historydragHistory 定义在 options.tsCommentingOptions 接口中:

export interface CommentingOptions {
	// History / undo
	/**
	 * How comment mutations interact with the editor undo stack. Defaults to `'ignore'` — comments
	 * are deliberately not undoable (see `TLComment`). `'record'` is a multiplayer footgun: undoing
	 * a delete resurrects a thread a collaborator already removed. Safe only single-player.
	 */
	readonly history: TLHistoryBatchOptions['history']
	/**
	 * History mode for the pin drag-to-move re-anchor specifically. Unlike posts/edits this is a
	 * spatial edit that may reasonably be undoable alongside a shape move. Defaults to `history`.
	 */
	readonly dragHistory: TLHistoryBatchOptions['history'] | undefined
	// ...
}

两个要点:

  1. 两者类型都是 TLHistoryBatchOptions['history']。该类型定义在 history-types.ts,取值为 'record' | 'record-preserveRedoStack' | 'ignore'
    • 'record':加入撤销栈并清空重做栈;
    • 'record-preserveRedoStack':加入撤销栈但不清空重做栈;
    • 'ignore':两个栈都不进。 也就是说 history 除了"记录/不记录"之外还有一个更细的选项。
  2. dragHistory 的类型是 ... | undefined:不设置时回退到 history,即"图钉拖拽默认与评论写入同策略"。

默认值集中在同文件的 defaultCommentingOptions

export const defaultCommentingOptions = {
	history: 'ignore',
	dragHistory: undefined,
	enableClustering: true,
	// ...
} as const satisfies CommentingOptions

配置方式与 ShapeUtil.configure 类似,通过 CommentTool.configure({...}) 一次性传入(静态配置),运行期响应式的值(如 currentUserId)则走 CommentingContext 属性。合并结果由 getCommentingOptions 从已注册的 comment 工具节点上读取,未注册时回退到默认值。由于选项在工具注册时即固定,运行中无法动态改——这正是示例里切换模式要整体重挂载编辑器的原因(见第 5 节)。

3. 源码中的解析规则:三种写入类别各走哪条历史策略

真正把选项翻译成行为的代码在 comment-mutations.ts。它首先把所有评论写入分成三种"类别"(L40):

export type CommentMutationKind = 'delete' | 'drag' | 'mutation'

然后由 historyModeForL50-L62)决定每种类别实际使用的历史模式:

function historyModeFor(
	options: CommentingOptions,
	kind: CommentMutationKind
): TLHistoryBatchOptions['history'] {
	switch (kind) {
		case 'delete':
			return 'ignore'
		case 'drag':
			return options.dragHistory ?? options.history
		case 'mutation':
			return options.history
	}
}

从源码结构看,这里有一个比 README 更精确的细节:

写入类别 触发场景 实际历史模式
mutation 发布、回复、编辑、解决/重开 options.history
drag 图钉拖拽重定位、区域锚点缩放 options.dragHistory ?? options.history
delete deleteComment / deleteThread 恒为 'ignore',与配置无关

删除被硬编码为 'ignore' 的原因写在源码注释里(L33-L36L249-L253):tldraw 的删除是软删除(置 isDeleted 标志),该标志在服务端是"一次写入"(write-once)的,撤销去清除这个标志会被服务端否决,而不是真正恢复内容。因此与其产生一个"撤销后失效"的操作,不如从一开始就不让它进栈。这也解释了为什么 deleteComment/deleteThread 调用 commitCommentMutation 时显式传入 'delete'L257-L273L286-L298)。

所有写入最终都经过统一入口 commitCommentMutationL80-L131):它按类别解析出历史模式后,调用 editor.run(fn, { history }),把底层 store.put / store.remove 包在回调提供的 writer 里执行。注释里还解释了一个坑:editor.run 的 history 选项不是可叠加的——嵌套的 run 会覆盖外层模式——所以构成性记录必须走 writer 而不是自己再开一次 commit,否则一次 drag 写入会被静默变成不可撤销。

4. 示例中的三种模式对照

示例用一张"模式表"把上述规则变成可交互的实验台(CommentHistoryExample.tsx L29-L47):

const MODE_TOOLS = {
	ignore: [CommentTool],                                        // 默认
	record: [CommentTool.configure({ history: 'record' })],      // 全部记录
	drag: [CommentTool.configure({ dragHistory: 'record' })],     // 仅记录图钉拖拽
}

const MODE_LABELS: Record<HistoryMode, string> = {
	ignore: 'Ignore (default)',
	record: 'Record everything',
	drag: 'Record pin drags only',
}

const MODE_HINTS: Record<HistoryMode, string> = {
	ignore: 'Undo rewinds the shape. Comments and pin positions stay put.',
	record: 'Undo rewinds comments too — the last thing you did, whatever it was.',
	drag: 'Undo rewinds the shape and pin drags, but never a posted comment.',
}
模式 配置 undo 的效果
Ignore(默认) CommentTool 只回退形状等画布操作;评论、图钉位置不动
Record everything CommentTool.configure({ history: 'record' }) 你最后做的任何事(包括发评论、编辑、解决)都会被撤销
Record pin drags only CommentTool.configure({ dragHistory: 'record' }) 形状移动和图钉拖拽可撤销,但已发布的评论不会

示例注释(文件底部 [1]-[4] 段落)给出了选择建议:'ignore' 是默认,也是共享文档的正确选择——可撤销的删除会复活协作者已删除的讨论串,可撤销的"解决"会回退对方更新的解决状态;'record' 只适合单机,或者评论存储不参与同步的场景;图钉拖拽则是"有趣的例外",作为空间编辑可以与形状移动一起撤销,而发布保持不记录,就是第三种模式。

5. 示例的完整装配:共享 store、key 重挂载与计数面板

要复现这个实验,有三个装配细节值得注意:

(1)store 跨模式共享,只在首次挂载时播种。 评论记录就存放在编辑器自己的 store 中,示例用 useMemo 创建一次、始终复用(L122-L125):

const store = useMemo(
	() => createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
	[]
)

handleMount 中播种一个"Move me"矩形,并且播种动作本身也演示了 history: 'ignore' 的另一个常见用法(L52-L71):

editor.run(
	() => {
		editor.createShapes([
			{
				type: 'geo',
				x: 180,
				y: 180,
				props: { geo: 'rectangle', w: 300, h: 200, richText: toRichText('Move me') },
			},
		])
	},
	{ history: 'ignore' } // 播种的形状也不应可撤销,否则第一次 undo 就删掉了示例的主角
)

(2)用 key={mode} 重挂载编辑器来切换模式。 因为评论选项在工具注册时固定,切换模式必须让编辑器带着新配置的工具重新挂载(L134-L147):

<Tldraw
	key={mode}                      // 切换模式 => 编辑器重挂载
	licenseKey={getLicenseKey()}    // Commenting 是许可功能,部署环境需要含 commenting 的 license key
	store={store}                  // 共享 store 让所有讨论串在切换中存活
	onMount={handleMount}
	tools={MODE_TOOLS[mode]}
	overrides={[commentToolOverrides]}
	components={components}
>

注释里点明了两者的生命周期差异:store 里的评论记录会跨模式存活,但 undo 栈属于编辑器而不属于 store,所以每次切换后撤销栈都是空的。

(3)计数面板是观察实验的"仪表"。 HistoryPaneluseCommentThreadsuseComments 两个 hook 反应式地读取评论记录,把线程数/评论数显示出来,同时提供 Undo/Redo 按钮(L74-L115):

const threads = useCommentThreads(editor)
const comments = useComments(editor)
const canUndo = useValue('can undo', () => editor.getCanUndo(), [editor])
const canRedo = useValue('can redo', () => editor.getCanRedo(), [editor])
// ...
<span className="comment-history-panel__count">
	{threads.length} {threads.length === 1 ? 'thread' : 'threads'}, {comments.length}{' '}
	{comments.length === 1 ? 'comment' : 'comments'}
</span>

按 undo 时盯着这两个数字就是整个示例的意义所在:在 'ignore' 模式下它们纹丝不动,在 'record' 模式下会跟着回退。

另外提醒一点:Commenting 是受许可的功能,本地开发环境默认全部开启,但部署上线的应用需要配置包含 commenting 的 license key(示例通过 getLicenseKey() 注入,见 dotcom-shared)。

6. 图钉拖拽在源码中如何走 drag 通道

README 说"pin drags are the exception",落到实现里就是 thread-pin.tsx。拖拽结束时,新的锚点通过 commitCommentMutation'drag' 类别提交(L300):

commitCommentMutation(editor, ({ put }) => put([{ ...thread, anchor }]), 'drag')

区域锚点(region anchor)的角点缩放也走同一条提交路径,源码注释明确说明了意图:// Same commit path as a pin drag, so the configured 'dragHistory' governs both — going straight to editor.run here would make region resizes silently ignore the option.L326-L337)。也就是说,"仅记录图钉拖拽"这一模式实际覆盖的是图钉拖拽 + 区域缩放两类空间编辑,而发布/回复/编辑/解决仍然只受 history 管。

7. 测试用例中的行为验证

两条测试直接印证了上述解析规则,可作为引用依据:

  • options.test.ts'uses dragHistory for a drag, falling back to history when unset'history: 'ignore', dragHistory: 'record' 时,commitCommentMutation(..., 'drag') 产生的 run 调用是 { history: 'record' };而 dragHistory: undefined 时落回 { history: 'ignore' }
  • options.test.ts'uses options.history for a mutation and returns the callback result'history: 'record' 的普通 mutation 以 { history: 'record' } 提交。
  • comment-mutations.test.ts'lets dragHistory govern a drag on its own' 则验证了 history: 'ignore', dragHistory: 'record' 组合下,拖拽的记录走 writer 且由 dragHistory 负责——不会因嵌套 commit 而丢失。

8. 实践建议

结合本文的源码证据,给出几条可直接落地的配置建议:

  1. 多人协作(评论存储有同步)时保持默认history: 'ignore'(即 dragHistory 也留空)。撤销只作用于画布操作,避免"撤销复活已被协作者删除/解决的讨论串"这类冲突。
  2. 单机或纯本地存储的评论场景:可以 CommentTool.configure({ history: 'record' }),让发布、回复、编辑、解决全部可撤销,形成统一的 undo 体验。
  3. 只希望"重定位评论"可撤销CommentTool.configure({ dragHistory: 'record' })。图钉拖拽和区域缩放会随形状移动一起进入撤销栈,而评论内容写入不受影响——这是示例中的第三种模式。
  4. 删除永远不可撤销:无论怎么配置,deleteComment/deleteThread 都恒为 'ignore'(软删除标志一次写入、由服务端清理),这一点不需要也无法通过配置改变。
  5. 切换策略需要重挂载编辑器:选项在 CommentTool.configure 注册时固定,运行中不可变;参照示例用 React 的 key 触发重挂载,并用共享 store 保住已有的评论记录。

参考文件一览:示例 READMECommentHistoryExample.tsx;实现 options.tscomment-mutations.tsthread-pin.tsx;类型 history-types.ts;测试 options.test.tscomment-mutations.test.ts

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