@tldraw/editor 5.5.2 → 5.6.0-canary.0091c91994d6
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/dist-cjs/index.d.ts +167 -2
- package/dist-cjs/index.js +7 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/TldrawEditor.js +18 -5
- package/dist-cjs/lib/TldrawEditor.js.map +3 -3
- package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js +57 -15
- package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js.map +2 -2
- package/dist-cjs/lib/config/TLI18n.js +64 -0
- package/dist-cjs/lib/config/TLI18n.js.map +7 -0
- package/dist-cjs/lib/editor/Editor.js +44 -16
- package/dist-cjs/lib/editor/Editor.js.map +2 -2
- package/dist-cjs/lib/editor/derivations/shapeIdsInCurrentPage.js +10 -3
- package/dist-cjs/lib/editor/derivations/shapeIdsInCurrentPage.js.map +2 -2
- package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js +1 -1
- package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/FontManager/FontManager.js +19 -2
- package/dist-cjs/lib/editor/managers/FontManager/FontManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js +3 -5
- package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js +7 -1
- package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js.map +2 -2
- package/dist-cjs/lib/editor/shapes/ShapeUtil.js +28 -0
- package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +2 -2
- package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js +3 -0
- package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js.map +2 -2
- package/dist-cjs/lib/editor/shapes/shared/resizeBox.js +21 -16
- package/dist-cjs/lib/editor/shapes/shared/resizeBox.js.map +2 -2
- package/dist-cjs/lib/hooks/useCursor.js +12 -8
- package/dist-cjs/lib/hooks/useCursor.js.map +2 -2
- package/dist-cjs/lib/hooks/useDocumentEvents.js +28 -3
- package/dist-cjs/lib/hooks/useDocumentEvents.js.map +2 -2
- package/dist-cjs/lib/license/LicenseManager.js +64 -1
- package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
- package/dist-cjs/lib/options.js +1 -1
- package/dist-cjs/lib/options.js.map +1 -1
- package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js +12 -5
- package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js.map +2 -2
- package/dist-cjs/version.js +3 -3
- package/dist-cjs/version.js.map +1 -1
- package/dist-esm/index.d.mts +167 -2
- package/dist-esm/index.mjs +13 -1
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/TldrawEditor.mjs +18 -5
- package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
- package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs +61 -16
- package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs.map +2 -2
- package/dist-esm/lib/config/TLI18n.mjs +44 -0
- package/dist-esm/lib/config/TLI18n.mjs.map +7 -0
- package/dist-esm/lib/editor/Editor.mjs +46 -17
- package/dist-esm/lib/editor/Editor.mjs.map +2 -2
- package/dist-esm/lib/editor/derivations/shapeIdsInCurrentPage.mjs +10 -3
- package/dist-esm/lib/editor/derivations/shapeIdsInCurrentPage.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs +1 -1
- package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs +20 -3
- package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs +3 -5
- package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs +7 -1
- package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs.map +2 -2
- package/dist-esm/lib/editor/shapes/ShapeUtil.mjs +28 -0
- package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +2 -2
- package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs +3 -0
- package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs.map +2 -2
- package/dist-esm/lib/editor/shapes/shared/resizeBox.mjs +21 -16
- package/dist-esm/lib/editor/shapes/shared/resizeBox.mjs.map +2 -2
- package/dist-esm/lib/hooks/useCursor.mjs +12 -8
- package/dist-esm/lib/hooks/useCursor.mjs.map +2 -2
- package/dist-esm/lib/hooks/useDocumentEvents.mjs +28 -3
- package/dist-esm/lib/hooks/useDocumentEvents.mjs.map +2 -2
- package/dist-esm/lib/license/LicenseManager.mjs +64 -1
- package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
- package/dist-esm/lib/options.mjs +1 -1
- package/dist-esm/lib/options.mjs.map +1 -1
- package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs +13 -6
- package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs.map +2 -2
- package/dist-esm/version.mjs +3 -3
- package/dist-esm/version.mjs.map +1 -1
- package/editor.css +1 -1
- package/package.json +17 -19
- package/src/index.ts +14 -2
- package/src/lib/TldrawEditor.tsx +44 -4
- package/src/lib/components/default-components/DefaultErrorFallback.tsx +66 -18
- package/src/lib/config/TLI18n.test.ts +84 -0
- package/src/lib/config/TLI18n.ts +172 -0
- package/src/lib/editor/Editor.ts +90 -33
- package/src/lib/editor/derivations/shapeIdsInCurrentPage.ts +19 -3
- package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.test.ts +17 -0
- package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.ts +2 -2
- package/src/lib/editor/managers/FontManager/FontManager.test.ts +1 -0
- package/src/lib/editor/managers/FontManager/FontManager.ts +26 -3
- package/src/lib/editor/managers/HistoryManager/HistoryManager.test.ts +32 -0
- package/src/lib/editor/managers/ScribbleManager/ScribbleManager.ts +4 -6
- package/src/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.ts +16 -7
- package/src/lib/editor/shapes/ShapeUtil.ts +30 -0
- package/src/lib/editor/shapes/group/GroupShapeUtil.tsx +4 -0
- package/src/lib/editor/shapes/shared/resizeBox.ts +21 -16
- package/src/lib/hooks/useCursor.ts +14 -7
- package/src/lib/hooks/useDocumentEvents.ts +51 -3
- package/src/lib/license/LicenseManager.test.ts +220 -0
- package/src/lib/license/LicenseManager.ts +89 -1
- package/src/lib/options.ts +1 -1
- package/src/lib/test/useCursor.test.tsx +36 -0
- package/src/lib/test/useDocumentEvents.test.tsx +153 -22
- package/src/lib/utils/sync/TLLocalSyncClient.test.ts +73 -0
- package/src/lib/utils/sync/TLLocalSyncClient.ts +24 -8
- package/src/version.ts +3 -3
|
@@ -41,6 +41,7 @@ export class SpatialIndexManager {
|
|
|
41
41
|
private rbush: RBushIndex
|
|
42
42
|
private spatialIndexComputed: Computed<number>
|
|
43
43
|
private lastPageId: TLPageId | null = null
|
|
44
|
+
private lastFontLoadEpoch = -1
|
|
44
45
|
|
|
45
46
|
// Bumps only when the rbush content may have changed. Consumers subscribe
|
|
46
47
|
// via the computed; a stable epoch lets prop-only diffs skip downstream
|
|
@@ -66,21 +67,28 @@ export class SpatialIndexManager {
|
|
|
66
67
|
const bindingHistory = this.editor.store.query.filterHistory('binding')
|
|
67
68
|
|
|
68
69
|
return computed<number>('spatialIndex', (_prevValue, lastComputedEpoch) => {
|
|
69
|
-
// These
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
70
|
+
// These four reads are the computed's only tracked dependencies: shape
|
|
71
|
+
// and binding record changes, plus font loads (measured text resizes
|
|
72
|
+
// when its font swaps in). Theme changes that resize shapes without a
|
|
73
|
+
// record diff (e.g. `updateTheme({ fontSize })`) are not tracked, so
|
|
74
|
+
// the index stays stale for those until the next rebuild.
|
|
75
|
+
// The rebuild/update paths below run without capture to avoid
|
|
76
|
+
// registering a dependency per checked shape on every update (and
|
|
77
|
+
// ~page-size dependencies on every rebuild).
|
|
75
78
|
const shapeDiff = shapeHistory.getDiffSince(lastComputedEpoch)
|
|
76
79
|
const bindingDiff = bindingHistory.getDiffSince(lastComputedEpoch)
|
|
77
80
|
const currentPageId = this.editor.getCurrentPageId()
|
|
81
|
+
const fontLoadEpoch = this.editor.fonts.getFontLoadEpoch()
|
|
78
82
|
|
|
79
83
|
if (
|
|
80
84
|
isUninitialized(_prevValue) ||
|
|
81
85
|
shapeDiff === RESET_VALUE ||
|
|
82
86
|
bindingDiff === RESET_VALUE ||
|
|
83
|
-
this.lastPageId !== currentPageId
|
|
87
|
+
this.lastPageId !== currentPageId ||
|
|
88
|
+
// Which shapes a font load resizes isn't known without visiting
|
|
89
|
+
// every shape, so rebuild; loads are rare and bounded by the
|
|
90
|
+
// number of distinct fonts.
|
|
91
|
+
this.lastFontLoadEpoch !== fontLoadEpoch
|
|
84
92
|
) {
|
|
85
93
|
return unsafe__withoutCapture(() => this.rebuildAndBumpEpoch())
|
|
86
94
|
}
|
|
@@ -97,6 +105,7 @@ export class SpatialIndexManager {
|
|
|
97
105
|
private buildFromScratch(): void {
|
|
98
106
|
this.rbush.clear()
|
|
99
107
|
this.lastPageId = this.editor.getCurrentPageId()
|
|
108
|
+
this.lastFontLoadEpoch = this.editor.fonts.getFontLoadEpoch()
|
|
100
109
|
|
|
101
110
|
const elements: SpatialElement[] = []
|
|
102
111
|
for (const shape of this.editor.getCurrentPageShapes()) {
|
|
@@ -200,6 +200,24 @@ export abstract class ShapeUtil<Shape extends TLShape = TLShape> {
|
|
|
200
200
|
*/
|
|
201
201
|
createShapeForAsset?(asset: TLAsset, position: VecModel): TLShapePartial | null
|
|
202
202
|
|
|
203
|
+
/**
|
|
204
|
+
* The shape's name, translated, as a screen reader should announce it.
|
|
205
|
+
*
|
|
206
|
+
* Return `undefined` — the default — and the shape is announced by its tool's label, under the
|
|
207
|
+
* `tool.<type>` convention. Override this when the name isn't that: a geo shape is a "Rectangle"
|
|
208
|
+
* or an "Ellipse" rather than a "Shape", so it names itself from its geo style.
|
|
209
|
+
*
|
|
210
|
+
* Resolve the name through {@link translateMessage} and the editor's `i18n` rather than
|
|
211
|
+
* returning English, so it follows the user's locale and still reads as words when an app
|
|
212
|
+
* supplies no translations.
|
|
213
|
+
*
|
|
214
|
+
* @param shape - The shape.
|
|
215
|
+
* @public
|
|
216
|
+
*/
|
|
217
|
+
getShapeName(_shape: Shape): string | undefined {
|
|
218
|
+
return undefined
|
|
219
|
+
}
|
|
220
|
+
|
|
203
221
|
/**
|
|
204
222
|
* Get the shape's geometry.
|
|
205
223
|
*
|
|
@@ -349,6 +367,18 @@ export abstract class ShapeUtil<Shape extends TLShape = TLShape> {
|
|
|
349
367
|
return false
|
|
350
368
|
}
|
|
351
369
|
|
|
370
|
+
/**
|
|
371
|
+
* Whether flipping changes the shape itself, by mirroring it or toggling its stored flip. The
|
|
372
|
+
* default UI only offers flip for a lone selected shape when this is true; {@link Editor.flipShapes}
|
|
373
|
+
* doesn't check it, so shapes that return false still move when flipped as part of a larger
|
|
374
|
+
* selection. Use {@link ShapeUtil.canBeLaidOut} to opt out of that.
|
|
375
|
+
*
|
|
376
|
+
* @public
|
|
377
|
+
*/
|
|
378
|
+
canFlip(shape: Shape): boolean {
|
|
379
|
+
return false
|
|
380
|
+
}
|
|
381
|
+
|
|
352
382
|
/**
|
|
353
383
|
* Whether the shape can participate in layout functions such as alignment or distribution.
|
|
354
384
|
*
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { VecModel } from '@tldraw/tlschema'
|
|
2
2
|
import { Box } from '../../../primitives/Box'
|
|
3
|
+
import { clamp } from '../../../primitives/utils'
|
|
3
4
|
import { Vec } from '../../../primitives/Vec'
|
|
4
5
|
import { TLResizeHandle } from '../../types/selection-types'
|
|
5
6
|
import type { TLBaseBoxShape } from '../BaseBoxShapeUtil'
|
|
@@ -36,29 +37,31 @@ export function resizeBox<T extends TLBaseBoxShape>(
|
|
|
36
37
|
const offset = new Vec(0, 0)
|
|
37
38
|
|
|
38
39
|
if (w > 0) {
|
|
39
|
-
|
|
40
|
+
const clampedW = clamp(w, minWidth, maxWidth)
|
|
41
|
+
if (clampedW !== w) {
|
|
40
42
|
switch (handle) {
|
|
41
43
|
case 'top_left':
|
|
42
44
|
case 'left':
|
|
43
45
|
case 'bottom_left': {
|
|
44
|
-
offset.x = w -
|
|
46
|
+
offset.x = w - clampedW
|
|
45
47
|
break
|
|
46
48
|
}
|
|
47
49
|
case 'top':
|
|
48
50
|
case 'bottom': {
|
|
49
|
-
offset.x = (w -
|
|
51
|
+
offset.x = (w - clampedW) / 2
|
|
50
52
|
break
|
|
51
53
|
}
|
|
52
54
|
default: {
|
|
53
55
|
offset.x = 0
|
|
54
56
|
}
|
|
55
57
|
}
|
|
56
|
-
w =
|
|
58
|
+
w = clampedW
|
|
57
59
|
}
|
|
58
60
|
} else {
|
|
59
61
|
offset.x = w
|
|
60
62
|
w = -w
|
|
61
|
-
|
|
63
|
+
const clampedW = clamp(w, minWidth, maxWidth)
|
|
64
|
+
if (clampedW !== w) {
|
|
62
65
|
switch (handle) {
|
|
63
66
|
case 'top_left':
|
|
64
67
|
case 'left':
|
|
@@ -67,26 +70,27 @@ export function resizeBox<T extends TLBaseBoxShape>(
|
|
|
67
70
|
break
|
|
68
71
|
}
|
|
69
72
|
default: {
|
|
70
|
-
offset.x = -
|
|
73
|
+
offset.x = -clampedW
|
|
71
74
|
}
|
|
72
75
|
}
|
|
73
76
|
|
|
74
|
-
w =
|
|
77
|
+
w = clampedW
|
|
75
78
|
}
|
|
76
79
|
}
|
|
77
80
|
|
|
78
81
|
if (h > 0) {
|
|
79
|
-
|
|
82
|
+
const clampedH = clamp(h, minHeight, maxHeight)
|
|
83
|
+
if (clampedH !== h) {
|
|
80
84
|
switch (handle) {
|
|
81
85
|
case 'top_left':
|
|
82
86
|
case 'top':
|
|
83
87
|
case 'top_right': {
|
|
84
|
-
offset.y = h -
|
|
88
|
+
offset.y = h - clampedH
|
|
85
89
|
break
|
|
86
90
|
}
|
|
87
91
|
case 'right':
|
|
88
92
|
case 'left': {
|
|
89
|
-
offset.y = (h -
|
|
93
|
+
offset.y = (h - clampedH) / 2
|
|
90
94
|
break
|
|
91
95
|
}
|
|
92
96
|
default: {
|
|
@@ -94,12 +98,13 @@ export function resizeBox<T extends TLBaseBoxShape>(
|
|
|
94
98
|
}
|
|
95
99
|
}
|
|
96
100
|
|
|
97
|
-
h =
|
|
101
|
+
h = clampedH
|
|
98
102
|
}
|
|
99
103
|
} else {
|
|
100
104
|
offset.y = h
|
|
101
105
|
h = -h
|
|
102
|
-
|
|
106
|
+
const clampedH = clamp(h, minHeight, maxHeight)
|
|
107
|
+
if (clampedH !== h) {
|
|
103
108
|
switch (handle) {
|
|
104
109
|
case 'top_left':
|
|
105
110
|
case 'top':
|
|
@@ -108,10 +113,10 @@ export function resizeBox<T extends TLBaseBoxShape>(
|
|
|
108
113
|
break
|
|
109
114
|
}
|
|
110
115
|
default: {
|
|
111
|
-
offset.y = -
|
|
116
|
+
offset.y = -clampedH
|
|
112
117
|
}
|
|
113
118
|
}
|
|
114
|
-
h =
|
|
119
|
+
h = clampedH
|
|
115
120
|
}
|
|
116
121
|
}
|
|
117
122
|
|
|
@@ -122,8 +127,8 @@ export function resizeBox<T extends TLBaseBoxShape>(
|
|
|
122
127
|
x,
|
|
123
128
|
y,
|
|
124
129
|
props: {
|
|
125
|
-
w
|
|
126
|
-
h
|
|
130
|
+
w,
|
|
131
|
+
h,
|
|
127
132
|
},
|
|
128
133
|
}
|
|
129
134
|
}
|
|
@@ -23,8 +23,9 @@ function getCursorCss(
|
|
|
23
23
|
const dx = 1 * c - 1 * s
|
|
24
24
|
const dy = 1 * s + 1 * c
|
|
25
25
|
|
|
26
|
+
// A raw '#' in the colour (hex) would end the unencoded data url as a fragment.
|
|
26
27
|
return (
|
|
27
|
-
`url("data:image/svg+xml,<svg height='32' width='32' viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg' style='color: ${color};'><defs><filter id='shadow' y='-40%' x='-40%' width='180px' height='180%' color-interpolation-filters='sRGB'><feDropShadow dx='${dx}' dy='${dy}' stdDeviation='1.2' flood-opacity='.5'/></filter></defs><g fill='none' transform='rotate(${
|
|
28
|
+
`url("data:image/svg+xml,<svg height='32' width='32' viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg' style='color: ${encodeURIComponent(color)};'><defs><filter id='shadow' y='-40%' x='-40%' width='180px' height='180%' color-interpolation-filters='sRGB'><feDropShadow dx='${dx}' dy='${dy}' stdDeviation='1.2' flood-opacity='.5'/></filter></defs><g fill='none' transform='rotate(${
|
|
28
29
|
r + tr
|
|
29
30
|
} 16 16)${f ? ` scale(-1,-1) translate(0, -32)` : ''}' filter='url(%23shadow)'>` +
|
|
30
31
|
svg.replaceAll(`"`, `'`) +
|
|
@@ -32,7 +33,8 @@ function getCursorCss(
|
|
|
32
33
|
)
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
// Cursors that map straight to a prerendered `--tl-cursor-*` css variable rather than a runtime svg.
|
|
37
|
+
const STATIC_CURSORS = new Set<TLCursorType>([
|
|
36
38
|
'default',
|
|
37
39
|
'pointer',
|
|
38
40
|
'cross',
|
|
@@ -43,11 +45,14 @@ const STATIC_CURSORS = [
|
|
|
43
45
|
'text',
|
|
44
46
|
'zoom-in',
|
|
45
47
|
'zoom-out',
|
|
46
|
-
|
|
48
|
+
'rotate',
|
|
49
|
+
'resize-edge',
|
|
50
|
+
'resize-corner',
|
|
51
|
+
])
|
|
47
52
|
|
|
48
53
|
type CursorFunction = (rotation: number, flip: boolean, color: string) => string
|
|
49
54
|
|
|
50
|
-
const
|
|
55
|
+
const DYNAMIC_CURSORS: Record<TLCursorType, CursorFunction> = {
|
|
51
56
|
none: () => 'none',
|
|
52
57
|
'ew-resize': (r, f, c) => getCursorCss(EDGE_SVG, r, 0, f, c),
|
|
53
58
|
'ns-resize': (r, f, c) => getCursorCss(EDGE_SVG, r, 90, f, c),
|
|
@@ -61,7 +66,8 @@ const CURSORS: Record<TLCursorType, CursorFunction> = {
|
|
|
61
66
|
|
|
62
67
|
/** @public */
|
|
63
68
|
export function getCursor(cursor: TLCursorType, rotation = 0, color = 'black') {
|
|
64
|
-
|
|
69
|
+
if (STATIC_CURSORS.has(cursor)) return `var(--tl-cursor-${cursor})`
|
|
70
|
+
return DYNAMIC_CURSORS[cursor](radiansToDegrees(rotation), false, color)
|
|
65
71
|
}
|
|
66
72
|
|
|
67
73
|
export function useCursor() {
|
|
@@ -73,8 +79,9 @@ export function useCursor() {
|
|
|
73
79
|
() => {
|
|
74
80
|
const { type, rotation } = editor.getInstanceState().cursor
|
|
75
81
|
|
|
76
|
-
|
|
77
|
-
|
|
82
|
+
// Static cursors don't need the theme colour, so skip reading it to avoid depending on it.
|
|
83
|
+
if (STATIC_CURSORS.has(type)) {
|
|
84
|
+
container.style.setProperty('--tl-cursor', getCursor(type))
|
|
78
85
|
return
|
|
79
86
|
}
|
|
80
87
|
|
|
@@ -18,6 +18,10 @@ export function useDocumentEvents() {
|
|
|
18
18
|
// blur release below needs the key remembered per code.
|
|
19
19
|
const heldKeysRef = useRef(new Map<string, string>())
|
|
20
20
|
|
|
21
|
+
// Non-modifier keys pressed while Meta was down. macOS never delivers their keyup, so
|
|
22
|
+
// `inputs.keys` would count them as held for good; see the Meta branch in `handleKeyUp`.
|
|
23
|
+
const keysPressedWithMetaRef = useRef(new Set<string>())
|
|
24
|
+
|
|
21
25
|
// Prevent the browser's default drag and drop behavior on our container (UI, etc)
|
|
22
26
|
useEffect(() => {
|
|
23
27
|
if (!container) return
|
|
@@ -217,6 +221,12 @@ export function useDocumentEvents() {
|
|
|
217
221
|
}
|
|
218
222
|
|
|
219
223
|
heldKeysRef.current.set(e.code, e.key)
|
|
224
|
+
// Only a fresh press counts. A key held from before Meta went down keeps auto-
|
|
225
|
+
// repeating, and those repeats carry `metaKey` too, which would make it look like
|
|
226
|
+
// it was pressed during the Meta window.
|
|
227
|
+
if (!e.repeat && e.metaKey && !MODIFIER_CODES.has(e.code)) {
|
|
228
|
+
keysPressedWithMetaRef.current.add(e.code)
|
|
229
|
+
}
|
|
220
230
|
editor.dispatch(info)
|
|
221
231
|
}
|
|
222
232
|
|
|
@@ -232,6 +242,13 @@ export function useDocumentEvents() {
|
|
|
232
242
|
return
|
|
233
243
|
}
|
|
234
244
|
|
|
245
|
+
// The native `metaKey` is still true on Meta's own keyup. Reported as-is it matches
|
|
246
|
+
// neither branch of the editor's modifier handling - which ignores `metaKey` on a
|
|
247
|
+
// key_up, then only arms the 150ms release when it reads false - so a cmd tap left
|
|
248
|
+
// `getMetaKey()` true until some later event happened to say otherwise.
|
|
249
|
+
const isMetaRelease = e.code === 'MetaLeft' || e.code === 'MetaRight'
|
|
250
|
+
const metaKey = isMetaRelease ? false : e.metaKey
|
|
251
|
+
|
|
235
252
|
const info: TLKeyboardEventInfo = {
|
|
236
253
|
type: 'keyboard',
|
|
237
254
|
name: 'key_up',
|
|
@@ -239,13 +256,32 @@ export function useDocumentEvents() {
|
|
|
239
256
|
code: e.code,
|
|
240
257
|
shiftKey: e.shiftKey,
|
|
241
258
|
altKey: e.altKey,
|
|
242
|
-
ctrlKey:
|
|
243
|
-
metaKey
|
|
244
|
-
accelKey: isAccelKey(e),
|
|
259
|
+
ctrlKey: metaKey || e.ctrlKey,
|
|
260
|
+
metaKey,
|
|
261
|
+
accelKey: isAccelKey({ ctrlKey: e.ctrlKey, metaKey }),
|
|
245
262
|
}
|
|
246
263
|
|
|
247
264
|
heldKeysRef.current.delete(e.code)
|
|
265
|
+
keysPressedWithMetaRef.current.delete(e.code)
|
|
248
266
|
editor.dispatch(info)
|
|
267
|
+
|
|
268
|
+
// macOS swallows the keyup of a non-modifier key pressed while Meta is held, so a
|
|
269
|
+
// stuck ArrowUp from Cmd+ArrowUp made every later arrow nudge diagonally: the nudge
|
|
270
|
+
// sums all held arrows. Anything held from before the Meta window still gets its
|
|
271
|
+
// own keyup, so only drop what was pressed inside it. That is still one too many -
|
|
272
|
+
// the key you are *still* holding is not owed anything - and the key_repeat case
|
|
273
|
+
// in `Editor.dispatch` is what takes that one back.
|
|
274
|
+
//
|
|
275
|
+
// Drop them rather than replaying a key_up: a tool's onKeyUp is a real action at
|
|
276
|
+
// the wrong moment, and a synthetic Enter re-entered shape editing and stole the
|
|
277
|
+
// focus that cmd+Enter's a11y action had just put on the style toolbar.
|
|
278
|
+
if (isMetaRelease) {
|
|
279
|
+
for (const code of keysPressedWithMetaRef.current) {
|
|
280
|
+
editor.inputs.keys.delete(code)
|
|
281
|
+
heldKeysRef.current.delete(code)
|
|
282
|
+
}
|
|
283
|
+
keysPressedWithMetaRef.current.clear()
|
|
284
|
+
}
|
|
249
285
|
}
|
|
250
286
|
|
|
251
287
|
function handleTouchStart(e: TouchEvent) {
|
|
@@ -334,6 +370,7 @@ export function useDocumentEvents() {
|
|
|
334
370
|
})
|
|
335
371
|
}
|
|
336
372
|
heldKeys.clear()
|
|
373
|
+
keysPressedWithMetaRef.current.clear()
|
|
337
374
|
}
|
|
338
375
|
|
|
339
376
|
win.addEventListener('blur', handleWindowBlur)
|
|
@@ -343,6 +380,17 @@ export function useDocumentEvents() {
|
|
|
343
380
|
}, [editor])
|
|
344
381
|
}
|
|
345
382
|
|
|
383
|
+
const MODIFIER_CODES = new Set([
|
|
384
|
+
'ShiftLeft',
|
|
385
|
+
'ShiftRight',
|
|
386
|
+
'AltLeft',
|
|
387
|
+
'AltRight',
|
|
388
|
+
'ControlLeft',
|
|
389
|
+
'ControlRight',
|
|
390
|
+
'MetaLeft',
|
|
391
|
+
'MetaRight',
|
|
392
|
+
])
|
|
393
|
+
|
|
346
394
|
function areShortcutsDisabled(editor: Editor) {
|
|
347
395
|
return (
|
|
348
396
|
editor.menus.hasOpenMenus() ||
|
|
@@ -678,6 +678,226 @@ describe('LicenseManager', () => {
|
|
|
678
678
|
)
|
|
679
679
|
})
|
|
680
680
|
|
|
681
|
+
describe('Canonical tldraw hosts', () => {
|
|
682
|
+
let fetchSpy: ReturnType<typeof vi.fn>
|
|
683
|
+
|
|
684
|
+
beforeEach(() => {
|
|
685
|
+
process.env.NODE_ENV = 'production'
|
|
686
|
+
fetchSpy = vi.fn(() => Promise.resolve(new Response()))
|
|
687
|
+
vi.stubGlobal('fetch', fetchSpy)
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
afterEach(() => {
|
|
691
|
+
vi.unstubAllGlobals()
|
|
692
|
+
process.env.NODE_ENV = 'test'
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
// The shared licenseManager was built against jsdom's localhost default, so it's in dev mode.
|
|
696
|
+
// Build one on the host itself so these run the constructor path a real page on our domains
|
|
697
|
+
// takes: production dev detection, license state, feature flags and the tracking ping.
|
|
698
|
+
async function createManagerOnHost(url: string, licenseKey = '') {
|
|
699
|
+
// @ts-ignore
|
|
700
|
+
delete window.location
|
|
701
|
+
// @ts-ignore
|
|
702
|
+
window.location = new URL(url)
|
|
703
|
+
|
|
704
|
+
const manager = new LicenseManager(licenseKey, keyPair.publicKey)
|
|
705
|
+
await vi.waitFor(() => expect(manager.state.get()).not.toBe('pending'))
|
|
706
|
+
return manager
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
it('Licenses tldraw-owned hosts with a watermark when no key is provided', async () => {
|
|
710
|
+
const hosts = [
|
|
711
|
+
'https://tldraw.com',
|
|
712
|
+
'https://www.tldraw.com',
|
|
713
|
+
'https://makereal.tldraw.com',
|
|
714
|
+
'https://tldraw.dev',
|
|
715
|
+
'https://staging.tldraw.dev',
|
|
716
|
+
'https://preview.tldraw.club',
|
|
717
|
+
'https://fairies.tldraw.workers.dev',
|
|
718
|
+
'https://teach.tldraw.xyz',
|
|
719
|
+
]
|
|
720
|
+
|
|
721
|
+
for (const host of hosts) {
|
|
722
|
+
const manager = await createManagerOnHost(host)
|
|
723
|
+
|
|
724
|
+
expect(manager.isDevelopment).toBe(false)
|
|
725
|
+
expect(await manager.getLicenseFromKey('')).toMatchObject({
|
|
726
|
+
isLicenseParseable: true,
|
|
727
|
+
isDomainValid: true,
|
|
728
|
+
isLicensedWithWatermark: true,
|
|
729
|
+
isCollaborationEnabled: true,
|
|
730
|
+
isCommentingEnabled: true,
|
|
731
|
+
isAnnualLicenseExpired: false,
|
|
732
|
+
isPerpetualLicenseExpired: false,
|
|
733
|
+
daysSinceExpiry: 0,
|
|
734
|
+
})
|
|
735
|
+
expect(manager.state.get()).toBe('licensed-with-watermark')
|
|
736
|
+
expect(manager.featureFlags.get()).toEqual({ collaboration: true, commenting: true })
|
|
737
|
+
manager.dispose()
|
|
738
|
+
}
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
it('Does not license hosts that merely look like ours', async () => {
|
|
742
|
+
const hosts = [
|
|
743
|
+
'https://nottldraw.com',
|
|
744
|
+
'https://tldraw.com.example.com',
|
|
745
|
+
'https://eviltldraw.dev',
|
|
746
|
+
'https://tldraw.co',
|
|
747
|
+
'https://tldraw.workers.dev.example.com',
|
|
748
|
+
]
|
|
749
|
+
|
|
750
|
+
for (const host of hosts) {
|
|
751
|
+
const manager = await createManagerOnHost(host)
|
|
752
|
+
|
|
753
|
+
expect(await manager.getLicenseFromKey('')).toMatchObject({
|
|
754
|
+
isLicenseParseable: false,
|
|
755
|
+
reason: 'no-key-provided',
|
|
756
|
+
})
|
|
757
|
+
expect(manager.state.get()).toBe('unlicensed-production')
|
|
758
|
+
expect(manager.featureFlags.get()).toEqual({ collaboration: false, commenting: false })
|
|
759
|
+
manager.dispose()
|
|
760
|
+
}
|
|
761
|
+
})
|
|
762
|
+
|
|
763
|
+
it('Licenses our VS Code extension webview when no key is provided', async () => {
|
|
764
|
+
const manager = await createManagerOnHost(
|
|
765
|
+
'vscode-webview://1ipd8pun8ud7nd7hv9d112g7evi7m10vak9vviuvia66ou6aibp3/index.html?id=6ec2dc7a-afe9-45d9-bd71-1749f9568d28&swVersion=4&extensionId=tldraw-org.tldraw-vscode&platform=electron'
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
expect(await manager.getLicenseFromKey('')).toMatchObject({
|
|
769
|
+
isLicenseParseable: true,
|
|
770
|
+
isDomainValid: true,
|
|
771
|
+
isLicensedWithWatermark: true,
|
|
772
|
+
})
|
|
773
|
+
expect(manager.state.get()).toBe('licensed-with-watermark')
|
|
774
|
+
manager.dispose()
|
|
775
|
+
})
|
|
776
|
+
|
|
777
|
+
it('Does not license other VS Code extension webviews', async () => {
|
|
778
|
+
const urls = [
|
|
779
|
+
'vscode-webview://abc/index.html?extensionId=evil-org.evil-vscode&x=tldraw-org.tldraw-vscode',
|
|
780
|
+
'vscode-webview://tldraw-org.tldraw-vscode/index.html?extensionId=evil-org.evil-vscode',
|
|
781
|
+
]
|
|
782
|
+
|
|
783
|
+
for (const url of urls) {
|
|
784
|
+
const manager = await createManagerOnHost(url)
|
|
785
|
+
|
|
786
|
+
expect(await manager.getLicenseFromKey('')).toMatchObject({
|
|
787
|
+
isLicenseParseable: false,
|
|
788
|
+
reason: 'no-key-provided',
|
|
789
|
+
})
|
|
790
|
+
manager.dispose()
|
|
791
|
+
}
|
|
792
|
+
})
|
|
793
|
+
|
|
794
|
+
it('Never sends a watermark tracking ping for our own domains', async () => {
|
|
795
|
+
const manager = await createManagerOnHost('https://makereal.tldraw.com')
|
|
796
|
+
|
|
797
|
+
expect(manager.state.get()).toBe('licensed-with-watermark')
|
|
798
|
+
expect(fetchSpy).not.toHaveBeenCalled()
|
|
799
|
+
manager.dispose()
|
|
800
|
+
})
|
|
801
|
+
|
|
802
|
+
it('Still tracks a real watermarked key on one of our domains', async () => {
|
|
803
|
+
const licenseKey = await generateLicenseKey(
|
|
804
|
+
JSON.stringify([
|
|
805
|
+
'a-real-key',
|
|
806
|
+
['*.tldraw.com'],
|
|
807
|
+
FLAGS.ANNUAL_LICENSE | FLAGS.WITH_WATERMARK,
|
|
808
|
+
expiryDate,
|
|
809
|
+
]),
|
|
810
|
+
keyPair
|
|
811
|
+
)
|
|
812
|
+
const manager = await createManagerOnHost('https://makereal.tldraw.com', licenseKey)
|
|
813
|
+
|
|
814
|
+
expect(manager.state.get()).toBe('licensed-with-watermark')
|
|
815
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
|
816
|
+
const trackUrl = new URL(fetchSpy.mock.calls[0][0] as string)
|
|
817
|
+
expect(trackUrl.searchParams.get('license_type')).toBe('with_watermark')
|
|
818
|
+
expect(trackUrl.searchParams.get('license_id')).toBe('a-real-key')
|
|
819
|
+
manager.dispose()
|
|
820
|
+
})
|
|
821
|
+
|
|
822
|
+
it('Lets an explicitly provided key win over the canonical-host grant', async () => {
|
|
823
|
+
const licenseKey = await generateLicenseKey(
|
|
824
|
+
JSON.stringify(['a-real-key', ['*.tldraw.com'], FLAGS.ANNUAL_LICENSE, expiryDate]),
|
|
825
|
+
keyPair
|
|
826
|
+
)
|
|
827
|
+
const manager = await createManagerOnHost('https://tldraw.com', licenseKey)
|
|
828
|
+
|
|
829
|
+
const result = (await manager.getLicenseFromKey(licenseKey)) as ValidLicenseKeyResult
|
|
830
|
+
expect(result.license.id).toBe('a-real-key')
|
|
831
|
+
expect(result.isLicensedWithWatermark).toBe(false)
|
|
832
|
+
expect(manager.state.get()).toBe('licensed')
|
|
833
|
+
manager.dispose()
|
|
834
|
+
})
|
|
835
|
+
})
|
|
836
|
+
|
|
837
|
+
describe('Tracked licenses', () => {
|
|
838
|
+
let fetchSpy: ReturnType<typeof vi.fn>
|
|
839
|
+
|
|
840
|
+
beforeEach(() => {
|
|
841
|
+
process.env.NODE_ENV = 'production'
|
|
842
|
+
fetchSpy = vi.fn(() => Promise.resolve(new Response()))
|
|
843
|
+
vi.stubGlobal('fetch', fetchSpy)
|
|
844
|
+
})
|
|
845
|
+
|
|
846
|
+
afterEach(() => {
|
|
847
|
+
vi.unstubAllGlobals()
|
|
848
|
+
process.env.NODE_ENV = 'test'
|
|
849
|
+
})
|
|
850
|
+
|
|
851
|
+
async function createManagerOnHost(url: string, flags: number) {
|
|
852
|
+
// @ts-ignore
|
|
853
|
+
delete window.location
|
|
854
|
+
// @ts-ignore
|
|
855
|
+
window.location = new URL(url)
|
|
856
|
+
|
|
857
|
+
const licenseKey = await generateLicenseKey(
|
|
858
|
+
JSON.stringify(['tracked-key', ['*'], flags, expiryDate]),
|
|
859
|
+
keyPair
|
|
860
|
+
)
|
|
861
|
+
const manager = new LicenseManager(licenseKey, keyPair.publicKey)
|
|
862
|
+
await vi.waitFor(() => expect(manager.state.get()).not.toBe('pending'))
|
|
863
|
+
return manager
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
it('Pings for a paid license with the tracked flag', async () => {
|
|
867
|
+
const manager = await createManagerOnHost(
|
|
868
|
+
'https://app.example.com',
|
|
869
|
+
FLAGS.ANNUAL_LICENSE | FLAGS.TRACKED
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
expect(manager.state.get()).toBe('licensed')
|
|
873
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
|
874
|
+
const trackUrl = new URL(fetchSpy.mock.calls[0][0] as string)
|
|
875
|
+
expect(trackUrl.searchParams.get('license_type')).toBe('tracked')
|
|
876
|
+
expect(trackUrl.searchParams.get('license_id')).toBe('tracked-key')
|
|
877
|
+
expect(trackUrl.searchParams.get('sku')).toBe('annual')
|
|
878
|
+
expect(trackUrl.searchParams.get('url')).toBe('https://app.example.com/')
|
|
879
|
+
manager.dispose()
|
|
880
|
+
})
|
|
881
|
+
|
|
882
|
+
it('Does not ping for the same paid license without the tracked flag', async () => {
|
|
883
|
+
const manager = await createManagerOnHost('https://app.example.com', FLAGS.ANNUAL_LICENSE)
|
|
884
|
+
|
|
885
|
+
expect(manager.state.get()).toBe('licensed')
|
|
886
|
+
expect(fetchSpy).not.toHaveBeenCalled()
|
|
887
|
+
manager.dispose()
|
|
888
|
+
})
|
|
889
|
+
|
|
890
|
+
it('Does not ping for a tracked license in development', async () => {
|
|
891
|
+
const manager = await createManagerOnHost(
|
|
892
|
+
'http://localhost:3000',
|
|
893
|
+
FLAGS.ANNUAL_LICENSE | FLAGS.TRACKED
|
|
894
|
+
)
|
|
895
|
+
|
|
896
|
+
expect(fetchSpy).not.toHaveBeenCalled()
|
|
897
|
+
manager.dispose()
|
|
898
|
+
})
|
|
899
|
+
})
|
|
900
|
+
|
|
681
901
|
describe('License types and flags', () => {
|
|
682
902
|
it('Checks for internal license', async () => {
|
|
683
903
|
const internalLicenseInfo = JSON.parse(STANDARD_LICENSE_INFO)
|