tldraw 5.3.2 → 5.4.0-canary.042da660fa66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/DOCS.md +2822 -3224
  2. package/RELEASE_NOTES.md +11 -39
  3. package/dist-cjs/index.d.ts +264 -275
  4. package/dist-cjs/index.js +1 -1
  5. package/dist-cjs/lib/defaultExternalContentHandlers.js +2 -2
  6. package/dist-cjs/lib/defaultExternalContentHandlers.js.map +2 -2
  7. package/dist-cjs/lib/shapes/draw/toolStates/Drawing.js +2 -2
  8. package/dist-cjs/lib/shapes/draw/toolStates/Drawing.js.map +2 -2
  9. package/dist-cjs/lib/shapes/frame/frameHelpers.js +3 -0
  10. package/dist-cjs/lib/shapes/frame/frameHelpers.js.map +2 -2
  11. package/dist-cjs/lib/shapes/video/VideoShapeUtil.js +3 -2
  12. package/dist-cjs/lib/shapes/video/VideoShapeUtil.js.map +2 -2
  13. package/dist-cjs/lib/ui/TldrawUi.js +2 -3
  14. package/dist-cjs/lib/ui/TldrawUi.js.map +2 -2
  15. package/dist-cjs/lib/ui/components/ActionsMenu/DefaultActionsMenu.js +0 -3
  16. package/dist-cjs/lib/ui/components/ActionsMenu/DefaultActionsMenu.js.map +2 -2
  17. package/dist-cjs/lib/ui/components/ContextMenu/DefaultContextMenuContent.js +1 -1
  18. package/dist-cjs/lib/ui/components/ContextMenu/DefaultContextMenuContent.js.map +2 -2
  19. package/dist-cjs/lib/ui/components/Dialogs.js +8 -0
  20. package/dist-cjs/lib/ui/components/Dialogs.js.map +2 -2
  21. package/dist-cjs/lib/ui/components/SharePanel/DefaultPeopleMenuItem.js +1 -2
  22. package/dist-cjs/lib/ui/components/SharePanel/DefaultPeopleMenuItem.js.map +2 -2
  23. package/dist-cjs/lib/ui/context/actions.js +0 -1
  24. package/dist-cjs/lib/ui/context/actions.js.map +2 -2
  25. package/dist-cjs/lib/ui/hooks/useClipboardEvents.js +2 -0
  26. package/dist-cjs/lib/ui/hooks/useClipboardEvents.js.map +2 -2
  27. package/dist-cjs/lib/ui/hooks/useFlatten.js +42 -23
  28. package/dist-cjs/lib/ui/hooks/useFlatten.js.map +2 -2
  29. package/dist-cjs/lib/ui/hooks/useKeyboardShortcuts.js +5 -17
  30. package/dist-cjs/lib/ui/hooks/useKeyboardShortcuts.js.map +2 -2
  31. package/dist-cjs/lib/ui/kbd-utils.js +22 -3
  32. package/dist-cjs/lib/ui/kbd-utils.js.map +2 -2
  33. package/dist-cjs/lib/ui/version.js +3 -3
  34. package/dist-cjs/lib/ui/version.js.map +1 -1
  35. package/dist-esm/index.d.mts +264 -275
  36. package/dist-esm/index.mjs +1 -1
  37. package/dist-esm/lib/defaultExternalContentHandlers.mjs +2 -2
  38. package/dist-esm/lib/defaultExternalContentHandlers.mjs.map +2 -2
  39. package/dist-esm/lib/shapes/draw/toolStates/Drawing.mjs +2 -2
  40. package/dist-esm/lib/shapes/draw/toolStates/Drawing.mjs.map +2 -2
  41. package/dist-esm/lib/shapes/frame/frameHelpers.mjs +3 -0
  42. package/dist-esm/lib/shapes/frame/frameHelpers.mjs.map +2 -2
  43. package/dist-esm/lib/shapes/video/VideoShapeUtil.mjs +3 -2
  44. package/dist-esm/lib/shapes/video/VideoShapeUtil.mjs.map +2 -2
  45. package/dist-esm/lib/ui/TldrawUi.mjs +2 -3
  46. package/dist-esm/lib/ui/TldrawUi.mjs.map +2 -2
  47. package/dist-esm/lib/ui/components/ActionsMenu/DefaultActionsMenu.mjs +2 -5
  48. package/dist-esm/lib/ui/components/ActionsMenu/DefaultActionsMenu.mjs.map +2 -2
  49. package/dist-esm/lib/ui/components/ContextMenu/DefaultContextMenuContent.mjs +1 -1
  50. package/dist-esm/lib/ui/components/ContextMenu/DefaultContextMenuContent.mjs.map +2 -2
  51. package/dist-esm/lib/ui/components/Dialogs.mjs +9 -1
  52. package/dist-esm/lib/ui/components/Dialogs.mjs.map +2 -2
  53. package/dist-esm/lib/ui/components/SharePanel/DefaultPeopleMenuItem.mjs +1 -2
  54. package/dist-esm/lib/ui/components/SharePanel/DefaultPeopleMenuItem.mjs.map +2 -2
  55. package/dist-esm/lib/ui/context/actions.mjs +0 -1
  56. package/dist-esm/lib/ui/context/actions.mjs.map +2 -2
  57. package/dist-esm/lib/ui/hooks/useClipboardEvents.mjs +2 -0
  58. package/dist-esm/lib/ui/hooks/useClipboardEvents.mjs.map +2 -2
  59. package/dist-esm/lib/ui/hooks/useFlatten.mjs +42 -23
  60. package/dist-esm/lib/ui/hooks/useFlatten.mjs.map +2 -2
  61. package/dist-esm/lib/ui/hooks/useKeyboardShortcuts.mjs +5 -17
  62. package/dist-esm/lib/ui/hooks/useKeyboardShortcuts.mjs.map +2 -2
  63. package/dist-esm/lib/ui/kbd-utils.mjs +22 -3
  64. package/dist-esm/lib/ui/kbd-utils.mjs.map +2 -2
  65. package/dist-esm/lib/ui/version.mjs +3 -3
  66. package/dist-esm/lib/ui/version.mjs.map +1 -1
  67. package/package.json +6 -6
  68. package/src/lib/defaultExternalContentHandlers.ts +2 -2
  69. package/src/lib/shapes/draw/DrawShapeTool.test.ts +19 -0
  70. package/src/lib/shapes/draw/toolStates/Drawing.ts +2 -2
  71. package/src/lib/shapes/frame/frameHelpers.ts +10 -1
  72. package/src/lib/shapes/video/VideoShapeUtil.tsx +4 -2
  73. package/src/lib/ui/TldrawUi.tsx +1 -2
  74. package/src/lib/ui/components/ActionsMenu/DefaultActionsMenu.tsx +2 -6
  75. package/src/lib/ui/components/ContextMenu/DefaultContextMenuContent.tsx +5 -1
  76. package/src/lib/ui/components/Dialogs.tsx +17 -1
  77. package/src/lib/ui/components/SharePanel/DefaultPeopleMenuItem.tsx +2 -3
  78. package/src/lib/ui/context/actions.tsx +0 -1
  79. package/src/lib/ui/hooks/useClipboardEvents.ts +2 -0
  80. package/src/lib/ui/hooks/useFlatten.ts +56 -23
  81. package/src/lib/ui/hooks/useKeyboardShortcuts.ts +5 -23
  82. package/src/lib/ui/kbd-utils.test.ts +11 -0
  83. package/src/lib/ui/kbd-utils.ts +27 -4
  84. package/src/lib/ui/version.ts +3 -3
  85. package/src/lib/ui.css +7 -16
  86. package/src/test/TldrawEditor.test.tsx +3 -0
  87. package/src/test/VideoShapeUtil.test.tsx +126 -0
  88. package/src/test/bookmark-shapes.test.ts +110 -0
  89. package/src/test/commands/alignShapes.test.tsx +19 -10
  90. package/src/test/commands/clipboard.test.ts +36 -1
  91. package/src/test/flatten.test.ts +135 -0
  92. package/src/test/modifiers.test.ts +104 -0
  93. package/tldraw.css +7 -16
