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.
- package/DOCS.md +2822 -3224
- package/RELEASE_NOTES.md +11 -39
- package/dist-cjs/index.d.ts +264 -275
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/defaultExternalContentHandlers.js +2 -2
- package/dist-cjs/lib/defaultExternalContentHandlers.js.map +2 -2
- package/dist-cjs/lib/shapes/draw/toolStates/Drawing.js +2 -2
- package/dist-cjs/lib/shapes/draw/toolStates/Drawing.js.map +2 -2
- package/dist-cjs/lib/shapes/frame/frameHelpers.js +3 -0
- package/dist-cjs/lib/shapes/frame/frameHelpers.js.map +2 -2
- package/dist-cjs/lib/shapes/video/VideoShapeUtil.js +3 -2
- package/dist-cjs/lib/shapes/video/VideoShapeUtil.js.map +2 -2
- package/dist-cjs/lib/ui/TldrawUi.js +2 -3
- package/dist-cjs/lib/ui/TldrawUi.js.map +2 -2
- package/dist-cjs/lib/ui/components/ActionsMenu/DefaultActionsMenu.js +0 -3
- package/dist-cjs/lib/ui/components/ActionsMenu/DefaultActionsMenu.js.map +2 -2
- package/dist-cjs/lib/ui/components/ContextMenu/DefaultContextMenuContent.js +1 -1
- package/dist-cjs/lib/ui/components/ContextMenu/DefaultContextMenuContent.js.map +2 -2
- package/dist-cjs/lib/ui/components/Dialogs.js +8 -0
- package/dist-cjs/lib/ui/components/Dialogs.js.map +2 -2
- package/dist-cjs/lib/ui/components/SharePanel/DefaultPeopleMenuItem.js +1 -2
- package/dist-cjs/lib/ui/components/SharePanel/DefaultPeopleMenuItem.js.map +2 -2
- package/dist-cjs/lib/ui/context/actions.js +0 -1
- package/dist-cjs/lib/ui/context/actions.js.map +2 -2
- package/dist-cjs/lib/ui/hooks/useClipboardEvents.js +2 -0
- package/dist-cjs/lib/ui/hooks/useClipboardEvents.js.map +2 -2
- package/dist-cjs/lib/ui/hooks/useFlatten.js +42 -23
- package/dist-cjs/lib/ui/hooks/useFlatten.js.map +2 -2
- package/dist-cjs/lib/ui/hooks/useKeyboardShortcuts.js +5 -17
- package/dist-cjs/lib/ui/hooks/useKeyboardShortcuts.js.map +2 -2
- package/dist-cjs/lib/ui/kbd-utils.js +22 -3
- package/dist-cjs/lib/ui/kbd-utils.js.map +2 -2
- package/dist-cjs/lib/ui/version.js +3 -3
- package/dist-cjs/lib/ui/version.js.map +1 -1
- package/dist-esm/index.d.mts +264 -275
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/defaultExternalContentHandlers.mjs +2 -2
- package/dist-esm/lib/defaultExternalContentHandlers.mjs.map +2 -2
- package/dist-esm/lib/shapes/draw/toolStates/Drawing.mjs +2 -2
- package/dist-esm/lib/shapes/draw/toolStates/Drawing.mjs.map +2 -2
- package/dist-esm/lib/shapes/frame/frameHelpers.mjs +3 -0
- package/dist-esm/lib/shapes/frame/frameHelpers.mjs.map +2 -2
- package/dist-esm/lib/shapes/video/VideoShapeUtil.mjs +3 -2
- package/dist-esm/lib/shapes/video/VideoShapeUtil.mjs.map +2 -2
- package/dist-esm/lib/ui/TldrawUi.mjs +2 -3
- package/dist-esm/lib/ui/TldrawUi.mjs.map +2 -2
- package/dist-esm/lib/ui/components/ActionsMenu/DefaultActionsMenu.mjs +2 -5
- package/dist-esm/lib/ui/components/ActionsMenu/DefaultActionsMenu.mjs.map +2 -2
- package/dist-esm/lib/ui/components/ContextMenu/DefaultContextMenuContent.mjs +1 -1
- package/dist-esm/lib/ui/components/ContextMenu/DefaultContextMenuContent.mjs.map +2 -2
- package/dist-esm/lib/ui/components/Dialogs.mjs +9 -1
- package/dist-esm/lib/ui/components/Dialogs.mjs.map +2 -2
- package/dist-esm/lib/ui/components/SharePanel/DefaultPeopleMenuItem.mjs +1 -2
- package/dist-esm/lib/ui/components/SharePanel/DefaultPeopleMenuItem.mjs.map +2 -2
- package/dist-esm/lib/ui/context/actions.mjs +0 -1
- package/dist-esm/lib/ui/context/actions.mjs.map +2 -2
- package/dist-esm/lib/ui/hooks/useClipboardEvents.mjs +2 -0
- package/dist-esm/lib/ui/hooks/useClipboardEvents.mjs.map +2 -2
- package/dist-esm/lib/ui/hooks/useFlatten.mjs +42 -23
- package/dist-esm/lib/ui/hooks/useFlatten.mjs.map +2 -2
- package/dist-esm/lib/ui/hooks/useKeyboardShortcuts.mjs +5 -17
- package/dist-esm/lib/ui/hooks/useKeyboardShortcuts.mjs.map +2 -2
- package/dist-esm/lib/ui/kbd-utils.mjs +22 -3
- package/dist-esm/lib/ui/kbd-utils.mjs.map +2 -2
- package/dist-esm/lib/ui/version.mjs +3 -3
- package/dist-esm/lib/ui/version.mjs.map +1 -1
- package/package.json +6 -6
- package/src/lib/defaultExternalContentHandlers.ts +2 -2
- package/src/lib/shapes/draw/DrawShapeTool.test.ts +19 -0
- package/src/lib/shapes/draw/toolStates/Drawing.ts +2 -2
- package/src/lib/shapes/frame/frameHelpers.ts +10 -1
- package/src/lib/shapes/video/VideoShapeUtil.tsx +4 -2
- package/src/lib/ui/TldrawUi.tsx +1 -2
- package/src/lib/ui/components/ActionsMenu/DefaultActionsMenu.tsx +2 -6
- package/src/lib/ui/components/ContextMenu/DefaultContextMenuContent.tsx +5 -1
- package/src/lib/ui/components/Dialogs.tsx +17 -1
- package/src/lib/ui/components/SharePanel/DefaultPeopleMenuItem.tsx +2 -3
- package/src/lib/ui/context/actions.tsx +0 -1
- package/src/lib/ui/hooks/useClipboardEvents.ts +2 -0
- package/src/lib/ui/hooks/useFlatten.ts +56 -23
- package/src/lib/ui/hooks/useKeyboardShortcuts.ts +5 -23
- package/src/lib/ui/kbd-utils.test.ts +11 -0
- package/src/lib/ui/kbd-utils.ts +27 -4
- package/src/lib/ui/version.ts +3 -3
- package/src/lib/ui.css +7 -16
- package/src/test/TldrawEditor.test.tsx +3 -0
- package/src/test/VideoShapeUtil.test.tsx +126 -0
- package/src/test/bookmark-shapes.test.ts +110 -0
- package/src/test/commands/alignShapes.test.tsx +19 -10
- package/src/test/commands/clipboard.test.ts +36 -1
- package/src/test/flatten.test.ts +135 -0
- package/src/test/modifiers.test.ts +104 -0
- 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
|
-
|
|
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:
|
|
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:
|
|
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(), '
|
|
41
|
+
editor.alignShapes(editor.getSelectedShapeIds(), 'center')
|
|
42
42
|
vi.advanceTimersByTime(1000)
|
|
43
43
|
|
|
44
|
-
editor.expectShapeToMatch({ id: ids.boxB, y:
|
|
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:
|
|
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:
|
|
133
|
+
rotation: 0.2,
|
|
123
134
|
},
|
|
124
135
|
{
|
|
125
136
|
id: ids.boxB,
|
|
126
137
|
type: 'geo',
|
|
127
|
-
rotation:
|
|
138
|
+
rotation: 0.4,
|
|
128
139
|
},
|
|
129
140
|
{
|
|
130
141
|
id: ids.boxC,
|
|
131
142
|
type: 'geo',
|
|
132
|
-
rotation:
|
|
143
|
+
rotation: 0.6,
|
|
133
144
|
},
|
|
134
145
|
])
|
|
135
146
|
|
|
136
|
-
editor.alignShapes(editor.getSelectedShapeIds(), 'center
|
|
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 {
|
|
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) => {
|