tldraw 4.3.0 → 4.4.0-canary.29afdff6bb04

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 (34) hide show
  1. package/dist-cjs/index.js +1 -1
  2. package/dist-cjs/lib/shapes/frame/FrameShapeUtil.js +1 -8
  3. package/dist-cjs/lib/shapes/frame/FrameShapeUtil.js.map +2 -2
  4. package/dist-cjs/lib/tools/EraserTool/childStates/Erasing.js +8 -1
  5. package/dist-cjs/lib/tools/EraserTool/childStates/Erasing.js.map +2 -2
  6. package/dist-cjs/lib/tools/SelectTool/childStates/Brushing.js +14 -1
  7. package/dist-cjs/lib/tools/SelectTool/childStates/Brushing.js.map +3 -3
  8. package/dist-cjs/lib/tools/SelectTool/childStates/ScribbleBrushing.js +15 -2
  9. package/dist-cjs/lib/tools/SelectTool/childStates/ScribbleBrushing.js.map +3 -3
  10. package/dist-cjs/lib/ui/components/DefaultDebugPanel.js +1 -1
  11. package/dist-cjs/lib/ui/components/DefaultDebugPanel.js.map +2 -2
  12. package/dist-cjs/lib/ui/version.js +3 -3
  13. package/dist-cjs/lib/ui/version.js.map +1 -1
  14. package/dist-esm/index.mjs +1 -1
  15. package/dist-esm/lib/shapes/frame/FrameShapeUtil.mjs +1 -8
  16. package/dist-esm/lib/shapes/frame/FrameShapeUtil.mjs.map +2 -2
  17. package/dist-esm/lib/tools/EraserTool/childStates/Erasing.mjs +9 -1
  18. package/dist-esm/lib/tools/EraserTool/childStates/Erasing.mjs.map +2 -2
  19. package/dist-esm/lib/tools/SelectTool/childStates/Brushing.mjs +14 -1
  20. package/dist-esm/lib/tools/SelectTool/childStates/Brushing.mjs.map +3 -3
  21. package/dist-esm/lib/tools/SelectTool/childStates/ScribbleBrushing.mjs +16 -2
  22. package/dist-esm/lib/tools/SelectTool/childStates/ScribbleBrushing.mjs.map +3 -3
  23. package/dist-esm/lib/ui/components/DefaultDebugPanel.mjs +1 -1
  24. package/dist-esm/lib/ui/components/DefaultDebugPanel.mjs.map +2 -2
  25. package/dist-esm/lib/ui/version.mjs +3 -3
  26. package/dist-esm/lib/ui/version.mjs.map +1 -1
  27. package/package.json +3 -3
  28. package/src/lib/shapes/frame/FrameShapeUtil.tsx +1 -7
  29. package/src/lib/tools/EraserTool/childStates/Erasing.ts +14 -1
  30. package/src/lib/tools/SelectTool/childStates/Brushing.ts +22 -3
  31. package/src/lib/tools/SelectTool/childStates/ScribbleBrushing.ts +25 -4
  32. package/src/lib/ui/components/DefaultDebugPanel.tsx +1 -1
  33. package/src/lib/ui/version.ts +3 -3
  34. package/src/test/notVisibleShapes.test.ts +698 -0
@@ -1,9 +1,9 @@
1
1
  // This file is automatically generated by internal/scripts/refresh-assets.ts.
2
2
  // Do not edit manually. Or do, I'm a comment, not a cop.
3
3
 
4
- export const version = '4.3.0'
4
+ export const version = '4.4.0-canary.29afdff6bb04'
5
5
  export const publishDates = {
6
6
  major: '2025-09-18T14:39:22.803Z',
7
- minor: '2026-01-21T11:56:39.106Z',
8
- patch: '2026-01-21T11:56:39.106Z',
7
+ minor: '2026-01-21T13:27:27.357Z',
8
+ patch: '2026-01-21T13:27:27.357Z',
9
9
  }