package/src/lib/ui.css CHANGED
@@ -2104,7 +2104,8 @@ tldraw? probably.
2104
2104
  align-items: center;
2105
2105
  }
2106
2106
 
2107
- .tlui-people-menu__user__color {
2107
+ .tlui-people-menu__user__color,
2108
+ .tlui-people-menu__user__edit {
2108
2109
  flex-shrink: 0;
2109
2110
  }
2110
2111
 
@@ -2150,7 +2151,8 @@ tldraw? probably.
2150
2151
  }
2151
2152
 
2152
2153
  .tlui-people-menu__item {
2153
- position: relative;
2154
+ display: flex;
2155
+ align-items: center;
2154
2156
  }
2155
2157
 
2156
2158
  .tlui-people-menu__item:last-of-type .tlui-button__menu {
@@ -2158,7 +2160,8 @@ tldraw? probably.
2158
2160
  }
2159
2161
 
2160
2162
  .tlui-people-menu__item__button {
2161
- padding: 0 11px;
2163
+ padding-inline-start: 12px;
2164
+ padding-inline-end: 0px;
2162
2165
  overflow: hidden;
2163
2166
  }
2164
2167
 
@@ -2168,7 +2171,7 @@ tldraw? probably.
2168
2171
  align-items: auto;
2169
2172
  justify-content: flex-start;
2170
2173
  flex-grow: 2;
2171
- gap: 11px;
2174
+ gap: 12px;
2172
2175
  }
2173
2176
 
2174
2177
  .tlui-people-menu__name {
@@ -2179,18 +2182,9 @@ tldraw? probably.
2179
2182
  }
