@tldraw/editor 3.16.0-canary.9aaa1c4236ff → 3.16.0-canary.9abe0dac1e0f

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 -4
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/lib/TldrawEditor.js +0 -2
  4. package/dist-cjs/lib/TldrawEditor.js.map +2 -2
  5. package/dist-cjs/lib/components/default-components/DefaultCanvas.js +11 -1
  6. package/dist-cjs/lib/components/default-components/DefaultCanvas.js.map +2 -2
  7. package/dist-cjs/lib/editor/Editor.js +38 -4
  8. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  9. package/dist-cjs/lib/editor/managers/FocusManager/FocusManager.js +4 -2
  10. package/dist-cjs/lib/editor/managers/FocusManager/FocusManager.js.map +2 -2
  11. package/dist-cjs/lib/hooks/useCanvasEvents.js +19 -16
  12. package/dist-cjs/lib/hooks/useCanvasEvents.js.map +2 -2
  13. package/dist-cjs/lib/hooks/useDocumentEvents.js +5 -5
  14. package/dist-cjs/lib/hooks/useDocumentEvents.js.map +2 -2
  15. package/dist-cjs/lib/hooks/useFixSafariDoubleTapZoomPencilEvents.js +1 -2
  16. package/dist-cjs/lib/hooks/useFixSafariDoubleTapZoomPencilEvents.js.map +2 -2
  17. package/dist-cjs/lib/hooks/useGestureEvents.js +1 -1
  18. package/dist-cjs/lib/hooks/useGestureEvents.js.map +2 -2
  19. package/dist-cjs/lib/hooks/useHandleEvents.js +6 -6
  20. package/dist-cjs/lib/hooks/useHandleEvents.js.map +2 -2
  21. package/dist-cjs/lib/hooks/useSelectionEvents.js +8 -8
  22. package/dist-cjs/lib/hooks/useSelectionEvents.js.map +2 -2
  23. package/dist-cjs/lib/license/LicenseManager.js +24 -4
  24. package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
  25. package/dist-cjs/lib/license/Watermark.js +97 -90
  26. package/dist-cjs/lib/license/Watermark.js.map +2 -2
  27. package/dist-cjs/lib/primitives/geometry/Geometry2d.js +24 -2
  28. package/dist-cjs/lib/primitives/geometry/Geometry2d.js.map +2 -2
  29. package/dist-cjs/lib/primitives/geometry/Group2d.js +5 -1
  30. package/dist-cjs/lib/primitives/geometry/Group2d.js.map +2 -2
  31. package/dist-cjs/lib/utils/dom.js.map +2 -2
  32. package/dist-cjs/lib/utils/getPointerInfo.js +2 -3
  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 -4
  37. package/dist-esm/index.mjs +1 -1
  38. package/dist-esm/lib/TldrawEditor.mjs +0 -2
  39. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  40. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs +11 -1
  41. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs.map +2 -2
  42. package/dist-esm/lib/editor/Editor.mjs +38 -4
  43. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  44. package/dist-esm/lib/editor/managers/FocusManager/FocusManager.mjs +4 -2
  45. package/dist-esm/lib/editor/managers/FocusManager/FocusManager.mjs.map +2 -2
  46. package/dist-esm/lib/hooks/useCanvasEvents.mjs +20 -22
  47. package/dist-esm/lib/hooks/useCanvasEvents.mjs.map +2 -2
  48. package/dist-esm/lib/hooks/useDocumentEvents.mjs +6 -6
  49. package/dist-esm/lib/hooks/useDocumentEvents.mjs.map +2 -2
  50. package/dist-esm/lib/hooks/useFixSafariDoubleTapZoomPencilEvents.mjs +1 -2
  51. package/dist-esm/lib/hooks/useFixSafariDoubleTapZoomPencilEvents.mjs.map +2 -2
  52. package/dist-esm/lib/hooks/useGestureEvents.mjs +2 -2
  53. package/dist-esm/lib/hooks/useGestureEvents.mjs.map +2 -2
  54. package/dist-esm/lib/hooks/useHandleEvents.mjs +6 -6
  55. package/dist-esm/lib/hooks/useHandleEvents.mjs.map +2 -2
  56. package/dist-esm/lib/hooks/useSelectionEvents.mjs +9 -14
  57. package/dist-esm/lib/hooks/useSelectionEvents.mjs.map +2 -2
  58. package/dist-esm/lib/license/LicenseManager.mjs +24 -4
  59. package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
  60. package/dist-esm/lib/license/Watermark.mjs +98 -91
  61. package/dist-esm/lib/license/Watermark.mjs.map +2 -2
  62. package/dist-esm/lib/primitives/geometry/Geometry2d.mjs +24 -2
  63. package/dist-esm/lib/primitives/geometry/Geometry2d.mjs.map +2 -2
  64. package/dist-esm/lib/primitives/geometry/Group2d.mjs +5 -1
  65. package/dist-esm/lib/primitives/geometry/Group2d.mjs.map +2 -2
  66. package/dist-esm/lib/utils/dom.mjs.map +2 -2
  67. package/dist-esm/lib/utils/getPointerInfo.mjs +2 -3
  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/lib/TldrawEditor.tsx +0 -2
  73. package/src/lib/components/default-components/DefaultCanvas.tsx +7 -1
  74. package/src/lib/editor/Editor.test.ts +90 -0
  75. package/src/lib/editor/Editor.ts +49 -4
  76. package/src/lib/editor/managers/FocusManager/FocusManager.ts +6 -2
  77. package/src/lib/hooks/useCanvasEvents.ts +20 -20
  78. package/src/lib/hooks/useDocumentEvents.ts +6 -6
  79. package/src/lib/hooks/useFixSafariDoubleTapZoomPencilEvents.ts +1 -1
  80. package/src/lib/hooks/useGestureEvents.ts +2 -2
  81. package/src/lib/hooks/useHandleEvents.ts +6 -6
  82. package/src/lib/hooks/useSelectionEvents.ts +9 -14
  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 +100 -92
  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 +103 -0
  91. package/src/lib/utils/dom.ts +8 -1
  92. package/src/lib/utils/getPointerInfo.ts +3 -2
  93. package/src/version.ts +3 -3
