@tldraw/tlschema 4.1.0-next.1b89b40eff1c → 4.1.0-next.2c81540f049b

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.
Files changed (212) hide show
  1. package/dist-cjs/TLStore.js +3 -10
  2. package/dist-cjs/TLStore.js.map +2 -2
  3. package/dist-cjs/assets/TLBaseAsset.js.map +2 -2
  4. package/dist-cjs/assets/TLBookmarkAsset.js.map +2 -2
  5. package/dist-cjs/assets/TLImageAsset.js.map +2 -2
  6. package/dist-cjs/assets/TLVideoAsset.js.map +2 -2
  7. package/dist-cjs/bindings/TLArrowBinding.js.map +2 -2
  8. package/dist-cjs/bindings/TLBaseBinding.js.map +2 -2
  9. package/dist-cjs/createPresenceStateDerivation.js.map +2 -2
  10. package/dist-cjs/createTLSchema.js.map +2 -2
  11. package/dist-cjs/index.d.ts +4416 -223
  12. package/dist-cjs/index.js +1 -1
  13. package/dist-cjs/index.js.map +2 -2
  14. package/dist-cjs/misc/TLColor.js.map +2 -2
  15. package/dist-cjs/misc/TLCursor.js.map +2 -2
  16. package/dist-cjs/misc/TLHandle.js.map +2 -2
  17. package/dist-cjs/misc/TLOpacity.js.map +2 -2
  18. package/dist-cjs/misc/TLRichText.js.map +2 -2
  19. package/dist-cjs/misc/TLScribble.js.map +2 -2
  20. package/dist-cjs/misc/geometry-types.js.map +2 -2
  21. package/dist-cjs/misc/id-validator.js.map +2 -2
  22. package/dist-cjs/records/TLAsset.js.map +2 -2
  23. package/dist-cjs/records/TLBinding.js.map +2 -2
  24. package/dist-cjs/records/TLCamera.js.map +2 -2
  25. package/dist-cjs/records/TLDocument.js.map +2 -2
  26. package/dist-cjs/records/TLInstance.js.map +2 -2
  27. package/dist-cjs/records/TLPage.js.map +2 -2
  28. package/dist-cjs/records/TLPageState.js.map +2 -2
  29. package/dist-cjs/records/TLPointer.js.map +2 -2
  30. package/dist-cjs/records/TLPresence.js.map +2 -2
  31. package/dist-cjs/records/TLRecord.js.map +1 -1
  32. package/dist-cjs/records/TLShape.js.map +2 -2
  33. package/dist-cjs/recordsWithProps.js.map +2 -2
  34. package/dist-cjs/shapes/ShapeWithCrop.js.map +1 -1
  35. package/dist-cjs/shapes/TLArrowShape.js.map +2 -2
  36. package/dist-cjs/shapes/TLBaseShape.js.map +2 -2
  37. package/dist-cjs/shapes/TLBookmarkShape.js.map +2 -2
  38. package/dist-cjs/shapes/TLDrawShape.js.map +2 -2
  39. package/dist-cjs/shapes/TLEmbedShape.js.map +2 -2
  40. package/dist-cjs/shapes/TLFrameShape.js.map +2 -2
  41. package/dist-cjs/shapes/TLGeoShape.js.map +2 -2
  42. package/dist-cjs/shapes/TLGroupShape.js.map +2 -2
  43. package/dist-cjs/shapes/TLHighlightShape.js.map +2 -2
  44. package/dist-cjs/shapes/TLImageShape.js.map +2 -2
  45. package/dist-cjs/shapes/TLLineShape.js.map +2 -2
  46. package/dist-cjs/shapes/TLNoteShape.js.map +2 -2
  47. package/dist-cjs/shapes/TLTextShape.js.map +2 -2
  48. package/dist-cjs/shapes/TLVideoShape.js.map +2 -2
  49. package/dist-cjs/store-migrations.js.map +2 -2
  50. package/dist-cjs/styles/TLColorStyle.js.map +2 -2
  51. package/dist-cjs/styles/TLDashStyle.js.map +2 -2
  52. package/dist-cjs/styles/TLFillStyle.js.map +2 -2
  53. package/dist-cjs/styles/TLFontStyle.js.map +2 -2
  54. package/dist-cjs/styles/TLHorizontalAlignStyle.js.map +2 -2
  55. package/dist-cjs/styles/TLSizeStyle.js.map +2 -2
  56. package/dist-cjs/styles/TLTextAlignStyle.js.map +2 -2
  57. package/dist-cjs/styles/TLVerticalAlignStyle.js.map +2 -2
  58. package/dist-cjs/translations/translations.js +1 -1
  59. package/dist-cjs/translations/translations.js.map +2 -2
  60. package/dist-cjs/util-types.js.map +1 -1
  61. package/dist-esm/TLStore.mjs +3 -10
  62. package/dist-esm/TLStore.mjs.map +2 -2
  63. package/dist-esm/assets/TLBaseAsset.mjs.map +2 -2
  64. package/dist-esm/assets/TLBookmarkAsset.mjs.map +2 -2
  65. package/dist-esm/assets/TLImageAsset.mjs.map +2 -2
  66. package/dist-esm/assets/TLVideoAsset.mjs.map +2 -2
  67. package/dist-esm/bindings/TLArrowBinding.mjs.map +2 -2
  68. package/dist-esm/bindings/TLBaseBinding.mjs.map +2 -2
  69. package/dist-esm/createPresenceStateDerivation.mjs.map +2 -2
  70. package/dist-esm/createTLSchema.mjs.map +2 -2
  71. package/dist-esm/index.d.mts +4416 -223
  72. package/dist-esm/index.mjs +1 -1
  73. package/dist-esm/index.mjs.map +2 -2
  74. package/dist-esm/misc/TLColor.mjs.map +2 -2
  75. package/dist-esm/misc/TLCursor.mjs.map +2 -2
  76. package/dist-esm/misc/TLHandle.mjs.map +2 -2
  77. package/dist-esm/misc/TLOpacity.mjs.map +2 -2
  78. package/dist-esm/misc/TLRichText.mjs.map +2 -2
  79. package/dist-esm/misc/TLScribble.mjs.map +2 -2
  80. package/dist-esm/misc/geometry-types.mjs.map +2 -2
  81. package/dist-esm/misc/id-validator.mjs.map +2 -2
  82. package/dist-esm/records/TLAsset.mjs.map +2 -2
  83. package/dist-esm/records/TLBinding.mjs.map +2 -2
  84. package/dist-esm/records/TLCamera.mjs.map +2 -2
  85. package/dist-esm/records/TLDocument.mjs.map +2 -2
  86. package/dist-esm/records/TLInstance.mjs.map +2 -2
  87. package/dist-esm/records/TLPage.mjs.map +2 -2
  88. package/dist-esm/records/TLPageState.mjs.map +2 -2
  89. package/dist-esm/records/TLPointer.mjs.map +2 -2
  90. package/dist-esm/records/TLPresence.mjs.map +2 -2
  91. package/dist-esm/records/TLShape.mjs.map +2 -2
  92. package/dist-esm/recordsWithProps.mjs.map +2 -2
  93. package/dist-esm/shapes/TLArrowShape.mjs.map +2 -2
  94. package/dist-esm/shapes/TLBaseShape.mjs.map +2 -2
  95. package/dist-esm/shapes/TLBookmarkShape.mjs.map +2 -2
  96. package/dist-esm/shapes/TLDrawShape.mjs.map +2 -2
  97. package/dist-esm/shapes/TLEmbedShape.mjs.map +2 -2
  98. package/dist-esm/shapes/TLFrameShape.mjs.map +2 -2
  99. package/dist-esm/shapes/TLGeoShape.mjs.map +2 -2
  100. package/dist-esm/shapes/TLGroupShape.mjs.map +2 -2
  101. package/dist-esm/shapes/TLHighlightShape.mjs.map +2 -2
  102. package/dist-esm/shapes/TLImageShape.mjs.map +2 -2
  103. package/dist-esm/shapes/TLLineShape.mjs.map +2 -2
  104. package/dist-esm/shapes/TLNoteShape.mjs.map +2 -2
  105. package/dist-esm/shapes/TLTextShape.mjs.map +2 -2
  106. package/dist-esm/shapes/TLVideoShape.mjs.map +2 -2
  107. package/dist-esm/store-migrations.mjs.map +2 -2
  108. package/dist-esm/styles/TLColorStyle.mjs.map +2 -2
  109. package/dist-esm/styles/TLDashStyle.mjs.map +2 -2
  110. package/dist-esm/styles/TLFillStyle.mjs.map +2 -2
  111. package/dist-esm/styles/TLFontStyle.mjs.map +2 -2
  112. package/dist-esm/styles/TLHorizontalAlignStyle.mjs.map +2 -2
  113. package/dist-esm/styles/TLSizeStyle.mjs.map +2 -2
  114. package/dist-esm/styles/TLTextAlignStyle.mjs.map +2 -2
  115. package/dist-esm/styles/TLVerticalAlignStyle.mjs.map +2 -2
  116. package/dist-esm/translations/translations.mjs +1 -1
  117. package/dist-esm/translations/translations.mjs.map +2 -2
  118. package/package.json +5 -5
  119. package/src/TLStore.test.ts +644 -0
  120. package/src/TLStore.ts +205 -20
  121. package/src/assets/TLBaseAsset.ts +90 -7
  122. package/src/assets/TLBookmarkAsset.test.ts +96 -0
  123. package/src/assets/TLBookmarkAsset.ts +52 -2
  124. package/src/assets/TLImageAsset.test.ts +213 -0
  125. package/src/assets/TLImageAsset.ts +60 -2
  126. package/src/assets/TLVideoAsset.test.ts +105 -0
  127. package/src/assets/TLVideoAsset.ts +93 -4
  128. package/src/bindings/TLArrowBinding.test.ts +55 -0
  129. package/src/bindings/TLArrowBinding.ts +132 -10
  130. package/src/bindings/TLBaseBinding.ts +140 -3
  131. package/src/createPresenceStateDerivation.test.ts +158 -0
  132. package/src/createPresenceStateDerivation.ts +71 -2
  133. package/src/createTLSchema.test.ts +181 -0
  134. package/src/createTLSchema.ts +164 -7
  135. package/src/index.ts +32 -0
  136. package/src/misc/TLColor.ts +50 -6
  137. package/src/misc/TLCursor.ts +110 -8
  138. package/src/misc/TLHandle.ts +86 -6
  139. package/src/misc/TLOpacity.ts +51 -2
  140. package/src/misc/TLRichText.ts +56 -3
  141. package/src/misc/TLScribble.ts +105 -5
  142. package/src/misc/geometry-types.ts +30 -2
  143. package/src/misc/id-validator.test.ts +50 -0
  144. package/src/misc/id-validator.ts +20 -1
  145. package/src/records/TLAsset.test.ts +234 -0
  146. package/src/records/TLAsset.ts +165 -8
  147. package/src/records/TLBinding.test.ts +22 -0
  148. package/src/records/TLBinding.ts +277 -11
  149. package/src/records/TLCamera.test.ts +19 -0
  150. package/src/records/TLCamera.ts +118 -7
  151. package/src/records/TLDocument.test.ts +35 -0
  152. package/src/records/TLDocument.ts +148 -8
  153. package/src/records/TLInstance.test.ts +201 -0
  154. package/src/records/TLInstance.ts +117 -9
  155. package/src/records/TLPage.test.ts +110 -0
  156. package/src/records/TLPage.ts +106 -8
  157. package/src/records/TLPageState.test.ts +228 -0
  158. package/src/records/TLPageState.ts +88 -7
  159. package/src/records/TLPointer.test.ts +63 -0
  160. package/src/records/TLPointer.ts +105 -7
  161. package/src/records/TLPresence.test.ts +190 -0
  162. package/src/records/TLPresence.ts +99 -5
  163. package/src/records/TLRecord.test.ts +70 -0
  164. package/src/records/TLRecord.ts +43 -1
  165. package/src/records/TLShape.test.ts +232 -0
  166. package/src/records/TLShape.ts +289 -12
  167. package/src/recordsWithProps.test.ts +188 -0
  168. package/src/recordsWithProps.ts +131 -2
  169. package/src/shapes/ShapeWithCrop.test.ts +18 -0
  170. package/src/shapes/ShapeWithCrop.ts +64 -2
  171. package/src/shapes/TLArrowShape.test.ts +505 -0
  172. package/src/shapes/TLArrowShape.ts +188 -10
  173. package/src/shapes/TLBaseShape.test.ts +142 -0
  174. package/src/shapes/TLBaseShape.ts +103 -4
  175. package/src/shapes/TLBookmarkShape.test.ts +122 -0
  176. package/src/shapes/TLBookmarkShape.ts +58 -4
  177. package/src/shapes/TLDrawShape.test.ts +177 -0
  178. package/src/shapes/TLDrawShape.ts +97 -6
  179. package/src/shapes/TLEmbedShape.test.ts +286 -0
  180. package/src/shapes/TLEmbedShape.ts +57 -4
  181. package/src/shapes/TLFrameShape.test.ts +71 -0
  182. package/src/shapes/TLFrameShape.ts +59 -4
  183. package/src/shapes/TLGeoShape.test.ts +247 -0
  184. package/src/shapes/TLGeoShape.ts +103 -7
  185. package/src/shapes/TLGroupShape.test.ts +59 -0
  186. package/src/shapes/TLGroupShape.ts +52 -4
  187. package/src/shapes/TLHighlightShape.test.ts +325 -0
  188. package/src/shapes/TLHighlightShape.ts +79 -4
  189. package/src/shapes/TLImageShape.test.ts +534 -0
  190. package/src/shapes/TLImageShape.ts +105 -5
  191. package/src/shapes/TLLineShape.test.ts +269 -0
  192. package/src/shapes/TLLineShape.ts +128 -8
  193. package/src/shapes/TLNoteShape.test.ts +1568 -0
  194. package/src/shapes/TLNoteShape.ts +97 -4
  195. package/src/shapes/TLTextShape.test.ts +407 -0
  196. package/src/shapes/TLTextShape.ts +94 -4
  197. package/src/shapes/TLVideoShape.test.ts +112 -0
  198. package/src/shapes/TLVideoShape.ts +99 -4
  199. package/src/store-migrations.test.ts +88 -0
  200. package/src/store-migrations.ts +47 -1
  201. package/src/styles/TLColorStyle.test.ts +439 -0
  202. package/src/styles/TLColorStyle.ts +228 -10
  203. package/src/styles/TLDashStyle.ts +54 -2
  204. package/src/styles/TLFillStyle.ts +54 -2
  205. package/src/styles/TLFontStyle.ts +72 -3
  206. package/src/styles/TLHorizontalAlignStyle.ts +55 -2
  207. package/src/styles/TLSizeStyle.ts +54 -2
  208. package/src/styles/TLTextAlignStyle.ts +52 -2
  209. package/src/styles/TLVerticalAlignStyle.ts +52 -2
  210. package/src/translations/translations.test.ts +378 -35
  211. package/src/translations/translations.ts +157 -10
  212. package/src/util-types.ts +51 -1
