首页
/ tldraw 自定义图形与工具完整指南:从 CardShapeUtil 到工具栏按钮与快捷键

tldraw 自定义图形与工具完整指南:从 CardShapeUtil 到工具栏按钮与快捷键

2026-09-08 17:13:49作者:郁楠烈Hubert

本文围绕 tldraw 官方示例 custom-config 展开,系统讲解如何在 <Tldraw> 中注册一个完全自定义的图形(card)与创建它的工具:用 CardShapeUtil 定义渲染、几何、props 校验与数据迁移,用继承 BaseBoxShapeToolCardShapeTool 免费获得“点击即创建、拖拽即创建”的能力,再通过 overridescomponents 把新工具接入工具栏和快捷键对话框(c 快捷键)。读完本文,你将掌握 tldraw 自定义形状体系的完整闭环,并能直接改造此示例代码投入自己的项目。

一、示例整体架构:一个能计数的卡片图形

本示例位于 apps/examples/src/examples/shapes/tools/custom-config/,包含以下文件:

文件 职责
CustomConfigExample.tsx 示例入口,向 <Tldraw> 传入自定义 shape util、tool 与 UI 覆盖
CardShape/CardShapeUtil.tsx 定义 card 图形的渲染、几何、默认 props、缩放逻辑(核心)
CardShape/CardShapeTool.tsx 定义创建 card 的工具,继承 BaseBoxShapeTool
CardShape/card-shape-props.ts card 图形 props 的运行时校验 Schema
CardShape/card-shape-migrations.ts card 图形 props 的版本迁移序列
ui-overrides.tsx 把 card 工具注册进工具栏与键盘快捷键对话框

运行该示例的交互逻辑为:在工具栏选择 ⚫️(颜色圆点,即 card 工具对应的自定义图标)或按下 c,然后在画布上单击或拖拽即可生成一张卡片;卡片内有一个显示“Clicks”计数的按钮,点击它只改变组件自身状态,而不会触发编辑器对形状的选择或拖拽——这验证了“普通 React state 可以在形状组件内正常工作”这一能力(见 README.md)。

整个注册链路一句话概括:形状由 CardShapeUtil 定义、由 CardShapeTool 创建,二者分别经 shapeUtilstools 两个 props 交给 <Tldraw>;UI 侧再由 overridescomponents 两个 props 接入(见 CustomConfigExample.tsx)。

二、入口配置:<Tldraw> 的四个关键 props

CustomConfigExample.tsx 是整个示例的接线中枢,核心代码如下:

// [1] 数组必须在任何 React 组件之外定义
const customShapeUtils = [CardShapeUtil]
const customTools = [CardShapeTool]

// [2] 将数组与 UI 覆盖一并传给 <Tldraw>
export default function CustomConfigExample() {
	return (
		<div className="tldraw__editor">
			<Tldraw
				shapeUtils={customShapeUtils} // 自定义图形类数组
				tools={customTools}          // 自定义工具类数组
				overrides={uiOverrides}      // UI 行为覆盖注册新工具components={components}      // UI 组件替换插入工具栏项)
			/>
		</div>
	)
}

这里有两个重要的工程约束(源码注释 [1][2] 原样强调):

  • 数组必须定义在组件外部shapeUtilstools 传入的是类本身,而不是实例;如果每次渲染都重新创建数组字面量,会导致引用不稳定、编辑器反复重建内部注册表。因此示例把 const customShapeUtils = [CardShapeUtil]const customTools = [CardShapeTool] 放在模块顶层,这是自定义配置时最容易踩的坑。
  • overridescomponents 分管“行为”与“组件”overridesTLUiOverrides)用于注册工具项定义(图标、标签、快捷键、选中回调);componentsTLComponents)用于整体替换内置组件(如 ToolbarKeyboardShortcutsDialog),以把新工具项“插入”到默认内容之前。

注意还需引入样式:import 'tldraw/tldraw.css'

三、形状核心:CardShapeUtil 的全生命周期实现

CardShapeUtil 继承自 ShapeUtil,是 card 图形的行为与外观中枢,位于 CardShapeUtil.tsx。它通过静态成员声明类型、props 校验和迁移,通过实例方法声明渲染、几何与事件处理。

3.1 类型声明、props Schema 与迁移(静态层)

