@tldraw/editor 3.16.0-next.f9f54ec051f3 → 3.16.0-next.fe14f1b4181f

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 (137) hide show
  1. package/dist-cjs/index.d.ts +110 -9
  2. package/dist-cjs/index.js +3 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/TldrawEditor.js +8 -2
  5. package/dist-cjs/lib/TldrawEditor.js.map +2 -2
  6. package/dist-cjs/lib/components/MenuClickCapture.js +0 -5
  7. package/dist-cjs/lib/components/MenuClickCapture.js.map +2 -2
  8. package/dist-cjs/lib/components/Shape.js +7 -10
  9. package/dist-cjs/lib/components/Shape.js.map +2 -2
  10. package/dist-cjs/lib/components/default-components/DefaultCanvas.js +4 -23
  11. package/dist-cjs/lib/components/default-components/DefaultCanvas.js.map +2 -2
  12. package/dist-cjs/lib/components/default-components/DefaultCollaboratorHint.js +1 -1
  13. package/dist-cjs/lib/components/default-components/DefaultCollaboratorHint.js.map +1 -1
  14. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js +1 -1
  15. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js.map +2 -2
  16. package/dist-cjs/lib/components/default-components/DefaultScribble.js +1 -1
  17. package/dist-cjs/lib/components/default-components/DefaultScribble.js.map +2 -2
  18. package/dist-cjs/lib/components/default-components/DefaultShapeIndicator.js +9 -1
  19. package/dist-cjs/lib/components/default-components/DefaultShapeIndicator.js.map +2 -2
  20. package/dist-cjs/lib/config/TLUserPreferences.js +9 -3
  21. package/dist-cjs/lib/config/TLUserPreferences.js.map +2 -2
  22. package/dist-cjs/lib/editor/Editor.js +63 -24
  23. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  24. package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js +9 -4
  25. package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js.map +2 -2
  26. package/dist-cjs/lib/editor/shapes/ShapeUtil.js +13 -0
  27. package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +2 -2
  28. package/dist-cjs/lib/editor/types/misc-types.js.map +1 -1
  29. package/dist-cjs/lib/exports/getSvgJsx.js +35 -16
  30. package/dist-cjs/lib/exports/getSvgJsx.js.map +2 -2
  31. package/dist-cjs/lib/hooks/useCanvasEvents.js +31 -25
  32. package/dist-cjs/lib/hooks/useCanvasEvents.js.map +2 -2
  33. package/dist-cjs/lib/hooks/usePassThroughWheelEvents.js +4 -1
  34. package/dist-cjs/lib/hooks/usePassThroughWheelEvents.js.map +2 -2
  35. package/dist-cjs/lib/{utils/nearestMultiple.js → hooks/useStateAttribute.js} +15 -14
  36. package/dist-cjs/lib/hooks/useStateAttribute.js.map +7 -0
  37. package/dist-cjs/lib/license/Watermark.js +6 -6
  38. package/dist-cjs/lib/license/Watermark.js.map +1 -1
  39. package/dist-cjs/lib/options.js +7 -0
  40. package/dist-cjs/lib/options.js.map +2 -2
  41. package/dist-cjs/lib/primitives/Box.js +3 -0
  42. package/dist-cjs/lib/primitives/Box.js.map +2 -2
  43. package/dist-cjs/lib/utils/EditorAtom.js +45 -0
  44. package/dist-cjs/lib/utils/EditorAtom.js.map +7 -0
  45. package/dist-cjs/version.js +3 -3
  46. package/dist-cjs/version.js.map +1 -1
  47. package/dist-esm/index.d.mts +110 -9
  48. package/dist-esm/index.mjs +3 -1
  49. package/dist-esm/index.mjs.map +2 -2
  50. package/dist-esm/lib/TldrawEditor.mjs +8 -2
  51. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  52. package/dist-esm/lib/components/MenuClickCapture.mjs +0 -5
  53. package/dist-esm/lib/components/MenuClickCapture.mjs.map +2 -2
  54. package/dist-esm/lib/components/Shape.mjs +7 -10
  55. package/dist-esm/lib/components/Shape.mjs.map +2 -2
  56. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs +4 -23
  57. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs.map +2 -2
  58. package/dist-esm/lib/components/default-components/DefaultCollaboratorHint.mjs +1 -1
  59. package/dist-esm/lib/components/default-components/DefaultCollaboratorHint.mjs.map +1 -1
  60. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs +1 -1
  61. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs.map +2 -2
  62. package/dist-esm/lib/components/default-components/DefaultScribble.mjs +1 -1
  63. package/dist-esm/lib/components/default-components/DefaultScribble.mjs.map +2 -2
  64. package/dist-esm/lib/components/default-components/DefaultShapeIndicator.mjs +9 -1
  65. package/dist-esm/lib/components/default-components/DefaultShapeIndicator.mjs.map +2 -2
  66. package/dist-esm/lib/config/TLUserPreferences.mjs +9 -3
  67. package/dist-esm/lib/config/TLUserPreferences.mjs.map +2 -2
  68. package/dist-esm/lib/editor/Editor.mjs +63 -24
  69. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  70. package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs +9 -4
  71. package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs.map +2 -2
  72. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs +13 -0
  73. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +2 -2
  74. package/dist-esm/lib/exports/getSvgJsx.mjs +36 -16
  75. package/dist-esm/lib/exports/getSvgJsx.mjs.map +2 -2
  76. package/dist-esm/lib/hooks/useCanvasEvents.mjs +32 -26
  77. package/dist-esm/lib/hooks/useCanvasEvents.mjs.map +2 -2
  78. package/dist-esm/lib/hooks/usePassThroughWheelEvents.mjs +4 -1
  79. package/dist-esm/lib/hooks/usePassThroughWheelEvents.mjs.map +2 -2
  80. package/dist-esm/lib/hooks/useStateAttribute.mjs +15 -0
  81. package/dist-esm/lib/hooks/useStateAttribute.mjs.map +7 -0
  82. package/dist-esm/lib/license/Watermark.mjs +6 -6
  83. package/dist-esm/lib/license/Watermark.mjs.map +1 -1
  84. package/dist-esm/lib/options.mjs +7 -0
  85. package/dist-esm/lib/options.mjs.map +2 -2
  86. package/dist-esm/lib/primitives/Box.mjs +4 -1
  87. package/dist-esm/lib/primitives/Box.mjs.map +2 -2
  88. package/dist-esm/lib/utils/EditorAtom.mjs +25 -0
  89. package/dist-esm/lib/utils/EditorAtom.mjs.map +7 -0
  90. package/dist-esm/version.mjs +3 -3
  91. package/dist-esm/version.mjs.map +1 -1
  92. package/editor.css +301 -288
  93. package/package.json +14 -37
  94. package/src/index.ts +2 -0
  95. package/src/lib/TldrawEditor.tsx +13 -6
  96. package/src/lib/components/MenuClickCapture.tsx +0 -8
  97. package/src/lib/components/Shape.tsx +6 -12
  98. package/src/lib/components/default-components/DefaultCanvas.tsx +5 -22
  99. package/src/lib/components/default-components/DefaultCollaboratorHint.tsx +1 -1
  100. package/src/lib/components/default-components/DefaultErrorFallback.tsx +1 -1
  101. package/src/lib/components/default-components/DefaultScribble.tsx +1 -1
  102. package/src/lib/components/default-components/DefaultShapeIndicator.tsx +5 -1
  103. package/src/lib/config/TLUserPreferences.ts +8 -1
  104. package/src/lib/editor/Editor.test.ts +12 -11
  105. package/src/lib/editor/Editor.ts +88 -47
  106. package/src/lib/editor/managers/ClickManager/ClickManager.test.ts +15 -14
  107. package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.test.ts +16 -15
  108. package/src/lib/editor/managers/FocusManager/FocusManager.test.ts +49 -48
  109. package/src/lib/editor/managers/FontManager/FontManager.test.ts +24 -23
  110. package/src/lib/editor/managers/HistoryManager/HistoryManager.test.ts +7 -6
  111. package/src/lib/editor/managers/ScribbleManager/ScribbleManager.test.ts +12 -11
  112. package/src/lib/editor/managers/SnapManager/SnapManager.test.ts +57 -50
  113. package/src/lib/editor/managers/TextManager/TextManager.test.ts +51 -26
  114. package/src/lib/editor/managers/TickManager/TickManager.test.ts +14 -13
  115. package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.test.ts +34 -26
  116. package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.ts +6 -1
  117. package/src/lib/editor/shapes/ShapeUtil.ts +14 -0
  118. package/src/lib/editor/types/misc-types.ts +54 -1
  119. package/src/lib/exports/getSvgJsx.test.ts +868 -0
  120. package/src/lib/exports/getSvgJsx.tsx +78 -21
  121. package/src/lib/hooks/useCanvasEvents.ts +45 -38
  122. package/src/lib/hooks/usePassThroughWheelEvents.ts +6 -1
  123. package/src/lib/hooks/useStateAttribute.ts +15 -0
  124. package/src/lib/license/LicenseManager.test.ts +3 -1
  125. package/src/lib/license/Watermark.test.tsx +2 -1
  126. package/src/lib/license/Watermark.tsx +6 -6
  127. package/src/lib/options.ts +8 -0
  128. package/src/lib/primitives/Box.test.ts +126 -0
  129. package/src/lib/primitives/Box.ts +10 -1
  130. package/src/lib/utils/EditorAtom.ts +37 -0
  131. package/src/lib/utils/sync/LocalIndexedDb.test.ts +2 -1
  132. package/src/lib/utils/sync/TLLocalSyncClient.test.ts +15 -15
  133. package/src/version.ts +3 -3
  134. package/dist-cjs/lib/utils/nearestMultiple.js.map +0 -7
  135. package/dist-esm/lib/utils/nearestMultiple.mjs +0 -14
  136. package/dist-esm/lib/utils/nearestMultiple.mjs.map +0 -7
  137. package/src/lib/utils/nearestMultiple.ts +0 -13