@@ -0,0 +1,1568 @@
1
+ import { T } from '@tldraw/validate'
2
+ import { describe, expect, it, test } from 'vitest'
3
+ import { getTestMigration } from '../__tests__/migrationTestUtils'
4
+ import { TLRichText, toRichText } from '../misc/TLRichText'
5
+ import { TLShapeId } from '../records/TLShape'
6
+ import { DefaultColorStyle, DefaultLabelColorStyle } from '../styles/TLColorStyle'
7
+ import { DefaultFontStyle } from '../styles/TLFontStyle'
8
+ import { DefaultHorizontalAlignStyle } from '../styles/TLHorizontalAlignStyle'
9
+ import { DefaultSizeStyle } from '../styles/TLSizeStyle'
10
+ import { DefaultVerticalAlignStyle } from '../styles/TLVerticalAlignStyle'
11
+ import {
12
+ TLNoteShape,
13
+ TLNoteShapeProps,
14
+ noteShapeMigrations,
15
+ noteShapeProps,
16
+ noteShapeVersions,
17
+ } from './TLNoteShape'
18
+
19
+ describe('TLNoteShape', () => {
20
+ describe('TLNoteShapeProps interface', () => {
21
+ it('should represent valid note shape properties', () => {
22
+ const validProps: TLNoteShapeProps = {
23
+ color: 'yellow',
24
+ labelColor: 'black',
25
+ size: 'm',
26
+ font: 'draw',
27
+ fontSizeAdjustment: 0,
28
+ align: 'middle',
29
+ verticalAlign: 'middle',
30
+ growY: 0,
31
+ url: '',
32
+ richText: toRichText('Hello World'),
33
+ scale: 1,
34
+ }
35
+
36
+ expect(validProps.color).toBe('yellow')
37
+ expect(validProps.labelColor).toBe('black')
38
+ expect(validProps.size).toBe('m')
39
+ expect(validProps.font).toBe('draw')
40
+ expect(validProps.fontSizeAdjustment).toBe(0)
41
+ expect(validProps.align).toBe('middle')
42
+ expect(validProps.verticalAlign).toBe('middle')
43
+ expect(validProps.growY).toBe(0)
44
+ expect(validProps.url).toBe('')
45
+ expect(validProps.richText).toBeDefined()
46
+ expect(validProps.scale).toBe(1)
47
+ })
48
+
49
+ it('should support different color combinations', () => {
50
+ const colorCombinations = [
51
+ { color: 'black' as const, labelColor: 'white' as const },
52
+ { color: 'red' as const, labelColor: 'black' as const },
53
+ { color: 'blue' as const, labelColor: 'yellow' as const },
54
+ { color: 'green' as const, labelColor: 'red' as const },
55
+ { color: 'light-blue' as const, labelColor: 'black' as const },
56
+ ]
57
+
58
+ colorCombinations.forEach(({ color, labelColor }) => {
59
+ const props: Partial<TLNoteShapeProps> = {
60
+ color,
61
+ labelColor,
62
+ }
63
+
64
+ expect(props.color).toBe(color)
65
+ expect(props.labelColor).toBe(labelColor)
66
+ })
67
+ })
68
+
69
+ it('should support different size variations', () => {
70
+ const sizes = ['s', 'm', 'l', 'xl'] as const
71
+
72
+ sizes.forEach((size) => {
73
+ const props: Partial<TLNoteShapeProps> = { size }
74
+ expect(props.size).toBe(size)
75
+ })
76
+ })
77
+
78
+ it('should support different font styles', () => {
79
+ const fonts = ['draw', 'sans', 'serif', 'mono'] as const
80
+
81
+ fonts.forEach((font) => {
82
+ const props: Partial<TLNoteShapeProps> = { font }
83
+ expect(props.font).toBe(font)
84
+ })
85
+ })
86
+
87
+ it('should support different alignment combinations', () => {
88
+ const alignmentCombinations: Array<
89
+ [TLNoteShapeProps['align'], TLNoteShapeProps['verticalAlign']]
90
+ > = [
91
+ ['start', 'start'],
92
+ ['middle', 'middle'],
93
+ ['end', 'end'],
94
+ ['start-legacy', 'start'],
95
+ ['middle-legacy', 'middle'],
96
+ ['end-legacy', 'end'],
97
+ ]
98
+
99
+ alignmentCombinations.forEach(([align, verticalAlign]) => {
100
+ const props: Partial<TLNoteShapeProps> = {
101
+ align,
102
+ verticalAlign,
103
+ }
104
+
105
+ expect(props.align).toBe(align)
106
+ expect(props.verticalAlign).toBe(verticalAlign)
107
+ })
108
+ })
109
+
110
+ it('should support font size adjustments', () => {
111
+ const fontAdjustments = [-2, -1, 0, 1, 2, 5, 10]
112
+
113
+ fontAdjustments.forEach((adjustment) => {
114
+ const props: Partial<TLNoteShapeProps> = {
115
+ fontSizeAdjustment: adjustment,
116
+ }
117
+
118
+ expect(props.fontSizeAdjustment).toBe(adjustment)
119
+ })
120
+ })
121
+
122
+ it('should support growY values for height expansion', () => {
123
+ const growYValues = [0, 10, 25, 50, 100, 200]
124
+
125
+ growYValues.forEach((growY) => {
126
+ const props: Partial<TLNoteShapeProps> = { growY }
127
+ expect(props.growY).toBe(growY)
128
+ })
129
+ })
130
+
131
+ it('should support scale variations', () => {
132
+ const scaleValues = [0.5, 0.8, 1, 1.2, 1.5, 2, 3]
133
+
134
+ scaleValues.forEach((scale) => {
135
+ const props: Partial<TLNoteShapeProps> = { scale }
136
+ expect(props.scale).toBe(scale)
137
+ })
138
+ })
139
+
140
+ it('should support rich text content', () => {
141
+ const richTexts = [
142
+ toRichText(''),
143
+ toRichText('Simple note'),
144
+ toRichText('**Bold** note'),
145
+ toRichText('*Italic* text'),
146
+ toRichText('Multiple\nLines\nNote'),
147
+ toRichText('Complex **bold** and *italic* formatting'),
148
+ ]
149
+
150
+ richTexts.forEach((richText) => {
151
+ const props: Partial<TLNoteShapeProps> = { richText }
152
+ expect(props.richText).toBe(richText)
153
+ })
154
+ })
155
+
156
+ it('should support URL links', () => {
157
+ const urlVariations = [
158
+ '',
159
+ 'https://tldraw.com',
160
+ 'http://example.com',
161
+ 'https://subdomain.example.com/path',
162
+ 'https://example.com/path?query=value#anchor',
163
+ ]
164
+
165
+ urlVariations.forEach((url) => {
166
+ const props: Partial<TLNoteShapeProps> = { url }
167
+ expect(props.url).toBe(url)
168
+ })
169
+ })
170
+ })
171
+
172
+ describe('TLNoteShape type', () => {
173
+ it('should represent complete note shape records', () => {
174
+ const validNoteShape: TLNoteShape = {
175
+ id: 'shape:note123' as TLShapeId,
176
+ typeName: 'shape',
177
+ type: 'note',
178
+ x: 100,
179
+ y: 200,
180
+ rotation: 0,
181
+ index: 'a1' as any,
182
+ parentId: 'page:main' as any,
183
+ isLocked: false,
184
+ opacity: 1,
185
+ props: {
186
+ color: 'yellow',
187
+ labelColor: 'black',
188
+ size: 's',
189
+ font: 'sans',
190
+ fontSizeAdjustment: 2,
191
+ align: 'start',
192
+ verticalAlign: 'start',
193
+ growY: 50,
194
+ url: 'https://example.com',
195
+ richText: toRichText('Important **note**!'),
196
+ scale: 1,
197
+ },
198
+ meta: {},
199
+ }
200
+
201
+ expect(validNoteShape.type).toBe('note')
202
+ expect(validNoteShape.typeName).toBe('shape')
203
+ expect(validNoteShape.props.color).toBe('yellow')
204
+ expect(validNoteShape.props.labelColor).toBe('black')
205
+ expect(validNoteShape.props.size).toBe('s')
206
+ expect(validNoteShape.props.font).toBe('sans')
207
+ })
208
+
209
+ it('should support different note configurations', () => {
210
+ const configurations = [
211
+ {
212
+ color: 'light-blue' as const,
213
+ labelColor: 'black' as const,
214
+ size: 's' as const,
215
+ font: 'draw' as const,
216
+ fontSizeAdjustment: 0,
217
+ align: 'start' as const,
218
+ verticalAlign: 'start' as const,
219
+ growY: 0,
220
+ scale: 1,
221
+ },
222
+ {
223
+ color: 'red' as const,
224
+ labelColor: 'white' as const,
225
+ size: 'l' as const,
226
+ font: 'serif' as const,
227
+ fontSizeAdjustment: 5,
228
+ align: 'middle' as const,
229
+ verticalAlign: 'middle' as const,
230
+ growY: 25,
231
+ scale: 1.5,
232
+ },
233
+ {
234
+ color: 'green' as const,
235
+ labelColor: 'black' as const,
236
+ size: 'xl' as const,
237
+ font: 'mono' as const,
238
+ fontSizeAdjustment: -1,
239
+ align: 'end' as const,
240
+ verticalAlign: 'end' as const,
241
+ growY: 100,
242
+ scale: 0.8,
243
+ },
244
+ ]
245
+
246
+ configurations.forEach((config, index) => {
247
+ const shape: TLNoteShape = {
248
+ id: `shape:note${index}` as TLShapeId,
249
+ typeName: 'shape',
250
+ type: 'note',
251
+ x: index * 100,
252
+ y: index * 50,
253
+ rotation: 0,
254
+ index: `a${index}` as any,
255
+ parentId: 'page:main' as any,
256
+ isLocked: false,
257
+ opacity: 1,
258
+ props: {
259
+ color: config.color,
260
+ labelColor: config.labelColor,
261
+ size: config.size,
262
+ font: config.font,
263
+ fontSizeAdjustment: config.fontSizeAdjustment,
264
+ align: config.align,
265
+ verticalAlign: config.verticalAlign,
266
+ growY: config.growY,
267
+ url: '',
268
+ richText: toRichText(`Note ${index + 1}`),
269
+ scale: config.scale,
270
+ },
271
+ meta: {},
272
+ }
273
+
274
+ expect(shape.props.color).toBe(config.color)
275
+ expect(shape.props.labelColor).toBe(config.labelColor)
276
+ expect(shape.props.size).toBe(config.size)
277
+ expect(shape.props.font).toBe(config.font)
278
+ expect(shape.props.fontSizeAdjustment).toBe(config.fontSizeAdjustment)
279
+ })
280
+ })
281
+
282
+ it('should support notes with different text content', () => {
283
+ const textVariations = [
284
+ '',
285
+ 'Quick note',
286
+ 'Multi\nLine\nNote',
287
+ 'Note with **formatting**',
288
+ 'Long note with multiple paragraphs and detailed content',
289
+ ]
290
+
291
+ textVariations.forEach((text, index) => {
292
+ const shape: TLNoteShape = {
293
+ id: `shape:text_note${index}` as TLShapeId,
294
+ typeName: 'shape',
295
+ type: 'note',
296
+ x: 0,
297
+ y: 0,
298
+ rotation: 0,
299
+ index: `a${index}` as any,
300
+ parentId: 'page:main' as any,
301
+ isLocked: false,
302
+ opacity: 1,
303
+ props: {
304
+ color: 'yellow',
305
+ labelColor: 'black',
306
+ size: 'm',
307
+ font: 'draw',
308
+ fontSizeAdjustment: 0,
309
+ align: 'middle',
310
+ verticalAlign: 'middle',
311
+ growY: index * 20, // Different growY for text expansion
312
+ url: '',
313
+ richText: toRichText(text),
314
+ scale: 1,
315
+ },
316
+ meta: {},
317
+ }
318
+
319
+ expect(shape.props.richText).toBeDefined()
320
+ expect(shape.props.growY).toBe(index * 20)
321
+ })
322
+ })
323
+
324
+ it('should support locked and transparent notes', () => {
325
+ const shape: TLNoteShape = {
326
+ id: 'shape:special_note' as TLShapeId,
327
+ typeName: 'shape',
328
+ type: 'note',
329
+ x: 50,
330
+ y: 75,
331
+ rotation: 1.57, // 90 degrees
332
+ index: 'b1' as any,
333
+ parentId: 'page:test' as any,
334
+ isLocked: true,
335
+ opacity: 0.5,
336
+ props: {
337
+ color: 'red',
338
+ labelColor: 'white',
339
+ size: 'l',
340
+ font: 'serif',
341
+ fontSizeAdjustment: 3,
342
+ align: 'end',
343
+ verticalAlign: 'start',
344
+ growY: 30,
345
+ url: 'https://important-note.com',
346
+ richText: toRichText('Locked note'),
347
+ scale: 0.9,
348
+ },
349
+ meta: { priority: 'high' },
350
+ }
351
+
352
+ expect(shape.isLocked).toBe(true)
353
+ expect(shape.opacity).toBe(0.5)
354
+ expect(shape.rotation).toBe(1.57)
355
+ expect(shape.meta).toEqual({ priority: 'high' })
356
+ })
357
+ })
358
+
359
+ describe('noteShapeProps validation schema', () => {
360
+ it('should validate all note shape properties', () => {
361
+ const validProps = {
362
+ color: 'blue',
363
+ labelColor: 'red',
364
+ size: 'l',
365
+ font: 'sans',
366
+ fontSizeAdjustment: 3,
367
+ align: 'start',
368
+ verticalAlign: 'end',
369
+ growY: 15,
370
+ url: 'https://example.com',
371
+ richText: toRichText('Test note'),
372
+ scale: 1.2,
373
+ }
374
+
375
+ // Validate each property individually
376
+ expect(() => noteShapeProps.color.validate(validProps.color)).not.toThrow()
377
+ expect(() => noteShapeProps.labelColor.validate(validProps.labelColor)).not.toThrow()
378
+ expect(() => noteShapeProps.size.validate(validProps.size)).not.toThrow()
379
+ expect(() => noteShapeProps.font.validate(validProps.font)).not.toThrow()
380
+ expect(() =>
381
+ noteShapeProps.fontSizeAdjustment.validate(validProps.fontSizeAdjustment)
382
+ ).not.toThrow()
383
+ expect(() => noteShapeProps.align.validate(validProps.align)).not.toThrow()
384
+ expect(() => noteShapeProps.verticalAlign.validate(validProps.verticalAlign)).not.toThrow()
385
+ expect(() => noteShapeProps.growY.validate(validProps.growY)).not.toThrow()
386
+ expect(() => noteShapeProps.url.validate(validProps.url)).not.toThrow()
387
+ expect(() => noteShapeProps.richText.validate(validProps.richText)).not.toThrow()
388
+ expect(() => noteShapeProps.scale.validate(validProps.scale)).not.toThrow()
389
+ })
390
+
391
+ it('should validate using comprehensive object validator', () => {
392
+ const fullValidator = T.object(noteShapeProps)
393
+
394
+ const validPropsObject = {
395
+ color: 'green' as const,
396
+ labelColor: 'black' as const,
397
+ size: 's' as const,
398
+ font: 'mono' as const,
399
+ fontSizeAdjustment: 2,
400
+ align: 'end' as const,
401
+ verticalAlign: 'start' as const,
402
+ growY: 25,
403
+ url: 'https://test.com',
404
+ richText: toRichText('Note test') as TLRichText,
405
+ scale: 0.8,
406
+ }
407
+
408
+ expect(() => fullValidator.validate(validPropsObject)).not.toThrow()
409
+ const result = fullValidator.validate(validPropsObject)
410
+ expect(result).toEqual(validPropsObject)
411
+ })
412
+
413
+ it('should validate fontSizeAdjustment as positiveNumber', () => {
414
+ // Valid positive numbers (including zero)
415
+ const validAdjustments = [0, 1, 2, 5, 10, 0.5]
416
+
417
+ validAdjustments.forEach((adjustment) => {
418
+ expect(() => noteShapeProps.fontSizeAdjustment.validate(adjustment)).not.toThrow()
419
+ })
420
+
421
+ // Invalid adjustments (negative numbers and non-numbers)
422
+ const invalidAdjustments = [-1, -0.1, -5, 'not-number', null, undefined, {}, [], true, false]
423
+
424
+ invalidAdjustments.forEach((adjustment) => {
425
+ expect(() => noteShapeProps.fontSizeAdjustment.validate(adjustment)).toThrow()
426
+ })
427
+ })
428
+
429
+ it('should validate growY as positiveNumber', () => {
430
+ // Valid positive numbers (including zero)
431
+ const validGrowY = [0, 0.1, 1, 5, 50, 100]
432
+
433
+ validGrowY.forEach((growY) => {
434
+ expect(() => noteShapeProps.growY.validate(growY)).not.toThrow()
435
+ })
436
+
437
+ // Invalid growY values (negative numbers and non-numbers)
438
+ const invalidGrowY = [-1, -0.1, -100, 'not-number', null, undefined, {}, [], true, false]
439
+
440
+ invalidGrowY.forEach((growY) => {
441
+ expect(() => noteShapeProps.growY.validate(growY)).toThrow()
442
+ })
443
+ })
444
+
445
+ it('should validate scale as nonZeroNumber', () => {
446
+ // Valid non-zero positive numbers
447
+ const validScales = [0.1, 0.5, 1, 1.5, 2, 10]
448
+
449
+ validScales.forEach((scale) => {
450
+ expect(() => noteShapeProps.scale.validate(scale)).not.toThrow()
451
+ })
452
+
453
+ // Invalid scales (zero, negative numbers, and non-numbers)
454
+ const invalidScales = [0, -0.5, -1, -2, 'not-number', null, undefined, {}, [], true, false]
455
+
456
+ invalidScales.forEach((scale) => {
457
+ expect(() => noteShapeProps.scale.validate(scale)).toThrow()
458
+ })
459
+ })
460
+
461
+ it('should validate URLs using linkUrl validator', () => {
462
+ const validUrls = [
463
+ '',
464
+ 'https://example.com',
465
+ 'http://test.com',
466
+ 'https://subdomain.example.com/path',
467
+ 'https://example.com/path?query=value#anchor',
468
+ ]
469
+
470
+ validUrls.forEach((url) => {
471
+ expect(() => noteShapeProps.url.validate(url)).not.toThrow()
472
+ })
473
+
474
+ // Invalid URLs should be handled by linkUrl validator
475
+ const invalidUrls = [
476
+ 'not-a-url',
477
+ 'ftp://example.com', // May be invalid depending on linkUrl implementation
478
+ null,
479
+ undefined,
480
+ 123,
481
+ {},
482
+ [],
483
+ ]
484
+
485
+ invalidUrls.forEach((url) => {
486
+ expect(() => noteShapeProps.url.validate(url)).toThrow()
487
+ })
488
+ })
489
+
490
+ it('should validate rich text property', () => {
491
+ const validRichTexts = [
492
+ toRichText(''),
493
+ toRichText('Simple text'),
494
+ toRichText('**Bold** text'),
495
+ toRichText('*Italic* and **bold**'),
496
+ toRichText('Multiple\nlines\nof\ntext'),
497
+ ]
498
+
499
+ validRichTexts.forEach((richText) => {
500
+ expect(() => noteShapeProps.richText.validate(richText)).not.toThrow()
501
+ })
502
+
503
+ const invalidRichTexts = [
504
+ 'plain string', // Not a TLRichText object
505
+ null,
506
+ undefined,
507
+ 123,
508
+ {},
509
+ [],
510
+ { invalid: 'structure' },
511
+ ]
512
+
513
+ invalidRichTexts.forEach((richText) => {
514
+ expect(() => noteShapeProps.richText.validate(richText)).toThrow()
515
+ })
516
+ })
517
+
518
+ it('should use correct default style validators', () => {
519
+ // Verify that the props schema uses the expected style validators
520
+ expect(noteShapeProps.color).toBe(DefaultColorStyle)
521
+ expect(noteShapeProps.labelColor).toBe(DefaultLabelColorStyle)
522
+ expect(noteShapeProps.size).toBe(DefaultSizeStyle)
523
+ expect(noteShapeProps.font).toBe(DefaultFontStyle)
524
+ expect(noteShapeProps.align).toBe(DefaultHorizontalAlignStyle)
525
+ expect(noteShapeProps.verticalAlign).toBe(DefaultVerticalAlignStyle)
526
+ })
527
+
528
+ it('should validate fontSizeAdjustment with T.positiveNumber', () => {
529
+ expect(noteShapeProps.fontSizeAdjustment).toBe(T.positiveNumber)
530
+ })
531
+
532
+ it('should validate growY with T.positiveNumber', () => {
533
+ expect(noteShapeProps.growY).toBe(T.positiveNumber)
534
+ })
535
+
536
+ it('should validate scale with T.nonZeroNumber', () => {
537
+ expect(noteShapeProps.scale).toBe(T.nonZeroNumber)
538
+ })
539
+
540
+ it('should validate url with T.linkUrl', () => {
541
+ expect(noteShapeProps.url).toBe(T.linkUrl)
542
+ })
543
+ })
544
+
545
+ describe('noteShapeVersions', () => {
546
+ it('should contain expected migration version IDs', () => {
547
+ expect(noteShapeVersions).toBeDefined()
548
+ expect(typeof noteShapeVersions).toBe('object')
549
+ })
550
+
551
+ it('should have all expected migration versions', () => {
552
+ const expectedVersions: Array<keyof typeof noteShapeVersions> = [
553
+ 'AddUrlProp',
554
+ 'RemoveJustify',
555
+ 'MigrateLegacyAlign',
556
+ 'AddVerticalAlign',
557
+ 'MakeUrlsValid',
558
+ 'AddFontSizeAdjustment',
559
+ 'AddScale',
560
+ 'AddLabelColor',
561
+ 'AddRichText',
562
+ ]
563
+
564
+ expectedVersions.forEach((version) => {
565
+ expect(noteShapeVersions[version]).toBeDefined()
566
+ expect(typeof noteShapeVersions[version]).toBe('string')
567
+ })
568
+ })
569
+
570
+ it('should have properly formatted migration IDs', () => {
571
+ Object.values(noteShapeVersions).forEach((versionId) => {
572
+ expect(versionId).toMatch(/^com\.tldraw\.shape\.note\//)
573
+ expect(versionId).toMatch(/\/\d+$/) // Should end with /number
574
+ })
575
+ })
576
+
577
+ it('should contain note in migration IDs', () => {
578
+ Object.values(noteShapeVersions).forEach((versionId) => {
579
+ expect(versionId).toContain('note')
580
+ })
581
+ })
582
+
583
+ it('should have unique version IDs', () => {
584
+ const versionIds = Object.values(noteShapeVersions)
585
+ const uniqueIds = new Set(versionIds)
586
+ expect(uniqueIds.size).toBe(versionIds.length)
587
+ })
588
+ })
589
+
590
+ describe('noteShapeMigrations', () => {
591
+ it('should be defined and have required structure', () => {
592
+ expect(noteShapeMigrations).toBeDefined()
593
+ expect(noteShapeMigrations.sequence).toBeDefined()
594
+ expect(Array.isArray(noteShapeMigrations.sequence)).toBe(true)
595
+ })
596
+
597
+ it('should have migrations for all version IDs', () => {
598
+ const migrationIds = noteShapeMigrations.sequence
599
+ .filter((migration) => 'id' in migration)
600
+ .map((migration) => ('id' in migration ? migration.id : null))
601
+ .filter(Boolean)
602
+
603
+ const versionIds = Object.values(noteShapeVersions)
604
+
605
+ versionIds.forEach((versionId) => {
606
+ expect(migrationIds).toContain(versionId)
607
+ })
608
+ })
609
+
610
+ it('should have correct number of migrations in sequence', () => {
611
+ // Should have at least as many migrations as version IDs
612
+ expect(noteShapeMigrations.sequence.length).toBeGreaterThanOrEqual(
613
+ Object.keys(noteShapeVersions).length
614
+ )
615
+ })
616
+ })
617
+
618
+ describe('noteShapeMigrations - AddUrlProp migration', () => {
619
+ const { up, down } = getTestMigration(noteShapeVersions.AddUrlProp)
620
+
621
+ describe('AddUrlProp up migration', () => {
622
+ it('should add url property with empty string default', () => {
623
+ const oldRecord = {
624
+ id: 'shape:note1',
625
+ typeName: 'shape',
626
+ type: 'note',
627
+ x: 100,
628
+ y: 200,
629
+ rotation: 0,
630
+ index: 'a1',
631
+ parentId: 'page:main',
632
+ isLocked: false,
633
+ opacity: 1,
634
+ props: {
635
+ color: 'yellow',
636
+ labelColor: 'black',
637
+ size: 'm',
638
+ },
639
+ meta: {},
640
+ }
641
+
642
+ const result = up(oldRecord)
643
+ expect(result.props.url).toBe('')
644
+ expect(result.props.color).toBe('yellow') // Preserve other props
645
+ })
646
+
647
+ it('should preserve all existing properties during migration', () => {
648
+ const oldRecord = {
649
+ id: 'shape:note2',
650
+ props: {
651
+ color: 'blue',
652
+ labelColor: 'red',
653
+ size: 'l',
654
+ font: 'sans',
655
+ fontSizeAdjustment: 2,
656
+ align: 'start',
657
+ verticalAlign: 'middle',
658
+ growY: 25,
659
+ scale: 1.5,
660
+ },
661
+ }
662
+
663
+ const result = up(oldRecord)
664
+ expect(result.props.url).toBe('')
665
+ expect(result.props.color).toBe('blue')
666
+ expect(result.props.labelColor).toBe('red')
667
+ expect(result.props.size).toBe('l')
668
+ expect(result.props.fontSizeAdjustment).toBe(2)
669
+ })
670
+ })
671
+
672
+ describe('AddUrlProp down migration', () => {
673
+ it('should be retired (no down migration)', () => {
674
+ expect(() => {
675
+ down({})
676
+ }).toThrow('Migration com.tldraw.shape.note/1 does not have a down function')
677
+ })
678
+ })
679
+ })
680
+
681
+ describe('noteShapeMigrations - RemoveJustify migration', () => {
682
+ const { up, down } = getTestMigration(noteShapeVersions.RemoveJustify)
683
+
684
+ describe('RemoveJustify up migration', () => {
685
+ it('should convert justify alignment to start', () => {
686
+ const oldRecord = {
687
+ id: 'shape:note1',
688
+ props: {
689
+ color: 'yellow',
690
+ align: 'justify',
691
+ labelColor: 'black',
692
+ },
693
+ }
694
+
695
+ const result = up(oldRecord)
696
+ expect(result.props.align).toBe('start')
697
+ expect(result.props.color).toBe('yellow') // Preserve other props
698
+ })
699
+
700
+ it('should preserve non-justify alignments', () => {
701
+ const alignments = ['start', 'middle', 'end']
702
+
703
+ alignments.forEach((align) => {
704
+ const oldRecord = {
705
+ id: 'shape:note1',
706
+ props: {
707
+ color: 'red',
708
+ align,
709
+ labelColor: 'black',
710
+ },
711
+ }
712
+
713
+ const result = up(oldRecord)
714
+ expect(result.props.align).toBe(align)
715
+ })
716
+ })
717
+
718
+ it('should preserve all other properties during migration', () => {
719
+ const oldRecord = {
720
+ id: 'shape:note1',
721
+ props: {
722
+ color: 'green',
723
+ align: 'justify',
724
+ labelColor: 'white',
725
+ size: 'l',
726
+ font: 'serif',
727
+ fontSizeAdjustment: 1,
728
+ verticalAlign: 'start',
729
+ growY: 30,
730
+ scale: 0.8,
731
+ },
732
+ }
733
+
734
+ const result = up(oldRecord)
735
+ expect(result.props.align).toBe('start')
736
+ expect(result.props.color).toBe('green')
737
+ expect(result.props.labelColor).toBe('white')
738
+ expect(result.props.size).toBe('l')
739
+ expect(result.props.font).toBe('serif')
740
+ })
741
+ })
742
+
743
+ describe('RemoveJustify down migration', () => {
744
+ it('should be retired (no down migration)', () => {
745
+ expect(() => {
746
+ down({})
747
+ }).toThrow('Migration com.tldraw.shape.note/2 does not have a down function')
748
+ })
749
+ })
750
+ })
751
+
752
+ describe('noteShapeMigrations - MigrateLegacyAlign migration', () => {
753
+ const { up, down } = getTestMigration(noteShapeVersions.MigrateLegacyAlign)
754
+
755
+ describe('MigrateLegacyAlign up migration', () => {
756
+ it('should convert start to start-legacy', () => {
757
+ const oldRecord = {
758
+ id: 'shape:note1',
759
+ props: {
760
+ color: 'yellow',
761
+ align: 'start',
762
+ verticalAlign: 'middle',
763
+ },
764
+ }
765
+
766
+ const result = up(oldRecord)
767
+ expect(result.props.align).toBe('start-legacy')
768
+ expect(result.props.verticalAlign).toBe('middle')
769
+ })
770
+
771
+ it('should convert end to end-legacy', () => {
772
+ const oldRecord = {
773
+ id: 'shape:note1',
774
+ props: {
775
+ color: 'blue',
776
+ align: 'end',
777
+ verticalAlign: 'start',
778
+ },
779
+ }
780
+
781
+ const result = up(oldRecord)
782
+ expect(result.props.align).toBe('end-legacy')
783
+ expect(result.props.verticalAlign).toBe('start')
784
+ })
785
+
786
+ it('should convert middle to middle-legacy', () => {
787
+ const oldRecord = {
788
+ id: 'shape:note1',
789
+ props: {
790
+ color: 'red',
791
+ align: 'middle',
792
+ verticalAlign: 'end',
793
+ },
794
+ }
795
+
796
+ const result = up(oldRecord)
797
+ expect(result.props.align).toBe('middle-legacy')
798
+ expect(result.props.verticalAlign).toBe('end')
799
+ })
800
+
801
+ it('should handle other alignment values as middle-legacy', () => {
802
+ const oldRecord = {
803
+ id: 'shape:note1',
804
+ props: {
805
+ color: 'green',
806
+ align: 'unknown-align',
807
+ verticalAlign: 'middle',
808
+ },
809
+ }
810
+
811
+ const result = up(oldRecord)
812
+ expect(result.props.align).toBe('middle-legacy')
813
+ })
814
+
815
+ it('should preserve all other properties during migration', () => {
816
+ const oldRecord = {
817
+ id: 'shape:note1',
818
+ props: {
819
+ color: 'purple',
820
+ align: 'start',
821
+ verticalAlign: 'middle',
822
+ labelColor: 'white',
823
+ size: 's',
824
+ font: 'mono',
825
+ fontSizeAdjustment: 3,
826
+ growY: 15,
827
+ url: 'https://example.com',
828
+ scale: 1.2,
829
+ },
830
+ }
831
+
832
+ const result = up(oldRecord)
833
+ expect(result.props.align).toBe('start-legacy')
834
+ expect(result.props.color).toBe('purple')
835
+ expect(result.props.labelColor).toBe('white')
836
+ expect(result.props.fontSizeAdjustment).toBe(3)
837
+ })
838
+ })
839
+
840
+ describe('MigrateLegacyAlign down migration', () => {
841
+ it('should be retired (no down migration)', () => {
842
+ expect(() => {
843
+ down({})
844
+ }).toThrow('Migration com.tldraw.shape.note/3 does not have a down function')
845
+ })
846
+ })
847
+ })
848
+
849
+ describe('noteShapeMigrations - AddVerticalAlign migration', () => {
850
+ const { up, down } = getTestMigration(noteShapeVersions.AddVerticalAlign)
851
+
852
+ describe('AddVerticalAlign up migration', () => {
853
+ it('should add verticalAlign property with default value "middle"', () => {
854
+ const oldRecord = {
855
+ id: 'shape:note1',
856
+ props: {
857
+ color: 'yellow',
858
+ align: 'start-legacy',
859
+ labelColor: 'black',
860
+ },
861
+ }
862
+
863
+ const result = up(oldRecord)
864
+ expect(result.props.verticalAlign).toBe('middle')
865
+ expect(result.props.align).toBe('start-legacy') // Preserve other props
866
+ })
867
+
868
+ it('should preserve all existing properties during migration', () => {
869
+ const oldRecord = {
870
+ id: 'shape:note1',
871
+ props: {
872
+ color: 'red',
873
+ align: 'middle-legacy',
874
+ labelColor: 'white',
875
+ size: 'xl',
876
+ font: 'draw',
877
+ fontSizeAdjustment: 0,
878
+ growY: 50,
879
+ url: 'https://test.com',
880
+ scale: 0.9,
881
+ },
882
+ }
883
+
884
+ const result = up(oldRecord)
885
+ expect(result.props.verticalAlign).toBe('middle')
886
+ expect(result.props.color).toBe('red')
887
+ expect(result.props.align).toBe('middle-legacy')
888
+ expect(result.props.size).toBe('xl')
889
+ })
890
+ })
891
+
892
+ describe('AddVerticalAlign down migration', () => {
893
+ it('should be retired (no down migration)', () => {
894
+ expect(() => {
895
+ down({})
896
+ }).toThrow('Migration com.tldraw.shape.note/4 does not have a down function')
897
+ })
898
+ })
899
+ })
900
+
901
+ describe('noteShapeMigrations - MakeUrlsValid migration', () => {
902
+ const { up, down } = getTestMigration(noteShapeVersions.MakeUrlsValid)
903
+
904
+ describe('MakeUrlsValid up migration', () => {
905
+ it('should clear invalid URLs', () => {
906
+ const oldRecord = {
907
+ id: 'shape:note1',
908
+ props: {
909
+ color: 'yellow',
910
+ url: 'invalid-url',
911
+ align: 'start-legacy',
912
+ verticalAlign: 'middle',
913
+ },
914
+ }
915
+
916
+ const result = up(oldRecord)
917
+ expect(result.props.url).toBe('')
918
+ expect(result.props.color).toBe('yellow') // Preserve other props
919
+ })
920
+
921
+ it('should preserve valid URLs', () => {
922
+ const validUrls = [
923
+ '',
924
+ 'https://example.com',
925
+ 'http://test.com',
926
+ 'https://subdomain.example.com/path',
927
+ ]
928
+
929
+ validUrls.forEach((url) => {
930
+ const oldRecord = {
931
+ id: 'shape:note1',
932
+ props: {
933
+ color: 'blue',
934
+ url,
935
+ align: 'middle-legacy',
936
+ verticalAlign: 'start',
937
+ },
938
+ }
939
+
940
+ const result = up(oldRecord)
941
+ expect(result.props.url).toBe(url)
942
+ })
943
+ })
944
+
945
+ it('should preserve all other properties during migration', () => {
946
+ const oldRecord = {
947
+ id: 'shape:note1',
948
+ props: {
949
+ color: 'green',
950
+ url: 'not-valid',
951
+ align: 'end-legacy',
952
+ verticalAlign: 'end',
953
+ labelColor: 'red',
954
+ size: 'm',
955
+ font: 'sans',
956
+ fontSizeAdjustment: 2,
957
+ growY: 40,
958
+ scale: 1.1,
959
+ },
960
+ }
961
+
962
+ const result = up(oldRecord)
963
+ expect(result.props.url).toBe('')
964
+ expect(result.props.color).toBe('green')
965
+ expect(result.props.align).toBe('end-legacy')
966
+ expect(result.props.verticalAlign).toBe('end')
967
+ })
968
+ })
969
+
970
+ describe('MakeUrlsValid down migration', () => {
971
+ it('should be a no-op migration', () => {
972
+ const newRecord = {
973
+ id: 'shape:note1',
974
+ props: {
975
+ color: 'yellow',
976
+ url: 'https://example.com',
977
+ align: 'start-legacy',
978
+ verticalAlign: 'middle',
979
+ },
980
+ }
981
+
982
+ const result = down(newRecord)
983
+ expect(result).toEqual(newRecord)
984
+ })
985
+ })
986
+ })
987
+
988
+ describe('noteShapeMigrations - AddFontSizeAdjustment migration', () => {
989
+ const { up, down } = getTestMigration(noteShapeVersions.AddFontSizeAdjustment)
990
+
991
+ describe('AddFontSizeAdjustment up migration', () => {
992
+ it('should add fontSizeAdjustment property with default value 0', () => {
993
+ const oldRecord = {
994
+ id: 'shape:note1',
995
+ props: {
996
+ color: 'yellow',
997
+ url: 'https://example.com',
998
+ align: 'start-legacy',
999
+ verticalAlign: 'middle',
1000
+ },
1001
+ }
1002
+
1003
+ const result = up(oldRecord)
1004
+ expect(result.props.fontSizeAdjustment).toBe(0)
1005
+ expect(result.props.color).toBe('yellow') // Preserve other props
1006
+ })
1007
+
1008
+ it('should preserve all existing properties during migration', () => {
1009
+ const oldRecord = {
1010
+ id: 'shape:note1',
1011
+ props: {
1012
+ color: 'red',
1013
+ url: '',
1014
+ align: 'middle-legacy',
1015
+ verticalAlign: 'start',
1016
+ labelColor: 'white',
1017
+ size: 'l',
1018
+ font: 'serif',
1019
+ growY: 35,
1020
+ scale: 1.3,
1021
+ },
1022
+ }
1023
+
1024
+ const result = up(oldRecord)
1025
+ expect(result.props.fontSizeAdjustment).toBe(0)
1026
+ expect(result.props.color).toBe('red')
1027
+ expect(result.props.align).toBe('middle-legacy')
1028
+ expect(result.props.size).toBe('l')
1029
+ })
1030
+ })
1031
+
1032
+ describe('AddFontSizeAdjustment down migration', () => {
1033
+ it('should remove fontSizeAdjustment property', () => {
1034
+ const newRecord = {
1035
+ id: 'shape:note1',
1036
+ props: {
1037
+ color: 'yellow',
1038
+ url: 'https://example.com',
1039
+ align: 'start-legacy',
1040
+ verticalAlign: 'middle',
1041
+ fontSizeAdjustment: 2,
1042
+ },
1043
+ }
1044
+
1045
+ const result = down(newRecord)
1046
+ expect(result.props.fontSizeAdjustment).toBeUndefined()
1047
+ expect(result.props.color).toBe('yellow') // Preserve other props
1048
+ })
1049
+ })
1050
+ })
1051
+
1052
+ describe('noteShapeMigrations - AddScale migration', () => {
1053
+ const { up, down } = getTestMigration(noteShapeVersions.AddScale)
1054
+
1055
+ describe('AddScale up migration', () => {
1056
+ it('should add scale property with default value 1', () => {
1057
+ const oldRecord = {
1058
+ id: 'shape:note1',
1059
+ props: {
1060
+ color: 'yellow',
1061
+ url: 'https://example.com',
1062
+ align: 'start-legacy',
1063
+ verticalAlign: 'middle',
1064
+ fontSizeAdjustment: 1,
1065
+ },
1066
+ }
1067
+
1068
+ const result = up(oldRecord)
1069
+ expect(result.props.scale).toBe(1)
1070
+ expect(result.props.color).toBe('yellow') // Preserve other props
1071
+ })
1072
+
1073
+ it('should preserve all existing properties during migration', () => {
1074
+ const oldRecord = {
1075
+ id: 'shape:note1',
1076
+ props: {
1077
+ color: 'blue',
1078
+ url: '',
1079
+ align: 'end-legacy',
1080
+ verticalAlign: 'end',
1081
+ labelColor: 'black',
1082
+ size: 's',
1083
+ font: 'mono',
1084
+ fontSizeAdjustment: 3,
1085
+ growY: 60,
1086
+ },
1087
+ }
1088
+
1089
+ const result = up(oldRecord)
1090
+ expect(result.props.scale).toBe(1)
1091
+ expect(result.props.color).toBe('blue')
1092
+ expect(result.props.fontSizeAdjustment).toBe(3)
1093
+ expect(result.props.growY).toBe(60)
1094
+ })
1095
+ })
1096
+
1097
+ describe('AddScale down migration', () => {
1098
+ it('should remove scale property', () => {
1099
+ const newRecord = {
1100
+ id: 'shape:note1',
1101
+ props: {
1102
+ color: 'yellow',
1103
+ url: 'https://example.com',
1104
+ align: 'start-legacy',
1105
+ verticalAlign: 'middle',
1106
+ fontSizeAdjustment: 1,
1107
+ scale: 1.5,
1108
+ },
1109
+ }
1110
+
1111
+ const result = down(newRecord)
1112
+ expect(result.props.scale).toBeUndefined()
1113
+ expect(result.props.color).toBe('yellow') // Preserve other props
1114
+ })
1115
+ })
1116
+ })
1117
+
1118
+ describe('noteShapeMigrations - AddLabelColor migration', () => {
1119
+ const { up, down } = getTestMigration(noteShapeVersions.AddLabelColor)
1120
+
1121
+ describe('AddLabelColor up migration', () => {
1122
+ it('should add labelColor property with default value "black"', () => {
1123
+ const oldRecord = {
1124
+ id: 'shape:note1',
1125
+ props: {
1126
+ color: 'yellow',
1127
+ url: 'https://example.com',
1128
+ align: 'start-legacy',
1129
+ verticalAlign: 'middle',
1130
+ fontSizeAdjustment: 1,
1131
+ scale: 1,
1132
+ },
1133
+ }
1134
+
1135
+ const result = up(oldRecord)
1136
+ expect(result.props.labelColor).toBe('black')
1137
+ expect(result.props.color).toBe('yellow') // Preserve other props
1138
+ })
1139
+
1140
+ it('should preserve all existing properties during migration', () => {
1141
+ const oldRecord = {
1142
+ id: 'shape:note1',
1143
+ props: {
1144
+ color: 'red',
1145
+ url: '',
1146
+ align: 'middle-legacy',
1147
+ verticalAlign: 'start',
1148
+ size: 'xl',
1149
+ font: 'draw',
1150
+ fontSizeAdjustment: 2,
1151
+ growY: 45,
1152
+ scale: 0.7,
1153
+ },
1154
+ }
1155
+
1156
+ const result = up(oldRecord)
1157
+ expect(result.props.labelColor).toBe('black')
1158
+ expect(result.props.color).toBe('red')
1159
+ expect(result.props.size).toBe('xl')
1160
+ expect(result.props.fontSizeAdjustment).toBe(2)
1161
+ })
1162
+ })
1163
+
1164
+ describe('AddLabelColor down migration', () => {
1165
+ it('should remove labelColor property', () => {
1166
+ const newRecord = {
1167
+ id: 'shape:note1',
1168
+ props: {
1169
+ color: 'yellow',
1170
+ labelColor: 'white',
1171
+ url: 'https://example.com',
1172
+ align: 'start-legacy',
1173
+ verticalAlign: 'middle',
1174
+ fontSizeAdjustment: 1,
1175
+ scale: 1,
1176
+ },
1177
+ }
1178
+
1179
+ const result = down(newRecord)
1180
+ expect(result.props.labelColor).toBeUndefined()
1181
+ expect(result.props.color).toBe('yellow') // Preserve other props
1182
+ })
1183
+ })
1184
+ })
1185
+
1186
+ describe('noteShapeMigrations - AddRichText migration', () => {
1187
+ const { up } = getTestMigration(noteShapeVersions.AddRichText)
1188
+
1189
+ describe('AddRichText up migration', () => {
1190
+ it('should convert text property to richText', () => {
1191
+ const oldRecord = {
1192
+ id: 'shape:note1',
1193
+ props: {
1194
+ color: 'yellow',
1195
+ labelColor: 'black',
1196
+ text: 'Simple note content',
1197
+ url: 'https://example.com',
1198
+ align: 'start-legacy',
1199
+ verticalAlign: 'middle',
1200
+ fontSizeAdjustment: 1,
1201
+ scale: 1,
1202
+ },
1203
+ }
1204
+
1205
+ const result = up(oldRecord)
1206
+ expect(result.props.richText).toBeDefined()
1207
+ expect(result.props.text).toBeUndefined()
1208
+ expect(result.props.color).toBe('yellow') // Preserve other props
1209
+ })
1210
+
1211
+ it('should handle empty text', () => {
1212
+ const oldRecord = {
1213
+ id: 'shape:note1',
1214
+ props: {
1215
+ color: 'blue',
1216
+ labelColor: 'white',
1217
+ text: '',
1218
+ url: '',
1219
+ align: 'middle-legacy',
1220
+ verticalAlign: 'start',
1221
+ fontSizeAdjustment: 0,
1222
+ scale: 1,
1223
+ },
1224
+ }
1225
+
1226
+ const result = up(oldRecord)
1227
+ expect(result.props.richText).toBeDefined()
1228
+ expect(result.props.text).toBeUndefined()
1229
+ })
1230
+
1231
+ it('should preserve all other properties during migration', () => {
1232
+ const oldRecord = {
1233
+ id: 'shape:note1',
1234
+ props: {
1235
+ color: 'green',
1236
+ labelColor: 'red',
1237
+ text: 'Multi-line\nnote content',
1238
+ url: 'https://test.com',
1239
+ align: 'end-legacy',
1240
+ verticalAlign: 'end',
1241
+ size: 'l',
1242
+ font: 'sans',
1243
+ fontSizeAdjustment: 3,
1244
+ growY: 70,
1245
+ scale: 1.4,
1246
+ },
1247
+ }
1248
+
1249
+ const result = up(oldRecord)
1250
+ expect(result.props.richText).toBeDefined()
1251
+ expect(result.props.text).toBeUndefined()
1252
+ expect(result.props.color).toBe('green')
1253
+ expect(result.props.labelColor).toBe('red')
1254
+ expect(result.props.size).toBe('l')
1255
+ expect(result.props.fontSizeAdjustment).toBe(3)
1256
+ })
1257
+ })
1258
+
1259
+ // Note: The down migration is explicitly not defined (forced client update)
1260
+ // so we don't test it
1261
+ })
1262
+
1263
+ describe('integration tests', () => {
1264
+ it('should work with complete note shape record validation', () => {
1265
+ const completeValidator = T.object({
1266
+ id: T.string,
1267
+ typeName: T.literal('shape'),
1268
+ type: T.literal('note'),
1269
+ x: T.number,
1270
+ y: T.number,
1271
+ rotation: T.number,
1272
+ index: T.string,
1273
+ parentId: T.string,
1274
+ isLocked: T.boolean,
1275
+ opacity: T.number,
1276
+ props: T.object(noteShapeProps),
1277
+ meta: T.jsonValue,
1278
+ })
1279
+
1280
+ const validNoteShape = {
1281
+ id: 'shape:note123',
1282
+ typeName: 'shape' as const,
1283
+ type: 'note' as const,
1284
+ x: 100,
1285
+ y: 200,
1286
+ rotation: 0.5,
1287
+ index: 'a1',
1288
+ parentId: 'page:main',
1289
+ isLocked: false,
1290
+ opacity: 0.8,
1291
+ props: {
1292
+ color: 'light-blue' as const,
1293
+ labelColor: 'black' as const,
1294
+ size: 'l' as const,
1295
+ font: 'sans' as const,
1296
+ fontSizeAdjustment: 2,
1297
+ align: 'start' as const,
1298
+ verticalAlign: 'end' as const,
1299
+ growY: 30,
1300
+ url: 'https://example.com',
1301
+ richText: toRichText('Important **note**') as TLRichText,
1302
+ scale: 1.1,
1303
+ },
1304
+ meta: { priority: 'high' },
1305
+ }
1306
+
1307
+ expect(() => completeValidator.validate(validNoteShape)).not.toThrow()
1308
+ })
1309
+
1310
+ it('should be compatible with TLBaseShape structure', () => {
1311
+ const noteShape: TLNoteShape = {
1312
+ id: 'shape:note_test' as TLShapeId,
1313
+ typeName: 'shape',
1314
+ type: 'note',
1315
+ x: 50,
1316
+ y: 75,
1317
+ rotation: 1.57,
1318
+ index: 'b1' as any,
1319
+ parentId: 'page:test' as any,
1320
+ isLocked: true,
1321
+ opacity: 0.5,
1322
+ props: {
1323
+ color: 'red',
1324
+ labelColor: 'white',
1325
+ size: 's',
1326
+ font: 'mono',
1327
+ fontSizeAdjustment: 1,
1328
+ align: 'middle',
1329
+ verticalAlign: 'middle',
1330
+ growY: 15,
1331
+ url: '',
1332
+ richText: toRichText('📝'),
1333
+ scale: 0.9,
1334
+ },
1335
+ meta: { category: 'reminder' },
1336
+ }
1337
+
1338
+ // Should satisfy TLBaseShape structure
1339
+ expect(noteShape.typeName).toBe('shape')
1340
+ expect(noteShape.type).toBe('note')
1341
+ expect(typeof noteShape.id).toBe('string')
1342
+ expect(typeof noteShape.x).toBe('number')
1343
+ expect(typeof noteShape.y).toBe('number')
1344
+ expect(typeof noteShape.rotation).toBe('number')
1345
+ expect(noteShape.props).toBeDefined()
1346
+ expect(noteShape.meta).toBeDefined()
1347
+ })
1348
+
1349
+ test('should handle all migration versions in correct order', () => {
1350
+ const expectedOrder: Array<keyof typeof noteShapeVersions> = [
1351
+ 'AddUrlProp',
1352
+ 'RemoveJustify',
1353
+ 'MigrateLegacyAlign',
1354
+ 'AddVerticalAlign',
1355
+ 'MakeUrlsValid',
1356
+ 'AddFontSizeAdjustment',
1357
+ 'AddScale',
1358
+ 'AddLabelColor',
1359
+ 'AddRichText',
1360
+ ]
1361
+
1362
+ const migrationIds = noteShapeMigrations.sequence
1363
+ .filter((migration) => 'id' in migration)
1364
+ .map((migration) => ('id' in migration ? migration.id : ''))
1365
+ .filter(Boolean)
1366
+
1367
+ expectedOrder.forEach((expectedVersion) => {
1368
+ const versionId = noteShapeVersions[expectedVersion]
1369
+ expect(migrationIds).toContain(versionId)
1370
+ })
1371
+ })
1372
+ })
1373
+
1374
+ describe('edge cases and error handling', () => {
1375
+ it('should handle empty or malformed props gracefully during validation', () => {
1376
+ const fullValidator = T.object(noteShapeProps)
1377
+
1378
+ // Missing required properties should throw
1379
+ expect(() => fullValidator.validate({})).toThrow()
1380
+
1381
+ // Partial props should throw for missing required fields
1382
+ expect(() =>
1383
+ fullValidator.validate({
1384
+ color: 'yellow',
1385
+ labelColor: 'black',
1386
+ // Missing other required properties
1387
+ })
1388
+ ).toThrow()
1389
+
1390
+ // Extra unexpected properties should throw
1391
+ expect(() =>
1392
+ fullValidator.validate({
1393
+ color: 'yellow',
1394
+ labelColor: 'black',
1395
+ size: 'm',
1396
+ font: 'draw',
1397
+ fontSizeAdjustment: 0,
1398
+ align: 'middle',
1399
+ verticalAlign: 'middle',
1400
+ growY: 0,
1401
+ url: '',
1402
+ richText: toRichText(''),
1403
+ scale: 1,
1404
+ unexpectedProperty: 'extra', // This should cause validation to fail
1405
+ })
1406
+ ).toThrow()
1407
+ })
1408
+
1409
+ it('should handle boundary values for numeric properties', () => {
1410
+ // Test extreme but valid values
1411
+ const extremeProps = {
1412
+ color: 'yellow' as const,
1413
+ labelColor: 'black' as const,
1414
+ size: 'm' as const,
1415
+ font: 'draw' as const,
1416
+ fontSizeAdjustment: 0, // Minimum positive number
1417
+ align: 'middle' as const,
1418
+ verticalAlign: 'middle' as const,
1419
+ growY: 0, // Minimum positive number
1420
+ url: '',
1421
+ richText: toRichText('') as TLRichText,
1422
+ scale: 0.0001, // Very small but not zero
1423
+ }
1424
+
1425
+ const fullValidator = T.object(noteShapeProps)
1426
+ expect(() => fullValidator.validate(extremeProps)).not.toThrow()
1427
+ })
1428
+
1429
+ it('should handle zero and negative values validation correctly', () => {
1430
+ // Zero should be invalid for scale (nonZeroNumber)
1431
+ expect(() => noteShapeProps.scale.validate(0)).toThrow()
1432
+
1433
+ // Negative values should be invalid for fontSizeAdjustment, growY, and scale
1434
+ expect(() => noteShapeProps.fontSizeAdjustment.validate(-1)).toThrow()
1435
+ expect(() => noteShapeProps.growY.validate(-1)).toThrow()
1436
+ expect(() => noteShapeProps.scale.validate(-1)).toThrow()
1437
+
1438
+ // Zero should be valid for fontSizeAdjustment and growY (positiveNumber includes zero)
1439
+ expect(() => noteShapeProps.fontSizeAdjustment.validate(0)).not.toThrow()
1440
+ expect(() => noteShapeProps.growY.validate(0)).not.toThrow()
1441
+ })
1442
+
1443
+ it('should handle complex rich text content', () => {
1444
+ const complexRichTexts = [
1445
+ toRichText(''),
1446
+ toRichText('Simple note'),
1447
+ toRichText('**Bold** and *italic* and ***both***'),
1448
+ toRichText('Line 1\nLine 2\nLine 3'),
1449
+ toRichText('Special chars: !@#$%^&*()'),
1450
+ toRichText('Unicode: 📝 ✅ ❗'),
1451
+ ]
1452
+
1453
+ complexRichTexts.forEach((richText) => {
1454
+ expect(() => noteShapeProps.richText.validate(richText)).not.toThrow()
1455
+ })
1456
+ })
1457
+
1458
+ it('should handle different font size adjustments', () => {
1459
+ const fontAdjustments = [0, 0.5, 1, 2, 5, 10, 20]
1460
+
1461
+ fontAdjustments.forEach((adjustment) => {
1462
+ expect(() => noteShapeProps.fontSizeAdjustment.validate(adjustment)).not.toThrow()
1463
+ })
1464
+
1465
+ // Negative adjustments should be invalid
1466
+ const negativeAdjustments = [-1, -0.5, -10]
1467
+ negativeAdjustments.forEach((adjustment) => {
1468
+ expect(() => noteShapeProps.fontSizeAdjustment.validate(adjustment)).toThrow()
1469
+ })
1470
+ })
1471
+
1472
+ it('should validate different URL formats correctly', () => {
1473
+ const urlTestCases = [
1474
+ { url: '', shouldPass: true },
1475
+ { url: 'https://example.com', shouldPass: true },
1476
+ { url: 'http://test.com', shouldPass: true },
1477
+ { url: 'https://subdomain.example.com/path?query=value', shouldPass: true },
1478
+ ]
1479
+
1480
+ urlTestCases.forEach(({ url, shouldPass }) => {
1481
+ if (shouldPass) {
1482
+ expect(() => noteShapeProps.url.validate(url)).not.toThrow()
1483
+ } else {
1484
+ expect(() => noteShapeProps.url.validate(url)).toThrow()
1485
+ }
1486
+ })
1487
+ })
1488
+
1489
+ it('should handle all style property combinations', () => {
1490
+ const styleVariations = [
1491
+ {
1492
+ color: 'black' as const,
1493
+ labelColor: 'white' as const,
1494
+ size: 's' as const,
1495
+ font: 'draw' as const,
1496
+ align: 'start' as const,
1497
+ verticalAlign: 'start' as const,
1498
+ },
1499
+ {
1500
+ color: 'red' as const,
1501
+ labelColor: 'black' as const,
1502
+ size: 'm' as const,
1503
+ font: 'sans' as const,
1504
+ align: 'middle' as const,
1505
+ verticalAlign: 'middle' as const,
1506
+ },
1507
+ {
1508
+ color: 'blue' as const,
1509
+ labelColor: 'yellow' as const,
1510
+ size: 'l' as const,
1511
+ font: 'serif' as const,
1512
+ align: 'end' as const,
1513
+ verticalAlign: 'end' as const,
1514
+ },
1515
+ {
1516
+ color: 'green' as const,
1517
+ labelColor: 'red' as const,
1518
+ size: 'xl' as const,
1519
+ font: 'mono' as const,
1520
+ align: 'start-legacy' as const,
1521
+ verticalAlign: 'start' as const,
1522
+ },
1523
+ ]
1524
+
1525
+ styleVariations.forEach((styling, index) => {
1526
+ const props: TLNoteShapeProps = {
1527
+ color: styling.color,
1528
+ labelColor: styling.labelColor,
1529
+ size: styling.size,
1530
+ font: styling.font,
1531
+ fontSizeAdjustment: index,
1532
+ align: styling.align,
1533
+ verticalAlign: styling.verticalAlign,
1534
+ growY: index * 10,
1535
+ url: '',
1536
+ richText: toRichText(`Note ${index + 1}`),
1537
+ scale: 1,
1538
+ }
1539
+
1540
+ const fullValidator = T.object(noteShapeProps)
1541
+ expect(() => fullValidator.validate(props)).not.toThrow()
1542
+ })
1543
+ })
1544
+
1545
+ it('should handle note shapes with different growth and scaling', () => {
1546
+ const dimensionVariations = [
1547
+ { fontSizeAdjustment: 0, growY: 0, scale: 0.5 },
1548
+ { fontSizeAdjustment: 1, growY: 10, scale: 1 },
1549
+ { fontSizeAdjustment: 3, growY: 50, scale: 1.5 },
1550
+ { fontSizeAdjustment: 5, growY: 100, scale: 2 },
1551
+ ]
1552
+
1553
+ dimensionVariations.forEach((dims) => {
1554
+ const props: Partial<TLNoteShapeProps> = {
1555
+ fontSizeAdjustment: dims.fontSizeAdjustment,
1556
+ growY: dims.growY,
1557
+ scale: dims.scale,
1558
+ }
1559
+
1560
+ expect(() =>
1561
+ noteShapeProps.fontSizeAdjustment.validate(props.fontSizeAdjustment)
1562
+ ).not.toThrow()
1563
+ expect(() => noteShapeProps.growY.validate(props.growY)).not.toThrow()
1564
+ expect(() => noteShapeProps.scale.validate(props.scale)).not.toThrow()
1565
+ })
1566
+ })
1567
+ })
1568
+ })