2180
2183
 
2181
2184
  .tlui-people-menu__item__follow {
2182
- position: absolute;
2183
- top: 0px;
2184
- inset-inline-end: 0px;
2185
- max-width: 40px;
2186
2185
  flex-shrink: 0;
2187
2186
  }
2188
2187
 
2189
- .tlui-people-menu__item[data-follow='true'],
2190
- .tlui-people-menu__item:has(.tlui-button:focus-visible) {
2191
- padding-inline-end: 36px;
2192
- }
2193
-
2194
2188
  .tlui-people-menu__item[data-follow='true'] .tlui-people-menu__item__follow,
2195
2189
  .tlui-people-menu__item:has(.tlui-button:focus-visible) .tlui-people-menu__item__follow {
2196
2190
  opacity: 1;
@@ -2201,9 +2195,6 @@ tldraw? probably.
2201
2195
  opacity: 0;
2202
2196
  }
2203
2197
 
2204
- .tlui-people-menu__item:hover {
2205
- padding-right: 36px;
2206
- }
2207
2198
  .tlui-people-menu__item:hover .tlui-people-menu__item__follow {
2208
2199
  opacity: 1;
2209
2200
  }
@@ -13,6 +13,7 @@ import {
13
13
  createTLStore,
14
14
  noop,
15
15
  react,
16
+ tleditors,
16
17
  toRichText,
17
18
  } from '@tldraw/editor'
18
19
  import { StrictMode } from 'react'
