首页
/ 在 tldraw 中构建自定义盖章(Stamp)工具:StateNode、共享图片资产与 TopPanel 扩展实战

在 tldraw 中构建自定义盖章(Stamp)工具:StateNode、共享图片资产与 TopPanel 扩展实战

2026-09-08 18:14:58作者:董斯意

本文围绕 stamp-tool 示例 讲解如何用 tldraw SDK 从零实现一个“图章工具”:从面板里挑选一个 emoji 或上传一张图片,然后在画布上单击盖章、按住拖拽连成一串轨迹。读完你将掌握自定义 StateNode 工具的写法、用 atom + track 驱动自建 UI、通过 editor.createAssets 让同一图章复用单个 image 资产,以及如何把整套面板挂进 TopPanel 组件插槽。

示例速览:先看效果再谈原理

完整、可运行的实现位于 StampToolExample.tsx。它配套的 README.md 以 frontmatter 声明了 title: Stamp tool、示例组件 component: ./StampToolExample.tsx 与优先级 priority: 3——这类元数据会被 examples 应用 的示例注册机制读取,从而把示例收录进示例目录(当前仓库的 examples 应用为 Vite 项目,可在 apps/examples/package.json 中查看 dev: vite --host 等脚本)。

体验路径与核心行为如下:

  • 单击:在当前指针位置放置一个图章,图章以指针位置为中心;
  • 按住拖拽:随着指针移动连续“盖”出一串图章,间距固定为图章尺寸(STAMP_SPACING = STAMP_SIZE = 64),整段拖拽在撤销历史中只算一步;
  • 再次单击:工具不会退出,可以连续盖章;
  • Escape:回到选择工具;
  • 面板:顶部出现一排图章按钮与一个 + 上传按钮,当前激活的图章高亮显示。

示例把完整能力拆成三块、彼此通过两个 atom 打通,这一架构正好对应 README 中强调的“数据驱动 + 状态机 + 插槽 UI”:

  1. 数据层:图章选项保存在 atom<Stamp[]> 中,纯数据即扩展点;
  2. 工具层StampTool extends StateNode,用与 tldraw 内置工具相同的状态机原语实现盖章逻辑;
  3. UI 层:通过 TLComponents.TopPanel 插槽注入面板,组件用 track 包裹,自动跟随 atom 数据刷新。

Stamp 数据模型与 atom:一切图章皆纯数据

示例将“图章”定义为一个可辨识联合类型:

type Stamp =
	| { type: 'emoji'; emoji: string }
	| { type: 'image'; id: string; src: string; w: number; h: number; mimeType: string }

即图章要么是一个 emoji 字符,要么是一张上传的图片(带 data URL 源、原始宽高与 MIME 类型)。两种形态的差异在“落章”时被统一成同一种产物——image 形状,因此后续渲染路径完全一致。

图章选项被放在 atom 中(来自 tldraw 的信号库 @tldraw/state),这正是 README 指出的扩展点

const stamps = atom<Stamp[]>('stamps', [
	{ type: 'emoji', emoji: '👍' },
	{ type: 'emoji', emoji: '❤️' },
	{ type: 'emoji', emoji: '🔥' },
	{ type: 'emoji', emoji: '⭐' },
	{ type: 'emoji', emoji: '😂' },
	{ type: 'emoji', emoji: '🎉' },
])
const currentStamp = atom<Stamp>('current stamp', stamps.get()[0])

两点值得注意:

  • 要发布新的默认图章,只需向数组加一项,无需改动工具或 UI 逻辑。因为面板组件运行在 track 包装内,运行时新增的图章(见上传流程)会自动出现在面板上;
  • 每个 Stamp 还有一个稳定且可派生的标识 getStampId:emoji 按码点(codePointAt)转十六进制并用 - 连接、前缀 emoji-;图片则直接使用其自带 id。这个 id 是后续“同一图章复用同一资产”的关键。
function getStampId(stamp: Stamp) {
	return stamp.type === 'emoji'
		? 'emoji-' +
				Array.from(stamp.emoji)
					.map((char) => char.codePointAt(0)!.toString(16))
					.join('-')
		: stamp.id
}

StampTool:基于 StateNode 的自定义工具

StampTool 继承自 StateNode。在 StateNode.ts 中可以看到,StateNode 是一个抽象类,实现了 Partial<TLEventHandlers>——也就是说,工具本质上是一组画布事件的处理器,这是 tldraw 内置工具(选择、画笔、箭头等)共同使用的状态机原语。自定义工具与内置工具地位完全对等,只需覆盖相关事件回调:

class StampTool extends StateNode {
	static override id = 'stamp'

	lastStampPoint = new Vec()

