tldraw 评论历史机制:CommentingOptions 的 history 与 dragHistory 如何决定评论写入是否可撤销
本文基于 tldraw 官方示例 collaboration/comment-history 展开,讲解 @tldraw/commenting 中两个关键配置项 history 与 dragHistory 的工作原理:它们分别控制评论写入(发布、回复、编辑、解决)和图钉拖拽重定位是否进入编辑器的撤销栈。读完后你将能根据协作场景正确配置评论的 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. 配置项的类型定义与默认值
history 和 dragHistory 定义在 options.ts 的 CommentingOptions 接口中:
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
// ...
}
两个要点:
- 两者类型都是
TLHistoryBatchOptions['history']。该类型定义在 history-types.ts,取值为'record' | 'record-preserveRedoStack' | 'ignore':'record':加入撤销栈并清空重做栈;'record-preserveRedoStack':加入撤销栈但不清空重做栈;'ignore':两个栈都不进。 也就是说history除了"记录/不记录"之外还有一个更细的选项。
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'
然后由 historyModeFor(L50-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-L36 与 L249-L253):tldraw 的删除是软删除(置 isDeleted 标志),该标志在服务端是"一次写入"(write-once)的,撤销去清除这个标志会被服务端否决,而不是真正恢复内容。因此与其产生一个"撤销后失效"的操作,不如从一开始就不让它进栈。这也解释了为什么 deleteComment/deleteThread 调用 commitCommentMutation 时显式传入 'delete'(L257-L273、L286-L298)。
所有写入最终都经过统一入口 commitCommentMutation(L80-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)计数面板是观察实验的"仪表"。 HistoryPanel 用 useCommentThreads 和 useComments 两个 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. 实践建议
结合本文的源码证据,给出几条可直接落地的配置建议:
- 多人协作(评论存储有同步)时保持默认:
history: 'ignore'(即dragHistory也留空)。撤销只作用于画布操作,避免"撤销复活已被协作者删除/解决的讨论串"这类冲突。 - 单机或纯本地存储的评论场景:可以
CommentTool.configure({ history: 'record' }),让发布、回复、编辑、解决全部可撤销,形成统一的 undo 体验。 - 只希望"重定位评论"可撤销:
CommentTool.configure({ dragHistory: 'record' })。图钉拖拽和区域缩放会随形状移动一起进入撤销栈,而评论内容写入不受影响——这是示例中的第三种模式。 - 删除永远不可撤销:无论怎么配置,
deleteComment/deleteThread都恒为'ignore'(软删除标志一次写入、由服务端清理),这一点不需要也无法通过配置改变。 - 切换策略需要重挂载编辑器:选项在
CommentTool.configure注册时固定,运行中不可变;参照示例用 React 的key触发重挂载,并用共享 store 保住已有的评论记录。
参考文件一览:示例 README、CommentHistoryExample.tsx;实现 options.ts、comment-mutations.ts、thread-pin.tsx;类型 history-types.ts;测试 options.test.ts、comment-mutations.test.ts。
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