@tldraw/editor 3.16.0-canary.54408756ac9c → 3.16.0-canary.5462c7eac75a

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 (93) hide show
  1. package/dist-cjs/index.d.ts +49 -3
  2. package/dist-cjs/index.js +4 -2
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/TldrawEditor.js +1 -1
  5. package/dist-cjs/lib/TldrawEditor.js.map +2 -2
  6. package/dist-cjs/lib/components/default-components/DefaultCanvas.js +11 -1
  7. package/dist-cjs/lib/components/default-components/DefaultCanvas.js.map +2 -2
  8. package/dist-cjs/lib/editor/Editor.js +9 -4
  9. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  10. package/dist-cjs/lib/hooks/useCanvasEvents.js +15 -12
  11. package/dist-cjs/lib/hooks/useCanvasEvents.js.map +2 -2
  12. package/dist-cjs/lib/hooks/useDocumentEvents.js +5 -5
  13. package/dist-cjs/lib/hooks/useDocumentEvents.js.map +2 -2
  14. package/dist-cjs/lib/hooks/useFixSafariDoubleTapZoomPencilEvents.js +1 -2
  15. package/dist-cjs/lib/hooks/useFixSafariDoubleTapZoomPencilEvents.js.map +2 -2
  16. package/dist-cjs/lib/hooks/useGestureEvents.js +1 -1
  17. package/dist-cjs/lib/hooks/useGestureEvents.js.map +2 -2
  18. package/dist-cjs/lib/hooks/useHandleEvents.js +3 -3
  19. package/dist-cjs/lib/hooks/useHandleEvents.js.map +2 -2
  20. package/dist-cjs/lib/hooks/useSelectionEvents.js +4 -4
  21. package/dist-cjs/lib/hooks/useSelectionEvents.js.map +2 -2
  22. package/dist-cjs/lib/license/LicenseManager.js +24 -4
  23. package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
  24. package/dist-cjs/lib/license/Watermark.js +2 -2
  25. package/dist-cjs/lib/license/Watermark.js.map +2 -2
  26. package/dist-cjs/lib/primitives/geometry/Geometry2d.js +24 -2
  27. package/dist-cjs/lib/primitives/geometry/Geometry2d.js.map +2 -2
  28. package/dist-cjs/lib/primitives/geometry/Group2d.js +5 -1
  29. package/dist-cjs/lib/primitives/geometry/Group2d.js.map +2 -2
  30. package/dist-cjs/lib/utils/dom.js +12 -1
  31. package/dist-cjs/lib/utils/dom.js.map +2 -2
  32. package/dist-cjs/lib/utils/getPointerInfo.js +2 -2
  33. package/dist-cjs/lib/utils/getPointerInfo.js.map +2 -2
  34. package/dist-cjs/version.js +3 -3
  35. package/dist-cjs/version.js.map +1 -1
  36. package/dist-esm/index.d.mts +49 -3
  37. package/dist-esm/index.mjs +7 -3
  38. package/dist-esm/index.mjs.map +2 -2
  39. package/dist-esm/lib/TldrawEditor.mjs +2 -2
  40. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  41. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs +12 -2
  42. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs.map +2 -2
  43. package/dist-esm/lib/editor/Editor.mjs +9 -4
  44. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  45. package/dist-esm/lib/hooks/useCanvasEvents.mjs +17 -13
  46. package/dist-esm/lib/hooks/useCanvasEvents.mjs.map +2 -2
  47. package/dist-esm/lib/hooks/useDocumentEvents.mjs +11 -6
  48. package/dist-esm/lib/hooks/useDocumentEvents.mjs.map +2 -2
  49. package/dist-esm/lib/hooks/useFixSafariDoubleTapZoomPencilEvents.mjs +2 -3
  50. package/dist-esm/lib/hooks/useFixSafariDoubleTapZoomPencilEvents.mjs.map +2 -2
  51. package/dist-esm/lib/hooks/useGestureEvents.mjs +2 -2
  52. package/dist-esm/lib/hooks/useGestureEvents.mjs.map +2 -2
  53. package/dist-esm/lib/hooks/useHandleEvents.mjs +9 -4
  54. package/dist-esm/lib/hooks/useHandleEvents.mjs.map +2 -2
  55. package/dist-esm/lib/hooks/useSelectionEvents.mjs +6 -5
  56. package/dist-esm/lib/hooks/useSelectionEvents.mjs.map +2 -2
  57. package/dist-esm/lib/license/LicenseManager.mjs +24 -4
  58. package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
  59. package/dist-esm/lib/license/Watermark.mjs +3 -3
  60. package/dist-esm/lib/license/Watermark.mjs.map +2 -2
  61. package/dist-esm/lib/primitives/geometry/Geometry2d.mjs +24 -2
  62. package/dist-esm/lib/primitives/geometry/Geometry2d.mjs.map +2 -2
  63. package/dist-esm/lib/primitives/geometry/Group2d.mjs +5 -1
  64. package/dist-esm/lib/primitives/geometry/Group2d.mjs.map +2 -2
  65. package/dist-esm/lib/utils/dom.mjs +12 -1
  66. package/dist-esm/lib/utils/dom.mjs.map +2 -2
  67. package/dist-esm/lib/utils/getPointerInfo.mjs +2 -2
  68. package/dist-esm/lib/utils/getPointerInfo.mjs.map +2 -2
  69. package/dist-esm/version.mjs +3 -3
  70. package/dist-esm/version.mjs.map +1 -1
  71. package/package.json +7 -7
  72. package/src/index.ts +2 -0
  73. package/src/lib/TldrawEditor.tsx +2 -2
  74. package/src/lib/components/default-components/DefaultCanvas.tsx +8 -2
  75. package/src/lib/editor/Editor.test.ts +90 -0
  76. package/src/lib/editor/Editor.ts +16 -4
  77. package/src/lib/hooks/useCanvasEvents.ts +17 -11
  78. package/src/lib/hooks/useDocumentEvents.ts +11 -6
  79. package/src/lib/hooks/useFixSafariDoubleTapZoomPencilEvents.ts +2 -2
  80. package/src/lib/hooks/useGestureEvents.ts +2 -2
  81. package/src/lib/hooks/useHandleEvents.ts +9 -4
  82. package/src/lib/hooks/useSelectionEvents.ts +6 -5
  83. package/src/lib/license/LicenseManager.test.ts +78 -2
  84. package/src/lib/license/LicenseManager.ts +31 -5
  85. package/src/lib/license/Watermark.tsx +3 -3
  86. package/src/lib/primitives/geometry/Geometry2d.test.ts +420 -0
  87. package/src/lib/primitives/geometry/Geometry2d.ts +29 -2
  88. package/src/lib/primitives/geometry/Group2d.ts +6 -1
  89. package/src/lib/test/InFrontOfTheCanvas.test.tsx +187 -0
  90. package/src/lib/utils/dom.test.ts +94 -0
  91. package/src/lib/utils/dom.ts +38 -1
  92. package/src/lib/utils/getPointerInfo.ts +2 -1
  93. package/src/version.ts +3 -3
