首页
/ tldraw 自定义 Binding 实战:用 Layout Bindings 实现容器内的自动排布与拖拽换位

tldraw 自定义 Binding 实战:用 Layout Bindings 实现容器内的自动排布与拖拽换位

2026-09-08 18:42:01作者:胡唯隽

导读

本篇文章以 tldraw 仓库 apps/examples 中的 Layout constraints (bindings) 官方示例为蓝本,系统讲解如何用 tldraw SDK 的自定义 Binding 机制,让一张画布上的图形彼此“建立关系”:容器图形(container)通过若干条自定义 layout binding 管理一组元素图形(element)的横排位置,当元素被拖入/拖出/在容器内换位时,布局自动重排、容器自动伸缩,元素像“吸附进卡槽”一样归位。读完本文你将掌握:自定义 BindingUtil 与 binding 属性的声明方式、BindingUtil 各生命周期钩子的触发时机与用途、ShapeUtil 拖拽钩子与 placeholder 占位 binding 配合实现的“让位”交互,以及底层 Editor binding API 与分数索引(IndexKey)的实际用法。

源码对应位置:LayoutExample.tsx 与示例说明 README.md


一、先说清楚:tldraw 里 Binding 是什么

在 tldraw 中,“绑定”是一条独立于图形的数据记录,用来描述两个图形之间的一种关系。例如箭头图形通过 arrow binding 记录它连接到了哪个图形的哪个点。Binding 与 tldraw 传统的父子层级不同——父子层级意味着子图形画在父图形之上、随父图形整体变换,而 binding 只是记录关系,图形本身仍然独立存在于页面上。

Binding 记录的最小结构定义在 TLBaseBinding.ts

  • id:记录唯一 ID,形如 binding:xxx
  • type:binding 的具体类型字符串,如 arrow 或自定义的 layout
  • fromId:关系“源”图形 ID;
  • toId:关系“目标”图形 ID;
  • props:该类型 binding 自己的属性(决定行为与外观);
  • meta:用户自定义的元数据。

仓库自带的类型化 binding 实例可参考 TLArrowBinding.ts,其中箭头的 fromId 指向箭头自身、toId 指向被连接的图形。

Binding 的生命周期由对应的 BindingUtil 驱动,BindingUtil 的抽象基类实现在 BindingUtil.ts。你可以覆写它提供的一整套可选钩子,包括:

钩子 触发时机 典型用途
getDefaultProps() 必须实现,创建 binding 时提供默认属性 indexplaceholder 等字段提供初值
onAfterCreate binding 创建完成后 立即执行一次布局
onAfterChange binding 自身记录变化后 元素换 slot 时重排
onAfterChangeFromShape / onAfterChangeToShape fromId / toId 对应的图形变化后 容器被移动时重排
onAfterDelete binding 删除后 移除某元素后收拢行布局
onBeforeDeleteFromShape / onBeforeDeleteToShape 绑定图形被删除前 处理被删图形周边的绑定
onOperationComplete 涉及本类型 binding 的一次 store 操作整体完成后 批量处理“变化过”的 binding

注意:onAfterChange 只在 binding 记录本身变化时触发;绑定的图形变化要走 onAfterChangeFromShape / onAfterChangeToShape。这是本节 Layout 示例同时覆写多个钩子的直接原因。


二、示例的三种角色:容器、元素与 layout binding

示例的场景是“在容器里横排元素”。它定义了三种自定义类型:

const CONTAINER_TYPE = 'container'
const ELEMENT_TYPE = 'element'
const LAYOUT_TYPE = 'layout'
const ELEMENT_SIZE = 100
const CONTAINER_PADDING = 24
  • container 形状:一个灰色底容器,宽高来自它的 props.width / props.height
  • element 形状:边长 ELEMENT_SIZE = 100 的彩色方块,颜色来自 props.color
  • layout binding:fromId 指向容器、toId 指向元素,props 记录该元素在行内的位置。

关键设计点在于:容器没有任何 tldraw 意义上的“子图形”(源码注释 [1] 明确说明),元素们仍平铺在页面上,与容器唯一的关联就是那些 layout binding。因此容器尺寸不是手动维护的,而是“有多少条 binding 就推导多大”。

