Merge branch 'main' into stickies-rc

pull/3249/head
Steve Ruiz 2024-04-11 15:50:42 +01:00
commit e28175f4e6
23 zmienionych plików z 641 dodań i 496 usunięć

Wyświetl plik

@ -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 {

Wyświetl plik

@ -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]
)

Wyświetl plik

@ -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) {

Wyświetl plik

@ -1,3 +1,7 @@
## 2.0.27
- Bug fixes and performance improvements.
## 2.0.26
- Bug fixes and performance improvements.

Wyświetl plik

@ -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.",

Wyświetl plik

@ -675,6 +675,7 @@ export class Editor extends EventEmitter<TLEventMap> {
// @internal
getCrashingError(): unknown;
getCroppingShapeId(): null | TLShapeId;
getCulledShapes(): Set<TLShapeId>;
getCurrentPage(): TLPage;
getCurrentPageBounds(): Box | undefined;
getCurrentPageId(): TLPageId;
@ -713,7 +714,6 @@ export class Editor extends EventEmitter<TLEventMap> {
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<TLEventMap> {
getStyleForNextShape<T>(style: StyleProp<T>): T;
// @deprecated (undocumented)
getSvg(shapes: TLShape[] | TLShapeId[], opts?: Partial<TLSvgOptions>): Promise<SVGSVGElement | undefined>;
getSvgElement(shapes: TLShape[] | TLShapeId[], opts?: Partial<TLSvgOptions>): Promise<{
svg: SVGSVGElement;
width: number;
height: number;
} | undefined>;
getSvgString(shapes: TLShape[] | TLShapeId[], opts?: Partial<TLSvgOptions>): Promise<{
svg: string;
width: number;
@ -817,7 +822,6 @@ export class Editor extends EventEmitter<TLEventMap> {
margin?: number | undefined;
hitInside?: boolean | undefined;
}): boolean;
isShapeCulled(shape: TLShape | TLShapeId): boolean;
isShapeInPage(shape: TLShape | TLShapeId, pageId?: TLPageId): boolean;
isShapeOfType<T extends TLUnknownShape>(shape: TLUnknownShape, type: T['type']): shape is T;
// (undocumented)

Wyświetl plik

@ -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)",

Wyświetl plik

@ -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 {

Wyświetl plik

@ -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()
}

Wyświetl plik

@ -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]
)

Wyświetl plik

@ -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<HTMLDivElement>(null)
const rHtmlLayer = useRef<HTMLDivElement>(null)
const rHtmlLayer2 = useRef<HTMLDivElement>(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 (
<>
<div
ref={rCanvas}
draggable={false}
data-iseditinganything={isEditingAnything}
className={classNames('tl-canvas', className)}
data-testid="canvas"
{...events}
>
<svg className="tl-svg-context">
<defs>
{shapeSvgDefs}
<CursorDef />
<CollaboratorHintDef />
{SvgDefs && <SvgDefs />}
</defs>
</svg>
{Background && (
<div className="tl-background__wrapper">
<Background />
</div>
)}
<div className="tl-culled-shapes">
<CulledShapes />
<GridWrapper />
<div ref={rHtmlLayer} className="tl-html-layer tl-shapes" draggable={false}>
<OnTheCanvasWrapper />
<SelectionBackgroundWrapper />
{hideShapes ? null : debugSvg ? <ShapesWithSVGs /> : <ShapesToDisplay />}
</div>
<div
ref={rCanvas}
draggable={false}
data-iseditinganything={isEditingAnything}
className={classNames('tl-canvas', className)}
data-testid="canvas"
{...events}
>
<svg className="tl-svg-context">
<defs>
{shapeSvgDefs}
<CursorDef />
<CollaboratorHintDef />
{SvgDefs && <SvgDefs />}
</defs>
</svg>
<GridWrapper />
<div ref={rHtmlLayer} className="tl-html-layer tl-shapes" draggable={false}>
<OnTheCanvasWrapper />
<SelectionBackgroundWrapper />
{hideShapes ? null : debugSvg ? <ShapesWithSVGs /> : <ShapesToDisplay />}
<div className="tl-overlays">
<div ref={rHtmlLayer2} className="tl-html-layer">
{debugGeometry ? <GeometryDebuggingView /> : null}
<HandlesWrapper />
<BrushWrapper />
<ScribbleWrapper />
<ZoomBrushWrapper />
<SelectedIdIndicators />
<HoveredShapeIndicator />
<HintedShapeIndicator />
<SnapIndicatorWrapper />
<SelectionForegroundWrapper />
<LiveCollaborators />
</div>
<div className="tl-overlays">
<div ref={rHtmlLayer2} className="tl-html-layer">
{debugGeometry ? <GeometryDebuggingView /> : null}
<HandlesWrapper />
<BrushWrapper />
<ScribbleWrapper />
<ZoomBrushWrapper />
<SelectedIdIndicators />
<HoveredShapeIndicator />
<HintedShapeIndicator />
<SnapIndicatorWrapper />
<SelectionForegroundWrapper />
<LiveCollaborators />
</div>
<InFrontOfTheCanvasWrapper />
</div>
<MovingCameraHitTestBlocker />
<InFrontOfTheCanvasWrapper />
</div>
</>
<MovingCameraHitTestBlocker />
</div>
)
}
@ -366,6 +388,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() {
const editor = useEditor()
@ -386,6 +432,7 @@ function ShapesToDisplay() {
{renderingShapes.map((result) => (
<Shape key={result.id + '_shape'} {...result} dprMultiple={dprMultiple} />
))}
{editor.environment.isSafari && <ReflowIfNeeded />}
</>
)
}

Wyświetl plik

@ -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

Wyświetl plik

@ -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<TLEventMap> {
/** @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<TLEventMap> {
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<TLEventMap> {
}
@computed
private _getShapeCullingInfoCache(): ComputedCache<boolean, TLShape> {
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<TLShapeId>(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<TLEventMap> {
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<TLEventMap> {
*
* @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<TLEventMap> {
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<TLSvgOptions>) {
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<TLEventMap> {
* @public
*/
async getSvgString(shapes: TLShapeId[] | TLShape[], opts = {} as Partial<TLSvgOptions>) {
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<TLSvgOptions>) {
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 --------------------- */

Wyświetl plik

@ -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
})
}

Wyświetl plik

@ -184,7 +184,6 @@ export async function getSvgJsx(
const svg = (
<SvgExportContextProvider editor={editor} context={exportContext}>
<svg
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio={preserveAspectRatio ? preserveAspectRatio : undefined}
direction="ltr"
width={w}

Wyświetl plik

@ -15,6 +15,8 @@ import { STRUCTURED_CLONE_OBJECT_PROTOTYPE } from '@tldraw/utils'
* @public
*/
export function devFreeze<T>(object: T): T {
return object
if (process.env.NODE_ENV === 'production') {
return object
}

Wyświetl plik

@ -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()

Wyświetl plik

@ -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')
}

Wyświetl plik

@ -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()
}

Wyświetl plik

@ -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
)
}

Wyświetl plik

@ -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"

Wyświetl plik

@ -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)
})

Wyświetl plik

@ -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