拆解 tldraw 的 Tldraw 组件:用 TldrawEditor、TldrawUi 与默认构件手工组装无限画布编辑器
在 tldraw 的 React SDK 中,日常使用的 <Tldraw /> 其实只是一个"便利封装",它的下层由三部分构成:不带任何形状、工具与 UI 的 TldrawEditor(画布与编辑器核心)、负责默认菜单/工具栏/面板的 TldrawUi,以及一组可整体替换的默认值(defaultShapeUtils、defaultBindingUtils、defaultTools、defaultShapeTools 等)。本文以官方示例 exploded 示例 为主线,结合 tldraw 包的真实源码,讲解如何把 <Tldraw /> 拆开再亲手装回去,掌握对编辑器的每一个零件进行增删、替换或彻底移除的能力——从只想去掉某个工具/形状,到彻底自绘整套 UI,都能在这一套"拆解思路"下完成。
一、先认清三个层次:Tldraw、TldrawEditor 与 TldrawUi
<Tldraw />(Tldraw.tsx)在官方文档与源码注释中被直接描述为 "convenience wrapper"(便利封装)。把它拆开,是三个可独立理解、独立使用的层次:
| 层次 | 职责 | 是否自带形状/工具/UI |
|---|---|---|
TldrawEditor |
画布与编辑器运行时核心 | 否:没有形状、工具,也没有任何 UI |
TldrawUi |
默认的菜单、工具栏、面板等界面 | 否:它只渲染界面壳,不注册形状与工具 |
| 一组 defaults | defaultShapeUtils 等,即"编辑器开箱即用的内容" |
是:全部默认形状与工具都从这里注入 |
注意,TldrawEditor 实际由 @tldraw/editor 包导出(即仓库 packages/editor 目录下的编辑器内核),而 TldrawUi、各类 default* 常量则由 tldraw 包提供。<Tldraw /> 的职责就是:把这些零散零件拼装起来、补齐默认值、处理挂载副作用,并对外暴露一套"傻瓜式" props。
这解释了官方示例 README 里的那句关键描述:Tldraw 组件底下是 TldrawEditor(一个没有任何形状、工具、UI 的画布与编辑器)、TldrawUi(默认菜单、工具栏与面板),以及一组 defaults。用官方示例 ExplodedExample.tsx 的话说:示例用这些零件组装出了和普通 <Tldraw /> 行为一致的编辑器,但每个部分都变得可见、可替换——从列表里删掉一个 shape util、传入另一套 tools,甚至不渲染 TldrawUi 而换成你自己的界面,都是可行的。
二、源码视角:<Tldraw /> 内部到底替你做了什么
拆解之前,先看清被拆的那个"整体"。阅读 Tldraw.tsx 的 Tldraw 函数,可以梳理出它替用户完成的全部装配工作,这也正是 exploded 示例需要逐一手动补回的内容。
1. 合并默认形状、绑定、资源、Overlay 与工具
<Tldraw /> 接受用户传入(通常为空数组)的 shapeUtils、bindingUtils、assetUtils、overlayUtils、tools,然后通过 mergeArraysAndReplaceDefaults 与默认值合并:
shapeUtilsWithDefaults:用户传入的 shape util 与defaultShapeUtils按type字段合并(同名替换,新名追加);bindingUtilsWithDefaults:与defaultBindingUtils按type合并;assetUtilsWithDefaults:与defaultAssetUtils合并,并且若用户提供了maxImageDimension、acceptedImageMimeTypes、acceptedVideoMimeTypes,还会通过configureDefaultAssetUtils内部调用ImageAssetUtil.configure/VideoAssetUtil.configure重新配置图片与视频资源工具;overlayUtilsWithDefaults:与defaultOverlayUtils合并;toolsWithDefaults:与allDefaultTools = [...defaultTools, ...defaultShapeTools]按id合并。
也就是说,即使你只写 <Tldraw />,它也会在渲染时自动带上全部默认工具与形状。而这些默认集合在源码中都是明文可见、可直接导入的常量文件:
- defaultShapeUtils.ts:
TextShapeUtil、BookmarkShapeUtil、DrawShapeUtil、GeoShapeUtil、NoteShapeUtil、LineShapeUtil、FrameShapeUtil、ArrowShapeUtil、HighlightShapeUtil、EmbedShapeUtil、ImageShapeUtil、VideoShapeUtil,共 12 个形状; - defaultShapeTools.ts:
TextShapeTool、DrawShapeTool、GeoShapeTool、NoteShapeTool、LineShapeTool、FrameShapeTool、ArrowShapeTool、HighlightShapeTool,共 8 个与形状配套的工具; - defaultTools.ts:
EraserTool、HandTool、LaserTool、ZoomTool、SelectTool,共 5 个通用工具; - defaultBindingUtils.ts、defaultAssetUtils.ts、defaultOverlayUtils.ts:绑定、资源、overlay 三类默认工具的对应集合。
2. 补齐富文本与字体默认配置
<Tldraw /> 还会把 options.text 展开成完整的 TLTextOptions:默认提供 addFontsFromNode: defaultAddFontsFromNode(从 DOM 节点收集字体),并把 tipTapConfig.extensions 默认填充为 tipTapDefaultExtensions,用户传入的配置在展开后被合并进来(Tldraw.tsx 第 242–259 行)。exploded 示例中传回的 options 正是这一结构。
3. 提供资源 URL、翻译与字体的 Provider 层级
<Tldraw /> 返回的 JSX 最外层依次包裹了 AssetUrlsProvider(assetUrls 会被 useDefaultUiAssetUrlsWithOverrides 处理)与 TldrawUiTranslationProvider(locale、翻译 overrides 都在这层生效),其内部才渲染 TldrawEditor。原因在源码注释里写得很直白:这样连渲染在 TldrawUi 之外的加载 UI(LoadingScreen、Spinner)也能被翻译、能拿到资源 URL。
4. 在挂载时注册副作用与外部内容处理器
最后,Tldraw 还嵌套了一个内部组件 InsideOfEditorAndUiContext(Tldraw.tsx 第 313–385 行),它在编辑器 onMount 时依次做三件事:
registerDefaultSideEffects(editor)——注册默认的 store 副作用;registerDefaultExternalContentHandlers(editor, {...})——注册拖放/粘贴文件、URL、embed、SVG 文本等外部内容处理器;- 执行
editor.store.props.onMount(editor)与用户的onMountprop。
此外它还会预加载 allDefaultFontFaces 与主题自定义字体,并监听首次编辑事件上报埋点。注意这些默认副作用与外部内容处理器都是 TldrawEditor 自身不会做的,这正是 exploded 示例必须自己补两段代码的根本原因。
三、动手拆装:ExplodedExample 的完整组装代码
官方示例 ExplodedExample.tsx 是"拆解"思想的最小完整实现。整体逻辑分为三个部分:在模块顶层汇总默认工具、组装组件树、在上下文内部补齐副作用。
步骤 1:汇总工具与默认选项
const allDefaultTools = [...defaultTools, ...defaultShapeTools]
const defaultOptions = {
text: {
tipTapConfig: {
extensions: tipTapDefaultExtensions,
},
addFontsFromNode: defaultAddFontsFromNode,
},
}
这一行的 allDefaultTools 与 Tldraw.tsx 第 118 行的内部常量完全一致(选择类/缩放类工具在前,各形状工具在后);defaultOptions 则对应 Tldraw 内部对 TLTextOptions 的默认填充。手动组装时,这两个值是绕不开的"默认内容清单"。
步骤 2:渲染 TldrawEditor + TldrawUi 组件树
export default function ExplodedExample() {
return (
<div className="tldraw__editor">
<TldrawEditor
initialState="select"
shapeUtils={defaultShapeUtils}
bindingUtils={defaultBindingUtils}
assetUtils={defaultAssetUtils}
overlayUtils={defaultOverlayUtils}
tools={allDefaultTools}
persistenceKey="exploded-example"
options={defaultOptions}
assetUrls={defaultEditorAssetUrls}
>
<TldrawUi>
<InsideEditorAndUiContext />
</TldrawUi>
</TldrawEditor>
</div>
)
}
这段 JSX 里 TldrawEditor 的各个 prop 意义如下:
| Prop | 传入值 | 作用 |
|---|---|---|
initialState |
"select" |
编辑器启动后进入的根工具状态,保持与 <Tldraw /> 一致 |
shapeUtils |
defaultShapeUtils |
12 个默认形状实现,决定画布上能创建/渲染哪些形状 |
bindingUtils |
defaultBindingUtils |
绑定工具(如箭头与形状之间的吸附绑定关系) |
assetUtils |
defaultAssetUtils |
图片与视频等资源的上传/解析工具 |
overlayUtils |
defaultOverlayUtils |
Overlay(悬停高亮等覆盖层)工具 |
tools |
allDefaultTools |
13 个默认工具(5 通用 + 8 形状配套) |
persistenceKey |
"exploded-example" |
本地持久化的存储键;同名实例共享一份 localStorage 数据 |
options |
defaultOptions |
富文本/字体等编辑器选项 |
assetUrls |
defaultEditorAssetUrls |
图标、字体、贴纸等默认静态资源的 URL 表 |
替换的入口就在这里:把 shapeUtils 换成你自己的 ShapeUtil 数组即可整体替换形状集;在 tools 数组里删掉某个工具,工具栏就不会出现它;传入 persistenceKey 就能让每次刷新保留画布内容。
步骤 3:在 UI 上下文内补齐挂载副作用
function InsideEditorAndUiContext() {
const editor = useEditor()
const toasts = useToasts()
const msg = useTranslation()
useEffect(() => {
registerDefaultExternalContentHandlers(editor, {
maxImageDimension: 5000,
maxAssetSize: 10 * 1024 * 1024, // 10mb
acceptedImageMimeTypes: DEFAULT_SUPPORTED_IMAGE_TYPES,
acceptedVideoMimeTypes: DEFAULT_SUPPORT_VIDEO_TYPES,
toasts,
msg,
})
const cleanupSideEffects = registerDefaultSideEffects(editor)
return () => {
cleanupSideEffects()
}
}, [editor, msg, toasts])
return (
<ContextMenu>
<DefaultContextMenuContent />
</ContextMenu>
)
}
为什么这段代码必须嵌在 TldrawUi 内部、且由独立的子组件渲染?因为 useToasts() 与 useTranslation() 都要依赖 TldrawUi 提供的上下文——这正是 <Tldraw /> 把内部逻辑放进 InsideOfEditorAndUiContext 的同一个原因。
这里涉及的两个注册函数分别是"画布内容交互"与"文档数据一致性"的关键:
registerDefaultExternalContentHandlers:处理用户拖入/粘贴的文件、URL、embed、SVG 文本等内容。其可选参数含义为:maxImageDimension(图片最大边长,示例取 5000)、maxAssetSize(单资源大小上限,示例为 10MB)、acceptedImageMimeTypes/acceptedVideoMimeTypes(允许的图片/视频 MIME 类型,默认取DEFAULT_SUPPORTED_IMAGE_TYPES与DEFAULT_SUPPORT_VIDEO_TYPES,二者均从tldraw顶层导出)。registerDefaultSideEffects:注册默认的 store 副作用,例如在本地用户创建 embed 形状或修改其 URL 时解析真实宽高比,以及在裁剪/编辑状态切换、相机停止时维护悬停形状等(详见 defaultSideEffects.ts)。它返回清理函数,示例在useEffect的 cleanup 中调用cleanupSideEffects()以便组件卸载时注销副作用。
ContextMenu + DefaultContextMenuContent 则提供了右键菜单,它是 TldrawEditor 与 TldrawUi 之外的"补全件",作为子组件挂载后能包住画布。
四、三种典型改造方向
1. 从默认集合中"减零件"
默认集合都是普通数组,做减法只需过滤:
const myShapeUtils = defaultShapeUtils.filter(
(util) => util.type !== 'embed' && util.type !== 'bookmark'
)
<TldrawEditor shapeUtils={myShapeUtils} /* ... */ />
去掉 embed 与 bookmark 后,画布上不再存在这两种形状,工具栏中配套工具也不会出现。所有默认工具在默认集中按 id / type 去重或按同键覆盖的合并规则,与 Tldraw.tsx 内部使用的 mergeArraysAndReplaceDefaults 完全一致。
2. 换工具集、换形状实现
因为 defaultShapeUtils 等只是 ShapeUtil/Tool 的类,你可以为某个 type 传入自己的实现类——同名 type 会替换默认项,这就是"自定义形状/工具"的最短路径。官方在 configuration 目录 下提供了配套的进阶示例,例如 only-editor(纯 TldrawEditor + 单一自定义形状与工具)、configure-shape-util 与 custom-options(自定义编辑器选项),它们展示的正是"自写形状与工具后如何注入",可作为本文之后继续深挖的路标。
3. 不要 TldrawUi,换成自己的界面
既然 TldrawUi 只是渲染在 TldrawEditor 内部的 UI 壳,最彻底的自定义就是不渲染它,改为基于 useEditor() 与 useEditorComponents() 自行组合画布、工具栏与面板。请记住一条硬约束:只要你的界面还需要 toast、翻译或右键菜单,就必须在对应 Provider/上下文内渲染——这也是官方示例把需要 useToasts()/useTranslation() 的注册逻辑放进 TldrawUi 子组件的原因。另外,TldrawEditor 不会替你做字体预加载与首次编辑事件埋点,这些是 Tldraw 在 InsideOfEditorAndUiContext 中顺带完成的(Tldraw.tsx 第 336–349 行);完全绕开 Tldraw 时,若需要这些行为需自行补上。
五、小结:一张"拆解清单"
把 <Tldraw /> 拆开后需要手动还原的五件事,也是排查"手动组装缺了点什么"的对照表:
- 注册形状、绑定、资源、Overlay 与工具——通过
TldrawEditor的五个 prop 传入(shapeUtils/bindingUtils/assetUtils/overlayUtils/tools); - 传入富文本与字体选项——
options.text里带上tipTapDefaultExtensions与defaultAddFontsFromNode; - 传入持久化与资源 URL——
persistenceKey决定本地存储复用,assetUrls={defaultEditorAssetUrls}保证默认图标字体可用; - 注册外部内容处理器——
registerDefaultExternalContentHandlers,需要 toasts 与翻译上下文; - 注册默认 store 副作用——
registerDefaultSideEffects,负责 embed 宽高比解析等一致性维护。
官方把这一思路凝结在 exploded README 中一句话里:这样组装出的编辑器行为上等同于 <Tldraw />,但每个部分都可见、可替换。理解这条拆装路径之后,无论是裁减开箱功能、注入私有形状,还是从零搭建一套全新 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 StartedRust4.21 K635- DDeepSeek-V4.1-FlashDeepSeek-V4.1-Flash 是一个多模态混合专家(MoE)模型,拥有 5520 亿骨干参数,并支持最多一百万 token 的上下文长度。该模型原生支持图像和文本输入,并以自回归方式生成文本Python40
jforgamejforgame是一个一站式游戏服务器开发框架。包含游戏服务器开发所需要的各种组件,比如网关,socket服务端与客户端,自定义高效消息编解码,游戏热更新,游戏通用工具等等。包含游戏服,跨服,匹配服,后台管理系统等实现,同时提供大量业务案例以供学习。亦可用于其他socket应用,例如及时聊天等。Java131
fizz-gateway-nodeAn Aggregation API Gateway in Java . FizzGate 是一个基于 Java开发的微服务聚合网关,是拥有自主知识产权的应用网关国产化替代方案,能够实现热服务编排聚合、自动授权选择、线上服务脚本编码、在线测试、高性能路由、API审核管理、回调管理等目的,拥有强大的自定义插件系统可以自行扩展,并且提供友好的图形化配置界面,能够快速帮助企业进行API服务治理、减少中间层胶水代码以及降低编码投入、提高 API 服务的稳定性和安全性。Java80
certd开源SSL证书管理工具;全自动证书申请、更新、续期;通配符证书,泛域名证书申请;证书自动化部署到阿里云、腾讯云、主机、群晖、宝塔;https证书,pfx证书,der证书,TLS证书,nginx证书自动续签自动部署JavaScript80
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python290