Skip to content

Commit

Permalink
[tinyish] Simplify / skip some work in Shape (tldraw#3176)
Browse files Browse the repository at this point in the history
This PR is a minor cleanup of the Shape component.

Here we:
- use some dumb memoized info to avoid unnecessary style changes
- move the dpr check up out of the shapes themselves, avoiding renders
on instance state changes

Culled shapes:
- move the props setting on the culled shape component to a layout
reactor
- no longer set the height / width on the culled shape component
- no longer update the culled shape component when the shape changes

Random:
- move the arrow shape defs to the arrow shape util (using that neat API
we didn't used to have)

### Change Type

<!-- ❗ Please select a 'Scope' label ❗️ -->

- [x] `sdk` — Changes the tldraw SDK
- [ ] `dotcom` — Changes the tldraw.com web app
- [ ] `docs` — Changes to the documentation, examples, or templates.
- [ ] `vs code` — Changes to the vscode plugin
- [ ] `internal` — Does not affect user-facing stuff

<!-- ❗ Please select a 'Type' label ❗️ -->

- [ ] `bugfix` — Bug fix
- [ ] `feature` — New feature
- [x] `improvement` — Improving existing features
- [ ] `chore` — Updating dependencies, other boring stuff
- [ ] `galaxy brain` — Architectural changes
- [ ] `tests` — Changes to any test code
- [ ] `tools` — Changes to infrastructure, CI, internal scripts,
debugging tools, etc.
- [ ] `dunno` — I don't know


### Test Plan

1. Use shapes
2. Use culled shapes

### Release Notes

- SDK: minor improvements to the Shape component
  • Loading branch information
steveruizok authored Mar 17, 2024
1 parent 4e0df07 commit 4801b35
Show file tree
Hide file tree
Showing 5 changed files with 173 additions and 115 deletions.
2 changes: 2 additions & 0 deletions packages/editor/editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,8 @@ input,
.tl-shape__culled {
position: relative;
background-color: var(--color-culled);
width: 100%;
height: 100%;
}

/* ---------------- Shape Containers ---------------- */
Expand Down
147 changes: 82 additions & 65 deletions packages/editor/src/lib/components/Shape.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,35 @@
import { track, useLayoutReaction, useStateTracking } from '@tldraw/state'
import { useLayoutReaction, useStateTracking } from '@tldraw/state'
import { IdOf } from '@tldraw/store'
import { TLShape, TLShapeId } from '@tldraw/tlschema'
import * as React from 'react'
import { memo, useCallback, useLayoutEffect, useRef } from 'react'
import { ShapeUtil } from '../editor/shapes/ShapeUtil'
import { useEditor } from '../hooks/useEditor'
import { useEditorComponents } from '../hooks/useEditorComponents'
import { Mat } from '../primitives/Mat'
import { toDomPrecision } from '../primitives/utils'
import { nearestMultiple } from '../utils/nearestMultiple'
import { setStyleProperty } from '../utils/dom'
import { OptionalErrorBoundary } from './ErrorBoundary'

/*
This component renders shapes on the canvas. There are two stages: positioning
and styling the shape's container using CSS, and then rendering the shape's
JSX using its shape util's render method. Rendering the "inside" of a shape is
more expensive than positioning it or changing its color, so we use React.memo
more expensive than positioning it or changing its color, so we use memo
to wrap the inner shape and only re-render it when the shape's props change.
The shape also receives props for its index and opacity. The index is used to
determine the z-index of the shape, and the opacity is used to set the shape's
opacity based on its own opacity and that of its parent's.
*/
export const Shape = track(function Shape({
export const Shape = memo(function Shape({
id,
shape,
util,
index,
backgroundIndex,
opacity,
isCulled,
dprMultiple,
}: {
id: TLShapeId
shape: TLShape
Expand All @@ -36,56 +38,79 @@ export const Shape = track(function Shape({
backgroundIndex: number
opacity: number
isCulled: boolean
dprMultiple: number
}) {
const editor = useEditor()

const { ShapeErrorFallback } = useEditorComponents()

const containerRef = React.useRef<HTMLDivElement>(null)
const backgroundContainerRef = React.useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const bgContainerRef = useRef<HTMLDivElement>(null)

const setProperty = React.useCallback((property: string, value: string) => {
containerRef.current?.style.setProperty(property, value)
backgroundContainerRef.current?.style.setProperty(property, value)
}, [])
const memoizedStuffRef = useRef({
transform: '',
clipPath: 'none',
width: 0,
height: 0,
})

useLayoutReaction('set shape stuff', () => {
const shape = editor.getShape(id)
if (!shape) return // probably the shape was just deleted

const pageTransform = editor.getShapePageTransform(id)
const transform = Mat.toCssString(pageTransform)
setProperty('transform', transform)

const clipPath = editor.getShapeClipPath(id)
setProperty('clip-path', clipPath ?? 'none')

const prev = memoizedStuffRef.current

// Clip path
const clipPath = editor.getShapeClipPath(id) ?? 'none'
if (clipPath !== prev.clipPath) {
setStyleProperty(containerRef.current, 'clip-path', clipPath)
setStyleProperty(bgContainerRef.current, 'clip-path', clipPath)
prev.clipPath = clipPath
}

// Page transform
const transform = Mat.toCssString(editor.getShapePageTransform(id))
if (transform !== prev.transform) {
setStyleProperty(containerRef.current, 'transform', transform)
setStyleProperty(bgContainerRef.current, 'transform', transform)
prev.transform = transform
}

// Width / Height
// We round the shape width and height up to the nearest multiple of dprMultiple
// to avoid the browser making miscalculations when applying the transform.
const bounds = editor.getShapeGeometry(shape).bounds
const dpr = Math.floor(editor.getInstanceState().devicePixelRatio * 100) / 100
// dprMultiple is the smallest number we can multiply dpr by to get an integer
// it's usually 1, 2, or 4 (for e.g. dpr of 2, 2.5 and 2.25 respectively)
const dprMultiple = nearestMultiple(dpr)
// We round the shape width and height up to the nearest multiple of dprMultiple to avoid the browser
// making miscalculations when applying the transform.
const widthRemainder = bounds.w % dprMultiple
const width = widthRemainder === 0 ? bounds.w : bounds.w + (dprMultiple - widthRemainder)
const heightRemainder = bounds.h % dprMultiple
const width = widthRemainder === 0 ? bounds.w : bounds.w + (dprMultiple - widthRemainder)
const height = heightRemainder === 0 ? bounds.h : bounds.h + (dprMultiple - heightRemainder)
setProperty('width', Math.max(width, dprMultiple) + 'px')
setProperty('height', Math.max(height, dprMultiple) + 'px')

if (width !== prev.width || height !== prev.height) {
setStyleProperty(containerRef.current, 'width', Math.max(width, dprMultiple) + 'px')
setStyleProperty(containerRef.current, 'height', Math.max(height, dprMultiple) + 'px')
setStyleProperty(bgContainerRef.current, 'width', Math.max(width, dprMultiple) + 'px')
setStyleProperty(bgContainerRef.current, 'height', Math.max(height, dprMultiple) + 'px')
prev.width = width
prev.height = height
}
})

// Set the opacity of the container when the opacity changes
React.useLayoutEffect(() => {
setProperty('opacity', opacity + '')
containerRef.current?.style.setProperty('z-index', index + '')
backgroundContainerRef.current?.style.setProperty('z-index', backgroundIndex + '')
}, [opacity, index, backgroundIndex, setProperty])

const annotateError = React.useCallback(
(error: any) => {
editor.annotateError(error, { origin: 'react.shape', willCrashApp: false })
},
// This stuff changes pretty infrequently, so we can change them together
useLayoutEffect(() => {
const container = containerRef.current
const bgContainer = bgContainerRef.current

// Opacity
setStyleProperty(container, 'opacity', opacity)
setStyleProperty(bgContainer, 'opacity', opacity)

// Z-Index
setStyleProperty(container, 'z-index', index)
setStyleProperty(bgContainer, 'z-index', backgroundIndex)
}, [opacity, index, backgroundIndex])

const annotateError = useCallback(
(error: any) => editor.annotateError(error, { origin: 'shape', willCrashApp: false }),
[editor]
)

Expand All @@ -95,12 +120,12 @@ export const Shape = track(function Shape({
<>
{util.backgroundComponent && (
<div
ref={backgroundContainerRef}
ref={bgContainerRef}
className="tl-shape tl-shape-background"
data-shape-type={shape.type}
draggable={false}
>
{!isCulled && (
{isCulled ? null : (
<OptionalErrorBoundary fallback={ShapeErrorFallback} onError={annotateError}>
<InnerShapeBackground shape={shape} util={util} />
</OptionalErrorBoundary>
Expand All @@ -109,7 +134,7 @@ export const Shape = track(function Shape({
)}
<div ref={containerRef} className="tl-shape" data-shape-type={shape.type} draggable={false}>
{isCulled ? (
<CulledShape shape={shape} />
<CulledShape shapeId={shape.id} />
) : (
<OptionalErrorBoundary fallback={ShapeErrorFallback as any} onError={annotateError}>
<InnerShape shape={shape} util={util} />
Expand All @@ -120,17 +145,14 @@ export const Shape = track(function Shape({
)
})

const InnerShape = React.memo(
const InnerShape = memo(
function InnerShape<T extends TLShape>({ shape, util }: { shape: T; util: ShapeUtil<T> }) {
return useStateTracking('InnerShape:' + shape.type, () => util.component(shape))
},
(prev, next) =>
prev.shape.props === next.shape.props &&
prev.shape.meta === next.shape.meta &&
prev.util === next.util
(prev, next) => prev.shape.props === next.shape.props && prev.shape.meta === next.shape.meta
)

const InnerShapeBackground = React.memo(
const InnerShapeBackground = memo(
function InnerShapeBackground<T extends TLShape>({
shape,
util,
Expand All @@ -143,23 +165,18 @@ const InnerShapeBackground = React.memo(
(prev, next) => prev.shape.props === next.shape.props && prev.shape.meta === next.shape.meta
)

const CulledShape = React.memo(
function CulledShape<T extends TLShape>({ shape }: { shape: T }) {
const editor = useEditor()
const bounds = editor.getShapeGeometry(shape).bounds
const CulledShape = function CulledShape<T extends TLShape>({ shapeId }: { shapeId: IdOf<T> }) {
const editor = useEditor()
const culledRef = useRef<HTMLDivElement>(null)

return (
<div
className="tl-shape__culled"
style={{
transform: `translate(${toDomPrecision(bounds.minX)}px, ${toDomPrecision(
bounds.minY
)}px)`,
width: Math.max(1, toDomPrecision(bounds.width)),
height: Math.max(1, toDomPrecision(bounds.height)),
}}
/>
useLayoutReaction('set shape stuff', () => {
const bounds = editor.getShapeGeometry(shapeId).bounds
setStyleProperty(
culledRef.current,
'transform',
`translate(${toDomPrecision(bounds.minX)}px, ${toDomPrecision(bounds.minY)}px)`
)
},
() => true
)
})

return <div ref={culledRef} className="tl-shape__culled" />
}
Loading

0 comments on commit 4801b35

Please sign in to comment.