	override onEnter() {
		this.editor.setCursor({ type: 'cross', rotation: 0 })
	}

	override onExit() {
		this.editor.setCursor({ type: 'default', rotation: 0 })
	}

	override onPointerDown() {
		this.editor.markHistoryStoppingPoint('stamping')
		this.stamp()
	}

	override onPointerMove() {
		if (!this.editor.inputs.getIsPointing()) return
		const distance = Vec.Dist(this.editor.inputs.getCurrentPagePoint(), this.lastStampPoint)
		if (distance < STAMP_SPACING) return
		this.stamp()
	}

	override onCancel() {
		this.editor.setCurrentTool('select')
	}

	private stamp() {
		const point = this.editor.inputs.getCurrentPagePoint()
		this.lastStampPoint = Vec.From(point)
		stampAtPoint(this.editor, point, currentStamp.get())
	}
}

逐个事件理解其职责:

  • static override id = 'stamp':工具注册 id,随后通过 tools={[StampTool]} 传入 <Tldraw>,便可通过 editor.setCurrentTool('stamp') 切换激活;
  • onEnter / onExit:进入工具时把光标设为十字准星(cross),退出时恢复默认。底层对应 Editor.setCursor,该方法在 Editor.ts 实现——它把光标写入实例状态并做了无变化短路优化,文档注释还说明它常被指针移动热路径调用,冗余写入会被跳过;
  • onPointerDown:先调用 editor.markHistoryStoppingPoint('stamping') 再盖章。这一步决定了撤销语义——见下文;
  • onPointerMove:用 inputs.getIsPointing() 判断拖拽是否进行中,随后计算与上一次落章点的距离,未超过 STAMP_SPACING 则跳过——这是连续盖章节流的核心;
  • onCancelEscape 时切回 select 工具;
  • 工具在按下后保持激活,因此无需反复切换即可连续盖章。

所有落章逻辑收敛到私有方法 stamp():取当前页面坐标(getCurrentPagePoint()),记录到 lastStampPoint,再调用全局函数 stampAtPoint 真正创建形状。

把整段拖拽合并为一个撤销步骤

markHistoryStoppingPoint 是示例里容易被忽略却至关重要的调用。其实现位于 Editor.ts

markHistoryStoppingPoint(name?: string): string {
	const id = `[${name ?? 'stop'}]_${uniqueId()}`
	this.history._mark(id)
	return id
}

它在撤销/重做栈里压入一个“停止点”标记。本例只在 onPointerDown 时标记一次,之后拖拽产生的一连串 createShape 都归属于同一个历史区间,因此整串盖章在 Ctrl/⌘+Z 时作为一步整体撤销。如果改成在每个 stamp() 前都打标记,一次拖拽就会被拆成几十步,体验截然不同——这正是 README 中“whole drag undoes as a single step”的实现依据。

拖拽连盖:基于页面距离的间隔控制

拖拽落章的节流条件是页面空间中的真实距离而非像素距离(源码中的 Vec.Dist(this.editor.inputs.getCurrentPagePoint(), this.lastStampPoint))。其好处是:无论当前缩放级别是多少,相邻图章的页面间距恒定为 STAMP_SPACING(64 单位),轨迹密度保持一致,不会因为缩放而变得过密或过疏。这与直接在屏幕坐标上数像素的方案形成鲜明对比,是书写“间距稳定”交互时应借鉴的做法。

间隔常数定义在文件顶部:

const STAMP_SIZE = 64
const STAMP_SPACING = STAMP_SIZE

同时,lastStampPoint 通过 Vec.From(point) 复制而非直接持有引用,避免后续修改串扰。

资产策略:同一图章只创建一个共享 image asset

这是本示例最具复用价值的设计。每个图章最终都变成一张 image 形状,形状不直接内嵌像素,而是通过 props.assetId 引用一个 image 资产(asset),因此关键在资产的管理。核心函数:

function getStampAssetId(editor: Editor, stamp: Stamp): TLAssetId {
	const assetId = AssetRecordType.createId(getStampId(stamp))
	if (editor.getAsset(assetId)) return assetId

	const { src, w, h, mimeType } = stamp.type === 'emoji' ? renderEmojiToPng(stamp.emoji) : stamp

	editor.createAssets([
		{
			id: assetId,
			type: 'image',
			typeName: 'asset',
			props: { name: 'stamp', src, w, h, mimeType, isAnimated: false },
			meta: {},
		},
	])
	return assetId
}

