首页
/ tldraw 事件屏蔽实战:用 InFrontOfTheCanvas 插槽实现覆盖在画布上方、拦截指针事件的交互层

tldraw 事件屏蔽实战:用 InFrontOfTheCanvas 插槽实现覆盖在画布上方、拦截指针事件的交互层

2026-09-07 17:43:23作者:咎岭娴Homer

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,捕获指针事件而不是将其传递下去。)

其核心结论浓缩为三句话:

  1. InFrontOfTheCanvas 组件插槽在画布之上渲染一个全尺寸图层,且该图层默认带 pointer-events: none,所以你放进去的东西默认都是点击穿透的
  2. 要让覆盖层的某一部分可交互,只需给那个元素设置 pointer-events: all
  3. 该插槽位于画布之上,并把"从插槽内部发起的指针事件"标记为已处理,因此 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>
	)
}

这段源码同时回答了两个问题:

  1. 为什么"画布不会响应":包装层给 onPointerDown / onPointerUp / onTouchStart / onTouchEnd 统一挂了 editor.markEventAsHandled。也就是说,只要指针/触摸事件从插槽内部冒泡上来,就会被记入"已处理事件"集合,画布的事件系统(useCanvasEvents 等)随后通过 wasEventAlreadyHandled 检查到该事件已被处理,便不再执行选中、绘制、平移等逻辑。
  2. 为什么不需要 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 工具,验证了四个场景:

  1. 拦截生效:对插槽内的按钮触发 pointerDown/pointerUp/click 后,追踪工具记录到的事件为空(expect(getTrackingTool().events).toEqual([])),即画布工具层完全没有感知到这次交互;
  2. 画布不受损:直接对 canvas 触发同样事件时,画布正常工作、不报错;
  3. 触摸事件同样被拦截:对插槽内的元素触发 touchStart/touchEnd 后,画布事件同样为空——这与 InFrontOfTheCanvasWrapper 上同时挂 onTouchStart/onTouchEnd 的处理相呼应;
  4. 状态可恢复:先交互插槽、再交互画布,编辑器保持响应,事件屏蔽是"按事件"标记的,不会把画布永久锁死。

这组测试从实现层面证实了 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.tsxevent-blocker.css,示例说明文档见 README.md

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