首页
/ tldraw 自定义交互形状实战:用 HTMLContainer 与 stopPropagation 打造可点击、可输入的 Todo 形状

tldraw 自定义交互形状实战:用 HTMLContainer 与 stopPropagation 打造可点击、可输入的 Todo 形状

2026-09-08 16:08:41作者:咎竹峻Karen

导读

在 tldraw 中,画布上的指针事件默认由编辑器统一接管——点击即选中形状、拖动即移动形状,自定义形状内部的 HTML 元素通常无法独立响应点击与输入。本文基于 tldraw 仓库 interactive-shape 示例,完整讲解如何通过 HTMLContainerpointer-events: all 与事件 stopPropagation(),让自定义形状里的 checkbox、文本输入框自行处理交互,实现一个带勾选状态与输入能力的 Todo 形状。读完你将掌握:形状内嵌 HTML 控件的命中机制、事件在画布与形状之间如何流转与拦截,以及注册自定义形状并实时写回状态的标准姿势。

一、示例要解决的核心问题

默认行为:编辑器接管画布上的一切指针事件

阅读本示例的入口 README(即 interactive-shape 示例说明)可知,tldraw 画布的默认交互模型是:

  • 点击一个形状 → 选中它;
  • 拖动一个形状 → 移动它。

这一行为由编辑器层的指针事件处理统一驱动。也就是说,如果自定义形状内嵌了 <button><input><a> 这类原生交互元素,直接渲染出来,它们通常「点不动」——指针事件还没到达这些元素,就已经被画布层的选中/拖拽逻辑消耗掉了。

从源码结构看,这正是形状内容容器默认不接收指针事件的原因:编辑器组件的样式表 editor.css.tl-html-container 的默认样式为 pointer-events: none,把事件全部让给上层的画布与编辑器。

出路:让「容器」选择性接收事件 + 在源头拦截冒泡

README 给出的解决方案非常直接:

要让形状的一部分自行处理交互,就在形状的 HTMLContainer 上设置 pointer-events: all,并对不希望传回画布的事件调用 stopPropagation()

落到本示例上,就是 Todo 形状里的 checkbox 要能点、文本框要能输入文字;而一旦某条 Todo 被勾选完成,文本框变为只读(readOnly),此时主动把事件放行给画布——点击它就恢复为普通形状的「选中并拖动」行为。

二、最小可运行骨架:如何挂载这个自定义形状

示例的 React 入口组件位于 InteractiveShapeExample.tsx,整体结构非常精简:

import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
import { myInteractiveShape } from './my-interactive-shape-util'

// [1] 自定义 shape util 数组在组件外部定义
const customShapeUtils = [myInteractiveShape]

export default function InteractiveShapeExample() {
	return (
		<div className="tldraw__editor">
			<Tldraw
				shapeUtils={customShapeUtils}
				onMount={(editor) => {
					editor.createShape({ type: 'my-interactive-shape', x: 100, y: 100 })
				}}
			/>
		</div>
	)
}

值得注意的两个实践点:

  1. shapeUtils 数组要定义在组件外部。文件底部的注释明确说明:这样能保证数组在多次渲染之间保持同一引用(same identity),避免因 props 引用变化引发不必要的重渲染或副作用。
  2. 通过 onMount 创建初始形状onMount 回调拿到 editor 实例后,调用 editor.createShape 在坐标 (100, 100) 处放置一个 'my-interactive-shape' 实例,方便打开示例立刻看到效果。如果你的使用场景需要复用此能力,把这一行放进你自己的创建流程即可。

三、核心实现:my-interactive-shape-util.tsx 逐步拆解

自定义形状类定义在 my-interactive-shape-util.tsx,本节按「类型声明 → props 定义 → 渲染 → 交互拦截 → 指示路径」顺序逐段展开,并附完整代码。

1. 形状类型、props 结构与模块扩展

import { BaseBoxShapeUtil, HTMLContainer, RecordProps, T, TLShape } from 'tldraw'

const MY_INTERACTIVE_SHAPE_TYPE = 'my-interactive-shape'

declare module 'tldraw' {
	export interface TLGlobalShapePropsMap {
		[MY_INTERACTIVE_SHAPE_TYPE]: { w: number; h: number; checked: boolean; text: string }
	}
}

export type IMyInteractiveShape = TLShape<typeof MY_INTERACTIVE_SHAPE_TYPE>

export class myInteractiveShape extends BaseBoxShapeUtil<IMyInteractiveShape> {
	static override type = MY_INTERACTIVE_SHAPE_TYPE
	static override props: RecordProps<IMyInteractiveShape> = {
		w: T.number,
		h: T.number,
		checked: T.boolean,
		text: T.string,
	}

