首页
/ tldraw SDK 自定义绑定实战:用 BindingUtil 实现"图钉钉住"多形状联动网络(Pin Bindings)

tldraw SDK 自定义绑定实战:用 BindingUtil 实现"图钉钉住"多形状联动网络(Pin Bindings)

2026-09-08 14:34:30作者:虞亚竹Luna

tldraw 的 bindings(绑定) 系统允许在形状之间建立持久化关联关系:你不再需要自己维护"A 移动后 B 该去哪里"的监听逻辑,而是可以声明一条记录两个形状关系的 binding,再通过 BindingUtil 的生命周期回调统一响应形状的变化。本文以仓库官方示例 pin-bindings 为主线,完整拆解如何用自定义 pin 绑定把多个重叠的形状"钉"在一起,让拖动任何一个形状时整张联动网络自动解算、所有图钉保持原位,并深入 BindingUtilEditor 的源码级实现,讲解 onAfterChangeToShapeonOperationCompletecanBindShapes 等核心 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,并声明了 bindingsbindingutilcustom bindingstatenodeonafterchangetoshape 等关键词。examples 应用(examples.tldraw.com)正是通过 examples.tsx 里的 import.meta.glob('./examples/**/README.md', { eager: true }) 自动扫描每个示例目录的 README 生成目录的,所以任何一个带 front matter 的示例目录都会被自动收录进 "Shapes & tools" 分类下。

按 README 的描述,你可以这样体验它:

  1. 用矩形工具画出两个互相重叠的形状;
  2. 切换到 Pin 工具(工具栏上会多出一个图钉项,快捷键 P);
  3. 在两个形状的重叠区域点一下,放下一枚图钉 📍;
  4. 拖动其中任意一个形状,另一个形状会跟着移动,使图钉始终钉在各自形状上的同一相对位置;
  5. 直接拖动图钉本身则相当于"拔钉"——图钉会从当前钉住的所有形状上松脱,落到新位置后若下方还有形状,又会自动重新钉上。

除此之外,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 modulepin 类型注册进 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)规定:当 fromShapetoShape 类型不同时,两个形状 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)

"如何确定钉在哪些形状上、钉在哪个位置"由 onTranslateStartonTranslateEnd 实现([3] 号注释):

  • 拖动开始即拔钉onTranslateStart 里调用 getBindingsFromShape(shape, PIN_TYPE) 取出图钉的全部 pin 绑定并 deleteBindings,实现 README 里"拖动图钉本身即解除图钉"的行为;
  • 松手时重新钉上onTranslateEnd 里取图钉针尖的页面坐标(getShapePageTransform(pin).applyToPoint({ x: 0, y: 0 })),用 editor.getShapesAtPoint(pageAnchor, { hitInside: true }) 做命中测试,再过滤掉三条不满足条件的形状:
    1. canBindShapes 校验不通过(例如目标也是 pin);
    2. 与图钉不在同一个父级(shape.parentId === pin.parentId),保证坐标空间一致、防止跨页面/跨容器错位;
    3. 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] 号注释)是一套轻量约束求解器,可拆成三步:

  1. 扩散收集:从 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 的偏移);
  2. 迭代松弛changedToShapes 里的形状被标记为固定点(它们刚刚被用户拖动,不允许再动);其余形状进入 30 轮迭代,每轮计算每个可动形状相对其所有邻居的"应移动量"并取平均(Vec.Average(deltas).add(currentPosition)),逐步把整张网络"松弛"到所有约束同时满足的状态;
  3. 一次提交:收敛后对比初始位置,只把位移超过阈值(delta.len2() <= 0.01 的忽略)的形状收进 updates,最后调用一次 editor.updateShapes(updates) 批量更新。

这个提交的妙处在于它自己会再次触发回调updateShapes 使 to 方再次变化 → onAfterChangeToShape 重新把 id 写进 changedToShapes → 下一次 onOperationComplete 再次解算。只有某轮解算"零位移"时才会 changedToShapes.clear()(见 PinExample.tsxonOperationComplete 末尾),因此网络会自然震荡收敛后停下来,不会无限循环。一次拖动最终只产生极少的 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 工具出现在默认工具栏上,示例用 TLUiOverridestools 钩子往 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
	},
}

随后用 TLComponentsToolbar 覆盖把 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 的类型映射、实现 PinShapeUtilPinBindingUtil、实现 PinTool、把它们分别通过 <Tldraw>bindingUtils/shapeUtils/tools 注入。若想继续深入研究,推荐阅读仓库内两处底层源码:

  • BindingUtil.ts——全部生命周期回调的接口定义与 JSDoc,是理解"删除回调 vs 隔离回调"的权威依据;
  • Editor.ts——bindings 相关查询、创建、删除与 canBindShapes 的实现细节。

同一仓库里还有另一个使用 bindings 的进阶示例 globs-editor,它在节点之间建立了 glob 绑定网络,可以对照阅读,进一步体会 getBindingsFromShape / getBindingsToShape 在实际产品级逻辑中的用法。

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

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.76 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
860
1.35 K
docsdocs
暂无描述
Markdown
899
5.83 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
925
1.85 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.84 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
533
601
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.03 K
525
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.37 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
395