tldraw 自定义 Binding 实战:用 Layout Bindings 实现容器内的自动排布与拖拽换位
导读
本篇文章以 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 时提供默认属性 | 为 index、placeholder 等字段提供初值 |
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;layoutbinding: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.container、com.tldraw.shape.element 与 com.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 映射中登记自己的属性。这里的关键是 index 与 placeholder 两个字段(源码注释 [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,记录也会自动带上默认的 index 与 placeholder。示例的 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)
}
四个钩子覆盖了所有会改变行布局的事件:
- onAfterCreate:松手落定(
onTranslateEnd中createBinding)后立即把元素吸进槽里; - onAfterChange:拖拽过程中 binding 的
index被更新时,立刻为元素腾出/锁定新槽位; - onAfterChangeFromShape:容器被拖动/移动时,从
binding.fromId这一侧感知容器变换,让元素跟随容器; - onAfterDelete:元素被拖出容器(binding 被删)或元素自身被删后,把剩余元素收拢、容器缩小。
updateElementsForContainer 的核心分两步。第一步按 index 排序、逐元素计算目标位置并写回(这里 placeholder 为 true 的、正在被拖拽的那个元素被跳过,避免它与指针争抢位置):
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 个元素的目标 x 由 24 + 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 = 644,height = 24 + 100 + 24 = 148,正好等于快照中容器记录的 width: 644 / height: 148;元素 x 从 54.9375(容器 x 30.9375 + 24)起按 124 递增,也与行内 slot 完全吻合。这可以作为“布局公式正确”的可验证证据。
幂等性(不产生死循环) 是这套实现的要点:每次写回前都用 shape.x !== point.x、width !== 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 类型,它本质是“整数部分 + 小数部分”的字符串(形如 a1、a0V、a2),完整实现见 reordering.ts,底层基于经典的分数索引(fractional indexing)算法,目的与图形/页面的 index 排序字段一致:插入时永远可以生成两个相邻值之间的新键,无需重排其他键。
示例用到的两个函数都来自该模块:
getIndexBetween(below, above)(reordering.ts):返回夹在below与above之间的一个IndexKey。若below为空则取 “above 之下”的键,若above为空则取 “below 之上”的键;ZERO_INDEX_KEY('a0'):序列的起点,getDefaultProps中示例默认给的是'a1'。
排序语义为字典序(同文件中的 sortByIndex),因此 snapshot 中 a1 → a5 正好对应从左到右五个卡槽,而 getIndexBetween('a1','a2') 会生成 a1V 之类的新键让新元素可以无缝插入任意两个已有元素之间。
八、把示例真正跑起来:文件位置与运行方式
示例位于 examples 应用的 shapes/tools 分类下,相关文件全部在当前仓库内可直接阅读:
- 示例说明:README.md
- 完整实现(含逐段注释
[1]~[7]):LayoutExample.tsx - 初始画布快照(1 容器 + 5 元素 + 5 binding):snapshot.json
- examples 应用工程配置:apps/examples/package.json
在 examples 目录安装依赖后启动开发服务器(具体脚本以 package.json 中 scripts 为准,例如 npm run dev / yarn dev),即可在编辑器里实际操作:拖动彩色元素在容器内换槽、拖出容器或从页面外拖回容器。需要说明,运行时数据流依赖本文所述的 layout binding 机制,README 强调其适用边界是“元素保持平铺、仅通过 binding 关联容器”,不依赖图形父子嵌套。
若想横向对照 tldraw binding 的更多形态,仓库同类示例还提供了 pin-bindings/PinExample.tsx、sticker-bindings/StickerExample.tsx、persistent-iframe-shape/PersistentIframeExample.tsx 等,可对比不同 binding 生命周期钩子的取舍。
九、设计要点回顾
- 关系与层级解耦:元素与容器只用 binding 关联,容器“看起来有子元素”但 store 中元素仍属页面,布局完全由数据推导;
- canBind 是唯一裁决源:两端 ShapeUtil 的
canBind+Editor.canBindShapes的组合,让拖拽投放判定与合法关系定义不重复; - 生命周期钩子统一收敛:create / change / fromShape change / delete 四条路径复用同一
updateElementsForContainer,配合“先比较再写回”保证幂等、避免自激循环; - placeholder 占位协议:用 binding 自身的一个布尔属性表达“拖拽中间态”,让布局既能持续腾位又不会跟指针抢图形;
- 分数索引支撑任意插入:
IndexKey与getIndexBetween使得“两个槽之间再插一个槽”永远不需要重排既有数据。
这套模式通用性很强:把横排换成网格、把“slot 数”从元素个数推导换成固定列数,再把 index 的换算规则换成二维坐标,就能扩展出表格、仪表盘磁贴、时间轴等更多“由关系驱动排布”的自定义图形系统。
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