kopia lustrzana https://github.com/Tldraw/Tldraw
311 wiersze
9.4 KiB
TypeScript
311 wiersze
9.4 KiB
TypeScript
import { Box, PageRecordType, TLShapeId, createShapeId } from '@tldraw/editor'
|
|
import { TestEditor } from './TestEditor'
|
|
|
|
let editor: TestEditor
|
|
|
|
beforeEach(() => {
|
|
editor = new TestEditor()
|
|
})
|
|
|
|
const SHAPE_SIZE = { min: 100, max: 300 }
|
|
const NUM_QUERIES = 100
|
|
|
|
type IdAndBounds = { id: TLShapeId; bounds: Box }
|
|
|
|
function generateShapes() {
|
|
const result: IdAndBounds[] = []
|
|
const numOfShapes = Math.floor(500 + Math.random() * 500)
|
|
for (let i = 0; i < numOfShapes; i++) {
|
|
const xNegative = Math.random() > 0.5
|
|
const yNegative = Math.random() > 0.5
|
|
const x = Math.random() * 10000 * (xNegative ? -1 : 1)
|
|
const y = Math.random() * 10000 * (yNegative ? -1 : 1)
|
|
const id = createShapeId()
|
|
editor.createShape({
|
|
id,
|
|
type: 'geo',
|
|
x,
|
|
y,
|
|
props: {
|
|
w: Math.random() * (SHAPE_SIZE.max - SHAPE_SIZE.min) + SHAPE_SIZE.min,
|
|
h: Math.random() * (SHAPE_SIZE.max - SHAPE_SIZE.min) + SHAPE_SIZE.min,
|
|
},
|
|
})
|
|
const shape = editor.getShape(id)
|
|
if (!shape) continue
|
|
const bounds = editor.getShapePageBounds(shape)
|
|
if (!bounds) continue
|
|
result.push({ id, bounds })
|
|
}
|
|
return result
|
|
}
|
|
|
|
function pickShapes(shapes: IdAndBounds[]) {
|
|
// We pick at max 1/40 of the shapes, so that the common bounds have more chance not to cover the whole area
|
|
const numOfShapes = Math.floor((Math.random() * shapes.length) / 40)
|
|
const pickedShapes: IdAndBounds[] = []
|
|
for (let i = 0; i < numOfShapes; i++) {
|
|
const index = Math.floor(Math.random() * shapes.length)
|
|
pickedShapes.push(shapes[index])
|
|
}
|
|
return pickedShapes
|
|
}
|
|
|
|
describe('Spatial Index', () => {
|
|
it('finds the shapes inside and outside bounds', () => {
|
|
const shapes = generateShapes()
|
|
for (let i = 0; i < NUM_QUERIES; i++) {
|
|
const pickedShapes = pickShapes(shapes)
|
|
const commonBounds = Box.Common(pickedShapes.map((s) => s.bounds))
|
|
let shapeIdsInsideBounds = editor.getShapeIdsInsideBounds(commonBounds)
|
|
// It should include all the shapes inside common bounds
|
|
expect(pickedShapes.every((s) => shapeIdsInsideBounds.includes(s.id))).toBe(true)
|
|
|
|
// It also works when we shrink the bounds so that we don't fully contain shapes
|
|
shapeIdsInsideBounds = editor.getShapeIdsInsideBounds(
|
|
commonBounds.expandBy(-SHAPE_SIZE.min / 2)
|
|
)
|
|
expect(pickedShapes.every((s) => shapeIdsInsideBounds.includes(s.id))).toBe(true)
|
|
|
|
const shapeIdsOutsideBounds = shapes
|
|
.map((i) => i.id)
|
|
.filter((id) => {
|
|
const shape = editor.getShape(id)
|
|
if (!shape) return false
|
|
const bounds = editor.getShapePageBounds(shape)
|
|
if (!bounds) return false
|
|
return !commonBounds.includes(bounds)
|
|
})
|
|
// It should not contain any shapes outside the bounds
|
|
expect(shapeIdsOutsideBounds.every((id) => !shapeIdsInsideBounds.includes(id))).toBe(true)
|
|
expect(shapeIdsInsideBounds.length + shapeIdsOutsideBounds.length).toBe(shapes.length)
|
|
}
|
|
})
|
|
|
|
it('works when switching pages', () => {
|
|
const currentPageId = editor.getCurrentPageId()
|
|
let shapesInsideBounds: TLShapeId[]
|
|
|
|
const page1Shapes = generateShapes()
|
|
const page1Picks = pickShapes(page1Shapes)
|
|
const page1CommonBounds = Box.Common(page1Picks.map((s) => s.bounds))
|
|
shapesInsideBounds = editor.getShapeIdsInsideBounds(page1CommonBounds)
|
|
expect(page1Picks.every((s) => shapesInsideBounds.includes(s.id))).toBe(true)
|
|
|
|
const newPage = {
|
|
id: PageRecordType.createId(),
|
|
name: 'Page 2',
|
|
}
|
|
editor.createPage(newPage)
|
|
editor.setCurrentPage(newPage.id)
|
|
|
|
const page2Shapes = generateShapes()
|
|
const page2Picks = pickShapes(page2Shapes)
|
|
const page2CommonBounds = Box.Common(page2Picks.map((s) => s.bounds))
|
|
shapesInsideBounds = editor.getShapeIdsInsideBounds(page2CommonBounds)
|
|
expect(page2Picks.every((s) => shapesInsideBounds.includes(s.id))).toBe(true)
|
|
expect(page1Shapes.every((s) => !shapesInsideBounds.includes(s.id))).toBe(true)
|
|
|
|
editor.setCurrentPage(currentPageId)
|
|
shapesInsideBounds = editor.getShapeIdsInsideBounds(page1CommonBounds)
|
|
expect(page1Picks.every((s) => shapesInsideBounds.includes(s.id))).toBe(true)
|
|
expect(page2Shapes.every((s) => !shapesInsideBounds.includes(s.id))).toBe(true)
|
|
})
|
|
|
|
it('works for groups', () => {
|
|
const box1Id = createShapeId()
|
|
const box2Id = createShapeId()
|
|
editor.createShapes([
|
|
{
|
|
id: box1Id,
|
|
props: { w: 100, h: 100, geo: 'rectangle' },
|
|
type: 'geo',
|
|
x: 0,
|
|
y: 0,
|
|
},
|
|
{
|
|
id: box2Id,
|
|
type: 'geo',
|
|
x: 200,
|
|
y: 200,
|
|
props: { w: 100, h: 100, geo: 'rectangle' },
|
|
},
|
|
])
|
|
|
|
const groupId = createShapeId()
|
|
editor.groupShapes([box1Id, box2Id], groupId)
|
|
let groupBounds = editor.getShapePageBounds(groupId)
|
|
expect(groupBounds).toEqual({ x: 0, y: 0, w: 300, h: 300 })
|
|
|
|
expect(editor.getShapeIdsInsideBounds(groupBounds!)).toEqual([box1Id, box2Id, groupId])
|
|
|
|
// Move the group to the right by 1000
|
|
editor.updateShape({ id: groupId, type: 'group', x: 1000 })
|
|
groupBounds = editor.getShapePageBounds(groupId)
|
|
// Make sure the group bounds are updated
|
|
expect(groupBounds).toEqual({ x: 1000, y: 0, w: 300, h: 300 })
|
|
|
|
// We only updated the group's position, but spatial index should have also updated
|
|
// the bounds of the shapes inside the group
|
|
expect(editor.getShapeIdsInsideBounds(groupBounds!)).toEqual([box1Id, box2Id, groupId])
|
|
|
|
editor.updateShape({ id: box1Id, type: 'geo', x: -1000 })
|
|
const box1Bounds = editor.getShapePageBounds(box1Id)
|
|
expect(box1Bounds).toEqual({ x: 0, y: 0, w: 100, h: 100 })
|
|
|
|
// We only updated box1's position, but spatial index should have also updated
|
|
// the bounds of the parent group
|
|
expect(editor.getShapeIdsInsideBounds(box1Bounds!)).toEqual([box1Id, groupId])
|
|
})
|
|
|
|
it('works for frames', () => {
|
|
const boxId = createShapeId()
|
|
const frameId = createShapeId()
|
|
editor.createShapes([
|
|
{
|
|
id: boxId,
|
|
props: { w: 100, h: 100, geo: 'rectangle' },
|
|
type: 'geo',
|
|
x: 100,
|
|
y: 100,
|
|
},
|
|
{
|
|
id: frameId,
|
|
type: 'frame',
|
|
x: 0,
|
|
y: 0,
|
|
props: { w: 300, h: 300 },
|
|
},
|
|
])
|
|
|
|
editor.reparentShapes([boxId], frameId)
|
|
let frameBounds = editor.getShapePageBounds(frameId)
|
|
expect(frameBounds).toEqual({ x: 0, y: 0, w: 300, h: 300 })
|
|
|
|
// move the frame to the right by 1000
|
|
editor.updateShape({ id: frameId, type: 'group', x: 1000 })
|
|
frameBounds = editor.getShapePageBounds(frameId)
|
|
expect(frameBounds).toEqual({ x: 1000, y: 0, w: 300, h: 300 })
|
|
|
|
// We only updated the frame's position, but spatial index should have also updated
|
|
// the bounds of the shapes inside the frame
|
|
expect(editor.getShapeIdsInsideBounds(frameBounds!)).toEqual([boxId, frameId])
|
|
})
|
|
|
|
it('works for arrows', () => {
|
|
const arrowId = createShapeId()
|
|
const boxId = createShapeId()
|
|
editor.createShapes([
|
|
{
|
|
id: arrowId,
|
|
type: 'arrow',
|
|
props: {
|
|
start: { type: 'point', x: 0, y: 0 },
|
|
end: { type: 'point', x: 100, y: 100 },
|
|
},
|
|
},
|
|
{
|
|
id: boxId,
|
|
type: 'geo',
|
|
x: 200,
|
|
y: 200,
|
|
props: { w: 100, h: 100, geo: 'rectangle' },
|
|
},
|
|
])
|
|
let arrowBounds = editor.getShapePageBounds(arrowId)
|
|
expect(arrowBounds).toEqual({ x: 0, y: 0, w: 100, h: 100 })
|
|
let boxBounds = editor.getShapePageBounds(boxId)
|
|
expect(boxBounds).toEqual({ x: 200, y: 200, w: 100, h: 100 })
|
|
|
|
// bind the arrow to the box
|
|
editor.updateShape({
|
|
id: arrowId,
|
|
type: 'arrow',
|
|
props: {
|
|
end: {
|
|
type: 'binding',
|
|
isExact: true,
|
|
boundShapeId: boxId,
|
|
normalizedAnchor: { x: 0.5, y: 0.5 },
|
|
isPrecise: false,
|
|
},
|
|
},
|
|
})
|
|
arrowBounds = editor.getShapePageBounds(arrowId)
|
|
// Arrow extends to the middle of the box now
|
|
expect(arrowBounds).toEqual({ x: 0, y: 0, w: 250, h: 250 })
|
|
// Arrow should be inside the box bounds
|
|
expect(editor.getShapeIdsInsideBounds(boxBounds!)).toEqual([arrowId, boxId])
|
|
|
|
// Move the box to the left
|
|
editor.updateShape({
|
|
id: boxId,
|
|
type: 'geo',
|
|
x: -200,
|
|
})
|
|
// We should not see any shapes inside the old bounds any longer
|
|
expect(editor.getShapeIdsInsideBounds(boxBounds!)).toEqual([])
|
|
boxBounds = editor.getShapePageBounds(boxId)
|
|
expect(boxBounds).toEqual({ x: -200, y: 200, w: 100, h: 100 })
|
|
|
|
// We only updated the box's position, but spatial index should have also updated
|
|
// the bounds of the arrow bound to it
|
|
expect(editor.getShapeIdsInsideBounds(boxBounds!)).toEqual([arrowId, boxId])
|
|
})
|
|
|
|
it('works for shapes that are outside of the viewport, but are then moved inside it', () => {
|
|
const box1Id = createShapeId()
|
|
const box2Id = createShapeId()
|
|
const arrowId = createShapeId()
|
|
|
|
editor.createShapes([
|
|
{
|
|
id: box1Id,
|
|
props: { w: 100, h: 100, geo: 'rectangle' },
|
|
type: 'geo',
|
|
x: -500,
|
|
y: 0,
|
|
},
|
|
{
|
|
id: box2Id,
|
|
type: 'geo',
|
|
x: -1000,
|
|
y: 200,
|
|
props: { w: 100, h: 100, geo: 'rectangle' },
|
|
},
|
|
{
|
|
id: arrowId,
|
|
type: 'arrow',
|
|
props: {
|
|
start: {
|
|
type: 'binding',
|
|
isExact: true,
|
|
boundShapeId: box1Id,
|
|
normalizedAnchor: { x: 0.5, y: 0.5 },
|
|
isPrecise: false,
|
|
},
|
|
end: {
|
|
type: 'binding',
|
|
isExact: true,
|
|
boundShapeId: box2Id,
|
|
normalizedAnchor: { x: 0.5, y: 0.5 },
|
|
isPrecise: false,
|
|
},
|
|
},
|
|
},
|
|
])
|
|
|
|
const viewportBounds = editor.getViewportPageBounds()
|
|
expect(viewportBounds).toEqual({ x: -0, y: -0, w: 1080, h: 720 })
|
|
expect(editor.getShapeIdsInsideBounds(viewportBounds)).toEqual([])
|
|
|
|
// Move box1 and box2 inside the viewport
|
|
editor.updateShapes([
|
|
{ id: box1Id, type: 'geo', x: 100 },
|
|
{ id: box2Id, type: 'geo', x: 200 },
|
|
])
|
|
// Spatial index should also see the arrow
|
|
expect(editor.getShapeIdsInsideBounds(viewportBounds)).toEqual([box1Id, box2Id, arrowId])
|
|
})
|
|
})
|