tldraw SDK 自定义绑定实战:用 BindingUtil 实现"图钉钉住"多形状联动网络(Pin Bindings)
tldraw 的 bindings(绑定) 系统允许在形状之间建立持久化关联关系:你不再需要自己维护"A 移动后 B 该去哪里"的监听逻辑,而是可以声明一条记录两个形状关系的 binding,再通过 BindingUtil 的生命周期回调统一响应形状的变化。本文以仓库官方示例 pin-bindings 为主线,完整拆解如何用自定义 pin 绑定把多个重叠的形状"钉"在一起,让拖动任何一个形状时整张联动网络自动解算、所有图钉保持原位,并深入 BindingUtil 与 Editor 的源码级实现,讲解 onAfterChangeToShape、onOperationComplete、canBindShapes 等核心 API 的真实调用时机与设计意图。读完本文,你将掌握在 tldraw SDK 中注册自定义绑定类型、编写多形状约束求解逻辑、以及将其接入自定义工具与 UI 的完整套路。
示例概览:这个"图钉"到底做了什么
示例由两个文件构成,全部代码都在 PinExample.tsx 中(文件底部还内嵌了一份编号注释版说明,即 [1]~[7] 标记,下文会逐条对应讲解):
apps/examples/src/examples/shapes/tools/pin-bindings/
├── README.md # 示例说明(front matter + 交互描述)
└── PinExample.tsx # 全部实现:形状、绑定、工具、UI 覆盖
README 的 front matter 把该示例注册为标题 "Pin (bindings)"、组件 ./PinExample.tsx、priority 10,并声明了 bindings、bindingutil、custom binding、statenode、onafterchangetoshape 等关键词。examples 应用(examples.tldraw.com)正是通过 examples.tsx 里的 import.meta.glob('./examples/**/README.md', { eager: true }) 自动扫描每个示例目录的 README 生成目录的,所以任何一个带 front matter 的示例目录都会被自动收录进 "Shapes & tools" 分类下。
按 README 的描述,你可以这样体验它:
- 用矩形工具画出两个互相重叠的形状;
- 切换到 Pin 工具(工具栏上会多出一个图钉项,快捷键
P); - 在两个形状的重叠区域点一下,放下一枚图钉 📍;
- 拖动其中任意一个形状,另一个形状会跟着移动,使图钉始终钉在各自形状上的同一相对位置;
- 直接拖动图钉本身则相当于"拔钉"——图钉会从当前钉住的所有形状上松脱,落到新位置后若下方还有形状,又会自动重新钉上。
除此之外,README 还提示了两条"配套规则":删除被钉住的形状会连它的图钉一起删除;把被钉住的形状重新归组(reparent)时,图钉会跟着进入新的父级坐标空间。
整个例子里,图钉 pin 本身只是一个没有任何 props 的自定义形状,真正"值钱"的部分是 PinBindingUtil:当一枚图钉被丢到若干形状之上时,系统会为它到每个形状各建立一条 pin 类型的 binding,并把图钉落在形状上的位置以归一化锚点的形式存进 binding 的 props 中。
Binding 与 BindingUtil:理解 tldraw 的关系机制
一条 binding 记录是什么
在 tldraw 中,binding 是 store 里一种独立于 shape 的记录(record),它至少包含三个关键字段:fromId(发起方形状)、toId(目标方形状)、type(绑定类型),外加一个可自定义的 props。箭头(arrow)形状与图形之间的连接就是 tldraw 内置 binding 的典型代表。查询、创建、删除这些记录不再需要你直接遍历 store,Editor 提供了一组专用方法,见 Editor.ts:
editor.getBindingsFromShape(shape, type)——返回所有fromId等于该形状的指定类型绑定;editor.getBindingsToShape(shape, type)——返回所有toId等于该形状的指定类型绑定;editor.getBindingsInvolvingShape(shape, type?)——双向都算,不传 type 时返回全部;editor.createBindings(partials)——批量创建,内部会先调用canBindShapes校验,再合并 util 的getDefaultProps()与传入的 props(见 Editor.ts);editor.deleteBindings(bindings, { isolateShapes })——批量删除,isolateShapes: true时会在原子事务里先触发隔离回调再移除记录。
BindingUtil 的生命周期回调
每个 binding 类型对应一个继承自抽象类 BindingUtil 的实例,声明位于 BindingUtil.ts。它需要实现:
static type:与 binding 记录的类型字符串一致;static props/static migrations(可选):props 校验与迁移;abstract getDefaultProps():创建该类型 binding 时的默认 props。
其余全部是可选的生命周期回调,按触发场景可分为四组:
| 回调 | 触发时机 | pin 示例中的用途 |
|---|---|---|
onBeforeCreate / onAfterCreate |
绑定记录创建前 / 后 | — |
onBeforeChange / onAfterChange |
绑定记录本身(props 等)变化前 / 后 | — |
onAfterChangeFromShape / onAfterChangeToShape |
绑定两端的形状(from 方 / to 方)发生变化后 | 记录"哪个被钉形状变了",必要时跟随 reparent |
onBeforeDeleteFromShape / onBeforeDeleteToShape |
绑定两端形状即将被删除时 | 被钉形状被删时连坐删除图钉 |
onBeforeIsolateFromShape / onBeforeIsolateToShape |
两个绑定形状发生分离(删除、复制、复制其中一方等)时 | — |
onOperationComplete |
涉及该绑定类型的一次 store 操作整体完成后 | 一次性解算整个图钉网络 |
源码注释特别提醒(BindingUtil.ts):隔离回调(isolate)与删除回调(delete)语义不同——若剩余形状依赖 binding 决定渲染位置(如箭头端点),删除/复制某方后应立即用隔离回调修正剩余方状态,防止箭头指向错误位置;而"被钉形状删除时顺带删掉贴纸/图钉"这类"连坐"行为,则更适合用删除回调。pin 示例选用的正是删除回调 onBeforeDeleteToShape。
值得注意的是 BindingOnShapeChangeOptions 里带了一个 reason: 'self' | 'ancestry' 字段:形状变化既可能是它自身变了(self),也可能是它父级变化导致(ancestry),在设计回调时可以用它区分处理。而 onOperationComplete 的 JSDoc 则直接点出了它的设计初衷——"当一次涉及该绑定类型的 store 操作完成时调用,非常适合处理需要一起更新的相关绑定网络"(见 BindingUtil.ts),这正是图钉网络解算选择它的原因。
类型扩展:为 shape 与 binding 声明全局映射
pin 示例的第一步,是通过 TypeScript 的 declare module 把 pin 类型注册进 tldraw 的全局类型映射,让 TLShape / TLBinding 的联合类型认识它们:
const PIN_TYPE = 'pin'
declare module 'tldraw' {
export interface TLGlobalShapePropsMap {
[PIN_TYPE]: Record<string, never> // 图钉形状没有任何 props
}
}
type PinShape = TLShape<typeof PIN_TYPE>
declare module 'tldraw' {
export interface TLGlobalBindingPropsMap {
[PIN_TYPE]: {
anchor: VecModel // 归一化锚点 { x, y },取值 0~1
}
}
}
type PinBinding = TLBinding<typeof PIN_TYPE>
绑定侧的 props 只存一个 anchor: VecModel。这里的关键设计是 anchor 是归一化坐标(0~1),而不是页面坐标:PinBindingUtil.getDefaultProps() 返回 { anchor: { x: 0.5, y: 0.5 } }(目标中心),写入时用 invLerp 把点击点映射到目标 bounds 内的 0~1 区间。这样即使被钉住的形状之后被缩放、变形,图钉仍能通过"锚点百分比"重新定位,天然跟随目标的几何变化。
PinShapeUtil:一个"钉不住图钉"的小形状
PinShapeUtil([1] 号注释对应的代码)代表图钉本体。它没有 props,渲染是一个 32×32 的 📍 emoji;关键是把 geometry 与 indicator 都偏移 (-16, -26),使形状的原点 (0,0) 正好落在 emoji 图钉的针尖上——这个原点就是后续要与其他形状绑定的点:
class PinShapeUtil extends ShapeUtil<PinShape> {
static override type = PIN_TYPE
static override props: RecordProps<PinShape> = {}
override canEdit() { return false }
override canResize() { return false }
override hideRotateHandle() { return true }
override isAspectRatioLocked() { return true }
override getGeometry() {
return new Rectangle2d({ width: 32, height: 32, x: offsetX, y: offsetY, isFilled: true })
}
// component() 渲染 📍;getIndicatorPath() 返回偏移后的矩形 Path2D
}
示例重点在 canBind([2] 号注释)。Editor.canBindShapes 的实现(Editor.ts)规定:当 fromShape 与 toShape 类型不同时,两个形状 util 的 canBind 都要返回 true 才允许绑定;类型相同时只问发起方。因此 pin 的 canBind 这样写:
override canBind({ toShape, bindingType }: TLShapeUtilCanBindOpts<PinShape>) {
if (bindingType === PIN_TYPE) {
return toShape.type !== PIN_TYPE // 图钉不能钉在图钉上
}
return true // 但图钉仍可作为其他绑定(如箭头)的目标
}
也就是:系统在询问"图钉能否钉上图钉"时返回 false(防止无限叠加),同时保留图钉被箭头等其它 binding 指向的可能性。
落下即钉:拖动与重新落点(onTranslateStart / onTranslateEnd)
"如何确定钉在哪些形状上、钉在哪个位置"由 onTranslateStart 与 onTranslateEnd 实现([3] 号注释):
- 拖动开始即拔钉:
onTranslateStart里调用getBindingsFromShape(shape, PIN_TYPE)取出图钉的全部 pin 绑定并deleteBindings,实现 README 里"拖动图钉本身即解除图钉"的行为; - 松手时重新钉上:
onTranslateEnd里取图钉针尖的页面坐标(getShapePageTransform(pin).applyToPoint({ x: 0, y: 0 })),用editor.getShapesAtPoint(pageAnchor, { hitInside: true })做命中测试,再过滤掉三条不满足条件的形状:canBindShapes校验不通过(例如目标也是 pin);- 与图钉不在同一个父级(
shape.parentId === pin.parentId),保证坐标空间一致、防止跨页面/跨容器错位; - z 序高于图钉的形状(
shape.index < pin.index),避免图钉落到自己上方形状的"背面"而被钉住。
对每一个通过筛选的目标,用 invLerp 把针尖在其 bounds 内的位置换算成 0~1 归一化锚点,再 createBinding 写入:
this.editor.createBinding({
type: PIN_TYPE,
fromId: pin.id,
toId: target.id,
props: { anchor }, // 归一化,可抗缩放
})
创建时 Editor.createBindings 会把 PinBindingUtil.getDefaultProps()(0.5, 0.5)与传入 props 合并(见 Editor.ts),这也是为什么重钉时不必再写全字段。
网络解算:先记账、后收敛(onAfterChangeToShape + onOperationComplete)
这是整个示例最有代表性的部分:一次拖动往往会让多个被钉形状同时变化,但解算必须只做一次,而不是每个形状各做一次。
做法分两步([4] 号注释)。先用一个实例级 private changedToShapes = new Set<TLShapeId>() 记账:
override onAfterChangeToShape({ binding }: BindingOnShapeChangeOptions<PinBinding>): void {
this.changedToShapes.add(binding.toId)
const pin = this.editor.getShape(binding.fromId)
if (!pin) return
if (pin.parentId !== shapeAfter.parentId) {
this.editor.reparentShapes([pin.id], shapeAfter.parentId)
}
}
onAfterChangeToShape 只负责"把被改变的 to 方 id 记下来",并把实际位移推迟到 onOperationComplete——后者是整次 store 操作结束后才触发的钩子,见 BindingUtil.ts。这样无论一次操作波及多少个形状,网络只需被解算一轮。
真正的解算逻辑([5] 号注释)是一套轻量约束求解器,可拆成三步:
- 扩散收集:从
changedToShapes出发,用getBindingsToShape(shape, PIN_TYPE)/getBindingsFromShape(pin.id, PIN_TYPE)沿 pin 绑定做类似 BFS 的扩散,把整个连通网络里的形状与 pin 全部收进allShapes。对每个 pin,遍历它钉住的每个目标,用lerp(targetBounds.minX, targetBounds.maxX, anchor.x)之类公式还原出"图钉当前应该停留的页面位置",算出它与目标原点的期望偏移targetDelta,存入双向邻接表targetDeltas(A 相对 B 的偏移取反即 B 相对 A 的偏移); - 迭代松弛:
changedToShapes里的形状被标记为固定点(它们刚刚被用户拖动,不允许再动);其余形状进入 30 轮迭代,每轮计算每个可动形状相对其所有邻居的"应移动量"并取平均(Vec.Average(deltas).add(currentPosition)),逐步把整张网络"松弛"到所有约束同时满足的状态; - 一次提交:收敛后对比初始位置,只把位移超过阈值(
delta.len2() <= 0.01的忽略)的形状收进updates,最后调用一次editor.updateShapes(updates)批量更新。
这个提交的妙处在于它自己会再次触发回调:updateShapes 使 to 方再次变化 → onAfterChangeToShape 重新把 id 写进 changedToShapes → 下一次 onOperationComplete 再次解算。只有某轮解算"零位移"时才会 changedToShapes.clear()(见 PinExample.tsx 中 onOperationComplete 末尾),因此网络会自然震荡收敛后停下来,不会无限循环。一次拖动最终只产生极少的 updateShapes 调用,性能开销很小。
删除连坐与跟随归组:两条"伴随"规则
README 提到删除被钉形状会删除图钉、重分组会带图钉走,对应两个回调:
override onBeforeDeleteToShape({ binding }: BindingOnShapeDeleteOptions<PinBinding>): void {
this.editor.deleteShape(binding.fromId) // 目标被删 → 连坐删除钉在它上面的图钉
}
onBeforeDeleteToShape 在被钉住的 to 方形状即将删除前触发,这里直接删掉图钉。要注意它是删除回调而非隔离回调:图钉的形状内容完全由绑定驱动,目标没了图钉就失去意义,所以适合连坐删除;而像箭头这类"删了端点还得保形"的场景才应该走隔离回调。
而"归组跟走"实现在上文 onAfterChangeToShape 里:当 shapeAfter 被 reparent(如拖进另一个 frame),pin.parentId !== shapeAfter.parentId 为真,于是调用 editor.reparentShapes([pin.id], shapeAfter.parentId)(该 API 见 Editor.ts),让图钉与被钉形状始终处于同一父级、同一坐标空间([6] 号注释)。这与前面 onTranslateEnd 里"只钉同一父级形状"的过滤条件互为呼应。
自定义 PinTool 与 UI 接线
StateNode 工具的"创建即拖拽"接力
PinTool 继承 StateNode([7] 号注释),核心技巧是创建图钉后立刻把控制权移交给 select 工具的 translating 状态:
class PinTool extends StateNode {
static override id = PIN_TYPE
override onEnter() {
this.editor.setCursor({ type: 'cross', rotation: 0 })
}
override onPointerDown(info: TLPointerEventInfo) {
const currentPagePoint = this.editor.inputs.getCurrentPagePoint()
const pinId = createShapeId()
this.editor.markHistoryStoppingPoint() // 保证一次点击 = 一个可撤销历史步
this.editor.createShape({ id: pinId, type: PIN_TYPE, x: currentPagePoint.x, y: currentPagePoint.y })
this.editor.setSelectedShapes([pinId])
this.editor.setCurrentTool('select.translating', {
...info,
target: 'shape',
shape: this.editor.getShape(pinId),
isCreating: true,
onInteractionEnd: 'pin', // 拖拽期间把当前工具 id"伪装"成 pin
onCreate: () => { this.editor.setCurrentTool('pin') },
})
}
}
这样图钉会"粘在指针上"跟着移动,直到鼠标松开,再由 PinShapeUtil.onTranslateEnd 完成落点钉合。markHistoryStoppingPoint(见 Editor.ts)把"放下图钉"标记为一个独立历史步,便于撤销;onInteractionEnd: 'pin' 在拖拽期间让内部工具 id 显示为 pin,工具被"锁住"时松手后会回到 pin 工具。
工具栏按钮、图标与组件覆盖
为了让 pin 工具出现在默认工具栏上,示例用 TLUiOverrides 的 tools 钩子往 schema 里插入一项,并用 TLUiAssetUrlOverrides 提供 heart-icon(存放于 apps/examples/public 下):
const overrides: TLUiOverrides = {
tools(editor, schema) {
schema['pin'] = {
id: 'pin', label: 'Pin', icon: 'heart-icon', kbd: 'p', // 快捷键 P
onSelect: () => editor.setCurrentTool('pin'),
}
return schema
},
}
随后用 TLComponents 的 Toolbar 覆盖把 TldrawUiMenuItem 插到 DefaultToolbarContent 之前,并借助 useIsToolSelected(pin) 实现选中态高亮。
组装进 Tldraw 根组件
最后把这些部分全部传给 <Tldraw>:
<Tldraw
persistenceKey="pin-example"
onMount={(editor) => editor.setStyleForNextShapes(DefaultFillStyle, 'semi')}
shapeUtils={[PinShapeUtil]}
bindingUtils={[PinBindingUtil]}
tools={[PinTool]}
overrides={overrides}
assetUrls={assetUrls}
components={components}
/>
bindingUtils 是注册自定义绑定的入口,persistenceKey="pin-example" 让画布状态持久化到 localStorage,onMount 里预置半透明填充让重叠区域更明显,方便复现"点击两形状重叠处"的操作。
运行示例与延伸阅读
示例应用位于 apps/examples(package name examples.tldraw.com,dev 脚本为 vite --host),在仓库根目录执行即可本地体验:
cd apps/examples
yarn dev
打开后进入 "Shapes & tools" 分类下的 "Pin (bindings)" 即可交互试玩(README 的 front matter 也会被 examples.tsx 扫描进目录并作为文章正文展示)。
想在你自己基于 tldraw SDK 的项目里复刻这套方案,核心只需四步:注册 pin 类型与 anchor props 的类型映射、实现 PinShapeUtil 与 PinBindingUtil、实现 PinTool、把它们分别通过 <Tldraw> 的 bindingUtils/shapeUtils/tools 注入。若想继续深入研究,推荐阅读仓库内两处底层源码:
- BindingUtil.ts——全部生命周期回调的接口定义与 JSDoc,是理解"删除回调 vs 隔离回调"的权威依据;
- Editor.ts——bindings 相关查询、创建、删除与
canBindShapes的实现细节。
同一仓库里还有另一个使用 bindings 的进阶示例 globs-editor,它在节点之间建立了 glob 绑定网络,可以对照阅读,进一步体会 getBindingsFromShape / getBindingsToShape 在实际产品级逻辑中的用法。
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 StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
video-shotcraftAI宣传片skill,使用 Remotion 制作电影级产品视频:提供106 张镜头配方卡和可复用的视频魔板。适用于 Claude Code 与 Codex以及所有其他智能体Markdown00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00