diff --git a/apps/docs/content/getting-started/quick-start.mdx b/apps/docs/content/getting-started/quick-start.mdx index fbfa2e481..7cc374c82 100644 --- a/apps/docs/content/getting-started/quick-start.mdx +++ b/apps/docs/content/getting-started/quick-start.mdx @@ -31,7 +31,7 @@ To import fonts and CSS for tldraw: - Copy and paste this into the file: ```CSS -@import url("https://fonts.googleapis.com/css2?family=Inter:wght@500;700;&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@500;700&display=swap"); @import url("tldraw/tldraw.css"); body { diff --git a/apps/dotcom/src/utils/context-menu/CursorChatMenuItem.tsx b/apps/dotcom/src/utils/context-menu/CursorChatMenuItem.tsx index db0e25d19..5a6872d56 100644 --- a/apps/dotcom/src/utils/context-menu/CursorChatMenuItem.tsx +++ b/apps/dotcom/src/utils/context-menu/CursorChatMenuItem.tsx @@ -7,7 +7,7 @@ export function CursorChatMenuItem() { const shouldShow = useValue( 'show cursor chat', () => { - return editor.getInstanceState().isCoarsePointer && !editor.getSelectedShapes().length + return !editor.getInstanceState().isCoarsePointer }, [editor] ) diff --git a/apps/examples/src/examples/rendering-shape-changes/useRenderingShapesChange.ts b/apps/examples/src/examples/rendering-shape-changes/useRenderingShapesChange.ts index 39b6145f3..4b4ac137f 100644 --- a/apps/examples/src/examples/rendering-shape-changes/useRenderingShapesChange.ts +++ b/apps/examples/src/examples/rendering-shape-changes/useRenderingShapesChange.ts @@ -5,25 +5,31 @@ export function useChangedShapesReactor( cb: (info: { culled: TLShape[]; restored: TLShape[] }) => void ) { const editor = useEditor() - const rPrevShapes = useRef(editor.getRenderingShapes()) + const rPrevShapes = useRef({ + renderingShapes: editor.getRenderingShapes(), + culledShapes: editor.getCulledShapes(), + }) useEffect(() => { return react('when rendering shapes change', () => { - const after = editor.getRenderingShapes() + const after = { + culledShapes: editor.getCulledShapes(), + renderingShapes: editor.getRenderingShapes(), + } const before = rPrevShapes.current const culled: TLShape[] = [] const restored: TLShape[] = [] - const beforeToVisit = new Set(before) + const beforeToVisit = new Set(before.renderingShapes) - for (const afterInfo of after) { - const beforeInfo = before.find((s) => s.id === afterInfo.id) + for (const afterInfo of after.renderingShapes) { + const beforeInfo = before.renderingShapes.find((s) => s.id === afterInfo.id) if (!beforeInfo) { continue } else { - const isAfterCulled = editor.isShapeCulled(afterInfo.id) - const isBeforeCulled = editor.isShapeCulled(beforeInfo.id) + const isAfterCulled = after.culledShapes.has(afterInfo.id) + const isBeforeCulled = before.culledShapes.has(beforeInfo.id) if (isAfterCulled && !isBeforeCulled) { culled.push(afterInfo.shape) } else if (!isAfterCulled && isBeforeCulled) { diff --git a/apps/vscode/extension/CHANGELOG.md b/apps/vscode/extension/CHANGELOG.md index 2713f418c..c40252b56 100644 --- a/apps/vscode/extension/CHANGELOG.md +++ b/apps/vscode/extension/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.27 + +- Bug fixes and performance improvements. + ## 2.0.26 - Bug fixes and performance improvements. diff --git a/apps/vscode/extension/package.json b/apps/vscode/extension/package.json index 35c3332bb..9558bbf95 100644 --- a/apps/vscode/extension/package.json +++ b/apps/vscode/extension/package.json @@ -1,7 +1,7 @@ { "name": "tldraw-vscode", "description": "The tldraw extension for VS Code.", - "version": "2.0.26", + "version": "2.0.27", "private": true, "author": { "name": "tldraw Inc.", diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index 385a38561..ae9662490 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -675,6 +675,7 @@ export class Editor extends EventEmitter { // @internal getCrashingError(): unknown; getCroppingShapeId(): null | TLShapeId; + getCulledShapes(): Set; getCurrentPage(): TLPage; getCurrentPageBounds(): Box | undefined; getCurrentPageId(): TLPageId; @@ -713,7 +714,6 @@ export class Editor extends EventEmitter { getPointInParentSpace(shape: TLShape | TLShapeId, point: VecLike): Vec; getPointInShapeSpace(shape: TLShape | TLShapeId, point: VecLike): Vec; getRenderingBounds(): Box; - getRenderingBoundsExpanded(): Box; getRenderingShapes(): { id: TLShapeId; shape: TLShape; @@ -768,6 +768,11 @@ export class Editor extends EventEmitter { getStyleForNextShape(style: StyleProp): T; // @deprecated (undocumented) getSvg(shapes: TLShape[] | TLShapeId[], opts?: Partial): Promise; + getSvgElement(shapes: TLShape[] | TLShapeId[], opts?: Partial): Promise<{ + svg: SVGSVGElement; + width: number; + height: number; + } | undefined>; getSvgString(shapes: TLShape[] | TLShapeId[], opts?: Partial): Promise<{ svg: string; width: number; @@ -817,7 +822,6 @@ export class Editor extends EventEmitter { margin?: number | undefined; hitInside?: boolean | undefined; }): boolean; - isShapeCulled(shape: TLShape | TLShapeId): boolean; isShapeInPage(shape: TLShape | TLShapeId, pageId?: TLPageId): boolean; isShapeOfType(shape: TLUnknownShape, type: T['type']): shape is T; // (undocumented) diff --git a/packages/editor/api/api.json b/packages/editor/api/api.json index e8c97ecb8..12c54a8d6 100644 --- a/packages/editor/api/api.json +++ b/packages/editor/api/api.json @@ -10284,6 +10284,51 @@ "isAbstract": false, "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", "canonicalReference": "@tldraw/editor!Editor#getCurrentPage:member(1)", @@ -11912,38 +11957,6 @@ "isAbstract": false, "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", "canonicalReference": "@tldraw/editor!Editor#getRenderingShapes:member(1)", @@ -13911,7 +13924,7 @@ { "kind": "Method", "canonicalReference": "@tldraw/editor!Editor#getSvg:member(1)", - "docComment": "/**\n * @deprecated\n *\n * Use {@link Editor.getSvgString} instead\n */\n", + "docComment": "/**\n * @deprecated\n *\n * Use {@link Editor.getSvgString} or {@link Editor.getSvgElement} instead.\n */\n", "excerptTokens": [ { "kind": "Content", @@ -14014,6 +14027,112 @@ "isAbstract": false, "name": "getSvg" }, + { + "kind": "Method", + "canonicalReference": "@tldraw/editor!Editor#getSvgElement:member(1)", + "docComment": "/**\n * Get an exported SVG element of the given shapes.\n *\n * @param ids - The shapes (or shape ids) to export.\n *\n * @param opts - Options for the export.\n *\n * @returns The SVG element.\n *\n * @public\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "getSvgElement(shapes: " + }, + { + "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": ", opts?: " + }, + { + "kind": "Reference", + "text": "Partial", + "canonicalReference": "!Partial:type" + }, + { + "kind": "Content", + "text": "<" + }, + { + "kind": "Reference", + "text": "TLSvgOptions", + "canonicalReference": "@tldraw/editor!TLSvgOptions:type" + }, + { + "kind": "Content", + "text": ">" + }, + { + "kind": "Content", + "text": "): " + }, + { + "kind": "Reference", + "text": "Promise", + "canonicalReference": "!Promise:interface" + }, + { + "kind": "Content", + "text": "<{\n svg: " + }, + { + "kind": "Reference", + "text": "SVGSVGElement", + "canonicalReference": "!SVGSVGElement:interface" + }, + { + "kind": "Content", + "text": ";\n width: number;\n height: number;\n } | undefined>" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isStatic": false, + "returnTypeTokenRange": { + "startIndex": 11, + "endIndex": 15 + }, + "releaseTag": "Public", + "isProtected": false, + "overloadIndex": 1, + "parameters": [ + { + "parameterName": "shapes", + "parameterTypeTokenRange": { + "startIndex": 1, + "endIndex": 5 + }, + "isOptional": false + }, + { + "parameterName": "opts", + "parameterTypeTokenRange": { + "startIndex": 6, + "endIndex": 10 + }, + "isOptional": true + } + ], + "isOptional": false, + "isAbstract": false, + "name": "getSvgElement" + }, { "kind": "Method", "canonicalReference": "@tldraw/editor!Editor#getSvgString:member(1)", @@ -14792,64 +14911,6 @@ "isAbstract": false, "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", "canonicalReference": "@tldraw/editor!Editor#isShapeInPage:member(1)", diff --git a/packages/editor/editor.css b/packages/editor/editor.css index ba4afc307..838175fe8 100644 --- a/packages/editor/editor.css +++ b/packages/editor/editor.css @@ -286,6 +286,8 @@ input, position: absolute; top: 0px; left: 0px; + width: 100%; + height: 100%; pointer-events: none; } @@ -293,16 +295,18 @@ input, .tl-background__wrapper { z-index: var(--layer-background); -} - -.tl-background { position: absolute; - background-color: var(--color-background); inset: 0px; height: 100%; width: 100%; } +.tl-background { + background-color: var(--color-background); + width: 100%; + height: 100%; +} + /* --------------------- Grid Layer --------------------- */ .tl-grid { diff --git a/packages/editor/src/lib/components/CulledShapes.tsx b/packages/editor/src/lib/components/CulledShapes.tsx deleted file mode 100644 index 937514083..000000000 --- a/packages/editor/src/lib/components/CulledShapes.tsx +++ /dev/null @@ -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(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 ? ( - - ) : null -} - -export function CulledShapes() { - if (process.env.NODE_ENV === 'test') { - return null - } - return _CulledShapes() -} diff --git a/packages/editor/src/lib/components/Shape.tsx b/packages/editor/src/lib/components/Shape.tsx index 8e21a66ed..909d51885 100644 --- a/packages/editor/src/lib/components/Shape.tsx +++ b/packages/editor/src/lib/components/Shape.tsx @@ -50,6 +50,7 @@ export const Shape = memo(function Shape({ height: 0, x: 0, y: 0, + isCulled: false, }) useQuickReactor( @@ -124,9 +125,13 @@ export const Shape = memo(function Shape({ const shape = editor.getShape(id) if (!shape) return // probably the shape was just deleted - const isCulled = editor.isShapeCulled(shape) - setStyleProperty(containerRef.current, 'display', isCulled ? 'none' : 'block') - setStyleProperty(bgContainerRef.current, 'display', isCulled ? 'none' : 'block') + const culledShapes = editor.getCulledShapes() + const isCulled = culledShapes.has(id) + if (isCulled !== memoizedStuffRef.current.isCulled) { + setStyleProperty(containerRef.current, 'display', isCulled ? 'none' : 'block') + setStyleProperty(bgContainerRef.current, 'display', isCulled ? 'none' : 'block') + memoizedStuffRef.current.isCulled = isCulled + } }, [editor] ) diff --git a/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx b/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx index ec9a6761f..e3de390e8 100644 --- a/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx +++ b/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx @@ -3,9 +3,10 @@ import { TLHandle, TLShapeId } from '@tldraw/tlschema' import { dedupe, modulate, objectMapValues } from '@tldraw/utils' import classNames from 'classnames' import { Fragment, JSX, useEffect, useRef, useState } from 'react' -import { COARSE_HANDLE_RADIUS, HANDLE_RADIUS } from '../../constants' +import { COARSE_HANDLE_RADIUS, HANDLE_RADIUS, TEXT_SHADOW_LOD } from '../../constants' import { useCanvasEvents } from '../../hooks/useCanvasEvents' import { useCoarsePointer } from '../../hooks/useCoarsePointer' +import { useContainer } from '../../hooks/useContainer' import { useDocumentEvents } from '../../hooks/useDocumentEvents' import { useEditor } from '../../hooks/useEditor' import { useEditorComponents } from '../../hooks/useEditorComponents' @@ -20,7 +21,6 @@ import { toDomPrecision } from '../../primitives/utils' import { debugFlags } from '../../utils/debug-flags' import { setStyleProperty } from '../../utils/dom' import { nearestMultiple } from '../../utils/nearestMultiple' -import { CulledShapes } from '../CulledShapes' import { GeometryDebuggingView } from '../GeometryDebuggingView' import { LiveCollaborators } from '../LiveCollaborators' import { Shape } from '../Shape' @@ -37,6 +37,7 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) { const rCanvas = useRef(null) const rHtmlLayer = useRef(null) const rHtmlLayer2 = useRef(null) + const container = useContainer() useScreenBounds(rCanvas) useDocumentEvents() @@ -45,11 +46,37 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) { useGestureEvents(rCanvas) useFixSafariDoubleTapZoomPencilEvents(rCanvas) + const rMemoizedStuff = useRef({ lodDisableTextOutline: false, allowTextOutline: true }) + useQuickReactor( 'position layers', function positionLayersWhenCameraMoves() { const { x, y, z } = editor.getCamera() + // This should only run once on first load + if (rMemoizedStuff.current.allowTextOutline && editor.environment.isSafari) { + container.style.setProperty('--tl-text-outline', 'none') + rMemoizedStuff.current.allowTextOutline = false + } + + // And this should only run if we're not in Safari; + // If we're below the lod distance for text shadows, turn them off + if ( + rMemoizedStuff.current.allowTextOutline && + z < TEXT_SHADOW_LOD !== rMemoizedStuff.current.lodDisableTextOutline + ) { + const lodDisableTextOutline = z < TEXT_SHADOW_LOD + container.style.setProperty( + '--tl-text-outline', + lodDisableTextOutline + ? 'none' + : `0 var(--b) 0 var(--color-background), 0 var(--a) 0 var(--color-background), + var(--b) var(--b) 0 var(--color-background), var(--a) var(--b) 0 var(--color-background), + var(--a) var(--a) 0 var(--color-background), var(--b) var(--a) 0 var(--color-background)` + ) + rMemoizedStuff.current.lodDisableTextOutline = lodDisableTextOutline + } + // Because the html container has a width/height of 1px, we // need to create a small offset when zoomed to ensure that // the html container and svg container are lined up exactly. @@ -62,7 +89,7 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) { setStyleProperty(rHtmlLayer.current, 'transform', transform) setStyleProperty(rHtmlLayer2.current, 'transform', transform) }, - [editor] + [editor, container] ) const events = useCanvasEvents() @@ -96,56 +123,51 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) { ) return ( - <> +
+ + + {shapeSvgDefs} + + + {SvgDefs && } + + {Background && (
)} -
- + +
+ + + {hideShapes ? null : debugSvg ? : }
-
- - - {shapeSvgDefs} - - - {SvgDefs && } - - - -
- - - {hideShapes ? null : debugSvg ? : } +
+
+ {debugGeometry ? : null} + + + + + + + + + +
-
-
- {debugGeometry ? : null} - - - - - - - - - - -
- -
- +
- + +
) } @@ -366,6 +388,30 @@ function ShapesWithSVGs() { ) } +function ReflowIfNeeded() { + const editor = useEditor() + const culledShapesRef = useRef>(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() { const editor = useEditor() @@ -386,6 +432,7 @@ function ShapesToDisplay() { {renderingShapes.map((result) => ( ))} + {editor.environment.isSafari && } ) } diff --git a/packages/editor/src/lib/constants.ts b/packages/editor/src/lib/constants.ts index 792dde32e..d27456340 100644 --- a/packages/editor/src/lib/constants.ts +++ b/packages/editor/src/lib/constants.ts @@ -110,3 +110,6 @@ export const SIDES = ['top', 'right', 'bottom', 'left'] as const /** @internal */ export const LONG_PRESS_DURATION = 500 + +/** @internal */ +export const TEXT_SHADOW_LOD = 0.35 diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index 185e101c2..a6da8ca3c 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -61,7 +61,6 @@ import { import { EventEmitter } from 'eventemitter3' import { flushSync } from 'react-dom' import { createRoot } from 'react-dom/client' -import { renderToStaticMarkup } from 'react-dom/server' import { TLUser, createTLUser } from '../config/createTLUser' import { checkShapesAndAddCore } from '../config/defaultShapes' import { @@ -102,6 +101,7 @@ 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 { getSvgJsx } from './getSvgJsx' @@ -3222,19 +3222,6 @@ export class Editor extends EventEmitter { /** @internal */ 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 * as at the end of a pan, zoom, or animation. @@ -3252,13 +3239,6 @@ export class Editor extends EventEmitter { if (viewportPageBounds.equals(this._renderingBounds.__unsafe__getWithoutCapture())) return this 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 } @@ -4247,48 +4227,30 @@ export class Editor extends EventEmitter { } @computed - private _getShapeCullingInfoCache(): ComputedCache { - return this.store.createComputedCache( - '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) - ) + private _notVisibleShapes() { + return notVisibleShapes(this) } /** - * Get whether the shape is culled or not. - * - * @example - * ```ts - * editor.isShapeCulled(myShape) - * editor.isShapeCulled(myShapeId) - * ``` - * - * @param shape - The shape (or shape id) to get the culled info for. + * Get culled shapes. * * @public */ - isShapeCulled(shape: TLShape | TLShapeId): boolean { - // If renderingBoundsMargin is set to Infinity, then we won't cull offscreen shapes - const isCullingOffScreenShapes = Number.isFinite(this.renderingBoundsMargin) - if (!isCullingOffScreenShapes) return false - - const id = typeof shape === 'string' ? shape : shape.id - - return this._getShapeCullingInfoCache().get(id)! as boolean + @computed + getCulledShapes() { + const notVisibleShapes = this._notVisibleShapes().get() + const selectedShapeIds = this.getSelectedShapeIds() + const editingId = this.getEditingShapeId() + const culledShapes = new Set(notVisibleShapes) + // we don't cull the shape we are editing + if (editingId) { + culledShapes.delete(editingId) + } + // we also don't cull selected shapes + selectedShapeIds.forEach((id) => { + culledShapes.delete(id) + }) + return culledShapes } /** @@ -4373,7 +4335,6 @@ export class Editor extends EventEmitter { if (filter) return filter(shape) return true }) - for (let i = shapesToCheck.length - 1; i >= 0; i--) { const shape = shapesToCheck[i] const geometry = this.getShapeGeometry(shape) @@ -4661,9 +4622,9 @@ export class Editor extends EventEmitter { * * @public */ - @computed - getCurrentPageRenderingShapesSorted(): TLShape[] { - return this.getCurrentPageShapesSorted().filter((shape) => !this.isShapeCulled(shape)) + @computed getCurrentPageRenderingShapesSorted(): TLShape[] { + const culledShapes = this.getCulledShapes() + return this.getCurrentPageShapesSorted().filter(({ id }) => !culledShapes.has(id)) } /** @@ -8114,6 +8075,33 @@ export class Editor extends EventEmitter { return this } + /** + * Get an exported SVG element of the given shapes. + * + * @param ids - The shapes (or shape ids) to export. + * @param opts - Options for the export. + * + * @returns The SVG element. + * + * @public + */ + async getSvgElement(shapes: TLShapeId[] | TLShape[], opts = {} as Partial) { + const result = await getSvgJsx(this, shapes, opts) + if (!result) return undefined + + const fragment = document.createDocumentFragment() + const root = createRoot(fragment) + flushSync(() => { + root.render(result.jsx) + }) + + const svg = fragment.firstElementChild + assert(svg instanceof SVGSVGElement, 'Expected an SVG element') + + root.unmount() + return { svg, width: result.width, height: result.height } + } + /** * Get an exported SVG string of the given shapes. * @@ -8125,21 +8113,22 @@ export class Editor extends EventEmitter { * @public */ async getSvgString(shapes: TLShapeId[] | TLShape[], opts = {} as Partial) { - const svg = await getSvgJsx(this, shapes, opts) - if (!svg) return undefined - return { svg: renderToStaticMarkup(svg.jsx), width: svg.width, height: svg.height } + const result = await this.getSvgElement(shapes, opts) + if (!result) return undefined + + const serializer = new XMLSerializer() + return { + svg: serializer.serializeToString(result.svg), + width: result.width, + height: result.height, + } } - /** @deprecated Use {@link Editor.getSvgString} instead */ + /** @deprecated Use {@link Editor.getSvgString} or {@link Editor.getSvgElement} instead. */ async getSvg(shapes: TLShapeId[] | TLShape[], opts = {} as Partial) { - const svg = await getSvgJsx(this, shapes, opts) - if (!svg) return undefined - const fragment = new DocumentFragment() - const root = createRoot(fragment) - flushSync(() => root.render(svg.jsx)) - const rendered = fragment.firstElementChild - root.unmount() - return rendered as SVGSVGElement + const result = await this.getSvgElement(shapes, opts) + if (!result) return undefined + return result.svg } /* --------------------- Events --------------------- */ diff --git a/packages/editor/src/lib/editor/derivations/notVisibleShapes.ts b/packages/editor/src/lib/editor/derivations/notVisibleShapes.ts new file mode 100644 index 000000000..461835500 --- /dev/null +++ b/packages/editor/src/lib/editor/derivations/notVisibleShapes.ts @@ -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 { + const shapes = editor.getCurrentPageShapeIds() + lastPageId = editor.getCurrentPageId() + const viewportPageBounds = editor.getViewportPageBounds() + prevViewportPageBounds = viewportPageBounds.clone() + const notVisibleShapes = new Set() + shapes.forEach((id) => { + if (isShapeNotVisible(editor, id, viewportPageBounds)) { + notVisibleShapes.add(id) + } + }) + return notVisibleShapes + } + return computed>('getCulledShapes', (prevValue, lastComputedEpoch) => { + if (!isCullingOffScreenShapes) return new Set() + + 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 + 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 + }) +} diff --git a/packages/editor/src/lib/editor/getSvgJsx.tsx b/packages/editor/src/lib/editor/getSvgJsx.tsx index 1f16b0ce1..f86bef475 100644 --- a/packages/editor/src/lib/editor/getSvgJsx.tsx +++ b/packages/editor/src/lib/editor/getSvgJsx.tsx @@ -184,7 +184,6 @@ export async function getSvgJsx( const svg = ( (object: T): T { + return object + if (process.env.NODE_ENV === 'production') { return object } diff --git a/packages/tldraw/src/lib/tools/EraserTool/childStates/Pointing.ts b/packages/tldraw/src/lib/tools/EraserTool/childStates/Pointing.ts index 8d3da33aa..bf46a8c01 100644 --- a/packages/tldraw/src/lib/tools/EraserTool/childStates/Pointing.ts +++ b/packages/tldraw/src/lib/tools/EraserTool/childStates/Pointing.ts @@ -4,6 +4,7 @@ import { TLEventHandlers, TLFrameShape, TLGroupShape, + TLPointerEventInfo, TLShapeId, } from '@tldraw/editor' @@ -52,9 +53,13 @@ export class Pointing extends StateNode { this.editor.setErasingShapes([...erasing]) } + override onLongPress: TLEventHandlers['onLongPress'] = (info) => { + this.startErasing(info) + } + override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => { if (this.editor.inputs.isDragging) { - this.parent.transition('erasing', info) + this.startErasing(info) } } @@ -74,6 +79,10 @@ export class Pointing extends StateNode { this.cancel() } + private startErasing(info: TLPointerEventInfo) { + this.parent.transition('erasing', info) + } + complete() { const erasingShapeIds = this.editor.getErasingShapeIds() diff --git a/packages/tldraw/src/lib/tools/HandTool/childStates/Dragging.ts b/packages/tldraw/src/lib/tools/HandTool/childStates/Dragging.ts index 8d6e60553..a0138ef0e 100644 --- a/packages/tldraw/src/lib/tools/HandTool/childStates/Dragging.ts +++ b/packages/tldraw/src/lib/tools/HandTool/childStates/Dragging.ts @@ -3,7 +3,10 @@ import { CAMERA_SLIDE_FRICTION, StateNode, TLEventHandlers, Vec } from '@tldraw/ export class Dragging extends StateNode { static override id = 'dragging' + initialCamera = new Vec() + override onEnter = () => { + this.initialCamera = Vec.From(this.editor.getCamera()) this.update() } @@ -16,7 +19,7 @@ export class Dragging extends StateNode { } override onCancel: TLEventHandlers['onCancel'] = () => { - this.complete() + this.parent.transition('idle') } override onComplete = () => { @@ -24,21 +27,27 @@ export class Dragging extends StateNode { } private update() { - const { currentScreenPoint, previousScreenPoint } = this.editor.inputs + const { initialCamera, editor } = this + const { currentScreenPoint, originScreenPoint } = editor.inputs - const delta = Vec.Sub(currentScreenPoint, previousScreenPoint) - - if (Math.abs(delta.x) > 0 || Math.abs(delta.y) > 0) { - this.editor.pan(delta) - } + const delta = Vec.Sub(currentScreenPoint, originScreenPoint).div(editor.getZoomLevel()) + if (delta.len2() === 0) return + editor.setCamera(initialCamera.clone().add(delta)) } private complete() { - this.editor.slideCamera({ - speed: Math.min(2, this.editor.inputs.pointerVelocity.len()), - direction: this.editor.inputs.pointerVelocity, - friction: CAMERA_SLIDE_FRICTION, - }) + const { editor } = this + const { pointerVelocity } = editor.inputs + + const velocityAtPointerUp = Math.min(pointerVelocity.len(), 2) + + if (velocityAtPointerUp > 0.1) { + this.editor.slideCamera({ + speed: velocityAtPointerUp, + direction: pointerVelocity, + friction: CAMERA_SLIDE_FRICTION, + }) + } this.parent.transition('idle') } diff --git a/packages/tldraw/src/lib/tools/HandTool/childStates/Pointing.ts b/packages/tldraw/src/lib/tools/HandTool/childStates/Pointing.ts index 6a585f3b8..0d8390d21 100644 --- a/packages/tldraw/src/lib/tools/HandTool/childStates/Pointing.ts +++ b/packages/tldraw/src/lib/tools/HandTool/childStates/Pointing.ts @@ -11,12 +11,20 @@ export class Pointing extends StateNode { ) } - override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => { + override onLongPress: TLEventHandlers['onLongPress'] = () => { + this.startDragging() + } + + override onPointerMove: TLEventHandlers['onPointerMove'] = () => { if (this.editor.inputs.isDragging) { - this.parent.transition('dragging', info) + this.startDragging() } } + private startDragging() { + this.parent.transition('dragging') + } + override onPointerUp: TLEventHandlers['onPointerUp'] = () => { this.complete() } diff --git a/packages/tldraw/src/lib/ui/components/Minimap/MinimapManager.ts b/packages/tldraw/src/lib/ui/components/Minimap/MinimapManager.ts index 993030791..eeef0fd7f 100644 --- a/packages/tldraw/src/lib/ui/components/Minimap/MinimapManager.ts +++ b/packages/tldraw/src/lib/ui/components/Minimap/MinimapManager.ts @@ -189,7 +189,7 @@ export class MinimapManager { this const { width: cw, height: ch } = canvasScreenBounds - const selectedShapeIds = editor.getSelectedShapeIds() + const selectedShapeIds = new Set(editor.getSelectedShapeIds()) const viewportPageBounds = editor.getViewportPageBounds() if (!cvs || !pageBounds) { @@ -215,16 +215,6 @@ export class MinimapManager { ctx.scale(sx, sy) ctx.translate(-contentPageBounds.minX, -contentPageBounds.minY) - // Default radius for rounded rects - const rx = 8 / sx - const ry = 8 / sx - // Min radius - const ax = 1 / sx - const ay = 1 / sx - // Max radius factor - const bx = rx / 4 - const by = ry / 4 - // shapes const shapesPath = new Path2D() const selectedPath = new Path2D() @@ -237,14 +227,11 @@ export class MinimapManager { let pb: Box & { id: TLShapeId } for (let i = 0, n = pageBounds.length; i < n; i++) { pb = pageBounds[i] - MinimapManager.roundedRect( - selectedShapeIds.includes(pb.id) ? selectedPath : shapesPath, + ;(selectedShapeIds.has(pb.id) ? selectedPath : shapesPath).rect( pb.minX, pb.minY, pb.width, - pb.height, - clamp(rx, ax, pb.width / bx), - clamp(ry, ay, pb.height / by) + pb.height ) } diff --git a/packages/tldraw/src/test/commands/__snapshots__/getSvgString.test.ts.snap b/packages/tldraw/src/test/commands/__snapshots__/getSvgString.test.ts.snap index 07990345a..bed42a9ce 100644 --- a/packages/tldraw/src/test/commands/__snapshots__/getSvgString.test.ts.snap +++ b/packages/tldraw/src/test/commands/__snapshots__/getSvgString.test.ts.snap @@ -7,7 +7,7 @@ exports[`Matches a snapshot: Basic SVG 1`] = ` height="564" stroke-linecap="round" stroke-linejoin="round" - style="background-color:transparent" + style="background-color: transparent;" viewBox="-32 -32 564 564" width="564" xmlns="http://www.w3.org/2000/svg" diff --git a/packages/tldraw/src/test/getCulledShapes.test.tsx b/packages/tldraw/src/test/getCulledShapes.test.tsx new file mode 100644 index 000000000..53c99bd35 --- /dev/null +++ b/packages/tldraw/src/test/getCulledShapes.test.tsx @@ -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([ + , + + + {/* this is outside of the frames clipping bounds, so it should never be rendered */} + + , + ]) +} + +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 = [] + 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) +}) diff --git a/packages/tldraw/src/test/renderingShapes.test.tsx b/packages/tldraw/src/test/renderingShapes.test.tsx index 9b7c2ebbd..8b774da5b 100644 --- a/packages/tldraw/src/test/renderingShapes.test.tsx +++ b/packages/tldraw/src/test/renderingShapes.test.tsx @@ -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 }) }) -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', () => { const ids = createShapes() // Expect the results to be sorted correctly by id