@@ -266,7 +266,7 @@ describe('LicenseManager', () => {
266
266
  delete window.location
267
267
  // @ts-ignore
268
268
  window.location = new URL(
269
- 'vscode-webview:vscode-webview://1ipd8pun8ud7nd7hv9d112g7evi7m10vak9vviuvia66ou6aibp3/index.html?id=6ec2dc7a-afe9-45d9-bd71-1749f9568d28&origin=955b256f-37e1-4a72-a2f4-ad633e88239c&swVersion=4&extensionId=tldraw-org.tldraw-vscode&platform=electron&vscode-resource-base-authority=vscode-resource.vscode-cdn.net&parentOrigin=vscode-file%3A%2F%2Fvscode-app'
269
+ 'vscode-webview://1ipd8pun8ud7nd7hv9d112g7evi7m10vak9vviuvia66ou6aibp3/index.html?id=6ec2dc7a-afe9-45d9-bd71-1749f9568d28&origin=955b256f-37e1-4a72-a2f4-ad633e88239c&swVersion=4&extensionId=tldraw-org.tldraw-vscode&platform=electron&vscode-resource-base-authority=vscode-resource.vscode-cdn.net&parentOrigin=vscode-file%3A%2F%2Fvscode-app'
270
270
  )
271
271
 
272
272
  const permissiveHostsInfo = JSON.parse(STANDARD_LICENSE_INFO)
@@ -286,7 +286,7 @@ describe('LicenseManager', () => {
286
286
  delete window.location
287
287
  // @ts-ignore
288
288
  window.location = new URL(
289
- 'vscode-webview:vscode-webview://1ipd8pun8ud7nd7hv9d112g7evi7m10vak9vviuvia66ou6aibp3/index.html?id=6ec2dc7a-afe9-45d9-bd71-1749f9568d28&origin=955b256f-37e1-4a72-a2f4-ad633e88239c&swVersion=4&extensionId=tldraw-org.tldraw-vscode&platform=electron&vscode-resource-base-authority=vscode-resource.vscode-cdn.net&parentOrigin=vscode-file%3A%2F%2Fvscode-app'
289
+ 'vscode-webview://1ipd8pun8ud7nd7hv9d112g7evi7m10vak9vviuvia66ou6aibp3/index.html?id=6ec2dc7a-afe9-45d9-bd71-1749f9568d28&origin=955b256f-37e1-4a72-a2f4-ad633e88239c&swVersion=4&extensionId=tldraw-org.tldraw-vscode&platform=electron&vscode-resource-base-authority=vscode-resource.vscode-cdn.net&parentOrigin=vscode-file%3A%2F%2Fvscode-app'
290
290
  )
291
291
 
292
292
  const permissiveHostsInfo = JSON.parse(STANDARD_LICENSE_INFO)
@@ -300,6 +300,70 @@ describe('LicenseManager', () => {
300
300
  )) as ValidLicenseKeyResult
301
301
  expect(result.isDomainValid).toBe(false)
302
302
  })
303
+
304
+ it('Succeeds if it is a native app', async () => {
305
+ // @ts-ignore
306
+ delete window.location
307
+ // @ts-ignore
308
+ window.location = new URL('app-bundle://app/index.html')
309
+
310
+ const nativeLicenseInfo = JSON.parse(STANDARD_LICENSE_INFO)
311
+ nativeLicenseInfo[PROPERTIES.FLAGS] = FLAGS.NATIVE_LICENSE
312
+ nativeLicenseInfo[PROPERTIES.HOSTS] = ['app-bundle:']
313
+ const nativeLicenseKey = await generateLicenseKey(JSON.stringify(nativeLicenseInfo), keyPair)
314
+ const result = (await licenseManager.getLicenseFromKey(
315
+ nativeLicenseKey
316
+ )) as ValidLicenseKeyResult
317
+ expect(result.isDomainValid).toBe(true)
318
+ })
319
+
320
+ it('Succeeds if it is a native app with a wildcard', async () => {
321
+ // @ts-ignore
322
+ delete window.location
323
+ // @ts-ignore
324
+ window.location = new URL('app-bundle://unique-id-123/index.html')
325
+
326
+ const nativeLicenseInfo = JSON.parse(STANDARD_LICENSE_INFO)
327
+ nativeLicenseInfo[PROPERTIES.FLAGS] = FLAGS.NATIVE_LICENSE
328
+ nativeLicenseInfo[PROPERTIES.HOSTS] = ['^app-bundle://unique-id-123.*']
329
+ const nativeLicenseKey = await generateLicenseKey(JSON.stringify(nativeLicenseInfo), keyPair)
330
+ const result = (await licenseManager.getLicenseFromKey(
331
+ nativeLicenseKey
332
+ )) as ValidLicenseKeyResult
333
+ expect(result.isDomainValid).toBe(true)
334
+ })
335
+
336
+ it('Succeeds if it is a native app with a wildcard and search param', async () => {
337
+ // @ts-ignore
338
+ delete window.location
339
+ // @ts-ignore
340
+ window.location = new URL('app-bundle://app/index.html?unique-id-123')
341
+
342
+ const nativeLicenseInfo = JSON.parse(STANDARD_LICENSE_INFO)
343
+ nativeLicenseInfo[PROPERTIES.FLAGS] = FLAGS.NATIVE_LICENSE
344
+ nativeLicenseInfo[PROPERTIES.HOSTS] = ['^app-bundle://app.*unique-id-123.*']
345
+ const nativeLicenseKey = await generateLicenseKey(JSON.stringify(nativeLicenseInfo), keyPair)
346
+ const result = (await licenseManager.getLicenseFromKey(
347
+ nativeLicenseKey
348
+ )) as ValidLicenseKeyResult
349
+ expect(result.isDomainValid).toBe(true)
350
+ })
351
+
352
+ it('Fails if it is a native app with the wrong protocol', async () => {
353
+ // @ts-ignore
354
+ delete window.location
355
+ // @ts-ignore
356
+ window.location = new URL('blah-blundle://app/index.html')
357
+
358
+ const nativeLicenseInfo = JSON.parse(STANDARD_LICENSE_INFO)
359
+ nativeLicenseInfo[PROPERTIES.FLAGS] = FLAGS.NATIVE_LICENSE
360
+ nativeLicenseInfo[PROPERTIES.HOSTS] = ['app-bundle:']
361
+ const nativeLicenseKey = await generateLicenseKey(JSON.stringify(nativeLicenseInfo), keyPair)
362
+ const result = (await licenseManager.getLicenseFromKey(
363
+ nativeLicenseKey
364
+ )) as ValidLicenseKeyResult
365
+ expect(result.isDomainValid).toBe(false)
366
+ })
303
367
  })
