Clipboard events

Filter, transform, or block copy, cut, and paste with the clipboard hooks in TldrawOptions.

import { useCallback, useSyncExternalStore } from 'react'
import { Tldraw, type TldrawOptions } from 'tldraw'
import 'tldraw/tldraw.css'
import './clipboard-events.css'

// [1]
interface ClipboardLog {
	action: string
	source: string
	prevented: boolean
	detail?: string
}

interface ClipboardEventsState {
	disableCopy: boolean
	disablePaste: boolean
	filterRedOnCopy: boolean
	filterRedOnPaste: boolean
	handleRawPaste: boolean
	useAsyncCallbacks: boolean
	log: ClipboardLog[]
}

const state: ClipboardEventsState = {
	disableCopy: false,
	disablePaste: false,
	filterRedOnCopy: false,
	filterRedOnPaste: false,
	handleRawPaste: false,
	useAsyncCallbacks: false,
	log: [],
}

let stateVersion = 0
const listeners = new Set<() => void>()

function updateState(patch: Partial<ClipboardEventsState>) {
	Object.assign(state, patch)
	stateVersion++
	listeners.forEach((l) => l())
}

function subscribe(listener: () => void) {
	listeners.add(listener)
	return () => listeners.delete(listener)
}

function getSnapshot() {
	return stateVersion
}

function addLog(entry: ClipboardLog) {
	updateState({ log: [entry, ...state.log].slice(0, 3) })
	;(window as any).__tldraw_clipboard_log = [
		...((window as any).__tldraw_clipboard_log ?? []),
		entry,
	]
}

function delay(ms: number) {
	return new Promise<void>((resolve) => setTimeout(resolve, ms))
}

// [2]
const options: Partial<TldrawOptions> = {
	onClipboardPasteRaw(info) {
		if (!state.handleRawPaste) return
		function doThings() {
			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',
					prevented: false,
					detail: `${kinds}${state.useAsyncCallbacks ? ' (async)' : ''}`,
				})
			} else {
				addLog({
					action: 'raw-paste',
					source: 'clipboard-read',
					prevented: false,
					detail: `${info.clipboardItems.length} clipboard item(s)${state.useAsyncCallbacks ? ' (async)' : ''}`,
				})
			}
		}

		if (state.useAsyncCallbacks) {
			delay(500).then(() => doThings())
		} else {
			doThings()
		}
		return false
	},
	async onBeforeCopyToClipboard({ content, operation, source }) {
		if (state.useAsyncCallbacks) await delay(500)
		if (state.disableCopy) {
			addLog({
				action: operation,
				source,
				prevented: true,
			})
			return false
		}
		addLog({
			action: operation,
			source,
			prevented: 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))
		const result = {
			...content,
			shapes: filtered,
			rootShapeIds: content.rootShapeIds.filter((id) => filteredIds.has(id)),
		}
		addLog({
			action: 'filter-copy',
			source: 'onBeforeCopyToClipboard',
			prevented: false,
			detail: `kept ${filtered.length}/${content.shapes.length} shapes`,
		})
		return result
	},
	async onBeforePasteFromClipboard({ content, source }) {
		if (state.useAsyncCallbacks) await delay(500)
		if (state.disablePaste) {
			addLog({ action: 'paste', source, prevented: true })
			return false
		}
		addLog({ action: 'paste', source, prevented: false })
		if (!state.filterRedOnPaste) return
		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))
		addLog({
			action: 'filter-paste',
			source: 'onBeforePasteFromClipboard',
			prevented: false,
			detail: `kept ${filtered.length}/${content.content.shapes.length} shapes`,
		})
		return {
			...content,
			content: {
				...content.content,
				shapes: filtered,
				rootShapeIds: content.content.rootShapeIds.filter((id) => filteredIds.has(id)),
			},
		}
	},
}

// [3]
;(window as any).__tldraw_clipboard_state = state
;(window as any).__tldraw_clipboard_updateState = updateState

