首页
/ tldraw 评论锚点实战:深入 TLCommentAnchor 的四种锚定方式与 Shape 锚点的精确定位机制

tldraw 评论锚点实战:深入 TLCommentAnchor 的四种锚定方式与 Shape 锚点的精确定位机制

2026-09-06 11:20:33作者:温玫谨Lighthearted

本文基于仓库中的协作示例 comment-anchors 及其背后的 @tldraw/commenting@tldraw/tlschema 源码,系统讲解 tldraw 评论线程(TLCommentThread)的 anchor 模型:pointshaperegionpage 四种锚点各自的数据结构、渲染行为与默认值(如 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)的核心思路是:创建每个线程时都用 createCommentThreadcreateComment,再用 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。当 isPrecisetrue 时 pin 恰好落在那里;为 false 时 pin 落在 impreciseShapeAnchor 选项指定的位置(默认右上角),此时锚点表达的是“整个形状”而非形状上的某个点。

这一行为在 options.ts 中有明确的选项定义与默认值(defaultCommentingOptionsimpreciseShapeAnchor: { 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/commentinganchorPagePoint 统一计算:

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
}

从源码结构看,这里有三个值得注意的细节:

  1. 旋转跟随shape 分支不是简单地在边界盒里做线性插值,而是通过 getShapePageTransform 拿到形状的页面变换后施加到点上(Mat.applyToPoint)。因此旋转后的形状上,pin 跟随旋转而非滞留在轴对齐的边界盒中。
  2. 模糊 pin 的内缩impreciseShapePinInset 会把模糊 Shape pin 向形状中心内缩(因为 marker 图形从锚点向右上展开,不内缩的话 pin 会挂在形状边缘外);内缩方向还会按形状的旋转角 Vec.Rot 旋转,保证旋转后 pin 依然是“往里缩”而不是“往外跑”。
  3. 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 的具体页面坐标由 regionPinPointregion.x + corner.x * region.w 计算。

一个实用的默认值差异:enableRegions 选项默认为 falsedefaultCommentingOptions),意味着默认交互下拖拽评论工具只产生 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,通过 TLComponentsInFrontOfTheCanvas 插槽插入到画布最上层;currentUserId 决定当前用户身份,resolveAuthor 把作者 id 解析成名字/颜色/头像;
  • 评论是受许可证保护的功能:本地开发全部启用,线上部署需要使用包含 commenting 特性的 license key(示例中通过 getLicenseKey() 读取,见 示例 L106-L109)。

样式方面需要引入 @tldraw/commenting/commenting.csstldraw/tldraw.css 两个样式表(示例文件头部)。

Schema 迁移:锚点字段是如何演进上来的

查看 TLComment 记录的迁移逻辑 可以印证上面各字段的历史背景,这对理解“为什么要这样建模”很有帮助:

  • Shape 锚点曾只有 shapeId,后来才增加了归一化 x/yisPrecise。迁移规则是:旧记录补上 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

主要参考文件:示例入口示例说明文档锚点类型定义锚点位置计算评论选项与默认值评论写入 API

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