首页
/ tldraw 自定义容器图形实战:用 BaseFrameLikeShapeUtil 实现拖拽收纳的 Container Shape

tldraw 自定义容器图形实战:用 BaseFrameLikeShapeUtil 实现拖拽收纳的 Container Shape

2026-09-08 10:12:56作者:晏闻田Solitary

导读

本文围绕 tldraw SDK 官方示例 apps/examples/src/examples/shapes/tools/drag-and-drop 展开,讲解如何基于 BaseFrameLikeShapeUtil 构建一个"容器类"自定义图形:它像内置 frame 一样接受被拖入的图形成为其子节点、允许子节点被拖出回到画布页面,并按自身几何裁切子节点。读完本文,你将掌握容器图形的两个关键开关——canReceiveNewChildrenOfTypecanRemoveChildrenOfType——的语义与组合用法,并能直接基于示例代码(一个只收不放的 "网格" 容器)落地到自己的 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 的泛型参数使用。这样 getDefaultPropscomponent 等方法内部都能得到 props 类型提示。

3.2 计数器:固定尺寸的普通图形(注释 [3])

MyCounterShapeUtil 继承普通 ShapeUtil,关键点在于:

  • canResize 返回 falsehideResizeHandles 返回 true,让计数器不可缩放、不显示缩放手柄;
  • getGeometry 返回填充的 Circle2d,命中检测(hit-testing)遵循圆形而非其包围盒;
  • 视觉使用 HTMLContainer 渲染为红色圆形;指示框 getIndicatorPathPath2D 画同样半径的圆。

它之所以是独立图形而非 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 内部才能被编辑器命中检测遍历到。示例因此返回包裹 Rectangle2dGroup2d

四、两个核心开关的语义与组合

整个示例的交互规则完全由下面两个钩子决定:

// 只有计数器可以被拖入网格
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 二者组合出的"只收不放"行为

组合阅读这两条规则,你就能精确推导示例的最终表现:

  1. 把计数器拖到网格上 → 类型通过 canReceiveNewChildrenOfTypeonDragShapesIn 执行 → 计数器成为网格子节点;
  2. 试图把计数器拖出网格边界 → canRemoveChildrenOfType 返回 false → 不触发 onDragShapesOut → 计数器保持在网格内,并且由于基类 getClipPath 提供了按容器几何的裁切路径,越界部分会被网格边缘裁掉,视觉上明确提示"它仍然属于网格";
  3. 画一个 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 工程。常规流程为:

  1. 在仓库根目录安装依赖并构建 SDK 相关包(示例依赖 tldraw@tldraw/editor 等 workspace 包);
  2. 进入 apps/examples 运行 dev 命令(如 yarn dev / npm run dev,具体以 apps/examples/package.json 中的 scripts 为准);
  3. 在浏览器中打开示例列表并定位 "Drag and drop shape"。

对照 README 中的描述逐项验证预期行为:

  • 拖一个红色计数器到网格上 → 计数器被收纳为子节点;
  • 试图把网格里的计数器拖出边缘 → 它仍是子节点,且越界部分在网格边界处被裁切;
  • 用图形工具画一个 geo 图形并拖过网格 → 被拒绝收纳,因为类型不匹配 canReceiveNewChildrenOfType

如果想反向验证基类默认行为,可以临时删掉示例中的两个 override,让网格退回"所有类型都能进、所有子节点都能出"的宽松容器——这正是 BaseFrameLikeShapeUtil 相对普通 ShapeUtil 的增量价值所在。

七、小结与扩展思路

BaseFrameLikeShapeUtil 让自定义容器图形不必重写整套拖拽重排逻辑,仅靠覆写两个"类型门槛"方法即可获得与内置 frame 完全一致的收纳体验。落到实际产品中,这套模式可以延伸出大量用途:卡片看板的分组容器、泳道图、目录树节点、代码编辑器里的分组块等。更进一步,你还可以覆写 onDragShapesOver / onDropShapesOver 实现拖拽过程中的实时高亮或二次校验,或者覆写 onDragShapesOut 定义"拖出时是否同时销毁子节点"等特殊语义——所有能力的入口都在 ShapeUtilBaseFrameLikeShapeUtil 这两个文件中,值得作为继续深入阅读的起点。

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

项目优选

收起
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
858
1.35 K
docsdocs
暂无描述
Markdown
899
5.82 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
923
1.85 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.83 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
532
596
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.03 K
524
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
393