const CARD_TYPE = 'card'

declare module 'tldraw' {
	export interface TLGlobalShapePropsMap {
		[CARD_TYPE]: { w: number; h: number; color: TLDefaultColorStyle }
	}
}

export type ICardShape = TLShape<typeof CARD_TYPE>

export class CardShapeUtil extends ShapeUtil<ICardShape> {
	static override type = CARD_TYPE
	static override props = cardShapeProps      // 运行时 props 校验
	static override migrations = cardShapeMigrations // 历史数据迁移
	// ...
}

要点:

  • static type 是形状在文档数据中的唯一标识,必须与迁移序列 id 一致(见后文 card-shape-migrations.ts)。
  • 借助 TypeScript 的 declare module,在 TLGlobalShapePropsMap 中登记 card 的 props 结构,使 ICardShape = TLShape<typeof CARD_TYPE> 获得完整的类型推导。
  • static propsRecordProps)在运行时校验写入文档的 props,保证脏数据(例如外部导入、旧版本文件)在进入编辑器前就被过滤或纠正;static migrations 负责把旧版本数据升级到当前版本。

3.2 props 校验:card-shape-props.ts

import { DefaultColorStyle, RecordProps, T } from 'tldraw'
import { ICardShape } from './CardShapeUtil'

export const cardShapeProps: RecordProps<ICardShape> = {
	w: T.number,          // 宽度:数字
	h: T.number,          // 高度:数字
	color: DefaultColorStyle, // 颜色:复用 tldraw 内置的默认颜色样式
}

这里复用了 tldraw 两个基础设施:

  • T 是 tldraw 内部基于结构校验(schema validation)的校验器命名空间,T.number 即数字类型校验器。
  • DefaultColorStyle 是编辑器内置的一组合法颜色取值(如 blackred 等)。直接把颜色字段声明为 DefaultColorStyle,即可免费获得“颜色样式”所配套的类型、运行时校验以及(如果配合 style panel 使用)取色 UI。若要定义项目专属的颜色体系(如自定义一套颜色名),README 指引可参考仓库中的 custom styles 示例。

3.3 数据迁移:card-shape-migrations.ts

import { createShapePropsMigrationIds, createShapePropsMigrationSequence } from 'tldraw'

const versions = createShapePropsMigrationIds('card', {
	AddColor: 1,
})

export const cardShapeMigrations = createShapePropsMigrationSequence({
	sequence: [
		{
			id: versions.AddColor,
			up(props) {
				// 在这里直接修改 props 对象是安全的
				props.color = 'black'
			},
			down(props) {
				delete props.color
			},
		},
	],
})

机制解读:

  • createShapePropsMigrationIds 生成的迁移 id 必须与 static type(即 'card')一致,否则迁移不会被正确关联到该形状。
  • 迁移序列中的每个迁移都提供 up(从旧版本升级到新版本)与 down(回退)两个方向。此处 AddColor 版本的 up 为旧数据补上 color: 'black' 默认值,down 则删除该字段。
  • 迁移是“可选但强烈推荐”的:一旦你把含自定义形状的文档持久化(存库、存文件、多人协作同步),后续每次修改 props 结构都应新增一个迁移版本,而不是直接改旧迁移,从而保证老文档能无损打开。

3.4 默认 props、几何与约束(实例层)

override isAspectRatioLocked(shape: ICardShape) {
	return false // 缩放时是否锁定宽高比,示例保持默认:不锁定
}
override canResize(shape: ICardShape) {
	return true // 是否允许缩放
}

getDefaultProps(): ICardShape['props'] {
	return { w: 300, h: 300, color: 'black' }
}

getGeometry(shape: ICardShape) {
	return new Rectangle2d({ width: shape.props.w, height: shape.props.h, isFilled: true })
}
  • getDefaultProps 返回新建卡片时的初始 props(300×300、黑色),供“点击即创建”使用。
  • getGeometry 返回基于 Rectangle2d 的几何体,编辑器据此进行命中测试(hit-testing)、吸附(bindings)以及各类几何计算。将 isFilled 设为 true 意味着卡片内部也参与点击命中,而不只是边框。
  • isAspectRatioLocked / canResize 在此虽与默认一致,但示例明确保留以提示开发者“这些钩子存在且可按需覆盖”。

