tldraw
Version:
A tiny little drawing editor.
699 lines (569 loc) • 22.1 kB
text/typescript
import {
Box,
Geometry2d,
PageRecordType,
RecordProps,
Rectangle2d,
ShapeUtil,
T,
TLShape,
createShapeId,
} from '@tldraw/editor'
import { TestEditor } from './TestEditor'
let editor: TestEditor
beforeEach(() => {
editor = new TestEditor()
editor.updateViewportScreenBounds(new Box(0, 0, 1000, 1000))
editor.setCamera({ x: 0, y: 0, z: 1 })
})
afterEach(() => {
editor?.dispose()
})
// Custom test shape for testing canCull behavior
declare module '@tldraw/tlschema' {
export interface TLGlobalShapePropsMap {
'not-visible-test-shape': { w: number; h: number; canCull: boolean }
}
}
type ITestShape = TLShape<'not-visible-test-shape'>
class TestShape extends ShapeUtil<ITestShape> {
static override type = 'not-visible-test-shape' as const
static override props: RecordProps<ITestShape> = {
w: T.number,
h: T.number,
canCull: T.boolean,
}
getDefaultProps(): ITestShape['props'] {
return { w: 100, h: 100, canCull: true }
}
getGeometry(shape: ITestShape): Geometry2d {
return new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: false,
})
}
override canCull(shape: ITestShape): boolean {
return shape.props.canCull
}
override canEdit() {
return true
}
indicator() {}
component() {}
}
describe('notVisibleShapes - basic culling', () => {
it('should identify shapes outside viewport', () => {
const insideId = createShapeId('inside')
const outsideId = createShapeId('outside')
editor.createShapes([
{ id: insideId, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } },
{ id: outsideId, type: 'geo', x: 2000, y: 2000, props: { w: 100, h: 100 } },
])
const notVisible = editor.getNotVisibleShapes()
expect(notVisible.has(insideId)).toBe(false)
expect(notVisible.has(outsideId)).toBe(true)
})
it('should update when shapes move in/out of viewport', () => {
const shapeId = createShapeId('moving')
editor.createShapes([{ id: shapeId, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } }])
// Initially visible
let notVisible = editor.getNotVisibleShapes()
expect(notVisible.has(shapeId)).toBe(false)
// Move outside viewport
editor.updateShapes([{ id: shapeId, type: 'geo', x: 2000, y: 2000 }])
notVisible = editor.getNotVisibleShapes()
expect(notVisible.has(shapeId)).toBe(true)
// Move back inside
editor.updateShapes([{ id: shapeId, type: 'geo', x: 100, y: 100 }])
notVisible = editor.getNotVisibleShapes()
expect(notVisible.has(shapeId)).toBe(false)
})
it('should update when viewport moves', () => {
const shapeId = createShapeId('stationary')
editor.createShapes([{ id: shapeId, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } }])
// Initially visible
let notVisible = editor.getNotVisibleShapes()
expect(notVisible.has(shapeId)).toBe(false)
// Pan viewport away from shape
editor.setCamera({ x: -2000, y: -2000, z: 1 })
notVisible = editor.getNotVisibleShapes()
expect(notVisible.has(shapeId)).toBe(true)
// Pan back
editor.setCamera({ x: 0, y: 0, z: 1 })
notVisible = editor.getNotVisibleShapes()
expect(notVisible.has(shapeId)).toBe(false)
})
it('should update when zoom level changes', () => {
const shapeId = createShapeId('shape')
// Create shape just outside initial viewport
editor.createShapes([{ id: shapeId, type: 'geo', x: 1100, y: 500, props: { w: 100, h: 100 } }])
// Initially outside
let notVisible = editor.getNotVisibleShapes()
expect(notVisible.has(shapeId)).toBe(true)
// Zoom out - viewport bounds expand, shape becomes visible
editor.setCamera({ x: 0, y: 0, z: 0.5 })
notVisible = editor.getNotVisibleShapes()
expect(notVisible.has(shapeId)).toBe(false)
// Zoom back in
editor.setCamera({ x: 0, y: 0, z: 1 })
notVisible = editor.getNotVisibleShapes()
expect(notVisible.has(shapeId)).toBe(true)
})
it('should keep very large shape visible when partially in viewport', () => {
const largeShapeId = createShapeId('large')
// Create massive shape that extends far beyond viewport
editor.createShapes([
{
id: largeShapeId,
type: 'geo',
x: -5000,
y: -5000,
props: { w: 10000, h: 10000 },
},
])
// Shape should be visible (viewport is inside it)
const notVisible = editor.getNotVisibleShapes()
expect(notVisible.has(largeShapeId)).toBe(false)
})
})
describe('notVisibleShapes - canCull behavior', () => {
it('should not cull shapes that return false from canCull', () => {
// Register TestShape temporarily for this test
const testEditor = new TestEditor({ shapeUtils: [TestShape] })
testEditor.updateViewportScreenBounds(new Box(0, 0, 1000, 1000))
testEditor.setCamera({ x: 0, y: 0, z: 1 })
const cullableId = createShapeId('cullable')
const nonCullableId = createShapeId('non-cullable')
testEditor.createShapes([
{
id: cullableId,
type: 'not-visible-test-shape',
x: 2000,
y: 2000,
props: { canCull: true },
},
{
id: nonCullableId,
type: 'not-visible-test-shape',
x: 2000,
y: 2000,
props: { canCull: false },
},
])
const notVisible = testEditor.getNotVisibleShapes()
expect(notVisible.has(cullableId)).toBe(true)
expect(notVisible.has(nonCullableId)).toBe(false)
testEditor.dispose()
})
})
describe('notVisibleShapes - selected shapes', () => {
it('should not cull selected shapes even if outside viewport', () => {
const shapeId = createShapeId('selected')
editor.createShapes([{ id: shapeId, type: 'geo', x: 2000, y: 2000, props: { w: 100, h: 100 } }])
// Not selected - should be in notVisible and culled
let notVisible = editor.getNotVisibleShapes()
let culled = editor.getCulledShapes()
expect(notVisible.has(shapeId)).toBe(true)
expect(culled.has(shapeId)).toBe(true)
// Select it - still in notVisible but not culled
editor.select(shapeId)
notVisible = editor.getNotVisibleShapes()
culled = editor.getCulledShapes()
expect(notVisible.has(shapeId)).toBe(true)
expect(culled.has(shapeId)).toBe(false)
// Deselect - back in culled
editor.selectNone()
notVisible = editor.getNotVisibleShapes()
culled = editor.getCulledShapes()
expect(notVisible.has(shapeId)).toBe(true)
expect(culled.has(shapeId)).toBe(true)
})
})
describe('notVisibleShapes - caching', () => {
it('should return same Set object when contents unchanged', () => {
editor.createShapes([{ id: createShapeId('shape1'), type: 'geo', x: 2000, y: 2000 }])
const notVisible1 = editor.getNotVisibleShapes()
const notVisible2 = editor.getNotVisibleShapes()
// Should return same reference when nothing changed
expect(notVisible1).toBe(notVisible2)
})
it('should return new Set object when contents change', () => {
const shapeId = createShapeId('moving')
editor.createShapes([{ id: shapeId, type: 'geo', x: 2000, y: 2000 }])
const notVisible1 = editor.getNotVisibleShapes()
// Move shape into viewport
editor.updateShapes([{ id: shapeId, type: 'geo', x: 100, y: 100 }])
const notVisible2 = editor.getNotVisibleShapes()
// Should return different reference when contents changed
expect(notVisible1).not.toBe(notVisible2)
})
})
describe('notVisibleShapes - multiple pages', () => {
it('should only cull shapes on current page', () => {
const page1 = editor.getCurrentPageId()
// Create shapes on page 1
const page1Shape1 = createShapeId('page1-inside')
const page1Shape2 = createShapeId('page1-outside')
editor.createShapes([
{ id: page1Shape1, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } },
{ id: page1Shape2, type: 'geo', x: 2000, y: 2000, props: { w: 100, h: 100 } },
])
// Create page 2
const page2 = PageRecordType.createId('page2')
editor.createPage({ name: 'page2', id: page2 })
editor.setCurrentPage(page2)
// Create shapes on page 2
const page2Shape1 = createShapeId('page2-inside')
const page2Shape2 = createShapeId('page2-outside')
editor.createShapes([
{ id: page2Shape1, type: 'geo', x: 200, y: 200, props: { w: 100, h: 100 } },
{ id: page2Shape2, type: 'geo', x: 3000, y: 3000, props: { w: 100, h: 100 } },
])
// Check page 2 culling
let notVisible = editor.getNotVisibleShapes()
expect(notVisible.has(page2Shape1)).toBe(false)
expect(notVisible.has(page2Shape2)).toBe(true)
// Page 1 shapes should not be in the set at all
expect(notVisible.has(page1Shape1)).toBe(false)
expect(notVisible.has(page1Shape2)).toBe(false)
// Switch back to page 1
editor.setCurrentPage(page1)
// Check page 1 culling
notVisible = editor.getNotVisibleShapes()
expect(notVisible.has(page1Shape1)).toBe(false)
expect(notVisible.has(page1Shape2)).toBe(true)
// Page 2 shapes should not be in the set
expect(notVisible.has(page2Shape1)).toBe(false)
expect(notVisible.has(page2Shape2)).toBe(false)
})
it('should maintain separate spatial indexes per page', () => {
const page1 = editor.getCurrentPageId()
// Create many shapes on page 1
for (let i = 0; i < 100; i++) {
editor.createShapes([
{
id: createShapeId(`page1-shape-${i}`),
type: 'geo',
x: (i % 10) * 200,
y: Math.floor(i / 10) * 200,
},
])
}
// Create page 2
const page2 = PageRecordType.createId('page2')
editor.createPage({ name: 'page2', id: page2 })
editor.setCurrentPage(page2)
// Create different shapes on page 2
for (let i = 0; i < 50; i++) {
editor.createShapes([
{
id: createShapeId(`page2-shape-${i}`),
type: 'geo',
x: (i % 5) * 300,
y: Math.floor(i / 5) * 300,
},
])
}
// Check page 2
const notVisiblePage2 = editor.getNotVisibleShapes()
const page2ShapeIds = editor.getCurrentPageShapeIds()
expect(page2ShapeIds.size).toBe(50)
// Switch to page 1
editor.setCurrentPage(page1)
const notVisiblePage1 = editor.getNotVisibleShapes()
const page1ShapeIds = editor.getCurrentPageShapeIds()
expect(page1ShapeIds.size).toBe(100)
// Results should be different (different shapes on each page)
expect(notVisiblePage1.size).not.toBe(notVisiblePage2.size)
// No page 2 shapes should appear in page 1 results
for (const id of notVisiblePage1) {
expect(id.includes('page2')).toBe(false)
}
// No page 1 shapes should appear in page 2 results
for (const id of notVisiblePage2) {
expect(id.includes('page1')).toBe(false)
}
})
it('should update indexes when switching pages', () => {
const page1 = editor.getCurrentPageId()
// Create shape outside viewport on page 1
const page1OutsideShape = createShapeId('page1-outside')
editor.createShapes([{ id: page1OutsideShape, type: 'geo', x: 2000, y: 2000 }])
// Verify it's culled
let notVisible = editor.getNotVisibleShapes()
expect(notVisible.has(page1OutsideShape)).toBe(true)
// Create page 2 and switch to it
const page2 = PageRecordType.createId('page2')
editor.createPage({ name: 'page2', id: page2 })
editor.setCurrentPage(page2)
// Create shape inside viewport on page 2
const page2InsideShape = createShapeId('page2-inside')
editor.createShapes([{ id: page2InsideShape, type: 'geo', x: 100, y: 100 }])
// Page 2 shape should not be culled
notVisible = editor.getNotVisibleShapes()
expect(notVisible.has(page2InsideShape)).toBe(false)
// Page 1 shape should not appear in results
expect(notVisible.has(page1OutsideShape)).toBe(false)
// Switch back to page 1
editor.setCurrentPage(page1)
// Page 1 shape should still be culled
notVisible = editor.getNotVisibleShapes()
expect(notVisible.has(page1OutsideShape)).toBe(true)
// Page 2 shape should not appear
expect(notVisible.has(page2InsideShape)).toBe(false)
})
})
describe('notVisibleShapes - arrows with bindings', () => {
it('should not cull selected arrow even if outside viewport', () => {
// Create arrow outside viewport
editor.setCurrentTool('arrow')
editor.pointerDown(2000, 2000)
editor.pointerMove(2200, 2000)
editor.pointerUp(2200, 2000)
const arrow = editor.getCurrentPageShapes().find((s) => s.type === 'arrow')!
expect(arrow).toBeDefined()
// Arrow outside viewport, selected by arrow tool
let notVisible = editor.getNotVisibleShapes()
let culled = editor.getCulledShapes()
expect(notVisible.has(arrow.id)).toBe(true)
// Arrow is selected after creation, so not culled
expect(culled.has(arrow.id)).toBe(false)
// Deselect arrow
editor.selectNone()
notVisible = editor.getNotVisibleShapes()
culled = editor.getCulledShapes()
expect(notVisible.has(arrow.id)).toBe(true)
expect(culled.has(arrow.id)).toBe(true) // Now culled
// Select arrow again - should not be culled
editor.select(arrow.id)
notVisible = editor.getNotVisibleShapes()
culled = editor.getCulledShapes()
expect(notVisible.has(arrow.id)).toBe(true)
expect(culled.has(arrow.id)).toBe(false) // Not culled because selected
})
it('should make arrow visible when bound shapes move into viewport', () => {
const boxAId = createShapeId('boxA')
const boxBId = createShapeId('boxB')
// Create two boxes outside viewport
editor.createShapes([
{ id: boxAId, type: 'geo', x: 2000, y: 2000, props: { w: 100, h: 100 } },
{ id: boxBId, type: 'geo', x: 2200, y: 2000, props: { w: 100, h: 100 } },
])
// Draw arrow between them (arrow is also outside viewport)
editor.setCurrentTool('arrow')
editor.pointerDown(2050, 2050)
editor.pointerMove(2250, 2050)
editor.pointerUp(2250, 2050)
const arrow = editor.getCurrentPageShapes().find((s) => s.type === 'arrow')!
expect(arrow).toBeDefined()
// Deselect all
editor.selectNone()
// Verify all invisible initially
let notVisible = editor.getNotVisibleShapes()
expect(notVisible.has(boxAId)).toBe(true)
expect(notVisible.has(boxBId)).toBe(true)
expect(notVisible.has(arrow.id)).toBe(true)
// Move bound shapes INTO viewport (arrow record doesn't change, but bounds do)
editor.updateShapes([
{ id: boxAId, type: 'geo', x: 100, y: 100 },
{ id: boxBId, type: 'geo', x: 300, y: 100 },
])
// CRITICAL: Arrow should now be visible even though arrow shape didn't update
notVisible = editor.getNotVisibleShapes()
expect(notVisible.has(boxAId)).toBe(false)
expect(notVisible.has(boxBId)).toBe(false)
expect(notVisible.has(arrow.id)).toBe(false) // Arrow visible due to reactive bounds
})
it('should update arrow visibility when only one bound shape moves', () => {
const boxAId = createShapeId('boxA')
const boxBId = createShapeId('boxB')
// Create boxes inside viewport
editor.createShapes([
{ id: boxAId, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } },
{ id: boxBId, type: 'geo', x: 300, y: 100, props: { w: 100, h: 100 } },
])
// Create arrow
editor.setCurrentTool('arrow')
editor.pointerDown(150, 150)
editor.pointerMove(350, 150)
editor.pointerUp(350, 150)
const arrow = editor.getCurrentPageShapes().find((s) => s.type === 'arrow')!
editor.selectNone()
// All visible
let notVisible = editor.getNotVisibleShapes()
expect(notVisible.has(arrow.id)).toBe(false)
// Move only boxA outside viewport (boxB stays visible)
editor.updateShapes([{ id: boxAId, type: 'geo', x: 2000, y: 2000 }])
notVisible = editor.getNotVisibleShapes()
// Arrow should still be visible (one endpoint visible)
expect(notVisible.has(boxAId)).toBe(true)
expect(notVisible.has(boxBId)).toBe(false)
expect(notVisible.has(arrow.id)).toBe(false)
})
it('should keep arrow visible when endpoints are in viewport but body extends outside', () => {
const boxAId = createShapeId('boxA')
const boxBId = createShapeId('boxB')
// Create boxes near opposite edges of viewport
editor.createShapes([
{ id: boxAId, type: 'geo', x: 50, y: 500, props: { w: 100, h: 100 } },
{ id: boxBId, type: 'geo', x: 850, y: 500, props: { w: 100, h: 100 } },
])
// Create curved arrow that might extend outside viewport
editor.setCurrentTool('arrow')
editor.pointerDown(100, 550)
editor.pointerMove(900, 550)
editor.pointerUp(900, 550)
const arrow = editor.getCurrentPageShapes().find((s) => s.type === 'arrow')!
// Make it curved so body might extend outside
editor.updateShapes([
{
id: arrow.id,
type: 'arrow',
props: { bend: 100 }, // Significant curve
},
])
editor.selectNone()
const notVisible = editor.getNotVisibleShapes()
// Arrow should be visible since endpoints are visible
expect(notVisible.has(arrow.id)).toBe(false)
})
})
describe('notVisibleShapes - frames', () => {
it('should cull frame when outside viewport', () => {
const frameId = createShapeId('frame')
// Create frame outside viewport
editor.createShapes([
{ id: frameId, type: 'frame', x: 2000, y: 2000, props: { w: 500, h: 500 } },
])
// Frame should be not visible
const notVisible = editor.getNotVisibleShapes()
expect(notVisible.has(frameId)).toBe(true)
})
it('should keep frame visible when inside viewport', () => {
const frameId = createShapeId('frame')
// Create frame inside viewport
editor.createShapes([{ id: frameId, type: 'frame', x: 100, y: 100, props: { w: 500, h: 500 } }])
// Frame should be visible
const notVisible = editor.getNotVisibleShapes()
expect(notVisible.has(frameId)).toBe(false)
})
it('should cull children when frame is outside viewport', () => {
const frameId = createShapeId('frame')
const childId = createShapeId('child')
// Frame outside viewport
editor.createShapes([
{ id: frameId, type: 'frame', x: 2000, y: 2000, props: { w: 500, h: 500 } },
])
// Child inside frame
editor.createShapes([
{
id: childId,
type: 'geo',
x: 2100,
y: 2100,
parentId: frameId,
props: { w: 100, h: 100 },
},
])
const notVisible = editor.getNotVisibleShapes()
// Both should be outside viewport
expect(notVisible.has(frameId)).toBe(true)
expect(notVisible.has(childId)).toBe(true)
})
it('should handle multiple levels of nesting', () => {
const frameId = createShapeId('frame')
const groupId = createShapeId('group')
const childId = createShapeId('child')
// Frame inside viewport
editor.createShapes([{ id: frameId, type: 'frame', x: 100, y: 100, props: { w: 500, h: 500 } }])
// Create shapes inside frame to form a group
editor.createShapes([
{ id: groupId, type: 'geo', x: 200, y: 200, parentId: frameId, props: { w: 100, h: 100 } },
{ id: childId, type: 'geo', x: 250, y: 200, parentId: frameId, props: { w: 100, h: 100 } },
])
// Group them (now they're nested inside frame)
editor.select(groupId, childId)
const actualGroupId = createShapeId('actual-group')
editor.groupShapes(editor.getSelectedShapeIds(), { groupId: actualGroupId })
editor.selectNone()
// All should be visible
let notVisible = editor.getNotVisibleShapes()
expect(notVisible.has(frameId)).toBe(false)
expect(notVisible.has(actualGroupId)).toBe(false)
// Move frame outside viewport
editor.updateShapes([{ id: frameId, type: 'frame', x: 3000, y: 3000 }])
notVisible = editor.getNotVisibleShapes()
// All should now be invisible
expect(notVisible.has(frameId)).toBe(true)
expect(notVisible.has(actualGroupId)).toBe(true)
})
})
describe('notVisibleShapes - groups', () => {
it('should keep group visible when any child is visible', () => {
const groupId = createShapeId('group')
const childAId = createShapeId('childA')
const childBId = createShapeId('childB')
// Create shapes - one inside, one outside viewport
editor.createShapes([
{ id: childAId, type: 'geo', x: 2000, y: 2000, props: { w: 100, h: 100 } },
{ id: childBId, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } },
])
// Group the shapes
editor.select(childAId, childBId)
editor.groupShapes(editor.getSelectedShapeIds(), { groupId })
editor.selectNone()
// Group has visible child, so group bounds include visible area
const notVisible = editor.getNotVisibleShapes()
expect(notVisible.has(groupId)).toBe(false) // Group visible because childB visible
expect(notVisible.has(childBId)).toBe(false) // childB is visible
expect(notVisible.has(childAId)).toBe(true) // childA is outside
})
it('should cull group when all children are outside viewport', () => {
const groupId = createShapeId('group')
const childAId = createShapeId('childA')
const childBId = createShapeId('childB')
// Create group with all children outside viewport
editor.createShapes([
{ id: childAId, type: 'geo', x: 2000, y: 2000, props: { w: 100, h: 100 } },
{ id: childBId, type: 'geo', x: 2200, y: 2200, props: { w: 100, h: 100 } },
])
// Group the shapes
editor.select(childAId, childBId)
editor.groupShapes(editor.getSelectedShapeIds(), { groupId })
// All should be not visible
const notVisible = editor.getNotVisibleShapes()
expect(notVisible.has(groupId)).toBe(true)
expect(notVisible.has(childAId)).toBe(true)
expect(notVisible.has(childBId)).toBe(true)
})
it('should update visibility when children move', () => {
const groupId = createShapeId('group')
const childAId = createShapeId('childA')
const childBId = createShapeId('childB')
// Create group with all children inside viewport
editor.createShapes([
{ id: childAId, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } },
{ id: childBId, type: 'geo', x: 200, y: 200, props: { w: 100, h: 100 } },
])
// Group the shapes
editor.select(childAId, childBId)
editor.groupShapes(editor.getSelectedShapeIds(), { groupId })
// Initially visible
let notVisible = editor.getNotVisibleShapes()
// Group is selected after creation, so not checking it
expect(notVisible.has(childAId)).toBe(false)
expect(notVisible.has(childBId)).toBe(false)
// Deselect and move both children outside viewport
editor.selectNone()
editor.updateShapes([
{ id: childAId, type: 'geo', x: 2000, y: 2000 },
{ id: childBId, type: 'geo', x: 2200, y: 2200 },
])
notVisible = editor.getNotVisibleShapes()
// All should now be not visible
expect(notVisible.has(groupId)).toBe(true)
expect(notVisible.has(childAId)).toBe(true)
expect(notVisible.has(childBId)).toBe(true)
})
})