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.
- package/DOCS.md +2822 -3224
- package/RELEASE_NOTES.md +21 -33
- 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/useCommentingEnabled.js +1 -1
- package/dist-cjs/lib/ui/hooks/useCommentingEnabled.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/useCommentingEnabled.mjs +2 -2
- package/dist-esm/lib/ui/hooks/useCommentingEnabled.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 +12 -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/useCommentingEnabled.ts +5 -3
- 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/src/test/ui-outside-editor.test.tsx +74 -0
- package/tldraw.css +7 -16
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
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
|
|
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
|
|
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
|
+
})
|
package/src/lib/ui/kbd-utils.ts
CHANGED
|
@@ -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(' ')
|
package/src/lib/ui/version.ts
CHANGED
|
@@ -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.
|
|
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-
|
|
8
|
-
patch: '2026-08-
|
|
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
|
-
|
|
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
|
+
})
|