通过 declare module 'tldraw' 为全局 props 映射表扩展两个图形的 props(LayoutExample.tsx):

declare module 'tldraw' {
	export interface TLGlobalShapePropsMap {
		[CONTAINER_TYPE]: { height: number; width: number }
		[ELEMENT_TYPE]: { color: string }
	}
}

随后在 <Tldraw> 组件上注册这两个 ShapeUtil 与一个 BindingUtil:

const shapeUtils = [ContainerShapeUtil, ElementShapeUtil]
const bindingUtils = [LayoutBindingUtil]

export default function LayoutExample() {
	return (
		<div className="tldraw__editor">
			<Tldraw
				snapshot={snapshot as unknown as TLStoreSnapshot}
				shapeUtils={shapeUtils}
				bindingUtils={bindingUtils}
			/>
		</div>
	)
}

snapshot 来自同目录的 snapshot.json,它直接预置了一份“1 个容器 + 5 个元素 + 5 条 layout binding”的完整 store 快照,schema 区可以看到自定义的 com.tldraw.shape.containercom.tldraw.shape.elementcom.tldraw.binding.layout 序列化记录。读者打开示例即可看到一行方块立在灰色容器内——这正是文件底部注释所说 “A container shape lays out element shapes in a row”。


三、用 canBind 把“谁能放哪里”收敛成唯一裁决点

ShapeUtil 基类中提供 canBind(opts) 钩子(见 ShapeUtil.ts),用来回答“这个 binding 允许建吗”。示例里容器与元素两个 ShapeUtil 覆写了完全一致的逻辑(源码注释 [2]):

override canBind({ fromShape, toShape, bindingType }: TLShapeUtilCanBindOpts<ContainerShape>) {
	return (
		fromShape.type === CONTAINER_TYPE &&
		toShape.type === ELEMENT_TYPE &&
		bindingType === LAYOUT_TYPE
	)
}

也就是:只允许“container → element”且类型为 layout 的 binding。这样设计的目的很巧妙:拖拽找投放点时,ElementShapeUtil 内部调用 editor.canBindShapes(...) 判断某个容器是否是合法投放目标,而 Editor.canBindShapes 会同时征询两侧图形的 canBind(实现见 Editor.ts:当 fromShapeType === toShapeType 时只查单侧,否则两侧都必须放行)。因此规则只有一份,杜绝了“ShapeUtil 里一套、拖拽逻辑里另一套”的漂移。

同时该示例中容器与元素都不能编辑/缩放/旋转,并锁定了宽高比,保证布局形态只由 binding 数据驱动:

override canEdit(shape) { return false }
override canResize(shape) { return false }
override hideRotateHandle(shape) { return true }
override isAspectRatioLocked(shape) { return true }

四、注册自定义 binding 类型:layout 的 props 设计

自定义 binding 需要在 tldraw 全局 props 映射中登记自己的属性。这里的关键是 indexplaceholder 两个字段(源码注释 [6]LayoutExample.tsx):

declare module 'tldraw' {
	export interface TLGlobalBindingPropsMap {
		[LAYOUT_TYPE]: {
			index: IndexKey
			placeholder: boolean
		}
	}
}
  • index: IndexKey:分数索引,决定该元素在行内的位置,值越小越靠前(详见第七节);
  • placeholder: boolean:标记“这次拖拽还没结束”,布局算法据此为该元素预留卡槽但不把它吸进槽里,让它继续跟随指针。

随后定义 binding 类型别名与默认属性:

type LayoutBinding = TLBinding<typeof LAYOUT_TYPE>

class LayoutBindingUtil extends BindingUtil<LayoutBinding> {
	static override type = LAYOUT_TYPE

	override getDefaultProps() {
		return {
			index: 'a1' as IndexKey,
			placeholder: true,
		}
	}
	// ...生命周期钩子
}