	getDefaultProps(): IMyInteractiveShape['props'] {
		return {
			w: 230,
			h: 230,
			checked: false,
			text: '',
		}
	}
	// ...(component 与 getIndicatorPath 见下文)
}
  • 形状类型常量type 是形状的全局唯一标识,示例统一使用 MY_INTERACTIVE_SHAPE_TYPE 常量,避免魔法字符串散落各处。
  • 类型安全:通过 declare module 'tldraw'TLGlobalShapePropsMap 声明本形状的 props 结构(w/h/checked/text),再由 TLShape<...> 推导出 IMyInteractiveShape。这是当前仓库自定义形状示例中用于打通全局类型映射的标准写法。
  • 运行时 props 校验static override props 使用 tldraw 的运行时校验器 T.number / T.boolean / T.string 描述每个字段。这与类型声明一一对应——类型与运行时校验必须保持同步,这是保证序列化、迁移与协作同步时数据不出错的关键。
  • 默认值getDefaultProps() 返回 230×230 的画布尺寸、未勾选、空文本。
  • 继承 BaseBoxShapeUtil:基类定义于 packages/editor/src/lib/editor/shapes/BaseBoxShapeUtil.tsx,它为带 w/h 的矩形类形状免费提供:getGeometryRectangle2d 命中区域、onResize 的缩放逻辑、getHandleSnapGeometry 的顶点吸附、以及 getInterpolatedProps 的动画插值能力,因此本示例无需手写几何与缩放代码。

2. component 渲染:HTML 容器与内嵌控件

	component(shape: IMyInteractiveShape) {
		return (
			<HTMLContainer
				style={{
					padding: 16,
					height: shape.props.h,
					width: shape.props.w,
					// [a] 关键开关允许形状接收指针事件
					pointerEvents: 'all',
					backgroundColor: '#efefef',
					overflow: 'hidden',
				}}
			>
				<input
					type="checkbox"
					checked={shape.props.checked}
					onChange={() =>
						this.editor.updateShape({
							id: shape.id,
							type: MY_INTERACTIVE_SHAPE_TYPE,
							props: { checked: !shape.props.checked },
						})
					}
					// [b] 拦截:点击 checkbox 不触发形状选中/拖动
					onPointerDown={(e) => e.stopPropagation()}
					onTouchStart={(e) => e.stopPropagation()}
					onTouchEnd={(e) => e.stopPropagation()}
				/>
				<input
					type="text"
					placeholder="Enter a todo..."
					readOnly={shape.props.checked}
					value={shape.props.text}
					onChange={(e) =>
						this.editor.updateShape({
							id: shape.id,
							type: MY_INTERACTIVE_SHAPE_TYPE,
							props: { text: e.currentTarget.value },
						})
					}
					// [c] 条件拦截:未勾选时输入框自行处理事件;勾选后放行给画布
					onPointerDown={(e) => {
						if (!shape.props.checked) {
							e.stopPropagation()
						}
					}}
					onTouchStart={(e) => {
						if (!shape.props.checked) {
							e.stopPropagation()
						}
					}}
					onTouchEnd={(e) => {
						if (!shape.props.checked) {
							e.stopPropagation()
						}
					}}
				/>
			</HTMLContainer>
		)
	}

HTMLContainer 本身只是一个带 tl-html-container 类名的普通 div 封装(见 packages/editor/src/lib/components/HTMLContainer.tsx),它把 tldraw 的形状画布坐标系与 React 元素桥接起来:容器绝对定位、铺满形状的可视区域。真正控制命中行为的是样式:

  • pointerEvents: 'all':把默认的 pointer-events: none 覆盖为接收全部指针事件。源码注释明确指出,形状容器默认 pointer-events: none 是为了让画布收到每一个指针事件;设置为 all(或 auto)即主动「订阅」形状 HTML 上的事件。
  • checkbox 的 stopPropagation():checkbox 的点击会被冒泡到形状容器、进而被转发给画布容器与编辑器;在 React 的 onPointerDown/onTouchStart/onTouchEnd 上调用 stopPropagation(),事件就到此为止,编辑器不会把它解释成「选中这个形状」或「开始拖动」。
  • 状态写回:勾选与输入都通过 this.editor.updateShape({ id, type, props }) 把新状态写回文档,再由编辑器统一渲染。checkbox 的 checked 与文本框的 value 均来自 shape.props,因此界面与文档状态严格单向绑定,tldraw 会负责后续的撤销、历史与(若有)协作同步。

3. getIndicatorPath:选中态的外框指示

	getIndicatorPath(shape: IMyInteractiveShape) {
		const path = new Path2D()
		path.rect(0, 0, shape.props.w, shape.props.h)
		return path
	}

当形状被选中时,编辑器会调用 getIndicatorPath 绘制选中外框/蓝线。这里用 Path2D 画一个与 w × h 等大的矩形即可,成本极低。

四、底层原理:事件为何「默认到不了」形状的 HTML

默认 pointer-events: none 的出处

在编辑器包样式 packages/editor/editor.css 中:

.tl-html-container {
	position: absolute;
	inset: 0px;
	height: 100%;
	width: 100%;
	pointer-events: none;   /* 默认不接收指针事件 */
	stroke-linecap: round;
	stroke-linejoin: round;
	transform-origin: top left;
	color: var(--tl-color-text-1);
}