function Controls() {
	useSyncExternalStore(subscribe, getSnapshot)

	const toggleCopy = useCallback(() => {
		updateState({ disableCopy: !state.disableCopy })
	}, [])

	const togglePaste = useCallback(() => {
		updateState({ disablePaste: !state.disablePaste })
	}, [])

	const toggleFilterCopy = useCallback(() => {
		updateState({ filterRedOnCopy: !state.filterRedOnCopy })
	}, [])

	const toggleFilterPaste = useCallback(() => {
		updateState({ filterRedOnPaste: !state.filterRedOnPaste })
	}, [])

	const toggleRawPaste = useCallback(() => {
		updateState({ handleRawPaste: !state.handleRawPaste })
	}, [])

	const toggleAsync = useCallback(() => {
		updateState({ useAsyncCallbacks: !state.useAsyncCallbacks })
	}, [])

	return (
		<div className="clipboard-events-panel">
			<div className="clipboard-events-controls">
				<label>
					<input type="checkbox" checked={state.disableCopy} onChange={toggleCopy} />
					Block copy/cut
				</label>
				<label>
					<input type="checkbox" checked={state.disablePaste} onChange={togglePaste} />
					Block paste
				</label>
				<label>
					<input type="checkbox" checked={state.filterRedOnCopy} onChange={toggleFilterCopy} />
					Filter red on copy
				</label>
				<label>
					<input type="checkbox" checked={state.filterRedOnPaste} onChange={toggleFilterPaste} />
					Filter red on paste
				</label>
				<label>
					<input type="checkbox" checked={state.handleRawPaste} onChange={toggleRawPaste} />
					Handle raw paste (take over)
				</label>
				<label>
					<input type="checkbox" checked={state.useAsyncCallbacks} onChange={toggleAsync} />
					Async callbacks (500ms delay)
				</label>
			</div>
			<div className="clipboard-events-log" data-testid="clipboard-log">
				{state.log.length === 0 && <span className="clipboard-events-empty">No events yet</span>}
				{state.log.map((entry, i) => (
					<div key={i} className="clipboard-events-entry" data-testid="clipboard-log-entry">
						<strong>{entry.action}</strong> via <em>{entry.source}</em>
						{entry.prevented ? ' (blocked)' : ''}
						{entry.detail ? ` — ${entry.detail}` : ''}
					</div>
				))}
			</div>
		</div>
	)
}

export default function ClipboardEventsExample() {
	return (
		<div className="tldraw__editor">
			<Tldraw
				options={options}
				onMount={(editor) => {
					;(window as any).editor = editor
				}}
				components={{ TopPanel: Controls }}
				persistenceKey="clipboard-events-example"
			/>
		</div>
	)
}

/*
[1]
The toggles live in a plain module-level object rather than React state. The clipboard
callbacks are defined once in a stable `options` object, so if they closed over React
state they'd see stale values. `useSyncExternalStore` lets the panel re-render when it
changes.

[2]
The `options` object must be stable across renders (changing it recreates the editor),
which is why it's module-level and reads from the shared state.

`onClipboardPasteRaw` runs first, before tldraw parses the clipboard. `source` is
`native-event` (paste event with a DataTransfer) or `clipboard-read` (ClipboardItem[]
from the async clipboard API). Return `false` to cancel tldraw's default handling for
that gesture. This one is synchronous: it must decide immediately whether to take over,
so the "async" toggle only delays the logging here.

`onBeforeCopyToClipboard` runs for both copy and cut; use `operation` to tell them
apart. Return a modified `TLContent` to change what's written, or `false` to cancel
(for cut, the selection is kept).

`onBeforePasteFromClipboard` runs when parsed content is about to be applied. Return a
modified `TLExternalContent` or `false` to cancel.

The two `onBefore*` hooks may return a Promise. The "Async callbacks" toggle adds a
500ms delay so you can check that a slow hook still works, including on Safari where
the clipboard write must stay within the user gesture.

[3]
State and updater are exposed on window for the e2e tests.
*/

Three options let you intercept clipboard operations. They run for both keyboard shortcuts and menu actions.

  • onClipboardPasteRaw runs first, before tldraw parses the clipboard. It receives the raw DataTransfer (source native-event) or ClipboardItem[] (source clipboard-read). Return false to cancel tldraw's default paste handling and deal with the data yourself.
  • onBeforeCopyToClipboard receives the serialized TLContent plus operation (copy or cut) and source (native or menu). Return a modified TLContent to change what's written, or false to cancel (for cut, the selection is kept).
  • onBeforePasteFromClipboard receives the parsed TLExternalContent about to be applied. Return a modified content object or false to cancel. It only fires for clipboard paste, not for file drops or programmatic putExternalContent calls.

Use the checkboxes in the top panel to block copy or paste, strip red shapes on copy or paste, take over raw paste, or add a 500ms delay to the hooks. The log below the controls shows each hook as it fires. Try drawing a red and a blue shape, then copying and pasting with different toggles on.

Is this page helpful?
Prev
External content sources
Next
Custom asset type