在 tldraw 中构建自定义盖章(Stamp)工具:StateNode、共享图片资产与 TopPanel 扩展实战
本文围绕 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”:
- 数据层:图章选项保存在
atom<Stamp[]>中,纯数据即扩展点; - 工具层:
StampTool extends StateNode,用与 tldraw 内置工具相同的状态机原语实现盖章逻辑; - 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则跳过——这是连续盖章节流的核心;onCancel:Escape时切回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
}
其中包含三处关键设计:
- 资产 id 由图章 id 确定性派生:
AssetRecordType.createId(getStampId(stamp))。同一种图章永远推导出同一个TLAssetId,于是getAsset命中即跳过创建,反复盖章永远复用同一资产,文档里明确说明了“stamping the same stamp many times reuses a single asset”; - 幂等创建:先查
editor.getAsset(assetId),不存在才创建,天然防重; - 创建与历史解耦:
createAssets在 Editor.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()
}
完整链路包含四个要点:
- 文件选择:动态创建
input[type=file],accept直接使用 tldraw 导出的DEFAULT_SUPPORTED_IMAGE_TYPES(SDK 自带的图片类型白名单),不写死字符串; - 读取为 data URL:
FileHelpers.blobToDataUrl(file)把文件转成 base64 数据地址。图章以 data URL 内嵌进文档与快照,因此文档自带图章、可独立保存与分享; - 探测尺寸:
MediaHelpers.getImageSize(file)读取原始宽高,供后续等比缩放使用; - 接入响应式数据流:
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.createShape(Editor.ts)内部转交createShapes批量处理,二者都对只读模式做了短路返回,属于事务型写入口。
TopPanel 面板与激活态高亮
面板组件通过 TLComponents 的 TopPanel 插槽注入编辑器的顶部区域:
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: 22px、object-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 中本示例实际用到的 markHistoryStoppingPoint、createAssets、createShape、setCursor 四个 API 的文档注释,即可举一反三地写出属于自己的自定义工具。
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
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
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