3.5 渲染方法 component 与主题色跟随

component(shape: ICardShape) {
	const { editor } = this
	const bounds = editor.getShapeGeometry(shape).bounds
	const theme = editor.getCurrentTheme()
	const colors = theme.colors[editor.getColorMode()]
	const { color } = shape.props

	// [a] 形状组件内可以使用普通 React 状态!
	// eslint-disable-next-line react-hooks/rules-of-hooks
	const [count, setCount] = useState(0)

	return (
		<HTMLContainer
			id={shape.id}
			style={{
				border: '1px solid black',
				display: 'flex',
				flexDirection: 'column',
				alignItems: 'center',
				justifyContent: 'center',
				pointerEvents: 'all',
				backgroundColor: getColorValue(colors, color, 'semi'),
				color: getColorValue(colors, color, 'solid'),
			}}
		>
			<h2>Clicks: {count}</h2>
			<button
				onClick={() => setCount((count) => count + 1)}
				onPointerDown={(e) => e.stopPropagation()} // [b] 关键!
			>
				{bounds.w.toFixed()}x{bounds.h.toFixed()}
			</button>
		</HTMLContainer>
	)
}

这一方法体现了形状组件的最佳实践:

  • component 是一个真正运行在编辑器画布内的 React 组件,因此可以使用 useState 等普通 React 状态机制——示例用点击计数器验证了这一事实。仓库代码通过 eslint-disable 注释说明这是有意为之的用法。
  • 按钮必须调用 e.stopPropagation() 阻止 pointerDown 冒泡:否则编辑器会认为你在“选择或拖拽形状”,导致按钮点击行为异常。这是把交互控件放进形状组件时最常被忽略的坑。
  • 颜色取自 editor.getCurrentTheme()editor.getColorMode(),再经 getColorValue(colors, color, 'semi' | 'solid') 取出半透明/实色两个变体,让卡片在深色/浅色模式下自动跟随主题,而不是写死 CSS 颜色。
  • 按钮文案实时显示几何宽高(bounds.w.toFixed()),演示了通过 editor.getShapeGeometry(shape).bounds 在渲染层获取几何信息的常用方式。
  • HTMLContainer 本质是一个用于包裹形状内 HTML 内容的 div 容器(官方注释原话),适合文字、按钮等富交互内容;纯矢量图形场景可改用 SVGContainer

3.6 选中态指示器与缩放处理

getIndicatorPath(shape: ICardShape) {
	const path = new Path2D()
	path.rect(0, 0, shape.props.w, shape.props.h)
	return path
}

override onResize(shape: ICardShape, info: TLResizeInfo<ICardShape>) {
	return resizeBox(shape, info)
}
  • getIndicatorPath 返回一个 Path2D,编辑器会把它描边绘制到画布 overlay 上,用作悬停高亮与选中边框;这里与 getGeometry 保持一致,画一个矩形路径。
  • onResize 是缩放回调。示例直接调用 tldraw 内置的 resizeBox 辅助函数完成标准盒式缩放;注释说明若需要自定义行为(例如约束某些字段的比例),可以在此实现自己的逻辑。

四、创建工具:继承 BaseBoxShapeTool

import { BaseBoxShapeTool } from 'tldraw'

export class CardShapeTool extends BaseBoxShapeTool {
	static override id = 'card'
	static override initial = 'idle'
	override shapeType = 'card' as const
}

工具本质上是 tldraw 状态机中的 StateNode。此工具:

  • 静态 id'card'——它同时是工具 id 与形状类型,UI 层通过该 id 把工具栏按钮映射到这个工具。
  • 静态 initial = 'idle' 声明初始子状态。
  • shapeType 指向要创建的形状类型 'card'

关键收益:由于继承 BaseBoxShapeTool点击创建与拖拽创建(click-to-create / drag-to-create)开箱即用,无需手写任何指针事件处理。若要更复杂的行为(例如双击、自定义绘制),示例注释指引可覆盖 onDoubleClick 等方法,并可参考仓库中的 screenshot-tool 示例(同目录 examples/shapes/tools/ 系列)。

五、UI 集成:工具栏按钮、图标与 c 快捷键

ui-overrides.tsx 分两层完成 UI 接入。

5.1 注册工具项(tools override)

