tldraw 评论锚点实战:深入 TLCommentAnchor 的四种锚定方式与 Shape 锚点的精确定位机制
本文基于仓库中的协作示例 comment-anchors 及其背后的 @tldraw/commenting、@tldraw/tlschema 源码,系统讲解 tldraw 评论线程(TLCommentThread)的 anchor 模型:point、shape、region、page 四种锚点各自的数据结构、渲染行为与默认值(如 impreciseShapeAnchor 默认锚在形状右上角 { x: 1, y: 0 }),并结合完整可运行的示例代码,演示如何用 createCommentThread + createComment + putCommentRecords 以编程方式预置每一种锚点的评论线程。读完后,你将能在自己的应用中正确选择锚点类型、控制 Shape 锚点的精确/模糊定位,并理解锚点在形状移动、缩放、旋转时如何持续跟随。
TLCommentAnchor:一个可判别联合,四种锚点
每个 TLCommentThread 都携带一个 anchor 属性,声明这条评论线程“住在”页面的哪个位置。该类型定义在 TLComment.ts,被建模为一个可判别联合(discriminated union),这样未来新增锚点类型时不会破坏已有线程:
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
/** 归一化(0–1)的 pin 所在角——即创建时拖拽释放的角。
* 旧记录中不存在;消费方自行回退到某个角。 */
pinX?: number
pinY?: number
}
| { type: 'page' }
四种锚点的语义(源自 TLComment.ts 的类型注释):
| 锚点类型 | 数据结构 | 渲染行为 |
|---|---|---|
shape |
shapeId + 归一化 x/y(0–1)+ isPrecise |
pin 钉在某个形状上,随形状移动、缩放、旋转而保持位置;x/y 是形状自身边界内的归一化坐标 |
point |
页面坐标 x/y |
pin 钉在页面的固定坐标,不依附任何形状 |
region |
页面坐标的矩形 x/y/w/h,可选 pinX/pinY |
覆盖一个矩形区域,绘制为虚线框,pin 落在角上 |
page |
无空间坐标 | 页面级线程,没有 pin,只在评论列表中显示 |
这个联合类型还有对应的校验器(commentAnchorValidator),以 type 字段作为判别键,保证写入 store 的锚点数据合法。
以编程方式预置线程:示例的完整实现
comment-anchors 示例(CommentAnchorsExample.tsx)的核心思路是:创建每个线程时都用 createCommentThread 和 createComment,再用 putCommentRecords 写入 store。抽取出来的 seedThread 辅助函数(示例 L42-L52):
function seedThread(editor: Editor, anchor: TLCommentAnchor, text: string) {
const pageId = editor.getCurrentPageId()
const thread = createCommentThread({ pageId, anchor, createdBy: 'ada' })
const comment = createComment({
threadId: thread.id,
pageId,
authorId: 'ada',
body: toRichText(text),
})
putCommentRecords(editor, [thread, comment])
}
几个关键点:
createCommentThread/createComment的工厂函数与commentSchemaRecords都来自 tlschema 的 TLComment 模块;putCommentRecords是@tldraw/commenting提供的类型化写入入口,定义在 comment-mutations.ts。- 线程(thread)拥有
anchor和解决状态,而具体消息是TLComment记录,通过threadId指向线程并按createdAt排序(TLCommentThread 注释)。 - 由于评论类型不属于默认 schema,客户端(以及协作场景下的服务端)必须用
createTLSchema({ records: commentSchemaRecords })显式注册,两侧注册方式必须一致(TLComment.ts 的说明)。示例中正是这样做的(示例 L55-L58):
const store = useMemo(
() => createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
[]
)
示例在 onMount 时用 editor.run(..., { history: 'ignore' }) 一次性种入一个矩形和四条线程(示例 L60-L95),覆盖所有锚点类型:
// shape(imprecise):存储的归一化 x/y 不参与定位,pin 落在形状右上角的
// badge 位置;锚点本身仍会跟随形状移动和缩放。
seedThread(
editor,
{ type: 'shape', shapeId: boxId, x: 1, y: 0, isPrecise: false },
'Anchored to this shape (imprecise — sits at the corner).'
)
// shape(precise):pin 恰好落在存储的归一化位置。
seedThread(
editor,
{ type: 'shape', shapeId: boxId, x: 0.5, y: 0.6, isPrecise: true },
'Anchored to a precise spot inside the shape.'
)
// point:裸的页面坐标,不依附任何形状。
seedThread(editor, { type: 'point', x: 200, y: 340 }, 'Anchored to a point on the page.')
// region:矩形区域;pin 落在角上,并绘制虚线框。
seedThread(
editor,
{ type: 'region', x: 380, y: 300, w: 200, h: 130 },
'Anchored to a region of the page.'
)
注意这里同时展示了 Shape 锚点的两种形态:isPrecise: false 时存储的 x: 1, y: 0 只是“记住的位置”,实际 pin 由 impreciseShapeAnchor 选项决定;isPrecise: true 时 pin 才落在存储的 x: 0.5, y: 0.6 处。拖动矩形形状即可看到两个 pin 跟随形状移动——这正是 shape 锚点相对 point 锚点的价值所在。
Shape 锚点精确定位的底层实现
Shape 锚点存储的是形状自身边界内的归一化 x/y。当 isPrecise 为 true 时 pin 恰好落在那里;为 false 时 pin 落在 impreciseShapeAnchor 选项指定的位置(默认右上角),此时锚点表达的是“整个形状”而非形状上的某个点。
这一行为在 options.ts 中有明确的选项定义与默认值(defaultCommentingOptions 中 impreciseShapeAnchor: { x: 1, y: 0 }):
/** 模糊 Shape pin 所在的形状内归一化(0–1)位置。默认右上角。 */
readonly impreciseShapeAnchor: { readonly x: number; readonly y: number }
/**
* 落在形状上的评论是钉在点击的精确位置,还是钉在形状整体
* (渲染在 impreciseShapeAnchor 处)?默认总是精确。
* 只影响新放置——已有锚点按存储方式渲染。
*/
shouldBePrecise(editor: Editor, context: ShapeCommentPrecisionContext): boolean
这两个选项通过 CommentTool.configure({ ... }) 覆盖,任何持有 Editor 的代码(包括没有 React 上下文的地方)都可以用 getCommentingOptions(editor) 读取(options.ts L243-L246)。
pin 实际落在页面哪个点,由 @tldraw/commenting 的 anchorPagePoint 统一计算:
switch (anchor.type) {
case 'shape': {
const shape = editor.getShape(anchor.shapeId as TLShapeId)
if (!shape) return null
const transform = editor.getShapePageTransform(shape)
if (!transform) return null
const { point, size } = editor.getShapeGeometry(shape).bounds
// 精确 pin 用存储的 x/y;模糊 pin 用 editor 配置的位置。
const spot = anchor.isPrecise ? anchor : getCommentingOptions(editor).impreciseShapeAnchor
return Mat.applyToPoint(transform, Vec.Add(point, Vec.MulV(spot, size)))
}
case 'point':
return { x: anchor.x, y: anchor.y }
case 'region':
return regionPinPoint(anchor, regionAnchorPinCorner(anchor))
case 'page':
return null
}
从源码结构看,这里有三个值得注意的细节:
- 旋转跟随:
shape分支不是简单地在边界盒里做线性插值,而是通过getShapePageTransform拿到形状的页面变换后施加到点上(Mat.applyToPoint)。因此旋转后的形状上,pin 跟随旋转而非滞留在轴对齐的边界盒中。 - 模糊 pin 的内缩:impreciseShapePinInset 会把模糊 Shape pin 向形状中心内缩(因为 marker 图形从锚点向右上展开,不内缩的话 pin 会挂在形状边缘外);内缩方向还会按形状的旋转角
Vec.Rot旋转,保证旋转后 pin 依然是“往里缩”而不是“往外跑”。 page返回null:即不画 pin,只存在于评论列表中,与类型注释一致。
对应的测试也验证了自定义 impreciseShapeAnchor 时聚类布局与 pin 渲染一致(cluster-input.test.ts),并覆盖了锚点生命周期的完整场景(anchor-lifecycle.test.ts)。
Region 锚点:矩形区域与 pin 角
region 锚点在页面坐标下描述一个矩形区域,渲染为虚线框,pin 位于框的某个角上。默认角是右下角,常量定义在 REGION_PIN_CORNER:
export const REGION_PIN_CORNER: VecLike = { x: 1, y: 1 }
如果创建时的拖拽释放角被记录在锚点的 pinX/pinY(归一化 0–1)中,regionAnchorPinCorner 会优先使用它,否则回退到右下角。pin 的具体页面坐标由 regionPinPoint 按 region.x + corner.x * region.w 计算。
一个实用的默认值差异:enableRegions 选项默认为 false(defaultCommentingOptions),意味着默认交互下拖拽评论工具只产生 point 或 shape 锚点;但像示例这样直接写入 type: 'region' 的锚点记录是完全支持的——直接写入和交互创建是两条独立路径。如果希望用户也能通过拖拽创建区域评论,需要 CommentTool.configure({ enableRegions: true })。
Page 锚点与 UI 接入
page 锚点没有任何空间坐标,anchorPagePoint 对其返回 null,因此画布上不出现 pin,线程只会在评论侧边栏/列表中露出——适合整页级别的讨论(例如对文档整体的反馈)。
要让这些 pin 真正渲染出来,除了 store 注册 schema,<Tldraw> 还需要接入评论工具、UI 覆盖层和评论层(示例的 完整挂载代码):
const components = useMemo<TLComponents>(
() => ({
InFrontOfTheCanvas: () => (
<CanvasComments currentUserId="me" resolveAuthor={resolveAuthor} />
),
}),
[]
)
return (
<div className="tldraw__editor">
<Tldraw
licenseKey={getLicenseKey()}
store={store}
onMount={handleMount}
tools={commentTools}
overrides={[commentToolOverrides]}
components={components}
/>
</div>
)
各部分的来源:
commentTools(含CommentTool)与commentToolOverrides(默认 UI 覆盖,例如快捷键)导出自 comment-tool.tsx;CanvasComments是画布评论层的 React 组件,定义在 comments-overlay.tsx,通过TLComponents的InFrontOfTheCanvas插槽插入到画布最上层;currentUserId决定当前用户身份,resolveAuthor把作者 id 解析成名字/颜色/头像;- 评论是受许可证保护的功能:本地开发全部启用,线上部署需要使用包含 commenting 特性的 license key(示例中通过
getLicenseKey()读取,见 示例 L106-L109)。
样式方面需要引入 @tldraw/commenting/commenting.css 与 tldraw/tldraw.css 两个样式表(示例文件头部)。
Schema 迁移:锚点字段是如何演进上来的
查看 TLComment 记录的迁移逻辑 可以印证上面各字段的历史背景,这对理解“为什么要这样建模”很有帮助:
- Shape 锚点曾只有
shapeId,后来才增加了归一化x/y与isPrecise。迁移规则是:旧记录补上x: 1, y: 0, isPrecise: false,即“模糊锚点、pin 在右上角”,与当前impreciseShapeAnchor的默认值完全对齐; - Region 锚点则经历过字段裁剪:旧记录上的
pinX/pinY在更早的版本中被剥离(pinX: _pinX, pinY: _pinY被解构丢弃),之后又作为可选字段以“拖拽释放角”的语义重新引入。
这两段迁移保证了旧数据在新版本下按“模糊锚点”的语义继续渲染,也解释了为什么 x/y 即使对 isPrecise: false 的锚点也要始终存在——它保存的是“记住的精确位置”。
小结
- 选择锚点类型的基本准则:评论针对某个形状 →
shape(按点击精度选isPrecise);针对画布上一处固定位置 →point;针对一块区域(如整段流程、图表分区)→region;整页讨论 →page。 - 编程式创建线程的统一模式是
createCommentThread+createComment+putCommentRecords,且必须先在客户端(及协作服务端)用commentSchemaRecords注册评论 schema。 - Shape 锚点用归一化坐标表达位置,因此天然随形状移动、缩放、旋转而跟随;
impreciseShapeAnchor(默认{ x: 1, y: 0 }右上角)与shouldBePrecise是控制定位策略的两个CommentTool.configure选项,且只影响新放置,已有锚点始终按存储数据渲染。 region锚点的 pin 角优先取记录中的pinX/pinY,否则回退右下角;而交互创建 region 需要先开启默认关闭的enableRegions。
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 StartedRust0623
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