Perf: Incremental culled shapes calculation. (#3411)
Reworks our culling logic: - No longer show the gray rectangles for culled shapes. - Don't use `renderingBoundExpanded`, instead we now use `viewportPageBounds`. I've removed `renderingBoundsExpanded`, but we might want to deprecate it? - There's now a incremental computation of non visible shapes, which are shapes outside of `viewportPageBounds` and shapes that outside of their parents' clipping bounds. - There's also a new `getCulledShapes` function in `Editor`, which uses the non visible shapes computation as a part of the culled shape computation. - Also moved some of the `getRenderingShapes` tests to newly created `getCullingShapes` tests. Feels much better on my old, 2017 ipad (first tab is this PR, second is current prod, third is staging). https://github.com/tldraw/tldraw/assets/2523721/327a7313-9273-4350-89a0-617a30fc01a2 ### Change Type <!-- ❗ Please select a 'Scope' label ❗️ --> - [x] `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 - [ ] `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 ### Test Plan 1. Regular culling shapes tests. Pan / zoom around. Use minimap. Change pages. - [x] Unit Tests - [ ] End to end tests --------- Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
This commit is contained in:
parent
2bbab1a790
commit
987b1ac0b9
10 changed files with 357 additions and 401 deletions
|
@ -5,25 +5,31 @@ export function useChangedShapesReactor(
|
||||||
cb: (info: { culled: TLShape[]; restored: TLShape[] }) => void
|
cb: (info: { culled: TLShape[]; restored: TLShape[] }) => void
|
||||||
) {
|
) {
|
||||||
const editor = useEditor()
|
const editor = useEditor()
|
||||||
const rPrevShapes = useRef(editor.getRenderingShapes())
|
const rPrevShapes = useRef({
|
||||||
|
renderingShapes: editor.getRenderingShapes(),
|
||||||
|
culledShapes: editor.getCulledShapes(),
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return react('when rendering shapes change', () => {
|
return react('when rendering shapes change', () => {
|
||||||
const after = editor.getRenderingShapes()
|
const after = {
|
||||||
|
culledShapes: editor.getCulledShapes(),
|
||||||
|
renderingShapes: editor.getRenderingShapes(),
|
||||||
|
}
|
||||||
const before = rPrevShapes.current
|
const before = rPrevShapes.current
|
||||||
|
|
||||||
const culled: TLShape[] = []
|
const culled: TLShape[] = []
|
||||||
const restored: TLShape[] = []
|
const restored: TLShape[] = []
|
||||||
|
|
||||||
const beforeToVisit = new Set(before)
|
const beforeToVisit = new Set(before.renderingShapes)
|
||||||
|
|
||||||
for (const afterInfo of after) {
|
for (const afterInfo of after.renderingShapes) {
|
||||||
const beforeInfo = before.find((s) => s.id === afterInfo.id)
|
const beforeInfo = before.renderingShapes.find((s) => s.id === afterInfo.id)
|
||||||
if (!beforeInfo) {
|
if (!beforeInfo) {
|
||||||
continue
|
continue
|
||||||
} else {
|
} else {
|
||||||
const isAfterCulled = editor.isShapeCulled(afterInfo.id)
|
const isAfterCulled = after.culledShapes.has(afterInfo.id)
|
||||||
const isBeforeCulled = editor.isShapeCulled(beforeInfo.id)
|
const isBeforeCulled = before.culledShapes.has(beforeInfo.id)
|
||||||
if (isAfterCulled && !isBeforeCulled) {
|
if (isAfterCulled && !isBeforeCulled) {
|
||||||
culled.push(afterInfo.shape)
|
culled.push(afterInfo.shape)
|
||||||
} else if (!isAfterCulled && isBeforeCulled) {
|
} else if (!isAfterCulled && isBeforeCulled) {
|
||||||
|
|
|
@ -675,6 +675,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
// @internal
|
// @internal
|
||||||
getCrashingError(): unknown;
|
getCrashingError(): unknown;
|
||||||
getCroppingShapeId(): null | TLShapeId;
|
getCroppingShapeId(): null | TLShapeId;
|
||||||
|
getCulledShapes(): Set<TLShapeId>;
|
||||||
getCurrentPage(): TLPage;
|
getCurrentPage(): TLPage;
|
||||||
getCurrentPageBounds(): Box | undefined;
|
getCurrentPageBounds(): Box | undefined;
|
||||||
getCurrentPageId(): TLPageId;
|
getCurrentPageId(): TLPageId;
|
||||||
|
@ -712,7 +713,6 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
getPointInParentSpace(shape: TLShape | TLShapeId, point: VecLike): Vec;
|
getPointInParentSpace(shape: TLShape | TLShapeId, point: VecLike): Vec;
|
||||||
getPointInShapeSpace(shape: TLShape | TLShapeId, point: VecLike): Vec;
|
getPointInShapeSpace(shape: TLShape | TLShapeId, point: VecLike): Vec;
|
||||||
getRenderingBounds(): Box;
|
getRenderingBounds(): Box;
|
||||||
getRenderingBoundsExpanded(): Box;
|
|
||||||
getRenderingShapes(): {
|
getRenderingShapes(): {
|
||||||
id: TLShapeId;
|
id: TLShapeId;
|
||||||
shape: TLShape;
|
shape: TLShape;
|
||||||
|
@ -817,7 +817,6 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
margin?: number | undefined;
|
margin?: number | undefined;
|
||||||
hitInside?: boolean | undefined;
|
hitInside?: boolean | undefined;
|
||||||
}): boolean;
|
}): boolean;
|
||||||
isShapeCulled(shape: TLShape | TLShapeId): boolean;
|
|
||||||
isShapeInPage(shape: TLShape | TLShapeId, pageId?: TLPageId): boolean;
|
isShapeInPage(shape: TLShape | TLShapeId, pageId?: TLPageId): boolean;
|
||||||
isShapeOfType<T extends TLUnknownShape>(shape: TLUnknownShape, type: T['type']): shape is T;
|
isShapeOfType<T extends TLUnknownShape>(shape: TLUnknownShape, type: T['type']): shape is T;
|
||||||
// (undocumented)
|
// (undocumented)
|
||||||
|
|
|
@ -10284,6 +10284,51 @@
|
||||||
"isAbstract": false,
|
"isAbstract": false,
|
||||||
"name": "getCroppingShapeId"
|
"name": "getCroppingShapeId"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"kind": "Method",
|
||||||
|
"canonicalReference": "@tldraw/editor!Editor#getCulledShapes:member(1)",
|
||||||
|
"docComment": "/**\n * Get culled shapes.\n *\n * @public\n */\n",
|
||||||
|
"excerptTokens": [
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": "getCulledShapes(): "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Reference",
|
||||||
|
"text": "Set",
|
||||||
|
"canonicalReference": "!Set:interface"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": "<"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Reference",
|
||||||
|
"text": "TLShapeId",
|
||||||
|
"canonicalReference": "@tldraw/tlschema!TLShapeId:type"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": ">"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": ";"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isStatic": false,
|
||||||
|
"returnTypeTokenRange": {
|
||||||
|
"startIndex": 1,
|
||||||
|
"endIndex": 5
|
||||||
|
},
|
||||||
|
"releaseTag": "Public",
|
||||||
|
"isProtected": false,
|
||||||
|
"overloadIndex": 1,
|
||||||
|
"parameters": [],
|
||||||
|
"isOptional": false,
|
||||||
|
"isAbstract": false,
|
||||||
|
"name": "getCulledShapes"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"kind": "Method",
|
"kind": "Method",
|
||||||
"canonicalReference": "@tldraw/editor!Editor#getCurrentPage:member(1)",
|
"canonicalReference": "@tldraw/editor!Editor#getCurrentPage:member(1)",
|
||||||
|
@ -11876,38 +11921,6 @@
|
||||||
"isAbstract": false,
|
"isAbstract": false,
|
||||||
"name": "getRenderingBounds"
|
"name": "getRenderingBounds"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"kind": "Method",
|
|
||||||
"canonicalReference": "@tldraw/editor!Editor#getRenderingBoundsExpanded:member(1)",
|
|
||||||
"docComment": "/**\n * The current rendering bounds in the current page space, expanded slightly. Used for determining which shapes to render and which to \"cull\".\n *\n * @public\n */\n",
|
|
||||||
"excerptTokens": [
|
|
||||||
{
|
|
||||||
"kind": "Content",
|
|
||||||
"text": "getRenderingBoundsExpanded(): "
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "Reference",
|
|
||||||
"text": "Box",
|
|
||||||
"canonicalReference": "@tldraw/editor!Box:class"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "Content",
|
|
||||||
"text": ";"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"isStatic": false,
|
|
||||||
"returnTypeTokenRange": {
|
|
||||||
"startIndex": 1,
|
|
||||||
"endIndex": 2
|
|
||||||
},
|
|
||||||
"releaseTag": "Public",
|
|
||||||
"isProtected": false,
|
|
||||||
"overloadIndex": 1,
|
|
||||||
"parameters": [],
|
|
||||||
"isOptional": false,
|
|
||||||
"isAbstract": false,
|
|
||||||
"name": "getRenderingBoundsExpanded"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"kind": "Method",
|
"kind": "Method",
|
||||||
"canonicalReference": "@tldraw/editor!Editor#getRenderingShapes:member(1)",
|
"canonicalReference": "@tldraw/editor!Editor#getRenderingShapes:member(1)",
|
||||||
|
@ -14814,64 +14827,6 @@
|
||||||
"isAbstract": false,
|
"isAbstract": false,
|
||||||
"name": "isPointInShape"
|
"name": "isPointInShape"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"kind": "Method",
|
|
||||||
"canonicalReference": "@tldraw/editor!Editor#isShapeCulled:member(1)",
|
|
||||||
"docComment": "/**\n * Get whether the shape is culled or not.\n *\n * @param shape - The shape (or shape id) to get the culled info for.\n *\n * @example\n * ```ts\n * editor.isShapeCulled(myShape)\n * editor.isShapeCulled(myShapeId)\n * ```\n *\n * @public\n */\n",
|
|
||||||
"excerptTokens": [
|
|
||||||
{
|
|
||||||
"kind": "Content",
|
|
||||||
"text": "isShapeCulled(shape: "
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "Reference",
|
|
||||||
"text": "TLShape",
|
|
||||||
"canonicalReference": "@tldraw/tlschema!TLShape:type"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "Content",
|
|
||||||
"text": " | "
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "Reference",
|
|
||||||
"text": "TLShapeId",
|
|
||||||
"canonicalReference": "@tldraw/tlschema!TLShapeId:type"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "Content",
|
|
||||||
"text": "): "
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "Content",
|
|
||||||
"text": "boolean"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "Content",
|
|
||||||
"text": ";"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"isStatic": false,
|
|
||||||
"returnTypeTokenRange": {
|
|
||||||
"startIndex": 5,
|
|
||||||
"endIndex": 6
|
|
||||||
},
|
|
||||||
"releaseTag": "Public",
|
|
||||||
"isProtected": false,
|
|
||||||
"overloadIndex": 1,
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"parameterName": "shape",
|
|
||||||
"parameterTypeTokenRange": {
|
|
||||||
"startIndex": 1,
|
|
||||||
"endIndex": 4
|
|
||||||
},
|
|
||||||
"isOptional": false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"isOptional": false,
|
|
||||||
"isAbstract": false,
|
|
||||||
"name": "isShapeCulled"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"kind": "Method",
|
"kind": "Method",
|
||||||
"canonicalReference": "@tldraw/editor!Editor#isShapeInPage:member(1)",
|
"canonicalReference": "@tldraw/editor!Editor#isShapeInPage:member(1)",
|
||||||
|
|
|
@ -1,186 +0,0 @@
|
||||||
import { computed, react } from '@tldraw/state'
|
|
||||||
import { useEffect, useRef } from 'react'
|
|
||||||
import { useEditor } from '../hooks/useEditor'
|
|
||||||
import { useIsDarkMode } from '../hooks/useIsDarkMode'
|
|
||||||
|
|
||||||
// Parts of the below code are taken from MIT licensed project:
|
|
||||||
// https://github.com/sessamekesh/webgl-tutorials-2023
|
|
||||||
function setupWebGl(canvas: HTMLCanvasElement | null, isDarkMode: boolean) {
|
|
||||||
if (!canvas) return
|
|
||||||
|
|
||||||
const context = canvas.getContext('webgl2')
|
|
||||||
if (!context) return
|
|
||||||
|
|
||||||
const vertexShaderSourceCode = `#version 300 es
|
|
||||||
precision mediump float;
|
|
||||||
|
|
||||||
in vec2 shapeVertexPosition;
|
|
||||||
uniform vec2 viewportStart;
|
|
||||||
uniform vec2 viewportEnd;
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
// We need to transform from page coordinates to something WebGl understands
|
|
||||||
float viewportWidth = viewportEnd.x - viewportStart.x;
|
|
||||||
float viewportHeight = viewportEnd.y - viewportStart.y;
|
|
||||||
vec2 finalPosition = vec2(
|
|
||||||
2.0 * (shapeVertexPosition.x - viewportStart.x) / viewportWidth - 1.0,
|
|
||||||
1.0 - 2.0 * (shapeVertexPosition.y - viewportStart.y) / viewportHeight
|
|
||||||
);
|
|
||||||
gl_Position = vec4(finalPosition, 0.0, 1.0);
|
|
||||||
}`
|
|
||||||
|
|
||||||
const vertexShader = context.createShader(context.VERTEX_SHADER)
|
|
||||||
if (!vertexShader) return
|
|
||||||
context.shaderSource(vertexShader, vertexShaderSourceCode)
|
|
||||||
context.compileShader(vertexShader)
|
|
||||||
if (!context.getShaderParameter(vertexShader, context.COMPILE_STATUS)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Dark = hsl(210, 11%, 19%)
|
|
||||||
// Light = hsl(204, 14%, 93%)
|
|
||||||
const color = isDarkMode ? 'vec4(0.169, 0.188, 0.212, 1.0)' : 'vec4(0.922, 0.933, 0.941, 1.0)'
|
|
||||||
|
|
||||||
const fragmentShaderSourceCode = `#version 300 es
|
|
||||||
precision mediump float;
|
|
||||||
|
|
||||||
out vec4 outputColor;
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
outputColor = ${color};
|
|
||||||
}`
|
|
||||||
|
|
||||||
const fragmentShader = context.createShader(context.FRAGMENT_SHADER)
|
|
||||||
if (!fragmentShader) return
|
|
||||||
context.shaderSource(fragmentShader, fragmentShaderSourceCode)
|
|
||||||
context.compileShader(fragmentShader)
|
|
||||||
if (!context.getShaderParameter(fragmentShader, context.COMPILE_STATUS)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const program = context.createProgram()
|
|
||||||
if (!program) return
|
|
||||||
context.attachShader(program, vertexShader)
|
|
||||||
context.attachShader(program, fragmentShader)
|
|
||||||
context.linkProgram(program)
|
|
||||||
if (!context.getProgramParameter(program, context.LINK_STATUS)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
context.useProgram(program)
|
|
||||||
|
|
||||||
const shapeVertexPositionAttributeLocation = context.getAttribLocation(
|
|
||||||
program,
|
|
||||||
'shapeVertexPosition'
|
|
||||||
)
|
|
||||||
if (shapeVertexPositionAttributeLocation < 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
context.enableVertexAttribArray(shapeVertexPositionAttributeLocation)
|
|
||||||
|
|
||||||
const viewportStartUniformLocation = context.getUniformLocation(program, 'viewportStart')
|
|
||||||
const viewportEndUniformLocation = context.getUniformLocation(program, 'viewportEnd')
|
|
||||||
if (!viewportStartUniformLocation || !viewportEndUniformLocation) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
context,
|
|
||||||
program,
|
|
||||||
shapeVertexPositionAttributeLocation,
|
|
||||||
viewportStartUniformLocation,
|
|
||||||
viewportEndUniformLocation,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function _CulledShapes() {
|
|
||||||
const editor = useEditor()
|
|
||||||
const isDarkMode = useIsDarkMode()
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
||||||
|
|
||||||
const isCullingOffScreenShapes = Number.isFinite(editor.renderingBoundsMargin)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const webGl = setupWebGl(canvasRef.current, isDarkMode)
|
|
||||||
if (!webGl) return
|
|
||||||
if (!isCullingOffScreenShapes) return
|
|
||||||
|
|
||||||
const {
|
|
||||||
context,
|
|
||||||
shapeVertexPositionAttributeLocation,
|
|
||||||
viewportStartUniformLocation,
|
|
||||||
viewportEndUniformLocation,
|
|
||||||
} = webGl
|
|
||||||
|
|
||||||
const shapeVertices = computed('shape vertices', function calculateCulledShapeVertices() {
|
|
||||||
const results: number[] = []
|
|
||||||
|
|
||||||
for (const { id } of editor.getUnorderedRenderingShapes(true)) {
|
|
||||||
const maskedPageBounds = editor.getShapeMaskedPageBounds(id)
|
|
||||||
if (editor.isShapeCulled(id) && maskedPageBounds) {
|
|
||||||
results.push(
|
|
||||||
// triangle 1
|
|
||||||
maskedPageBounds.minX,
|
|
||||||
maskedPageBounds.minY,
|
|
||||||
maskedPageBounds.minX,
|
|
||||||
maskedPageBounds.maxY,
|
|
||||||
maskedPageBounds.maxX,
|
|
||||||
maskedPageBounds.maxY,
|
|
||||||
// triangle 2
|
|
||||||
maskedPageBounds.minX,
|
|
||||||
maskedPageBounds.minY,
|
|
||||||
maskedPageBounds.maxX,
|
|
||||||
maskedPageBounds.minY,
|
|
||||||
maskedPageBounds.maxX,
|
|
||||||
maskedPageBounds.maxY
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
})
|
|
||||||
|
|
||||||
return react('render culled shapes ', function renderCulledShapes() {
|
|
||||||
const canvas = canvasRef.current
|
|
||||||
if (!canvas) return
|
|
||||||
|
|
||||||
const width = canvas.clientWidth
|
|
||||||
const height = canvas.clientHeight
|
|
||||||
if (width !== canvas.width || height !== canvas.height) {
|
|
||||||
canvas.width = width
|
|
||||||
canvas.height = height
|
|
||||||
context.viewport(0, 0, width, height)
|
|
||||||
}
|
|
||||||
|
|
||||||
const verticesArray = shapeVertices.get()
|
|
||||||
|
|
||||||
context.clear(context.COLOR_BUFFER_BIT | context.DEPTH_BUFFER_BIT)
|
|
||||||
|
|
||||||
if (verticesArray.length > 0) {
|
|
||||||
const viewport = editor.getViewportPageBounds() // when the viewport changes...
|
|
||||||
context.uniform2f(viewportStartUniformLocation, viewport.minX, viewport.minY)
|
|
||||||
context.uniform2f(viewportEndUniformLocation, viewport.maxX, viewport.maxY)
|
|
||||||
const triangleGeoCpuBuffer = new Float32Array(verticesArray)
|
|
||||||
const triangleGeoBuffer = context.createBuffer()
|
|
||||||
context.bindBuffer(context.ARRAY_BUFFER, triangleGeoBuffer)
|
|
||||||
context.bufferData(context.ARRAY_BUFFER, triangleGeoCpuBuffer, context.STATIC_DRAW)
|
|
||||||
context.vertexAttribPointer(
|
|
||||||
shapeVertexPositionAttributeLocation,
|
|
||||||
2,
|
|
||||||
context.FLOAT,
|
|
||||||
false,
|
|
||||||
2 * Float32Array.BYTES_PER_ELEMENT,
|
|
||||||
0
|
|
||||||
)
|
|
||||||
context.drawArrays(context.TRIANGLES, 0, verticesArray.length / 2)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [isCullingOffScreenShapes, isDarkMode, editor])
|
|
||||||
return isCullingOffScreenShapes ? (
|
|
||||||
<canvas ref={canvasRef} className="tl-culled-shapes__canvas" />
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CulledShapes() {
|
|
||||||
if (process.env.NODE_ENV === 'test') {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return _CulledShapes()
|
|
||||||
}
|
|
|
@ -50,6 +50,7 @@ export const Shape = memo(function Shape({
|
||||||
height: 0,
|
height: 0,
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
|
isCulled: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
useQuickReactor(
|
useQuickReactor(
|
||||||
|
@ -124,9 +125,13 @@ export const Shape = memo(function Shape({
|
||||||
const shape = editor.getShape(id)
|
const shape = editor.getShape(id)
|
||||||
if (!shape) return // probably the shape was just deleted
|
if (!shape) return // probably the shape was just deleted
|
||||||
|
|
||||||
const isCulled = editor.isShapeCulled(shape)
|
const culledShapes = editor.getCulledShapes()
|
||||||
|
const isCulled = culledShapes.has(id)
|
||||||
|
if (isCulled !== memoizedStuffRef.current.isCulled) {
|
||||||
setStyleProperty(containerRef.current, 'display', isCulled ? 'none' : 'block')
|
setStyleProperty(containerRef.current, 'display', isCulled ? 'none' : 'block')
|
||||||
setStyleProperty(bgContainerRef.current, 'display', isCulled ? 'none' : 'block')
|
setStyleProperty(bgContainerRef.current, 'display', isCulled ? 'none' : 'block')
|
||||||
|
memoizedStuffRef.current.isCulled = isCulled
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[editor]
|
[editor]
|
||||||
)
|
)
|
||||||
|
|
|
@ -21,7 +21,6 @@ import { toDomPrecision } from '../../primitives/utils'
|
||||||
import { debugFlags } from '../../utils/debug-flags'
|
import { debugFlags } from '../../utils/debug-flags'
|
||||||
import { setStyleProperty } from '../../utils/dom'
|
import { setStyleProperty } from '../../utils/dom'
|
||||||
import { nearestMultiple } from '../../utils/nearestMultiple'
|
import { nearestMultiple } from '../../utils/nearestMultiple'
|
||||||
import { CulledShapes } from '../CulledShapes'
|
|
||||||
import { GeometryDebuggingView } from '../GeometryDebuggingView'
|
import { GeometryDebuggingView } from '../GeometryDebuggingView'
|
||||||
import { LiveCollaborators } from '../LiveCollaborators'
|
import { LiveCollaborators } from '../LiveCollaborators'
|
||||||
import { Shape } from '../Shape'
|
import { Shape } from '../Shape'
|
||||||
|
@ -125,9 +124,6 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) {
|
||||||
<Background />
|
<Background />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="tl-culled-shapes">
|
|
||||||
<CulledShapes />
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
ref={rCanvas}
|
ref={rCanvas}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
|
@ -388,6 +384,30 @@ function ShapesWithSVGs() {
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
function ReflowIfNeeded() {
|
||||||
|
const editor = useEditor()
|
||||||
|
const culledShapesRef = useRef<Set<TLShapeId>>(new Set())
|
||||||
|
useQuickReactor(
|
||||||
|
'reflow for culled shapes',
|
||||||
|
() => {
|
||||||
|
const culledShapes = editor.getCulledShapes()
|
||||||
|
if (
|
||||||
|
culledShapesRef.current.size === culledShapes.size &&
|
||||||
|
[...culledShapes].every((id) => culledShapesRef.current.has(id))
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
culledShapesRef.current = culledShapes
|
||||||
|
const canvas = document.getElementsByClassName('tl-canvas')
|
||||||
|
if (canvas.length === 0) return
|
||||||
|
// This causes a reflow
|
||||||
|
// https://gist.github.com/paulirish/5d52fb081b3570c81e3a
|
||||||
|
const _height = (canvas[0] as HTMLDivElement).offsetHeight
|
||||||
|
},
|
||||||
|
[editor]
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function ShapesToDisplay() {
|
function ShapesToDisplay() {
|
||||||
const editor = useEditor()
|
const editor = useEditor()
|
||||||
|
@ -408,6 +428,7 @@ function ShapesToDisplay() {
|
||||||
{renderingShapes.map((result) => (
|
{renderingShapes.map((result) => (
|
||||||
<Shape key={result.id + '_shape'} {...result} dprMultiple={dprMultiple} />
|
<Shape key={result.id + '_shape'} {...result} dprMultiple={dprMultiple} />
|
||||||
))}
|
))}
|
||||||
|
{editor.environment.isSafari && <ReflowIfNeeded />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -102,6 +102,7 @@ import { getReorderingShapesChanges } from '../utils/reorderShapes'
|
||||||
import { applyRotationToSnapshotShapes, getRotationSnapshot } from '../utils/rotation'
|
import { applyRotationToSnapshotShapes, getRotationSnapshot } from '../utils/rotation'
|
||||||
import { uniqueId } from '../utils/uniqueId'
|
import { uniqueId } from '../utils/uniqueId'
|
||||||
import { arrowBindingsIndex } from './derivations/arrowBindingsIndex'
|
import { arrowBindingsIndex } from './derivations/arrowBindingsIndex'
|
||||||
|
import { notVisibleShapes } from './derivations/notVisibleShapes'
|
||||||
import { parentsToChildren } from './derivations/parentsToChildren'
|
import { parentsToChildren } from './derivations/parentsToChildren'
|
||||||
import { deriveShapeIdsInCurrentPage } from './derivations/shapeIdsInCurrentPage'
|
import { deriveShapeIdsInCurrentPage } from './derivations/shapeIdsInCurrentPage'
|
||||||
import { getSvgJsx } from './getSvgJsx'
|
import { getSvgJsx } from './getSvgJsx'
|
||||||
|
@ -3224,19 +3225,6 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
/** @internal */
|
/** @internal */
|
||||||
private readonly _renderingBounds = atom('rendering viewport', new Box())
|
private readonly _renderingBounds = atom('rendering viewport', new Box())
|
||||||
|
|
||||||
/**
|
|
||||||
* The current rendering bounds in the current page space, expanded slightly. Used for determining which shapes
|
|
||||||
* to render and which to "cull".
|
|
||||||
*
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
getRenderingBoundsExpanded() {
|
|
||||||
return this._renderingBoundsExpanded.get()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @internal */
|
|
||||||
private readonly _renderingBoundsExpanded = atom('rendering viewport expanded', new Box())
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the rendering bounds. This should be called when the viewport has stopped changing, such
|
* Update the rendering bounds. This should be called when the viewport has stopped changing, such
|
||||||
* as at the end of a pan, zoom, or animation.
|
* as at the end of a pan, zoom, or animation.
|
||||||
|
@ -3254,13 +3242,6 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
if (viewportPageBounds.equals(this._renderingBounds.__unsafe__getWithoutCapture())) return this
|
if (viewportPageBounds.equals(this._renderingBounds.__unsafe__getWithoutCapture())) return this
|
||||||
this._renderingBounds.set(viewportPageBounds.clone())
|
this._renderingBounds.set(viewportPageBounds.clone())
|
||||||
|
|
||||||
if (Number.isFinite(this.renderingBoundsMargin)) {
|
|
||||||
this._renderingBoundsExpanded.set(
|
|
||||||
viewportPageBounds.clone().expandBy(this.renderingBoundsMargin / this.getZoomLevel())
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
this._renderingBoundsExpanded.set(viewportPageBounds)
|
|
||||||
}
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4243,48 +4224,30 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
}
|
}
|
||||||
|
|
||||||
@computed
|
@computed
|
||||||
private _getShapeCullingInfoCache(): ComputedCache<boolean, TLShape> {
|
private _notVisibleShapes() {
|
||||||
return this.store.createComputedCache(
|
return notVisibleShapes(this)
|
||||||
'shapeCullingInfo',
|
|
||||||
({ id }) => {
|
|
||||||
// We don't cull shapes that are being edited
|
|
||||||
if (this.getEditingShapeId() === id) return false
|
|
||||||
|
|
||||||
const maskedPageBounds = this.getShapeMaskedPageBounds(id)
|
|
||||||
// if the shape is fully outside of its parent's clipping bounds...
|
|
||||||
if (maskedPageBounds === undefined) return true
|
|
||||||
|
|
||||||
// We don't cull selected shapes
|
|
||||||
if (this.getSelectedShapeIds().includes(id)) return false
|
|
||||||
const renderingBoundsExpanded = this.getRenderingBoundsExpanded()
|
|
||||||
// the shape is outside of the expanded viewport bounds...
|
|
||||||
return !renderingBoundsExpanded.includes(maskedPageBounds)
|
|
||||||
},
|
|
||||||
(a, b) => this.getShapeMaskedPageBounds(a) === this.getShapeMaskedPageBounds(b)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get whether the shape is culled or not.
|
* Get culled shapes.
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* editor.isShapeCulled(myShape)
|
|
||||||
* editor.isShapeCulled(myShapeId)
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @param shape - The shape (or shape id) to get the culled info for.
|
|
||||||
*
|
*
|
||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
isShapeCulled(shape: TLShape | TLShapeId): boolean {
|
@computed
|
||||||
// If renderingBoundsMargin is set to Infinity, then we won't cull offscreen shapes
|
getCulledShapes() {
|
||||||
const isCullingOffScreenShapes = Number.isFinite(this.renderingBoundsMargin)
|
const notVisibleShapes = this._notVisibleShapes().get()
|
||||||
if (!isCullingOffScreenShapes) return false
|
const selectedShapeIds = this.getSelectedShapeIds()
|
||||||
|
const editingId = this.getEditingShapeId()
|
||||||
const id = typeof shape === 'string' ? shape : shape.id
|
const culledShapes = new Set<TLShapeId>(notVisibleShapes)
|
||||||
|
// we don't cull the shape we are editing
|
||||||
return this._getShapeCullingInfoCache().get(id)! as boolean
|
if (editingId) {
|
||||||
|
culledShapes.delete(editingId)
|
||||||
|
}
|
||||||
|
// we also don't cull selected shapes
|
||||||
|
selectedShapeIds.forEach((id) => {
|
||||||
|
culledShapes.delete(id)
|
||||||
|
})
|
||||||
|
return culledShapes
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -4369,7 +4332,6 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
if (filter) return filter(shape)
|
if (filter) return filter(shape)
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
for (let i = shapesToCheck.length - 1; i >= 0; i--) {
|
for (let i = shapesToCheck.length - 1; i >= 0; i--) {
|
||||||
const shape = shapesToCheck[i]
|
const shape = shapesToCheck[i]
|
||||||
const geometry = this.getShapeGeometry(shape)
|
const geometry = this.getShapeGeometry(shape)
|
||||||
|
@ -4657,9 +4619,9 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
*
|
*
|
||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
@computed
|
@computed getCurrentPageRenderingShapesSorted(): TLShape[] {
|
||||||
getCurrentPageRenderingShapesSorted(): TLShape[] {
|
const culledShapes = this.getCulledShapes()
|
||||||
return this.getCurrentPageShapesSorted().filter((shape) => !this.isShapeCulled(shape))
|
return this.getCurrentPageShapesSorted().filter(({ id }) => !culledShapes.has(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
105
packages/editor/src/lib/editor/derivations/notVisibleShapes.ts
Normal file
105
packages/editor/src/lib/editor/derivations/notVisibleShapes.ts
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
138
packages/tldraw/src/test/getCulledShapes.test.tsx
Normal file
138
packages/tldraw/src/test/getCulledShapes.test.tsx
Normal file
|
@ -0,0 +1,138 @@
|
||||||
|
import { Box, TLShapeId, createShapeId } from '@tldraw/editor'
|
||||||
|
import { TestEditor } from './TestEditor'
|
||||||
|
import { TL } from './test-jsx'
|
||||||
|
|
||||||
|
let editor: TestEditor
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
editor = new TestEditor()
|
||||||
|
editor.setScreenBounds({ x: 0, y: 0, w: 1800, h: 900 })
|
||||||
|
editor.renderingBoundsMargin = 100
|
||||||
|
})
|
||||||
|
|
||||||
|
function createShapes() {
|
||||||
|
return editor.createShapesFromJsx([
|
||||||
|
<TL.geo ref="A" x={100} y={100} w={100} h={100} />,
|
||||||
|
<TL.frame ref="B" x={200} y={200} w={300} h={300}>
|
||||||
|
<TL.geo ref="C" x={200} y={200} w={50} h={50} />
|
||||||
|
{/* this is outside of the frames clipping bounds, so it should never be rendered */}
|
||||||
|
<TL.geo ref="D" x={1000} y={1000} w={50} h={50} />
|
||||||
|
</TL.frame>,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
it('lists shapes in viewport', () => {
|
||||||
|
const ids = createShapes()
|
||||||
|
editor.selectNone()
|
||||||
|
// D is clipped and so should always be culled / outside of viewport
|
||||||
|
expect(editor.getCulledShapes()).toStrictEqual(new Set([ids.D]))
|
||||||
|
|
||||||
|
// Move the camera 201 pixels to the right and 201 pixels down
|
||||||
|
editor.pan({ x: -201, y: -201 })
|
||||||
|
jest.advanceTimersByTime(500)
|
||||||
|
|
||||||
|
// A is now outside of the viewport
|
||||||
|
expect(editor.getCulledShapes()).toStrictEqual(new Set([ids.A, ids.D]))
|
||||||
|
|
||||||
|
editor.pan({ x: -900, y: -900 })
|
||||||
|
jest.advanceTimersByTime(500)
|
||||||
|
// Now all shapes are outside of the viewport
|
||||||
|
expect(editor.getCulledShapes()).toStrictEqual(new Set([ids.A, ids.B, ids.C, ids.D]))
|
||||||
|
|
||||||
|
editor.select(ids.B)
|
||||||
|
// We don't cull selected shapes
|
||||||
|
expect(editor.getCulledShapes()).toStrictEqual(new Set([ids.A, ids.C, ids.D]))
|
||||||
|
|
||||||
|
editor.setEditingShape(ids.C)
|
||||||
|
// or shapes being edited
|
||||||
|
expect(editor.getCulledShapes()).toStrictEqual(new Set([ids.A, ids.D]))
|
||||||
|
})
|
||||||
|
|
||||||
|
const shapeSize = 100
|
||||||
|
const numberOfShapes = 100
|
||||||
|
|
||||||
|
function getChangeOutsideBounds(viewportSize: number) {
|
||||||
|
const changeDirection = Math.random() > 0.5 ? 1 : -1
|
||||||
|
const maxChange = 1000
|
||||||
|
const changeAmount = 1 + Math.random() * maxChange
|
||||||
|
if (changeDirection === 1) {
|
||||||
|
// We need to get past the viewport size and then add a bit more
|
||||||
|
return viewportSize + changeAmount
|
||||||
|
} else {
|
||||||
|
// We also need to take the shape size into account
|
||||||
|
return -changeAmount - shapeSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChangeInsideBounds(viewportSize: number) {
|
||||||
|
// We can go from -shapeSize to viewportSize
|
||||||
|
return -shapeSize + Math.random() * (viewportSize + shapeSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFuzzShape(viewport: Box) {
|
||||||
|
const id = createShapeId()
|
||||||
|
if (Math.random() > 0.5) {
|
||||||
|
const positionChange = Math.random()
|
||||||
|
// Should x, or y, or both go outside the bounds?
|
||||||
|
const dimensionChange = positionChange < 0.33 ? 'x' : positionChange < 0.66 ? 'y' : 'both'
|
||||||
|
const xOutsideBounds = dimensionChange === 'x' || dimensionChange === 'both'
|
||||||
|
const yOutsideBounds = dimensionChange === 'y' || dimensionChange === 'both'
|
||||||
|
|
||||||
|
// Create a shape outside the viewport
|
||||||
|
editor.createShape({
|
||||||
|
id,
|
||||||
|
type: 'geo',
|
||||||
|
x:
|
||||||
|
viewport.x +
|
||||||
|
(xOutsideBounds ? getChangeOutsideBounds(viewport.w) : getChangeInsideBounds(viewport.w)),
|
||||||
|
y:
|
||||||
|
viewport.y +
|
||||||
|
(yOutsideBounds ? getChangeOutsideBounds(viewport.h) : getChangeInsideBounds(viewport.h)),
|
||||||
|
props: { w: shapeSize, h: shapeSize },
|
||||||
|
})
|
||||||
|
return { isCulled: true, id }
|
||||||
|
} else {
|
||||||
|
// Create a shape inside the viewport
|
||||||
|
editor.createShape({
|
||||||
|
id,
|
||||||
|
type: 'geo',
|
||||||
|
x: viewport.x + getChangeInsideBounds(viewport.w),
|
||||||
|
y: viewport.y + getChangeInsideBounds(viewport.h),
|
||||||
|
props: { w: shapeSize, h: shapeSize },
|
||||||
|
})
|
||||||
|
return { isCulled: false, id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('correctly calculates the culled shapes when adding and deleting shapes', () => {
|
||||||
|
const viewport = editor.getViewportPageBounds()
|
||||||
|
const shapes: Array<TLShapeId | undefined> = []
|
||||||
|
for (let i = 0; i < numberOfShapes; i++) {
|
||||||
|
const { isCulled, id } = createFuzzShape(viewport)
|
||||||
|
shapes.push(id)
|
||||||
|
if (isCulled) {
|
||||||
|
expect(editor.getCulledShapes()).toContain(id)
|
||||||
|
} else {
|
||||||
|
expect(editor.getCulledShapes()).not.toContain(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const numberOfShapesToDelete = Math.floor((Math.random() * numberOfShapes) / 2)
|
||||||
|
for (let i = 0; i < numberOfShapesToDelete; i++) {
|
||||||
|
const index = Math.floor(Math.random() * (shapes.length - 1))
|
||||||
|
const id = shapes[index]
|
||||||
|
if (id) {
|
||||||
|
editor.deleteShape(id)
|
||||||
|
shapes[index] = undefined
|
||||||
|
expect(editor.getCulledShapes()).not.toContain(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const culledShapesIncremental = editor.getCulledShapes()
|
||||||
|
|
||||||
|
// force full refresh
|
||||||
|
editor.pan({ x: -1, y: 0 })
|
||||||
|
editor.pan({ x: 1, y: 0 })
|
||||||
|
|
||||||
|
const culledShapeFromScratch = editor.getCulledShapes()
|
||||||
|
expect(culledShapesIncremental).toEqual(culledShapeFromScratch)
|
||||||
|
})
|
|
@ -60,55 +60,6 @@ it('updates the rendering viewport when the camera stops moving', () => {
|
||||||
expect(editor.getShapePageBounds(ids.A)).toMatchObject({ x: 100, y: 100, w: 100, h: 100 })
|
expect(editor.getShapePageBounds(ids.A)).toMatchObject({ x: 100, y: 100, w: 100, h: 100 })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('lists shapes in viewport', () => {
|
|
||||||
const ids = createShapes()
|
|
||||||
editor.selectNone()
|
|
||||||
expect(editor.getRenderingShapes().map(({ id }) => [id, editor.isShapeCulled(id)])).toStrictEqual(
|
|
||||||
[
|
|
||||||
[ids.A, false], // A is within the expanded rendering bounds, so should not be culled; and it's in the regular viewport too, so it's on screen.
|
|
||||||
[ids.B, false],
|
|
||||||
[ids.C, false],
|
|
||||||
[ids.D, true], // D is clipped and so should always be culled / outside of viewport
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Move the camera 201 pixels to the right and 201 pixels down
|
|
||||||
editor.pan({ x: -201, y: -201 })
|
|
||||||
jest.advanceTimersByTime(500)
|
|
||||||
|
|
||||||
expect(editor.getRenderingShapes().map(({ id }) => [id, editor.isShapeCulled(id)])).toStrictEqual(
|
|
||||||
[
|
|
||||||
[ids.A, false], // A should not be culled, even though it's no longer in the viewport (because it's still in the EXPANDED viewport)
|
|
||||||
[ids.B, false],
|
|
||||||
[ids.C, false],
|
|
||||||
[ids.D, true], // D is clipped and so should always be culled / outside of viewport
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
editor.pan({ x: -100, y: -100 })
|
|
||||||
jest.advanceTimersByTime(500)
|
|
||||||
|
|
||||||
expect(editor.getRenderingShapes().map(({ id }) => [id, editor.isShapeCulled(id)])).toStrictEqual(
|
|
||||||
[
|
|
||||||
[ids.A, true], // A should be culled now that it's outside of the expanded viewport too
|
|
||||||
[ids.B, false],
|
|
||||||
[ids.C, false],
|
|
||||||
[ids.D, true], // D is clipped and so should always be culled, even if it's in the viewport
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
editor.pan({ x: -900, y: -900 })
|
|
||||||
jest.advanceTimersByTime(500)
|
|
||||||
expect(editor.getRenderingShapes().map(({ id }) => [id, editor.isShapeCulled(id)])).toStrictEqual(
|
|
||||||
[
|
|
||||||
[ids.A, true],
|
|
||||||
[ids.B, true],
|
|
||||||
[ids.C, true],
|
|
||||||
[ids.D, true],
|
|
||||||
]
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('lists shapes in viewport sorted by id with correct indexes & background indexes', () => {
|
it('lists shapes in viewport sorted by id with correct indexes & background indexes', () => {
|
||||||
const ids = createShapes()
|
const ids = createShapes()
|
||||||
// Expect the results to be sorted correctly by id
|
// Expect the results to be sorted correctly by id
|
||||||
|
|
Loading…
Reference in a new issue