@@ -0,0 +1,698 @@
1
+ import {
2
+ Box,
3
+ Geometry2d,
4
+ PageRecordType,
5
+ RecordProps,
6
+ Rectangle2d,
7
+ ShapeUtil,
8
+ T,
9
+ TLShape,
10
+ createShapeId,
11
+ } from '@tldraw/editor'
12
+ import { TestEditor } from './TestEditor'
13
+
14
+ let editor: TestEditor
15
+
16
+ beforeEach(() => {
17
+ editor = new TestEditor()
18
+ editor.updateViewportScreenBounds(new Box(0, 0, 1000, 1000))
19
+ editor.setCamera({ x: 0, y: 0, z: 1 })
20
+ })
21
+
22
+ afterEach(() => {
23
+ editor?.dispose()
24
+ })
25
+
26
+ // Custom test shape for testing canCull behavior
27
+ declare module '@tldraw/tlschema' {
28
+ export interface TLGlobalShapePropsMap {
29
+ 'not-visible-test-shape': { w: number; h: number; canCull: boolean }
30
+ }
31
+ }
32
+
33
+ type ITestShape = TLShape<'not-visible-test-shape'>
34
+
35
+ class TestShape extends ShapeUtil<ITestShape> {
36
+ static override type = 'not-visible-test-shape' as const
37
+ static override props: RecordProps<ITestShape> = {
38
+ w: T.number,
39
+ h: T.number,
40
+ canCull: T.boolean,
41
+ }
42
+ getDefaultProps(): ITestShape['props'] {
43
+ return { w: 100, h: 100, canCull: true }
44
+ }
45
+ getGeometry(shape: ITestShape): Geometry2d {
46
+ return new Rectangle2d({
47
+ width: shape.props.w,
48
+ height: shape.props.h,
49
+ isFilled: false,
50
+ })
51
+ }
52
+ override canCull(shape: ITestShape): boolean {
53
+ return shape.props.canCull
54
+ }
55
+ override canEdit() {
56
+ return true
57
+ }
58
+ indicator() {}
59
+ component() {}
60
+ }
61
+
62
+ describe('notVisibleShapes - basic culling', () => {
63
+ it('should identify shapes outside viewport', () => {
64
+ const insideId = createShapeId('inside')
65
+ const outsideId = createShapeId('outside')
66
+
67
+ editor.createShapes([
68
+ { id: insideId, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } },
69
+ { id: outsideId, type: 'geo', x: 2000, y: 2000, props: { w: 100, h: 100 } },
70
+ ])
71
+
72
+ const notVisible = editor.getNotVisibleShapes()
73
+
74
+ expect(notVisible.has(insideId)).toBe(false)
75
+ expect(notVisible.has(outsideId)).toBe(true)
76
+ })
77
+
78
+ it('should update when shapes move in/out of viewport', () => {
79
+ const shapeId = createShapeId('moving')
80
+ editor.createShapes([{ id: shapeId, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } }])
81
+
82
+ // Initially visible
83
+ let notVisible = editor.getNotVisibleShapes()
84
+ expect(notVisible.has(shapeId)).toBe(false)
85
+
86
+ // Move outside viewport
87
+ editor.updateShapes([{ id: shapeId, type: 'geo', x: 2000, y: 2000 }])
88
+ notVisible = editor.getNotVisibleShapes()
89
+ expect(notVisible.has(shapeId)).toBe(true)
90
+
91
+ // Move back inside
92
+ editor.updateShapes([{ id: shapeId, type: 'geo', x: 100, y: 100 }])
93
+ notVisible = editor.getNotVisibleShapes()
94
+ expect(notVisible.has(shapeId)).toBe(false)
95
+ })
96
+
97
+ it('should update when viewport moves', () => {
98
+ const shapeId = createShapeId('stationary')
99
+ editor.createShapes([{ id: shapeId, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } }])
100
+
101
+ // Initially visible
102
+ let notVisible = editor.getNotVisibleShapes()
103
+ expect(notVisible.has(shapeId)).toBe(false)
104
+
105
+ // Pan viewport away from shape
106
+ editor.setCamera({ x: -2000, y: -2000, z: 1 })
107
+ notVisible = editor.getNotVisibleShapes()
108
+ expect(notVisible.has(shapeId)).toBe(true)
109
+
110
+ // Pan back
111
+ editor.setCamera({ x: 0, y: 0, z: 1 })
112
+ notVisible = editor.getNotVisibleShapes()
113
+ expect(notVisible.has(shapeId)).toBe(false)
114
+ })
115
+
116
+ it('should update when zoom level changes', () => {
117
+ const shapeId = createShapeId('shape')
118
+
119
+ // Create shape just outside initial viewport
120
+ editor.createShapes([{ id: shapeId, type: 'geo', x: 1100, y: 500, props: { w: 100, h: 100 } }])
121
+
122
+ // Initially outside
123
+ let notVisible = editor.getNotVisibleShapes()
124
+ expect(notVisible.has(shapeId)).toBe(true)
125
+
126
+ // Zoom out - viewport bounds expand, shape becomes visible
127
+ editor.setCamera({ x: 0, y: 0, z: 0.5 })
128
+ notVisible = editor.getNotVisibleShapes()
129
+ expect(notVisible.has(shapeId)).toBe(false)
130
+
131
+ // Zoom back in
132
+ editor.setCamera({ x: 0, y: 0, z: 1 })
133
+ notVisible = editor.getNotVisibleShapes()
134
+ expect(notVisible.has(shapeId)).toBe(true)
135
+ })
136
+
137
+ it('should keep very large shape visible when partially in viewport', () => {
138
+ const largeShapeId = createShapeId('large')
139
+
140
+ // Create massive shape that extends far beyond viewport
141
+ editor.createShapes([
142
+ {
143
+ id: largeShapeId,
144
+ type: 'geo',
145
+ x: -5000,
146
+ y: -5000,
147
+ props: { w: 10000, h: 10000 },
148
+ },
149
+ ])
150
+
151
+ // Shape should be visible (viewport is inside it)
152
+ const notVisible = editor.getNotVisibleShapes()
153
+ expect(notVisible.has(largeShapeId)).toBe(false)
154
+ })
155
+ })
156
+
157
+ describe('notVisibleShapes - canCull behavior', () => {
158
+ it('should not cull shapes that return false from canCull', () => {
159
+ // Register TestShape temporarily for this test
160
+ const testEditor = new TestEditor({ shapeUtils: [TestShape] })
161
+ testEditor.updateViewportScreenBounds(new Box(0, 0, 1000, 1000))
162
+ testEditor.setCamera({ x: 0, y: 0, z: 1 })
163
+
164
+ const cullableId = createShapeId('cullable')
165
+ const nonCullableId = createShapeId('non-cullable')
166
+
167
+ testEditor.createShapes([
168
+ {
169
+ id: cullableId,
170
+ type: 'not-visible-test-shape',
171
+ x: 2000,
172
+ y: 2000,
173
+ props: { canCull: true },
174
+ },
175
+ {
176
+ id: nonCullableId,
177
+ type: 'not-visible-test-shape',
178
+ x: 2000,
179
+ y: 2000,
180
+ props: { canCull: false },
181
+ },
182
+ ])
183
+
184
+ const notVisible = testEditor.getNotVisibleShapes()
185
+
186
+ expect(notVisible.has(cullableId)).toBe(true)
187
+ expect(notVisible.has(nonCullableId)).toBe(false)
188
+
189
+ testEditor.dispose()
190
+ })
191
+ })
192
+
193
+ describe('notVisibleShapes - selected shapes', () => {
194
+ it('should not cull selected shapes even if outside viewport', () => {
195
+ const shapeId = createShapeId('selected')
196
+ editor.createShapes([{ id: shapeId, type: 'geo', x: 2000, y: 2000, props: { w: 100, h: 100 } }])
197
+
198
+ // Not selected - should be in notVisible and culled
199
+ let notVisible = editor.getNotVisibleShapes()
200
+ let culled = editor.getCulledShapes()
201
+ expect(notVisible.has(shapeId)).toBe(true)
202
+ expect(culled.has(shapeId)).toBe(true)
203
+
204
+ // Select it - still in notVisible but not culled
205
+ editor.select(shapeId)
206
+ notVisible = editor.getNotVisibleShapes()
207
+ culled = editor.getCulledShapes()
208
+ expect(notVisible.has(shapeId)).toBe(true)
209
+ expect(culled.has(shapeId)).toBe(false)
210
+
211
+ // Deselect - back in culled
212
+ editor.selectNone()
213
+ notVisible = editor.getNotVisibleShapes()
214
+ culled = editor.getCulledShapes()
215
+ expect(notVisible.has(shapeId)).toBe(true)
216
+ expect(culled.has(shapeId)).toBe(true)
217
+ })
218
+ })
219
+
220
+ describe('notVisibleShapes - caching', () => {
221
+ it('should return same Set object when contents unchanged', () => {
222
+ editor.createShapes([{ id: createShapeId('shape1'), type: 'geo', x: 2000, y: 2000 }])
223
+
224
+ const notVisible1 = editor.getNotVisibleShapes()
225
+ const notVisible2 = editor.getNotVisibleShapes()
226
+
227
+ // Should return same reference when nothing changed
228
+ expect(notVisible1).toBe(notVisible2)
229
+ })
230
+
231
+ it('should return new Set object when contents change', () => {
232
+ const shapeId = createShapeId('moving')
233
+ editor.createShapes([{ id: shapeId, type: 'geo', x: 2000, y: 2000 }])
234
+
235
+ const notVisible1 = editor.getNotVisibleShapes()
236
+
237
+ // Move shape into viewport
238
+ editor.updateShapes([{ id: shapeId, type: 'geo', x: 100, y: 100 }])
239
+
240
+ const notVisible2 = editor.getNotVisibleShapes()
241
+
242
+ // Should return different reference when contents changed
243
+ expect(notVisible1).not.toBe(notVisible2)
244
+ })
245
+ })
246
+
247
+ describe('notVisibleShapes - multiple pages', () => {
248
+ it('should only cull shapes on current page', () => {
249
+ const page1 = editor.getCurrentPageId()
250
+
251
+ // Create shapes on page 1
252
+ const page1Shape1 = createShapeId('page1-inside')
253
+ const page1Shape2 = createShapeId('page1-outside')
254
+ editor.createShapes([
255
+ { id: page1Shape1, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } },
256
+ { id: page1Shape2, type: 'geo', x: 2000, y: 2000, props: { w: 100, h: 100 } },
257
+ ])
258
+
259
+ // Create page 2
260
+ const page2 = PageRecordType.createId('page2')
261
+ editor.createPage({ name: 'page2', id: page2 })
262
+ editor.setCurrentPage(page2)
263
+
264
+ // Create shapes on page 2
265
+ const page2Shape1 = createShapeId('page2-inside')
266
+ const page2Shape2 = createShapeId('page2-outside')
267
+ editor.createShapes([
268
+ { id: page2Shape1, type: 'geo', x: 200, y: 200, props: { w: 100, h: 100 } },
269
+ { id: page2Shape2, type: 'geo', x: 3000, y: 3000, props: { w: 100, h: 100 } },
270
+ ])
271
+
272
+ // Check page 2 culling
273
+ let notVisible = editor.getNotVisibleShapes()
274
+ expect(notVisible.has(page2Shape1)).toBe(false)
275
+ expect(notVisible.has(page2Shape2)).toBe(true)
276
+ // Page 1 shapes should not be in the set at all
277
+ expect(notVisible.has(page1Shape1)).toBe(false)
278
+ expect(notVisible.has(page1Shape2)).toBe(false)
279
+
280
+ // Switch back to page 1
281
+ editor.setCurrentPage(page1)
282
+
283
+ // Check page 1 culling
284
+ notVisible = editor.getNotVisibleShapes()
285
+ expect(notVisible.has(page1Shape1)).toBe(false)
286
+ expect(notVisible.has(page1Shape2)).toBe(true)
287
+ // Page 2 shapes should not be in the set
288
+ expect(notVisible.has(page2Shape1)).toBe(false)
289
+ expect(notVisible.has(page2Shape2)).toBe(false)
290
+ })
291
+
292
+ it('should maintain separate spatial indexes per page', () => {
293
+ const page1 = editor.getCurrentPageId()
294
+
295
+ // Create many shapes on page 1
296
+ for (let i = 0; i < 100; i++) {
297
+ editor.createShapes([
298
+ {
299
+ id: createShapeId(`page1-shape-${i}`),
300
+ type: 'geo',
301
+ x: (i % 10) * 200,
302
+ y: Math.floor(i / 10) * 200,
303
+ },
304
+ ])
305
+ }
306
+
307
+ // Create page 2
308
+ const page2 = PageRecordType.createId('page2')
309
+ editor.createPage({ name: 'page2', id: page2 })
310
+ editor.setCurrentPage(page2)
311
+
312
+ // Create different shapes on page 2
313
+ for (let i = 0; i < 50; i++) {
314
+ editor.createShapes([
315
+ {
316
+ id: createShapeId(`page2-shape-${i}`),
317
+ type: 'geo',
318
+ x: (i % 5) * 300,
319
+ y: Math.floor(i / 5) * 300,
320
+ },
321
+ ])
322
+ }
323
+
324
+ // Check page 2
325
+ const notVisiblePage2 = editor.getNotVisibleShapes()
326
+ const page2ShapeIds = editor.getCurrentPageShapeIds()
327
+ expect(page2ShapeIds.size).toBe(50)
328
+
329
+ // Switch to page 1
330
+ editor.setCurrentPage(page1)
331
+ const notVisiblePage1 = editor.getNotVisibleShapes()
332
+ const page1ShapeIds = editor.getCurrentPageShapeIds()
333
+ expect(page1ShapeIds.size).toBe(100)
334
+
335
+ // Results should be different (different shapes on each page)
336
+ expect(notVisiblePage1.size).not.toBe(notVisiblePage2.size)
337
+
338
+ // No page 2 shapes should appear in page 1 results
339
+ for (const id of notVisiblePage1) {
340
+ expect(id.includes('page2')).toBe(false)
341
+ }
342
+
343
+ // No page 1 shapes should appear in page 2 results
344
+ for (const id of notVisiblePage2) {
345
+ expect(id.includes('page1')).toBe(false)
346
+ }
347
+ })
348
+
349
+ it('should update indexes when switching pages', () => {
350
+ const page1 = editor.getCurrentPageId()
351
+
352
+ // Create shape outside viewport on page 1
353
+ const page1OutsideShape = createShapeId('page1-outside')
354
+ editor.createShapes([{ id: page1OutsideShape, type: 'geo', x: 2000, y: 2000 }])
355
+
356
+ // Verify it's culled
357
+ let notVisible = editor.getNotVisibleShapes()
358
+ expect(notVisible.has(page1OutsideShape)).toBe(true)
359
+
360
+ // Create page 2 and switch to it
361
+ const page2 = PageRecordType.createId('page2')
362
+ editor.createPage({ name: 'page2', id: page2 })
363
+ editor.setCurrentPage(page2)
364
+
365
+ // Create shape inside viewport on page 2
366
+ const page2InsideShape = createShapeId('page2-inside')
367
+ editor.createShapes([{ id: page2InsideShape, type: 'geo', x: 100, y: 100 }])
368
+
369
+ // Page 2 shape should not be culled
370
+ notVisible = editor.getNotVisibleShapes()
371
+ expect(notVisible.has(page2InsideShape)).toBe(false)
372
+ // Page 1 shape should not appear in results
373
+ expect(notVisible.has(page1OutsideShape)).toBe(false)
374
+
375
+ // Switch back to page 1
376
+ editor.setCurrentPage(page1)
377
+
378
+ // Page 1 shape should still be culled
379
+ notVisible = editor.getNotVisibleShapes()
380
+ expect(notVisible.has(page1OutsideShape)).toBe(true)
381
+ // Page 2 shape should not appear
382
+ expect(notVisible.has(page2InsideShape)).toBe(false)
383
+ })
384
+ })
385
+
386
+ describe('notVisibleShapes - arrows with bindings', () => {
387
+ it('should not cull selected arrow even if outside viewport', () => {
388
+ // Create arrow outside viewport
389
+ editor.setCurrentTool('arrow')
390
+ editor.pointerDown(2000, 2000)
391
+ editor.pointerMove(2200, 2000)
392
+ editor.pointerUp(2200, 2000)
393
+
394
+ const arrow = editor.getCurrentPageShapes().find((s) => s.type === 'arrow')!
395
+ expect(arrow).toBeDefined()
396
+
397
+ // Arrow outside viewport, selected by arrow tool
398
+ let notVisible = editor.getNotVisibleShapes()
399
+ let culled = editor.getCulledShapes()
400
+ expect(notVisible.has(arrow.id)).toBe(true)
401
+ // Arrow is selected after creation, so not culled
402
+ expect(culled.has(arrow.id)).toBe(false)
403
+
404
+ // Deselect arrow
405
+ editor.selectNone()
406
+ notVisible = editor.getNotVisibleShapes()
407
+ culled = editor.getCulledShapes()
408
+ expect(notVisible.has(arrow.id)).toBe(true)
409
+ expect(culled.has(arrow.id)).toBe(true) // Now culled
410
+
411
+ // Select arrow again - should not be culled
412
+ editor.select(arrow.id)
413
+ notVisible = editor.getNotVisibleShapes()
414
+ culled = editor.getCulledShapes()
415
+ expect(notVisible.has(arrow.id)).toBe(true)
416
+ expect(culled.has(arrow.id)).toBe(false) // Not culled because selected
417
+ })
418
+
419
+ it('should make arrow visible when bound shapes move into viewport', () => {
420
+ const boxAId = createShapeId('boxA')
421
+ const boxBId = createShapeId('boxB')
422
+
423
+ // Create two boxes outside viewport
424
+ editor.createShapes([
425
+ { id: boxAId, type: 'geo', x: 2000, y: 2000, props: { w: 100, h: 100 } },
426
+ { id: boxBId, type: 'geo', x: 2200, y: 2000, props: { w: 100, h: 100 } },
427
+ ])
428
+
429
+ // Draw arrow between them (arrow is also outside viewport)
430
+ editor.setCurrentTool('arrow')
431
+ editor.pointerDown(2050, 2050)
432
+ editor.pointerMove(2250, 2050)
433
+ editor.pointerUp(2250, 2050)
434
+
435
+ const arrow = editor.getCurrentPageShapes().find((s) => s.type === 'arrow')!
436
+ expect(arrow).toBeDefined()
437
+
438
+ // Deselect all
439
+ editor.selectNone()
440
+
441
+ // Verify all invisible initially
442
+ let notVisible = editor.getNotVisibleShapes()
443
+ expect(notVisible.has(boxAId)).toBe(true)
444
+ expect(notVisible.has(boxBId)).toBe(true)
445
+ expect(notVisible.has(arrow.id)).toBe(true)
446
+
447
+ // Move bound shapes INTO viewport (arrow record doesn't change, but bounds do)
448
+ editor.updateShapes([
449
+ { id: boxAId, type: 'geo', x: 100, y: 100 },
450
+ { id: boxBId, type: 'geo', x: 300, y: 100 },
451
+ ])
452
+
453
+ // CRITICAL: Arrow should now be visible even though arrow shape didn't update
454
+ notVisible = editor.getNotVisibleShapes()
455
+ expect(notVisible.has(boxAId)).toBe(false)
456
+ expect(notVisible.has(boxBId)).toBe(false)
457
+ expect(notVisible.has(arrow.id)).toBe(false) // Arrow visible due to reactive bounds
458
+ })
459
+
460
+ it('should update arrow visibility when only one bound shape moves', () => {
461
+ const boxAId = createShapeId('boxA')
462
+ const boxBId = createShapeId('boxB')
463
+
464
+ // Create boxes inside viewport
465
+ editor.createShapes([
466
+ { id: boxAId, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } },
467
+ { id: boxBId, type: 'geo', x: 300, y: 100, props: { w: 100, h: 100 } },
468
+ ])
469
+
470
+ // Create arrow
471
+ editor.setCurrentTool('arrow')
472
+ editor.pointerDown(150, 150)
473
+ editor.pointerMove(350, 150)
474
+ editor.pointerUp(350, 150)
475
+
476
+ const arrow = editor.getCurrentPageShapes().find((s) => s.type === 'arrow')!
477
+ editor.selectNone()
478
+
479
+ // All visible
480
+ let notVisible = editor.getNotVisibleShapes()
481
+ expect(notVisible.has(arrow.id)).toBe(false)
482
+
483
+ // Move only boxA outside viewport (boxB stays visible)
484
+ editor.updateShapes([{ id: boxAId, type: 'geo', x: 2000, y: 2000 }])
485
+ notVisible = editor.getNotVisibleShapes()
486
+
487
+ // Arrow should still be visible (one endpoint visible)
488
+ expect(notVisible.has(boxAId)).toBe(true)
489
+ expect(notVisible.has(boxBId)).toBe(false)
490
+ expect(notVisible.has(arrow.id)).toBe(false)
491
+ })
492
+
493
+ it('should keep arrow visible when endpoints are in viewport but body extends outside', () => {
494
+ const boxAId = createShapeId('boxA')
495
+ const boxBId = createShapeId('boxB')
496
+
497
+ // Create boxes near opposite edges of viewport
498
+ editor.createShapes([
499
+ { id: boxAId, type: 'geo', x: 50, y: 500, props: { w: 100, h: 100 } },
500
+ { id: boxBId, type: 'geo', x: 850, y: 500, props: { w: 100, h: 100 } },
501
+ ])
502
+
503
+ // Create curved arrow that might extend outside viewport
504
+ editor.setCurrentTool('arrow')
505
+ editor.pointerDown(100, 550)
506
+ editor.pointerMove(900, 550)
507
+ editor.pointerUp(900, 550)
508
+
509
+ const arrow = editor.getCurrentPageShapes().find((s) => s.type === 'arrow')!
510
+
511
+ // Make it curved so body might extend outside
512
+ editor.updateShapes([
513
+ {
514
+ id: arrow.id,
515
+ type: 'arrow',
516
+ props: { bend: 100 }, // Significant curve
517
+ },
518
+ ])
519
+
520
+ editor.selectNone()
521
+ const notVisible = editor.getNotVisibleShapes()
522
+
523
+ // Arrow should be visible since endpoints are visible
524
+ expect(notVisible.has(arrow.id)).toBe(false)
525
+ })
526
+ })
527
+
528
+ describe('notVisibleShapes - frames', () => {
529
+ it('should cull frame when outside viewport', () => {
530
+ const frameId = createShapeId('frame')
531
+
532
+ // Create frame outside viewport
533
+ editor.createShapes([
534
+ { id: frameId, type: 'frame', x: 2000, y: 2000, props: { w: 500, h: 500 } },
535
+ ])
536
+
537
+ // Frame should be not visible
538
+ const notVisible = editor.getNotVisibleShapes()
539
+ expect(notVisible.has(frameId)).toBe(true)
540
+ })
541
+
542
+ it('should keep frame visible when inside viewport', () => {
543
+ const frameId = createShapeId('frame')
544
+
545
+ // Create frame inside viewport
546
+ editor.createShapes([{ id: frameId, type: 'frame', x: 100, y: 100, props: { w: 500, h: 500 } }])
547
+
548
+ // Frame should be visible
549
+ const notVisible = editor.getNotVisibleShapes()
550
+ expect(notVisible.has(frameId)).toBe(false)
551
+ })
552
+
553
+ it('should cull children when frame is outside viewport', () => {
554
+ const frameId = createShapeId('frame')
555
+ const childId = createShapeId('child')
556
+
557
+ // Frame outside viewport
558
+ editor.createShapes([
559
+ { id: frameId, type: 'frame', x: 2000, y: 2000, props: { w: 500, h: 500 } },
560
+ ])
561
+
562
+ // Child inside frame
563
+ editor.createShapes([
564
+ {
565
+ id: childId,
566
+ type: 'geo',
567
+ x: 2100,
568
+ y: 2100,
569
+ parentId: frameId,
570
+ props: { w: 100, h: 100 },
571
+ },
572
+ ])
573
+
574
+ const notVisible = editor.getNotVisibleShapes()
575
+
576
+ // Both should be outside viewport
577
+ expect(notVisible.has(frameId)).toBe(true)
578
+ expect(notVisible.has(childId)).toBe(true)
579
+ })
580
+
581
+ it('should handle multiple levels of nesting', () => {
582
+ const frameId = createShapeId('frame')
583
+ const groupId = createShapeId('group')
584
+ const childId = createShapeId('child')
585
+
586
+ // Frame inside viewport
587
+ editor.createShapes([{ id: frameId, type: 'frame', x: 100, y: 100, props: { w: 500, h: 500 } }])
588
+
589
+ // Create shapes inside frame to form a group
590
+ editor.createShapes([
591
+ { id: groupId, type: 'geo', x: 200, y: 200, parentId: frameId, props: { w: 100, h: 100 } },
592
+ { id: childId, type: 'geo', x: 250, y: 200, parentId: frameId, props: { w: 100, h: 100 } },
593
+ ])
594
+
595
+ // Group them (now they're nested inside frame)
596
+ editor.select(groupId, childId)
597
+ const actualGroupId = createShapeId('actual-group')
598
+ editor.groupShapes(editor.getSelectedShapeIds(), { groupId: actualGroupId })
599
+
600
+ editor.selectNone()
601
+
602
+ // All should be visible
603
+ let notVisible = editor.getNotVisibleShapes()
604
+ expect(notVisible.has(frameId)).toBe(false)
605
+ expect(notVisible.has(actualGroupId)).toBe(false)
606
+
607
+ // Move frame outside viewport
608
+ editor.updateShapes([{ id: frameId, type: 'frame', x: 3000, y: 3000 }])
609
+
610
+ notVisible = editor.getNotVisibleShapes()
611
+
612
+ // All should now be invisible
613
+ expect(notVisible.has(frameId)).toBe(true)
614
+ expect(notVisible.has(actualGroupId)).toBe(true)
615
+ })
616
+ })
617
+
618
+ describe('notVisibleShapes - groups', () => {
619
+ it('should keep group visible when any child is visible', () => {
620
+ const groupId = createShapeId('group')
621
+ const childAId = createShapeId('childA')
622
+ const childBId = createShapeId('childB')
623
+
624
+ // Create shapes - one inside, one outside viewport
625
+ editor.createShapes([
626
+ { id: childAId, type: 'geo', x: 2000, y: 2000, props: { w: 100, h: 100 } },
627
+ { id: childBId, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } },
628
+ ])
629
+
630
+ // Group the shapes
631
+ editor.select(childAId, childBId)
632
+ editor.groupShapes(editor.getSelectedShapeIds(), { groupId })
633
+
634
+ editor.selectNone()
635
+
636
+ // Group has visible child, so group bounds include visible area
637
+ const notVisible = editor.getNotVisibleShapes()
638
+ expect(notVisible.has(groupId)).toBe(false) // Group visible because childB visible
639
+ expect(notVisible.has(childBId)).toBe(false) // childB is visible
640
+ expect(notVisible.has(childAId)).toBe(true) // childA is outside
641
+ })
642
+
643
+ it('should cull group when all children are outside viewport', () => {
644
+ const groupId = createShapeId('group')
645
+ const childAId = createShapeId('childA')
646
+ const childBId = createShapeId('childB')
647
+
648
+ // Create group with all children outside viewport
649
+ editor.createShapes([
650
+ { id: childAId, type: 'geo', x: 2000, y: 2000, props: { w: 100, h: 100 } },
651
+ { id: childBId, type: 'geo', x: 2200, y: 2200, props: { w: 100, h: 100 } },
652
+ ])
653
+
654
+ // Group the shapes
655
+ editor.select(childAId, childBId)
656
+ editor.groupShapes(editor.getSelectedShapeIds(), { groupId })
657
+
658
+ // All should be not visible
659
+ const notVisible = editor.getNotVisibleShapes()
660
+ expect(notVisible.has(groupId)).toBe(true)
661
+ expect(notVisible.has(childAId)).toBe(true)
662
+ expect(notVisible.has(childBId)).toBe(true)
663
+ })
664
+
665
+ it('should update visibility when children move', () => {
666
+ const groupId = createShapeId('group')
667
+ const childAId = createShapeId('childA')
668
+ const childBId = createShapeId('childB')
669
+
670
+ // Create group with all children inside viewport
671
+ editor.createShapes([
672
+ { id: childAId, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } },
673
+ { id: childBId, type: 'geo', x: 200, y: 200, props: { w: 100, h: 100 } },
674
+ ])
675
+
676
+ // Group the shapes
677
+ editor.select(childAId, childBId)
678
+ editor.groupShapes(editor.getSelectedShapeIds(), { groupId })
679
+
680
+ // Initially visible
681
+ let notVisible = editor.getNotVisibleShapes()
682
+ // Group is selected after creation, so not checking it
683
+ expect(notVisible.has(childAId)).toBe(false)
684
+ expect(notVisible.has(childBId)).toBe(false)
685
+
686
+ // Deselect and move both children outside viewport
687
+ editor.selectNone()
688
+ editor.updateShapes([
689
+ { id: childAId, type: 'geo', x: 2000, y: 2000 },
690
+ { id: childBId, type: 'geo', x: 2200, y: 2200 },
691
+ ])
692
+ notVisible = editor.getNotVisibleShapes()
693
+ // All should now be not visible
694
+ expect(notVisible.has(groupId)).toBe(true)
695
+ expect(notVisible.has(childAId)).toBe(true)
696
+ expect(notVisible.has(childBId)).toBe(true)
697
+ })
698
+ })