由此可以清楚看出默认交互模型的设计:形状内的 HTML 层默认处于「透明」状态,指针事件穿透到画布,由编辑器统一完成「命中检测 → 选中 → 拖拽」的状态机。这也解释了为什么「在容器样式里写一行 pointerEvents: 'all'」会成为让自定义控件可交互的第一步。

拦截的本质:事件冒泡在哪一层被掐断

示例 util 文件末尾的源码注释对这套机制做了权威说明:

  • 事件从目标 HTML 元素向上冒泡,会先后经过形状容器、画布容器,最终到达编辑器;
  • 画布容器会把收到的指针事件转发给编辑器(forward to the editor);
  • 因此在元素自身的事件处理器中调用 stopPropagation(),等于在事件进入画布/编辑器之前将其拦下。

从 DOM 冒泡模型看:stopPropagation() 阻止的是事件继续向父节点冒泡,所以只要在 React 合成事件(onPointerDownonTouchStartonTouchEnd)里调用,父级画布容器就监听不到这次按下,自然不会触发选中与拖拽。要注意把 pointer-events: allstopPropagation() 配合使用——前者让事件「进得来」,后者让事件「出不去」,二者缺一不可。

为什么触摸事件也要一并处理

示例对 checkbox 与输入框同时处理了 onPointerDownonTouchStartonTouchEnd 三类事件。这是因为 tldraw 需要同时覆盖鼠标(Pointer Events)与触屏设备(Touch Events)两类输入路径,只处理其中一种会在另一端设备上留下「点不动 / 一点就拖动」的体验死角。

五、条件放行:勾选完成后的「交互让位」

本示例最有价值的细节是 [c] 处的条件拦截逻辑:文本框只有在「未勾选」时才调用 stopPropagation()

  • 未勾选:文本框是普通输入框,stopPropagation() 掐断冒泡,用户可以在形状内部正常打字、聚焦;
  • 已勾选readOnly 生效的同时,事件处理器不再拦截,按下事件冒泡到画布——点击这个只读输入框就等价于点击形状本身,编辑器照常执行「选中 / 拖动」;

这展示了一种通用的「交互让位」设计:当形状内部不再需要处理某个交互时,把该区域重新「归还」给画布,让形状回归普通 tldraw 形状的默认行为,避免只读控件吞掉本应属于画布的拖动操作。

六、组合成完整 Todo 形状

把以上片段拼到一起,即可得到功能闭环的 Todo 形状:

  1. 页面上有一个 230×230、浅灰底、带内边距的「卡片」;
  2. 点击 checkbox → checked 翻转 → 渲染层立即反映勾选状态;
  3. 未勾选时,文本框可输入任意文字,输入实时写回 shape.props.text
  4. 勾选后文本框变只读,点击它不再编辑文字,而是选中/拖拽整个形状;
  5. 文本框为空时展示占位符 Enter a todo...

整套逻辑没有任何外部组件与后端依赖,适合作为理解 tldraw「自定义形状 + HTML 交互」的最小范例嵌入自己的编辑器项目。更多自定义形状的进阶话题(样式化、几何、迁移、原生 onClick 等)可继续在仓库的同类示例中探索,例如同目录下的 custom-shape 示例shape-with-onClick 示例editable-shape 示例size-from-dom 示例

七、要点速查与实践清单

对照本示例,在自研交互形状时可照此清单自查:

关注点 正确做法 依据
容器命中 HTMLContainer 的 style 中设置 pointerEvents: 'all'(或 'auto'),覆盖默认的 pointer-events: none my-interactive-shape-util.tsxeditor.css
事件拦截 对想保留给控件自身的事件(点击、聚焦等)在 React 合成事件上调用 stopPropagation() 同上,注释 [b]
输入覆盖 鼠标路径(onPointerDown)与触屏路径(onTouchStart/onTouchEnd)都要处理 同上,checkbox/input 的 handler
状态写回 通过 this.editor.updateShape({ id, type, props }) 单向更新,由 shape.props 驱动渲染 示例 onChange
类型与校验 TLGlobalShapePropsMap 的类型声明、RecordProps 的运行时校验、getDefaultProps 的默认值三者保持一致 本示例前三段代码
形态复用 继承 BaseBoxShapeUtil 以免费获得矩形几何、缩放、吸附能力 BaseBoxShapeUtil.tsx
注册挂载 把 shape util 放进 TldrawshapeUtils prop,且数组定义在组件外部保持引用稳定 InteractiveShapeExample.tsx
条件放行 当形状内控件不再需要独占交互时(如只读态),停止拦截事件,将其归还画布的选中/拖拽行为 注释 [c] 的 readOnly 分支

一句话总结这套模式:pointer-events: all 决定事件能否进入形状的 HTML,stopPropagation() 决定事件能否逃逸到画布,二者共同把「画布选中/拖拽」与「控件自交互」两种模式精确地切分开来。

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

项目优选

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