@@ -0,0 +1,868 @@
1
+ import {
2
+ Geometry2d,
3
+ RecordProps,
4
+ Rectangle2d,
5
+ ShapeUtil,
6
+ T,
7
+ TLBaseShape,
8
+ createShapeId,
9
+ } from '../..'
10
+ import { createTLStore } from '../config/createTLStore'
11
+ import { Editor } from '../editor/Editor'
12
+ import { Box } from '../primitives/Box'
13
+ import { getExportDefaultBounds } from './getSvgJsx'
14
+
15
+ type ITestShape = TLBaseShape<
16
+ 'test-shape',
17
+ {
18
+ w: number
19
+ h: number
20
+ x: number
21
+ y: number
22
+ isContainer?: boolean
23
+ }
24
+ >
25
+
26
+ class TestShape extends ShapeUtil<ITestShape> {
27
+ static override type = 'test-shape' as const
28
+ static override props: RecordProps<ITestShape> = {
29
+ w: T.number,
30
+ h: T.number,
31
+ x: T.number,
32
+ y: T.number,
33
+ isContainer: T.boolean.optional(),
34
+ }
35
+ getDefaultProps(): ITestShape['props'] {
36
+ return {
37
+ w: 100,
38
+ h: 100,
39
+ x: 0,
40
+ y: 0,
41
+ isContainer: false,
42
+ }
43
+ }
44
+ getGeometry(shape: ITestShape): Geometry2d {
45
+ return new Rectangle2d({
46
+ width: shape.props.w,
47
+ height: shape.props.h,
48
+ x: shape.props.x,
49
+ y: shape.props.y,
50
+ isFilled: false,
51
+ })
52
+ }
53
+
54
+ override isExportBoundsContainer(shape: ITestShape): boolean {
55
+ return shape.props.isContainer ?? false
56
+ }
57
+
58
+ indicator() {}
59
+ component() {}
60
+ }
61
+
62
+ let editor: Editor
63
+
64
+ beforeEach(() => {
65
+ editor = new Editor({
66
+ shapeUtils: [TestShape],
67
+ bindingUtils: [],
68
+ tools: [],
69
+ store: createTLStore({ shapeUtils: [TestShape], bindingUtils: [] }),
70
+ getContainer: () => document.body,
71
+ })
72
+ })
73
+
74
+ describe('getExportDefaultBounds', () => {
75
+ it('returns null when no rendering shapes provided', () => {
76
+ const result = getExportDefaultBounds(editor, [], 32, null)
77
+ expect(result).toBeNull()
78
+ })
79
+
80
+ it('returns bounds for single shape with padding', () => {
81
+ const shapeId = createShapeId('test1')
82
+ editor.createShape({
83
+ id: shapeId,
84
+ type: 'test-shape',
85
+ x: 10,
86
+ y: 20,
87
+ props: { w: 100, h: 80, x: 0, y: 0 },
88
+ })
89
+
90
+ const renderingShapes = editor.getUnorderedRenderingShapes(false)
91
+ const testShape = renderingShapes.find((s) => s.id === shapeId)!
92
+
93
+ const result = getExportDefaultBounds(editor, [testShape], 32, null)
94
+
95
+ expect(result).toBeInstanceOf(Box)
96
+ // Bounds should include 32px padding on all sides
97
+ expect(result?.x).toBe(10 - 32) // -22
98
+ expect(result?.y).toBe(20 - 32) // -12
99
+ expect(result?.w).toBe(100 + 64) // 164 (32px on each side)
100
+ expect(result?.h).toBe(80 + 64) // 144 (32px on each side)
101
+ })
102
+
103
+ it('returns union bounds for multiple shapes with padding', () => {
104
+ const shape1Id = createShapeId('test1')
105
+ const shape2Id = createShapeId('test2')
106
+
107
+ editor.createShape({
108
+ id: shape1Id,
109
+ type: 'test-shape',
110
+ x: 0,
111
+ y: 0,
112
+ props: { w: 50, h: 50, x: 0, y: 0 },
113
+ })
114
+
115
+ editor.createShape({
116
+ id: shape2Id,
117
+ type: 'test-shape',
118
+ x: 30,
119
+ y: 30,
120
+ props: { w: 60, h: 60, x: 0, y: 0 },
121
+ })
122
+
123
+ const renderingShapes = editor.getUnorderedRenderingShapes(false)
124
+ const testShapes = renderingShapes.filter((s) => [shape1Id, shape2Id].includes(s.id))
125
+
126
+ const result = getExportDefaultBounds(editor, testShapes, 32, null)
127
+
128
+ expect(result).toBeInstanceOf(Box)
129
+ // Raw bounds would be (0,0) to (90,90), with 32px padding on all sides
130
+ expect(result?.x).toBe(0 - 32) // -32
131
+ expect(result?.y).toBe(0 - 32) // -32
132
+ expect(result?.w).toBe(90 + 64) // 154
133
+ expect(result?.h).toBe(90 + 64) // 154
134
+ })
135
+
136
+ it('handles shapes with transforms correctly', () => {
137
+ const shapeId = createShapeId('test1')
138
+ editor.createShape({
139
+ id: shapeId,
140
+ type: 'test-shape',
141
+ x: 25,
142
+ y: 35,
143
+ props: { w: 50, h: 40, x: 0, y: 0 },
144
+ })
145
+
146
+ // Rotate the shape
147
+ editor.rotateShapesBy([shapeId], Math.PI / 4)
148
+
149
+ const renderingShapes = editor.getUnorderedRenderingShapes(false)
150
+ const testShape = renderingShapes.find((s) => s.id === shapeId)!
151
+
152
+ const result = getExportDefaultBounds(editor, [testShape], 32, null)
153
+
154
+ expect(result).toBeInstanceOf(Box)
155
+ // The rotated shape should have expanded bounds, plus padding
156
+ expect(result!.w).toBeGreaterThan(50 + 64)
157
+ expect(result!.h).toBeGreaterThan(40 + 64)
158
+ })
159
+
160
+ it('handles multiple overlapping shapes correctly', () => {
161
+ const shape1Id = createShapeId('test1')
162
+ const shape2Id = createShapeId('test2')
163
+ const shape3Id = createShapeId('test3')
164
+
165
+ // Create overlapping shapes
166
+ editor.createShape({
167
+ id: shape1Id,
168
+ type: 'test-shape',
169
+ x: 0,
170
+ y: 0,
171
+ props: { w: 40, h: 40, x: 0, y: 0 },
172
+ })
173
+
174
+ editor.createShape({
175
+ id: shape2Id,
176
+ type: 'test-shape',
177
+ x: 20,
178
+ y: 20,
179
+ props: { w: 40, h: 40, x: 0, y: 0 },
180
+ })
181
+
182
+ editor.createShape({
183
+ id: shape3Id,
184
+ type: 'test-shape',
185
+ x: 10,
186
+ y: 10,
187
+ props: { w: 20, h: 20, x: 0, y: 0 },
188
+ })
189
+
190
+ const renderingShapes = editor.getUnorderedRenderingShapes(false)
191
+ const testShapes = renderingShapes.filter((s) => [shape1Id, shape2Id, shape3Id].includes(s.id))
192
+
193
+ const result = getExportDefaultBounds(editor, testShapes, 32, null)
194
+
195
+ expect(result).toBeInstanceOf(Box)
196
+ // Raw bounds would be (0,0) to (60,60), with 32px padding on all sides
197
+ expect(result?.x).toBe(0 - 32) // -32
198
+ expect(result?.y).toBe(0 - 32) // -32
199
+ expect(result?.w).toBe(60 + 64) // 124 (32px on each side)
200
+ expect(result?.h).toBe(60 + 64) // 124 (32px on each side)
201
+ })
202
+
203
+ it('handles complex geometry with multiple shapes', () => {
204
+ const shape1Id = createShapeId('shape1')
205
+ const shape2Id = createShapeId('shape2')
206
+ const shape3Id = createShapeId('shape3')
207
+
208
+ // Create shapes with different positions and sizes
209
+ editor.createShape({
210
+ id: shape1Id,
211
+ type: 'test-shape',
212
+ x: 0,
213
+ y: 0,
214
+ props: { w: 50, h: 50, x: 0, y: 0 },
215
+ })
216
+
217
+ editor.createShape({
218
+ id: shape2Id,
219
+ type: 'test-shape',
220
+ x: 100,
221
+ y: 100,
222
+ props: { w: 60, h: 40, x: 0, y: 0 },
223
+ })
224
+
225
+ editor.createShape({
226
+ id: shape3Id,
227
+ type: 'test-shape',
228
+ x: 200,
229
+ y: 50,
230
+ props: { w: 40, h: 80, x: 0, y: 0 },
231
+ })
232
+
233
+ const renderingShapes = editor.getUnorderedRenderingShapes(false)
234
+ const testShapes = renderingShapes.filter((s) => [shape1Id, shape2Id, shape3Id].includes(s.id))
235
+
236
+ const result = getExportDefaultBounds(editor, testShapes, 32, null)
237
+
238
+ expect(result).toBeInstanceOf(Box)
239
+
240
+ // The bounds should encompass:
241
+ // - shape1: (0, 0) to (50, 50)
242
+ // - shape2: (100, 100) to (160, 140)
243
+ // - shape3: (200, 50) to (240, 130)
244
+ // Raw total bounds: (0, 0) to (240, 140), with 32px padding on all sides
245
+ expect(result!.x).toBe(0 - 32) // -32 (leftmost edge with padding)
246
+ expect(result!.y).toBe(0 - 32) // -32 (topmost edge with padding)
247
+ expect(result!.w).toBe(240 + 64) // 304 (width + 32px on each side)
248
+ expect(result!.h).toBe(140 + 64) // 204 (height + 32px on each side)
249
+ })
250
+
251
+ it('handles empty rendering shapes array after filtering', () => {
252
+ // Create a shape but don't include it in rendering shapes
253
+ const shapeId = createShapeId('test1')
254
+ editor.createShape({
255
+ id: shapeId,
256
+ type: 'test-shape',
257
+ x: 10,
258
+ y: 20,
259
+ props: { w: 100, h: 80, x: 0, y: 0 },
260
+ })
261
+
262
+ // Pass empty array to simulate filtered out shapes
263
+ const result = getExportDefaultBounds(editor, [], 32, null)
264
+
265
+ expect(result).toBeNull()
266
+ })
267
+
268
+ it('does not apply padding when exporting single frame shape', () => {
269
+ const shapeId = createShapeId('test1')
270
+ editor.createShape({
271
+ id: shapeId,
272
+ type: 'test-shape',
273
+ x: 10,
274
+ y: 20,
275
+ props: { w: 100, h: 80, x: 0, y: 0 },
276
+ })
277
+
278
+ const renderingShapes = editor.getUnorderedRenderingShapes(false)
279
+ const testShape = renderingShapes.find((s) => s.id === shapeId)!
280
+
281
+ // Pass the shape ID as singleFrameShapeId to simulate single frame export
282
+ const result = getExportDefaultBounds(editor, [testShape], 32, shapeId)
283
+
284
+ expect(result).toBeInstanceOf(Box)
285
+ // No padding should be applied
286
+ expect(result?.x).toBe(10)
287
+ expect(result?.y).toBe(20)
288
+ expect(result?.w).toBe(100)
289
+ expect(result?.h).toBe(80)
290
+ })
291
+
292
+ describe('isExportBoundsContainer behavior', () => {
293
+ it('applies normal padding when no container shapes exist', () => {
294
+ const shape1Id = createShapeId('shape1')
295
+ const shape2Id = createShapeId('shape2')
296
+
297
+ editor.createShape({
298
+ id: shape1Id,
299
+ type: 'test-shape',
300
+ x: 0,
301
+ y: 0,
302
+ props: { w: 50, h: 50, x: 0, y: 0, isContainer: false },
303
+ })
304
+
305
+ editor.createShape({
306
+ id: shape2Id,
307
+ type: 'test-shape',
308
+ x: 10,
309
+ y: 10,
310
+ props: { w: 30, h: 30, x: 0, y: 0, isContainer: false },
311
+ })
312
+
313
+ const renderingShapes = editor.getUnorderedRenderingShapes(false)
314
+ const testShapes = renderingShapes.filter((s) => [shape1Id, shape2Id].includes(s.id))
315
+
316
+ const result = getExportDefaultBounds(editor, testShapes, 32, null)
317
+
318
+ expect(result).toBeInstanceOf(Box)
319
+ // Raw bounds: (0,0) to (50,50), with padding
320
+ expect(result?.x).toBe(-32)
321
+ expect(result?.y).toBe(-32)
322
+ expect(result?.w).toBe(50 + 64) // 114
323
+ expect(result?.h).toBe(50 + 64) // 114
324
+ })
325
+
326
+ it('skips padding when container shape contains all other shapes', () => {
327
+ const containerId = createShapeId('container')
328
+ const shape1Id = createShapeId('shape1')
329
+ const shape2Id = createShapeId('shape2')
330
+
331
+ // Container shape that encompasses everything
332
+ editor.createShape({
333
+ id: containerId,
334
+ type: 'test-shape',
335
+ x: 0,
336
+ y: 0,
337
+ props: { w: 100, h: 100, x: 0, y: 0, isContainer: true },
338
+ })
339
+
340
+ // Smaller shapes inside the container
341
+ editor.createShape({
342
+ id: shape1Id,
343
+ type: 'test-shape',
344
+ x: 10,
345
+ y: 10,
346
+ props: { w: 20, h: 20, x: 0, y: 0, isContainer: false },
347
+ })
348
+
349
+ editor.createShape({
350
+ id: shape2Id,
351
+ type: 'test-shape',
352
+ x: 60,
353
+ y: 60,
354
+ props: { w: 30, h: 30, x: 0, y: 0, isContainer: false },
355
+ })
356
+
357
+ const renderingShapes = editor.getUnorderedRenderingShapes(false)
358
+ const testShapes = renderingShapes.filter((s) =>
359
+ [containerId, shape1Id, shape2Id].includes(s.id)
360
+ )
361
+
362
+ const result = getExportDefaultBounds(editor, testShapes, 32, null)
363
+
364
+ expect(result).toBeInstanceOf(Box)
365
+ // Should use container bounds without padding: (0,0) to (100,100)
366
+ expect(result?.x).toBe(0)
367
+ expect(result?.y).toBe(0)
368
+ expect(result?.w).toBe(100)
369
+ expect(result?.h).toBe(100)
370
+ })
371
+
372
+ it('applies padding when container does not contain all shapes', () => {
373
+ const containerId = createShapeId('container')
374
+ const insideShapeId = createShapeId('inside')
375
+ const outsideShapeId = createShapeId('outside')
376
+
377
+ // Small container
378
+ editor.createShape({
379
+ id: containerId,
380
+ type: 'test-shape',
381
+ x: 0,
382
+ y: 0,
383
+ props: { w: 50, h: 50, x: 0, y: 0, isContainer: true },
384
+ })
385
+
386
+ // Shape inside container
387
+ editor.createShape({
388
+ id: insideShapeId,
389
+ type: 'test-shape',
390
+ x: 10,
391
+ y: 10,
392
+ props: { w: 20, h: 20, x: 0, y: 0, isContainer: false },
393
+ })
394
+
395
+ // Shape outside container bounds
396
+ editor.createShape({
397
+ id: outsideShapeId,
398
+ type: 'test-shape',
399
+ x: 70,
400
+ y: 70,
401
+ props: { w: 30, h: 30, x: 0, y: 0, isContainer: false },
402
+ })
403
+
404
+ const renderingShapes = editor.getUnorderedRenderingShapes(false)
405
+ const testShapes = renderingShapes.filter((s) =>
406
+ [containerId, insideShapeId, outsideShapeId].includes(s.id)
407
+ )
408
+
409
+ const result = getExportDefaultBounds(editor, testShapes, 32, null)
410
+
411
+ expect(result).toBeInstanceOf(Box)
412
+ // Total bounds: (0,0) to (100,100), with padding applied
413
+ expect(result?.x).toBe(-32)
414
+ expect(result?.y).toBe(-32)
415
+ expect(result?.w).toBe(100 + 64) // 164
416
+ expect(result?.h).toBe(100 + 64) // 164
417
+ })
418
+
419
+ it('works with multiple containers where one contains all', () => {
420
+ const container1Id = createShapeId('container1')
421
+ const container2Id = createShapeId('container2')
422
+ const shapeId = createShapeId('shape1')
423
+
424
+ // Small container
425
+ editor.createShape({
426
+ id: container1Id,
427
+ type: 'test-shape',
428
+ x: 10,
429
+ y: 10,
430
+ props: { w: 40, h: 40, x: 0, y: 0, isContainer: true },
431
+ })
432
+
433
+ // Large container that contains everything
434
+ editor.createShape({
435
+ id: container2Id,
436
+ type: 'test-shape',
437
+ x: 0,
438
+ y: 0,
439
+ props: { w: 100, h: 100, x: 0, y: 0, isContainer: true },
440
+ })
441
+
442
+ // Shape inside both containers
443
+ editor.createShape({
444
+ id: shapeId,
445
+ type: 'test-shape',
446
+ x: 20,
447
+ y: 20,
448
+ props: { w: 10, h: 10, x: 0, y: 0, isContainer: false },
449
+ })
450
+
451
+ const renderingShapes = editor.getUnorderedRenderingShapes(false)
452
+ const testShapes = renderingShapes.filter((s) =>
453
+ [container1Id, container2Id, shapeId].includes(s.id)
454
+ )
455
+
456
+ const result = getExportDefaultBounds(editor, testShapes, 32, null)
457
+
458
+ expect(result).toBeInstanceOf(Box)
459
+ // Should use the large container's bounds without padding
460
+ expect(result?.x).toBe(0)
461
+ expect(result?.y).toBe(0)
462
+ expect(result?.w).toBe(100)
463
+ expect(result?.h).toBe(100)
464
+ })
465
+
466
+ it('container behavior is overridden by single frame shape', () => {
467
+ const containerId = createShapeId('container')
468
+ const shapeId = createShapeId('shape1')
469
+
470
+ // Container that would normally prevent padding
471
+ editor.createShape({
472
+ id: containerId,
473
+ type: 'test-shape',
474
+ x: 0,
475
+ y: 0,
476
+ props: { w: 100, h: 100, x: 0, y: 0, isContainer: true },
477
+ })
478
+
479
+ // Shape inside container
480
+ editor.createShape({
481
+ id: shapeId,
482
+ type: 'test-shape',
483
+ x: 10,
484
+ y: 10,
485
+ props: { w: 20, h: 20, x: 0, y: 0, isContainer: false },
486
+ })
487
+
488
+ const renderingShapes = editor.getUnorderedRenderingShapes(false)
489
+ const testShapes = renderingShapes.filter((s) => [containerId, shapeId].includes(s.id))
490
+
491
+ // Single frame shape logic takes precedence over container logic
492
+ const result = getExportDefaultBounds(editor, testShapes, 32, containerId)
493
+
494
+ expect(result).toBeInstanceOf(Box)
495
+ // Should use total bounds without padding (single frame overrides container)
496
+ expect(result?.x).toBe(0)
497
+ expect(result?.y).toBe(0)
498
+ expect(result?.w).toBe(100)
499
+ expect(result?.h).toBe(100)
500
+ })
501
+
502
+ it('handles containers with inner shapes correctly', () => {
503
+ const containerId = createShapeId('container')
504
+ const innerShapeId = createShapeId('inner')
505
+
506
+ // Container shape large enough to contain inner shape
507
+ editor.createShape({
508
+ id: containerId,
509
+ type: 'test-shape',
510
+ x: 0,
511
+ y: 0,
512
+ props: { w: 200, h: 120, x: 0, y: 0, isContainer: true },
513
+ })
514
+
515
+ // Shape inside container bounds
516
+ editor.createShape({
517
+ id: innerShapeId,
518
+ type: 'test-shape',
519
+ x: 50,
520
+ y: 20,
521
+ props: { w: 100, h: 60, x: 0, y: 0, isContainer: false },
522
+ })
523
+
524
+ const renderingShapes = editor.getUnorderedRenderingShapes(false)
525
+ const testShapes = renderingShapes.filter((s) => [containerId, innerShapeId].includes(s.id))
526
+
527
+ const result = getExportDefaultBounds(editor, testShapes, 32, null)
528
+
529
+ expect(result).toBeInstanceOf(Box)
530
+ // Container (0,0,200,120) should contain inner shape bounds,
531
+ // so no padding should be applied
532
+ expect(result?.x).toBe(0)
533
+ expect(result?.y).toBe(0)
534
+ expect(result?.w).toBe(200)
535
+ expect(result?.h).toBe(120)
536
+ })
537
+
538
+ it('handles order sensitivity - container processed first', () => {
539
+ const containerId = createShapeId('container')
540
+ const shapeId = createShapeId('shape')
541
+
542
+ // Create container first (will be processed first due to creation order)
543
+ editor.createShape({
544
+ id: containerId,
545
+ type: 'test-shape',
546
+ x: 0,
547
+ y: 0,
548
+ props: { w: 100, h: 100, x: 0, y: 0, isContainer: true },
549
+ })
550
+
551
+ // Create regular shape second
552
+ editor.createShape({
553
+ id: shapeId,
554
+ type: 'test-shape',
555
+ x: 20,
556
+ y: 20,
557
+ props: { w: 30, h: 30, x: 0, y: 0, isContainer: false },
558
+ })
559
+
560
+ const renderingShapes = editor.getUnorderedRenderingShapes(false)
561
+ const testShapes = renderingShapes.filter((s) => [containerId, shapeId].includes(s.id))
562
+
563
+ const result = getExportDefaultBounds(editor, testShapes, 32, null)
564
+
565
+ expect(result).toBeInstanceOf(Box)
566
+ // Container should contain regular shape, no padding applied
567
+ expect(result?.x).toBe(0)
568
+ expect(result?.y).toBe(0)
569
+ expect(result?.w).toBe(100)
570
+ expect(result?.h).toBe(100)
571
+ })
572
+
573
+ it('handles order sensitivity - regular shape processed first', () => {
574
+ const shapeId = createShapeId('shape')
575
+ const containerId = createShapeId('container')
576
+
577
+ // Create regular shape first (will be processed first due to creation order)
578
+ editor.createShape({
579
+ id: shapeId,
580
+ type: 'test-shape',
581
+ x: 20,
582
+ y: 20,
583
+ props: { w: 30, h: 30, x: 0, y: 0, isContainer: false },
584
+ })
585
+
586
+ // Create container second
587
+ editor.createShape({
588
+ id: containerId,
589
+ type: 'test-shape',
590
+ x: 0,
591
+ y: 0,
592
+ props: { w: 100, h: 100, x: 0, y: 0, isContainer: true },
593
+ })
594
+
595
+ const renderingShapes = editor.getUnorderedRenderingShapes(false)
596
+ const testShapes = renderingShapes.filter((s) => [shapeId, containerId].includes(s.id))
597
+
598
+ const result = getExportDefaultBounds(editor, testShapes, 32, null)
599
+
600
+ expect(result).toBeInstanceOf(Box)
601
+ // Container should still contain regular shape, no padding applied
602
+ expect(result?.x).toBe(0)
603
+ expect(result?.y).toBe(0)
604
+ expect(result?.w).toBe(100)
605
+ expect(result?.h).toBe(100)
606
+ })
607
+
608
+ it('multiple containers - only one that contains all others skips padding', () => {
609
+ const smallContainerId = createShapeId('smallContainer')
610
+ const largeContainerId = createShapeId('largeContainer')
611
+ const shapeId = createShapeId('shape')
612
+
613
+ // Small container
614
+ editor.createShape({
615
+ id: smallContainerId,
616
+ type: 'test-shape',
617
+ x: 10,
618
+ y: 10,
619
+ props: { w: 30, h: 30, x: 0, y: 0, isContainer: true },
620
+ })
621
+
622
+ // Large container that contains the small container AND the regular shape
623
+ editor.createShape({
624
+ id: largeContainerId,
625
+ type: 'test-shape',
626
+ x: 0,
627
+ y: 0,
628
+ props: { w: 100, h: 100, x: 0, y: 0, isContainer: true },
629
+ })
630
+
631
+ // Regular shape inside both containers
632
+ editor.createShape({
633
+ id: shapeId,
634
+ type: 'test-shape',
635
+ x: 15,
636
+ y: 15,
637
+ props: { w: 10, h: 10, x: 0, y: 0, isContainer: false },
638
+ })
639
+
640
+ const renderingShapes = editor.getUnorderedRenderingShapes(false)
641
+ const testShapes = renderingShapes.filter((s) =>
642
+ [smallContainerId, largeContainerId, shapeId].includes(s.id)
643
+ )
644
+
645
+ const result = getExportDefaultBounds(editor, testShapes, 32, null)
646
+
647
+ expect(result).toBeInstanceOf(Box)
648
+ // Large container contains everything (including small container), no padding
649
+ expect(result?.x).toBe(0)
650
+ expect(result?.y).toBe(0)
651
+ expect(result?.w).toBe(100)
652
+ expect(result?.h).toBe(100)
653
+ })
654
+
655
+ it('multiple containers - none contains all others, padding applied', () => {
656
+ const container1Id = createShapeId('container1')
657
+ const container2Id = createShapeId('container2')
658
+ const shape1Id = createShapeId('shape1')
659
+ const shape2Id = createShapeId('shape2')
660
+
661
+ // Container 1 contains shape1 but not container2 or shape2
662
+ editor.createShape({
663
+ id: container1Id,
664
+ type: 'test-shape',
665
+ x: 0,
666
+ y: 0,
667
+ props: { w: 40, h: 40, x: 0, y: 0, isContainer: true },
668
+ })
669
+
670
+ // Container 2 contains shape2 but not container1 or shape1
671
+ editor.createShape({
672
+ id: container2Id,
673
+ type: 'test-shape',
674
+ x: 60,
675
+ y: 60,
676
+ props: { w: 40, h: 40, x: 0, y: 0, isContainer: true },
677
+ })
678
+
679
+ // Shape inside container1
680
+ editor.createShape({
681
+ id: shape1Id,
682
+ type: 'test-shape',
683
+ x: 10,
684
+ y: 10,
685
+ props: { w: 20, h: 20, x: 0, y: 0, isContainer: false },
686
+ })
687
+
688
+ // Shape inside container2
689
+ editor.createShape({
690
+ id: shape2Id,
691
+ type: 'test-shape',
692
+ x: 70,
693
+ y: 70,
694
+ props: { w: 20, h: 20, x: 0, y: 0, isContainer: false },
695
+ })
696
+
697
+ const renderingShapes = editor.getUnorderedRenderingShapes(false)
698
+ const testShapes = renderingShapes.filter((s) =>
699
+ [container1Id, container2Id, shape1Id, shape2Id].includes(s.id)
700
+ )
701
+
702
+ const result = getExportDefaultBounds(editor, testShapes, 32, null)
703
+
704
+ expect(result).toBeInstanceOf(Box)
705
+ // No single container contains all others, padding should be applied
706
+ // Total bounds: (0,0) to (100,100), with padding
707
+ expect(result?.x).toBe(-32)
708
+ expect(result?.y).toBe(-32)
709
+ expect(result?.w).toBe(100 + 64) // 164
710
+ expect(result?.h).toBe(100 + 64) // 164
711
+ })
712
+
713
+ it('container covers most but not all shapes - padding applied', () => {
714
+ const containerId = createShapeId('container')
715
+ const insideShapeId = createShapeId('inside')
716
+ const partiallyOutsideId = createShapeId('partiallyOutside')
717
+
718
+ // Container
719
+ editor.createShape({
720
+ id: containerId,
721
+ type: 'test-shape',
722
+ x: 0,
723
+ y: 0,
724
+ props: { w: 80, h: 80, x: 0, y: 0, isContainer: true },
725
+ })
726
+
727
+ // Shape fully inside container
728
+ editor.createShape({
729
+ id: insideShapeId,
730
+ type: 'test-shape',
731
+ x: 20,
732
+ y: 20,
733
+ props: { w: 20, h: 20, x: 0, y: 0, isContainer: false },
734
+ })
735
+
736
+ // Shape that partially extends outside container
737
+ editor.createShape({
738
+ id: partiallyOutsideId,
739
+ type: 'test-shape',
740
+ x: 70,
741
+ y: 70,
742
+ props: { w: 20, h: 20, x: 0, y: 0, isContainer: false },
743
+ })
744
+
745
+ const renderingShapes = editor.getUnorderedRenderingShapes(false)
746
+ const testShapes = renderingShapes.filter((s) =>
747
+ [containerId, insideShapeId, partiallyOutsideId].includes(s.id)
748
+ )
749
+
750
+ const result = getExportDefaultBounds(editor, testShapes, 32, null)
751
+
752
+ expect(result).toBeInstanceOf(Box)
753
+ // Container doesn't contain all shapes, padding applied
754
+ // Total bounds: (0,0) to (90,90), with padding
755
+ expect(result?.x).toBe(-32)
756
+ expect(result?.y).toBe(-32)
757
+ expect(result?.w).toBe(90 + 64) // 154
758
+ expect(result?.h).toBe(90 + 64) // 154
759
+ })
760
+
761
+ it('nested containers - inner container processed first', () => {
762
+ const outerContainerId = createShapeId('outerContainer')
763
+ const innerContainerId = createShapeId('innerContainer')
764
+ const shapeId = createShapeId('shape')
765
+
766
+ // Inner container (created first)
767
+ editor.createShape({
768
+ id: innerContainerId,
769
+ type: 'test-shape',
770
+ x: 20,
771
+ y: 20,
772
+ props: { w: 40, h: 40, x: 0, y: 0, isContainer: true },
773
+ })
774
+
775
+ // Outer container that contains inner container
776
+ editor.createShape({
777
+ id: outerContainerId,
778
+ type: 'test-shape',
779
+ x: 0,
780
+ y: 0,
781
+ props: { w: 100, h: 100, x: 0, y: 0, isContainer: true },
782
+ })
783
+
784
+ // Shape inside inner container
785
+ editor.createShape({
786
+ id: shapeId,
787
+ type: 'test-shape',
788
+ x: 30,
789
+ y: 30,
790
+ props: { w: 20, h: 20, x: 0, y: 0, isContainer: false },
791
+ })
792
+
793
+ const renderingShapes = editor.getUnorderedRenderingShapes(false)
794
+ const testShapes = renderingShapes.filter((s) =>
795
+ [innerContainerId, outerContainerId, shapeId].includes(s.id)
796
+ )
797
+
798
+ const result = getExportDefaultBounds(editor, testShapes, 32, null)
799
+
800
+ expect(result).toBeInstanceOf(Box)
801
+ // Outer container contains everything, should use outer bounds without padding
802
+ expect(result?.x).toBe(0)
803
+ expect(result?.y).toBe(0)
804
+ expect(result?.w).toBe(100)
805
+ expect(result?.h).toBe(100)
806
+ })
807
+
808
+ it('container-only shapes should not skip padding', () => {
809
+ const container1Id = createShapeId('container1')
810
+ const container2Id = createShapeId('container2')
811
+
812
+ // Two containers, neither containing the other completely
813
+ editor.createShape({
814
+ id: container1Id,
815
+ type: 'test-shape',
816
+ x: 0,
817
+ y: 0,
818
+ props: { w: 50, h: 50, x: 0, y: 0, isContainer: true },
819
+ })
820
+
821
+ editor.createShape({
822
+ id: container2Id,
823
+ type: 'test-shape',
824
+ x: 30,
825
+ y: 30,
826
+ props: { w: 50, h: 50, x: 0, y: 0, isContainer: true },
827
+ })
828
+
829
+ const renderingShapes = editor.getUnorderedRenderingShapes(false)
830
+ const testShapes = renderingShapes.filter((s) => [container1Id, container2Id].includes(s.id))
831
+
832
+ const result = getExportDefaultBounds(editor, testShapes, 32, null)
833
+
834
+ expect(result).toBeInstanceOf(Box)
835
+ // Neither container fully contains the other, padding should be applied
836
+ // Total bounds: (0,0) to (80,80), with padding
837
+ expect(result?.x).toBe(-32)
838
+ expect(result?.y).toBe(-32)
839
+ expect(result?.w).toBe(80 + 64) // 144
840
+ expect(result?.h).toBe(80 + 64) // 144
841
+ })
842
+
843
+ it('single container with only itself skips padding', () => {
844
+ const containerId = createShapeId('container')
845
+
846
+ // Single container shape
847
+ editor.createShape({
848
+ id: containerId,
849
+ type: 'test-shape',
850
+ x: 10,
851
+ y: 20,
852
+ props: { w: 100, h: 80, x: 0, y: 0, isContainer: true },
853
+ })
854
+
855
+ const renderingShapes = editor.getUnorderedRenderingShapes(false)
856
+ const testShapes = renderingShapes.filter((s) => s.id === containerId)
857
+
858
+ const result = getExportDefaultBounds(editor, testShapes, 32, null)
859
+
860
+ expect(result).toBeInstanceOf(Box)
861
+ // Single container should skip padding (it trivially contains "all other shapes")
862
+ expect(result?.x).toBe(10)
863
+ expect(result?.y).toBe(20)
864
+ expect(result?.w).toBe(100)
865
+ expect(result?.h).toBe(80)
866
+ })
867
+ })
868
+ })