需要说明的是 Editor.createBindings 在真正入库时会做三件事(见 Editor.ts):先确认两侧图形存在、再调用 canBindShapes 校验合法性、最后把 getDefaultProps() 的返回值与传入的 partial.props 合并({ ...defaultProps, ...partial.props })。所以即使调用方只传 type/fromId/toId,记录也会自动带上默认的 indexplaceholder。示例的 snapshot.json 中 5 条 layout binding 的 props 即形如 { "index": "a2", "placeholder": false }——注意落地数据里 placeholder 都是 false,说明运行时只有拖拽中间态才会把它置为 true


五、BindingUtil 生命周期钩子:增、改、移、删都会触发同一套布局

LayoutBindingUtil 把四个钩子全部指向同一个私有方法 updateElementsForContainer(源码注释 [7]):

override onAfterCreate({ binding }: BindingOnCreateOptions<LayoutBinding>): void {
	this.updateElementsForContainer(binding)
}

override onAfterChange({ bindingAfter }: BindingOnChangeOptions<LayoutBinding>): void {
	this.updateElementsForContainer(bindingAfter)
}

override onAfterChangeFromShape({ binding }: BindingOnShapeChangeOptions<LayoutBinding>): void {
	this.updateElementsForContainer(binding)
}

override onAfterDelete({ binding }: BindingOnDeleteOptions<LayoutBinding>): void {
	this.updateElementsForContainer(binding)
}

四个钩子覆盖了所有会改变行布局的事件:

  1. onAfterCreate:松手落定(onTranslateEndcreateBinding)后立即把元素吸进槽里;
  2. onAfterChange:拖拽过程中 binding 的 index 被更新时,立刻为元素腾出/锁定新槽位;
  3. onAfterChangeFromShape:容器被拖动/移动时,从 binding.fromId 这一侧感知容器变换,让元素跟随容器;
  4. onAfterDelete:元素被拖出容器(binding 被删)或元素自身被删后,把剩余元素收拢、容器缩小。

updateElementsForContainer 的核心分两步。第一步按 index 排序、逐元素计算目标位置并写回(这里 placeholdertrue 的、正在被拖拽的那个元素被跳过,避免它与指针争抢位置):

const bindings = this.editor
	.getBindingsFromShape(container, LAYOUT_TYPE)
	.sort((a, b) => (a.props.index > b.props.index ? 1 : -1))
if (bindings.length === 0) return

for (let i = 0; i < bindings.length; i++) {
	const binding = bindings[i]
	if (toId === binding.toId && placeholder) continue   // 被拖拽元素只留槽位、不归位

	const offset = new Vec(
		CONTAINER_PADDING + i * (ELEMENT_SIZE + CONTAINER_PADDING),
		CONTAINER_PADDING
	)
	const shape = this.editor.getShape<ElementShape>(binding.toId)
	if (!shape) continue

	const point = this.editor.getPointInParentSpace(
		shape,
		this.editor.getShapePageTransform(container)!.applyToPoint(offset)
	)

	if (shape.x !== point.x || shape.y !== point.y) {
		this.editor.updateShape({ id: binding.toId, type: ELEMENT_TYPE, x: point.x, y: point.y })
	}
}

一个工程细节:第 i 个元素的目标 x24 + i × (100 + 24) 得出(24 为左右内边距,124 为一个元素加一条间隔)。第二步是重算容器尺寸并写回:

const width =
	CONTAINER_PADDING +
	(bindings.length * ELEMENT_SIZE + (bindings.length - 1) * CONTAINER_PADDING) +
	CONTAINER_PADDING
const height = CONTAINER_PADDING + ELEMENT_SIZE + CONTAINER_PADDING

if (width !== container.props.width || height !== container.props.height) {
	this.editor.updateShape({
		id: container.id,
		type: CONTAINER_TYPE,
		props: { width, height },
	})
}

你可以用 snapshot.json 里的数据做一次核对:5 个元素时 width = 24 + (5×100 + 4×24) + 24 = 644height = 24 + 100 + 24 = 148,正好等于快照中容器记录的 width: 644 / height: 148;元素 x54.9375(容器 x 30.9375 + 24)起按 124 递增,也与行内 slot 完全吻合。这可以作为“布局公式正确”的可验证证据。

幂等性(不产生死循环) 是这套实现的要点:每次写回前都用 shape.x !== point.xwidth !== container.props.width 这类比较做短路——目标已达成就不调用 updateShape;而没有实际变化的 update 也不会再次触发钩子,因此增删改的联动不会无限递归。源码注释 [7] 特意点出 “Comparing before updating keeps this idempotent so it doesn't loop.”


