tldraw 自定义图形与工具完整指南:从 CardShapeUtil 到工具栏按钮与快捷键
本文围绕 tldraw 官方示例 custom-config 展开,系统讲解如何在 <Tldraw> 中注册一个完全自定义的图形(card)与创建它的工具:用 CardShapeUtil 定义渲染、几何、props 校验与数据迁移,用继承 BaseBoxShapeTool 的 CardShapeTool 免费获得“点击即创建、拖拽即创建”的能力,再通过 overrides 与 components 把新工具接入工具栏和快捷键对话框(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 创建,二者分别经 shapeUtils 与 tools 两个 props 交给 <Tldraw>;UI 侧再由 overrides 与 components 两个 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] 原样强调):
- 数组必须定义在组件外部。
shapeUtils与tools传入的是类本身,而不是实例;如果每次渲染都重新创建数组字面量,会导致引用不稳定、编辑器反复重建内部注册表。因此示例把const customShapeUtils = [CardShapeUtil]、const customTools = [CardShapeTool]放在模块顶层,这是自定义配置时最容易踩的坑。 overrides与components分管“行为”与“组件”。overrides(TLUiOverrides)用于注册工具项定义(图标、标签、快捷键、选中回调);components(TLComponents)用于整体替换内置组件(如Toolbar、KeyboardShortcutsDialog),以把新工具项“插入”到默认内容之前。
注意还需引入样式: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 props(RecordProps)在运行时校验写入文档的 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是编辑器内置的一组合法颜色取值(如black、red等)。直接把颜色字段声明为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 支持替换的组件远不止这两个(例如还可覆盖 StylePanel、ContextMenu 等),本示例只演示与工具发现相关的两处。
六、实战使用与运行方式
使用前先安装依赖并启动示例工程:
# 在仓库根目录
yarn install
yarn dev:examples # 以 examples 应用为入口启动开发服务器
若仅需浏览源码,示例的完整代码位于 apps/examples/src/examples/shapes/tools/custom-config/;官方在 README frontmatter 中为其登记的检索关键词覆盖 shapeutils、tools、toolbar、custom tool、custom shape、icon、overrides、card、statenode、keyboard shortcut、migrations 等,说明这正是该示例所要传达的核心能力清单。
七、要点回顾
- 四步接入:定义
ShapeUtil子类 + 定义StateNode工具类 → 组件外缓存数组 → 通过shapeUtils、tools传入<Tldraw>→ 用overrides注册工具项、用components把菜单项插入内置工具栏/快捷键面板。 - 静态层决定数据契约:
static type、static props(运行时校验)与static migrations(版本升级)三者共同保证自定义形状在文档读写、导入导出与协作同步中保持稳定。 - 实例层决定交互与外观:
getDefaultProps/getGeometry/component/getIndicatorPath/onResize各司其职;图形组件内可以使用普通 React 状态,但交互控件记得stopPropagation()。 - 继承代替手写:
BaseBoxShapeTool让点击/拖拽创建开箱即用;选择icon、label、kbd、onSelect即可完成工具的 UI 化,整套模式可平滑迁移到你自己的图形与工具。
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 StartedRust0629
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证件照制作算法。Python07
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