tldraw 自定义交互形状实战:用 HTMLContainer 与 stopPropagation 打造可点击、可输入的 Todo 形状
导读
在 tldraw 中,画布上的指针事件默认由编辑器统一接管——点击即选中形状、拖动即移动形状,自定义形状内部的 HTML 元素通常无法独立响应点击与输入。本文基于 tldraw 仓库 interactive-shape 示例,完整讲解如何通过 HTMLContainer 的 pointer-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>
)
}
值得注意的两个实践点:
shapeUtils数组要定义在组件外部。文件底部的注释明确说明:这样能保证数组在多次渲染之间保持同一引用(same identity),避免因 props 引用变化引发不必要的重渲染或副作用。- 通过
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的矩形类形状免费提供:getGeometry的Rectangle2d命中区域、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 合成事件(onPointerDown、onTouchStart、onTouchEnd)里调用,父级画布容器就监听不到这次按下,自然不会触发选中与拖拽。要注意把 pointer-events: all 与 stopPropagation() 配合使用——前者让事件「进得来」,后者让事件「出不去」,二者缺一不可。
为什么触摸事件也要一并处理
示例对 checkbox 与输入框同时处理了 onPointerDown、onTouchStart、onTouchEnd 三类事件。这是因为 tldraw 需要同时覆盖鼠标(Pointer Events)与触屏设备(Touch Events)两类输入路径,只处理其中一种会在另一端设备上留下「点不动 / 一点就拖动」的体验死角。
五、条件放行:勾选完成后的「交互让位」
本示例最有价值的细节是 [c] 处的条件拦截逻辑:文本框只有在「未勾选」时才调用 stopPropagation():
- 未勾选:文本框是普通输入框,
stopPropagation()掐断冒泡,用户可以在形状内部正常打字、聚焦; - 已勾选:
readOnly生效的同时,事件处理器不再拦截,按下事件冒泡到画布——点击这个只读输入框就等价于点击形状本身,编辑器照常执行「选中 / 拖动」;
这展示了一种通用的「交互让位」设计:当形状内部不再需要处理某个交互时,把该区域重新「归还」给画布,让形状回归普通 tldraw 形状的默认行为,避免只读控件吞掉本应属于画布的拖动操作。
六、组合成完整 Todo 形状
把以上片段拼到一起,即可得到功能闭环的 Todo 形状:
- 页面上有一个 230×230、浅灰底、带内边距的「卡片」;
- 点击 checkbox →
checked翻转 → 渲染层立即反映勾选状态; - 未勾选时,文本框可输入任意文字,输入实时写回
shape.props.text; - 勾选后文本框变只读,点击它不再编辑文字,而是选中/拖拽整个形状;
- 文本框为空时展示占位符
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.tsx、editor.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 放进 Tldraw 的 shapeUtils prop,且数组定义在组件外部保持引用稳定 |
InteractiveShapeExample.tsx |
| 条件放行 | 当形状内控件不再需要独占交互时(如只读态),停止拦截事件,将其归还画布的选中/拖拽行为 | 注释 [c] 的 readOnly 分支 |
一句话总结这套模式:pointer-events: all 决定事件能否进入形状的 HTML,stopPropagation() 决定事件能否逃逸到画布,二者共同把「画布选中/拖拽」与「控件自交互」两种模式精确地切分开来。
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 StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
video-shotcraftAI宣传片skill,使用 Remotion 制作电影级产品视频:提供106 张镜头配方卡和可复用的视频魔板。适用于 Claude Code 与 Codex以及所有其他智能体Markdown00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00