tldraw 5.3.1 → 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 (99) hide show
  1. package/DOCS.md +2822 -3224
  2. package/RELEASE_NOTES.md +21 -33
  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/useCommentingEnabled.js +1 -1
  28. package/dist-cjs/lib/ui/hooks/useCommentingEnabled.js.map +2 -2
  29. package/dist-cjs/lib/ui/hooks/useFlatten.js +42 -23
  30. package/dist-cjs/lib/ui/hooks/useFlatten.js.map +2 -2
  31. package/dist-cjs/lib/ui/hooks/useKeyboardShortcuts.js +5 -17
  32. package/dist-cjs/lib/ui/hooks/useKeyboardShortcuts.js.map +2 -2
  33. package/dist-cjs/lib/ui/kbd-utils.js +22 -3
  34. package/dist-cjs/lib/ui/kbd-utils.js.map +2 -2
  35. package/dist-cjs/lib/ui/version.js +3 -3
  36. package/dist-cjs/lib/ui/version.js.map +1 -1
  37. package/dist-esm/index.d.mts +264 -275
  38. package/dist-esm/index.mjs +1 -1
  39. package/dist-esm/lib/defaultExternalContentHandlers.mjs +2 -2
  40. package/dist-esm/lib/defaultExternalContentHandlers.mjs.map +2 -2
  41. package/dist-esm/lib/shapes/draw/toolStates/Drawing.mjs +2 -2
  42. package/dist-esm/lib/shapes/draw/toolStates/Drawing.mjs.map +2 -2
  43. package/dist-esm/lib/shapes/frame/frameHelpers.mjs +3 -0
  44. package/dist-esm/lib/shapes/frame/frameHelpers.mjs.map +2 -2
  45. package/dist-esm/lib/shapes/video/VideoShapeUtil.mjs +3 -2
  46. package/dist-esm/lib/shapes/video/VideoShapeUtil.mjs.map +2 -2
  47. package/dist-esm/lib/ui/TldrawUi.mjs +2 -3
  48. package/dist-esm/lib/ui/TldrawUi.mjs.map +2 -2
  49. package/dist-esm/lib/ui/components/ActionsMenu/DefaultActionsMenu.mjs +2 -5
  50. package/dist-esm/lib/ui/components/ActionsMenu/DefaultActionsMenu.mjs.map +2 -2
  51. package/dist-esm/lib/ui/components/ContextMenu/DefaultContextMenuContent.mjs +1 -1
  52. package/dist-esm/lib/ui/components/ContextMenu/DefaultContextMenuContent.mjs.map +2 -2
  53. package/dist-esm/lib/ui/components/Dialogs.mjs +9 -1
  54. package/dist-esm/lib/ui/components/Dialogs.mjs.map +2 -2
  55. package/dist-esm/lib/ui/components/SharePanel/DefaultPeopleMenuItem.mjs +1 -2
  56. package/dist-esm/lib/ui/components/SharePanel/DefaultPeopleMenuItem.mjs.map +2 -2
  57. package/dist-esm/lib/ui/context/actions.mjs +0 -1
  58. package/dist-esm/lib/ui/context/actions.mjs.map +2 -2
  59. package/dist-esm/lib/ui/hooks/useClipboardEvents.mjs +2 -0
  60. package/dist-esm/lib/ui/hooks/useClipboardEvents.mjs.map +2 -2
  61. package/dist-esm/lib/ui/hooks/useCommentingEnabled.mjs +2 -2
  62. package/dist-esm/lib/ui/hooks/useCommentingEnabled.mjs.map +2 -2
  63. package/dist-esm/lib/ui/hooks/useFlatten.mjs +42 -23
  64. package/dist-esm/lib/ui/hooks/useFlatten.mjs.map +2 -2
  65. package/dist-esm/lib/ui/hooks/useKeyboardShortcuts.mjs +5 -17
  66. package/dist-esm/lib/ui/hooks/useKeyboardShortcuts.mjs.map +2 -2
  67. package/dist-esm/lib/ui/kbd-utils.mjs +22 -3
  68. package/dist-esm/lib/ui/kbd-utils.mjs.map +2 -2
  69. package/dist-esm/lib/ui/version.mjs +3 -3
  70. package/dist-esm/lib/ui/version.mjs.map +1 -1
  71. package/package.json +12 -6
  72. package/src/lib/defaultExternalContentHandlers.ts +2 -2
  73. package/src/lib/shapes/draw/DrawShapeTool.test.ts +19 -0
  74. package/src/lib/shapes/draw/toolStates/Drawing.ts +2 -2
  75. package/src/lib/shapes/frame/frameHelpers.ts +10 -1
  76. package/src/lib/shapes/video/VideoShapeUtil.tsx +4 -2
  77. package/src/lib/ui/TldrawUi.tsx +1 -2
  78. package/src/lib/ui/components/ActionsMenu/DefaultActionsMenu.tsx +2 -6
  79. package/src/lib/ui/components/ContextMenu/DefaultContextMenuContent.tsx +5 -1
  80. package/src/lib/ui/components/Dialogs.tsx +17 -1
  81. package/src/lib/ui/components/SharePanel/DefaultPeopleMenuItem.tsx +2 -3
  82. package/src/lib/ui/context/actions.tsx +0 -1
  83. package/src/lib/ui/hooks/useClipboardEvents.ts +2 -0
  84. package/src/lib/ui/hooks/useCommentingEnabled.ts +5 -3
  85. package/src/lib/ui/hooks/useFlatten.ts +56 -23
  86. package/src/lib/ui/hooks/useKeyboardShortcuts.ts +5 -23
  87. package/src/lib/ui/kbd-utils.test.ts +11 -0
  88. package/src/lib/ui/kbd-utils.ts +27 -4
  89. package/src/lib/ui/version.ts +3 -3
  90. package/src/lib/ui.css +7 -16
  91. package/src/test/TldrawEditor.test.tsx +3 -0
  92. package/src/test/VideoShapeUtil.test.tsx +126 -0
  93. package/src/test/bookmark-shapes.test.ts +110 -0
  94. package/src/test/commands/alignShapes.test.tsx +19 -10
  95. package/src/test/commands/clipboard.test.ts +36 -1
  96. package/src/test/flatten.test.ts +135 -0
  97. package/src/test/modifiers.test.ts +104 -0
  98. package/src/test/ui-outside-editor.test.tsx +74 -0
  99. package/tldraw.css +7 -16
