tldraw 4.3.0 → 4.4.0-canary.6f91153ede5e
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.
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/shapes/frame/FrameShapeUtil.js +1 -8
- package/dist-cjs/lib/shapes/frame/FrameShapeUtil.js.map +2 -2
- package/dist-cjs/lib/tools/EraserTool/childStates/Erasing.js +8 -1
- package/dist-cjs/lib/tools/EraserTool/childStates/Erasing.js.map +2 -2
- package/dist-cjs/lib/tools/SelectTool/childStates/Brushing.js +14 -1
- package/dist-cjs/lib/tools/SelectTool/childStates/Brushing.js.map +3 -3
- package/dist-cjs/lib/tools/SelectTool/childStates/ScribbleBrushing.js +15 -2
- package/dist-cjs/lib/tools/SelectTool/childStates/ScribbleBrushing.js.map +3 -3
- package/dist-cjs/lib/ui/components/DefaultDebugPanel.js +1 -1
- package/dist-cjs/lib/ui/components/DefaultDebugPanel.js.map +2 -2
- package/dist-cjs/lib/ui/version.js +3 -3
- package/dist-cjs/lib/ui/version.js.map +1 -1
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/shapes/frame/FrameShapeUtil.mjs +1 -8
- package/dist-esm/lib/shapes/frame/FrameShapeUtil.mjs.map +2 -2
- package/dist-esm/lib/tools/EraserTool/childStates/Erasing.mjs +9 -1
- package/dist-esm/lib/tools/EraserTool/childStates/Erasing.mjs.map +2 -2
- package/dist-esm/lib/tools/SelectTool/childStates/Brushing.mjs +14 -1
- package/dist-esm/lib/tools/SelectTool/childStates/Brushing.mjs.map +3 -3
- package/dist-esm/lib/tools/SelectTool/childStates/ScribbleBrushing.mjs +16 -2
- package/dist-esm/lib/tools/SelectTool/childStates/ScribbleBrushing.mjs.map +3 -3
- package/dist-esm/lib/ui/components/DefaultDebugPanel.mjs +1 -1
- package/dist-esm/lib/ui/components/DefaultDebugPanel.mjs.map +2 -2
- package/dist-esm/lib/ui/version.mjs +3 -3
- package/dist-esm/lib/ui/version.mjs.map +1 -1
- package/package.json +3 -3
- package/src/lib/shapes/frame/FrameShapeUtil.tsx +1 -7
- package/src/lib/tools/EraserTool/childStates/Erasing.ts +14 -1
- package/src/lib/tools/SelectTool/childStates/Brushing.ts +22 -3
- package/src/lib/tools/SelectTool/childStates/ScribbleBrushing.ts +25 -4
- package/src/lib/ui/components/DefaultDebugPanel.tsx +1 -1
- package/src/lib/ui/version.ts +3 -3
- package/src/test/notVisibleShapes.test.ts +698 -0
package/src/lib/ui/version.ts
CHANGED
|
@@ -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.
|
|
4
|
+
export const version = '4.4.0-canary.6f91153ede5e'
|
|
5
5
|
export const publishDates = {
|
|
6
6
|
major: '2025-09-18T14:39:22.803Z',
|
|
7
|
-
minor: '2026-01-
|
|
8
|
-
patch: '2026-01-
|
|
7
|
+
minor: '2026-01-21T12:39:00.559Z',
|
|
8
|
+
patch: '2026-01-21T12:39:00.559Z',
|
|
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
|
+
})
|