@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,190 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
instancePresenceMigrations,
|
|
4
|
+
instancePresenceValidator,
|
|
5
|
+
instancePresenceVersions,
|
|
6
|
+
TLInstancePresenceID,
|
|
7
|
+
} from './TLPresence'
|
|
8
|
+
|
|
9
|
+
describe('instancePresenceValidator', () => {
|
|
10
|
+
it('should validate valid instance presence records', () => {
|
|
11
|
+
const validPresence = {
|
|
12
|
+
typeName: 'instance_presence',
|
|
13
|
+
id: 'instance_presence:test' as TLInstancePresenceID,
|
|
14
|
+
userId: 'user123',
|
|
15
|
+
userName: 'Test User',
|
|
16
|
+
lastActivityTimestamp: null,
|
|
17
|
+
color: '#007AFF',
|
|
18
|
+
camera: null,
|
|
19
|
+
selectedShapeIds: [],
|
|
20
|
+
currentPageId: 'page:main' as any,
|
|
21
|
+
brush: null,
|
|
22
|
+
scribbles: [],
|
|
23
|
+
screenBounds: null,
|
|
24
|
+
followingUserId: null,
|
|
25
|
+
cursor: null,
|
|
26
|
+
chatMessage: '',
|
|
27
|
+
meta: {},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
expect(() => instancePresenceValidator.validate(validPresence)).not.toThrow()
|
|
31
|
+
const validated = instancePresenceValidator.validate(validPresence)
|
|
32
|
+
expect(validated).toEqual(validPresence)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('should validate presence with complete data', () => {
|
|
36
|
+
const complexPresence = {
|
|
37
|
+
typeName: 'instance_presence',
|
|
38
|
+
id: 'instance_presence:complex' as TLInstancePresenceID,
|
|
39
|
+
userId: 'user456',
|
|
40
|
+
userName: 'Complex User',
|
|
41
|
+
lastActivityTimestamp: Date.now(),
|
|
42
|
+
color: '#FF3B30',
|
|
43
|
+
camera: { x: -100, y: 200, z: 0.75 },
|
|
44
|
+
selectedShapeIds: ['shape:1' as any, 'shape:2' as any],
|
|
45
|
+
currentPageId: 'page:design' as any,
|
|
46
|
+
brush: { x: 50, y: 75, w: 150, h: 100 },
|
|
47
|
+
scribbles: [
|
|
48
|
+
{
|
|
49
|
+
id: 'scribble:1',
|
|
50
|
+
points: [{ x: 0, y: 0, z: 0.5 }],
|
|
51
|
+
size: 4,
|
|
52
|
+
color: 'black',
|
|
53
|
+
opacity: 1,
|
|
54
|
+
state: 'starting',
|
|
55
|
+
delay: 0,
|
|
56
|
+
shrink: 0,
|
|
57
|
+
taper: false,
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
screenBounds: { x: 0, y: 0, w: 2560, h: 1440 },
|
|
61
|
+
followingUserId: 'leader123',
|
|
62
|
+
cursor: { x: 300, y: 400, type: 'pointer', rotation: 45 },
|
|
63
|
+
chatMessage: 'Working on design!',
|
|
64
|
+
meta: { team: 'design', role: 'designer' },
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
expect(() => instancePresenceValidator.validate(complexPresence)).not.toThrow()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('should reject invalid typeName', () => {
|
|
71
|
+
const invalidPresence = {
|
|
72
|
+
typeName: 'not-instance-presence',
|
|
73
|
+
id: 'instance_presence:test' as TLInstancePresenceID,
|
|
74
|
+
userId: 'user123',
|
|
75
|
+
userName: 'Test',
|
|
76
|
+
lastActivityTimestamp: null,
|
|
77
|
+
color: '#000000',
|
|
78
|
+
camera: null,
|
|
79
|
+
selectedShapeIds: [],
|
|
80
|
+
currentPageId: 'page:main' as any,
|
|
81
|
+
brush: null,
|
|
82
|
+
scribbles: [],
|
|
83
|
+
screenBounds: null,
|
|
84
|
+
followingUserId: null,
|
|
85
|
+
cursor: null,
|
|
86
|
+
chatMessage: '',
|
|
87
|
+
meta: {},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
expect(() => instancePresenceValidator.validate(invalidPresence)).toThrow()
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('instancePresenceMigrations', () => {
|
|
95
|
+
it('should migrate AddScribbleDelay correctly', () => {
|
|
96
|
+
const migration = instancePresenceMigrations.sequence.find(
|
|
97
|
+
(m) => m.id === instancePresenceVersions.AddScribbleDelay
|
|
98
|
+
)!
|
|
99
|
+
|
|
100
|
+
const oldRecordWithScribble: any = {
|
|
101
|
+
scribble: { points: [], size: 4, color: 'black' },
|
|
102
|
+
}
|
|
103
|
+
migration.up(oldRecordWithScribble)
|
|
104
|
+
expect(oldRecordWithScribble.scribble.delay).toBe(0)
|
|
105
|
+
|
|
106
|
+
const oldRecordWithoutScribble: any = {
|
|
107
|
+
scribble: null,
|
|
108
|
+
}
|
|
109
|
+
migration.up(oldRecordWithoutScribble)
|
|
110
|
+
expect(oldRecordWithoutScribble.scribble).toBe(null)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('should migrate RemoveInstanceId correctly', () => {
|
|
114
|
+
const migration = instancePresenceMigrations.sequence.find(
|
|
115
|
+
(m) => m.id === instancePresenceVersions.RemoveInstanceId
|
|
116
|
+
)!
|
|
117
|
+
const oldRecord: any = {
|
|
118
|
+
instanceId: 'instance:removed',
|
|
119
|
+
otherProp: 'keep-me',
|
|
120
|
+
}
|
|
121
|
+
migration.up(oldRecord)
|
|
122
|
+
|
|
123
|
+
expect(oldRecord.instanceId).toBeUndefined()
|
|
124
|
+
expect(oldRecord.otherProp).toBe('keep-me')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('should migrate AddChatMessage correctly', () => {
|
|
128
|
+
const migration = instancePresenceMigrations.sequence.find(
|
|
129
|
+
(m) => m.id === instancePresenceVersions.AddChatMessage
|
|
130
|
+
)!
|
|
131
|
+
const oldRecord: any = { id: 'instance_presence:test' }
|
|
132
|
+
migration.up(oldRecord)
|
|
133
|
+
|
|
134
|
+
expect(oldRecord.chatMessage).toBe('')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('should migrate AddMeta correctly', () => {
|
|
138
|
+
const migration = instancePresenceMigrations.sequence.find(
|
|
139
|
+
(m) => m.id === instancePresenceVersions.AddMeta
|
|
140
|
+
)!
|
|
141
|
+
const oldRecord: any = { id: 'instance_presence:test' }
|
|
142
|
+
migration.up(oldRecord)
|
|
143
|
+
|
|
144
|
+
expect(oldRecord.meta).toEqual({})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('should handle RenameSelectedShapeIds migration (noop)', () => {
|
|
148
|
+
const migration = instancePresenceMigrations.sequence.find(
|
|
149
|
+
(m) => m.id === instancePresenceVersions.RenameSelectedShapeIds
|
|
150
|
+
)!
|
|
151
|
+
const oldRecord: any = { selectedShapeIds: ['shape:1'] }
|
|
152
|
+
const originalRecord = { ...oldRecord }
|
|
153
|
+
migration.up(oldRecord)
|
|
154
|
+
|
|
155
|
+
// Should be a noop
|
|
156
|
+
expect(oldRecord).toEqual(originalRecord)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('should handle NullableCameraCursor migration up (noop)', () => {
|
|
160
|
+
const migration = instancePresenceMigrations.sequence.find(
|
|
161
|
+
(m) => m.id === instancePresenceVersions.NullableCameraCursor
|
|
162
|
+
)!
|
|
163
|
+
const record: any = { camera: null, cursor: null }
|
|
164
|
+
const originalRecord = { ...record }
|
|
165
|
+
migration.up(record)
|
|
166
|
+
|
|
167
|
+
// Should be a noop
|
|
168
|
+
expect(record).toEqual(originalRecord)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('should handle NullableCameraCursor migration down', () => {
|
|
172
|
+
const migration = instancePresenceMigrations.sequence.find(
|
|
173
|
+
(m) => m.id === instancePresenceVersions.NullableCameraCursor
|
|
174
|
+
)!
|
|
175
|
+
expect(migration.down).toBeDefined()
|
|
176
|
+
|
|
177
|
+
const record: any = {
|
|
178
|
+
camera: null,
|
|
179
|
+
lastActivityTimestamp: null,
|
|
180
|
+
cursor: null,
|
|
181
|
+
screenBounds: null,
|
|
182
|
+
}
|
|
183
|
+
migration.down!(record)
|
|
184
|
+
|
|
185
|
+
expect(record.camera).toEqual({ x: 0, y: 0, z: 1 })
|
|
186
|
+
expect(record.lastActivityTimestamp).toBe(0)
|
|
187
|
+
expect(record.cursor).toEqual({ type: 'default', x: 0, y: 0, rotation: 0 })
|
|
188
|
+
expect(record.screenBounds).toEqual({ x: 0, y: 0, w: 1, h: 1 })
|
|
189
|
+
})
|
|
190
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { TLRecord } from './TLRecord'
|
|
3
|
+
|
|
4
|
+
describe('TLRecord', () => {
|
|
5
|
+
it('should support type discrimination by typeName', () => {
|
|
6
|
+
function processRecord(record: TLRecord): string {
|
|
7
|
+
// TypeScript should be able to narrow types based on typeName
|
|
8
|
+
switch (record.typeName) {
|
|
9
|
+
case 'shape':
|
|
10
|
+
return `Shape at (${record.x}, ${record.y})`
|
|
11
|
+
case 'page':
|
|
12
|
+
return `Page: ${record.name}`
|
|
13
|
+
case 'asset':
|
|
14
|
+
return `Asset: ${record.type}`
|
|
15
|
+
case 'binding':
|
|
16
|
+
return `Binding from ${record.fromId} to ${record.toId}`
|
|
17
|
+
case 'camera':
|
|
18
|
+
return `Camera at (${record.x}, ${record.y}) zoom: ${record.z}`
|
|
19
|
+
case 'document':
|
|
20
|
+
return `Document: ${record.name}, grid: ${record.gridSize}`
|
|
21
|
+
case 'instance':
|
|
22
|
+
return `Instance on page ${record.currentPageId}`
|
|
23
|
+
case 'instance_page_state':
|
|
24
|
+
return `Page state for ${record.pageId}`
|
|
25
|
+
case 'instance_presence':
|
|
26
|
+
return `Presence of ${record.userName}`
|
|
27
|
+
case 'pointer':
|
|
28
|
+
return `Pointer at (${record.x}, ${record.y})`
|
|
29
|
+
default:
|
|
30
|
+
// This should never be reached if all cases are handled
|
|
31
|
+
return 'Unknown record type'
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Test that the discriminated union works correctly
|
|
36
|
+
const shapeRecord: TLRecord = {
|
|
37
|
+
id: 'shape:test' as any,
|
|
38
|
+
typeName: 'shape',
|
|
39
|
+
type: 'geo',
|
|
40
|
+
x: 50,
|
|
41
|
+
y: 100,
|
|
42
|
+
rotation: 0,
|
|
43
|
+
index: 'a1' as any,
|
|
44
|
+
parentId: 'page:main' as any,
|
|
45
|
+
isLocked: false,
|
|
46
|
+
opacity: 1,
|
|
47
|
+
props: {
|
|
48
|
+
geo: 'rectangle',
|
|
49
|
+
w: 80,
|
|
50
|
+
h: 80,
|
|
51
|
+
color: 'black',
|
|
52
|
+
fill: 'none',
|
|
53
|
+
dash: 'draw',
|
|
54
|
+
size: 'm',
|
|
55
|
+
},
|
|
56
|
+
meta: {},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const pageRecord: TLRecord = {
|
|
60
|
+
id: 'page:test' as any,
|
|
61
|
+
typeName: 'page',
|
|
62
|
+
name: 'Test Page',
|
|
63
|
+
index: 'a1' as any,
|
|
64
|
+
meta: {},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
expect(processRecord(shapeRecord)).toBe('Shape at (50, 100)')
|
|
68
|
+
expect(processRecord(pageRecord)).toBe('Page: Test Page')
|
|
69
|
+
})
|
|
70
|
+
})
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { T } from '@tldraw/validate'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { StyleProp } from '../styles/StyleProp'
|
|
4
|
+
import {
|
|
5
|
+
createShapeId,
|
|
6
|
+
createShapePropsMigrationIds,
|
|
7
|
+
createShapeRecordType,
|
|
8
|
+
getShapePropKeysByStyle,
|
|
9
|
+
isShape,
|
|
10
|
+
isShapeId,
|
|
11
|
+
rootShapeMigrations,
|
|
12
|
+
rootShapeVersions,
|
|
13
|
+
TLShapeId,
|
|
14
|
+
} from './TLShape'
|
|
15
|
+
|
|
16
|
+
describe('rootShapeMigrations', () => {
|
|
17
|
+
it('should migrate AddIsLocked correctly', () => {
|
|
18
|
+
const migration = rootShapeMigrations.sequence.find(
|
|
19
|
+
(m) => m.id === rootShapeVersions.AddIsLocked
|
|
20
|
+
)!
|
|
21
|
+
expect(migration.up).toBeDefined()
|
|
22
|
+
expect(migration.down).toBeDefined()
|
|
23
|
+
|
|
24
|
+
const record: any = { id: 'shape:test', typeName: 'shape', type: 'geo' }
|
|
25
|
+
migration.up(record)
|
|
26
|
+
expect(record.isLocked).toBe(false)
|
|
27
|
+
|
|
28
|
+
migration.down!(record)
|
|
29
|
+
expect(record.isLocked).toBeUndefined()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('should migrate HoistOpacity correctly', () => {
|
|
33
|
+
const migration = rootShapeMigrations.sequence.find(
|
|
34
|
+
(m) => m.id === rootShapeVersions.HoistOpacity
|
|
35
|
+
)!
|
|
36
|
+
expect(migration.up).toBeDefined()
|
|
37
|
+
expect(migration.down).toBeDefined()
|
|
38
|
+
|
|
39
|
+
// Test up migration
|
|
40
|
+
const record: any = {
|
|
41
|
+
id: 'shape:test',
|
|
42
|
+
typeName: 'shape',
|
|
43
|
+
type: 'geo',
|
|
44
|
+
props: { opacity: '0.5', color: 'red' },
|
|
45
|
+
}
|
|
46
|
+
migration.up(record)
|
|
47
|
+
expect(record.opacity).toBe(0.5)
|
|
48
|
+
expect(record.props.opacity).toBeUndefined()
|
|
49
|
+
expect(record.props.color).toBe('red')
|
|
50
|
+
|
|
51
|
+
// Test down migration
|
|
52
|
+
migration.down!(record)
|
|
53
|
+
expect(record.props.opacity).toBe('0.5')
|
|
54
|
+
expect(record.opacity).toBeUndefined()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('should migrate AddMeta correctly', () => {
|
|
58
|
+
const migration = rootShapeMigrations.sequence.find((m) => m.id === rootShapeVersions.AddMeta)!
|
|
59
|
+
const record: any = { id: 'shape:test', typeName: 'shape', type: 'geo' }
|
|
60
|
+
migration.up(record)
|
|
61
|
+
expect(record.meta).toEqual({})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('should handle AddWhite migration', () => {
|
|
65
|
+
const migration = rootShapeMigrations.sequence.find((m) => m.id === rootShapeVersions.AddWhite)!
|
|
66
|
+
expect(migration.up).toBeDefined()
|
|
67
|
+
expect(migration.down).toBeDefined()
|
|
68
|
+
|
|
69
|
+
// Up migration is noop
|
|
70
|
+
const record: any = { props: { color: 'white' } }
|
|
71
|
+
const original = { ...record }
|
|
72
|
+
migration.up(record)
|
|
73
|
+
expect(record).toEqual(original)
|
|
74
|
+
|
|
75
|
+
// Down migration converts white to black
|
|
76
|
+
migration.down!(record)
|
|
77
|
+
expect(record.props.color).toBe('black')
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('isShape', () => {
|
|
82
|
+
it('should return true for shape records', () => {
|
|
83
|
+
const shape = {
|
|
84
|
+
id: 'shape:test' as TLShapeId,
|
|
85
|
+
typeName: 'shape',
|
|
86
|
+
type: 'geo',
|
|
87
|
+
x: 0,
|
|
88
|
+
y: 0,
|
|
89
|
+
rotation: 0,
|
|
90
|
+
index: 'a1' as any,
|
|
91
|
+
parentId: 'page:main' as any,
|
|
92
|
+
isLocked: false,
|
|
93
|
+
opacity: 1,
|
|
94
|
+
props: {},
|
|
95
|
+
meta: {},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
expect(isShape(shape)).toBe(true)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('should return false for non-shape records', () => {
|
|
102
|
+
const notShape = {
|
|
103
|
+
id: 'page:test',
|
|
104
|
+
typeName: 'page',
|
|
105
|
+
name: 'Test Page',
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
expect(isShape(notShape as any)).toBe(false)
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('isShapeId', () => {
|
|
113
|
+
it('should return true for valid shape IDs', () => {
|
|
114
|
+
expect(isShapeId('shape:test')).toBe(true)
|
|
115
|
+
expect(isShapeId('shape:abc123')).toBe(true)
|
|
116
|
+
expect(isShapeId('shape:')).toBe(true)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('should return false for invalid shape IDs', () => {
|
|
120
|
+
expect(isShapeId('page:test')).toBe(false)
|
|
121
|
+
expect(isShapeId('asset:test')).toBe(false)
|
|
122
|
+
expect(isShapeId('invalid')).toBe(false)
|
|
123
|
+
expect(isShapeId('')).toBe(false)
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe('createShapeId', () => {
|
|
128
|
+
it('should create shape IDs with auto-generated suffix', () => {
|
|
129
|
+
const id1 = createShapeId()
|
|
130
|
+
const id2 = createShapeId()
|
|
131
|
+
|
|
132
|
+
expect(id1.startsWith('shape:')).toBe(true)
|
|
133
|
+
expect(id2.startsWith('shape:')).toBe(true)
|
|
134
|
+
expect(id1).not.toBe(id2)
|
|
135
|
+
expect(id1.length).toBeGreaterThan(6) // 'shape:' + some ID
|
|
136
|
+
expect(id2.length).toBeGreaterThan(6)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('should create shape IDs with custom suffix', () => {
|
|
140
|
+
const customId = createShapeId('my-custom-id')
|
|
141
|
+
expect(customId).toBe('shape:my-custom-id')
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe('getShapePropKeysByStyle', () => {
|
|
146
|
+
it('should map style props to their keys', () => {
|
|
147
|
+
const colorStyle = StyleProp.define('color', { defaultValue: 'black' })
|
|
148
|
+
const sizeStyle = StyleProp.define('size', { defaultValue: 'm' })
|
|
149
|
+
|
|
150
|
+
const props = {
|
|
151
|
+
color: colorStyle,
|
|
152
|
+
size: sizeStyle,
|
|
153
|
+
width: T.number,
|
|
154
|
+
height: T.number,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const styleMap = getShapePropKeysByStyle(props)
|
|
158
|
+
expect(styleMap.get(colorStyle)).toBe('color')
|
|
159
|
+
expect(styleMap.get(sizeStyle)).toBe('size')
|
|
160
|
+
expect(styleMap.size).toBe(2) // Only style props
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('should throw error for duplicate style props', () => {
|
|
164
|
+
const colorStyle = StyleProp.define('color', { defaultValue: 'black' })
|
|
165
|
+
const props = {
|
|
166
|
+
color1: colorStyle,
|
|
167
|
+
color2: colorStyle, // Same style prop used twice
|
|
168
|
+
width: T.number,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
expect(() => getShapePropKeysByStyle(props)).toThrow('Duplicate style prop')
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe('createShapePropsMigrationIds', () => {
|
|
176
|
+
it('should create formatted migration IDs', () => {
|
|
177
|
+
const ids = createShapePropsMigrationIds('custom', {
|
|
178
|
+
AddColor: 1,
|
|
179
|
+
AddSize: 2,
|
|
180
|
+
RefactorProps: 3,
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
expect(ids.AddColor).toBe('com.tldraw.shape.custom/1')
|
|
184
|
+
expect(ids.AddSize).toBe('com.tldraw.shape.custom/2')
|
|
185
|
+
expect(ids.RefactorProps).toBe('com.tldraw.shape.custom/3')
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
describe('createShapeRecordType', () => {
|
|
190
|
+
it('should create a record type for shapes', () => {
|
|
191
|
+
const shapes = {
|
|
192
|
+
geo: {
|
|
193
|
+
props: {
|
|
194
|
+
w: T.number,
|
|
195
|
+
h: T.number,
|
|
196
|
+
color: StyleProp.define('color', { defaultValue: 'black' }),
|
|
197
|
+
},
|
|
198
|
+
meta: {},
|
|
199
|
+
},
|
|
200
|
+
text: {
|
|
201
|
+
props: {
|
|
202
|
+
text: T.string,
|
|
203
|
+
size: StyleProp.define('size', { defaultValue: 'm' }),
|
|
204
|
+
},
|
|
205
|
+
meta: {},
|
|
206
|
+
},
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const ShapeRecordType = createShapeRecordType(shapes)
|
|
210
|
+
|
|
211
|
+
expect(ShapeRecordType.typeName).toBe('shape')
|
|
212
|
+
expect(ShapeRecordType.scope).toBe('document')
|
|
213
|
+
|
|
214
|
+
// Should be able to create shapes
|
|
215
|
+
const geoShape = ShapeRecordType.create({
|
|
216
|
+
id: createShapeId(),
|
|
217
|
+
type: 'geo',
|
|
218
|
+
parentId: 'page:main' as any,
|
|
219
|
+
index: 'a1' as any,
|
|
220
|
+
props: {},
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
expect(geoShape.typeName).toBe('shape')
|
|
224
|
+
expect(geoShape.type).toBe('geo')
|
|
225
|
+
expect(geoShape.x).toBe(0) // Default
|
|
226
|
+
expect(geoShape.y).toBe(0) // Default
|
|
227
|
+
expect(geoShape.rotation).toBe(0) // Default
|
|
228
|
+
expect(geoShape.isLocked).toBe(false) // Default
|
|
229
|
+
expect(geoShape.opacity).toBe(1) // Default
|
|
230
|
+
expect(geoShape.meta).toEqual({}) // Default
|
|
231
|
+
})
|
|
232
|
+
})
|
package/src/records/TLShape.ts
CHANGED
|
@@ -79,51 +79,12 @@ export type TLDefaultShape =
|
|
|
79
79
|
*/
|
|
80
80
|
export type TLUnknownShape = TLBaseShape<string, object>
|
|
81
81
|
|
|
82
|
-
/** @public */
|
|
83
|
-
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
84
|
-
export interface TLGlobalShapePropsMap {}
|
|
85
|
-
|
|
86
|
-
/** @public */
|
|
87
|
-
// prettier-ignore
|
|
88
|
-
export type TLIndexedShapes = {
|
|
89
|
-
// We iterate over a union of augmented keys and default shape types.
|
|
90
|
-
// This allows us to include (or conditionally exclude or override) the default shapes in one go.
|
|
91
|
-
//
|
|
92
|
-
// In the `as` clause we are filtering out disabled shapes.
|
|
93
|
-
[K in keyof TLGlobalShapePropsMap | TLDefaultShape['type'] as K extends TLDefaultShape['type']
|
|
94
|
-
? // core shapes are always available and cannot be overridden so we just include them
|
|
95
|
-
K extends 'group'
|
|
96
|
-
? K
|
|
97
|
-
: K extends keyof TLGlobalShapePropsMap
|
|
98
|
-
? // if it extends a nullish value the user has disabled this shape type so we filter it out with never
|
|
99
|
-
TLGlobalShapePropsMap[K] extends null | undefined
|
|
100
|
-
? never
|
|
101
|
-
: K
|
|
102
|
-
: K
|
|
103
|
-
: K]: K extends 'group'
|
|
104
|
-
? // core shapes are always available and cannot be overridden so we just include them
|
|
105
|
-
Extract<TLDefaultShape, { type: K }>
|
|
106
|
-
: K extends TLDefaultShape['type']
|
|
107
|
-
? // if it's a default shape type we need to check if it's been overridden
|
|
108
|
-
K extends keyof TLGlobalShapePropsMap
|
|
109
|
-
? // if it has been overriden then use the custom shape definition
|
|
110
|
-
TLBaseShape<K, TLGlobalShapePropsMap[K]>
|
|
111
|
-
: // if it has not been overriden then reuse existing type aliases for better type display
|
|
112
|
-
Extract<TLDefaultShape, { type: K }>
|
|
113
|
-
: // use the custom shape definition
|
|
114
|
-
TLBaseShape<K, TLGlobalShapePropsMap[K & keyof TLGlobalShapePropsMap]>
|
|
115
|
-
}
|
|
116
|
-
|
|
117
82
|
/**
|
|
118
|
-
* The set of all shapes that are available in the editor.
|
|
83
|
+
* The set of all shapes that are available in the editor, including unknown shapes.
|
|
119
84
|
*
|
|
120
85
|
* This is the primary shape type used throughout tldraw. It includes both the
|
|
121
86
|
* built-in default shapes and any custom shapes that might be added.
|
|
122
87
|
*
|
|
123
|
-
* You can use this type without a type argument to work with any shape, or pass
|
|
124
|
-
* a specific shape type string (e.g., `'geo'`, `'arrow'`, `'text'`) to narrow
|
|
125
|
-
* down to that specific shape type.
|
|
126
|
-
*
|
|
127
88
|
* @example
|
|
128
89
|
* ```ts
|
|
129
90
|
* // Work with any shape in the editor
|
|
@@ -134,16 +95,11 @@ export type TLIndexedShapes = {
|
|
|
134
95
|
* y: shape.y + deltaY
|
|
135
96
|
* }
|
|
136
97
|
* }
|
|
137
|
-
*
|
|
138
|
-
* // Narrow to a specific shape type by passing the type as a generic argument
|
|
139
|
-
* function getArrowLabel(shape: TLShape<'arrow'>): string {
|
|
140
|
-
* return shape.props.text // TypeScript knows this is a TLArrowShape
|
|
141
|
-
* }
|
|
142
98
|
* ```
|
|
143
99
|
*
|
|
144
100
|
* @public
|
|
145
101
|
*/
|
|
146
|
-
export type TLShape
|
|
102
|
+
export type TLShape = TLDefaultShape | TLUnknownShape
|
|
147
103
|
|
|
148
104
|
/**
|
|
149
105
|
* A partial version of a shape, useful for updates and patches.
|
|
@@ -183,57 +139,6 @@ export type TLShapePartial<T extends TLShape = TLShape> = T extends T
|
|
|
183
139
|
} & Partial<Omit<T, 'type' | 'id' | 'props' | 'meta'>>
|
|
184
140
|
: never
|
|
185
141
|
|
|
186
|
-
/**
|
|
187
|
-
* A partial version of a shape, useful for creating shapes.
|
|
188
|
-
*
|
|
189
|
-
* This type represents a shape where all properties except `type` are optional.
|
|
190
|
-
* It's commonly used when creating shapes.
|
|
191
|
-
*
|
|
192
|
-
* @example
|
|
193
|
-
* ```ts
|
|
194
|
-
* // Create a shape
|
|
195
|
-
* const shapeCreate: TLCreateShapePartial = {
|
|
196
|
-
* type: 'geo',
|
|
197
|
-
* x: 100,
|
|
198
|
-
* y: 200
|
|
199
|
-
* }
|
|
200
|
-
*
|
|
201
|
-
* // Create shape properties
|
|
202
|
-
* const propsCreate: TLCreateShapePartial<TLGeoShape> = {
|
|
203
|
-
* type: 'geo',
|
|
204
|
-
* props: {
|
|
205
|
-
* w: 150,
|
|
206
|
-
* h: 100
|
|
207
|
-
* }
|
|
208
|
-
* }
|
|
209
|
-
* ```
|
|
210
|
-
*
|
|
211
|
-
* @public
|
|
212
|
-
*/
|
|
213
|
-
export type TLCreateShapePartial<T extends TLShape = TLShape> = T extends T
|
|
214
|
-
? {
|
|
215
|
-
type: T['type']
|
|
216
|
-
props?: Partial<T['props']>
|
|
217
|
-
meta?: Partial<T['meta']>
|
|
218
|
-
} & Partial<Omit<T, 'type' | 'props' | 'meta'>>
|
|
219
|
-
: never
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* Extract a shape type by its props.
|
|
223
|
-
*
|
|
224
|
-
* This utility type takes a props object type and returns the corresponding shape type
|
|
225
|
-
* from the TLShape union whose props match the given type.
|
|
226
|
-
*
|
|
227
|
-
* @example
|
|
228
|
-
* ```ts
|
|
229
|
-
* type MyShape = ExtractShapeByProps<{ w: number; h: number }>
|
|
230
|
-
* // MyShape is now the type of shape(s) that have props with w and h as numbers
|
|
231
|
-
* ```
|
|
232
|
-
*
|
|
233
|
-
* @public
|
|
234
|
-
*/
|
|
235
|
-
export type ExtractShapeByProps<P> = Extract<TLShape, { props: P }>
|
|
236
|
-
|
|
237
142
|
/**
|
|
238
143
|
* A unique identifier for a shape record.
|
|
239
144
|
*
|
|
@@ -248,7 +153,7 @@ export type ExtractShapeByProps<P> = Extract<TLShape, { props: P }>
|
|
|
248
153
|
*
|
|
249
154
|
* @public
|
|
250
155
|
*/
|
|
251
|
-
export type TLShapeId = RecordId<
|
|
156
|
+
export type TLShapeId = RecordId<TLUnknownShape>
|
|
252
157
|
|
|
253
158
|
/**
|
|
254
159
|
* The ID of a shape's parent, which can be either a page or another shape.
|
|
@@ -290,7 +195,7 @@ export const rootShapeVersions = createMigrationIds('com.tldraw.shape', {
|
|
|
290
195
|
HoistOpacity: 2,
|
|
291
196
|
AddMeta: 3,
|
|
292
197
|
AddWhite: 4,
|
|
293
|
-
})
|
|
198
|
+
} as const)
|
|
294
199
|
|
|
295
200
|
/**
|
|
296
201
|
* Migration sequence for the root shape record type.
|
|
@@ -564,7 +469,7 @@ export function createShapePropsMigrationIds<
|
|
|
564
469
|
* @internal
|
|
565
470
|
*/
|
|
566
471
|
export function createShapeRecordType(shapes: Record<string, SchemaPropsInfo>) {
|
|
567
|
-
return createRecordType('shape', {
|
|
472
|
+
return createRecordType<TLShape>('shape', {
|
|
568
473
|
scope: 'document',
|
|
569
474
|
validator: T.model(
|
|
570
475
|
'shape',
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
import { TLShapeCrop } from './ShapeWithCrop'
|
|
3
|
+
|
|
4
|
+
describe('TLShapeCrop', () => {
|
|
5
|
+
test('should calculate crop dimensions correctly', () => {
|
|
6
|
+
const crop: TLShapeCrop = {
|
|
7
|
+
topLeft: { x: 0.2, y: 0.3 },
|
|
8
|
+
bottomRight: { x: 0.8, y: 0.7 },
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// This tests the actual utility of the crop interface - calculating dimensions
|
|
12
|
+
const width = crop.bottomRight.x - crop.topLeft.x
|
|
13
|
+
const height = crop.bottomRight.y - crop.topLeft.y
|
|
14
|
+
|
|
15
|
+
expect(width).toBeCloseTo(0.6)
|
|
16
|
+
expect(height).toBeCloseTo(0.4)
|
|
17
|
+
})
|
|
18
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { VecModel } from '../misc/geometry-types'
|
|
2
|
-
import {
|
|
2
|
+
import { TLBaseShape } from './TLBaseShape'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Defines cropping parameters for shapes that support cropping.
|
|
@@ -71,4 +71,4 @@ export interface TLShapeCrop {
|
|
|
71
71
|
*
|
|
72
72
|
* @public
|
|
73
73
|
*/
|
|
74
|
-
export type ShapeWithCrop =
|
|
74
|
+
export type ShapeWithCrop = TLBaseShape<string, { w: number; h: number; crop: TLShapeCrop | null }>
|