首页
/ tldraw 实战:通过继承 BrushOverlayUtil 替换画布内置选择画笔(Selection Brush)

tldraw 实战:通过继承 BrushOverlayUtil 替换画布内置选择画笔(Selection Brush)

2026-09-07 16:54:32作者:胡易黎Nicole

本文围绕 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 负责定义一类覆盖层,并回答四个问题:

  1. 何时激活isActive())——例如画笔只有在框选进行中才激活;
  2. 当前实例长什么样getOverlays())——从编辑器状态派生出 TLOverlay 实例数组;
  3. 如何绘制render(ctx, overlays))——直接操作 Canvas 2D 上下文;
  4. 是否可交互getGeometry / getCursor / 指针事件)——提供命中几何与光标样式。

这一设计由基类 OverlayUtil 定义。它是抽象类,强制子类实现 isActive()getOverlays(),并提供可覆写的 getGeometrygetCursor、指针中断等钩子。基类上还暴露了一个静态 configure 方法(源码),用于以“子类化 + options 合并”的方式定制已有 util,例如:

const MyBrush = BrushOverlayUtil.configure({ /* partial options */ })

内置覆盖层全部集中在 packages/tldraw/src/lib/overlays 目录下,包括 BrushOverlayUtil(选择画笔)、ScribbleOverlayUtil(缩放 scribble)、SnapIndicatorOverlayUtil(吸附提示)、SelectionForegroundOverlayUtil(选中框)、ShapeHandleOverlayUtil / ShapeIndicatorOverlayUtil(形状句柄与指示器)、ZoomBrushOverlayUtil,以及一组协作覆盖层(CollaboratorCursorOverlayUtilCollaboratorScribbleOverlayUtil 等)。任何一个都可以用同样的方式替换——这正是本示例标题 “Replace a built-in overlay” 的含义。

示例总览:替换选择画笔为紫色虚线框

官方示例位于 apps/examples/src/examples/editor-api/replace-brush-overlay,由两个文件组成:

替换后的效果是:在画布上按住拖拽框选时,原本的蓝色实线选框会变成半透明紫色填充 + 虚线描边的矩形;而激活时机、数据来源、缩放行为全部原样继承——因为 getOverlaysisActive 和静态 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.getDefaultDisplayValuestheme.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'定义处),只传它一个就能精确命中并替换默认画笔,无需手动从默认列表里剔除。

示例文件的注释中特别指出:shapeUtilsbindingUtils 也按静态 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 这种纯展示型覆盖层保持默认即可。

实践要点与注意事项

  1. 验证方式:运行该示例后,在画布上拖拽框选,即可看到默认蓝色实线选框被替换为紫色虚线矩形;框选行为本身(选中形状、移动等)完全不受影响,因为替换的只是视觉层,事件路由在别的层处理。
  2. 不要手动“卸载”默认 brushoverlayUtils 的合并语义是“覆盖”,传入你的子类即完成替换,默认列表由 <Tldraw> 内部管理(见 mergeArraysAndReplaceDefaultsTldraw.tsx)。
  3. 渲染热路径意识render 每帧执行,沿用内置实现 fillRect / strokeRect 的直绘风格,避免在其中构造复杂 Path 或分配大对象;线宽、虚线段一律按 1 / zoom 归一化,保证跨缩放一致性。
  4. 主题适配:需要跟随主题的绘制,读取 options.getDefaultDisplayValues 提供的 display values(内置 brush 为 brushFill / brushStroke 映射),而不是硬编码颜色。
  5. 同一范式可复制:替换 scribble、吸附指示器、形状句柄等其他内置覆盖层,流程完全一致——继承 packages/tldraw/src/lib/overlays 下对应的 XxxOverlayUtil,覆写目标方法,通过 overlayUtils 传入即可。

参考文件

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

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
916
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388