首页
/ 深入 tldraw 剪贴板事件钩子:用 TldrawOptions 拦截、改写与阻断复制、剪切与粘贴

深入 tldraw 剪贴板事件钩子:用 TldrawOptions 拦截、改写与阻断复制、剪切与粘贴

2026-09-07 19:25:49作者:庞眉杨Will

tldraw 在 TldrawOptions 中提供了三个剪贴板钩子,让开发者可以在复制、剪切与粘贴的完整流程中插入自己的逻辑:过滤掉不希望被复制的图形、把外部内容改写后再粘贴、甚至直接接管原始剪贴板数据。本文结合仓库中的官方示例 clipboard-events 与底层实现,逐一讲解三个钩子的触发时机、入参结构与返回值约定,并给出可直接运行的过滤与阻断代码。读完你可以在自己的 tldraw 应用中精确控制剪贴板的每一次读写,且兼容键盘快捷键与菜单操作两种入口。

三个剪贴板钩子:各自负责哪一段流程

packages/editor/src/lib/options.ts 中,三个钩子作为 TldrawOptions 的可选字段被定义,默认值均为 undefined(见 options.ts 中的 defaultTldrawOptions),也就是说默认情况下 tldraw 走完整的原生复制/粘贴流程。

它们的执行顺序与分工可以概括为:

钩子 触发时机 入参核心内容 返回值含义
onClipboardPasteRaw 粘贴一开始,tldraw 尚未解析任何剪贴板内容时 sourcenative-eventclipboard-read)、原始 clipboardDataclipboardItems 返回 false 取消本次粘贴的默认处理;返回 void 则继续
onBeforeCopyToClipboard 复制/剪切,内容写入剪贴板之前 已序列化的 TLContentoperationcopy / cut)、sourcenative / menu 返回修改后的 TLContent 改变写入内容;返回 false 取消写入(剪切时选中图形不会被移除);返回 void 原样通过
onBeforePasteFromClipboard 粘贴,内容已被解析、即将创建图形之前 已解析的 TLExternalContentsource 返回修改后的内容对象改写粘贴结果;返回 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' 时,来自粘贴事件的原始 clipboardDataDataTransfer);
  • source === 'clipboard-read' 时,来自异步剪贴板 API 的 clipboardItemsClipboardItem[])。

它的返回约定是:返回 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 目录下提供了一个完整的交互式示例,用于直观验证上述行为。文件结构为:

示例组件用 persistenceKey="clipboard-events-example" 保持画布持久化,并把 stateupdateState 暴露到 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-copypaste (blocked) 等条目与画布实际结果是否一致。

底层实现与测试佐证

在实现层面,三个钩子分别由粘贴与复制两条内部链路调用。以 useClipboardEvents.ts 为例,可以看到 onClipboardPasteRaw 在两个分支中被调用:当剪贴板读取走异步 Clipboard API、数据是 ClipboardItem[] 时,以 source: 'clipboard-read' 并携带 clipboardItems 调用;当处理浏览器原生粘贴事件 e.clipboardData 时,以 source: 'native-event' 并携带 eventclipboardData 调用。而键盘快捷键与菜单操作共用同一套内部处理逻辑,这正是 README 中所说“它们同时覆盖键盘快捷键与菜单操作”的原因。

onBeforePasteFromClipboard 的调用链位于粘贴内容的落地逻辑中;粘贴结果最终经过 putPastedContent(见 putPastedContent.ts)等模块被应用为图形。仓库还提供了针对性的测试文件:

写自定义剪贴板策略时,可以参照这些测试来确认你的钩子在不同 sourceoperation 组合下的预期行为。

关键注意事项速查

  • options 必须稳定:三个钩子定义在传入 <Tldraw> 的 options 对象上,对象引用变化会重建编辑器,建议把回调提升到模块级或 useMemo
  • 返回值三态语义void = 放行原样处理,false = 取消本次操作,对象 = 用返回值替代默认内容;三个钩子一致。
  • onClipboardPasteRaw 是同步的:需要在手势当下立即决定是否接管,不能异步;另外两个 onBefore* 钩子支持异步(Promise)。
  • 复制方向必须同步过滤 rootShapeIds:改写 TLContent.shapes 时如果不同步修正顶层图形 ID 列表,会产生悬空引用。
  • onBeforePasteFromClipboard 不覆盖拖放与 putExternalContent:需要拦截拖放请另寻 experimental__onDropOnCanvas 等拖放相关选项(同样定义在 options.ts 中)。
  • cut 被取消时选中内容保留:在 onBeforeCopyToClipboard 返回 false 只会阻止写入剪贴板,不会删除选中的图形。
  • Safari 的剪贴板写入限制:如果钩子逻辑耗时较长,异步处理必须保持在用户手势派生出的任务链内,示例中的 500ms 延迟开关就是为验证此类场景而设。
登录后查看全文
热门项目推荐
相关项目推荐