304
368
 
305
369
  describe('License types and flags', () => {
@@ -316,6 +380,17 @@ describe('LicenseManager', () => {
316
380
  expect(result.isInternalLicense).toBe(true)
317
381
  })
318
382
 
383
+ it('Checks for native license', async () => {
384
+ const nativeLicenseInfo = JSON.parse(STANDARD_LICENSE_INFO)
385
+ nativeLicenseInfo[PROPERTIES.FLAGS] = FLAGS.NATIVE_LICENSE
386
+ const nativeLicenseKey = await generateLicenseKey(JSON.stringify(nativeLicenseInfo), keyPair)
387
+
388
+ const result = (await licenseManager.getLicenseFromKey(
389
+ nativeLicenseKey
390
+ )) as ValidLicenseKeyResult
391
+ expect(result.isNativeLicense).toBe(true)
392
+ })
393
+
319
394
  it('Checks for license with watermark', async () => {
320
395
  const withWatermarkLicenseInfo = JSON.parse(STANDARD_LICENSE_INFO)
321
396
  withWatermarkLicenseInfo[PROPERTIES.FLAGS] |= FLAGS.WITH_WATERMARK
@@ -553,6 +628,7 @@ function getDefaultLicenseResult(overrides: Partial<ValidLicenseKeyResult>): Val
553
628
  isAnnualLicense: true,
554
629
  isAnnualLicenseExpired: false,
555
630
  isInternalLicense: false,
631
+ isNativeLicense: false,
556
632
  isDevelopment: false,
557
633
  isDomainValid: true,
558
634
  isPerpetualLicense: false,
@@ -6,11 +6,22 @@ import { importPublicKey, str2ab } from '../utils/licensing'
6
6
  const GRACE_PERIOD_DAYS = 30
7
7
 
8
8
  export const FLAGS = {
9
+ // -- MUTUALLY EXCLUSIVE FLAGS --
10
+ // Annual means the license expires after a time period, usually 1 year.
9
11
  ANNUAL_LICENSE: 1,
12
+ // Perpetual means the license never expires up to the max supported version.
10
13
  PERPETUAL_LICENSE: 1 << 1,
14
+
15
+ // -- ADDITIVE FLAGS --
16
+ // Internal means the license is for internal use only.
11
17
  INTERNAL_LICENSE: 1 << 2,
18
+ // Watermark means the product is watermarked.
12
19
  WITH_WATERMARK: 1 << 3,
20
+ // Evaluation means the license is for evaluation purposes only.
13
21
  EVALUATION_LICENSE: 1 << 4,
22
+ // Native means the license is for native apps which switches
23
+ // on special-case logic.
24
+ NATIVE_LICENSE: 1 << 5,
14
25
  }
15
26
  const HIGHEST_FLAG = Math.max(...Object.values(FLAGS))
16
27
 
@@ -69,6 +80,7 @@ export interface ValidLicenseKeyResult {
69
80
  isPerpetualLicense: boolean
70
81
  isPerpetualLicenseExpired: boolean
71
82
  isInternalLicense: boolean
83
+ isNativeLicense: boolean
72
84
  isLicensedWithWatermark: boolean
73
85
  isEvaluationLicense: boolean
74
86
  isEvaluationLicenseExpired: boolean
@@ -166,6 +178,9 @@ export class LicenseManager {
166
178
  const url = new URL(WATERMARK_TRACK_SRC)
167
179
  url.searchParams.set('version', version)
168
180
  url.searchParams.set('license_type', trackType)
181
+ if ('license' in result) {
182
+ url.searchParams.set('license_id', result.license.id)
183
+ }
169
184
 
170
185
  // eslint-disable-next-line no-restricted-globals
171
186
  fetch(url.toString())
@@ -271,6 +286,7 @@ export class LicenseManager {
271
286
  isPerpetualLicense,
272
287
  isPerpetualLicenseExpired: isPerpetualLicense && this.isPerpetualLicenseExpired(expiryDate),
273
288
  isInternalLicense: this.isFlagEnabled(licenseInfo.flags, FLAGS.INTERNAL_LICENSE),
289
+ isNativeLicense: this.isNativeLicense(licenseInfo),
274
290
  isLicensedWithWatermark: this.isFlagEnabled(licenseInfo.flags, FLAGS.WITH_WATERMARK),
275
291
  isEvaluationLicense,
276
292
  isEvaluationLicenseExpired:
@@ -291,13 +307,13 @@ export class LicenseManager {
291
307
  const currentHostname = window.location.hostname.toLowerCase()
292
308
 
293
309
  return licenseInfo.hosts.some((host) => {
294
- const normalizedHost = host.toLowerCase().trim()
310
+ const normalizedHostOrUrlRegex = host.toLowerCase().trim()
295
311
 
296
312
  // Allow the domain if listed and www variations, 'example.com' allows 'example.com' and 'www.example.com'
297
313
  if (
298
- normalizedHost === currentHostname ||
299
- `www.${normalizedHost}` === currentHostname ||
300
- normalizedHost === `www.${currentHostname}`
314
+ normalizedHostOrUrlRegex === currentHostname ||
315
+ `www.${normalizedHostOrUrlRegex}` === currentHostname ||
316
+ normalizedHostOrUrlRegex === `www.${currentHostname}`
301
317
  ) {
302
318
  return true
303
319
  }
@@ -308,6 +324,12 @@ export class LicenseManager {
308
324
  return true
309
325
  }
310
326
 
327
+ // Native license support
328
+ // In this case, `normalizedHost` is actually a protocol, e.g. `app-bundle:`
329
+ if (this.isNativeLicense(licenseInfo)) {
330
+ return new RegExp(normalizedHostOrUrlRegex).test(window.location.href)
331
+ }
332
+
311
333
  // Glob testing, we only support '*.somedomain.com' right now.
312
334
  if (host.includes('*')) {
313
335
  const globToRegex = new RegExp(host.replace(/\*/g, '.*?'))
@@ -318,7 +340,7 @@ export class LicenseManager {
318
340
  if (window.location.protocol === 'vscode-webview:') {
319
341
  const currentUrl = new URL(window.location.href)
320
342
  const extensionId = currentUrl.searchParams.get('extensionId')
321
- if (normalizedHost === extensionId) {
343
+ if (normalizedHostOrUrlRegex === extensionId) {
322
344
  return true
323
345
  }
324
346
  }
@@ -327,6 +349,10 @@ export class LicenseManager {
327
349
  })
328
350
  }
329
351
 
352
+ private isNativeLicense(licenseInfo: LicenseInfo) {
353
+ return this.isFlagEnabled(licenseInfo.flags, FLAGS.NATIVE_LICENSE)
354
+ }
355
+
330
356
  private getExpirationDateWithoutGracePeriod(expiryDate: Date) {
331
357
  return new Date(expiryDate.getFullYear(), expiryDate.getMonth(), expiryDate.getDate())
332
358
  }
@@ -3,7 +3,7 @@ import { memo, useRef } from 'react'
3
3
  import { useCanvasEvents } from '../hooks/useCanvasEvents'
4
4
  import { useEditor } from '../hooks/useEditor'
5
5
  import { usePassThroughWheelEvents } from '../hooks/usePassThroughWheelEvents'
6
- import { preventDefault, stopEventPropagation } from '../utils/dom'
6
+ import { markEventAsHandled, preventDefault } from '../utils/dom'
7
7
  import { runtime } from '../utils/runtime'
8
8
  import { watermarkDesktopSvg, watermarkMobileSvg } from '../watermarks'
9
9
  import { LicenseManager } from './LicenseManager'
@@ -64,7 +64,7 @@ const UnlicensedWatermark = memo(function UnlicensedWatermark({
64
64
  draggable={false}
65
65
  role="button"
66
66
  onPointerDown={(e) => {
67
- stopEventPropagation(e)
67
+ markEventAsHandled(e)
68
68
  preventDefault(e)
69
69
  }}
70
70
  title="Unlicensed - click to get a license"
@@ -127,7 +127,7 @@ const WatermarkInner = memo(function WatermarkInner({
127
127
  draggable={false}
128
128
  role="button"
129
129
  onPointerDown={(e) => {
130
- stopEventPropagation(e)
130
+ markEventAsHandled(e)
131
131
  preventDefault(e)
132
132
  }}
133
133
  title="made with tldraw"
@@ -1,6 +1,7 @@
1
1
  import { Mat } from '../Mat'
2
2
  import { Vec, VecLike } from '../Vec'
3
3
  import { Geometry2dFilters } from './Geometry2d'
4
+ import { Group2d } from './Group2d'
4
5
  import { Rectangle2d } from './Rectangle2d'
5
6
 
6
7
  describe('TransformedGeometry2d', () => {
@@ -36,6 +37,425 @@ describe('TransformedGeometry2d', () => {
36
37
  })
37
38
  })
38
39
 
40
+ describe('excludeFromShapeBounds', () => {
41
+ test('simple geometry with excludeFromShapeBounds flag', () => {
42
+ const rect = new Rectangle2d({
43
+ width: 100,
44
+ height: 50,
45
+ isFilled: true,
46
+ excludeFromShapeBounds: true,
47
+ })
48
+
49
+ // The bounds should still be calculated normally for simple geometry
50
+ const bounds = rect.bounds
51
+ expect(bounds.width).toBe(100)
52
+ expect(bounds.height).toBe(50)
53
+ expect(bounds.x).toBe(0)
54
+ expect(bounds.y).toBe(0)
55
+ })
56
+
57
+ test('group with excluded child geometry', () => {
58
+ const mainRect = new Rectangle2d({
59
+ width: 100,
60
+ height: 50,
61
+ isFilled: true,
62
+ })
63
+
64
+ const excludedRect = new Rectangle2d({
65
+ width: 200,
66
+ height: 100,
67
+ isFilled: true,
68
+ excludeFromShapeBounds: true,
69
+ })
70
+
71
+ const group = new Group2d({
72
+ children: [mainRect, excludedRect],
73
+ })
74
+
75
+ // The bounds should only include the non-excluded rectangle
76
+ const bounds = group.bounds
77
+ expect(bounds.width).toBe(100) // Only the main rectangle width
78
+ expect(bounds.height).toBe(50) // Only the main rectangle height
79
+ expect(bounds.x).toBe(0)
80
+ expect(bounds.y).toBe(0)
81
+ })
82
+
83
+ test('group with multiple excluded children', () => {
84
+ const rect1 = new Rectangle2d({
85
+ width: 50,
86
+ height: 50,
87
+ isFilled: true,
88
+ })
89
+
90
+ const rect2 = new Rectangle2d({
91
+ width: 100,
92
+ height: 30,
93
+ isFilled: true,
94
+ })
95
+
96
+ const excludedRect1 = new Rectangle2d({
97
+ width: 200,
98
+ height: 200,
99
+ isFilled: true,
100
+ excludeFromShapeBounds: true,
101
+ })
102
+
103
+ const excludedRect2 = new Rectangle2d({
104
+ width: 300,
105
+ height: 300,
106
+ isFilled: true,
107
+ excludeFromShapeBounds: true,
108
+ })
109
+
110
+ const group = new Group2d({
111
+ children: [rect1, excludedRect1, rect2, excludedRect2],
112
+ })
113
+
114
+ // The bounds should include both non-excluded rectangles
115
+ const bounds = group.bounds
116
+ expect(bounds.width).toBe(100) // Width of rect2 (larger of the two)
117
+ expect(bounds.height).toBe(50) // Height of rect1 (larger of the two)
118
+ expect(bounds.x).toBe(0)
119
+ expect(bounds.y).toBe(0)
120
+ })
121
+
122
+ test('group with all children excluded', () => {
123
+ const excludedRect1 = new Rectangle2d({
124
+ width: 100,
125
+ height: 50,
126
+ isFilled: true,
127
+ excludeFromShapeBounds: true,
128
+ })
129
+
130
+ const excludedRect2 = new Rectangle2d({
131
+ width: 200,
132
+ height: 100,
133
+ isFilled: true,
134
+ excludeFromShapeBounds: true,
135
+ })
136
+
137
+ const group = new Group2d({
138
+ children: [excludedRect1, excludedRect2],
139
+ })
140
+
141
+ // The bounds should be empty when all children are excluded
142
+ const bounds = group.bounds
143
+ expect(bounds.width).toBe(0)
144
+ expect(bounds.height).toBe(0)
145
+ expect(bounds.x).toBe(0)
146
+ expect(bounds.y).toBe(0)
147
+ })
148
+
149
+ test('nested groups with excluded geometry', () => {
150
+ const innerRect = new Rectangle2d({
151
+ width: 50,
152
+ height: 50,
153
+ isFilled: true,
154
+ })
155
+
156
+ const excludedRect = new Rectangle2d({
157
+ width: 200,
158
+ height: 200,
159
+ isFilled: true,
160
+ excludeFromShapeBounds: true,
161
+ })
162
+
163
+ const innerGroup = new Group2d({
164
+ children: [innerRect, excludedRect],
165
+ })
166
+
167
+ const outerRect = new Rectangle2d({
168
+ width: 100,
169
+ height: 30,
170
+ isFilled: true,
171
+ })
172
+
173
+ const outerGroup = new Group2d({
174
+ children: [innerGroup, outerRect],
175
+ })
176
+
177
+ // The bounds should include both the inner group (without excluded rect) and outer rect
178
+ const bounds = outerGroup.bounds
179
+ expect(bounds.width).toBe(100) // Width of outerRect (larger)
180
+ expect(bounds.height).toBe(50) // Height of innerRect (larger)
181
+ expect(bounds.x).toBe(0)
182
+ expect(bounds.y).toBe(0)
183
+ })
184
+
185
+ test('bounds calculation with transformed geometry', () => {
186
+ const rect = new Rectangle2d({
187
+ width: 50,
188
+ height: 50,
189
+ isFilled: true,
190
+ }).transform(Mat.Translate(100, 100))
191
+
192
+ const excludedRect = new Rectangle2d({
193
+ width: 200,
194
+ height: 200,
195
+ isFilled: true,
196
+ excludeFromShapeBounds: true,
197
+ }).transform(Mat.Translate(50, 50))
198
+
199
+ const group = new Group2d({
200
+ children: [rect, excludedRect],
201
+ })
202
+
203
+ // The bounds should only include the non-excluded rectangle
204
+ const bounds = group.bounds
205
+ // Verify that the excluded rectangle doesn't affect the bounds
206
+ // The bounds should be smaller than if the excluded rect was included
207
+ expect(bounds.width).toBeLessThan(200) // Should not include the excluded rect's width
208
+ expect(bounds.height).toBeLessThan(200) // Should not include the excluded rect's height
209
+ // The bounds should not be empty
210
+ expect(bounds.width).toBeGreaterThan(0)
211
+ expect(bounds.height).toBeGreaterThan(0)
212
+ })
213
+ })
214
+
215
+ describe('getBoundsVertices', () => {
216
+ test('basic geometry returns vertices when not excluded from bounds', () => {
217
+ const rect = new Rectangle2d({
218
+ width: 100,
219
+ height: 50,
220
+ isFilled: true,
221
+ })
222
+
223
+ const boundsVertices = rect.getBoundsVertices()
224
+ const vertices = rect.getVertices()
225
+
226
+ expect(boundsVertices).toEqual(vertices)
227
+ expect(boundsVertices.length).toBe(4)
228
+ expect(boundsVertices).toMatchObject([
229
+ { x: 0, y: 0, z: 1 },
230
+ { x: 100, y: 0, z: 1 },
231
+ { x: 100, y: 50, z: 1 },
232
+ { x: 0, y: 50, z: 1 },
233
+ ])
234
+ })
235
+
236
+ test('geometry excluded from shape bounds returns empty array', () => {
237
+ const rect = new Rectangle2d({
238
+ width: 100,
239
+ height: 50,
240
+ isFilled: true,
241
+ excludeFromShapeBounds: true,
242
+ })
243
+
244
+ const boundsVertices = rect.getBoundsVertices()
245
+ expect(boundsVertices).toEqual([])
246
+ })
247
+
248
+ test('cached boundsVertices property', () => {
249
+ const rect = new Rectangle2d({
250
+ width: 100,
251
+ height: 50,
252
+ isFilled: true,
253
+ })
254
+
255
+ // Access the cached property multiple times
256
+ const boundsVertices1 = rect.boundsVertices
257
+ const boundsVertices2 = rect.boundsVertices
258
+
259
+ // Should return the same reference (cached)
260
+ expect(boundsVertices1).toBe(boundsVertices2)
261
+ expect(boundsVertices1.length).toBe(4)
262
+ })
263
+ })
264
+
265
+ describe('TransformedGeometry2d getBoundsVertices', () => {
266
+ test('transforms bounds vertices correctly', () => {
267
+ const rect = new Rectangle2d({
268
+ width: 100,
269
+ height: 50,
270
+ isFilled: true,
271
+ })
272
+
273
+ const transformed = rect.transform(Mat.Translate(50, 100).scale(2, 2))
274
+ const boundsVertices = transformed.getBoundsVertices()
275
+
276
+ expect(boundsVertices).toMatchObject([
277
+ { x: 50, y: 100, z: 1 },
278
+ { x: 250, y: 100, z: 1 },
279
+ { x: 250, y: 200, z: 1 },
280
+ { x: 50, y: 200, z: 1 },
281
+ ])
282
+ })
283
+
284
+ test('transforms empty bounds vertices for excluded geometry', () => {
285
+ const rect = new Rectangle2d({
286
+ width: 100,
287
+ height: 50,
288
+ isFilled: true,
289
+ excludeFromShapeBounds: true,
290
+ })
291
+
292
+ const transformed = rect.transform(Mat.Translate(50, 100))
293
+ const boundsVertices = transformed.getBoundsVertices()
294
+
295
+ expect(boundsVertices).toEqual([])
296
+ })
297
+
298
+ test('nested transform preserves bounds vertices behavior', () => {
299
+ const rect = new Rectangle2d({
300
+ width: 100,
301
+ height: 50,
302
+ isFilled: true,
303
+ })
304
+
305
+ const transformed1 = rect.transform(Mat.Translate(10, 20))
306
+ const transformed2 = transformed1.transform(Mat.Scale(2, 2))
307
+ const boundsVertices = transformed2.getBoundsVertices()
308
+
309
+ expect(boundsVertices).toMatchObject([
310
+ { x: 20, y: 40, z: 1 },
311
+ { x: 220, y: 40, z: 1 },
312
+ { x: 220, y: 140, z: 1 },
313
+ { x: 20, y: 140, z: 1 },
314
+ ])
315
+ })
316
+ })
317
+
318
+ describe('Group2d getBoundsVertices', () => {
319
+ test('flattens children bounds vertices', () => {
320
+ const rect1 = new Rectangle2d({
321
+ width: 50,
322
+ height: 50,
323
+ isFilled: true,
324
+ })
325
+
326
+ const rect2 = new Rectangle2d({
327
+ width: 30,
328
+ height: 30,
329
+ isFilled: true,
330
+ }).transform(Mat.Translate(60, 60))
331
+
332
+ const group = new Group2d({
333
+ children: [rect1, rect2],
334
+ })
335
+
336
+ const boundsVertices = group.getBoundsVertices()
337
+
338
+ // Should include all vertices from both rectangles
339
+ expect(boundsVertices.length).toBe(8) // 4 vertices from each rectangle
340
+
341
+ // Check that we have vertices from both rectangles
342
+ expect(boundsVertices).toEqual(
343
+ expect.arrayContaining([
344
+ expect.objectContaining({ x: 0, y: 0 }), // rect1 vertices
345
+ expect.objectContaining({ x: 50, y: 0 }),
346
+ expect.objectContaining({ x: 50, y: 50 }),
347
+ expect.objectContaining({ x: 0, y: 50 }),
348
+ expect.objectContaining({ x: 60, y: 60 }), // rect2 vertices
349
+ expect.objectContaining({ x: 90, y: 60 }),
350
+ expect.objectContaining({ x: 90, y: 90 }),
351
+ expect.objectContaining({ x: 60, y: 90 }),
352
+ ])
353
+ )
354
+ })
355
+
356
+ test('excludes children marked as excluded from bounds', () => {
357
+ const rect1 = new Rectangle2d({
358
+ width: 50,
359
+ height: 50,
360
+ isFilled: true,
361
+ })
362
+
363
+ const rect2 = new Rectangle2d({
364
+ width: 100,
365
+ height: 100,
366
+ isFilled: true,
367
+ excludeFromShapeBounds: true,
368
+ })
369
+
370
+ const group = new Group2d({
371
+ children: [rect1, rect2],
372
+ })
373
+
374
+ const boundsVertices = group.getBoundsVertices()
375
+
376
+ // Should only include vertices from rect1, not rect2
377
+ expect(boundsVertices.length).toBe(4) // Only rect1's 4 vertices
378
+ expect(boundsVertices).toMatchObject([
379
+ { x: 0, y: 0, z: 1 },
380
+ { x: 50, y: 0, z: 1 },
381
+ { x: 50, y: 50, z: 1 },
382
+ { x: 0, y: 50, z: 1 },
383
+ ])
384
+ })
385
+
386
+ test('returns empty array when group itself is excluded from bounds', () => {
387
+ const rect1 = new Rectangle2d({
388
+ width: 50,
389
+ height: 50,
390
+ isFilled: true,
391
+ })
392
+
393
+ const rect2 = new Rectangle2d({
394
+ width: 30,
395
+ height: 30,
396
+ isFilled: true,
397
+ })
398
+
399
+ const group = new Group2d({
400
+ children: [rect1, rect2],
401
+ excludeFromShapeBounds: true,
402
+ })
403
+
404
+ const boundsVertices = group.getBoundsVertices()
405
+ expect(boundsVertices).toEqual([])
406
+ })
407
+
408
+ test('handles nested groups correctly', () => {
409
+ const rect1 = new Rectangle2d({
410
+ width: 50,
411
+ height: 50,
412
+ isFilled: true,
413
+ })
414
+
415
+ const rect2 = new Rectangle2d({
416
+ width: 30,
417
+ height: 30,
418
+ isFilled: true,
419
+ })
420
+
421
+ const innerGroup = new Group2d({
422
+ children: [rect2],
423
+ })
424
+
425
+ const outerGroup = new Group2d({
426
+ children: [rect1, innerGroup],
427
+ })
428
+
429
+ const boundsVertices = outerGroup.getBoundsVertices()
430
+
431
+ // Should include vertices from both rectangles
432
+ expect(boundsVertices.length).toBe(8) // 4 vertices from each rectangle
433
+ })
434
+
435
+ test('handles all children excluded from bounds', () => {
436
+ const rect1 = new Rectangle2d({
437
+ width: 50,
438
+ height: 50,
439
+ isFilled: true,
440
+ excludeFromShapeBounds: true,
441
+ })
442
+
443
+ const rect2 = new Rectangle2d({
444
+ width: 30,
445
+ height: 30,
446
+ isFilled: true,
447
+ excludeFromShapeBounds: true,
448
+ })
449
+
450
+ const group = new Group2d({
451
+ children: [rect1, rect2],
452
+ })
453
+
454
+ const boundsVertices = group.getBoundsVertices()
455
+ expect(boundsVertices).toEqual([])
456
+ })
457
+ })
458
+
39
459
  function expectApproxMatch(a: VecLike, b: VecLike) {
40
460
  expect(a.x).toBeCloseTo(b.x, 0.0001)
41
461
  expect(a.y).toBeCloseTo(b.y, 0.0001)