六、元素拖拽三段式:onTranslateStart / onTranslate / onTranslateEnd

ShapeUtil 提供三个与平移相关的可选钩子(定义见 ShapeUtil.ts):onTranslateStart(shape)onTranslate(initial, shape)onTranslateEnd(initial, shape)。示例用它们拼出了完整的“让位-挪槽-落定”交互。

1. 拖拽开始:把既有 binding 标记为 placeholder

onTranslateStart(源码注释 [4])把元素当前的所有 layout binding 统一改成 placeholder: true。这样布局算法会继续“占住”它的槽位(防止别人补位),但不再把它吸回去:

override onTranslateStart(shape: ElementShape) {
	this.editor.updateBindings(
		this.editor.getBindingsToShape(shape, LAYOUT_TYPE).map((binding) => ({
			...binding,
			props: { ...binding.props, placeholder: true },
		}))
	)
}

2. 拖拽进行中:实时计算该进哪个容器、哪个槽

onTranslate 每次指针移动都会执行一次“重定位判断”,分三步(用注释标号 [3] 对应的辅助方法实现):

  • getElementCenter:通过 editor.getShapePageTransform(shape).applyToPoint(...) 把元素中心点换算成页面坐标 pageAnchor
  • getTargetContainer:在 pageAnchor 处用 editor.getShapeAtPoint(pageAnchor, { hitInside: true, filter: ... }) 做命中测试,filter 内调用 editor.canBindShapes(...) 确认“候选图形(作为 from)→ 当前元素(作为 to)”的 layout binding 合法,从而只接受真正的容器;
  • getBindingIndexForPosition:把落点换算成分数索引,见源码:
const order = clamp(
	Math.round(
		(pageAnchor.x - container.x - CONTAINER_PADDING) / (ELEMENT_SIZE + CONTAINER_PADDING)
	),
	0,
	siblings.length + 1
)

const belowSib = allBindings[order - 1]
const aboveSib = allBindings[order]
let index: IndexKey

if (belowSib?.toId === shape.id) {
	index = belowSib.props.index
} else if (aboveSib?.toId === shape.id) {
	index = aboveSib.props.index
} else {
	index = getIndexBetween(belowSib?.props.index, aboveSib?.props.index)
}

即:用“元素中心距离哪个卡槽最近”四舍五入出序号 order,再取它上下两条相邻 binding 的 index 求中间值。而当相邻 binding 本来就是该元素自己时,直接复用旧 index,避免拖拽过程中同一槽位反复 churn 出新的索引值。

onTranslate 主流程再依据目标状态做三件事:指针不在任何容器上 → 删除该元素的 binding(元素被“拖出”行);已在该容器的 binding 中且 index 未变 → 不动;否则更新已有 binding 或新建一条 placeholder: true 的 binding 到新容器/新槽位:

if (!targetContainer) {
	this.editor.deleteBindings(this.editor.getBindingsToShape(shape, LAYOUT_TYPE))
	return
}
// ...计算 index ...
const existingBinding = this.editor
	.getBindingsFromShape(targetContainer, LAYOUT_TYPE)
	.find((b) => b.toId === shape.id)

if (existingBinding) {
	if (existingBinding.props.index === index) return
	this.editor.updateBinding({ ...existingBinding, props: { ...existingBinding.props, placeholder: true, index } })
} else {
	this.editor.createBinding({
		type: LAYOUT_TYPE,
		fromId: targetContainer.id,
		toId: shape.id,
		props: { index, placeholder: true },
	})
}

由于这些写入会立刻触发第五节中的 onAfterChange / onAfterCreate 钩子,整行的其他元素就会在拖拽进行中实时地“让出位置”,形成行内插入的预览效果。

3. 拖拽结束:清掉临时 binding,建立正式 binding

onTranslateEnd(源码注释 [5])代表交互定稿:如果元素落在了某个容器上方,就把它身上残留的 binding 全部删除,再以非 placeholder 的形式重新创建一条干净的 binding。这次创建会触发 onAfterCreate,布局算法立即把元素精确“吸”进槽位:

override onTranslateEnd(_: ElementShape, shape: ElementShape) {
	const pageAnchor = this.getElementCenter(shape)
	const targetContainer = this.getTargetContainer(shape, pageAnchor)
	if (!targetContainer) return

	const index = this.getBindingIndexForPosition(shape, targetContainer, pageAnchor)

	this.editor.deleteBindings(this.editor.getBindingsToShape(shape, LAYOUT_TYPE))
	this.editor.createBinding({
		type: LAYOUT_TYPE,
		fromId: targetContainer.id,
		toId: shape.id,
		props: { index, placeholder: false },
	})
}

完整交互闭环因此是:开始拖拽 → 槽位变为 placeholder(预留)→ 指针移动实时插入/移除/跨容器迁移槽位 → 松手 → 单条非 placeholder binding 落库并触发吸合。README 建议你亲手拖一个元素到容器里的其他卡槽,或拖出容器再拖回来,观察占位让位与重排过程。


七、IndexKey:用来“夹”出中间槽位的分数索引

示例的 index 使用 tldraw 的 IndexKey 类型,它本质是“整数部分 + 小数部分”的字符串(形如 a1a0Va2),完整实现见 reordering.ts,底层基于经典的分数索引(fractional indexing)算法,目的与图形/页面的 index 排序字段一致:插入时永远可以生成两个相邻值之间的新键,无需重排其他键

示例用到的两个函数都来自该模块:

  • getIndexBetween(below, above)reordering.ts):返回夹在 belowabove 之间的一个 IndexKey。若 below 为空则取 “above 之下”的键,若 above 为空则取 “below 之上”的键;
  • ZERO_INDEX_KEY'a0'):序列的起点,getDefaultProps 中示例默认给的是 'a1'

排序语义为字典序(同文件中的 sortByIndex),因此 snapshot 中 a1 → a5 正好对应从左到右五个卡槽,而 getIndexBetween('a1','a2') 会生成 a1V 之类的新键让新元素可以无缝插入任意两个已有元素之间。


八、把示例真正跑起来:文件位置与运行方式

示例位于 examples 应用的 shapes/tools 分类下,相关文件全部在当前仓库内可直接阅读:

在 examples 目录安装依赖后启动开发服务器(具体脚本以 package.json 中 scripts 为准,例如 npm run dev / yarn dev),即可在编辑器里实际操作:拖动彩色元素在容器内换槽、拖出容器或从页面外拖回容器。需要说明,运行时数据流依赖本文所述的 layout binding 机制,README 强调其适用边界是“元素保持平铺、仅通过 binding 关联容器”,不依赖图形父子嵌套。

若想横向对照 tldraw binding 的更多形态,仓库同类示例还提供了 pin-bindings/PinExample.tsxsticker-bindings/StickerExample.tsxpersistent-iframe-shape/PersistentIframeExample.tsx 等,可对比不同 binding 生命周期钩子的取舍。


九、设计要点回顾

  1. 关系与层级解耦:元素与容器只用 binding 关联,容器“看起来有子元素”但 store 中元素仍属页面,布局完全由数据推导;
  2. canBind 是唯一裁决源:两端 ShapeUtil 的 canBind + Editor.canBindShapes 的组合,让拖拽投放判定与合法关系定义不重复;
  3. 生命周期钩子统一收敛:create / change / fromShape change / delete 四条路径复用同一 updateElementsForContainer,配合“先比较再写回”保证幂等、避免自激循环;
  4. placeholder 占位协议:用 binding 自身的一个布尔属性表达“拖拽中间态”,让布局既能持续腾位又不会跟指针抢图形;
  5. 分数索引支撑任意插入IndexKeygetIndexBetween 使得“两个槽之间再插一个槽”永远不需要重排既有数据。

这套模式通用性很强:把横排换成网格、把“slot 数”从元素个数推导换成固定列数,再把 index 的换算规则换成二维坐标,就能扩展出表格、仪表盘磁贴、时间轴等更多“由关系驱动排布”的自定义图形系统。

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

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.75 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++
924
1.85 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.84 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
533
599
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
394