Skip to content

Commit 90d5d62

Browse files
ds300steveruizok
andauthored
feat(editor): add clipboard hooks to TldrawOptions (tldraw#8290)
Closes tldraw#7547 In order to let SDK consumers intercept and customize clipboard copy, cut, and paste from both keyboard shortcuts and app-invoked clipboard reads, this PR adds `TldrawOptions` hooks and wires them through `@tldraw/tldraw` clipboard handling. Integrators can filter or transform serialized content before it hits the clipboard, filter parsed paste payloads before shapes are created, or handle raw clipboard data before tldraw parses it (`native-event` `ClipboardEvent`/`DataTransfer` vs `clipboard-read` `ClipboardItem[]`). Keyboard paste invokes the raw hook synchronously on the `paste` event so `clipboardData` stays usable. This was requested by the Replit team, who need to filter certain shapes from clipboard operations and work with platform clipboard APIs. Previously, keyboard clipboard shortcuts could not be intercepted in a structured way. ### Change type - [x] `api` ### API changes - Added `TLClipboardWriteInfo` for copy/cut metadata: `operation` (`copy` | `cut`) and `source` (`native` | `menu`) - Added `TLClipboardPasteRawInfo` as a discriminated union: `source: 'native-event'` (`ClipboardEvent`, `clipboardData`, `point`) vs `source: 'clipboard-read'` (`clipboardItems`, `point`) - Added `TldrawOptions.onBeforeCopyToClipboard` to filter or transform `TLContent` before clipboard writes; return `false` to cancel and preserve selection on cut - Added `TldrawOptions.onBeforePasteFromClipboard` to filter or transform parsed `TLExternalContent` before placement; clipboard paste only (not drops); return `false` to cancel; `source` is `native-event` | `clipboard-read` - Added `TldrawOptions.onClipboardPasteRaw` to intercept raw clipboard data before tldraw parses it; return `false` to cancel tldraw handling for that gesture - Exported `handleNativeOrMenuCopy` from `@tldraw/tldraw` for custom clipboard write flows ### Test plan 1. Open the `Clipboard events` example at `/clipboard-events` 2. Copy/paste with keyboard shortcuts and verify the event log shows `copy` / `cut` with `native` and paste events with `native-event` 3. Toggle `Block copy/cut` and verify copy/cut are cancelled without deleting the selection on cut 4. Toggle `Block paste`, `Filter red on copy`, `Filter red on paste`, and `Handle raw paste (take over)` and verify the logged behavior matches the toggle state - [x] Unit tests - [ ] End to end tests - [x] Typecheck ### Code changes | Section | LOC change | | --------------- | ---------- | | Core code | +358 / -102 | | Tests | +511 / -0 | | Automated files | +45 / -1 | | Documentation | +329 / -0 | ### Release notes - Add `onBeforeCopyToClipboard` and `onBeforePasteFromClipboard` to `TldrawOptions` for filtering or transforming clipboard content before copy/cut and after paste is parsed. - Add `onClipboardPasteRaw` for handling raw clipboard data before tldraw's default paste pipeline. - Add paste source names `native-event` and `clipboard-read` to distinguish DOM paste events from explicit Clipboard API reads. - Export `handleNativeOrMenuCopy` from the `tldraw` package for custom clipboard flows. --------- Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
1 parent 75bb66f commit 90d5d62

20 files changed

Lines changed: 1245 additions & 106 deletions

File tree

apps/dotcom/client/src/tla/utils/FeatureFlagPoller.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
22
import type { FeatureFlags } from './FeatureFlagPoller'
33

44
const mockFetch = vi.fn()
5-
vi.mock('tldraw', async () => {
6-
const actual = await vi.importActual<typeof import('tldraw')>('tldraw')
7-
return { ...actual, fetch: (...args: any[]) => mockFetch(...args) }
5+
vi.mock('tldraw', () => {
6+
return { fetch: (...args: any[]) => mockFetch(...args) }
87
})
98

109
function makeFlags(overrides: Partial<FeatureFlags> = {}): FeatureFlags {
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
import { expect } from '@playwright/test'
2+
import { type Editor } from 'tldraw'
3+
import test from '../fixtures/fixtures'
4+
import { sleep } from '../shared-e2e'
5+
6+
declare const editor: Editor
7+
8+
test.describe('clipboard event callbacks', () => {
9+
test.beforeEach(async ({ page, context }) => {
10+
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
11+
await page.goto('http://localhost:5420/clipboard-events/full')
12+
await page.waitForSelector('.tl-canvas')
13+
await page.evaluate(() => {
14+
editor.user.updateUserPreferences({ animationSpeed: 0 })
15+
})
16+
})
17+
18+
test.beforeEach(async ({ page }) => {
19+
await page.evaluate(() => {
20+
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
21+
;(window as any).__tldraw_clipboard_log = []
22+
;(window as any).__tldraw_clipboard_state.disableCopy = false
23+
;(window as any).__tldraw_clipboard_state.disablePaste = false
24+
;(window as any).__tldraw_clipboard_state.filterRedOnCopy = false
25+
;(window as any).__tldraw_clipboard_state.filterRedOnPaste = false
26+
;(window as any).__tldraw_clipboard_state.handleRawPaste = false
27+
})
28+
29+
await page.evaluate(() => {
30+
editor.createShapes([{ type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } }])
31+
editor.selectAll()
32+
})
33+
await page.locator('.tl-container').focus()
34+
})
35+
36+
test('onBeforeCopyToClipboard runs on keyboard copy', async ({ page, isMac }) => {
37+
const modifier = isMac ? 'Meta' : 'Control'
38+
await page.keyboard.down(modifier)
39+
await page.keyboard.press('KeyC')
40+
await page.keyboard.up(modifier)
41+
await sleep(100)
42+
43+
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
44+
expect(log).toHaveLength(1)
45+
expect(log[0]).toMatchObject({ action: 'copy', source: 'native', prevented: false })
46+
})
47+
48+
test('onBeforeCopyToClipboard runs on keyboard cut', async ({ page, isMac }) => {
49+
const modifier = isMac ? 'Meta' : 'Control'
50+
await page.keyboard.down(modifier)
51+
await page.keyboard.press('KeyX')
52+
await page.keyboard.up(modifier)
53+
await sleep(100)
54+
55+
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
56+
expect(log).toHaveLength(1)
57+
expect(log[0]).toMatchObject({ action: 'cut', source: 'native', prevented: false })
58+
})
59+
60+
test('onBeforePasteFromClipboard runs on keyboard paste', async ({ page, isMac }) => {
61+
const modifier = isMac ? 'Meta' : 'Control'
62+
await page.keyboard.down(modifier)
63+
await page.keyboard.press('KeyC')
64+
await page.keyboard.up(modifier)
65+
await sleep(100)
66+
67+
await page.keyboard.down(modifier)
68+
await page.keyboard.press('KeyV')
69+
await page.keyboard.up(modifier)
70+
await sleep(100)
71+
72+
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
73+
const pasteEntry = log.find((e: any) => e.action === 'paste')
74+
expect(pasteEntry).toMatchObject({
75+
action: 'paste',
76+
source: 'native-event',
77+
prevented: false,
78+
})
79+
})
80+
81+
test('onBeforeCopyToClipboard can block copy', async ({ page, isMac }) => {
82+
await page.evaluate(() => {
83+
;(window as any).__tldraw_clipboard_state.disableCopy = true
84+
})
85+
86+
const modifier = isMac ? 'Meta' : 'Control'
87+
await page.keyboard.down(modifier)
88+
await page.keyboard.press('KeyC')
89+
await page.keyboard.up(modifier)
90+
await sleep(100)
91+
92+
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
93+
expect(log).toHaveLength(1)
94+
expect(log[0]).toMatchObject({ action: 'copy', source: 'native', prevented: true })
95+
})
96+
97+
test('onBeforePasteFromClipboard can block paste', async ({ page, isMac }) => {
98+
const modifier = isMac ? 'Meta' : 'Control'
99+
100+
// Copy normally first
101+
await page.keyboard.down(modifier)
102+
await page.keyboard.press('KeyC')
103+
await page.keyboard.up(modifier)
104+
await sleep(100)
105+
106+
// Now block paste
107+
await page.evaluate(() => {
108+
;(window as any).__tldraw_clipboard_state.disablePaste = true
109+
;(window as any).__tldraw_clipboard_log = []
110+
})
111+
112+
await page.keyboard.down(modifier)
113+
await page.keyboard.press('KeyV')
114+
await page.keyboard.up(modifier)
115+
await sleep(200)
116+
117+
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
118+
const pasteEntry = log.find((e: any) => e.action === 'paste')
119+
expect(pasteEntry).toMatchObject({
120+
action: 'paste',
121+
source: 'native-event',
122+
prevented: true,
123+
})
124+
125+
const shapeCount = await page.evaluate(() => editor.getCurrentPageShapes().length)
126+
expect(shapeCount).toBe(1)
127+
})
128+
129+
test('onBeforeCopyToClipboard can block cut', async ({ page, isMac }) => {
130+
await page.evaluate(() => {
131+
;(window as any).__tldraw_clipboard_state.disableCopy = true
132+
})
133+
134+
const modifier = isMac ? 'Meta' : 'Control'
135+
await page.keyboard.down(modifier)
136+
await page.keyboard.press('KeyX')
137+
await page.keyboard.up(modifier)
138+
await sleep(100)
139+
140+
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
141+
expect(log).toHaveLength(1)
142+
expect(log[0]).toMatchObject({ action: 'cut', source: 'native', prevented: true })
143+
144+
const shapeCount = await page.evaluate(() => editor.getCurrentPageShapes().length)
145+
expect(shapeCount).toBe(1)
146+
})
147+
148+
test('onBeforeCopyToClipboard can filter shapes from copy', async ({ page, isMac }) => {
149+
// Create a red shape and a blue shape
150+
await page.evaluate(() => {
151+
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
152+
editor.createShapes([
153+
{ type: 'geo', x: 100, y: 100, props: { w: 100, h: 100, color: 'red' } },
154+
{ type: 'geo', x: 300, y: 100, props: { w: 100, h: 100, color: 'blue' } },
155+
])
156+
editor.selectAll()
157+
;(window as any).__tldraw_clipboard_log = []
158+
;(window as any).__tldraw_clipboard_state.filterRedOnCopy = true
159+
})
160+
161+
const modifier = isMac ? 'Meta' : 'Control'
162+
163+
// Copy (should filter out the red shape)
164+
await page.keyboard.down(modifier)
165+
await page.keyboard.press('KeyC')
166+
await page.keyboard.up(modifier)
167+
await sleep(200)
168+
169+
// Delete all shapes, then paste
170+
await page.evaluate(() => {
171+
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
172+
})
173+
174+
await page.keyboard.down(modifier)
175+
await page.keyboard.press('KeyV')
176+
await page.keyboard.up(modifier)
177+
await sleep(200)
178+
179+
// Only the blue shape should have been pasted
180+
const shapes = await page.evaluate(() =>
181+
editor.getCurrentPageShapes().map((s: any) => s.props.color)
182+
)
183+
expect(shapes).toEqual(['blue'])
184+
185+
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
186+
const filterEntry = log.find((e: any) => e.action === 'filter-copy')
187+
expect(filterEntry).toMatchObject({
188+
action: 'filter-copy',
189+
detail: 'kept 1/2 shapes',
190+
})
191+
})
192+
193+
test('onBeforePasteFromClipboard can filter shapes on paste', async ({ page, isMac }) => {
194+
// Create a red shape and a blue shape, then copy them normally
195+
await page.evaluate(() => {
196+
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
197+
editor.createShapes([
198+
{ type: 'geo', x: 100, y: 100, props: { w: 100, h: 100, color: 'red' } },
199+
{ type: 'geo', x: 300, y: 100, props: { w: 100, h: 100, color: 'blue' } },
200+
])
201+
editor.selectAll()
202+
;(window as any).__tldraw_clipboard_log = []
203+
})
204+
205+
const modifier = isMac ? 'Meta' : 'Control'
206+
207+
// Copy both shapes normally
208+
await page.keyboard.down(modifier)
209+
await page.keyboard.press('KeyC')
210+
await page.keyboard.up(modifier)
211+
await sleep(200)
212+
213+
// Enable paste filter and delete all shapes
214+
await page.evaluate(() => {
215+
;(window as any).__tldraw_clipboard_state.filterRedOnPaste = true
216+
;(window as any).__tldraw_clipboard_log = []
217+
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
218+
})
219+
220+
// Paste (should filter out the red shape on the paste side)
221+
await page.keyboard.down(modifier)
222+
await page.keyboard.press('KeyV')
223+
await page.keyboard.up(modifier)
224+
await sleep(200)
225+
226+
const shapes = await page.evaluate(() =>
227+
editor.getCurrentPageShapes().map((s: any) => s.props.color)
228+
)
229+
expect(shapes).toEqual(['blue'])
230+
231+
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
232+
const filterEntry = log.find((e: any) => e.action === 'filter-paste')
233+
expect(filterEntry).toMatchObject({
234+
action: 'filter-paste',
235+
detail: 'kept 1/2 shapes',
236+
})
237+
})
238+
239+
test('onClipboardPasteRaw runs before parsed paste and can take over (keyboard)', async ({
240+
page,
241+
isMac,
242+
}) => {
243+
await page.evaluate(() => {
244+
;(window as any).__tldraw_clipboard_state.handleRawPaste = true
245+
;(window as any).__tldraw_clipboard_log = []
246+
})
247+
248+
const modifier = isMac ? 'Meta' : 'Control'
249+
await page.keyboard.down(modifier)
250+
await page.keyboard.press('KeyC')
251+
await page.keyboard.up(modifier)
252+
await sleep(100)
253+
254+
await page.keyboard.down(modifier)
255+
await page.keyboard.press('KeyV')
256+
await page.keyboard.up(modifier)
257+
await sleep(200)
258+
259+
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
260+
const rawEntry = log.find((e: any) => e.action === 'raw-paste')
261+
expect(rawEntry).toMatchObject({ source: 'native-event' })
262+
expect(rawEntry.detail).toContain('string')
263+
264+
const parsedPaste = log.find((e: any) => e.action === 'paste' && e.source === 'native-event')
265+
expect(parsedPaste).toBeUndefined()
266+
267+
const shapeCount = await page.evaluate(() => editor.getCurrentPageShapes().length)
268+
expect(shapeCount).toBe(1)
269+
})
270+
})

0 commit comments

Comments
 (0)