其中包含三处关键设计:

  1. 资产 id 由图章 id 确定性派生AssetRecordType.createId(getStampId(stamp))。同一种图章永远推导出同一个 TLAssetId,于是 getAsset 命中即跳过创建,反复盖章永远复用同一资产,文档里明确说明了“stamping the same stamp many times reuses a single asset”;
  2. 幂等创建:先查 editor.getAsset(assetId),不存在才创建,天然防重;
  3. 创建与历史解耦createAssetsEditor.ts 的实现是 this.run(() => this.store.put(assets), { history: 'ignore' })——资产创建被标记为 history: 'ignore',不会进入撤销历史,也就不会因撤销盖出的形状而把资产误删。这与前面的 markHistoryStoppingPoint 一起构成了干净的历史语义。

emoji 为何要渲染成 PNG:跨平台一致的图章

emoji 字形在不同操作系统、不同字体环境下渲染差异很大。为让同一枚 emoji 图章在导出、协作对方、任意平台上都长得完全一致,示例先把 emoji 一次性绘制到 canvas,再转成 PNG data URL,之后始终复用这份位图:

function renderEmojiToPng(emoji: string) {
	const canvas = document.createElement('canvas')
	canvas.width = PNG_SIZE
	canvas.height = PNG_SIZE
	const ctx = canvas.getContext('2d')!
	ctx.font = `${PNG_SIZE * 0.8}px sans-serif`
	ctx.textAlign = 'center'
	ctx.textBaseline = 'middle'
	ctx.fillText(emoji, PNG_SIZE / 2, PNG_SIZE / 2 + PNG_SIZE * 0.05)
	return {
		src: canvas.toDataURL('image/png'),
		w: PNG_SIZE,
		h: PNG_SIZE,
		mimeType: 'image/png',
	}
}

可见度相关的参数都集中在文件顶部:离屏画布尺寸 PNG_SIZE = 128,字号取画布的 80%,并做轻微垂直偏移(+ PNG_SIZE * 0.05)以抵消部分 emoji 字形的视觉重心偏移。渲染出的位图随后以 image 资产形式进入文档,配合幂等 getStampAssetId,一枚 emoji 只经历一次栅格化,后续盖章零开销。

上传图片作为运行时图章

README 特别指出面板的 + 按钮展示了“运行时版本”的扩展路径。上传流程见 uploadStamp

function uploadStamp(editor: Editor) {
	const input = document.createElement('input')
	input.type = 'file'
	input.accept = DEFAULT_SUPPORTED_IMAGE_TYPES.join(',')
	input.addEventListener('change', async (e) => {
		const file = (e.target as HTMLInputElement).files?.[0]
		if (!file) return

		const src = await FileHelpers.blobToDataUrl(file)
		const { w, h } = await MediaHelpers.getImageSize(file)
		const stamp: Stamp = { type: 'image', id: uniqueId(), src, w, h, mimeType: file.type }

		stamps.update((s) => [...s, stamp])
		currentStamp.set(stamp)
		editor.setCurrentTool('stamp')
	})
	input.click()
}

完整链路包含四个要点:

  1. 文件选择:动态创建 input[type=file]accept 直接使用 tldraw 导出的 DEFAULT_SUPPORTED_IMAGE_TYPES(SDK 自带的图片类型白名单),不写死字符串;
  2. 读取为 data URLFileHelpers.blobToDataUrl(file) 把文件转成 base64 数据地址。图章以 data URL 内嵌进文档与快照,因此文档自带图章、可独立保存与分享
  3. 探测尺寸MediaHelpers.getImageSize(file) 读取原始宽高,供后续等比缩放使用;
  4. 接入响应式数据流stamps.update(...) 追加新图章 → 因为面板组件是 track 包裹的,新按钮自动出现;currentStamp.set(stamp) 选中它;editor.setCurrentTool('stamp') 立即激活盖章工具——用户上传后无需任何额外操作即可作画。

新图章同样拥有独立 uniqueId(),其资产在第一次落章时才惰性创建。

落章:以指针为中心的 image 形状

真正创建画布内容的 stampAtPoint 对两种图章做了统一收口:

function stampAtPoint(editor: Editor, point: VecLike, stamp: Stamp) {
	let w = STAMP_SIZE
	let h = STAMP_SIZE
	if (stamp.type === 'image') {
		const scale = STAMP_SIZE / Math.max(stamp.w, stamp.h)
		w = stamp.w * scale
		h = stamp.h * scale
	}

	editor.run(() => {
		const assetId = getStampAssetId(editor, stamp)
		editor.createShape({
			type: 'image',
			x: point.x - w / 2,
			y: point.y - h / 2,
			props: { assetId, w, h },
		})
	})
}