@@ -181,6 +182,7 @@ describe('<TldrawEditor />', () => {
181
182
 
182
183
  // mounted after render:
183
184
  expect(editor.getIsMounted()).toBe(true)
185
+ expect(tleditors.getMounted()).toEqual([editor])
184
186
 
185
187
  // getIsMounted is reactive:
186
188
  const mountedValues: boolean[] = []
@@ -191,6 +193,7 @@ describe('<TldrawEditor />', () => {
191
193
  // the unmount event fired and the mounted state flipped back to false:
192
194
  expect(onUnmount).toHaveBeenCalledTimes(1)
193
195
  expect(editor.getIsMounted()).toBe(false)
196
+ expect(tleditors.getMounted()).toEqual([])
194
197
  expect(mountedValues).toEqual([true, false])
195
198
 
196
199
  stop()
@@ -0,0 +1,126 @@
1
+ import { act, fireEvent } from '@testing-library/react'
2
+ import { AssetRecordType, Editor, TLAssetId, TLShapeId, createShapeId } from '@tldraw/editor'
3
+ import { Tldraw } from '../lib/Tldraw'
4
+ import { renderTldrawComponentWithEditor } from './testutils/renderTldrawComponent'
5
+
6
+ const LOADING_BACKGROUND = 'var(--tl-color-low)'
7
+ const LOADING_BORDER = '1px solid var(--tl-color-low-border)'
8
+
9
+ let editor: Editor
10
+ let videoShapeId: TLShapeId
11
+ let videoAssetId: TLAssetId
12
+
13
+ function getShapeContainer() {
14
+ const container = document.getElementById(videoShapeId)
15
+ expect(container).toBeTruthy()
16
+ return container!
17
+ }
18
+
19
+ beforeEach(async () => {
20
+ videoShapeId = createShapeId('video')
21
+ videoAssetId = AssetRecordType.createId('videoAsset')
22
+
23
+ const result = await renderTldrawComponentWithEditor((onMount) => <Tldraw onMount={onMount} />, {
24
+ waitForPatterns: false,
25
+ })
26
+ editor = result.editor
27
+ })
28
+
29
+ describe('VideoShapeUtil loading state', () => {
30
+ it('shows the loading plate when the asset record is missing', async () => {
31
+ await act(async () => {
32
+ editor.createShapes([
33
+ { id: videoShapeId, type: 'video', x: 0, y: 0, props: { w: 100, h: 100 } },
34
+ ])
35
+ })
36
+
37
+ const container = getShapeContainer()
38
+ expect(container.style.backgroundColor).toBe(LOADING_BACKGROUND)
39
+ expect(container.style.border).toBe(LOADING_BORDER)
40
+ })
41
+
42
+ it('shows the loading plate while the asset is uploading (no src)', async () => {
43
+ await act(async () => {
44
+ editor.createAssets([
45
+ {
46
+ id: videoAssetId,
47
+ type: 'video',
48
+ typeName: 'asset',
49
+ props: {
50
+ w: 100,
51
+ h: 100,
52
+ name: 'video.mp4',
53
+ isAnimated: true,
54
+ mimeType: 'video/mp4',
55
+ src: '',
56
+ },
57
+ meta: {},
58
+ },
59
+ ])
60
+ editor.createShapes([
61
+ {
62
+ id: videoShapeId,
63
+ type: 'video',
64
+ x: 0,
65
+ y: 0,
66
+ props: { w: 100, h: 100, assetId: videoAssetId },
67
+ },
68
+ ])
69
+ })
70
+
71
+ const container = getShapeContainer()
72
+ expect(container.style.backgroundColor).toBe(LOADING_BACKGROUND)
73
+ expect(container.style.border).toBe(LOADING_BORDER)
74
+ })
75
+
76
+ it('shows the loading plate until the video loads, then goes transparent', async () => {
77
+ await act(async () => {
78
+ editor.createAssets([
79
+ {
80
+ id: videoAssetId,
81
+ type: 'video',
82
+ typeName: 'asset',
83
+ props: {
84
+ w: 100,
85
+ h: 100,
86
+ name: 'video.mp4',
87
+ isAnimated: true,
88
+ mimeType: 'video/mp4',
89
+ src: 'http://localhost/video.mp4',
90
+ },
91
+ meta: {},
92
+ },
93
+ ])
94
+ editor.createShapes([
95
+ {
96
+ id: videoShapeId,
97
+ type: 'video',
98
+ x: 0,
99
+ y: 0,
100
+ props: { w: 100, h: 100, assetId: videoAssetId },
101
+ },
102
+ ])
103
+ })
104
+
105
+ // Wait for the async asset URL resolution so the <video> element renders
106
+ await act(async () => {
107
+ await new Promise((resolve) => setTimeout(resolve, 0))
108
+ })
109
+
110
+ const container = getShapeContainer()
111
+ const video = container.querySelector('video')
112
+ expect(video).toBeTruthy()
113
+
114
+ // The video element exists but hasn't fired loadeddata yet
115
+ expect(container.style.backgroundColor).toBe(LOADING_BACKGROUND)
116
+ expect(container.style.border).toBe(LOADING_BORDER)
117
+
118
+ await act(async () => {
119
+ fireEvent.loadedData(video!)
120
+ })
121
+
122
+ expect(container.style.backgroundColor).toBe('transparent')
123
+ // jsdom doesn't round-trip the `border: none` shorthand, so check the style directly
124
+ expect(container.style.borderStyle).toBe('none')
125
+ })
126
+ })
@@ -1,5 +1,6 @@
1
1
  import { AssetRecordType, TLBookmarkShape, createShapeId, getHashForString } from '@tldraw/editor'
2
2
  import { vi } from 'vitest'
3
+ import { defaultHandleExternalUrlAsset } from '../lib/defaultExternalContentHandlers'
3
4
  import {
4
5
  createBookmarkFromUrl,
5
6
  getBookmarkShapeHeight,
@@ -482,3 +483,112 @@ describe('createBookmarkFromUrl', () => {
482
483
  expect(editor.getShapeGeometry(redone.id).bounds.height).toBe(shortHeight)
483
484
  })
484
485
  })
486
+
487
+ describe('defaultHandleExternalUrlAsset', () => {
488
+ const url = 'https://example.com/some/page'
489
+
490
+ let opts: any
491
+ let fetchMock: ReturnType<typeof vi.fn>
492
+
493
+ beforeEach(() => {
494
+ opts = {
495
+ toasts: { addToast: vi.fn(), removeToast: vi.fn(), clearToasts: vi.fn() },
496
+ msg: (id: string) => id,
497
+ }
498
+ })
499
+
500
+ afterEach(() => {
501
+ vi.unstubAllGlobals()
502
+ })
503
+
504
+ function mockResponseBody(html: string) {
505
+ fetchMock = vi.fn().mockResolvedValue({ text: async () => html })
506
+ vi.stubGlobal('fetch', fetchMock)
507
+ }
508
+
509
+ function mockPageHead(head: string) {
510
+ mockResponseBody(`<html><head>${head}</head><body></body></html>`)
511
+ }
512
+
513
+ it('leaves the image and favicon empty when the page provides neither', async () => {
514
+ mockPageHead('<title>Example</title>')
515
+
516
+ const asset = await defaultHandleExternalUrlAsset(editor, { type: 'url', url }, opts)
517
+
518
+ expect(asset.props.image).toBe('')
519
+ expect(asset.props.favicon).toBe('')
520
+ })
521
+
522
+ it('resolves relative image and favicon urls against the page', async () => {
523
+ mockPageHead(
524
+ '<meta property="og:image" content="/img/preview.png" />' +
525
+ '<link rel="icon" href="favicon.ico" />'
526
+ )
527
+
528
+ const asset = await defaultHandleExternalUrlAsset(editor, { type: 'url', url }, opts)
529
+
530
+ expect(asset.props.image).toBe('https://example.com/img/preview.png')
531
+ expect(asset.props.favicon).toBe('https://example.com/some/favicon.ico')
532
+ })
533
+
534
+ it('leaves absolute image urls alone', async () => {
535
+ mockPageHead('<meta property="og:image" content="https://cdn.example.com/preview.png" />')
536
+
537
+ const asset = await defaultHandleExternalUrlAsset(editor, { type: 'url', url }, opts)
538
+
539
+ expect(asset.props.image).toBe('https://cdn.example.com/preview.png')
540
+ })
541
+
542
+ it('toasts and returns a blank bookmark when the fetch throws', async () => {
543
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
544
+ fetchMock = vi.fn().mockRejectedValue(new Error('CORS'))
545
+ vi.stubGlobal('fetch', fetchMock)
546
+
547
+ const asset = await defaultHandleExternalUrlAsset(editor, { type: 'url', url }, opts)
548
+
549
+ expect(opts.toasts.addToast).toHaveBeenCalledWith(
550
+ expect.objectContaining({ severity: 'error' })
551
+ )
552
+ expect(asset.props.src).toBe(url)
553
+ expect(asset.props.image).toBe('')
554
+ expect(asset.props.title).toBe('')
555
+
556
+ consoleSpy.mockRestore()
557
+ })
558
+
559
+ // Pins behaviour we'd like to change, so that changing it is deliberate.
560
+ describe('behaviour to revisit', () => {
561
+ it('extracts nothing at all from a cross-origin page', async () => {
562
+ // `mode: 'no-cors'` makes the response opaque cross-origin, and an
563
+ // opaque body always reads as ''.
564
+ mockResponseBody('')
565
+
566
+ const asset = await defaultHandleExternalUrlAsset(editor, { type: 'url', url }, opts)
567
+
568
+ expect(fetchMock).toHaveBeenCalledWith(url, expect.objectContaining({ mode: 'no-cors' }))
569
+ expect(asset.props.image).toBe('')
570
+ expect(asset.props.favicon).toBe('')
571
+ expect(asset.props.description).toBe('')
572
+ expect(asset.props.title).toBe(url)
573
+ expect(opts.toasts.addToast).not.toHaveBeenCalled()
574
+ })
575
+
576
+ it('ignores twitter card tags, og:image:secure_url and <title>', async () => {
577
+ mockPageHead(
578
+ '<title>Example</title>' +
579
+ '<meta property="og:image:secure_url" content="https://cdn.example.com/secure.png" />' +
580
+ '<meta name="twitter:image" content="https://cdn.example.com/twitter.png" />' +
581
+ '<meta name="twitter:title" content="Example, from twitter" />' +
582
+ '<meta name="description" content="A plain meta description" />'
583
+ )
584
+
585
+ const asset = await defaultHandleExternalUrlAsset(editor, { type: 'url', url }, opts)
586
+
587
+ // cloudflare-workers-unfurl reads all four of these; this handler
588
+ // reads only the plain `og:` tags.
589
+ expect(asset.props.image).toBe('')
590
+ expect(asset.props.description).toBe('')
591
+ expect(asset.props.title).toBe(url)
592
+ })
593
+ })
594
+ })
@@ -38,14 +38,14 @@ describe('when less than two shapes are selected', () => {
38
38
  describe('when multiple shapes are selected', () => {
39
39
  it('does, undoes and redoes command', () => {
40
40
  editor.markHistoryStoppingPoint('align')
41
- editor.alignShapes(editor.getSelectedShapeIds(), 'top')
41
+ editor.alignShapes(editor.getSelectedShapeIds(), 'center')
42
42
  vi.advanceTimersByTime(1000)
43
43
 
44
- editor.expectShapeToMatch({ id: ids.boxB, y: 0 })
44
+ editor.expectShapeToMatch({ id: ids.boxB, x: 225, y: 225 })
45
45
  editor.undo()
46
- editor.expectShapeToMatch({ id: ids.boxB, y: 100 })
46
+ editor.expectShapeToMatch({ id: ids.boxB, x: 100, y: 100 })
47
47
  editor.redo()
48
- editor.expectShapeToMatch({ id: ids.boxB, y: 0 })
48
+ editor.expectShapeToMatch({ id: ids.boxB, x: 225, y: 225 })
49
49
  })
50
50
 
51
51
  it('aligns top', () => {
@@ -114,28 +114,37 @@ describe('when multiple shapes are selected', () => {
114
114
  )
115
115
  })
116
116
 
117
+ it('aligns center on both axes', () => {
118
+ editor.alignShapes(editor.getSelectedShapeIds(), 'center')
119
+ vi.advanceTimersByTime(1000)
120
+
121
+ editor.expectShapeToMatch(
122
+ { id: ids.boxA, x: 200, y: 200 },
123
+ { id: ids.boxB, x: 225, y: 225 },
124
+ { id: ids.boxC, x: 200, y: 200 }
125
+ )
126
+ })
127
+
117
128
  it('aligns center, when shapes are rotated', () => {
118
129
  editor.updateShapes([
119
130
  {
120
131
  id: ids.boxA,
121
132
  type: 'geo',
122
- rotation: PI,
133
+ rotation: 0.2,
123
134
  },
124
135
  {
125
136
  id: ids.boxB,
126
137
  type: 'geo',
127
- rotation: PI,
138
+ rotation: 0.4,
128
139
  },
129
140
  {
130
141
  id: ids.boxC,
131
142
  type: 'geo',
132
- rotation: PI,
143
+ rotation: 0.6,
133
144
  },
134
145
  ])
135
146
 
136
- editor.alignShapes(editor.getSelectedShapeIds(), 'center-vertical')
137
- vi.advanceTimersByTime(1000)
138
- editor.alignShapes(editor.getSelectedShapeIds(), 'center-horizontal')
147
+ editor.alignShapes(editor.getSelectedShapeIds(), 'center')
139
148
  vi.advanceTimersByTime(1000)
140
149
 
141
150
  const commonBounds = Box.Common([
@@ -1,6 +1,9 @@
1
- import { createShapeId, TLArrowShape } from '@tldraw/editor'
1
+ import { act, renderHook } from '@testing-library/react'
2
+ import { createShapeId, EditorContext, TLArrowShape } from '@tldraw/editor'
3
+ import { createElement } from 'react'
2
4
  import { vi } from 'vitest'
3
5
  import { getArrowBindings } from '../../lib/shapes/arrow/shared'
6
+ import { useMenuClipboardEvents } from '../../lib/ui/hooks/useClipboardEvents'
4
7
  import { TestEditor } from '../TestEditor'
5
8
 
6
9
  let editor: TestEditor
@@ -481,3 +484,35 @@ describe('When copying and pasting', () => {
481
484
  expect(pastedPoint).toMatchObject({ x: 150, y: 150 }) // center of group
482
485
  })
483
486
  })
487
+
488
+ describe('When cutting', () => {
489
+ function renderCut() {
490
+ const { result } = renderHook(() => useMenuClipboardEvents(), {
491
+ wrapper: ({ children }) => createElement(EditorContext.Provider, { value: editor }, children),
492
+ })
493
+ return result.current.cut
494
+ }
495
+
496
+ it('marks a history stopping point so undo does not roll back earlier changes', async () => {
497
+ // the cut writes to the clipboard before it deletes anything
498
+ Object.assign(window.navigator, { clipboard: { write: vi.fn() } })
499
+ globalThis.ClipboardItem = class {} as any
500
+
501
+ const cut = renderCut()
502
+ editor.createShape({ id: ids.box1, type: 'geo', x: 0, y: 0 })
503
+ editor.markHistoryStoppingPoint('rename page')
504
+ editor.renamePage(editor.getCurrentPageId(), 'My page')
505
+ editor.select(ids.box1)
506
+
507
+ await act(async () => {
508
+ await cut('menu')
509
+ })
510
+ expect(editor.getShape(ids.box1)).toBeUndefined()
511
+
512
+ editor.undo()
513
+
514
+ // the cut is undone, but the page rename before it is not
515
+ expect(editor.getShape(ids.box1)).toBeDefined()
516
+ expect(editor.getCurrentPage().name).toBe('My page')
517
+ })
518
+ })
@@ -0,0 +1,135 @@
1
+ import {
2
+ AssetRecordType,
3
+ TLImageAsset,
4
+ TLShapeId,
5
+ TLShapePartial,
6
+ createShapeId,
7
+ } from '@tldraw/editor'
8
+ import { vi } from 'vitest'
9
+ import { flattenShapesToImages } from '../lib/ui/hooks/useFlatten'
10
+ import { TestEditor } from './TestEditor'
11
+
12
+ let editor: TestEditor
13
+ let assetCount = 0
14
+
15
+ function installFlattenMocks(target: TestEditor) {
16
+ // flattenShapesToImages calls editor.getSvgString and
17
+ // editor.getAssetForExternalContent. Mock both to bypass the real export
18
+ // pipeline (jsdom can't render SVGs and we don't need real pixels — these
19
+ // tests only care about which shapes end up in which group).
20
+ vi.spyOn(target, 'getSvgString').mockResolvedValue({
21
+ svg: '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"></svg>',
22
+ width: 10,
23
+ height: 10,
24
+ trimPadding: 0,
25
+ })
26
+ vi.spyOn(target, 'getAssetForExternalContent').mockImplementation(async () => {
27
+ const asset: TLImageAsset = {
28
+ id: AssetRecordType.createId(`flat-${assetCount++}`),
29
+ typeName: 'asset',
30
+ type: 'image',
31
+ props: {
32
+ w: 10,
33
+ h: 10,
34
+ name: 'asset.svg',
35
+ isAnimated: false,
36
+ mimeType: 'image/svg+xml',
37
+ src: 'data:image/svg+xml;base64,',
38
+ },
39
+ meta: {},
40
+ }
41
+ return asset
42
+ })
43
+ }
44
+
45
+ beforeEach(() => {
46
+ editor = new TestEditor()
47
+ assetCount = 0
48
+ installFlattenMocks(editor)
49
+ })
50
+
51
+ afterEach(() => {
52
+ editor?.dispose()
53
+ })
54
+
55
+ const box = (id: TLShapeId, x: number, y: number, w = 50, h = 50): TLShapePartial => ({
56
+ id,
57
+ type: 'geo',
58
+ x,
59
+ y,
60
+ props: { w, h, fill: 'solid' },
61
+ })
62
+
63
+ function getImageShapeCount() {
64
+ return editor.getCurrentPageShapes().filter((s) => s.type === 'image').length
65
+ }
66
+
67
+ describe('flattenShapesToImages clustering', () => {
68
+ const expand = 64
69
+
70
+ it('groups shapes within the expand threshold into one image', async () => {
71
+ const a = createShapeId('a')
72
+ const b = createShapeId('b')
73
+ // gap of 30 < 2 * 64 expand, so the expanded boxes intersect
74
+ editor.createShapes([box(a, 0, 0), box(b, 80, 0)])
75
+
76
+ await flattenShapesToImages(editor, [a, b], expand)
77
+
78
+ expect(getImageShapeCount()).toBe(1)
79
+ })
80
+
81
+ it('keeps shapes in separate images when their gap exceeds the expand threshold', async () => {
82
+ const a = createShapeId('a')
83
+ const b = createShapeId('b')
84
+ // gap of 200 > 2 * 64 expand, so the expanded boxes do not intersect
85
+ editor.createShapes([box(a, 0, 0), box(b, 250, 0)])
86
+
87
+ await flattenShapesToImages(editor, [a, b], expand)
88
+
89
+ expect(getImageShapeCount()).toBe(2)
90
+ })
91
+
92
+ it('merges all shapes into one group when a bridging shape connects them, independent of input order', async () => {
93
+ // Layout: A — gap — bridge — gap — C. A and C alone do not intersect
94
+ // each other's expanded bounds, but `bridge` sits in the middle and
95
+ // should pull them all into a single group regardless of where it
96
+ // appears in the list. The previous greedy pass would only merge
97
+ // `bridge` into the first group it touched, leaving C as a separate
98
+ // cluster when `bridge` came after C in the list — which made the
99
+ // result depend on selection order.
100
+ const a = createShapeId('a')
101
+ const bridge = createShapeId('bridge')
102
+ const c = createShapeId('c')
103
+
104
+ const orders: TLShapeId[][] = [
105
+ [a, bridge, c],
106
+ [a, c, bridge],
107
+ [bridge, a, c],
108
+ [c, bridge, a],
109
+ ]
110
+
111
+ for (const order of orders) {
112
+ editor.dispose()
113
+ editor = new TestEditor()
114
+ assetCount = 0
115
+ installFlattenMocks(editor)
116
+ editor.createShapes([box(a, 0, 0), box(bridge, 100, 0), box(c, 200, 0)])
117
+
118
+ await flattenShapesToImages(editor, order, expand)
119
+
120
+ expect(getImageShapeCount(), `order ${order.join(',')}`).toBe(1)
121
+ }
122
+ })
123
+
124
+ it('uses a single Box.Common group when no expand value is provided', async () => {
125
+ // Two shapes far apart should still flatten to one image when called
126
+ // via the no-expand path (the default useFlatten() hook).
127
+ const a = createShapeId('a')
128
+ const b = createShapeId('b')
129
+ editor.createShapes([box(a, 0, 0), box(b, 1000, 0)])
130
+
131
+ await flattenShapesToImages(editor, [a, b])
132
+
133
+ expect(getImageShapeCount()).toBe(1)
134
+ })
135
+ })
@@ -37,6 +37,110 @@ it('Ctrl Key', () => {
37
37
  expect(editor.inputs.getCtrlKey()).toBe(false)
38
38
  })
39
39
 
40
+ it('pointer down releases a modifier still in its debounce window', () => {
41
+ // Collect any synthetic key_up events fired when the release is flushed.
42
+ const keyUps: string[] = []
43
+ editor.on('event', (info) => {
44
+ if (info.type === 'keyboard' && info.name === 'key_up') keyUps.push(info.key)
45
+ })
46
+
47
+ // Hold then release meta via pointer events; the release is debounced for 150ms.
48
+ editor.pointerMove(100, 100, { metaKey: true })
49
+ editor.pointerMove(100, 100, { metaKey: false })
50
+ expect(editor.inputs.getMetaKey()).toBe(true) // still held during the debounce window
51
+
52
+ // A new interaction starts with the key physically up: the lingering modifier
53
+ // should be released immediately (same path as the timer), including its
54
+ // synthetic key_up, without waiting for the timeout.
55
+ editor.pointerDown(100, 100, { metaKey: false })
56
+ expect(editor.inputs.getMetaKey()).toBe(false)
57
+ expect(keyUps).toEqual(['Meta'])
58
+
59
+ // The pending timer must have been cancelled, so advancing past it does nothing
60
+ // and doesn't fire the synthetic key_up a second time.
61
+ vi.advanceTimersByTime(200)
62
+ expect(editor.inputs.getMetaKey()).toBe(false)
63
+ expect(keyUps).toEqual(['Meta'])
64
+ })
65
+
66
+ it('pointer down releases every modifier still in its debounce window', () => {
67
+ const keyUps: string[] = []
68
+ editor.on('event', (info) => {
69
+ if (info.type === 'keyboard' && info.name === 'key_up') keyUps.push(info.key)
70
+ })
71
+
72
+ editor.pointerMove(100, 100, { shiftKey: true, altKey: true, ctrlKey: true, metaKey: true })
73
+ editor.pointerMove(100, 100, { shiftKey: false, altKey: false, ctrlKey: false, metaKey: false })
74
+ expect(editor.inputs.getShiftKey()).toBe(true)
75
+ expect(editor.inputs.getAltKey()).toBe(true)
76
+ expect(editor.inputs.getCtrlKey()).toBe(true)
77
+ expect(editor.inputs.getMetaKey()).toBe(true)
78
+
79
+ editor.pointerDown(100, 100, { shiftKey: false, altKey: false, ctrlKey: false, metaKey: false })
80
+ expect(editor.inputs.getShiftKey()).toBe(false)
81
+ expect(editor.inputs.getAltKey()).toBe(false)
82
+ expect(editor.inputs.getCtrlKey()).toBe(false)
83
+ expect(editor.inputs.getMetaKey()).toBe(false)
84
+
85
+ // Each flushed modifier fires its synthetic key_up so tools can react.
86
+ expect(keyUps).toEqual(expect.arrayContaining(['Shift', 'Alt', 'Ctrl', 'Meta']))
87
+ expect(keyUps).toHaveLength(4)
88
+
89
+ // All four timers were cancelled, so nothing flips back or fires again.
90
+ vi.advanceTimersByTime(200)
91
+ expect(editor.inputs.getShiftKey()).toBe(false)
92
+ expect(editor.inputs.getAltKey()).toBe(false)
93
+ expect(editor.inputs.getCtrlKey()).toBe(false)
94
+ expect(editor.inputs.getMetaKey()).toBe(false)
95
+ expect(keyUps).toHaveLength(4)
96
+ })
97
+
98
+ it('pointer down clears stale key codes when flushing a debounced modifier', () => {
99
+ // A real key_down records the code, then the release is only seen via pointer
100
+ // flags (no keyboard key_up), so the code lingers in inputs.keys until flushed.
101
+ editor.keyDown('Shift')
102
+ expect(editor.inputs.keys.has('ShiftLeft')).toBe(true)
103
+
104
+ editor.pointerMove(100, 100, { shiftKey: false })
105
+ expect(editor.inputs.getShiftKey()).toBe(true) // debounced, still counted as held
106
+ expect(editor.inputs.keys.has('ShiftLeft')).toBe(true) // code not yet cleared
107
+
108
+ // The pointer down flushes the debounce through the timer's path, which clears
109
+ // both the modifier atom and the stale code.
110
+ editor.pointerDown(100, 100, { shiftKey: false })
111
+ expect(editor.inputs.getShiftKey()).toBe(false)
112
+ expect(editor.inputs.keys.has('ShiftLeft')).toBe(false)
113
+ })
114
+
115
+ it('does not re-release a modifier after its natural debounce timer fired', () => {
116
+ const keyUps: string[] = []
117
+ editor.on('event', (info) => {
118
+ if (info.type === 'keyboard' && info.name === 'key_up') keyUps.push(info.key)
119
+ })
120
+
121
+ // Hold then release shift via pointer events and let the 150ms timer fire on its
122
+ // own, which releases shift with a single synthetic key_up.
123
+ editor.pointerMove(100, 100, { shiftKey: true })
124
+ editor.pointerMove(100, 100, { shiftKey: false })
125
+ vi.advanceTimersByTime(200)
126
+ expect(editor.inputs.getShiftKey()).toBe(false)
127
+ expect(keyUps).toEqual(['Shift'])
128
+
129
+ // A later pointer down must not treat the already-fired timer as an active
130
+ // debounce and emit another synthetic key_up.
131
+ editor.pointerDown(100, 100, { shiftKey: false })
132
+ expect(keyUps).toEqual(['Shift'])
133
+ })
134
+
135
+ it('pointer down keeps a genuinely held modifier', () => {
136
+ // Shift is actually held during the pointer down (flag stays true), so it must
137
+ // not be cleared by the debounce-flush.
138
+ editor.pointerMove(100, 100, { shiftKey: true })
139
+ expect(editor.inputs.getShiftKey()).toBe(true)
140
+ editor.pointerDown(100, 100, { shiftKey: true })
141
+ expect(editor.inputs.getShiftKey()).toBe(true)
142
+ })
143
+
40
144
  it('keyboard events carry currently-held modifiers', () => {
41
145
  const keyboardEvents: TLKeyboardEventInfo[] = []
42
146
  editor.on('event', (info) => {