tldraw 实战:通过继承 BrushOverlayUtil 替换画布内置选择画笔(Selection Brush)
本文围绕 tldraw 官方示例 replace-brush-overlay 展开,讲解如何替换 tldraw 画布上每一帧都在参与渲染的“选择画笔”(selection brush):只需继承内置的 BrushOverlayUtil 并覆写 render 方法,再把它传入 <Tldraw> 的 overlayUtils 属性即可完成替换。读完本文,你将掌握 tldraw 覆盖层系统(Overlay System)的工作机制——哪些内置覆盖层是可替换的、<Tldraw> 如何按 type 合并自定义 util 与默认 util,以及覆写 render 时如何保证线宽随缩放恒定、如何复用主题色等实战细节。
覆盖层(Overlay)是什么:tldraw 画布 UI 的可插拔单元
在 tldraw 中,画布上所有“临时性”UI 元素——框选时的选择画笔、协作者的 scribble(涂抹高亮)、吸附指示器(snap indicators)、形状句柄(shape handles)——都不是 React 组件,而是 OverlayUtil 的子类。每个 OverlayUtil 负责定义一类覆盖层,并回答四个问题:
- 何时激活(
isActive())——例如画笔只有在框选进行中才激活; - 当前实例长什么样(
getOverlays())——从编辑器状态派生出TLOverlay实例数组; - 如何绘制(
render(ctx, overlays))——直接操作 Canvas 2D 上下文; - 是否可交互(
getGeometry/getCursor/ 指针事件)——提供命中几何与光标样式。
这一设计由基类 OverlayUtil 定义。它是抽象类,强制子类实现 isActive() 和 getOverlays(),并提供可覆写的 getGeometry、getCursor、指针中断等钩子。基类上还暴露了一个静态 configure 方法(源码),用于以“子类化 + options 合并”的方式定制已有 util,例如:
const MyBrush = BrushOverlayUtil.configure({ /* partial options */ })
内置覆盖层全部集中在 packages/tldraw/src/lib/overlays 目录下,包括 BrushOverlayUtil(选择画笔)、ScribbleOverlayUtil(缩放 scribble)、SnapIndicatorOverlayUtil(吸附提示)、SelectionForegroundOverlayUtil(选中框)、ShapeHandleOverlayUtil / ShapeIndicatorOverlayUtil(形状句柄与指示器)、ZoomBrushOverlayUtil,以及一组协作覆盖层(CollaboratorCursorOverlayUtil、CollaboratorScribbleOverlayUtil 等)。任何一个都可以用同样的方式替换——这正是本示例标题 “Replace a built-in overlay” 的含义。
示例总览:替换选择画笔为紫色虚线框
官方示例位于 apps/examples/src/examples/editor-api/replace-brush-overlay,由两个文件组成:
- DashedBrushOverlayUtil.ts:继承
BrushOverlayUtil,覆写render,把默认的实线框画成虚线紫色矩形; - ReplaceBrushOverlayExample.tsx:在
<Tldraw>上通过overlayUtils传入该子类,完成替换。
替换后的效果是:在画布上按住拖拽框选时,原本的蓝色实线选框会变成半透明紫色填充 + 虚线描边的矩形;而激活时机、数据来源、缩放行为全部原样继承——因为 getOverlays、isActive 和静态 type 都从父类继承而来。
第一步:继承 BrushOverlayUtil 并覆写 render
完整代码如下(即 DashedBrushOverlayUtil.ts 的全文):
import { BrushOverlayUtil, TLBrushOverlay } from 'tldraw'
export class DashedBrushOverlayUtil extends BrushOverlayUtil {
override render(ctx: CanvasRenderingContext2D, overlays: TLBrushOverlay[]): void {
const overlay = overlays[0]
if (!overlay) return
const { x, y, w, h } = overlay.props
const zoom = this.editor.getZoomLevel()
ctx.fillStyle = 'rgba(147, 51, 234, 0.08)'
ctx.fillRect(x, y, w, h)
ctx.save()
ctx.lineWidth = 2 / zoom
ctx.setLineDash([8 / zoom, 4 / zoom])
ctx.strokeStyle = 'rgb(147, 51, 234)'
ctx.strokeRect(x, y, w, h)
ctx.restore()
}
}
结合内置实现(BrushOverlayUtil)逐点拆解:
1. 数据契约 TLBrushOverlay。 render 的第二个参数类型是 TLBrushOverlay[],该接口在 BrushOverlayUtil.ts 中定义:
export interface TLBrushOverlay extends TLOverlay {
props: { x: number; y: number; w: number; h: number }
}
props 中的 x, y, w, h 是**页坐标(page coordinates)**下框选矩形的矩形参数,由父类 getOverlays() 从 editor.getInstanceState().brush 实时派生(宽度/高度会做 Math.max(1, ...) 兜底,防止零尺寸矩形)。子类因此无需关心“用户现在拖到了哪里”,直接消费 overlay.props 即可。
2. 为什么线宽和虚线要除以 zoom。 覆盖层绘制在页坐标空间里,而屏幕上的像素密度随缩放变化。为了让虚线框在任何缩放下都保持视觉上的 2px 线宽和 8/4 像素虚线段,示例里统一除以 this.editor.getZoomLevel()。内置的 BrushOverlayUtil.render 用了同样的手法(ctx.lineWidth = dv.lineWidth / zoom),并在注释中说明用 fillRect / strokeRect 而非 path 是为了避免路径构造的开销——覆盖层每帧都在重绘,这是典型的性能敏感路径。
3. ctx.save() / ctx.restore() 的作用。 setLineDash 会改变上下文的持久状态;示例把它包在 save/restore 里,避免虚线设置泄漏到同一 canvas 上下文中后续的绘制(其他 overlay util 按 zIndex 顺序共用同一帧的绘制流程)。
4. 可以不改的东西。 内置 render 还依赖主题:options.getDefaultDisplayValues 从 theme.colors[colorMode] 读取 brushFill / brushStroke 作为填充与描边色(源码)。示例为了醒目直接硬编码了紫色 rgb(147, 51, 234);如果想跟随暗色/亮色主题,也可以改为读取 getOverlayDisplayValues(this, overlay) 返回的 display values,而不是硬编码颜色。
另外注意:BrushOverlayUtil 还定义了 renderMinimap(源码),用于小地图上的同步绘制。本示例没有覆写它,所以小地图上仍是内置画笔样式——如果要彻底统一视觉,可以一并覆写该方法。
第二步:通过 overlayUtils 属性注册替换
应用侧只有一行关键代码(即 ReplaceBrushOverlayExample.tsx 的核心部分):
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
import { DashedBrushOverlayUtil } from './DashedBrushOverlayUtil'
// 只传一个 util:因为子类继承了静态 type = 'brush',即可顶掉默认的 brush
const overlayUtils = [DashedBrushOverlayUtil]
export default function ReplaceBrushOverlayExample() {
return (
<div className="tldraw__editor">
<Tldraw overlayUtils={overlayUtils} />
</div>
)
}
这里的“替换”并不是“注册一个新的 overlay”,而是顶替默认实现。其机制在 <Tldraw> 组件内部:在 Tldraw.tsx 中,overlayUtils 属性经过浅层数组去抖(useShallowArrayIdentity)后,与内置的 defaultOverlayUtils 执行 mergeArraysAndReplaceDefaults('type', _overlayUtils, defaultOverlayUtils)——即按静态 type 合并,自定义 util 覆盖同 type 的默认 util。由于 DashedBrushOverlayUtil 继承了父类的 static type = 'brush'(定义处),只传它一个就能精确命中并替换默认画笔,无需手动从默认列表里剔除。
示例文件的注释中特别指出:shapeUtils 和 bindingUtils 也按静态 type 同规则合并,而 tools 是按静态 id 合并的。也就是说,“继承内置 util → 覆写想改的方法 → 通过对应属性传入”是 tldraw 里替换任何内置单元(形状、绑定、覆盖层、工具)的统一范式。
继承到底继承了什么:激活逻辑与生命周期
只看这个例子容易误以为“覆写了 render 就丢了激活逻辑”,实际上什么都没丢。对照 BrushOverlayUtil 的完整实现:
| 成员 | 作用 | 示例是否覆写 |
|---|---|---|
static type = 'brush' |
合并与命中查找的键,决定它替换哪个默认 util | 否(继承) |
options.zIndex: 300 |
跨 util 的绘制与命中顺序,越大越靠上、先命中 | 否(继承) |
options.getDefaultDisplayValues |
按主题色模式提供 fillColor / strokeColor / lineWidth |
否(继承) |
isActive() |
返回 editor.getInstanceState().brush !== null,即“框选进行中” |
否(继承) |
getOverlays() |
把 brush 实例状态转成 TLBrushOverlay[] |
否(继承) |
render(ctx, overlays) |
实际绘制 | 是 |
renderMinimap(ctx, overlays, zoom) |
小地图绘制 | 否(继承) |
从源码结构看,基类 OverlayUtil 的类注释说明了各 util 的分工:“Determine when its overlays should be active / Produce overlay instances from current editor state / Provide hit-test geometry / Provide cursor style / Render into a canvas 2D context”。zIndex 的注释也值得注意:数值越大绘制越靠上、命中测试越优先,平局按注册顺序;内置 util 使用 100、200、300 这样带间隔的整数,就是为了让自定义 util 能插入其间。画笔取 300,意味着它默认压在大多数协作提示之上。
如果你需要改的不只是画法,还有激活时机(比如希望画笔在非选区时也显示),覆写 isActive() 即可;若要让覆盖层可交互(悬停光标、按下拦截),则覆写 getGeometry / getCursor 以及指针相关钩子——这些都定义在基类上,对 brush 这种纯展示型覆盖层保持默认即可。
实践要点与注意事项
- 验证方式:运行该示例后,在画布上拖拽框选,即可看到默认蓝色实线选框被替换为紫色虚线矩形;框选行为本身(选中形状、移动等)完全不受影响,因为替换的只是视觉层,事件路由在别的层处理。
- 不要手动“卸载”默认 brush:
overlayUtils的合并语义是“覆盖”,传入你的子类即完成替换,默认列表由<Tldraw>内部管理(见mergeArraysAndReplaceDefaults,Tldraw.tsx)。 - 渲染热路径意识:
render每帧执行,沿用内置实现fillRect/strokeRect的直绘风格,避免在其中构造复杂 Path 或分配大对象;线宽、虚线段一律按1 / zoom归一化,保证跨缩放一致性。 - 主题适配:需要跟随主题的绘制,读取
options.getDefaultDisplayValues提供的 display values(内置 brush 为brushFill/brushStroke映射),而不是硬编码颜色。 - 同一范式可复制:替换 scribble、吸附指示器、形状句柄等其他内置覆盖层,流程完全一致——继承 packages/tldraw/src/lib/overlays 下对应的
XxxOverlayUtil,覆写目标方法,通过overlayUtils传入即可。
参考文件
- 示例文档:apps/examples/src/examples/editor-api/replace-brush-overlay/README.md
- 示例实现:DashedBrushOverlayUtil.ts、ReplaceBrushOverlayExample.tsx
- 内置画笔覆盖层:packages/tldraw/src/lib/overlays/BrushOverlayUtil.ts
- 覆盖层基类:packages/editor/src/lib/editor/overlays/OverlayUtil.ts
- overlayUtils 合并逻辑:packages/tldraw/src/lib/Tldraw.tsx
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
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00