@tldraw/tlschema 4.2.2 → 4.2.3
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/bindings/TLBaseBinding.js.map +2 -2
- package/dist-cjs/createTLSchema.js.map +2 -2
- package/dist-cjs/index.d.ts +71 -242
- package/dist-cjs/index.js +1 -4
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/misc/TLOpacity.js +5 -1
- package/dist-cjs/misc/TLOpacity.js.map +2 -2
- package/dist-cjs/misc/TLRichText.js +1 -5
- package/dist-cjs/misc/TLRichText.js.map +2 -2
- package/dist-cjs/records/TLAsset.js.map +1 -1
- package/dist-cjs/records/TLBinding.js.map +2 -2
- package/dist-cjs/records/TLShape.js.map +2 -2
- package/dist-cjs/shapes/ShapeWithCrop.js.map +1 -1
- package/dist-cjs/shapes/TLArrowShape.js +13 -26
- package/dist-cjs/shapes/TLArrowShape.js.map +2 -2
- package/dist-cjs/shapes/TLBaseShape.js.map +2 -2
- package/dist-cjs/shapes/TLDrawShape.js +4 -37
- package/dist-cjs/shapes/TLDrawShape.js.map +2 -2
- package/dist-cjs/shapes/TLEmbedShape.js +0 -17
- package/dist-cjs/shapes/TLEmbedShape.js.map +2 -2
- package/dist-cjs/shapes/TLGeoShape.js +1 -12
- package/dist-cjs/shapes/TLGeoShape.js.map +2 -2
- package/dist-cjs/shapes/TLHighlightShape.js +2 -29
- package/dist-cjs/shapes/TLHighlightShape.js.map +2 -2
- package/dist-cjs/shapes/TLNoteShape.js +1 -12
- package/dist-cjs/shapes/TLNoteShape.js.map +2 -2
- package/dist-cjs/shapes/TLTextShape.js +1 -12
- package/dist-cjs/shapes/TLTextShape.js.map +2 -2
- package/dist-cjs/store-migrations.js +15 -15
- package/dist-cjs/store-migrations.js.map +2 -2
- package/dist-esm/bindings/TLBaseBinding.mjs.map +2 -2
- package/dist-esm/createTLSchema.mjs.map +2 -2
- package/dist-esm/index.d.mts +71 -242
- package/dist-esm/index.mjs +1 -5
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/misc/TLOpacity.mjs +5 -1
- package/dist-esm/misc/TLOpacity.mjs.map +2 -2
- package/dist-esm/misc/TLRichText.mjs +1 -5
- package/dist-esm/misc/TLRichText.mjs.map +2 -2
- package/dist-esm/records/TLAsset.mjs.map +1 -1
- package/dist-esm/records/TLBinding.mjs.map +2 -2
- package/dist-esm/records/TLShape.mjs.map +2 -2
- package/dist-esm/shapes/TLArrowShape.mjs +13 -26
- package/dist-esm/shapes/TLArrowShape.mjs.map +2 -2
- package/dist-esm/shapes/TLBaseShape.mjs.map +2 -2
- package/dist-esm/shapes/TLDrawShape.mjs +4 -37
- package/dist-esm/shapes/TLDrawShape.mjs.map +2 -2
- package/dist-esm/shapes/TLEmbedShape.mjs +0 -17
- package/dist-esm/shapes/TLEmbedShape.mjs.map +2 -2
- package/dist-esm/shapes/TLGeoShape.mjs +1 -12
- package/dist-esm/shapes/TLGeoShape.mjs.map +2 -2
- package/dist-esm/shapes/TLHighlightShape.mjs +2 -29
- package/dist-esm/shapes/TLHighlightShape.mjs.map +2 -2
- package/dist-esm/shapes/TLNoteShape.mjs +1 -12
- package/dist-esm/shapes/TLNoteShape.mjs.map +2 -2
- package/dist-esm/shapes/TLTextShape.mjs +1 -12
- package/dist-esm/shapes/TLTextShape.mjs.map +2 -2
- package/dist-esm/store-migrations.mjs +15 -15
- package/dist-esm/store-migrations.mjs.map +2 -2
- package/package.json +8 -8
- package/src/__tests__/migrationTestUtils.ts +3 -9
- package/src/assets/TLBookmarkAsset.test.ts +96 -0
- package/src/assets/TLImageAsset.test.ts +213 -0
- package/src/assets/TLVideoAsset.test.ts +105 -0
- package/src/bindings/TLArrowBinding.test.ts +55 -0
- package/src/bindings/TLBaseBinding.ts +14 -25
- package/src/createTLSchema.ts +2 -8
- package/src/index.ts +0 -9
- package/src/migrations.test.ts +1 -149
- package/src/misc/TLOpacity.ts +5 -1
- package/src/misc/TLRichText.ts +1 -6
- package/src/misc/id-validator.test.ts +50 -0
- package/src/records/TLAsset.test.ts +234 -0
- package/src/records/TLAsset.ts +2 -2
- package/src/records/TLBinding.test.ts +22 -0
- package/src/records/TLBinding.ts +23 -65
- package/src/records/TLCamera.test.ts +19 -0
- package/src/records/TLDocument.test.ts +35 -0
- package/src/records/TLInstance.test.ts +201 -0
- package/src/records/TLPage.test.ts +110 -0
- package/src/records/TLPageState.test.ts +228 -0
- package/src/records/TLPointer.test.ts +63 -0
- package/src/records/TLPresence.test.ts +190 -0
- package/src/records/TLRecord.test.ts +70 -0
- package/src/records/TLShape.test.ts +232 -0
- package/src/records/TLShape.ts +5 -100
- package/src/shapes/ShapeWithCrop.test.ts +18 -0
- package/src/shapes/ShapeWithCrop.ts +2 -2
- package/src/shapes/TLArrowShape.test.ts +505 -0
- package/src/shapes/TLArrowShape.ts +14 -28
- package/src/shapes/TLBaseShape.test.ts +142 -0
- package/src/shapes/TLBaseShape.ts +10 -34
- package/src/shapes/TLBookmarkShape.test.ts +122 -0
- package/src/shapes/TLDrawShape.test.ts +177 -0
- package/src/shapes/TLDrawShape.ts +12 -59
- package/src/shapes/TLEmbedShape.test.ts +286 -0
- package/src/shapes/TLEmbedShape.ts +0 -17
- package/src/shapes/TLFrameShape.test.ts +71 -0
- package/src/shapes/TLGeoShape.test.ts +247 -0
- package/src/shapes/TLGeoShape.ts +1 -14
- package/src/shapes/TLGroupShape.test.ts +59 -0
- package/src/shapes/TLHighlightShape.test.ts +325 -0
- package/src/shapes/TLHighlightShape.ts +0 -37
- package/src/shapes/TLImageShape.test.ts +534 -0
- package/src/shapes/TLLineShape.test.ts +269 -0
- package/src/shapes/TLNoteShape.test.ts +1568 -0
- package/src/shapes/TLNoteShape.ts +1 -15
- package/src/shapes/TLTextShape.test.ts +407 -0
- package/src/shapes/TLTextShape.ts +2 -16
- package/src/shapes/TLVideoShape.test.ts +112 -0
- package/src/store-migrations.ts +16 -17
- package/src/styles/TLColorStyle.test.ts +439 -0
- package/dist-cjs/misc/b64Vecs.js +0 -224
- package/dist-cjs/misc/b64Vecs.js.map +0 -7
- package/dist-esm/misc/b64Vecs.mjs +0 -204
- package/dist-esm/misc/b64Vecs.mjs.map +0 -7
- package/src/misc/b64Vecs.ts +0 -308
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { T } from '@tldraw/validate'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { createShapeValidator, parentIdValidator, shapeIdValidator } from './TLBaseShape'
|
|
4
|
+
|
|
5
|
+
describe('TLBaseShape', () => {
|
|
6
|
+
describe('parentIdValidator', () => {
|
|
7
|
+
it('should accept valid page and shape parent IDs', () => {
|
|
8
|
+
expect(() => parentIdValidator.validate('page:main')).not.toThrow()
|
|
9
|
+
expect(() => parentIdValidator.validate('shape:frame1')).not.toThrow()
|
|
10
|
+
expect(parentIdValidator.validate('page:main')).toBe('page:main')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('should reject invalid parent ID prefixes', () => {
|
|
14
|
+
expect(() => parentIdValidator.validate('invalid:123')).toThrow(
|
|
15
|
+
'Parent ID must start with "page:" or "shape:"'
|
|
16
|
+
)
|
|
17
|
+
expect(() => parentIdValidator.validate('asset:123')).toThrow(
|
|
18
|
+
'Parent ID must start with "page:" or "shape:"'
|
|
19
|
+
)
|
|
20
|
+
expect(() => parentIdValidator.validate('page-main')).toThrow(
|
|
21
|
+
'Parent ID must start with "page:" or "shape:"'
|
|
22
|
+
)
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('shapeIdValidator', () => {
|
|
27
|
+
it('should accept valid shape IDs', () => {
|
|
28
|
+
expect(() => shapeIdValidator.validate('shape:abc123')).not.toThrow()
|
|
29
|
+
expect(shapeIdValidator.validate('shape:test')).toBe('shape:test')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('should reject non-shape IDs', () => {
|
|
33
|
+
expect(() => shapeIdValidator.validate('page:123')).toThrow(
|
|
34
|
+
'shape ID must start with "shape:"'
|
|
35
|
+
)
|
|
36
|
+
expect(() => shapeIdValidator.validate('asset:123')).toThrow(
|
|
37
|
+
'shape ID must start with "shape:"'
|
|
38
|
+
)
|
|
39
|
+
expect(() => shapeIdValidator.validate('shape-abc123')).toThrow(
|
|
40
|
+
'shape ID must start with "shape:"'
|
|
41
|
+
)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('createShapeValidator', () => {
|
|
46
|
+
it('should create validator for shape with no custom props', () => {
|
|
47
|
+
const validator = createShapeValidator('simple')
|
|
48
|
+
|
|
49
|
+
const validShape = {
|
|
50
|
+
id: 'shape:simple123',
|
|
51
|
+
typeName: 'shape',
|
|
52
|
+
type: 'simple',
|
|
53
|
+
x: 100,
|
|
54
|
+
y: 200,
|
|
55
|
+
rotation: 0.5,
|
|
56
|
+
index: 'a1',
|
|
57
|
+
parentId: 'page:main',
|
|
58
|
+
isLocked: false,
|
|
59
|
+
opacity: 0.8,
|
|
60
|
+
props: {},
|
|
61
|
+
meta: {},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
expect(() => validator.validate(validShape)).not.toThrow()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('should create validator for shape with custom props', () => {
|
|
68
|
+
const validator = createShapeValidator('custom', {
|
|
69
|
+
width: T.number,
|
|
70
|
+
height: T.number,
|
|
71
|
+
color: T.string,
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const validShape = {
|
|
75
|
+
id: 'shape:custom456',
|
|
76
|
+
typeName: 'shape',
|
|
77
|
+
type: 'custom',
|
|
78
|
+
x: 50,
|
|
79
|
+
y: 75,
|
|
80
|
+
rotation: 1.0,
|
|
81
|
+
index: 'a2',
|
|
82
|
+
parentId: 'shape:frame1',
|
|
83
|
+
isLocked: true,
|
|
84
|
+
opacity: 0.5,
|
|
85
|
+
props: {
|
|
86
|
+
width: 150,
|
|
87
|
+
height: 100,
|
|
88
|
+
color: 'blue',
|
|
89
|
+
},
|
|
90
|
+
meta: {},
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
expect(() => validator.validate(validShape)).not.toThrow()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('should reject shapes with wrong type', () => {
|
|
97
|
+
const validator = createShapeValidator('test')
|
|
98
|
+
|
|
99
|
+
const invalidShape = {
|
|
100
|
+
id: 'shape:test',
|
|
101
|
+
typeName: 'shape',
|
|
102
|
+
type: 'wrong',
|
|
103
|
+
x: 0,
|
|
104
|
+
y: 0,
|
|
105
|
+
rotation: 0,
|
|
106
|
+
index: 'a1',
|
|
107
|
+
parentId: 'page:main',
|
|
108
|
+
isLocked: false,
|
|
109
|
+
opacity: 1,
|
|
110
|
+
props: {},
|
|
111
|
+
meta: {},
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
expect(() => validator.validate(invalidShape)).toThrow()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('should reject shapes with invalid custom props', () => {
|
|
118
|
+
const validator = createShapeValidator('custom', {
|
|
119
|
+
width: T.number,
|
|
120
|
+
height: T.number,
|
|
121
|
+
color: T.string,
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const invalidShape = {
|
|
125
|
+
id: 'shape:custom123',
|
|
126
|
+
typeName: 'shape',
|
|
127
|
+
type: 'custom',
|
|
128
|
+
x: 0,
|
|
129
|
+
y: 0,
|
|
130
|
+
rotation: 0,
|
|
131
|
+
index: 'a1',
|
|
132
|
+
parentId: 'page:main',
|
|
133
|
+
isLocked: false,
|
|
134
|
+
opacity: 1,
|
|
135
|
+
meta: {},
|
|
136
|
+
props: { width: '100' }, // Wrong type - should be number
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
expect(() => validator.validate(invalidShape)).toThrow()
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
})
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BaseRecord } from '@tldraw/store'
|
|
1
2
|
import { IndexKey, JsonObject } from '@tldraw/utils'
|
|
2
3
|
import { T } from '@tldraw/validate'
|
|
3
4
|
import { TLOpacityType, opacityValidator } from '../misc/TLOpacity'
|
|
@@ -8,37 +9,18 @@ import { TLParentId, TLShapeId } from '../records/TLShape'
|
|
|
8
9
|
* Base interface for all shapes in tldraw.
|
|
9
10
|
*
|
|
10
11
|
* This interface defines the common properties that all shapes share, regardless of their
|
|
11
|
-
* specific type. Every
|
|
12
|
-
*
|
|
13
|
-
* Custom shapes should be defined by augmenting the TLGlobalShapePropsMap type and getting the shape type from the TLShape type.
|
|
12
|
+
* specific type. Every shape extends this base with additional type-specific properties.
|
|
14
13
|
*
|
|
15
14
|
* @example
|
|
16
15
|
* ```ts
|
|
17
|
-
* // Define a
|
|
18
|
-
* interface
|
|
19
|
-
* kind: TLArrowShapeKind
|
|
20
|
-
* labelColor: TLDefaultColorStyle
|
|
21
|
-
* color: TLDefaultColorStyle
|
|
22
|
-
* fill: TLDefaultFillStyle
|
|
23
|
-
* dash: TLDefaultDashStyle
|
|
24
|
-
* size: TLDefaultSizeStyle
|
|
25
|
-
* arrowheadStart: TLArrowShapeArrowheadStyle
|
|
26
|
-
* arrowheadEnd: TLArrowShapeArrowheadStyle
|
|
27
|
-
* font: TLDefaultFontStyle
|
|
28
|
-
* start: VecModel
|
|
29
|
-
* end: VecModel
|
|
30
|
-
* bend: number
|
|
31
|
-
* richText: TLRichText
|
|
32
|
-
* labelPosition: number
|
|
33
|
-
* scale: number
|
|
34
|
-
* elbowMidPoint: number
|
|
35
|
-
* }> {}
|
|
16
|
+
* // Define a custom shape type
|
|
17
|
+
* interface MyCustomShape extends TLBaseShape<'custom', { size: number; color: string }> {}
|
|
36
18
|
*
|
|
37
19
|
* // Create a shape instance
|
|
38
|
-
* const
|
|
20
|
+
* const myShape: MyCustomShape = {
|
|
39
21
|
* id: 'shape:abc123',
|
|
40
22
|
* typeName: 'shape',
|
|
41
|
-
* type: '
|
|
23
|
+
* type: 'custom',
|
|
42
24
|
* x: 100,
|
|
43
25
|
* y: 200,
|
|
44
26
|
* rotation: 0,
|
|
@@ -47,10 +29,8 @@ import { TLParentId, TLShapeId } from '../records/TLShape'
|
|
|
47
29
|
* isLocked: false,
|
|
48
30
|
* opacity: 1,
|
|
49
31
|
* props: {
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* end: { x: 100, y: 100 },
|
|
53
|
-
* // ... other props
|
|
32
|
+
* size: 50,
|
|
33
|
+
* color: 'blue'
|
|
54
34
|
* },
|
|
55
35
|
* meta: {}
|
|
56
36
|
* }
|
|
@@ -58,12 +38,8 @@ import { TLParentId, TLShapeId } from '../records/TLShape'
|
|
|
58
38
|
*
|
|
59
39
|
* @public
|
|
60
40
|
*/
|
|
61
|
-
export interface TLBaseShape<Type extends string, Props extends object>
|
|
62
|
-
|
|
63
|
-
// and for that reason those "base members" have to be declared manually here
|
|
64
|
-
readonly id: TLShapeId
|
|
65
|
-
readonly typeName: 'shape'
|
|
66
|
-
|
|
41
|
+
export interface TLBaseShape<Type extends string, Props extends object>
|
|
42
|
+
extends BaseRecord<'shape', TLShapeId> {
|
|
67
43
|
type: Type
|
|
68
44
|
x: number
|
|
69
45
|
y: number
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { getTestMigration } from '../__tests__/migrationTestUtils'
|
|
3
|
+
import { bookmarkShapeProps, bookmarkShapeVersions } from './TLBookmarkShape'
|
|
4
|
+
|
|
5
|
+
describe('TLBookmarkShape', () => {
|
|
6
|
+
describe('bookmarkShapeProps validation', () => {
|
|
7
|
+
it('should validate width as nonZeroNumber', () => {
|
|
8
|
+
// Valid non-zero positive numbers
|
|
9
|
+
expect(() => bookmarkShapeProps.w.validate(0.1)).not.toThrow()
|
|
10
|
+
expect(() => bookmarkShapeProps.w.validate(100)).not.toThrow()
|
|
11
|
+
|
|
12
|
+
// Invalid: zero and negative
|
|
13
|
+
expect(() => bookmarkShapeProps.w.validate(0)).toThrow()
|
|
14
|
+
expect(() => bookmarkShapeProps.w.validate(-1)).toThrow()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('should validate height as nonZeroNumber', () => {
|
|
18
|
+
// Valid non-zero positive numbers
|
|
19
|
+
expect(() => bookmarkShapeProps.h.validate(0.1)).not.toThrow()
|
|
20
|
+
expect(() => bookmarkShapeProps.h.validate(100)).not.toThrow()
|
|
21
|
+
|
|
22
|
+
// Invalid: zero and negative
|
|
23
|
+
expect(() => bookmarkShapeProps.h.validate(0)).toThrow()
|
|
24
|
+
expect(() => bookmarkShapeProps.h.validate(-1)).toThrow()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('should validate assetId as nullable asset ID', () => {
|
|
28
|
+
// Valid asset IDs
|
|
29
|
+
expect(() => bookmarkShapeProps.assetId.validate(null)).not.toThrow()
|
|
30
|
+
expect(() => bookmarkShapeProps.assetId.validate('asset:bookmark123')).not.toThrow()
|
|
31
|
+
|
|
32
|
+
// Invalid asset IDs
|
|
33
|
+
expect(() => bookmarkShapeProps.assetId.validate('shape:notasset')).toThrow()
|
|
34
|
+
expect(() => bookmarkShapeProps.assetId.validate('bookmark123')).toThrow()
|
|
35
|
+
expect(() => bookmarkShapeProps.assetId.validate(undefined)).toThrow()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('should validate url as linkUrl', () => {
|
|
39
|
+
// Valid URLs
|
|
40
|
+
expect(() => bookmarkShapeProps.url.validate('')).not.toThrow()
|
|
41
|
+
expect(() => bookmarkShapeProps.url.validate('https://example.com')).not.toThrow()
|
|
42
|
+
|
|
43
|
+
// Invalid URLs
|
|
44
|
+
expect(() => bookmarkShapeProps.url.validate('not-a-url')).toThrow()
|
|
45
|
+
expect(() => bookmarkShapeProps.url.validate('javascript:alert("xss")')).toThrow()
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('NullAssetId migration', () => {
|
|
50
|
+
const { up, down } = getTestMigration(bookmarkShapeVersions.NullAssetId)
|
|
51
|
+
|
|
52
|
+
it('should add assetId as null when undefined', () => {
|
|
53
|
+
const oldRecord = {
|
|
54
|
+
props: {
|
|
55
|
+
w: 300,
|
|
56
|
+
h: 320,
|
|
57
|
+
url: 'https://example.com',
|
|
58
|
+
// assetId undefined
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const result = up(oldRecord)
|
|
63
|
+
expect(result.props.assetId).toBeNull()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('should preserve existing assetId when present', () => {
|
|
67
|
+
const oldRecord = {
|
|
68
|
+
props: {
|
|
69
|
+
w: 300,
|
|
70
|
+
h: 320,
|
|
71
|
+
url: 'https://example.com',
|
|
72
|
+
assetId: 'asset:existing123',
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const result = up(oldRecord)
|
|
77
|
+
expect(result.props.assetId).toBe('asset:existing123')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('should throw on retired down migration', () => {
|
|
81
|
+
expect(() => down({})).toThrow()
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('MakeUrlsValid migration', () => {
|
|
86
|
+
const { up, down } = getTestMigration(bookmarkShapeVersions.MakeUrlsValid)
|
|
87
|
+
|
|
88
|
+
it('should set invalid URLs to empty string', () => {
|
|
89
|
+
const oldRecord = {
|
|
90
|
+
props: {
|
|
91
|
+
w: 300,
|
|
92
|
+
h: 320,
|
|
93
|
+
assetId: null,
|
|
94
|
+
url: 'not-a-valid-url',
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const result = up(oldRecord)
|
|
99
|
+
expect(result.props.url).toBe('')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('should preserve valid URLs', () => {
|
|
103
|
+
const oldRecord = {
|
|
104
|
+
props: {
|
|
105
|
+
w: 300,
|
|
106
|
+
h: 320,
|
|
107
|
+
assetId: null,
|
|
108
|
+
url: 'https://example.com',
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const result = up(oldRecord)
|
|
113
|
+
expect(result.props.url).toBe('https://example.com')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('should be noop for down migration', () => {
|
|
117
|
+
const newRecord = { props: { url: 'https://example.com' } }
|
|
118
|
+
const result = down(newRecord)
|
|
119
|
+
expect(result).toEqual(newRecord)
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
})
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { T } from '@tldraw/validate'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { getTestMigration } from '../__tests__/migrationTestUtils'
|
|
4
|
+
import { VecModel } from '../misc/geometry-types'
|
|
5
|
+
import { DefaultColorStyle } from '../styles/TLColorStyle'
|
|
6
|
+
import { DefaultDashStyle } from '../styles/TLDashStyle'
|
|
7
|
+
import { DefaultFillStyle } from '../styles/TLFillStyle'
|
|
8
|
+
import { DefaultSizeStyle } from '../styles/TLSizeStyle'
|
|
9
|
+
import { DrawShapeSegment, drawShapeProps, drawShapeVersions } from './TLDrawShape'
|
|
10
|
+
|
|
11
|
+
describe('TLDrawShape', () => {
|
|
12
|
+
describe('DrawShapeSegment validator', () => {
|
|
13
|
+
it('should validate valid segment structures', () => {
|
|
14
|
+
const validSegments = [
|
|
15
|
+
{ type: 'free', points: [{ x: 0, y: 0 }] },
|
|
16
|
+
{ type: 'straight', points: [{ x: 0, y: 0, z: 0.5 }] },
|
|
17
|
+
{ type: 'free', points: [] },
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
validSegments.forEach((segment) => {
|
|
21
|
+
expect(() => DrawShapeSegment.validate(segment)).not.toThrow()
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('should reject invalid segment types and points', () => {
|
|
26
|
+
const invalidSegments = [
|
|
27
|
+
{ type: 'invalid', points: [{ x: 0, y: 0 }] },
|
|
28
|
+
{ type: 'free', points: [{ x: 'invalid', y: 0 }] },
|
|
29
|
+
{ type: 'free', points: 'not-array' },
|
|
30
|
+
{}, // Missing required fields
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
invalidSegments.forEach((segment) => {
|
|
34
|
+
expect(() => DrawShapeSegment.validate(segment)).toThrow()
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('drawShapeProps validation schema', () => {
|
|
40
|
+
it('should validate complete valid props object', () => {
|
|
41
|
+
const fullValidator = T.object(drawShapeProps)
|
|
42
|
+
|
|
43
|
+
const validProps = {
|
|
44
|
+
color: 'red' as const,
|
|
45
|
+
fill: 'solid' as const,
|
|
46
|
+
dash: 'dashed' as const,
|
|
47
|
+
size: 'l' as const,
|
|
48
|
+
segments: [{ type: 'free' as const, points: [{ x: 0, y: 0, z: 0.5 }] as VecModel[] }],
|
|
49
|
+
isComplete: true,
|
|
50
|
+
isClosed: true,
|
|
51
|
+
isPen: true,
|
|
52
|
+
scale: 1.5,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
expect(() => fullValidator.validate(validProps)).not.toThrow()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('should reject invalid property values', () => {
|
|
59
|
+
// Test key invalid cases that matter for business logic
|
|
60
|
+
expect(() => drawShapeProps.scale.validate(0)).toThrow() // zero scale invalid
|
|
61
|
+
expect(() => drawShapeProps.scale.validate(-1)).toThrow() // negative scale invalid
|
|
62
|
+
expect(() => drawShapeProps.segments.validate('not-array')).toThrow()
|
|
63
|
+
expect(() => drawShapeProps.segments.validate([{ type: 'invalid' }])).toThrow()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('should use correct default style validators', () => {
|
|
67
|
+
expect(drawShapeProps.color).toBe(DefaultColorStyle)
|
|
68
|
+
expect(drawShapeProps.fill).toBe(DefaultFillStyle)
|
|
69
|
+
expect(drawShapeProps.dash).toBe(DefaultDashStyle)
|
|
70
|
+
expect(drawShapeProps.size).toBe(DefaultSizeStyle)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('AddInPen migration', () => {
|
|
75
|
+
const { up } = getTestMigration(drawShapeVersions.AddInPen)
|
|
76
|
+
|
|
77
|
+
it('should detect pen from non-standard pressure values', () => {
|
|
78
|
+
const recordWithPen = {
|
|
79
|
+
props: {
|
|
80
|
+
segments: [
|
|
81
|
+
{
|
|
82
|
+
type: 'free',
|
|
83
|
+
points: [
|
|
84
|
+
{ x: 0, y: 0, z: 0.3 }, // Non-standard pressure
|
|
85
|
+
{ x: 10, y: 10, z: 0.7 }, // Non-standard pressure
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const result = up(recordWithPen)
|
|
93
|
+
expect(result.props.isPen).toBe(true)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('should not detect pen from standard pressure values', () => {
|
|
97
|
+
const recordWithoutPen = {
|
|
98
|
+
props: {
|
|
99
|
+
segments: [
|
|
100
|
+
{
|
|
101
|
+
type: 'free',
|
|
102
|
+
points: [
|
|
103
|
+
{ x: 0, y: 0, z: 0 }, // Standard mouse pressure
|
|
104
|
+
{ x: 10, y: 10, z: 0.5 }, // Standard touch pressure
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const result = up(recordWithoutPen)
|
|
112
|
+
expect(result.props.isPen).toBe(false)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('should handle empty segments', () => {
|
|
116
|
+
const recordEmpty = {
|
|
117
|
+
props: {
|
|
118
|
+
segments: [{ type: 'free', points: [] }],
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const result = up(recordEmpty)
|
|
123
|
+
expect(result.props.isPen).toBe(false)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('should require both points to have non-standard pressure', () => {
|
|
127
|
+
const recordMixed = {
|
|
128
|
+
props: {
|
|
129
|
+
segments: [
|
|
130
|
+
{
|
|
131
|
+
type: 'free',
|
|
132
|
+
points: [
|
|
133
|
+
{ x: 0, y: 0, z: 0.3 }, // Non-standard
|
|
134
|
+
{ x: 10, y: 10, z: 0.5 }, // Standard
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const result = up(recordMixed)
|
|
142
|
+
expect(result.props.isPen).toBe(false)
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
describe('AddScale migration', () => {
|
|
147
|
+
const { up, down } = getTestMigration(drawShapeVersions.AddScale)
|
|
148
|
+
|
|
149
|
+
it('should add scale property with default value 1', () => {
|
|
150
|
+
const oldRecord = {
|
|
151
|
+
props: {
|
|
152
|
+
color: 'blue',
|
|
153
|
+
segments: [{ type: 'free', points: [{ x: 0, y: 0 }] }],
|
|
154
|
+
isPen: false,
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const result = up(oldRecord)
|
|
159
|
+
expect(result.props.scale).toBe(1)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('should remove scale property on down migration', () => {
|
|
163
|
+
const newRecord = {
|
|
164
|
+
props: {
|
|
165
|
+
color: 'blue',
|
|
166
|
+
segments: [{ type: 'free', points: [{ x: 0, y: 0 }] }],
|
|
167
|
+
isPen: false,
|
|
168
|
+
scale: 1.5,
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const result = down(newRecord)
|
|
173
|
+
expect(result.props.scale).toBeUndefined()
|
|
174
|
+
expect(result.props.color).toBe('blue') // Other props preserved
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
})
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { T } from '@tldraw/validate'
|
|
2
|
-
import {
|
|
3
|
-
import { VecModel } from '../misc/geometry-types'
|
|
2
|
+
import { VecModel, vecModelValidator } from '../misc/geometry-types'
|
|
4
3
|
import { createShapePropsMigrationIds, createShapePropsMigrationSequence } from '../records/TLShape'
|
|
5
4
|
import { RecordProps } from '../recordsWithProps'
|
|
6
5
|
import { DefaultColorStyle, TLDefaultColorStyle } from '../styles/TLColorStyle'
|
|
@@ -17,18 +16,26 @@ import { TLBaseShape } from './TLBaseShape'
|
|
|
17
16
|
export interface TLDrawShapeSegment {
|
|
18
17
|
/** Type of drawing segment - 'free' for freehand curves, 'straight' for line segments */
|
|
19
18
|
type: 'free' | 'straight'
|
|
20
|
-
/**
|
|
21
|
-
points:
|
|
19
|
+
/** Array of points defining the segment path with x, y coordinates and pressure (z) */
|
|
20
|
+
points: VecModel[]
|
|
22
21
|
}
|
|
23
22
|
|
|
24
23
|
/**
|
|
25
24
|
* Validator for draw shape segments ensuring proper structure and data types.
|
|
26
25
|
*
|
|
27
26
|
* @public
|
|
27
|
+
* @example
|
|
28
|
+
* ```ts
|
|
29
|
+
* const segment: TLDrawShapeSegment = {
|
|
30
|
+
* type: 'free',
|
|
31
|
+
* points: [{ x: 0, y: 0, z: 0.5 }, { x: 10, y: 10, z: 0.7 }]
|
|
32
|
+
* }
|
|
33
|
+
* const isValid = DrawShapeSegment.isValid(segment)
|
|
34
|
+
* ```
|
|
28
35
|
*/
|
|
29
36
|
export const DrawShapeSegment: T.ObjectValidator<TLDrawShapeSegment> = T.object({
|
|
30
37
|
type: T.literalEnum('free', 'straight'),
|
|
31
|
-
points: T.
|
|
38
|
+
points: T.arrayOf(vecModelValidator),
|
|
32
39
|
})
|
|
33
40
|
|
|
34
41
|
/**
|
|
@@ -55,10 +62,6 @@ export interface TLDrawShapeProps {
|
|
|
55
62
|
isPen: boolean
|
|
56
63
|
/** Scale factor applied to the drawing */
|
|
57
64
|
scale: number
|
|
58
|
-
/** Horizontal scale factor for lazy resize */
|
|
59
|
-
scaleX: number
|
|
60
|
-
/** Vertical scale factor for lazy resize */
|
|
61
|
-
scaleY: number
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
/**
|
|
@@ -115,7 +118,6 @@ export type TLDrawShape = TLBaseShape<'draw', TLDrawShapeProps>
|
|
|
115
118
|
* const isValid = drawShapeProps.color.isValid(props.color)
|
|
116
119
|
* ```
|
|
117
120
|
*/
|
|
118
|
-
/** @public */
|
|
119
121
|
export const drawShapeProps: RecordProps<TLDrawShape> = {
|
|
120
122
|
color: DefaultColorStyle,
|
|
121
123
|
fill: DefaultFillStyle,
|
|
@@ -126,14 +128,11 @@ export const drawShapeProps: RecordProps<TLDrawShape> = {
|
|
|
126
128
|
isClosed: T.boolean,
|
|
127
129
|
isPen: T.boolean,
|
|
128
130
|
scale: T.nonZeroNumber,
|
|
129
|
-
scaleX: T.nonZeroFiniteNumber,
|
|
130
|
-
scaleY: T.nonZeroFiniteNumber,
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
const Versions = createShapePropsMigrationIds('draw', {
|
|
134
134
|
AddInPen: 1,
|
|
135
135
|
AddScale: 2,
|
|
136
|
-
Base64: 3,
|
|
137
136
|
})
|
|
138
137
|
|
|
139
138
|
/**
|
|
@@ -185,51 +184,5 @@ export const drawShapeMigrations = createShapePropsMigrationSequence({
|
|
|
185
184
|
delete props.scale
|
|
186
185
|
},
|
|
187
186
|
},
|
|
188
|
-
{
|
|
189
|
-
id: Versions.Base64,
|
|
190
|
-
up: (props) => {
|
|
191
|
-
props.segments = props.segments.map((segment: any) => {
|
|
192
|
-
return {
|
|
193
|
-
...segment,
|
|
194
|
-
// Only encode if points is an array (not already base64 string)
|
|
195
|
-
points:
|
|
196
|
-
typeof segment.points === 'string'
|
|
197
|
-
? segment.points
|
|
198
|
-
: b64Vecs.encodePoints(segment.points),
|
|
199
|
-
}
|
|
200
|
-
})
|
|
201
|
-
props.scaleX = props.scaleX ?? 1
|
|
202
|
-
props.scaleY = props.scaleY ?? 1
|
|
203
|
-
},
|
|
204
|
-
down: (props) => {
|
|
205
|
-
props.segments = props.segments.map((segment: any) => ({
|
|
206
|
-
...segment,
|
|
207
|
-
// Only decode if points is a string (not already VecModel[])
|
|
208
|
-
points: Array.isArray(segment.points)
|
|
209
|
-
? segment.points
|
|
210
|
-
: b64Vecs.decodePoints(segment.points),
|
|
211
|
-
}))
|
|
212
|
-
delete props.scaleX
|
|
213
|
-
delete props.scaleY
|
|
214
|
-
},
|
|
215
|
-
},
|
|
216
187
|
],
|
|
217
188
|
})
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* Compress legacy draw shape segments by converting VecModel[] points to base64 format.
|
|
221
|
-
* This function is useful for converting old draw shape data to the new compressed format.
|
|
222
|
-
*
|
|
223
|
-
* @public
|
|
224
|
-
*/
|
|
225
|
-
export function compressLegacySegments(
|
|
226
|
-
segments: {
|
|
227
|
-
type: 'free' | 'straight'
|
|
228
|
-
points: VecModel[]
|
|
229
|
-
}[]
|
|
230
|
-
): TLDrawShapeSegment[] {
|
|
231
|
-
return segments.map((segment) => ({
|
|
232
|
-
...segment,
|
|
233
|
-
points: b64Vecs.encodePoints(segment.points),
|
|
234
|
-
}))
|
|
235
|
-
}
|