export const uiOverrides: TLUiOverrides = {
	tools(editor, tools) {
		// 在 UI 的上下文(context)里创建一个工具项
		tools.card = {
			id: 'card',
			icon: 'color',   // 使用内置 color 图标(渲染为 ⚫️)
			label: 'Card',   // 悬停提示 / 可访问性标签
			kbd: 'c',        // 键盘快捷键
			onSelect: () => {
				editor.setCurrentTool('card') // 选中后切换当前工具
			},
		}
		return tools
	},
}

override 接收 (editor, tools),返回一个包含默认所有工具的 tools 对象;向其中追加 tools.card 即可在 UI 上下文注册新工具。字段说明:

  • icon:使用的图标 id,示例选用内置 'color' 图标(工具栏上呈现为 ⚫️)。如需自定义 svg 图标,可替换为自定义图标 id。
  • label:展示名称,用于悬停提示与键盘快捷键对话框中的可读文案。
  • kbd:快捷键字符,注册后 c 键即可切换到 card 工具。
  • onSelect:工具被选中时的行为,示例中即 editor.setCurrentTool('card')

5.2 插入工具栏与快捷键对话框(components 覆盖)

export const components: TLComponents = {
	Toolbar: (props) => {
		const tools = useTools()
		const isCardSelected = useIsToolSelected(tools['card'])
		return (
			<DefaultToolbar {...props}>
				<TldrawUiMenuItem {...tools['card']} isSelected={isCardSelected} />
				<DefaultToolbarContent />
			</DefaultToolbar>
		)
	},
	KeyboardShortcutsDialog: (props) => {
		const tools = useTools()
		return (
			<DefaultKeyboardShortcutsDialog {...props}>
				<TldrawUiMenuItem {...tools['card']} />
				<DefaultKeyboardShortcutsDialogContent />
			</DefaultKeyboardShortcutsDialog>
		)
	},
}

这里的模式是“包一层默认组件,在开头插入新菜单项”:

  • useTools() 从 UI 上下文取出全部已注册工具(含第 5.1 节添加的 tools['card'])。
  • useIsToolSelected(tools['card']) 返回该工具当前是否处于激活状态,用于让按钮呈现选中态。
  • TldrawUiMenuItem 是 tldraw 提供的现成菜单项组件,展开传入 {...tools['card']} 后自动带上图标、label、快捷键与 onSelect 行为。
  • 把自定义菜单项放在 <DefaultToolbarContent /> / <DefaultKeyboardShortcutsDialogContent /> 之前,即可让新按钮排在内置工具的前面,实现“与默认内容并列展示”。

TLComponents 支持替换的组件远不止这两个(例如还可覆盖 StylePanelContextMenu 等),本示例只演示与工具发现相关的两处。

六、实战使用与运行方式

使用前先安装依赖并启动示例工程:

# 在仓库根目录
yarn install
yarn dev:examples  # 以 examples 应用为入口启动开发服务器

若仅需浏览源码,示例的完整代码位于 apps/examples/src/examples/shapes/tools/custom-config/;官方在 README frontmatter 中为其登记的检索关键词覆盖 shapeutilstoolstoolbarcustom toolcustom shapeiconoverridescardstatenodekeyboard shortcutmigrations 等,说明这正是该示例所要传达的核心能力清单。

七、要点回顾

  1. 四步接入:定义 ShapeUtil 子类 + 定义 StateNode 工具类 → 组件外缓存数组 → 通过 shapeUtilstools 传入 <Tldraw> → 用 overrides 注册工具项、用 components 把菜单项插入内置工具栏/快捷键面板。
  2. 静态层决定数据契约static typestatic props(运行时校验)与 static migrations(版本升级)三者共同保证自定义形状在文档读写、导入导出与协作同步中保持稳定。
  3. 实例层决定交互与外观getDefaultProps / getGeometry / component / getIndicatorPath / onResize 各司其职;图形组件内可以使用普通 React 状态,但交互控件记得 stopPropagation()
  4. 继承代替手写BaseBoxShapeTool 让点击/拖拽创建开箱即用;选择 iconlabelkbdonSelect 即可完成工具的 UI 化,整套模式可平滑迁移到你自己的图形与工具。
登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
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
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.81 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
596
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
920
1.84 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.79 K
1.02 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
519
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
390