细节如下:

  • 等比适配:emoji 图章按正方形处理;上传图片则取 STAMP_SIZE / max(原始宽, 原始高) 作为缩放因子,使长边贴合 64、短边等比收缩,既保留宽高比又不会溢出目标框;
  • 居中落章:形状左上角 x/y 减去宽高一半,图章中心恰好落在指针点上;
  • 原子化创建:整个“取资产 + 建形状”包在 editor.run 事务中执行。Editor.createShapeEditor.ts)内部转交 createShapes 批量处理,二者都对只读模式做了短路返回,属于事务型写入口。

TopPanel 面板与激活态高亮

面板组件通过 TLComponentsTopPanel 插槽注入编辑器的顶部区域:

const StampPanel = track(() => {
	const editor = useEditor()
	const isStampToolActive = editor.getCurrentToolId() === 'stamp'
	const currentStampId = getStampId(currentStamp.get())

	return (
		<div className="tlui-menu stamp-panel">
			{stamps.get().map((stamp) => (
				<TldrawUiButton
					key={getStampId(stamp)}
					type="icon"
					className="stamp-panel-button"
					data-active={isStampToolActive && currentStampId === getStampId(stamp)}
					onClick={() => {
						currentStamp.set(stamp)
						editor.setCurrentTool('stamp')
					}}
				>
					{stamp.type === 'emoji' ? (
						stamp.emoji
					) : (
						<img className="stamp-panel-thumbnail" src={stamp.src} alt="custom stamp" />
					)}
				</TldrawUiButton>
			))}
			<TldrawUiButton
				type="icon"
				className="stamp-panel-button stamp-panel-upload"
				title="Upload a stamp"
				onClick={() => uploadStamp(editor)}
			>
				+
			</TldrawUiButton>
		</div>
	)
})

const components: TLComponents = {
	TopPanel: StampPanel,
}

const tools = [StampTool]

export default function StampToolExample() {
	return (
		<div className="tldraw__editor">
			<Tldraw tools={tools} components={components} />
		</div>
	)
}
  • 响应式渲染:组件用 track(...) 包裹后,对 stamps/currentStamp 两个 atom 与编辑器状态的读取都会建立订阅,数据变化自动重渲染,无需手动刷新;
  • 激活高亮:通过 editor.getCurrentToolId() === 'stamp' 判断工具是否激活,再结合 currentStampId 判断是哪一枚图章,最终输出 data-active="true" 属性交由 CSS 呈现;
  • 缩略图:emoji 直接渲染字符本身;上传图章渲染 <img> 缩略图,CSS 中设置 width/height: 22pxobject-fit: contain 保证缩略图不变形(见 stamp-tool.css);
  • 按钮复用:按钮使用 tldraw 的 TldrawUiButton,可以继承内置 UI 的质感、键盘可达性与主题变量。示例 CSS 中选中态背景使用设计令牌 var(--tl-color-muted-2),因此高亮会随主题明暗自动适配。

接入点最后是 <Tldraw tools={tools} components={components} />tools 注册 StampTool 到编辑器的工具状态机,components 把面板放进 TopPanel 插槽。这种“只注入、不改源码”的方式,正是 tldraw 扩展自定义工具与自建 UI 的标准姿势。

小结:从示例沉淀出的可复用模式

复盘 StampToolExample.tsx 的代码,可以得到一套可直接迁移到产品中的模式:

关注点 示例中的方案 底层依据
工具状态机 class StampTool extends StateNode,覆盖 onEnter/onExit/onPointerDown/onPointerMove/onCancel StateNode.ts
盖章间距节流 在页面空间中按 STAMP_SPACING 距离判断 inputs.getCurrentPagePoint() + Vec.Dist
撤销粒度 仅在 onPointerDown 打一次历史停止点 markHistoryStoppingPoint
资源复用 资产 id 由图章 id 确定性派生,先查后建、幂等创建 createAssets
图章外观一致 emoji 用 canvas 栅格化为 PNG,一次渲染、处处复用 renderEmojiToPng
运行时扩展 上传图片转 data URL 追加进 atom,UI 自动跟随 stamps.update + track
UI 挂载 TLComponents.TopPanel 插槽注入 面板组件返回 TldrawUiButton 列表

这套结构覆盖了自定义工具类应用最常见的全部关切:状态机接入、指针事件、历史语义、资产生命周期、跨端一致性与响应式 UI。若要继续深入,建议从阅读 StateNode.ts 的完整事件处理契约开始,再对照 Editor.ts 中本示例实际用到的 markHistoryStoppingPointcreateAssetscreateShapesetCursor 四个 API 的文档注释,即可举一反三地写出属于自己的自定义工具。

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

项目优选

收起
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
858
1.35 K
docsdocs
暂无描述
Markdown
899
5.82 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
923
1.85 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.83 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
532
596
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.03 K
524
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
393