tldraw 自定义容器图形实战:用 BaseFrameLikeShapeUtil 实现拖拽收纳的 Container Shape
导读
本文围绕 tldraw SDK 官方示例 apps/examples/src/examples/shapes/tools/drag-and-drop 展开,讲解如何基于 BaseFrameLikeShapeUtil 构建一个"容器类"自定义图形:它像内置 frame 一样接受被拖入的图形成为其子节点、允许子节点被拖出回到画布页面,并按自身几何裁切子节点。读完本文,你将掌握容器图形的两个关键开关——canReceiveNewChildrenOfType 与 canRemoveChildrenOfType——的语义与组合用法,并能直接基于示例代码(一个只收不放的 "网格" 容器)落地到自己的 React + tldraw 应用中。
一、问题的场景:为什么自定义图形也需要"拖入/拖出"
在 tldraw 的画布上,内置的 frame(框架) 拥有一套特殊的交互语义:当你把任意图形拖到 frame 上松手,它会自动被"收编"为 frame 的子节点(reparent);把子节点拖出 frame 边界,它又会回到页面层级。同时子节点会被 frame 的几何边界裁切。
这套能力并非 frame 独享的魔法,而是由基类 BaseFrameLikeShapeUtil 提供的通用行为。它位于 packages/editor/src/lib/editor/shapes/BaseFrameLikeShapeUtil.tsx,其 JSDoc 明确写道:"Extending this class is the easiest way to create a custom frame-like shape"(继承它是创建自定义 frame-like 图形最简单的方式)。官方内置的 FrameShapeUtil 本身就是它的直接子类(见 packages/tldraw/src/lib/shapes/frame/FrameShapeUtil.tsx#L76),所以任何继承它的自定义图形都能"免费获得"与 frame 一致的容器交互。
本示例展示的正是这套机制的最小可运行实现:一个绘制成网格的自定义容器图形(my-grid-shape),与若干红色圆形计数器(my-counter-shape)。网格只接受计数器拖入,且一旦拖入就"锁死"不允许再拖出。
二、示例的核心设计
先看示例目录结构:
apps/examples/src/examples/shapes/tools/drag-and-drop/
├── DragAndDropExample.tsx # 示例源码
└── README.md # 示例说明
示例定义了两种图形:
| 图形类型 | 类 | 基类 | 行为 |
|---|---|---|---|
my-grid-shape |
MyGridShapeUtil |
BaseFrameLikeShapeUtil |
容器:接收计数器、裁切子节点、禁止移出 |
my-counter-shape |
MyCounterShapeUtil |
ShapeUtil |
内容:固定大小的圆形计数器,不可缩放 |
两者通过一个"只收不放"的规则组合,让你直观地看到 BaseFrameLikeShapeUtil 的拖入/拖出钩子与两个门槛方法的实际效果。
三、逐段拆解完整实现
示例源码为 DragAndDropExample.tsx,其完整逻辑如下:
import {
BaseFrameLikeShapeUtil,
Circle2d,
Geometry2d,
Group2d,
HTMLContainer,
Rectangle2d,
ShapeUtil,
TLShape,
Tldraw,
} from 'tldraw'
import 'tldraw/tldraw.css'
const MY_GRID_SHAPE_TYPE = 'my-grid-shape'
const MY_COUNTER_SHAPE_TYPE = 'my-counter-shape'
// [1] 把自定义图形的 props 注册进全局类型系统
declare module 'tldraw' {
export interface TLGlobalShapePropsMap {
[MY_GRID_SHAPE_TYPE]: { w: number; h: number }
[MY_COUNTER_SHAPE_TYPE]: Record<string, never>
}
}
// [2] 以 type 为类型参数派生具体图形类型
type MyGridShape = TLShape<typeof MY_GRID_SHAPE_TYPE>
type MyCounterShape = TLShape<typeof MY_COUNTER_SHAPE_TYPE>
// [3] 计数器:固定尺寸的普通 ShapeUtil
const SLOT_SIZE = 100
class MyCounterShapeUtil extends ShapeUtil<MyCounterShape> {
static override type = MY_COUNTER_SHAPE_TYPE
override canResize(shape: MyCounterShape) {
return false
}
override hideResizeHandles(shape: MyCounterShape) {
return true
}
getDefaultProps(): MyCounterShape['props'] {
return {}
}
getGeometry(): Geometry2d {
return new Circle2d({ radius: SLOT_SIZE / 2 - 10, isFilled: true })
}
component() {
return (
<HTMLContainer
style={{
backgroundColor: '#e03131',
border: '1px solid #ff8787',
borderRadius: '50%',
}}
/>
)
}
getIndicatorPath() {
const path = new Path2D()
path.arc(SLOT_SIZE / 2 - 10, SLOT_SIZE / 2 - 10, SLOT_SIZE / 2 - 10, 0, Math.PI * 2)
return path
}
}
// [4] 网格:继承 BaseFrameLikeShapeUtil 的容器图形
class MyGridShapeUtil extends BaseFrameLikeShapeUtil<MyGridShape> {
static override type = MY_GRID_SHAPE_TYPE
getDefaultProps(): MyGridShape['props'] {
return {
w: SLOT_SIZE * 5,
h: SLOT_SIZE * 2,
}
}
// Frame-like 图形必须返回 Group2d:编辑器命中检测会遍历其子节点
override getGeometry(shape: MyGridShape): Geometry2d {
return new Group2d({
children: [new Rectangle2d({ width: shape.props.w, height: shape.props.h, isFilled: true })],
})
}
override canResize(_shape: MyGridShape) {
return false
}
override hideResizeHandles(_shape: MyGridShape) {
return true
}
// [5] 只允许计数器类型被拖入
override canReceiveNewChildrenOfType(_shape: MyGridShape, type: TLShape['type']) {
return type === MY_COUNTER_SHAPE_TYPE
}
// [6] 计数器一旦成为子节点就不允许被拖出
override canRemoveChildrenOfType(_shape: MyGridShape, type: TLShape['type']) {
return type !== MY_COUNTER_SHAPE_TYPE
}
// [7] 用 CSS 渐变画出网格槽位
component(shape: MyGridShape) {
return (
<HTMLContainer
style={{
backgroundColor: '#efefef',
borderRight: '1px solid #ccc',
borderBottom: '1px solid #ccc',
backgroundSize: `${SLOT_SIZE}px ${SLOT_SIZE}px`,
width: shape.props.w,
height: shape.props.h,
backgroundImage: `
linear-gradient(to right, #ccc 1px, transparent 1px),
linear-gradient(to bottom, #ccc 1px, transparent 1px)
`,
}}
/>
)
}
override getIndicatorPath(shape: MyGridShape) {
const path = new Path2D()
path.rect(0, 0, shape.props.w, shape.props.h)
return path
}
}
const shapeUtils = [MyGridShapeUtil, MyCounterShapeUtil]
export default function DragAndDropExample() {
return (
<div className="tldraw__editor">
<Tldraw
shapeUtils={shapeUtils}
onMount={(editor) => {
if (editor.getCurrentPageShapeIds().size > 0) return
editor.createShape({ type: 'my-grid-shape', x: 100, y: 100 })
editor.createShape({ type: 'my-counter-shape', x: 700, y: 100 })
editor.createShape({ type: 'my-counter-shape', x: 750, y: 200 })
editor.createShape({ type: 'my-counter-shape', x: 770, y: 300 })
}}
/>
</div>
)
}
3.1 类型系统接入(注释 [1][2])
自定义图形要参与 tldraw 的类型推导,需要向 TLGlobalShapePropsMap 做模块声明补齐(module augmentation):网格声明 { w: number; h: number },计数器没有任何 props,用 Record<string, never> 表示空 props。随后用 TLShape<typeof TYPE> 泛型声明得到强类型的图形类型,供 ShapeUtil / BaseFrameLikeShapeUtil 的泛型参数使用。这样 getDefaultProps、component 等方法内部都能得到 props 类型提示。
3.2 计数器:固定尺寸的普通图形(注释 [3])
MyCounterShapeUtil 继承普通 ShapeUtil,关键点在于:
canResize返回false、hideResizeHandles返回true,让计数器不可缩放、不显示缩放手柄;getGeometry返回填充的Circle2d,命中检测(hit-testing)遵循圆形而非其包围盒;- 视觉使用
HTMLContainer渲染为红色圆形;指示框getIndicatorPath用Path2D画同样半径的圆。
它之所以是独立图形而非 grid 的静态元素,是因为容器收纳的本质就是对"真实子节点"的 reparent 操作——被收纳的图形本身必须是可以独立选中、移动、拖拽的 Shape。
3.3 容器:继承 BaseFrameLikeShapeUtil(注释 [4])
MyGridShapeUtil extends BaseFrameLikeShapeUtil<MyGridShape>。基类源码中(packages/editor/src/lib/editor/shapes/BaseFrameLikeShapeUtil.tsx)列出了一组"开箱即用"的默认行为:
isFrameLike()返回true;providesBackgroundForChildren()返回true(为子节点提供背景层级);canReceiveNewChildrenOfType()默认返回true(除非容器被锁定shape.isLocked);canRemoveChildrenOfType()默认返回true(除非容器被锁定);getClipPath()返回自身几何的顶点数组,即子节点按容器几何裁切;shouldClipChild()除arrow类型外一律裁切;onDragShapesIn()负责将拖入的图形 reparent 到容器下(并尽量恢复其原有的 z 序 index);onDragShapesOut()负责在拖出时把子节点 reparent 回当前页面。
以上全部方法都允许在子类中 override 以定制行为。本示例只覆写了两个"门槛"方法([5][6]),其余交互全部复用基类实现。
另外需要注意:继承该基类的图形 getGeometry 必须返回 Group2d,子节点位于 Group 内部才能被编辑器命中检测遍历到。示例因此返回包裹 Rectangle2d 的 Group2d。
四、两个核心开关的语义与组合
整个示例的交互规则完全由下面两个钩子决定:
// 只有计数器可以被拖入网格
override canReceiveNewChildrenOfType(_shape: MyGridShape, type: TLShape['type']) {
return type === MY_COUNTER_SHAPE_TYPE
}
// 计数器一旦进来就永远出不去
override canRemoveChildrenOfType(_shape: MyGridShape, type: TLShape['type']) {
return type !== MY_COUNTER_SHAPE_TYPE
}
4.1 canReceiveNewChildrenOfType:拖入准入
在 packages/editor/src/lib/editor/shapes/ShapeUtil.ts 中,canReceiveNewChildrenOfType(shape, type) 被描述为:用于决定当某个类型的图形被拖入时是否触发 onDragShapesIn。
在拖拽重排工具 packages/editor/src/lib/utils/reparenting.ts 中,编辑器只有在 canReceiveNewChildrenOfType 通过时才会把被拖图形重排(reparent)到目标容器下(L312 附近同样有该门槛的二次校验)。换言之:拖入是一个有准入控制的动作。示例中网格只对 my-counter-shape 返回 true,所以把任意 geo 图形拖到网格上不会发生任何收纳。
4.2 canRemoveChildrenOfType:拖出许可
对应地,ShapeUtil.ts 描述 canRemoveChildrenOfType 用于决定当某类型子节点被拖出时是否触发 onDragShapesOut。在 reparenting.ts 中,当判断一个子节点能否离开父容器时,正是调用 parentUtil.canRemoveChildrenOfType(parent, child.type)。
当该方法返回 false 时,编辑器不会调用 onDragShapesOut,即子节点即使被拖出容器几何边界也不会被 reparent 回页面。示例里计数器类型返回 false,配合 onDragShapesOut 的"放行检查",计数器就始终钉在网格上。
4.3 二者组合出的"只收不放"行为
组合阅读这两条规则,你就能精确推导示例的最终表现:
- 把计数器拖到网格上 → 类型通过
canReceiveNewChildrenOfType→onDragShapesIn执行 → 计数器成为网格子节点; - 试图把计数器拖出网格边界 →
canRemoveChildrenOfType返回false→ 不触发onDragShapesOut→ 计数器保持在网格内,并且由于基类getClipPath提供了按容器几何的裁切路径,越界部分会被网格边缘裁掉,视觉上明确提示"它仍然属于网格"; - 画一个 geo 图形拖过网格 → 类型未通过准入 → 被整体拒绝,无任何反应。
这就是内置 frame 拖拽语义被"收紧"为一个具体业务规则的完整过程——想实现"能收能放",只需让 canRemoveChildrenOfType 返回 true(或不覆写,用基类默认值)。
五、底层机制:从钩子到 reparent 的调用链
要透彻理解这套交互,需要知道 drag-and-drop 阶段对应 ShapeUtil 上的一组生命周期钩子(全部定义在 packages/editor/src/lib/editor/shapes/ShapeUtil.ts):
| 钩子 | 触发时机 | 说明 |
|---|---|---|
onDragShapesIn? |
图形首次被拖入本图形 | 通常在此 reparent 到容器 |
onDragShapesOver? |
拖入后持续拖拽经过 | 拖拽过程中的每帧更新触发 |
onDragShapesOut? |
子节点被拖出本图形 | 通常 reparent 回页面 |
onDropShapesOver? |
松手落在本图形上 | 落点收尾阶段 |
BaseFrameLikeShapeUtil 的关键实现(BaseFrameLikeShapeUtil.tsx):
- onDragShapesIn:先过滤掉 parentId 已经是本容器的图形;若被拖图形原本就是从本容器拖出的旧子节点,且它们在容器中对应的 index 位置仍空置,则会在重新收纳时恢复原始 z 序 index;随后调用
editor.reparentShapes(draggingShapes, shape.id)完成重排。实现还包含防呆:如果被拖图形里有本容器的祖先,直接放弃(避免形成环)。 - onDragShapesOut:只有当前没有"新的拖拽目标图形"(
info.nextDraggingOverShapeId为空)时,才调用editor.reparentShapes(..., editor.getCurrentPageId())把子节点归还到当前页面;若正拖向另一个容器,则交给新容器的onDragShapesIn处理,避免中途反复 reparent。
裁切与选中语义方面,基类默认 getClipPath() 返回自身几何顶点,shouldClipChild() 裁掉除箭头外的所有子节点,isFrameLike() 返回 true——这些都解释了为什么容器行为与内置 frame 一致(全包围框选择、内部不可被橡皮擦擦除等)。
六、如何运行与验证
该示例位于 apps/examples,是仓库内可独立运行的 examples 工程。常规流程为:
- 在仓库根目录安装依赖并构建 SDK 相关包(示例依赖
tldraw与@tldraw/editor等 workspace 包); - 进入
apps/examples运行 dev 命令(如yarn dev/npm run dev,具体以 apps/examples/package.json 中的 scripts 为准); - 在浏览器中打开示例列表并定位 "Drag and drop shape"。
对照 README 中的描述逐项验证预期行为:
- 拖一个红色计数器到网格上 → 计数器被收纳为子节点;
- 试图把网格里的计数器拖出边缘 → 它仍是子节点,且越界部分在网格边界处被裁切;
- 用图形工具画一个 geo 图形并拖过网格 → 被拒绝收纳,因为类型不匹配
canReceiveNewChildrenOfType。
如果想反向验证基类默认行为,可以临时删掉示例中的两个 override,让网格退回"所有类型都能进、所有子节点都能出"的宽松容器——这正是 BaseFrameLikeShapeUtil 相对普通 ShapeUtil 的增量价值所在。
七、小结与扩展思路
BaseFrameLikeShapeUtil 让自定义容器图形不必重写整套拖拽重排逻辑,仅靠覆写两个"类型门槛"方法即可获得与内置 frame 完全一致的收纳体验。落到实际产品中,这套模式可以延伸出大量用途:卡片看板的分组容器、泳道图、目录树节点、代码编辑器里的分组块等。更进一步,你还可以覆写 onDragShapesOver / onDropShapesOver 实现拖拽过程中的实时高亮或二次校验,或者覆写 onDragShapesOut 定义"拖出时是否同时销毁子节点"等特殊语义——所有能力的入口都在 ShapeUtil 与 BaseFrameLikeShapeUtil 这两个文件中,值得作为继续深入阅读的起点。
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
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
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