@@ -1,7 +1,6 @@
1
1
  /*!
2
- * The kbd-string splitter (`getKeys`) and the form-input filter pattern in `shouldSkipEvent`
3
- * (including its list of non-text INPUT types) are adapted from hotkeys-js, which this hook
4
- * previously depended on.
2
+ * The form-input filter pattern in `shouldSkipEvent` (including its list of non-text INPUT
3
+ * types) is adapted from hotkeys-js, which this hook previously depended on.
5
4
  *
6
5
  * MIT License: https://github.com/jaywcjlove/hotkeys-js/blob/master/LICENSE
7
6
  * Copyright (c) 2015-present, Kenny Wong
@@ -19,6 +18,7 @@ import {
19
18
  } from '@tldraw/editor'
20
19
  import { useEffect } from 'react'
21
20
  import { useActions } from '../context/actions'
21
+ import { splitKbd } from '../kbd-utils'
22
22
  import { useCommentingEnabled } from './useCommentingEnabled'
23
23
  import { useReadonly } from './useReadonly'
24
24
  import { useTools } from './useTools'
@@ -360,7 +360,7 @@ const PHYSICAL_KEY_MAP: Record<string, string> = {
360
360
  */
361
361
  export function parseKbd(kbd: string): ParsedKbd[] {
362
362
  const out: ParsedKbd[] = []
363
- for (const shortcut of getKeys(kbd)) {
363
+ for (const shortcut of splitKbd(kbd.replace(/\s/g, ''))) {
364
364
  const parsed = parseShortcut(shortcut)
365
365
  if (parsed) out.push(parsed)
366
366
  }
@@ -472,7 +472,7 @@ function shouldSkipEvent(e: KeyboardEvent): boolean {
472
472
  * @internal
473
473
  */
474
474
  export function getHotkeysStringFromKbd(kbd: string) {
475
- return getKeys(kbd)
475
+ return splitKbd(kbd.replace(/\s/g, ''))
476
476
  .map((kbd) => {
477
477
  let str = ''
478
478
 
@@ -505,21 +505,3 @@ export function getHotkeysStringFromKbd(kbd: string) {
505
505
  })
506
506
  .join(',')
507
507
  }
508
-
509
- // Split a kbd string on commas, treating an empty entry produced by "x,," as a literal
510
- // trailing comma on the previous entry. Verbatim port of the splitter from hotkeys-js
511
- // (MIT, see top-of-file attribution).
512
- function getKeys(key: string) {
513
- if (typeof key !== 'string') key = ''
514
- key = key.replace(/\s/g, '')
515
- const keys = key.split(',')
516
- let index = keys.lastIndexOf('')
517
-
518
- for (; index >= 0; ) {
519
- keys[index - 1] += ','
520
- keys.splice(index, 1)
521
- index = keys.lastIndexOf('')
522
- }
523
-
524
- return keys
525
- }
@@ -0,0 +1,11 @@
1
+ import { kbd } from './kbd-utils'
2
+
3
+ describe('kbd', () => {
4
+ it('displays a comma key with modifiers', () => {
5
+ expect(kbd('shift+,,shift+alt+,')).toEqual([...kbd('shift+.').slice(0, -1), ','])
6
+ })
7
+
8
+ it('preserves spaces in atomic key labels', () => {
9
+ expect(kbd('[[Page Up]]')).toEqual(['Page Up'])
10
+ })
11
+ })
@@ -1,5 +1,14 @@
1
1
  import { tlenv } from '@tldraw/editor'
2
2
 
3
+ /*!
4
+ * `splitKbd` is adapted from hotkeys-js.
5
+ *
6
+ * MIT License: https://github.com/jaywcjlove/hotkeys-js/blob/master/LICENSE
7
+ * Copyright (c) 2015-present, Kenny Wong
8
+ * Copyright (c) 2011-2013 Thomas Fuchs (https://github.com/madrobby/keymaster)
9
+ * Source: https://github.com/jaywcjlove/hotkeys-js
10
+ */
11
+
3
12
  // N.B. We rework these Windows placeholders down below.
4
13
  const cmdKey = tlenv.isDarwin ? '⌘' : '__CTRL__'
5
14
  const ctrlKey = tlenv.isDarwin ? '⌃' : '__CTRL__'
@@ -7,11 +16,8 @@ const altKey = tlenv.isDarwin ? '⌥' : '__ALT__'
7
16
 
8
17
  /** @public */
9
18
  export function kbd(str: string) {
10
- if (str === ',') return [',']
11
-
12
19
  return (
13
- str
14
- .split(',')[0]
20
+ (splitKbd(str)[0] ?? '')
15
21
  // If the string contains [[Tab]], we don't split these up
16
22
  // as they're meant to be atomic.
17
23
  .split(/(\[\[[^\]]+\]\])/g)
@@ -47,6 +53,23 @@ export function kbd(str: string) {
47
53
  )
48
54
  }
49
55
 
56
+ // Split a kbd string on commas, treating an empty entry produced by "x,," as a literal
57
+ // trailing comma on the previous entry.
58
+ /** @internal */
59
+ export function splitKbd(key: string) {
60
+ if (!key) return []
61
+ const keys = key.split(',')
62
+ let index = keys.lastIndexOf('')
63
+
64
+ for (; index >= 0; ) {
65
+ keys[index - 1] += ','
66
+ keys.splice(index, 1)
67
+ index = keys.lastIndexOf('')
68
+ }
69
+
70
+ return keys
71
+ }
72
+
50
73
  /** @public */
51
74
  export function kbdStr(str: string) {
52
75
  return '— ' + kbd(str).join(' ')
@@ -1,9 +1,9 @@
1
1
  // This file is automatically generated by internal/scripts/refresh-assets.ts.
2
2
  // Do not edit manually. Or do, I'm a comment, not a cop.
3
3
 
4
- export const version = '5.3.1'
4
+ export const version = '5.4.0-canary.042da660fa66'
5
5
  export const publishDates = {
6
6
  major: '2026-05-06T16:28:18.473Z',
7
- minor: '2026-08-05T11:39:54.978Z',
8
- patch: '2026-08-14T10:46:20.010Z',
7
+ minor: '2026-08-19T12:47:12.111Z',
8
+ patch: '2026-08-19T12:47:12.111Z',
9
9
  }
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
+ })