perf improvements around selected / hovered shapes

This commit is contained in:
Steve Ruiz 2021-06-28 13:13:34 +01:00
parent 2cfeea0449
commit 8ff8b87a9e
26 changed files with 643 additions and 612 deletions

View file

@ -2,11 +2,9 @@ import * as React from 'react'
import { Edge, Corner } from 'types'
import { useSelector } from 'state'
import {
deepCompareArrays,
getBoundsCenter,
getCurrentCamera,
getPage,
getSelectedIds,
getSelectedShapes,
isMobile,
} from 'utils'
@ -24,25 +22,22 @@ export default function Bounds(): JSX.Element {
const bounds = useSelector((s) => s.values.selectedBounds)
const selectedIds = useSelector(
(s) => Array.from(s.values.selectedIds.values()),
deepCompareArrays
)
const rotation = useSelector(({ data }) =>
getSelectedIds(data).size === 1 ? getSelectedShapes(data)[0].rotation : 0
const rotation = useSelector((s) =>
s.values.selectedIds.length === 1
? getSelectedShapes(s.data)[0].rotation
: 0
)
const isAllLocked = useSelector((s) => {
const page = getPage(s.data)
return selectedIds.every((id) => page.shapes[id]?.isLocked)
return s.values.selectedIds.every((id) => page.shapes[id]?.isLocked)
})
const isSingleHandles = useSelector((s) => {
const page = getPage(s.data)
return (
selectedIds.length === 1 &&
page.shapes[selectedIds[0]]?.handles !== undefined
s.values.selectedIds.length === 1 &&
page.shapes[s.values.selectedIds[0]]?.handles !== undefined
)
})

View file

@ -2,7 +2,7 @@ import { useRef } from 'react'
import state, { useSelector } from 'state'
import inputs from 'state/inputs'
import styled from 'styles'
import { deepCompareArrays, getPage } from 'utils'
import { getPage } from 'utils'
function handlePointerDown(e: React.PointerEvent<SVGRectElement>) {
if (!inputs.canAccept(e.pointerId)) return
@ -31,28 +31,30 @@ export default function BoundsBg(): JSX.Element {
const isSelecting = useSelector((s) => s.isIn('selecting'))
const selectedIds = useSelector(
(s) => Array.from(s.values.selectedIds.values()),
deepCompareArrays
)
const rotation = useSelector((s) => {
const selectedIds = s.values.selectedIds
if (selectedIds.length === 1) {
const { shapes } = getPage(s.data)
const selected = Array.from(s.values.selectedIds.values())[0]
return shapes[selected]?.rotation
const selected = selectedIds[0]
const page = getPage(s.data)
return page.shapes[selected]?.rotation
} else {
return 0
}
})
const isAllHandles = useSelector((s) => {
const page = getPage(s.data)
const selectedIds = Array.from(s.values.selectedIds.values())
return (
selectedIds.length === 1 &&
page.shapes[selectedIds[0]]?.handles !== undefined
)
const selectedIds = s.values.selectedIds
if (selectedIds.length === 1) {
const page = getPage(s.data)
const selected = selectedIds[0]
return (
selectedIds.length === 1 && page.shapes[selected]?.handles !== undefined
)
}
})
if (isAllHandles) return null

View file

@ -3,18 +3,14 @@ import { getShapeUtils } from 'state/shape-utils'
import { useRef } from 'react'
import { useSelector } from 'state'
import styled from 'styles'
import { deepCompareArrays, getPage } from 'utils'
import { getPage } from 'utils'
import vec from 'utils/vec'
export default function Handles(): JSX.Element {
const selectedIds = useSelector(
(s) => Array.from(s.values.selectedIds.values()),
deepCompareArrays
)
const shape = useSelector(
({ data }) =>
selectedIds.length === 1 && getPage(data).shapes[selectedIds[0]]
(s) =>
s.values.selectedIds.length === 1 &&
getPage(s.data).shapes[s.values.selectedIds[0]]
)
const isSelecting = useSelector((s) =>

View file

@ -13,6 +13,10 @@ import Handles from './bounds/handles'
import useCanvasEvents from 'hooks/useCanvasEvents'
import ContextMenu from './context-menu/context-menu'
function resetError() {
null
}
export default function Canvas(): JSX.Element {
const rCanvas = useRef<SVGSVGElement>(null)
const rGroup = useRef<SVGGElement>(null)
@ -28,12 +32,7 @@ export default function Canvas(): JSX.Element {
return (
<ContextMenu>
<MainSVG ref={rCanvas} {...events}>
<ErrorBoundary
FallbackComponent={ErrorFallback}
onReset={() => {
// reset the state of your app so the error doesn't happen again
}}
>
<ErrorBoundary FallbackComponent={ErrorFallback} onReset={resetError}>
<Defs />
{isReady && (
<g ref={rGroup} id="shapes">

View file

@ -5,14 +5,7 @@ import {
IconButton as _IconButton,
RowButton,
} from 'components/shared'
import {
commandKey,
deepCompareArrays,
getSelectedIds,
getShape,
isMobile,
setToArray,
} from 'utils'
import { commandKey, deepCompareArrays, getShape, isMobile } from 'utils'
import state, { useSelector } from 'state'
import {
AlignType,
@ -82,7 +75,7 @@ export default function ContextMenu({
children: React.ReactNode
}): JSX.Element {
const selectedShapeIds = useSelector(
(s) => setToArray(getSelectedIds(s.data)),
(s) => s.values.selectedIds,
deepCompareArrays
)

View file

@ -1,6 +1,6 @@
import { getShapeStyle } from 'state/shape-styles'
import { getShapeUtils } from 'state/shape-utils'
import React, { memo } from 'react'
import React from 'react'
import { useSelector } from 'state'
import { getCurrentCamera } from 'utils'
import { DotCircle, Handle } from './misc'
@ -12,28 +12,32 @@ export default function Defs(): JSX.Element {
return (
<defs>
{shapeIdsToRender.map((id) => (
<Def key={id} id={id} />
))}
<DotCircle id="dot" r={4} />
<Handle id="handle" r={4} />
<ExpandDef />
{shapeIdsToRender.map((id) => (
<Def key={id} id={id} />
))}
</defs>
)
}
const Def = memo(function Def({ id }: { id: string }) {
function Def({ id }: { id: string }) {
const shape = useShapeDef(id)
if (!shape) return null
const style = getShapeStyle(shape.style)
return React.cloneElement(
getShapeUtils(shape).render(shape, { isEditing: false }),
{ id, ...style }
return (
<>
{React.cloneElement(
getShapeUtils(shape).render(shape, { isEditing: false }),
{ id, ...style, strokeWidth: undefined }
)}
</>
)
})
}
function ExpandDef() {
const zoom = useSelector((s) => getCurrentCamera(s.data).zoom)

View file

@ -0,0 +1,43 @@
import { memo } from 'react'
import { getShape } from 'utils'
import { getShapeUtils } from 'state/shape-utils'
import vec from 'utils/vec'
import styled from 'styles'
import { useSelector } from 'state'
import { getShapeStyle } from 'state/shape-styles'
function HoveredShape({ id }: { id: string }) {
const transform = useSelector((s) => {
const shape = getShape(s.data, id)
const center = getShapeUtils(shape).getCenter(shape)
const rotation = shape.rotation * (180 / Math.PI)
const parentPoint = getShape(s.data, shape.parentId)?.point || [0, 0]
return `
translate(${vec.neg(parentPoint)})
rotate(${rotation}, ${center})
translate(${shape.point})
`
})
const strokeWidth = useSelector((s) => {
const shape = getShape(s.data, id)
const style = getShapeStyle(shape.style)
return +style.strokeWidth
})
return (
<g transform={transform}>
<StyledHoverShape href={'#' + id} strokeWidth={strokeWidth + 8} />
<text>hello</text>
</g>
)
}
const StyledHoverShape = styled('use', {
stroke: '$selected',
filter: 'url(#expand)',
opacity: 0.1,
})
export default memo(HoveredShape)

View file

@ -1,5 +1,6 @@
import { useSelector } from 'state'
import Shape from './shape'
import HoveredShape from './hovered-shape'
import usePageShapes from 'hooks/usePageShapes'
/*
@ -8,22 +9,22 @@ on the current page. Kind of expensive but only happens
here; and still cheaper than any other pattern I've found.
*/
const noOffset = [0, 0]
export default function Page(): JSX.Element {
const currentPageShapeIds = usePageShapes()
const isSelecting = useSelector((s) => s.isIn('selecting'))
const visiblePageShapeIds = usePageShapes()
const hoveredShapeId = useSelector((s) => {
return visiblePageShapeIds.find((id) => id === s.data.hoveredId)
})
return (
<g pointerEvents={isSelecting ? 'all' : 'none'}>
{currentPageShapeIds.map((shapeId) => (
<Shape
key={shapeId}
id={shapeId}
isSelecting={isSelecting}
parentPoint={noOffset}
/>
{isSelecting && hoveredShapeId && (
<HoveredShape key={hoveredShapeId} id={hoveredShapeId} />
)}
{visiblePageShapeIds.map((id) => (
<Shape key={id} id={id} isSelecting={isSelecting} />
))}
</g>
)

View file

@ -1,12 +1,12 @@
import styled from 'styles'
import { useSelector } from 'state'
import { deepCompareArrays, getPage, getSelectedIds, setToArray } from 'utils'
import { deepCompareArrays, getPage } from 'utils'
import { getShapeUtils } from 'state/shape-utils'
import { memo } from 'react'
export default function Selected(): JSX.Element {
const currentSelectedShapeIds = useSelector(
({ data }) => setToArray(getSelectedIds(data)),
(s) => s.values.selectedIds,
deepCompareArrays
)

View file

@ -2,31 +2,150 @@ import React, { useRef, memo, useEffect } from 'react'
import { useSelector } from 'state'
import styled from 'styles'
import { getShapeUtils } from 'state/shape-utils'
import { getPage, getSelectedIds, isMobile } from 'utils'
import { deepCompareArrays, getPage, getShape } from 'utils'
import useShapeEvents from 'hooks/useShapeEvents'
import { Shape as _Shape } from 'types'
import vec from 'utils/vec'
import { getShapeStyle } from 'state/shape-styles'
const isMobileDevice = isMobile()
import useShapeDef from 'hooks/useShape'
interface ShapeProps {
id: string
isSelecting: boolean
parentPoint: number[]
}
function Shape({ id, isSelecting, parentPoint }: ShapeProps): JSX.Element {
function Shape({ id, isSelecting }: ShapeProps): JSX.Element {
const rGroup = useRef<SVGGElement>(null)
const shapeUtils = useSelector((s) => {
const shape = getShape(s.data, id)
return getShapeUtils(shape)
})
const isHidden = useSelector((s) => {
const shape = getShape(s.data, id)
return shape.isHidden
})
const children = useSelector((s) => {
const shape = getShape(s.data, id)
return shape.children
}, deepCompareArrays)
const isParent = shapeUtils.isParent
const isForeignObject = shapeUtils.isForeignObject
const strokeWidth = useSelector((s) => {
const shape = getShape(s.data, id)
const style = getShapeStyle(shape.style)
return +style.strokeWidth
})
const transform = useSelector((s) => {
const shape = getShape(s.data, id)
const center = shapeUtils.getCenter(shape)
const rotation = shape.rotation * (180 / Math.PI)
const parentPoint = getShape(s.data, shape.parentId)?.point || [0, 0]
return `
translate(${vec.neg(parentPoint)})
rotate(${rotation}, ${center})
translate(${shape.point})
`
})
const events = useShapeEvents(id, isParent, rGroup)
return (
<StyledGroup
id={id + '-group'}
ref={rGroup}
transform={transform}
{...events}
>
{isSelecting &&
(isForeignObject ? (
<ForeignObjectHover id={id} />
) : (
<EventSoak
as="use"
href={'#' + id}
strokeWidth={strokeWidth + 8}
variant={shapeUtils.canStyleFill ? 'filled' : 'hollow'}
/>
))}
{!isHidden &&
(isForeignObject ? (
<ForeignObjectRender id={id} />
) : (
<RealShape id={id} isParent={isParent} strokeWidth={strokeWidth} />
))}
{isParent &&
children.map((shapeId) => (
<Shape key={shapeId} id={shapeId} isSelecting={isSelecting} />
))}
</StyledGroup>
)
}
interface RealShapeProps {
id: string
isParent: boolean
strokeWidth: number
}
const RealShape = memo(function RealShape({
id,
isParent,
strokeWidth,
}: RealShapeProps) {
return (
<StyledShape
as="use"
data-shy={isParent}
href={'#' + id}
strokeWidth={strokeWidth}
/>
)
})
const ForeignObjectHover = memo(function ForeignObjectHover({
id,
}: {
id: string
}) {
const size = useSelector((s) => {
const shape = getPage(s.data).shapes[id]
const bounds = getShapeUtils(shape).getBounds(shape)
return [bounds.width, bounds.height]
}, deepCompareArrays)
return (
<EventSoak
as="rect"
width={size[0]}
height={size[1]}
strokeWidth={1.5}
variant={'ghost'}
/>
)
})
const ForeignObjectRender = memo(function ForeignObjectRender({
id,
}: {
id: string
}) {
const shape = useShapeDef(id)
const rFocusable = useRef<HTMLTextAreaElement>(null)
const isEditing = useSelector((s) => s.data.editingId === id)
const isSelected = useSelector((s) => getSelectedIds(s.data).has(id))
const shape = useSelector((s) => getPage(s.data).shapes[id])
const events = useShapeEvents(id, getShapeUtils(shape)?.isParent, rGroup)
const shapeUtils = getShapeUtils(shape)
useEffect(() => {
if (isEditing) {
@ -38,85 +157,7 @@ function Shape({ id, isSelecting, parentPoint }: ShapeProps): JSX.Element {
}
}, [isEditing])
// This is a problem with deleted shapes. The hooks in this component
// may sometimes run before the hook in the Page component, which means
// a deleted shape will still be pulled here before the page component
// detects the change and pulls this component.
if (!shape) return null
const style = getShapeStyle(shape.style)
const shapeUtils = getShapeUtils(shape)
const { isShy, isParent, isForeignObject } = shapeUtils
const bounds = shapeUtils.getBounds(shape)
const center = shapeUtils.getCenter(shape)
const rotation = shape.rotation * (180 / Math.PI)
const transform = `
translate(${vec.neg(parentPoint)})
rotate(${rotation}, ${center})
translate(${shape.point})
`
return (
<StyledGroup
id={id + '-group'}
ref={rGroup}
transform={transform}
isSelected={isSelected}
device={isMobileDevice ? 'mobile' : 'desktop'}
{...events}
>
{isSelecting && !isShy && (
<>
{isForeignObject ? (
<HoverIndicator
as="rect"
width={bounds.width}
height={bounds.height}
strokeWidth={1.5}
variant={'ghost'}
/>
) : (
<HoverIndicator
as="use"
href={'#' + id}
strokeWidth={+style.strokeWidth + 5}
variant={shapeUtils.canStyleFill ? 'filled' : 'hollow'}
/>
)}
</>
)}
{!shape.isHidden &&
(isForeignObject ? (
shapeUtils.render(shape, { isEditing, ref: rFocusable })
) : (
<RealShape id={id} isParent={isParent} shape={shape} />
))}
{isParent &&
shape.children.map((shapeId) => (
<Shape
key={shapeId}
id={shapeId}
isSelecting={isSelecting}
parentPoint={shape.point}
/>
))}
</StyledGroup>
)
}
interface RealShapeProps {
id: string
shape: _Shape
isParent: boolean
}
const RealShape = memo(function RealShape({ id, isParent }: RealShapeProps) {
return <StyledShape as="use" data-shy={isParent} href={'#' + id} />
return shapeUtils.render(shape, { isEditing, ref: rFocusable })
})
const StyledShape = styled('path', {
@ -125,12 +166,10 @@ const StyledShape = styled('path', {
pointerEvents: 'none',
})
const HoverIndicator = styled('path', {
stroke: '$selected',
const EventSoak = styled('use', {
opacity: 0,
strokeLinecap: 'round',
strokeLinejoin: 'round',
fill: 'transparent',
filter: 'url(#expand)',
variants: {
variant: {
ghost: {
@ -150,81 +189,6 @@ const HoverIndicator = styled('path', {
const StyledGroup = styled('g', {
outline: 'none',
[`& *[data-shy="true"]`]: {
opacity: '0',
},
[`& ${HoverIndicator}`]: {
opacity: '0',
},
variants: {
device: {
mobile: {},
desktop: {},
},
isSelected: {
true: {
[`& *[data-shy="true"]`]: {
opacity: '1',
},
[`& ${HoverIndicator}`]: {
opacity: '0.2',
},
},
false: {
[`& ${HoverIndicator}`]: {
opacity: '0',
},
},
},
},
compoundVariants: [
{
device: 'desktop',
isSelected: 'false',
css: {
[`&:hover ${HoverIndicator}`]: {
opacity: '0.16',
},
[`&:hover *[data-shy="true"]`]: {
opacity: '1',
},
},
},
{
device: 'desktop',
isSelected: 'true',
css: {
[`&:hover ${HoverIndicator}`]: {
opacity: '0.25',
},
[`&:active ${HoverIndicator}`]: {
opacity: '0.25',
},
},
},
],
})
// function Label({ children }: { children: React.ReactNode }) {
// return (
// <text
// y={4}
// x={4}
// fontSize={12}
// fill="black"
// stroke="none"
// alignmentBaseline="text-before-edge"
// pointerEvents="none"
// >
// {children}
// </text>
// )
// }
// function pp(n: number[]) {
// return '[' + n.map((v) => v.toFixed(1)).join(', ') + ']'
// }
export { HoverIndicator }
export default memo(Shape)