news 2026/9/7 14:34:33

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
tldraw 评论历史机制:CommentingOptions 的 history 与 dragHistory 如何决定评论写入是否可撤销

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

【免费下载链接】tldrawBuild infinite canvas apps in React with the tldraw SDK. World's best, top-most agent recommended #1 five star SDK.项目地址: https://gitcode.com/GitHub_Trending/tl/tldraw

本文基于 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.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 // ... }

两个要点:

  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'

然后由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
deletedeleteComment/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 everythingCommentTool.configure({ history: 'record' })你最后做的任何事(包括发评论、编辑、解决)都会被撤销
Record pin drags onlyCommentTool.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 保住已有的评论记录。

参考文件一览:示例 README、CommentHistoryExample.tsx;实现 options.ts、comment-mutations.ts、thread-pin.tsx;类型 history-types.ts;测试 options.test.ts、comment-mutations.test.ts。

【免费下载链接】tldrawBuild infinite canvas apps in React with the tldraw SDK. World's best, top-most agent recommended #1 five star SDK.项目地址: https://gitcode.com/GitHub_Trending/tl/tldraw

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/7 14:33:18

OneDrive本地同步配置与无法登录卸载安装排障指南

你电脑里的OneDrive&#xff0c;到底是用来存东西的&#xff0c;还是只会占着任务栏图标给你添堵的&#xff1f;这段时间我帮不少同事和朋友处理过OneDrive的同步问题&#xff0c;发现大多数人根本分不清自己的文件是在云端还是本地。这个标题问的其实就是一件很基础又很实用的…

作者头像 李华
网站建设 2026/9/7 14:24:29

从TikTok成瘾性设计解析推荐算法与交互优化技术实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 14:23:54

MODBUS协议调试笔记:从报文结构到RS485实战排查

翻了翻手头的调试笔记&#xff0c;前面几篇写的是驱动、中断、总线调试&#xff0c;这次轮到MODBUS协议了。做嵌入式这些年&#xff0c;工业控制、仪器采集、能源监控这类项目里&#xff0c;MODBUS协议几乎是绕不开的选项&#xff1a;它简单、稳定、资料多&#xff0c;从单片机…

作者头像 李华
网站建设 2026/9/7 14:23:00

CUDA编程核心概念与实战:从并行计算到AI推理优化

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 14:19:00

大数据背景做RAG:权限和日志才是小团队最难啃的骨头

聊《我用大数据经验做了次 AI 项目&#xff0c;最先失效的是旧方法》之前&#xff0c;先说一句实在的&#xff1a;别急着背概念&#xff0c;先看它在真实项目里到底解决什么问题。摘要去年我们组接到一个需求&#xff1a;把内部文档库接进大模型&#xff0c;做问答系统。前端同…

作者头像 李华