tldraw 事件屏蔽实战:用 InFrontOfTheCanvas 插槽实现覆盖在画布上方、拦截指针事件的交互层
tldraw 提供了一个位于画布最上层的组件插槽 InFrontOfTheCanvas,它默认设置了 pointer-events: none,允许你在其上渲染任意 UI 而默认保持"点击穿透"。本篇以 tldraw 官方示例 event-blocker 为核心,完整讲解如何在画布上方放置一个拦截(block)指针事件的覆盖层:哪些区域点击穿透、哪些区域被事件屏蔽、为什么不需要手动 stopPropagation,以及文本选择被禁用时如何恢复。读完本文,你可以掌握在 tldraw 中构建模态面板、自定义覆盖层等交互 UI 的标准做法,并能从 @tldraw/editor 源码层面理解事件屏蔽的底层机制。
一、示例要解决的问题:覆盖层捕获指针事件而不是穿透
tldraw 官方示例库中有一个名为 Block events 的示例(位于 README.md,frontmatter 中的关键词为 event, block, pointer-events, overlay, infrontofthecanvas, user-select),它演示的目标很明确:
Overlay UI on the canvas that captures pointer events instead of passing them through.(覆盖在画布上的 UI,捕获指针事件而不是将其传递下去。)
其核心结论浓缩为三句话:
InFrontOfTheCanvas组件插槽在画布之上渲染一个全尺寸图层,且该图层默认带pointer-events: none,所以你放进去的东西默认都是点击穿透的;- 要让覆盖层的某一部分可交互,只需给那个元素设置
pointer-events: all; - 该插槽位于画布之上,并把"从插槽内部发起的指针事件"标记为已处理,因此 tldraw 不会响应它去选中、绘制或平移画布——你无需自己调用
stopPropagation。
示例还特别指出:示例盒子中的段落设置了 user-select: text,因为 tldraw 默认在其容器内部禁用了文本选择,若希望用户能选中覆盖层中的文字,需要手动恢复。
二、示例完整实现:三个文件即可复现
2.1 React 组件:注册 InFrontOfTheCanvas
示例组件 EventBlockerExample.tsx 的完整实现非常短,核心是向 <Tldraw> 的 components prop 注册一个 InFrontOfTheCanvas 组件:
import { TLComponents, Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
import './event-blocker.css'
function WelcomeScreen() {
// [1]
return (
<div className="event-blocker__backdrop">
{/* [2] */}
<div className="event-blocker__panel">
{/* [3] */}
<p>
Notice that if you click on this box or start a drag from in here, you will not be
interacting with the canvas. However, you can still interact with the canvas by clicking
anywhere else!
</p>
<div>
<button onClick={() => window.alert('Thanks')}>Click here</button>
</div>
</div>
</div>
)
}
const components: TLComponents = {
InFrontOfTheCanvas: WelcomeScreen,
}
export default function EventBlockerExample() {
return (
<div className="tldraw__editor">
<Tldraw persistenceKey="event-blocker-example" components={components} />
</div>
)
}
组件内嵌的注释(原文件第 39–55 行的 block comment)把三个关键点标注在了对应的 JSX 节点上:
- [1] 背景层(backdrop):
InFrontOfTheCanvas插槽是覆盖在画布上的全尺寸图层,起始状态就是pointer-events: none,所以在这里渲染的东西默认是点击穿透的。这个 backdrop 只负责把面板居中,指针事件会直接穿过它落到画布上。 - [2] 面板(panel):在面板上设置
pointer-events: all,就把这个元素"重新纳入"事件接收。插槽位于画布之上,会把从其内部发起的指针事件标记为已处理(见editor.markEventAsHandled),于是 tldraw 会忽略它们:在这里点击或拖拽不会触发选中、绘制或平移。你不需要自己调用stopPropagation。 - [3] 文本选择:tldraw 在容器内部全局禁用了文本选择,避免在画布上拖拽时误选中文本。如果你希望用户能选中覆盖层里的文字,请给它设置
user-select: text。
使用体验上可以这样验证:在面板内部点击、拖拽——画布毫无反应(不会进入选择、不会平移);点击面板以外的画布区域——一切照常工作。
2.2 样式:用 pointer-events 划分"穿透区"与"屏蔽区"
配套的样式文件 event-blocker.css 只有 20 行,却是理解整个机制的关键:
.event-blocker__backdrop {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.event-blocker__panel {
pointer-events: all;
width: 400px;
padding: 32px;
border-radius: 20px;
box-shadow: 2px 2px 12px rgba(0, 0, 0, 0.2);
background-color: white;
}
.event-blocker__panel p {
user-select: text;
}
}
三个要点:
- backdrop 不设置任何 pointer-events。由于插槽容器本身是
pointer-events: none(CSS 中pointer-events是可继承属性),backdrop 默认继承"无事件"状态,整个背景区域都是点击穿透的——它只做 flex 居中布局。 - panel 显式设置
pointer-events: all,让这一块 400px 宽的面板成为事件接收区。注意这里用的是all而不是auto,两者在此场景下效果一致,示例选择了all。 p元素设置user-select: text,恢复该段落内文本的可选中性(原因见第五节)。
2.3 组合效果
最终得到的交互模型是:画布最上层有一个全尺寸、默认全穿透的图层;其中被 pointer-events: all 点亮的面板区域会拦截所有指针/触摸事件,tldraw 完全感知不到这些交互;其余区域则原样透传给画布。这正是"事件屏蔽(block events)"的标准实现方式。
三、插槽机制源码解析:InFrontOfTheCanvas 是如何挂载的
3.1 插槽位置:画布之后、菜单捕获层之前
在 @tldraw/editor 的默认画布组件 DefaultCanvas.tsx 中,InFrontOfTheCanvas 的包装器被渲染在形状层(ShapesLayer)、画布覆盖层(CanvasOverlays)之后:
// DefaultCanvas.tsx(节选)
<div ref={rHtmlLayer} className="tl-html-layer tl-shapes" draggable={false}>
<OnTheCanvasWrapper />
{hideShapes ? null : <ShapesLayer canvasRef={rCanvas} />}
</div>
<CanvasOverlays />
<MovingCameraHitTestBlocker />
</div>
<InFrontOfTheCanvasWrapper /> // ← 插槽层,位于画布层之上
<LiveCollaborators />
<MenuClickCapture />
</>
)
}
function InFrontOfTheCanvasWrapper() {
const editor = useEditor()
const { InFrontOfTheCanvas } = useEditorComponents()
if (!InFrontOfTheCanvas) return null
return (
<div
className="tl-canvas__in-front"
onPointerDown={editor.markEventAsHandled}
onPointerUp={editor.markEventAsHandled}
onTouchStart={editor.markEventAsHandled}
onTouchEnd={editor.markEventAsHandled}
>
<InFrontOfTheCanvas />
</div>
)
}
这段源码同时回答了两个问题:
- 为什么"画布不会响应":包装层给
onPointerDown/onPointerUp/onTouchStart/onTouchEnd统一挂了editor.markEventAsHandled。也就是说,只要指针/触摸事件从插槽内部冒泡上来,就会被记入"已处理事件"集合,画布的事件系统(useCanvasEvents等)随后通过wasEventAlreadyHandled检查到该事件已被处理,便不再执行选中、绘制、平移等逻辑。 - 为什么不需要
stopPropagation:tldraw 用一套"事件已处理"标记机制替代了粗暴的中止传播——事件仍按 DOM 正常顺序传播,其他非 tldraw 的事件处理器不受影响,只是 tldraw 内部各模块会跳过它。
3.2 插槽容器样式:pointer-events: none 的出处
README 中"插槽带 pointer-events: none"这一说法,对应 editor.css 中 .tl-canvas__in-front 的定义:
.tl-canvas__in-front {
position: absolute;
inset: 0;
pointer-events: none;
z-index: var(--tl-layer-canvas-in-front);
}
position: absolute; inset: 0 使其成为全尺寸覆盖层;z-index: var(--tl-layer-canvas-in-front) 保证它位于画布之上;而 pointer-events: none 就是示例中"默认点击穿透"行为的直接来源——示例的 backdrop 没有另设任何 pointer-events,正是靠继承这一属性实现穿透的。
3.3 markEventAsHandled / wasEventAlreadyHandled 的实现
在 Editor.ts 中,这两个 API 的实现基于一个 WeakSet<Event>:
/** @internal */
private handledEvents = new WeakSet<Event>()
/**
* ... By using `markEventAsHandled`, you'll stop other parts of tldraw from
* handling the event without impacting other, non-tldraw event handlers.
* See also {@link Editor.wasEventAlreadyHandled}.
*
* @public
*/
markEventAsHandled(e: Event | { nativeEvent: Event }) {
const nativeEvent = 'nativeEvent' in e ? e.nativeEvent : e
this.handledEvents.add(nativeEvent)
}
wasEventAlreadyHandled(e: Event | { nativeEvent: Event }) {
const nativeEvent = 'nativeEvent' in e ? e.nativeEvent : e
return this.handledEvents.has(nativeEvent)
}
其设计动机在源码注释中写得很清楚:画布与形状可能同时拥有事件处理器,画布对 pointerDown 调用 .preventDefault() 会连带压制形状的 click;用 stopPropagation() 又会影响挂在别处的非 tldraw 处理器。markEventAsHandled 提供了一个折中——只让 tldraw 自己"闭嘴",不干扰外部生态。这也解释了为什么示例注释强调"你不需要手动调用 stopPropagation"。
3.4 高封装组件 如何处理自定义插槽
示例用的是高封装的 <Tldraw> 组件而非底层的 TldrawEditor。在 Tldraw.tsx 中,自定义 InFrontOfTheCanvas 与 SDK 内置的 TldrawUiInFrontOfTheCanvas(承载富文本工具栏、图片/视频工具栏、光标聊天气泡等)做了组合:
// Tldraw.tsx(节选)
const CustomInFrontOfTheCanvas = components?.InFrontOfTheCanvas
const InFrontOfTheCanvas = useMemo(() => {
if (rest.hideUi) return CustomInFrontOfTheCanvas ?? null
if (!CustomInFrontOfTheCanvas) return TldrawUiInFrontOfTheCanvas
return () => (
<>
<TldrawUiInFrontOfTheCanvas />
<CustomInFrontOfTheCanvas />
</>
)
}, [rest.hideUi, CustomInFrontOfTheCanvas])
从这段合并逻辑可以看出两条使用规则:
- 不传
hideUi时,自定义插槽与 SDK 内置的画布上层 UI 并存,自定义组件渲染在内置组件之后; - 传
hideUi时,只渲染自定义插槽(未提供则为 null)。
内置组件 TldrawUiInFrontOfTheCanvas 的定义见 TldrawUi.tsx。如果你的覆盖层需要全屏遮罩,要留意默认工具栏(RichTextToolbar 等)同样存在于这一层,必要时需配合 hideUi 或调整 z-index 处理遮挡关系。
四、事件屏蔽行为的测试验证
@tldraw/editor 内置了针对该机制的专项测试 InFrontOfTheCanvas.test.tsx,它渲染一个带按钮和色块的 TestInFrontOfTheCanvas 组件,配合一个记录事件的 TrackingTool 工具,验证了四个场景:
- 拦截生效:对插槽内的按钮触发
pointerDown/pointerUp/click后,追踪工具记录到的事件为空(expect(getTrackingTool().events).toEqual([])),即画布工具层完全没有感知到这次交互; - 画布不受损:直接对 canvas 触发同样事件时,画布正常工作、不报错;
- 触摸事件同样被拦截:对插槽内的元素触发
touchStart/touchEnd后,画布事件同样为空——这与InFrontOfTheCanvasWrapper上同时挂onTouchStart/onTouchEnd的处理相呼应; - 状态可恢复:先交互插槽、再交互画布,编辑器保持响应,事件屏蔽是"按事件"标记的,不会把画布永久锁死。
这组测试从实现层面证实了 README 的表述:"点击、拖拽在面板内不会触发 select、draw、pan,而点击面板外则一切照常"。
五、user-select: text——为什么覆盖层里要手动恢复文本选择
tldraw 的 UI 样式 ui.css 中多处设置了 user-select: none(如第 346、447 行),目的如示例注释所述:在画布上拖拽时不希望浏览器把文本高亮出来。这带来一个副作用——放在 tldraw 容器内的任何文本(包括你的覆盖层 UI)默认都不可选中。
处理方式正是示例所演示的:对目标元素显式设置 user-select: text(示例中是 .event-blocker__panel p)。ui.css 中同样存在把 user-select 打开的先例(如第 1780–1781 行对可编辑文本使用 user-select: all 加 -webkit-user-select: text),可见"局部恢复文本选择"是项目内的既有惯例。
实操建议:如果你的覆盖层包含说明文字、代码片段等用户可能需要复制的内容,记得在对应元素上恢复 user-select: text(如需兼容旧版 WebKit 内核,可同时加 -webkit-user-select: text);反之,纯装饰性、作为拖拽把手的 UI 则应保持默认禁用状态。
六、总结与适用边界
回顾本例的完整技术链路:
| 环节 | 位置 | 作用 |
|---|---|---|
插槽容器 .tl-canvas__in-front |
editor.css | 全尺寸绝对定位层,pointer-events: none,默认穿透 |
| 事件拦截钩子 | DefaultCanvas.tsx | 对指针/触摸四事件统一调用 editor.markEventAsHandled |
| 已处理事件集合 | Editor.ts | WeakSet<Event> 实现 markEventAsHandled / wasEventAlreadyHandled |
| 高封装组合逻辑 | Tldraw.tsx | 自定义插槽与内置 TldrawUiInFrontOfTheCanvas 合并,hideUi 时仅保留自定义 |
| 文本选择恢复 | ui.css | 容器内默认 user-select: none,需局部设 user-select: text |
| 行为验证 | InFrontOfTheCanvas.test.tsx | 指针、触摸拦截及画布恢复的四个测试场景 |
使用这套机制时的几点边界提醒:
- 屏蔽是按事件标记的:只有从插槽内部发起(并冒泡到包装层)的指针/触摸事件会被标记,画布对其余事件的响应不受影响;
- 不需要也不应该额外调用
stopPropagation,否则会波及 tldraw 之外的事件处理器,违背该机制的设计初衷; - 若使用高封装
<Tldraw>组件,注意内置画布上层 UI 与自定义插槽同层并存的关系,全屏遮罩场景下需考虑hideUi与层叠顺序; - 覆盖层中的文本默认不可选中,需要按元素显式恢复
user-select: text。
完整可运行的示例代码见 EventBlockerExample.tsx 与 event-blocker.css,示例说明文档见 README.md。
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 StartedRust0627
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