@@ -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 { preventDefault } from '../utils/dom'
7
7
  import { runtime } from '../utils/runtime'
8
8
  import { watermarkDesktopSvg, watermarkMobileSvg } from '../watermarks'
9
9
  import { LicenseManager } from './LicenseManager'
@@ -43,11 +43,13 @@ const UnlicensedWatermark = memo(function UnlicensedWatermark({
43
43
  isDebugMode: boolean
44
44
  isMobile: boolean
45
45
  }) {
46
+ const editor = useEditor()
46
47
  const events = useCanvasEvents()
47
48
  const ref = useRef<HTMLDivElement>(null)
48
49
  usePassThroughWheelEvents(ref)
49
50
 
50
- const url = 'https://tldraw.dev/?utm_source=dotcom&utm_medium=organic&utm_campaign=watermark'
51
+ const url =
52
+ 'https://tldraw.dev/pricing?utm_source=dotcom&utm_medium=organic&utm_campaign=watermark'
51
53
 
52
54
  return (
53
55
  <div
@@ -64,26 +66,13 @@ const UnlicensedWatermark = memo(function UnlicensedWatermark({
64
66
  draggable={false}
65
67
  role="button"
66
68
  onPointerDown={(e) => {
67
- stopEventPropagation(e)
69
+ editor.markEventAsHandled(e)
68
70
  preventDefault(e)
69
71
  }}
70
- title="Unlicensed - click to get a license"
72
+ title="The tldraw SDK requires a license key to work in production. You can get a free 100-day trial license at tldraw.dev/pricing."
71
73
  onClick={() => runtime.openWindow(url, '_blank')}
72
- style={{
73
- position: 'absolute',
74
- pointerEvents: 'all',
75
- cursor: 'pointer',
76
- color: 'var(--tl-color-text)',
77
- opacity: 0.8,
78
- border: 0,
79
- padding: 0,
80
- backgroundColor: 'transparent',
81
- fontSize: '11px',
82
- fontWeight: '600',
83
- textAlign: 'center',
84
- }}
85
74
  >
86
- Unlicensed
75
+ Get a license for production
87
76
  </button>
88
77
  </div>
89
78
  )
@@ -127,10 +116,10 @@ const WatermarkInner = memo(function WatermarkInner({
127
116
  draggable={false}
128
117
  role="button"
129
118
  onPointerDown={(e) => {
130
- stopEventPropagation(e)
119
+ editor.markEventAsHandled(e)
131
120
  preventDefault(e)
132
121
  }}
133
- title="made with tldraw"
122
+ title="Build infinite canvas applications with the tldraw SDK. Learn more at https://tldraw.dev."
134
123
  onClick={() => runtime.openWindow(url, '_blank')}
135
124
  style={{ mask: maskCss, WebkitMask: maskCss }}
136
125
  />
@@ -142,7 +131,8 @@ const LicenseStyles = memo(function LicenseStyles() {
142
131
  const editor = useEditor()
143
132
  const className = LicenseManager.className
144
133
 
145
- const CSS = `/* ------------------- SEE LICENSE -------------------
134
+ const CSS = `
135
+ /* ------------------- SEE LICENSE -------------------
146
136
  The tldraw watermark is part of tldraw's license. It is shown for unlicensed
147
137
  or "licensed-with-watermark" users. By using this library, you agree to
148
138
  preserve the watermark's behavior, keeping it visible, unobscured, and
@@ -151,87 +141,105 @@ available to user-interaction.
151
141
  To remove the watermark, please purchase a license at tldraw.dev.
152
142
  */
153
143
 
154
- .${className} {
155
- position: absolute;
156
- bottom: max(var(--tl-space-2), env(safe-area-inset-bottom));
157
- right: max(var(--tl-space-2), env(safe-area-inset-right));
158
- width: 96px;
159
- height: 32px;
160
- display: flex;
161
- align-items: center;
162
- justify-content: center;
163
- z-index: var(--tl-layer-watermark) !important;
164
- background-color: color-mix(in srgb, var(--tl-color-background) 62%, transparent);
165
- opacity: 1;
166
- border-radius: 5px;
167
- pointer-events: all;
168
- padding: 2px;
169
- box-sizing: content-box;
144
+ .${className} {
145
+ position: absolute;
146
+ bottom: max(var(--tl-space-2), env(safe-area-inset-bottom));
147
+ right: max(var(--tl-space-2), env(safe-area-inset-right));
148
+ width: 96px;
149
+ height: 32px;
150
+ display: flex;
151
+ align-items: center;
152
+ justify-content: center;
153
+ z-index: var(--tl-layer-watermark) !important;
154
+ background-color: color-mix(in srgb, var(--tl-color-background) 62%, transparent);
155
+ opacity: 1;
156
+ border-radius: 5px;
157
+ pointer-events: all;
158
+ padding: 2px;
159
+ box-sizing: content-box;
160
+ }
161
+
162
+ .${className} > button {
163
+ position: absolute;
164
+ width: 96px;
165
+ height: 32px;
166
+ pointer-events: all;
167
+ cursor: inherit;
168
+ color: var(--tl-color-text);
169
+ opacity: .38;
170
+ border: 0;
171
+ padding: 0;
172
+ background-color: currentColor;
173
+ }
174
+
175
+ .${className}[data-debug='true'] {
176
+ bottom: max(46px, env(safe-area-inset-bottom));
177
+ }
178
+
179
+ .${className}[data-mobile='true'] {
180
+ border-radius: 4px 0px 0px 4px;
181
+ right: max(-2px, calc(env(safe-area-inset-right) - 2px));
182
+ width: 8px;
183
+ height: 48px;
184
+ }
185
+
186
+ .${className}[data-mobile='true'] > button {
187
+ width: 8px;
188
+ height: 32px;
189
+ }
190
+
191
+ .${className}[data-unlicensed='true'] > button {
192
+ font-size: 100px;
193
+ position: absolute;
194
+ pointer-events: all;
195
+ cursor: pointer;
196
+ color: var(--tl-color-text);
197
+ opacity: 0.8;
198
+ border: 0;
199
+ padding: 0;
200
+ background-color: transparent;
201
+ font-size: 11px;
202
+ font-weight: 600;
203
+ text-align: center;
204
+ }
205
+
206
+ .${className}[data-mobile='true'][data-unlicensed='true'] > button {
207
+ display: none;
208
+ }
209
+
210
+ @media (hover: hover) {
211
+ .${className}[data-licensed='false'] > button {
212
+ pointer-events: none;
170
213
  }
171
214
 
172
- .${className} > button {
173
- position: absolute;
174
- width: 96px;
175
- height: 32px;
176
- pointer-events: all;
177
- cursor: inherit;
178
- color: var(--tl-color-text);
179
- opacity: .38;
180
- border: 0;
181
- padding: 0;
182
- background-color: currentColor;
215
+ .${className}[data-licensed='false']:hover {
216
+ background-color: var(--tl-color-background);
217
+ transition: background-color 0.2s ease-in-out;
218
+ transition-delay: 0.32s;
183
219
  }
184
220
 
185
- .${className}[data-debug='true'] {
186
- bottom: max(46px, env(safe-area-inset-bottom));
221
+ .${className}[data-licensed='false']:hover > button {
222
+ animation: ${className}_delayed_link 0.2s forwards ease-in-out;
223
+ animation-delay: 0.32s;
187
224
  }
188
225
 
189
- .${className}[data-mobile='true'] {
190
- border-radius: 4px 0px 0px 4px;
191
- right: max(-2px, calc(env(safe-area-inset-right) - 2px));
192
- width: 8px;
193
- height: 48px;
226
+ .${className}[data-licensed='false'] > button:focus-visible {
227
+ opacity: 1;
194
228
  }
229
+ }
195
230
 
196
- .${className}[data-mobile='true'] > button {
197
- width: 8px;
198
- height: 32px;
231
+ @keyframes ${className}_delayed_link {
232
+ 0% {
233
+ cursor: inherit;
234
+ opacity: .38;
235
+ pointer-events: none;
199
236
  }
200
-
201
- @media (hover: hover) {
202
- .${className} > button {
203
- pointer-events: none;
204
- }
205
-
206
- .${className}:hover {
207
- background-color: var(--tl-color-background);
208
- transition: background-color 0.2s ease-in-out;
209
- transition-delay: 0.32s;
210
- }
211
-
212
- .${className}:hover > button {
213
- animation: ${className}_delayed_link 0.2s forwards ease-in-out;
214
- animation-delay: 0.32s;
215
- }
216
-
217
- .${className} > button:focus-visible {
218
- opacity: 1;
219
- }
237
+ 100% {
238
+ cursor: pointer;
239
+ opacity: 1;
240
+ pointer-events: all;
220
241
  }
221
-
222
-
223
- @keyframes ${className}_delayed_link {
224
- 0% {
225
- cursor: inherit;
226
- opacity: .38;
227
- pointer-events: none;
228
- }
229
- 100% {
230
- cursor: pointer;
231
- opacity: 1;
232
- pointer-events: all;
233
- }
234
- }`
242
+ }`
235
243
 
236
244
  return <style nonce={editor.options.nonce}>{CSS}</style>
237
245
  })
@@ -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)