RBush again? (#3439)
Adds RBush to handle spatial querying. We use it for: - Culling. Helps a lot with panning as we don't have to compute the culled shapes from scratch. Instead we just query rbush again. It makes culling quite granular: spatial index updates when shapes change (additions, removals, changes to bounds), visible shapes depends on that, but also updates when the viewport page bound change, culled shapes then depend on that but also change with selections changes. The api stayed the same, which is great since the fuzz tests can stay as they are. - Brushing - Erasing - Scribble brushing - Getting shapes at point (for example, when updating the hover id) This improves performance of all of those operations. I might have missed some places where this might also be useful. ### Erasing before (Test on my old ipad) https://github.com/tldraw/tldraw/assets/2523721/edb9c004-a44a-4779-b2d0-98617b057314 ### Erasing after https://github.com/tldraw/tldraw/assets/2523721/8f8367fd-fa8e-4963-ba13-720c5f0c2da5 ### Creating an arrow before https://github.com/tldraw/tldraw/assets/2523721/4068f8b7-f7b8-4826-83f2-083b1f3783bc ### After (much better, but still bad) https://github.com/tldraw/tldraw/assets/2523721/11af6be6-01d8-4740-bf15-896e2dd31dd6 ### Change Type <!-- ❗ Please select a 'Scope' label ❗️ --> - [ ] `sdk` — Changes the tldraw SDK - [ ] `dotcom` — Changes the tldraw.com web app - [ ] `docs` — Changes to the documentation, examples, or templates. - [ ] `vs code` — Changes to the vscode plugin - [x] `internal` — Does not affect user-facing stuff <!-- ❗ Please select a 'Type' label ❗️ --> - [ ] `bugfix` — Bug fix - [ ] `feature` — New feature - [x] `improvement` — Improving existing features - [ ] `chore` — Updating dependencies, other boring stuff - [ ] `galaxy brain` — Architectural changes - [ ] `tests` — Changes to any test code - [ ] `tools` — Changes to infrastructure, CI, internal scripts, debugging tools, etc. - [ ] `dunno` — I don't know --------- Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
This commit is contained in:
parent
4f70a4f4e8
commit
45dffd1af6
14 changed files with 570 additions and 132 deletions
|
@ -188,6 +188,8 @@ export interface BoundsSnapPoint {
|
|||
export class Box {
|
||||
constructor(x?: number, y?: number, w?: number, h?: number);
|
||||
// (undocumented)
|
||||
static AroundPoint(point: VecLike, n: number): Box;
|
||||
// (undocumented)
|
||||
get aspectRatio(): number;
|
||||
// (undocumented)
|
||||
get center(): Vec;
|
||||
|
@ -755,6 +757,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
getShapeClipPath(shape: TLShape | TLShapeId): string | undefined;
|
||||
getShapeGeometry<T extends Geometry2d>(shape: TLShape | TLShapeId): T;
|
||||
getShapeHandles<T extends TLShape>(shape: T | T['id']): TLHandle[] | undefined;
|
||||
getShapeIdsInsideBounds(bounds: Box): TLShapeId[];
|
||||
getShapeLocalTransform(shape: TLShape | TLShapeId): Mat;
|
||||
getShapeMask(shape: TLShape | TLShapeId): undefined | VecLike[];
|
||||
getShapeMaskedPageBounds(shape: TLShape | TLShapeId): Box | undefined;
|
||||
|
@ -766,6 +769,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
hitInside?: boolean | undefined;
|
||||
margin?: number | undefined;
|
||||
}): TLShape[];
|
||||
getShapesInsideBounds(bounds: Box): TLShape[];
|
||||
// (undocumented)
|
||||
getShapeStyleIfExists<T>(shape: TLShape, style: StyleProp<T>): T | undefined;
|
||||
getShapeUtil<S extends TLUnknownShape>(shape: S | TLShapePartial<S>): ShapeUtil<S>;
|
||||
|
|
|
@ -1623,6 +1623,72 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "Method",
|
||||
"canonicalReference": "@tldraw/editor!Box.AroundPoint:member(1)",
|
||||
"docComment": "",
|
||||
"excerptTokens": [
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "static AroundPoint(point: "
|
||||
},
|
||||
{
|
||||
"kind": "Reference",
|
||||
"text": "VecLike",
|
||||
"canonicalReference": "@tldraw/editor!VecLike:type"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": ", n: "
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "number"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "): "
|
||||
},
|
||||
{
|
||||
"kind": "Reference",
|
||||
"text": "Box",
|
||||
"canonicalReference": "@tldraw/editor!Box:class"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": ";"
|
||||
}
|
||||
],
|
||||
"isStatic": true,
|
||||
"returnTypeTokenRange": {
|
||||
"startIndex": 5,
|
||||
"endIndex": 6
|
||||
},
|
||||
"releaseTag": "Public",
|
||||
"isProtected": false,
|
||||
"overloadIndex": 1,
|
||||
"parameters": [
|
||||
{
|
||||
"parameterName": "point",
|
||||
"parameterTypeTokenRange": {
|
||||
"startIndex": 1,
|
||||
"endIndex": 2
|
||||
},
|
||||
"isOptional": false
|
||||
},
|
||||
{
|
||||
"parameterName": "n",
|
||||
"parameterTypeTokenRange": {
|
||||
"startIndex": 3,
|
||||
"endIndex": 4
|
||||
},
|
||||
"isOptional": false
|
||||
}
|
||||
],
|
||||
"isOptional": false,
|
||||
"isAbstract": false,
|
||||
"name": "AroundPoint"
|
||||
},
|
||||
{
|
||||
"kind": "Property",
|
||||
"canonicalReference": "@tldraw/editor!Box#aspectRatio:member",
|
||||
|
@ -12734,6 +12800,60 @@
|
|||
"isAbstract": false,
|
||||
"name": "getShapeHandles"
|
||||
},
|
||||
{
|
||||
"kind": "Method",
|
||||
"canonicalReference": "@tldraw/editor!Editor#getShapeIdsInsideBounds:member(1)",
|
||||
"docComment": "/**\n * Get the shapes ids of shapes that are are (at least partially) inside the bounds.\n *\n * @param bounds - The bounds to check.\n *\n * @returns The shape ids of shapes that are at least partially inside the bounds.\n *\n * @public\n */\n",
|
||||
"excerptTokens": [
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "getShapeIdsInsideBounds(bounds: "
|
||||
},
|
||||
{
|
||||
"kind": "Reference",
|
||||
"text": "Box",
|
||||
"canonicalReference": "@tldraw/editor!Box:class"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "): "
|
||||
},
|
||||
{
|
||||
"kind": "Reference",
|
||||
"text": "TLShapeId",
|
||||
"canonicalReference": "@tldraw/tlschema!TLShapeId:type"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "[]"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": ";"
|
||||
}
|
||||
],
|
||||
"isStatic": false,
|
||||
"returnTypeTokenRange": {
|
||||
"startIndex": 3,
|
||||
"endIndex": 5
|
||||
},
|
||||
"releaseTag": "Public",
|
||||
"isProtected": false,
|
||||
"overloadIndex": 1,
|
||||
"parameters": [
|
||||
{
|
||||
"parameterName": "bounds",
|
||||
"parameterTypeTokenRange": {
|
||||
"startIndex": 1,
|
||||
"endIndex": 2
|
||||
},
|
||||
"isOptional": false
|
||||
}
|
||||
],
|
||||
"isOptional": false,
|
||||
"isAbstract": false,
|
||||
"name": "getShapeIdsInsideBounds"
|
||||
},
|
||||
{
|
||||
"kind": "Method",
|
||||
"canonicalReference": "@tldraw/editor!Editor#getShapeLocalTransform:member(1)",
|
||||
|
@ -13237,6 +13357,60 @@
|
|||
"isAbstract": false,
|
||||
"name": "getShapesAtPoint"
|
||||
},
|
||||
{
|
||||
"kind": "Method",
|
||||
"canonicalReference": "@tldraw/editor!Editor#getShapesInsideBounds:member(1)",
|
||||
"docComment": "/**\n * Get the shapes that are are (at least partially) inside the bounds.\n *\n * @param bounds - The bounds to check.\n *\n * @returns The shapes that are at least partially inside the bounds.\n *\n * @public\n */\n",
|
||||
"excerptTokens": [
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "getShapesInsideBounds(bounds: "
|
||||
},
|
||||
{
|
||||
"kind": "Reference",
|
||||
"text": "Box",
|
||||
"canonicalReference": "@tldraw/editor!Box:class"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "): "
|
||||
},
|
||||
{
|
||||
"kind": "Reference",
|
||||
"text": "TLShape",
|
||||
"canonicalReference": "@tldraw/tlschema!TLShape:type"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "[]"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": ";"
|
||||
}
|
||||
],
|
||||
"isStatic": false,
|
||||
"returnTypeTokenRange": {
|
||||
"startIndex": 3,
|
||||
"endIndex": 5
|
||||
},
|
||||
"releaseTag": "Public",
|
||||
"isProtected": false,
|
||||
"overloadIndex": 1,
|
||||
"parameters": [
|
||||
{
|
||||
"parameterName": "bounds",
|
||||
"parameterTypeTokenRange": {
|
||||
"startIndex": 1,
|
||||
"endIndex": 2
|
||||
},
|
||||
"isOptional": false
|
||||
}
|
||||
],
|
||||
"isOptional": false,
|
||||
"isAbstract": false,
|
||||
"name": "getShapesInsideBounds"
|
||||
},
|
||||
{
|
||||
"kind": "Method",
|
||||
"canonicalReference": "@tldraw/editor!Editor#getShapeStyleIfExists:member(1)",
|
||||
|
|
|
@ -59,7 +59,8 @@
|
|||
"is-plain-object": "^5.0.0",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"lodash.uniq": "^4.5.0",
|
||||
"nanoid": "4.0.2"
|
||||
"nanoid": "4.0.2",
|
||||
"rbush": "^3.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18",
|
||||
|
@ -72,6 +73,7 @@
|
|||
"@types/benchmark": "^2.1.2",
|
||||
"@types/lodash.throttle": "^4.1.7",
|
||||
"@types/lodash.uniq": "^4.5.7",
|
||||
"@types/rbush": "^3.0.3",
|
||||
"@types/react-test-renderer": "^18.0.0",
|
||||
"@types/wicg-file-system-access": "^2020.9.5",
|
||||
"benchmark": "^2.1.4",
|
||||
|
|
|
@ -100,9 +100,9 @@ import { getReorderingShapesChanges } from '../utils/reorderShapes'
|
|||
import { applyRotationToSnapshotShapes, getRotationSnapshot } from '../utils/rotation'
|
||||
import { uniqueId } from '../utils/uniqueId'
|
||||
import { arrowBindingsIndex } from './derivations/arrowBindingsIndex'
|
||||
import { notVisibleShapes } from './derivations/notVisibleShapes'
|
||||
import { parentsToChildren } from './derivations/parentsToChildren'
|
||||
import { deriveShapeIdsInCurrentPage } from './derivations/shapeIdsInCurrentPage'
|
||||
import { SpatialIndex } from './derivations/spatialIndex'
|
||||
import { getSvgJsx } from './getSvgJsx'
|
||||
import { ClickManager } from './managers/ClickManager'
|
||||
import { EnvironmentManager } from './managers/EnvironmentManager'
|
||||
|
@ -589,6 +589,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
)
|
||||
this._parentIdsToChildIds = parentsToChildren(this.store)
|
||||
|
||||
this._spatialIndex = new SpatialIndex(this)
|
||||
this.disposables.add(
|
||||
this.store.listen((changes) => {
|
||||
this.emit('change', changes)
|
||||
|
@ -4198,10 +4199,8 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
return this.isShapeOrAncestorLocked(this.getShapeParent(shape))
|
||||
}
|
||||
|
||||
@computed
|
||||
private _notVisibleShapes() {
|
||||
return notVisibleShapes(this)
|
||||
}
|
||||
/* @internal */
|
||||
private readonly _spatialIndex: SpatialIndex
|
||||
|
||||
/**
|
||||
* Get culled shapes.
|
||||
|
@ -4210,7 +4209,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
*/
|
||||
@computed
|
||||
getCulledShapes() {
|
||||
const notVisibleShapes = this._notVisibleShapes().get()
|
||||
const notVisibleShapes = this._spatialIndex.getNotVisibleShapes()
|
||||
const selectedShapeIds = this.getSelectedShapeIds()
|
||||
const editingId = this.getEditingShapeId()
|
||||
const culledShapes = new Set<TLShapeId>(notVisibleShapes)
|
||||
|
@ -4225,6 +4224,31 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
return culledShapes
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the shapes ids of shapes that are are (at least partially) inside the bounds.
|
||||
*
|
||||
* @param bounds - The bounds to check.
|
||||
* @returns The shape ids of shapes that are at least partially inside the bounds.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
getShapeIdsInsideBounds(bounds: Box): TLShapeId[] {
|
||||
return this._spatialIndex.getShapeIdsInsideBounds(bounds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the shapes that are are (at least partially) inside the bounds.
|
||||
*
|
||||
* @param bounds - The bounds to check.
|
||||
* @returns The shapes that are at least partially inside the bounds.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
getShapesInsideBounds(bounds: Box): TLShape[] {
|
||||
const shapeIds = this.getShapeIdsInsideBounds(bounds)
|
||||
return compact(shapeIds.map((id) => this.getShape(id)))
|
||||
}
|
||||
|
||||
/**
|
||||
* The bounds of the current page (the common bounds of all of the shapes on the page).
|
||||
*
|
||||
|
@ -4254,9 +4278,18 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
* @returns The top-most selected shape at the given point, or undefined if there is no shape at the point.
|
||||
*/
|
||||
getSelectedShapeAtPoint(point: VecLike): TLShape | undefined {
|
||||
const shapesCloseToPoint = new Set(
|
||||
this.getShapeIdsInsideBounds(Box.AroundPoint(point, HIT_TEST_MARGIN))
|
||||
)
|
||||
const selectedShapeIds = this.getSelectedShapeIds()
|
||||
|
||||
return this.getCurrentPageShapesSorted()
|
||||
.filter((shape) => shape.type !== 'group' && selectedShapeIds.includes(shape.id))
|
||||
.filter(
|
||||
(shape) =>
|
||||
shape.type !== 'group' &&
|
||||
shapesCloseToPoint.has(shape.id) &&
|
||||
selectedShapeIds.includes(shape.id)
|
||||
)
|
||||
.reverse() // findlast
|
||||
.find((shape) => this.isPointInShape(shape, point, { hitInside: true, margin: 0 }))
|
||||
}
|
||||
|
@ -4296,11 +4329,15 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
let inMarginClosestToEdgeDistance = Infinity
|
||||
let inMarginClosestToEdgeHit: TLShape | null = null
|
||||
|
||||
const shapesCloseToPoint = new Set(
|
||||
this.getShapeIdsInsideBounds(Box.AroundPoint(point, HIT_TEST_MARGIN))
|
||||
)
|
||||
const shapesToCheck = (
|
||||
opts.renderingOnly
|
||||
? this.getCurrentPageRenderingShapesSorted()
|
||||
: this.getCurrentPageShapesSorted()
|
||||
).filter((shape) => {
|
||||
if (!shapesCloseToPoint.has(shape.id)) return
|
||||
if (this.isShapeOfType(shape, 'group')) return false
|
||||
const pageMask = this.getShapeMask(shape)
|
||||
if (pageMask && !pointInPolygon(point, pageMask)) return false
|
||||
|
|
|
@ -1,105 +0,0 @@
|
|||
import { RESET_VALUE, computed, isUninitialized } from '@tldraw/state'
|
||||
import { TLPageId, TLShapeId, isShape, isShapeId } from '@tldraw/tlschema'
|
||||
import { Box } from '../../primitives/Box'
|
||||
import { Editor } from '../Editor'
|
||||
|
||||
function isShapeNotVisible(editor: Editor, id: TLShapeId, viewportPageBounds: Box): boolean {
|
||||
const maskedPageBounds = editor.getShapeMaskedPageBounds(id)
|
||||
// if the shape is fully outside of its parent's clipping bounds...
|
||||
if (maskedPageBounds === undefined) return true
|
||||
|
||||
// if the shape is fully outside of the viewport page bounds...
|
||||
return !viewportPageBounds.includes(maskedPageBounds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental derivation of not visible shapes.
|
||||
* Non visible shapes are shapes outside of the viewport page bounds and shapes outside of parent's clipping bounds.
|
||||
*
|
||||
* @param editor - Instance of the tldraw Editor.
|
||||
* @returns Incremental derivation of non visible shapes.
|
||||
*/
|
||||
export const notVisibleShapes = (editor: Editor) => {
|
||||
const isCullingOffScreenShapes = Number.isFinite(editor.renderingBoundsMargin)
|
||||
const shapeHistory = editor.store.query.filterHistory('shape')
|
||||
let lastPageId: TLPageId | null = null
|
||||
let prevViewportPageBounds: Box
|
||||
|
||||
function fromScratch(editor: Editor): Set<TLShapeId> {
|
||||
const shapes = editor.getCurrentPageShapeIds()
|
||||
lastPageId = editor.getCurrentPageId()
|
||||
const viewportPageBounds = editor.getViewportPageBounds()
|
||||
prevViewportPageBounds = viewportPageBounds.clone()
|
||||
const notVisibleShapes = new Set<TLShapeId>()
|
||||
shapes.forEach((id) => {
|
||||
if (isShapeNotVisible(editor, id, viewportPageBounds)) {
|
||||
notVisibleShapes.add(id)
|
||||
}
|
||||
})
|
||||
return notVisibleShapes
|
||||
}
|
||||
return computed<Set<TLShapeId>>('getCulledShapes', (prevValue, lastComputedEpoch) => {
|
||||
if (!isCullingOffScreenShapes) return new Set<TLShapeId>()
|
||||
|
||||
if (isUninitialized(prevValue)) {
|
||||
return fromScratch(editor)
|
||||
}
|
||||
const diff = shapeHistory.getDiffSince(lastComputedEpoch)
|
||||
|
||||
if (diff === RESET_VALUE) {
|
||||
return fromScratch(editor)
|
||||
}
|
||||
|
||||
const currentPageId = editor.getCurrentPageId()
|
||||
if (lastPageId !== currentPageId) {
|
||||
return fromScratch(editor)
|
||||
}
|
||||
const viewportPageBounds = editor.getViewportPageBounds()
|
||||
if (!prevViewportPageBounds || !viewportPageBounds.equals(prevViewportPageBounds)) {
|
||||
return fromScratch(editor)
|
||||
}
|
||||
|
||||
let nextValue = null as null | Set<TLShapeId>
|
||||
const addId = (id: TLShapeId) => {
|
||||
// Already added
|
||||
if (prevValue.has(id)) return
|
||||
if (!nextValue) nextValue = new Set(prevValue)
|
||||
nextValue.add(id)
|
||||
}
|
||||
const deleteId = (id: TLShapeId) => {
|
||||
// No need to delete since it's not there
|
||||
if (!prevValue.has(id)) return
|
||||
if (!nextValue) nextValue = new Set(prevValue)
|
||||
nextValue.delete(id)
|
||||
}
|
||||
|
||||
for (const changes of diff) {
|
||||
for (const record of Object.values(changes.added)) {
|
||||
if (isShape(record)) {
|
||||
const isCulled = isShapeNotVisible(editor, record.id, viewportPageBounds)
|
||||
if (isCulled) {
|
||||
addId(record.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [_from, to] of Object.values(changes.updated)) {
|
||||
if (isShape(to)) {
|
||||
const isCulled = isShapeNotVisible(editor, to.id, viewportPageBounds)
|
||||
if (isCulled) {
|
||||
addId(to.id)
|
||||
} else {
|
||||
deleteId(to.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const id of Object.keys(changes.removed)) {
|
||||
if (isShapeId(id)) {
|
||||
deleteId(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nextValue ?? prevValue
|
||||
})
|
||||
}
|
172
packages/editor/src/lib/editor/derivations/spatialIndex.ts
Normal file
172
packages/editor/src/lib/editor/derivations/spatialIndex.ts
Normal file
|
@ -0,0 +1,172 @@
|
|||
import { RESET_VALUE, computed, isUninitialized } from '@tldraw/state'
|
||||
import { TLPageId, TLShapeId, isShape, isShapeId } from '@tldraw/tlschema'
|
||||
import RBush from 'rbush'
|
||||
import { Box } from '../../primitives/Box'
|
||||
import { Editor } from '../Editor'
|
||||
|
||||
type Element = {
|
||||
minX: number
|
||||
minY: number
|
||||
maxX: number
|
||||
maxY: number
|
||||
id: TLShapeId
|
||||
}
|
||||
|
||||
class TldrawRBush extends RBush<Element> {}
|
||||
|
||||
export class SpatialIndex {
|
||||
private readonly spatialIndex: ReturnType<typeof this.createSpatialIndex>
|
||||
private lastPageId: TLPageId | null = null
|
||||
private shapesInTree: Map<TLShapeId, Element>
|
||||
private rBush: TldrawRBush
|
||||
|
||||
constructor(private editor: Editor) {
|
||||
this.spatialIndex = this.createSpatialIndex()
|
||||
this.shapesInTree = new Map<TLShapeId, Element>()
|
||||
this.rBush = new TldrawRBush()
|
||||
}
|
||||
|
||||
private addElement(id: TLShapeId, a: Element[], existingBounds?: Box) {
|
||||
const e = this.getElement(id, existingBounds)
|
||||
if (!e) return
|
||||
a.push(e)
|
||||
this.shapesInTree.set(id, e)
|
||||
}
|
||||
|
||||
private getElement(id: TLShapeId, existingBounds?: Box): Element | null {
|
||||
const bounds = existingBounds ?? this.editor.getShapeMaskedPageBounds(id)
|
||||
if (!bounds) return null
|
||||
return {
|
||||
minX: bounds.minX,
|
||||
minY: bounds.minY,
|
||||
maxX: bounds.maxX,
|
||||
maxY: bounds.maxY,
|
||||
id,
|
||||
}
|
||||
}
|
||||
|
||||
private fromScratch(lastComputedEpoch: number) {
|
||||
this.lastPageId = this.editor.getCurrentPageId()
|
||||
this.shapesInTree = new Map<TLShapeId, Element>()
|
||||
const elementsToAdd: Element[] = []
|
||||
|
||||
this.editor.getCurrentPageShapeIds().forEach((id) => {
|
||||
this.addElement(id, elementsToAdd)
|
||||
})
|
||||
|
||||
this.rBush = new TldrawRBush().load(elementsToAdd)
|
||||
|
||||
return lastComputedEpoch
|
||||
}
|
||||
|
||||
private createSpatialIndex() {
|
||||
const shapeHistory = this.editor.store.query.filterHistory('shape')
|
||||
|
||||
return computed<number>('spatialIndex', (prevValue, lastComputedEpoch) => {
|
||||
if (isUninitialized(prevValue)) {
|
||||
return this.fromScratch(lastComputedEpoch)
|
||||
}
|
||||
|
||||
const diff = shapeHistory.getDiffSince(lastComputedEpoch)
|
||||
if (diff === RESET_VALUE) {
|
||||
return this.fromScratch(lastComputedEpoch)
|
||||
}
|
||||
|
||||
const currentPageId = this.editor.getCurrentPageId()
|
||||
if (!this.lastPageId || this.lastPageId !== currentPageId) {
|
||||
return this.fromScratch(lastComputedEpoch)
|
||||
}
|
||||
|
||||
let isDirty = false
|
||||
for (const changes of diff) {
|
||||
const elementsToAdd: Element[] = []
|
||||
for (const record of Object.values(changes.added)) {
|
||||
if (isShape(record)) {
|
||||
this.addElement(record.id, elementsToAdd)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [_from, to] of Object.values(changes.updated)) {
|
||||
if (isShape(to)) {
|
||||
const currentElement = this.shapesInTree.get(to.id)
|
||||
const newBounds = this.editor.getShapeMaskedPageBounds(to.id)
|
||||
if (currentElement) {
|
||||
if (
|
||||
newBounds?.minX === currentElement.minX &&
|
||||
newBounds.minY === currentElement.minY &&
|
||||
newBounds.maxX === currentElement.maxX &&
|
||||
newBounds.maxY === currentElement.maxY
|
||||
) {
|
||||
continue
|
||||
}
|
||||
this.shapesInTree.delete(to.id)
|
||||
this.rBush.remove(currentElement)
|
||||
isDirty = true
|
||||
}
|
||||
this.addElement(to.id, elementsToAdd, newBounds)
|
||||
}
|
||||
}
|
||||
if (elementsToAdd.length) {
|
||||
this.rBush.load(elementsToAdd)
|
||||
isDirty = true
|
||||
}
|
||||
for (const id of Object.keys(changes.removed)) {
|
||||
if (isShapeId(id)) {
|
||||
const currentElement = this.shapesInTree.get(id)
|
||||
if (currentElement) {
|
||||
this.shapesInTree.delete(id)
|
||||
this.rBush.remove(currentElement)
|
||||
isDirty = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return isDirty ? lastComputedEpoch : prevValue
|
||||
})
|
||||
}
|
||||
|
||||
@computed
|
||||
private _getVisibleShapes() {
|
||||
return computed<Set<TLShapeId>>('visible shapes', (prevValue) => {
|
||||
// Make sure the spatial index is up to date
|
||||
const _index = this.spatialIndex.get()
|
||||
const newValue = this.rBush.search(this.editor.getViewportPageBounds()).map((s) => s.id)
|
||||
if (isUninitialized(prevValue)) {
|
||||
return new Set(newValue)
|
||||
}
|
||||
const isSame = prevValue.size === newValue.length && newValue.every((id) => prevValue.has(id))
|
||||
return isSame ? prevValue : new Set(newValue)
|
||||
})
|
||||
}
|
||||
|
||||
@computed
|
||||
getVisibleShapes() {
|
||||
return this._getVisibleShapes().get()
|
||||
}
|
||||
|
||||
@computed
|
||||
_getNotVisibleShapes() {
|
||||
return computed<Set<TLShapeId>>('not visible shapes', (prevValue) => {
|
||||
const visibleShapes = this._getVisibleShapes().get()
|
||||
const pageShapes = this.editor.getCurrentPageShapeIds()
|
||||
const nonVisibleShapes = [...pageShapes].filter((id) => !visibleShapes.has(id))
|
||||
if (isUninitialized(prevValue)) return new Set(nonVisibleShapes)
|
||||
const isSame =
|
||||
prevValue.size === nonVisibleShapes.length &&
|
||||
nonVisibleShapes.every((id) => prevValue.has(id))
|
||||
return isSame ? prevValue : new Set(nonVisibleShapes)
|
||||
})
|
||||
}
|
||||
|
||||
@computed
|
||||
getNotVisibleShapes() {
|
||||
return this._getNotVisibleShapes().get()
|
||||
}
|
||||
|
||||
getShapeIdsInsideBounds(bounds: Box) {
|
||||
// Make sure the spatial index is up to date
|
||||
const _index = this.spatialIndex.get()
|
||||
return this.rBush.search(bounds).map((s) => s.id)
|
||||
}
|
||||
}
|
|
@ -376,6 +376,10 @@ export class Box {
|
|||
return new Box(minX, minY, maxX - minX, maxY - minY)
|
||||
}
|
||||
|
||||
static AroundPoint(point: VecLike, n: number) {
|
||||
return new Box(point.x - n, point.y - n, 2 * n, 2 * n)
|
||||
}
|
||||
|
||||
static Expand(A: Box, B: Box) {
|
||||
const minX = Math.min(B.minX, A.minX)
|
||||
const minY = Math.min(B.minY, A.minY)
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
Box,
|
||||
HIT_TEST_MARGIN,
|
||||
StateNode,
|
||||
TLEventHandlers,
|
||||
|
@ -80,9 +81,8 @@ export class Erasing extends StateNode {
|
|||
|
||||
update() {
|
||||
const { editor, excludedShapeIds } = this
|
||||
const erasingShapeIds = editor.getErasingShapeIds()
|
||||
const zoomLevel = editor.getZoomLevel()
|
||||
const currentPageShapes = editor.getCurrentPageShapes()
|
||||
const erasingShapeIds = this.editor.getErasingShapeIds()
|
||||
const zoomLevel = this.editor.getZoomLevel()
|
||||
const {
|
||||
inputs: { currentPagePoint, previousPagePoint },
|
||||
} = editor
|
||||
|
@ -92,8 +92,11 @@ export class Erasing extends StateNode {
|
|||
const erasing = new Set<TLShapeId>(erasingShapeIds)
|
||||
const minDist = HIT_TEST_MARGIN / zoomLevel
|
||||
|
||||
for (const shape of currentPageShapes) {
|
||||
if (editor.isShapeOfType<TLGroupShape>(shape, 'group')) continue
|
||||
const shapesNearPoint = this.editor.getShapesInsideBounds(
|
||||
Box.FromPoints([currentPagePoint, previousPagePoint]).expandBy(minDist)
|
||||
)
|
||||
for (const shape of shapesNearPoint) {
|
||||
if (this.editor.isShapeOfType<TLGroupShape>(shape, 'group')) continue
|
||||
|
||||
// Avoid testing masked shapes, unless the pointer is inside the mask
|
||||
const pageMask = editor.getShapeMask(shape.id)
|
||||
|
|
|
@ -125,12 +125,13 @@ export class Brushing extends StateNode {
|
|||
pageTransform: Mat | undefined,
|
||||
localCorners: Vec[]
|
||||
|
||||
const currentPageShapes = editor.getCurrentPageShapes()
|
||||
const currentPageId = editor.getCurrentPageId()
|
||||
|
||||
testAllShapes: for (let i = 0, n = currentPageShapes.length; i < n; i++) {
|
||||
shape = currentPageShapes[i]
|
||||
if (excludedShapeIds.has(shape.id) || results.has(shape.id)) continue testAllShapes
|
||||
const shapesInsideBounds = this.editor.getShapesInsideBounds(brush)
|
||||
testAllShapes: for (let i = 0, n = shapesInsideBounds.length; i < n; i++) {
|
||||
shape = shapesInsideBounds[i]
|
||||
if (excludedShapeIds.has(shape.id)) continue testAllShapes
|
||||
if (results.has(shape.id)) continue testAllShapes
|
||||
|
||||
pageBounds = editor.getShapePageBounds(shape)
|
||||
if (!pageBounds) continue testAllShapes
|
||||
|
@ -176,7 +177,7 @@ export class Brushing extends StateNode {
|
|||
|
||||
const current = editor.getSelectedShapeIds()
|
||||
if (current.length !== results.size || current.some((id) => !results.has(id))) {
|
||||
editor.setSelectedShapes(Array.from(results), { squashing: true })
|
||||
editor.setSelectedShapes(Array.from(results).sort(), { squashing: true })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
Box,
|
||||
Geometry2d,
|
||||
StateNode,
|
||||
TLEventHandlers,
|
||||
|
@ -83,8 +84,6 @@ export class ScribbleBrushing extends StateNode {
|
|||
|
||||
private updateScribbleSelection(addPoint: boolean) {
|
||||
const { editor } = this
|
||||
// const zoomLevel = this.editor.getZoomLevel()
|
||||
const currentPageShapes = this.editor.getCurrentPageShapes()
|
||||
const {
|
||||
inputs: { shiftKey, originPagePoint, previousPagePoint, currentPagePoint },
|
||||
} = this.editor
|
||||
|
@ -95,7 +94,9 @@ export class ScribbleBrushing extends StateNode {
|
|||
this.pushPointToScribble()
|
||||
}
|
||||
|
||||
const shapes = currentPageShapes
|
||||
const shapes = this.editor.getShapesInsideBounds(
|
||||
Box.FromPoints([previousPagePoint, currentPagePoint])
|
||||
)
|
||||
let shape: TLShape, geometry: Geometry2d, A: Vec, B: Vec
|
||||
|
||||
const minDist = 0 // HIT_TEST_MARGIN / zoomLevel
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Box, TLShapeId, createShapeId } from '@tldraw/editor'
|
||||
import { Box, PageRecordType, TLShapeId, createShapeId } from '@tldraw/editor'
|
||||
import { TestEditor } from './TestEditor'
|
||||
import { TL } from './test-jsx'
|
||||
|
||||
|
@ -130,8 +130,14 @@ it('correctly calculates the culled shapes when adding and deleting shapes', ()
|
|||
const culledShapesIncremental = editor.getCulledShapes()
|
||||
|
||||
// force full refresh
|
||||
editor.pan({ x: -1, y: 0 })
|
||||
editor.pan({ x: 1, y: 0 })
|
||||
const currentPage = editor.getCurrentPageId()
|
||||
const id = PageRecordType.createId('page2')
|
||||
editor.createPage({ id, name: 'Page 2' })
|
||||
editor.setCurrentPage(id)
|
||||
// Make sure we access the culled shapes so that the editor knows to update them
|
||||
// In prod this happens automatically since rendering of shapes depends on the culled shapes
|
||||
const _culledOnOtherPage = editor.getCulledShapes()
|
||||
editor.setCurrentPage(currentPage)
|
||||
|
||||
const culledShapeFromScratch = editor.getCulledShapes()
|
||||
expect(culledShapesIncremental).toEqual(culledShapeFromScratch)
|
||||
|
|
|
@ -1612,16 +1612,16 @@ describe('shift brushes to add to the selection', () => {
|
|||
editor.keyDown('Shift')
|
||||
editor.pointerDown()
|
||||
editor.pointerMove(1, 1)
|
||||
expect(editor.getSelectedShapeIds()).toEqual([ids.box2, ids.box1])
|
||||
expect(editor.getSelectedShapeIds()).toEqual([ids.box1, ids.box2])
|
||||
editor.keyUp('Shift')
|
||||
// there's a timer here—we should keep the shift mode until the timer expires
|
||||
expect(editor.getSelectedShapeIds()).toEqual([ids.box2, ids.box1])
|
||||
expect(editor.getSelectedShapeIds()).toEqual([ids.box1, ids.box2])
|
||||
jest.advanceTimersByTime(500)
|
||||
// once the timer expires, we should be back in regular mode
|
||||
expect(editor.getSelectedShapeIds()).toEqual([ids.box1])
|
||||
editor.keyDown('Shift')
|
||||
// there's no timer on key down, so go right into shift mode again
|
||||
expect(editor.getSelectedShapeIds()).toEqual([ids.box2, ids.box1])
|
||||
expect(editor.getSelectedShapeIds()).toEqual([ids.box1, ids.box2])
|
||||
})
|
||||
})
|
||||
|
||||
|
|
114
packages/tldraw/src/test/spatialIndex.test.ts
Normal file
114
packages/tldraw/src/test/spatialIndex.test.ts
Normal file
|
@ -0,0 +1,114 @@
|
|||
import { Box, PageRecordType, TLShapeId, createShapeId } from '@tldraw/editor'
|
||||
import { TestEditor } from './TestEditor'
|
||||
|
||||
let editor: TestEditor
|
||||
|
||||
beforeEach(() => {
|
||||
editor = new TestEditor()
|
||||
})
|
||||
|
||||
const NUM_SHAPES = 1000
|
||||
const SHAPE_SIZE = { min: 100, max: 300 }
|
||||
const NUM_QUERIES = 100
|
||||
|
||||
type IdAndBounds = { id: TLShapeId; bounds: Box }
|
||||
|
||||
function generateShapes() {
|
||||
const result: IdAndBounds[] = []
|
||||
for (let i = 0; i < NUM_SHAPES; 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() * NUM_SHAPES) / 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(NUM_SHAPES)
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
25
yarn.lock
25
yarn.lock
|
@ -7489,6 +7489,7 @@ __metadata:
|
|||
"@types/core-js": "npm:^2.5.5"
|
||||
"@types/lodash.throttle": "npm:^4.1.7"
|
||||
"@types/lodash.uniq": "npm:^4.5.7"
|
||||
"@types/rbush": "npm:^3.0.3"
|
||||
"@types/react-test-renderer": "npm:^18.0.0"
|
||||
"@types/wicg-file-system-access": "npm:^2020.9.5"
|
||||
"@use-gesture/react": "npm:^10.2.27"
|
||||
|
@ -7505,6 +7506,7 @@ __metadata:
|
|||
lodash.throttle: "npm:^4.1.1"
|
||||
lodash.uniq: "npm:^4.5.0"
|
||||
nanoid: "npm:4.0.2"
|
||||
rbush: "npm:^3.0.1"
|
||||
react-test-renderer: "npm:^18.2.0"
|
||||
resize-observer-polyfill: "npm:^1.5.1"
|
||||
peerDependencies:
|
||||
|
@ -8331,6 +8333,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/rbush@npm:^3.0.3":
|
||||
version: 3.0.3
|
||||
resolution: "@types/rbush@npm:3.0.3"
|
||||
checksum: 59c75d20d3ebf95f8853a98f67d437adc047bf875df6e6bba90884fdfa8fa927402ccec762ecbc8724d98f9ed14c9e97d16eddb709a702021ce1874da5d0d8d7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react-dom@npm:^18.0.0, @types/react-dom@npm:^18.2.18":
|
||||
version: 18.2.18
|
||||
resolution: "@types/react-dom@npm:18.2.18"
|
||||
|
@ -21070,6 +21079,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"quickselect@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "quickselect@npm:2.0.0"
|
||||
checksum: ed2e78431050d223fb75da20ee98011aef1a03f7cb04e1a32ee893402e640be3cfb76d72e9dbe01edf3bb457ff6a62e5c2d85748424d1aa531f6ba50daef098c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"raf@npm:^3.4.1":
|
||||
version: 3.4.1
|
||||
resolution: "raf@npm:3.4.1"
|
||||
|
@ -21107,6 +21123,15 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"rbush@npm:^3.0.1":
|
||||
version: 3.0.1
|
||||
resolution: "rbush@npm:3.0.1"
|
||||
dependencies:
|
||||
quickselect: "npm:^2.0.0"
|
||||
checksum: 489e2e7d9889888ad533518f194e3ab7cc19b1f1365a38ee99fbdda542a47f41cda7dc89870180050f4d04ea402e9ff294e1d767d03c0f1694e0028b7609eec9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"rc@npm:^1.2.7, rc@npm:^1.2.8, rc@npm:~1.2.7":
|
||||
version: 1.2.8
|
||||
resolution: "rc@npm:1.2.8"
|
||||
|
|
Loading…
Reference in a new issue