深入 tldraw 剪贴板事件钩子:用 TldrawOptions 拦截、改写与阻断复制、剪切与粘贴
tldraw 在 TldrawOptions 中提供了三个剪贴板钩子,让开发者可以在复制、剪切与粘贴的完整流程中插入自己的逻辑:过滤掉不希望被复制的图形、把外部内容改写后再粘贴、甚至直接接管原始剪贴板数据。本文结合仓库中的官方示例 clipboard-events 与底层实现,逐一讲解三个钩子的触发时机、入参结构与返回值约定,并给出可直接运行的过滤与阻断代码。读完你可以在自己的 tldraw 应用中精确控制剪贴板的每一次读写,且兼容键盘快捷键与菜单操作两种入口。
三个剪贴板钩子:各自负责哪一段流程
在 packages/editor/src/lib/options.ts 中,三个钩子作为 TldrawOptions 的可选字段被定义,默认值均为 undefined(见 options.ts 中的 defaultTldrawOptions),也就是说默认情况下 tldraw 走完整的原生复制/粘贴流程。
它们的执行顺序与分工可以概括为:
| 钩子 | 触发时机 | 入参核心内容 | 返回值含义 |
|---|---|---|---|
onClipboardPasteRaw |
粘贴一开始,tldraw 尚未解析任何剪贴板内容时 | source(native-event 或 clipboard-read)、原始 clipboardData 或 clipboardItems |
返回 false 取消本次粘贴的默认处理;返回 void 则继续 |
onBeforeCopyToClipboard |
复制/剪切,内容写入剪贴板之前 | 已序列化的 TLContent、operation(copy / cut)、source(native / menu) |
返回修改后的 TLContent 改变写入内容;返回 false 取消写入(剪切时选中图形不会被移除);返回 void 原样通过 |
onBeforePasteFromClipboard |
粘贴,内容已被解析、即将创建图形之前 | 已解析的 TLExternalContent、source |
返回修改后的内容对象改写粘贴结果;返回 false 取消;返回 void 原样通过 |
这里有两个容易混淆的 source 取值维度:复制钩子的 source 是 'native' 或 'menu',用来区分用户是通过原生键盘快捷键还是通过菜单项触发的操作;而粘贴类钩子的 source 是 'native-event' 或 'clipboard-read',用来区分底层拿到剪贴板数据的通道——前者来自粘贴事件的 DataTransfer,后者来自异步 Clipboard API 的 ClipboardItem[]。
如何传入钩子:options 对象需要保持稳定
三个钩子都挂在 TldrawOptions 上,通过 <Tldraw options={...}> 传入。需要注意一个关键的 React 约束:传入的 options 对象应当在渲染之间保持稳定引用,因为该对象变化会导致编辑器被重建。官方示例 ClipboardEventsExample.tsx 采用模块级 options 常量而非组件内联对象,正是为了避免每次渲染产生新引用;同时,钩子回调如果在闭包中捕获 React 状态会读到过期值,因此示例把开关状态放在模块级共享对象 state 中,并用 useSyncExternalStore 让控制面板在状态变化时重渲染。
一个最小可运行的骨架如下:
import { Tldraw, type TldrawOptions } from 'tldraw'
import 'tldraw/tldraw.css'
const options: Partial<TldrawOptions> = {
onBeforeCopyToClipboard({ content, operation, source }) {
// 返回修改后的 content、false 或 void
},
onBeforePasteFromClipboard({ content, source }) {
// 返回修改后的 content、false 或 void
},
onClipboardPasteRaw(info) {
// 返回 false 或 void
},
}
export default function App() {
return <Tldraw options={options} />
}
在 ClipboardEventsExample.tsx 中,options 同时被传递给 <Tldraw>,并额外通过 components={{ TopPanel: Controls }} 把开关面板挂到编辑器顶部,方便实时观察每个钩子的触发情况。
onClipboardPasteRaw:在 tldraw 解析之前接管原始粘贴
onClipboardPasteRaw 是最“先手”的钩子,在键盘快捷键与菜单触发的粘贴流程中都最先执行,运行时机早于 tldraw 对剪贴板数据的解析,也早于 onBeforePasteFromClipboard。它接收两类原始数据:
- 当
source === 'native-event'时,来自粘贴事件的原始clipboardData(DataTransfer); - 当
source === 'clipboard-read'时,来自异步剪贴板 API 的clipboardItems(ClipboardItem[])。
它的返回约定是:返回 false 取消 tldraw 对该手势的默认粘贴处理(你可以用原始数据自行实现粘贴);返回 void/undefined 则放行,让 tldraw 继续解析。由于该钩子必须在手势发生当下立刻决定是否接管,它是同步的,不能像另外两个钩子那样返回 Promise——官方示例中的 “Async callbacks” 开关只会让它的日志输出延后,而非延迟决策。
典型场景包括:完全自定义的粘贴格式处理、需要预览或二次确认的粘贴、以及彻底禁用粘贴。示例中“Handle raw paste (take over)”开关打开后,回调会把剪贴板里的每一项枚举出来并返回 false 阻断默认行为:
onClipboardPasteRaw(info) {
if (info.source === 'native-event') {
const kinds = info.clipboardData
? [...info.clipboardData.items].map((i) => `${i.kind}:${i.type}`).join(', ')
: '(no clipboardData)'
addLog({
action: 'raw-paste',
source: 'native-event',
detail: kinds,
})
} else {
addLog({
action: 'raw-paste',
source: 'clipboard-read',
detail: `${info.clipboardItems.length} clipboard item(s)`,
})
}
return false
}
onBeforeCopyToClipboard:改写写进剪贴板的内容
onBeforeCopyToClipboard 在复制或剪切发生时、内容被写入剪贴板之前被调用,接收已序列化的 TLContent(包含 shapes、bindings、assets 等),通过 operation 区分是 copy 还是 cut,通过 source 区分 native(键盘)或 menu(菜单)。它同时覆盖复制与剪切两条路径。
三种返回值的语义分别是:
- 返回修改后的
TLContent:把改写后的内容写入剪贴板; - 返回
false:取消本次剪贴板写入;对剪切操作而言,选中的图形不会被移除(等于同时拦下了“删除选中项”这一步,需要小心预期); - 返回
void:内容原样写入。
该钩子可以是异步的(返回 Awaitable<TLContent | false | void>)。官方示例用“Filter red on copy”演示了一个真实场景:复制时把所有颜色为红色的图形从 TLContent 中剔除。关键点是,只过滤 shapes 还不够,必须同步过滤 rootShapeIds,否则顶层图形 ID 会引用已不存在的内容:
async onBeforeCopyToClipboard({ content, operation, source }) {
if (state.disableCopy) return false
if (!state.filterRedOnCopy) return
const filtered = content.shapes.filter((s) => !('color' in s.props && s.props.color === 'red'))
const filteredIds = new Set(filtered.map((s) => s.id))
return {
...content,
shapes: filtered,
rootShapeIds: content.rootShapeIds.filter((id) => filteredIds.has(id)),
}
}
在 options.ts 的类型注释中,还给出了另一个常见用法示例——复制时剔除 meta.locked 标记的锁定图形。凡是“复制时不想带走某些内容”的需求,例如按权限过滤、剔除元数据、去水印、翻译文本等,都可以在这里完成。
onBeforePasteFromClipboard:改写即将应用的粘贴内容
onBeforePasteFromClipboard 在剪贴板内容被解析之后、图形实际创建之前执行。此时内容已经变成结构化的 TLExternalContent(可能是 tldraw 格式的文档内容、纯文本、图片文件、URL 等)。返回值同样是三态:修改后的内容对象、false(取消粘贴)、void(放行)。它同样支持异步。
一个必须记住的边界:该钩子只为剪贴板粘贴触发,文件拖放到画布、或以编程方式调用 putExternalContent 都不会经过它(参见 options.ts 的说明)。如果要做统一的拖放/粘贴内容过滤,需要另外处理拖放路径。
示例中“Filter red on paste”展示了过滤逻辑:只有当外部内容是 tldraw 类型时才处理,并且像复制方向一样,过滤 shapes 的同时要修正 rootShapeIds:
async onBeforePasteFromClipboard({ content }) {
if (content.type !== 'tldraw') return
const filtered = content.content.shapes.filter(
(s) => !('color' in s.props && s.props.color === 'red')
)
const filteredIds = new Set(filtered.map((s) => s.id))
return {
...content,
content: {
...content.content,
shapes: filtered,
rootShapeIds: content.content.rootShapeIds.filter((id) => filteredIds.has(id)),
},
}
}
options.ts 类型注释里还提供了一个拦截图片文件的例子:当 content.type === 'files' 时过滤掉非图片文件,若全部被过滤则返回 false 直接取消粘贴。
官方交互示例:用复选框验证每个钩子
仓库在 clipboard-events 目录下提供了一个完整的交互式示例,用于直观验证上述行为。文件结构为:
- ClipboardEventsExample.tsx:示例主体,包含
options定义与控制面板; - clipboard-events.css:面板样式;
- README.md:示例元数据与说明(frontmatter 中的
component指向示例组件,keywords便于搜索)。
示例组件用 persistenceKey="clipboard-events-example" 保持画布持久化,并把 state 与 updateState 暴露到 window.__tldraw_clipboard_state 供 e2e 测试驱动。顶层面板提供 6 个复选框,正好覆盖三个钩子的全部能力:
- Block copy/cut:在
onBeforeCopyToClipboard中返回false,验证复制/剪切被阻断且剪切不移除选中项; - Block paste:在
onBeforePasteFromClipboard中返回false,验证粘贴被取消; - Filter red on copy:复制时剔除红色图形;
- Filter red on paste:粘贴时剔除红色图形;
- Handle raw paste (take over):打开
onClipboardPasteRaw的接管分支并返回false,日志会显示原始剪贴板中每一项的kind:type; - Async callbacks (500ms delay):给两个
onBefore*钩子各增加 500ms 延时,验证慢速(异步)钩子依然工作——这在 Safari 上尤其重要,因为该浏览器要求剪贴板写入必须保持在用户手势触发的任务链内,异步完成回调正好模拟这种真实约束。
控件下方的日志区(.clipboard-events-log)展示最近三条钩子触发记录,内容包括动作名、来源与是否被阻断(blocked)。动手验证方式很简单:先画一个红色图形和一个蓝色图形,然后配合不同开关组合执行复制/粘贴,观察日志里 filter-copy、paste (blocked) 等条目与画布实际结果是否一致。
底层实现与测试佐证
在实现层面,三个钩子分别由粘贴与复制两条内部链路调用。以 useClipboardEvents.ts 为例,可以看到 onClipboardPasteRaw 在两个分支中被调用:当剪贴板读取走异步 Clipboard API、数据是 ClipboardItem[] 时,以 source: 'clipboard-read' 并携带 clipboardItems 调用;当处理浏览器原生粘贴事件 e.clipboardData 时,以 source: 'native-event' 并携带 event 与 clipboardData 调用。而键盘快捷键与菜单操作共用同一套内部处理逻辑,这正是 README 中所说“它们同时覆盖键盘快捷键与菜单操作”的原因。
onBeforePasteFromClipboard 的调用链位于粘贴内容的落地逻辑中;粘贴结果最终经过 putPastedContent(见 putPastedContent.ts)等模块被应用为图形。仓库还提供了针对性的测试文件:
- clipboardCallbacks.test.ts:专门验证剪贴板回调的过滤、阻断与改写行为;
- clipboardPaste.test.ts 与 ContextMenu.test.tsx:覆盖粘贴整体流程与菜单入口。
写自定义剪贴板策略时,可以参照这些测试来确认你的钩子在不同 source、operation 组合下的预期行为。
关键注意事项速查
- options 必须稳定:三个钩子定义在传入
<Tldraw>的 options 对象上,对象引用变化会重建编辑器,建议把回调提升到模块级或useMemo。 - 返回值三态语义:
void= 放行原样处理,false= 取消本次操作,对象 = 用返回值替代默认内容;三个钩子一致。 onClipboardPasteRaw是同步的:需要在手势当下立即决定是否接管,不能异步;另外两个onBefore*钩子支持异步(Promise)。- 复制方向必须同步过滤
rootShapeIds:改写TLContent.shapes时如果不同步修正顶层图形 ID 列表,会产生悬空引用。 onBeforePasteFromClipboard不覆盖拖放与putExternalContent:需要拦截拖放请另寻experimental__onDropOnCanvas等拖放相关选项(同样定义在 options.ts 中)。- cut 被取消时选中内容保留:在
onBeforeCopyToClipboard返回false只会阻止写入剪贴板,不会删除选中的图形。 - Safari 的剪贴板写入限制:如果钩子逻辑耗时较长,异步处理必须保持在用户手势派生出的任务链内,示例中的 500ms 延迟开关就是为验证此类场景而设。
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 StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00