Merge pull request #32 from tldraw/cleanup

Refactor shape rendering
This commit is contained in:
Steve Ruiz 2021-07-09 21:45:23 +01:00 committed by GitHub
commit 0bf450b01d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 476 additions and 417 deletions

View file

@ -11,7 +11,9 @@ import RotateHandle from './rotate-handle'
export default function Bounds(): JSX.Element { export default function Bounds(): JSX.Element {
const isBrushing = useSelector((s) => s.isIn('brushSelecting')) const isBrushing = useSelector((s) => s.isIn('brushSelecting'))
const isSelecting = useSelector((s) => s.isIn('selecting')) const shouldDisplay = useSelector((s) =>
s.isInAny('selecting', 'selectPinching')
)
const zoom = useSelector((s) => tld.getCurrentCamera(s.data).zoom) const zoom = useSelector((s) => tld.getCurrentCamera(s.data).zoom)
@ -38,7 +40,7 @@ export default function Bounds(): JSX.Element {
if (!bounds) return null if (!bounds) return null
if (!isSelecting) return null if (!shouldDisplay) return null
if (isSingleHandles) return null if (isSingleHandles) return null

View file

@ -1,47 +1,39 @@
import { getShapeUtils } from 'state/shape-utils'
import React from 'react' import React from 'react'
import { useSelector } from 'state' import { useSelector } from 'state'
import tld from 'utils/tld' import tld from 'utils/tld'
import { DotCircle, Handle } from './misc' import { DotCircle, Handle } from './misc'
import useShape from 'hooks/useShape'
import useShapesToRender from 'hooks/useShapesToRender'
import styled from 'styles' import styled from 'styles'
export default function Defs(): JSX.Element { export default function Defs(): JSX.Element {
const shapeIdsToRender = useShapesToRender()
return ( return (
<defs> <defs>
<DotCircle id="dot" r={4} /> <DotCircle id="dot" r={4} />
<Handle id="handle" r={4} /> <Handle id="handle" r={4} />
<ExpandDef /> <ExpandDef />
<ShadowDef /> <HoverDef />
{shapeIdsToRender.map((id) => (
<Def key={id} id={id} />
))}
</defs> </defs>
) )
} }
function Def({ id }: { id: string }) {
const shape = useShape(id)
if (!shape) return null
return getShapeUtils(shape).render(shape, { isEditing: false })
}
function ExpandDef() { function ExpandDef() {
const zoom = useSelector((s) => tld.getCurrentCamera(s.data).zoom) const zoom = useSelector((s) => tld.getCurrentCamera(s.data).zoom)
return ( return (
<filter id="expand"> <filter id="expand">
<feMorphology operator="dilate" radius={2 / zoom} /> <feMorphology operator="dilate" radius={0.5 / zoom} />
</filter> </filter>
) )
} }
function ShadowDef() { function HoverDef() {
return ( return (
<filter id="hover"> <filter id="hover">
<StyledShadow dx="0" dy="0" stdDeviation="1.2" floodOpacity="1" /> <StyledShadow
dx="2"
dy="2"
stdDeviation="0.5"
floodOpacity="1"
floodColor="blue"
/>
</filter> </filter>
) )
} }

View file

@ -1,33 +1,121 @@
import { useSelector } from 'state' import { useSelector } from 'state'
import Shape from './shape' import tld from 'utils/tld'
import HoveredShape from './hovered-shape' import { Data, Shape, ShapeType } from 'types'
import usePageShapes from 'hooks/usePageShapes' import { getShapeUtils } from 'state/shape-utils'
import { boundsCollide, boundsContain } from 'utils'
import ShapeComponent from './shape'
/* /*
On each state change, compare node ids of all shapes On each state change, populate a tree structure with all of
on the current page. Kind of expensive but only happens the shapes that we need to render..
here; and still cheaper than any other pattern I've found.
*/ */
interface Node {
shape: Shape
children: Node[]
isEditing: boolean
isHovered: boolean
isSelected: boolean
isCurrentParent: boolean
}
export default function Page(): JSX.Element { export default function Page(): JSX.Element {
const showHovers = useSelector((s) => // Get a tree of shapes to render
s.isInAny('selecting', 'text', 'editingShape') const shapeTree = useSelector((s) => {
) // Get the shapes that fit into the current viewport
const visiblePageShapeIds = usePageShapes() const viewport = tld.getViewport(s.data)
const hoveredShapeId = useSelector((s) => { const shapesToShow = s.values.currentShapes.filter((shape) => {
return visiblePageShapeIds.find((id) => id === s.data.hoveredId) const shapeBounds = getShapeUtils(shape).getBounds(shape)
return (
shape.type === ShapeType.Ray ||
shape.type === ShapeType.Line ||
boundsContain(viewport, shapeBounds) ||
boundsCollide(viewport, shapeBounds)
)
})
// Should we allow shapes to be hovered?
const allowHovers = s.isInAny('selecting', 'text', 'editingShape')
// Populate the shape tree
const tree: Node[] = []
shapesToShow.forEach((shape) =>
addToTree(s.data, s.values.selectedIds, allowHovers, tree, shape)
)
return tree
}) })
return ( return (
<g pointerEvents={showHovers ? 'all' : 'none'}> <>
{showHovers && hoveredShapeId && ( {shapeTree.map((node) => (
<HoveredShape key={hoveredShapeId} id={hoveredShapeId} /> <ShapeNode key={node.shape.id} node={node} />
)}
{visiblePageShapeIds.map((id) => (
<Shape key={id} id={id} />
))} ))}
</g> </>
) )
} }
interface ShapeNodeProps {
node: Node
parentPoint?: number[]
}
const ShapeNode = ({
node: { shape, children, isEditing, isHovered, isSelected, isCurrentParent },
}: ShapeNodeProps) => {
return (
<>
<ShapeComponent
shape={shape}
isEditing={isEditing}
isHovered={isHovered}
isSelected={isSelected}
isCurrentParent={isCurrentParent}
/>
{children.map((childNode) => (
<ShapeNode key={childNode.shape.id} node={childNode} />
))}
</>
)
}
/**
* Populate the shape tree. This helper is recursive and only one call is needed.
*
* ### Example
*
*```ts
* addDataToTree(data, selectedIds, allowHovers, branch, shape)
*```
*/
function addToTree(
data: Data,
selectedIds: string[],
allowHovers: boolean,
branch: Node[],
shape: Shape
): void {
const node = {
shape,
children: [],
isHovered: data.hoveredId === shape.id,
isCurrentParent: data.currentParentId === shape.id,
isEditing: data.editingId === shape.id,
isSelected: selectedIds.includes(shape.id),
}
branch.push(node)
if (shape.children) {
shape.children
.map((id) => tld.getShape(data, id))
.sort((a, b) => a.childIndex - b.childIndex)
.forEach((shape) => {
addToTree(data, selectedIds, allowHovers, node.children, shape)
})
}
}

View file

@ -1,224 +1,112 @@
import React, { useRef, memo, useEffect } from 'react'
import state, { useSelector } from 'state'
import styled from 'styles'
import { getShapeUtils } from 'state/shape-utils'
import { deepCompareArrays } from 'utils'
import tld from 'utils/tld'
import useShapeEvents from 'hooks/useShapeEvents' import useShapeEvents from 'hooks/useShapeEvents'
import useShape from 'hooks/useShape' import { Shape as _Shape, ShapeType, TextShape } from 'types'
import vec from 'utils/vec' import { getShapeUtils } from 'state/shape-utils'
import { getShapeStyle } from 'state/shape-styles' import { shallowEqual } from 'utils'
import { memo, useRef } from 'react'
interface ShapeProps { interface ShapeProps {
id: string shape: _Shape
isEditing: boolean
isHovered: boolean
isSelected: boolean
isCurrentParent: boolean
} }
function Shape({ id }: ShapeProps): JSX.Element { const Shape = memo(
const rGroup = useRef<SVGGElement>(null) ({
shape,
isEditing,
isHovered,
isSelected,
isCurrentParent,
}: ShapeProps) => {
const rGroup = useRef<SVGGElement>(null)
const events = useShapeEvents(shape.id, isCurrentParent, rGroup)
const utils = getShapeUtils(shape)
const isHidden = useSelector((s) => { const center = utils.getCenter(shape)
const shape = tld.getShape(s.data, id)
if (shape === undefined) return true
return shape?.isHidden
})
const children = useSelector((s) => {
const shape = tld.getShape(s.data, id)
if (shape === undefined) return []
return shape?.children
})
const strokeWidth = useSelector((s) => {
const shape = tld.getShape(s.data, id)
if (shape === undefined) return 0
const style = getShapeStyle(shape?.style)
return +style.strokeWidth
})
const transform = useSelector((s) => {
const shape = tld.getShape(s.data, id)
if (shape === undefined) return ''
const center = getShapeUtils(shape).getCenter(shape)
const rotation = shape.rotation * (180 / Math.PI) const rotation = shape.rotation * (180 / Math.PI)
const parentPoint = tld.getShape(s.data, shape.parentId)?.point || [0, 0] const transform = `
rotate(${rotation}, ${center})
translate(${shape.point})
`
return ` return (
translate(${vec.neg(parentPoint)}) <g
rotate(${rotation}, ${center}) ref={rGroup}
translate(${shape.point}) id={shape.id}
` transform={transform}
}) filter={isHovered ? 'url(#expand)' : 'none'}
{...events}
const isCurrentParent = useSelector((s) => { >
return s.data.currentParentId === id {isEditing && shape.type === ShapeType.Text ? (
}) <EditingTextShape shape={shape} />
const events = useShapeEvents(id, isCurrentParent, rGroup)
const shape = tld.getShape(state.data, id)
if (!shape) {
console.warn('Could not find that shape:', id)
return null
}
// From here on, not reactive—if we're here, we can trust that the
// shape in state is a shape with changes that we need to render.
const { isParent, isForeignObject, canStyleFill } = getShapeUtils(shape)
return (
<StyledGroup
id={id + '-group'}
ref={rGroup}
transform={transform}
isCurrentParent={isCurrentParent}
{...events}
>
{isForeignObject ? (
<ForeignObjectHover id={id} />
) : (
<EventSoak
as="use"
href={'#' + id}
strokeWidth={strokeWidth + 8}
variant={canStyleFill ? 'filled' : 'hollow'}
/>
)}
{!isHidden &&
(isForeignObject ? (
<ForeignObjectRender id={id} />
) : ( ) : (
<RealShape id={id} isParent={isParent} strokeWidth={strokeWidth} /> <RenderedShape
))} shape={shape}
isEditing={isEditing}
isHovered={isHovered}
isSelected={isSelected}
isCurrentParent={isCurrentParent}
/>
)}
</g>
)
},
shallowEqual
)
{isParent && export default Shape
children.map((shapeId) => <Shape key={shapeId} id={shapeId} />)}
</StyledGroup> interface RenderedShapeProps {
) shape: _Shape
isEditing: boolean
isHovered: boolean
isSelected: boolean
isCurrentParent: boolean
} }
export default memo(Shape) const RenderedShape = memo(
function RenderedShape({
interface RealShapeProps { shape,
id: string isEditing,
isParent: boolean isHovered,
strokeWidth: number isSelected,
} isCurrentParent,
}: RenderedShapeProps) {
const RealShape = memo(function RealShape({ return getShapeUtils(shape).render(shape, {
id, isEditing,
isParent, isHovered,
strokeWidth, isSelected,
}: RealShapeProps) { isCurrentParent,
return ( })
<StyledShape },
as="use" (prev, next) => {
data-shy={isParent} if (
href={'#' + id} prev.isEditing !== next.isEditing ||
strokeWidth={strokeWidth} prev.isHovered !== next.isHovered ||
/> prev.isSelected !== next.isSelected ||
) prev.isCurrentParent !== next.isCurrentParent
}) ) {
return false
const ForeignObjectHover = memo(function ForeignObjectHover({
id,
}: {
id: string
}) {
const size = useSelector((s) => {
const shape = tld.getPage(s.data).shapes[id]
if (shape === undefined) return [0, 0]
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 = useShape(id)
const rFocusable = useRef<HTMLTextAreaElement>(null)
const isEditing = useSelector((s) => s.data.editingId === id)
useEffect(() => {
if (isEditing) {
setTimeout(() => {
const elm = rFocusable.current
if (!elm) return
elm.focus()
}, 0)
} }
}, [isEditing])
if (shape === undefined) return null if (next.shape !== prev.shape) {
return !getShapeUtils(next.shape).shouldRender(next.shape, prev.shape)
}
return getShapeUtils(shape).render(shape, { isEditing, ref: rFocusable }) return true
}) }
)
const StyledShape = styled('path', { function EditingTextShape({ shape }: { shape: TextShape }) {
strokeLinecap: 'round', const ref = useRef<HTMLTextAreaElement>(null)
strokeLinejoin: 'round',
pointerEvents: 'none',
})
const EventSoak = styled('use', { return getShapeUtils(shape).render(shape, {
opacity: 0, ref,
strokeLinecap: 'round', isEditing: true,
strokeLinejoin: 'round', isHovered: false,
variants: { isSelected: false,
variant: { isCurrentParent: false,
ghost: { })
pointerEvents: 'all', }
filter: 'none',
opacity: 0,
},
hollow: {
pointerEvents: 'stroke',
},
filled: {
pointerEvents: 'all',
},
},
},
})
const StyledGroup = styled('g', {
outline: 'none',
'& > *[data-shy=true]': {
opacity: 0,
},
'&:hover': {
'& > *[data-shy=true]': {
opacity: 1,
},
},
variants: {
isCurrentParent: {
true: {
'& > *[data-shy=true]': {
opacity: 1,
},
},
},
},
})

View file

@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { MutableRefObject, useCallback } from 'react' import { MutableRefObject, useCallback, useEffect } from 'react'
import state from 'state' import state from 'state'
import { import {
fastBrushSelect, fastBrushSelect,
@ -74,6 +74,19 @@ export default function useCanvasEvents(
// } // }
}, []) }, [])
// Send event on iOS when a user presses the "Done" key while editing a text element
useEffect(() => {
function handleFocusOut() {
state.send('BLURRED_EDITING_SHAPE')
}
document.addEventListener('focusout', handleFocusOut)
return () => {
document.removeEventListener('focusout', handleFocusOut)
}
}, [])
return { return {
onPointerDown: handlePointerDown, onPointerDown: handlePointerDown,
onPointerMove: handlePointerMove, onPointerMove: handlePointerMove,

View file

@ -1,7 +1,7 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import state, { useSelector } from 'state' import state, { useSelector } from 'state'
import { getShapeUtils } from 'state/shape-utils' import { getShapeUtils } from 'state/shape-utils'
import { PageState, Bounds, ShapeType } from 'types' import { PageState, Bounds, ShapeType, Shape } from 'types'
import { import {
boundsCollide, boundsCollide,
boundsContain, boundsContain,
@ -12,7 +12,7 @@ import tld from 'utils/tld'
const viewportCache = new WeakMap<PageState, Bounds>() const viewportCache = new WeakMap<PageState, Bounds>()
export default function usePageShapes(): string[] { export default function usePageShapes(): Shape[] {
// Reset the viewport cache when the window resizes // Reset the viewport cache when the window resizes
useEffect(() => { useEffect(() => {
const handleResize = debounce(() => state.send('RESIZED_WINDOW'), 32) const handleResize = debounce(() => state.send('RESIZED_WINDOW'), 32)
@ -35,19 +35,17 @@ export default function usePageShapes(): string[] {
const viewport = viewportCache.get(pageState) const viewport = viewportCache.get(pageState)
const shapesToShow = s.values.currentShapes const shapesToShow = s.values.currentShapes.filter((shape) => {
.filter((shape) => { if (shape.type === ShapeType.Ray || shape.type === ShapeType.Line) {
if (shape.type === ShapeType.Ray || shape.type === ShapeType.Line) { return true
return true }
}
const shapeBounds = getShapeUtils(shape).getBounds(shape) const shapeBounds = getShapeUtils(shape).getBounds(shape)
return ( return (
boundsContain(viewport, shapeBounds) || boundsContain(viewport, shapeBounds) ||
boundsCollide(viewport, shapeBounds) boundsCollide(viewport, shapeBounds)
) )
}) })
.map((shape) => shape.id)
return shapesToShow return shapesToShow
}, deepCompareArrays) }, deepCompareArrays)

View file

@ -1,6 +1,7 @@
import Command from './command' import Command from './command'
import history from '../history' import history from '../history'
import { Data } from 'types' import { Data } from 'types'
import tld from 'utils/tld'
import storage from 'state/storage' import storage from 'state/storage'
export default function changePage(data: Data, toPageId: string): void { export default function changePage(data: Data, toPageId: string): void {
@ -17,11 +18,15 @@ export default function changePage(data: Data, toPageId: string): void {
storage.loadPage(data, data.document.id, toPageId) storage.loadPage(data, data.document.id, toPageId)
data.currentPageId = toPageId data.currentPageId = toPageId
data.currentParentId = toPageId data.currentParentId = toPageId
tld.setZoomCSS(tld.getPageState(data).camera.zoom)
}, },
undo(data) { undo(data) {
storage.loadPage(data, data.document.id, fromPageId) storage.loadPage(data, data.document.id, fromPageId)
data.currentPageId = fromPageId data.currentPageId = fromPageId
data.currentParentId = fromPageId data.currentParentId = fromPageId
tld.setZoomCSS(tld.getPageState(data).camera.zoom)
}, },
}) })
) )

View file

@ -2,6 +2,7 @@ import Command from './command'
import history from '../history' import history from '../history'
import { Data, Page, PageState } from 'types' import { Data, Page, PageState } from 'types'
import { uniqueId } from 'utils/utils' import { uniqueId } from 'utils/utils'
import tld from 'utils/tld'
import storage from 'state/storage' import storage from 'state/storage'
export default function createPage(data: Data, goToPage = true): void { export default function createPage(data: Data, goToPage = true): void {
@ -25,6 +26,7 @@ export default function createPage(data: Data, goToPage = true): void {
storage.savePage(data, data.document.id, page.id) storage.savePage(data, data.document.id, page.id)
storage.saveDocumentToLocalStorage(data) storage.saveDocumentToLocalStorage(data)
tld.setZoomCSS(tld.getPageState(data).camera.zoom)
}, },
undo(data) { undo(data) {
const { page, currentPageId } = snapshot const { page, currentPageId } = snapshot
@ -32,6 +34,7 @@ export default function createPage(data: Data, goToPage = true): void {
delete data.pageStates[page.id] delete data.pageStates[page.id]
data.currentPageId = currentPageId data.currentPageId = currentPageId
storage.saveDocumentToLocalStorage(data) storage.saveDocumentToLocalStorage(data)
tld.setZoomCSS(tld.getPageState(data).camera.zoom)
}, },
}) })
) )

View file

@ -87,6 +87,8 @@ export default function moveToPageCommand(data: Data, newPageId: string): void {
// Move to the new page // Move to the new page
data.currentPageId = toPageId data.currentPageId = toPageId
tld.setZoomCSS(tld.getPageState(data).camera.zoom)
}, },
undo(data) { undo(data) {
const fromPageId = newPageId const fromPageId = newPageId
@ -141,6 +143,8 @@ export default function moveToPageCommand(data: Data, newPageId: string): void {
tld.setSelectedIds(data, [...selectedIds]) tld.setSelectedIds(data, [...selectedIds])
data.currentPageId = toPageId data.currentPageId = toPageId
tld.setZoomCSS(tld.getPageState(data).camera.zoom)
}, },
}) })
) )

View file

@ -90,6 +90,8 @@ export function fastZoomUpdate(point: number[], delta: number): void {
data.pageStates[data.currentPageId].camera = deepClone(camera) data.pageStates[data.currentPageId].camera = deepClone(camera)
tld.setZoomCSS(camera.zoom)
state.forceData(freeze(data)) state.forceData(freeze(data))
} }
@ -116,6 +118,8 @@ export function fastPinchCamera(
data.pageStates[data.currentPageId] = { ...pageState } data.pageStates[data.currentPageId] = { ...pageState }
tld.setZoomCSS(camera.zoom)
state.forceData(freeze(data)) state.forceData(freeze(data))
} }

View file

@ -77,5 +77,5 @@ export const defaultStyle: ShapeStyles = {
color: ColorStyle.Black, color: ColorStyle.Black,
size: SizeStyle.Medium, size: SizeStyle.Medium,
isFilled: false, isFilled: false,
dash: DashStyle.Solid, dash: DashStyle.Draw,
} }

View file

@ -7,7 +7,6 @@ import {
getBoundsFromPoints, getBoundsFromPoints,
translateBounds, translateBounds,
pointInBounds, pointInBounds,
pointInCircle,
circleFromThreePoints, circleFromThreePoints,
isAngleBetween, isAngleBetween,
getPerfectDashProps, getPerfectDashProps,
@ -103,7 +102,7 @@ const arrow = registerShapeUtils<ArrowShape>({
}, },
render(shape) { render(shape) {
const { id, bend, handles, style } = shape const { bend, handles, style } = shape
const { start, end, bend: _bend } = handles const { start, end, bend: _bend } = handles
const isStraightLine = const isStraightLine =
@ -146,11 +145,11 @@ const arrow = registerShapeUtils<ArrowShape>({
<path <path
d={path} d={path}
fill="none" fill="none"
stroke="transparent"
strokeWidth={Math.max(8, strokeWidth * 2)} strokeWidth={Math.max(8, strokeWidth * 2)}
strokeDasharray="none" strokeDasharray="none"
strokeDashoffset="none" strokeDashoffset="none"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round"
/> />
<path <path
d={path} d={path}
@ -160,6 +159,7 @@ const arrow = registerShapeUtils<ArrowShape>({
strokeDasharray={strokeDasharray} strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset} strokeDashoffset={strokeDashoffset}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round"
/> />
</> </>
) )
@ -206,6 +206,7 @@ const arrow = registerShapeUtils<ArrowShape>({
strokeDasharray="none" strokeDasharray="none"
strokeDashoffset="none" strokeDashoffset="none"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round"
/> />
<path <path
d={path} d={path}
@ -215,22 +216,28 @@ const arrow = registerShapeUtils<ArrowShape>({
strokeDasharray={strokeDasharray} strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset} strokeDashoffset={strokeDashoffset}
strokeLinecap="round" strokeLinecap="round"
></path> strokeLinejoin="round"
/>
</> </>
) )
} }
const sw = strokeWidth * 1.618
return ( return (
<g id={id}> <g pointerEvents="all">
{shaftPath} {shaftPath}
{shape.decorations.start === Decoration.Arrow && ( {shape.decorations.start === Decoration.Arrow && (
<path <path
d={getArrowHeadPath(shape, start.point, insetStart)} d={getArrowHeadPath(shape, start.point, insetStart)}
fill="none" fill="none"
stroke={styles.stroke} stroke={styles.stroke}
strokeWidth={strokeWidth * 1.618} strokeWidth={sw}
strokeDashoffset="none" strokeDashoffset="none"
strokeDasharray="none" strokeDasharray="none"
strokeLinecap="round"
strokeLinejoin="round"
pointerEvents="stroke"
/> />
)} )}
{shape.decorations.end === Decoration.Arrow && ( {shape.decorations.end === Decoration.Arrow && (
@ -238,9 +245,12 @@ const arrow = registerShapeUtils<ArrowShape>({
d={getArrowHeadPath(shape, end.point, insetEnd)} d={getArrowHeadPath(shape, end.point, insetEnd)}
fill="none" fill="none"
stroke={styles.stroke} stroke={styles.stroke}
strokeWidth={strokeWidth * 1.618} strokeWidth={sw}
strokeDashoffset="none" strokeDashoffset="none"
strokeDasharray="none" strokeDasharray="none"
strokeLinecap="round"
strokeLinejoin="round"
pointerEvents="stroke"
/> />
)} )}
</g> </g>
@ -302,21 +312,8 @@ const arrow = registerShapeUtils<ArrowShape>({
return vec.add(shape.point, vec.med(start.point, end.point)) return vec.add(shape.point, vec.med(start.point, end.point))
}, },
hitTest(shape, point) { hitTest() {
const { start, end } = shape.handles return true
if (shape.bend === 0) {
return (
vec.distanceToLineSegment(
start.point,
end.point,
vec.sub(point, shape.point)
) < 4
)
}
const [cx, cy, r] = getCtp(shape)
return !pointInCircle(point, vec.add(shape.point, [cx, cy]), r - 4)
}, },
hitTestBounds(this, shape, brushBounds) { hitTestBounds(this, shape, brushBounds) {

View file

@ -20,13 +20,9 @@ const dot = registerShapeUtils<DotShape>({
}, },
render(shape) { render(shape) {
const { id } = shape
const styles = getShapeStyle(shape.style) const styles = getShapeStyle(shape.style)
return ( return <use href="#dot" stroke={styles.stroke} fill={styles.stroke} />
<use id={id} href="#dot" stroke={styles.stroke} fill={styles.stroke} />
)
}, },
getBounds(shape) { getBounds(shape) {

View file

@ -40,29 +40,32 @@ const draw = registerShapeUtils<DrawShape>({
return shape.points !== prev.points || shape.style !== prev.style return shape.points !== prev.points || shape.style !== prev.style
}, },
render(shape) { render(shape, { isHovered }) {
const { id, points, style } = shape const { points, style } = shape
const styles = getShapeStyle(style) const styles = getShapeStyle(style)
const strokeWidth = +styles.strokeWidth const strokeWidth = +styles.strokeWidth
const shouldFill = const shouldFill =
style.isFilled &&
points.length > 3 && points.length > 3 &&
vec.dist(points[0], points[points.length - 1]) < +styles.strokeWidth * 2 vec.dist(points[0], points[points.length - 1]) < +styles.strokeWidth * 2
// For very short lines, draw a point instead of a line // For very short lines, draw a point instead of a line
if (points.length > 0 && points.length < 3) { if (points.length > 0 && points.length < 3) {
const sw = strokeWidth * 0.618
return ( return (
<g id={id}> <circle
<circle r={strokeWidth * 0.618}
r={strokeWidth * 0.618} fill={styles.stroke}
fill={styles.stroke} stroke={styles.stroke}
stroke={styles.stroke} strokeWidth={sw}
strokeWidth={styles.strokeWidth} pointerEvents="all"
/> filter={isHovered ? 'url(#expand)' : 'none'}
</g> />
) )
} }
@ -78,13 +81,15 @@ const draw = registerShapeUtils<DrawShape>({
}) })
return ( return (
<g id={id}> <>
{shouldFill && ( {shouldFill && (
<path <path
d={polygonPathData} d={polygonPathData}
strokeWidth="0"
stroke="none" stroke="none"
fill={styles.fill} fill={styles.fill}
strokeLinejoin="round"
strokeLinecap="round"
pointerEvents="fill"
/> />
)} )}
<path <path
@ -92,8 +97,12 @@ const draw = registerShapeUtils<DrawShape>({
fill={styles.stroke} fill={styles.stroke}
stroke={styles.stroke} stroke={styles.stroke}
strokeWidth={strokeWidth} strokeWidth={strokeWidth}
strokeLinejoin="round"
strokeLinecap="round"
pointerEvents="all"
filter={isHovered ? 'url(#expand)' : 'none'}
/> />
</g> </>
) )
} }
@ -119,25 +128,32 @@ const draw = registerShapeUtils<DrawShape>({
const path = simplePathCache.get(points) const path = simplePathCache.get(points)
const sw = strokeWidth * 1.618
return ( return (
<g id={id}> <>
{style.dash !== DashStyle.Solid && (
<path
d={path}
fill="transparent"
stroke="transparent"
strokeWidth={strokeWidth * 2}
/>
)}
<path <path
d={path} d={path}
fill={shouldFill ? styles.fill : 'none'} fill={shouldFill ? styles.fill : 'none'}
stroke="transparent"
strokeWidth={Math.min(4, strokeWidth * 2)}
strokeLinejoin="round"
strokeLinecap="round"
pointerEvents={shouldFill ? 'all' : 'stroke'}
/>
<path
d={path}
fill="transparent"
stroke={styles.stroke} stroke={styles.stroke}
strokeWidth={strokeWidth * 1.618} strokeWidth={sw}
strokeDasharray={strokeDasharray} strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset} strokeDashoffset={strokeDashoffset}
strokeLinejoin="round"
strokeLinecap="round"
pointerEvents="stroke"
filter={isHovered ? 'url(#expand)' : 'none'}
/> />
</g> </>
) )
}, },
@ -160,13 +176,8 @@ const draw = registerShapeUtils<DrawShape>({
return getBoundsCenter(this.getBounds(shape)) return getBoundsCenter(this.getBounds(shape))
}, },
hitTest(shape, point) { hitTest() {
const pt = vec.sub(point, shape.point) return true
const min = +getShapeStyle(shape.style).strokeWidth
return shape.points.some(
(curr, i) =>
i > 0 && vec.distanceToLineSegment(shape.points[i - 1], curr, pt) < min
)
}, },
hitTestBounds(this, shape, brushBounds) { hitTestBounds(this, shape, brushBounds) {

View file

@ -43,7 +43,7 @@ const ellipse = registerShapeUtils<EllipseShape>({
}, },
render(shape) { render(shape) {
const { id, radiusX, radiusY, style } = shape const { radiusX, radiusY, style } = shape
const styles = getShapeStyle(style) const styles = getShapeStyle(style)
const strokeWidth = +styles.strokeWidth const strokeWidth = +styles.strokeWidth
@ -58,25 +58,26 @@ const ellipse = registerShapeUtils<EllipseShape>({
const path = pathCache.get(shape) const path = pathCache.get(shape)
return ( return (
<g id={id}> <>
{style.isFilled && ( {style.isFilled && (
<ellipse <ellipse
id={id}
cx={radiusX} cx={radiusX}
cy={radiusY} cy={radiusY}
rx={rx} rx={rx}
ry={ry} ry={ry}
stroke="none" stroke="none"
fill={styles.fill} fill={styles.fill}
pointerEvents="fill"
/> />
)} )}
<path <path
d={path} d={path}
fill={styles.stroke} fill={styles.stroke}
stroke={styles.stroke} stroke={styles.stroke}
strokeWidth={styles.strokeWidth} strokeWidth={strokeWidth}
pointerEvents="all"
/> />
</g> </>
) )
} }
@ -92,21 +93,21 @@ const ellipse = registerShapeUtils<EllipseShape>({
4 4
) )
const sw = strokeWidth * 1.618
return ( return (
<g id={id}> <ellipse
<ellipse cx={radiusX}
id={id} cy={radiusY}
cx={radiusX} rx={rx}
cy={radiusY} ry={ry}
rx={rx} fill={styles.fill}
ry={ry} stroke={styles.stroke}
fill={styles.fill} strokeWidth={sw}
stroke={styles.stroke} strokeDasharray={strokeDasharray}
strokeWidth={strokeWidth * 1.618} strokeDashoffset={strokeDashoffset}
strokeDasharray={strokeDasharray} pointerEvents={style.isFilled ? 'all' : 'stroke'}
strokeDashoffset={strokeDashoffset} />
/>
</g>
) )
}, },

View file

@ -26,11 +26,10 @@ const group = registerShapeUtils<GroupShape>({
}, },
render(shape) { render(shape) {
const { id, size } = shape const { size } = shape
return ( return (
<StyledGroupShape <StyledGroupShape
id={id}
width={size[0]} width={size[0]}
height={size[1]} height={size[1]}
data-shy={true} data-shy={true}

View file

@ -26,7 +26,7 @@ const line = registerShapeUtils<LineShape>({
return shape.direction !== prev.direction || shape.style !== prev.style return shape.direction !== prev.direction || shape.style !== prev.style
}, },
render(shape) { render(shape, { isHovered }) {
const { id, direction } = shape const { id, direction } = shape
const [x1, y1] = vec.add([0, 0], vec.mul(direction, 10000)) const [x1, y1] = vec.add([0, 0], vec.mul(direction, 10000))
const [x2, y2] = vec.sub([0, 0], vec.mul(direction, 10000)) const [x2, y2] = vec.sub([0, 0], vec.mul(direction, 10000))
@ -34,7 +34,7 @@ const line = registerShapeUtils<LineShape>({
const styles = getShapeStyle(shape.style) const styles = getShapeStyle(shape.style)
return ( return (
<g id={id}> <g id={id} filter={isHovered ? 'url(#expand)' : 'none'}>
<ThinLine x1={x1} y1={y1} x2={x2} y2={y2} stroke={styles.stroke} /> <ThinLine x1={x1} y1={y1} x2={x2} y2={y2} stroke={styles.stroke} />
<circle r={4} fill="transparent" /> <circle r={4} fill="transparent" />
<use href="#dot" fill="black" /> <use href="#dot" fill="black" />

View file

@ -29,17 +29,19 @@ const polyline = registerShapeUtils<PolylineShape>({
return shape.points !== prev.points || shape.style !== prev.style return shape.points !== prev.points || shape.style !== prev.style
}, },
render(shape) { render(shape) {
const { id, points } = shape const { points, style } = shape
const styles = getShapeStyle(shape.style) const styles = getShapeStyle(style)
return ( return (
<polyline <polyline
id={id}
points={points.toString()} points={points.toString()}
stroke={styles.stroke} stroke={styles.stroke}
strokeWidth={styles.strokeWidth} strokeWidth={styles.strokeWidth * 1.618}
fill={shape.style.isFilled ? styles.fill : 'none'} fill={shape.style.isFilled ? styles.fill : 'none'}
pointerEvents={style.isFilled ? 'all' : 'stroke'}
strokeLinecap="round"
strokeLinejoin="round"
/> />
) )
}, },
@ -61,19 +63,8 @@ const polyline = registerShapeUtils<PolylineShape>({
return [bounds.minX + bounds.width / 2, bounds.minY + bounds.height / 2] return [bounds.minX + bounds.width / 2, bounds.minY + bounds.height / 2]
}, },
hitTest(shape, point) { hitTest() {
const pt = vec.sub(point, shape.point) return true
let prev = shape.points[0]
for (let i = 1; i < shape.points.length; i++) {
const curr = shape.points[i]
if (vec.distanceToLineSegment(prev, curr, pt) < 4) {
return true
}
prev = curr
}
return false
}, },
hitTestBounds(this, shape, brushBounds) { hitTestBounds(this, shape, brushBounds) {
@ -125,7 +116,7 @@ const polyline = registerShapeUtils<PolylineShape>({
canTransform: true, canTransform: true,
canChangeAspectRatio: true, canChangeAspectRatio: true,
canStyleFill: false, canStyleFill: true,
}) })
export default polyline export default polyline

View file

@ -26,18 +26,18 @@ const ray = registerShapeUtils<RayShape>({
return shape.direction !== prev.direction || shape.style !== prev.style return shape.direction !== prev.direction || shape.style !== prev.style
}, },
render(shape) { render(shape) {
const { id, direction } = shape const { direction } = shape
const styles = getShapeStyle(shape.style) const styles = getShapeStyle(shape.style)
const [x2, y2] = vec.add([0, 0], vec.mul(direction, 10000)) const [x2, y2] = vec.add([0, 0], vec.mul(direction, 10000))
return ( return (
<g id={id}> <>
<ThinLine x1={0} y1={0} x2={x2} y2={y2} stroke={styles.stroke} /> <ThinLine x1={0} y1={0} x2={x2} y2={y2} stroke={styles.stroke} />
<circle r={4} fill="transparent" /> <circle r={4} fill="transparent" />
<use href="#dot" /> <use href="#dot" />
</g> </>
) )
}, },

View file

@ -28,7 +28,7 @@ const rectangle = registerShapeUtils<RectangleShape>({
return shape.size !== prev.size || shape.style !== prev.style return shape.size !== prev.size || shape.style !== prev.style
}, },
render(shape) { render(shape, { isHovered }) {
const { id, size, radius, style } = shape const { id, size, radius, style } = shape
const styles = getShapeStyle(style) const styles = getShapeStyle(style)
const strokeWidth = +styles.strokeWidth const strokeWidth = +styles.strokeWidth
@ -39,25 +39,29 @@ const rectangle = registerShapeUtils<RectangleShape>({
}) })
return ( return (
<g id={id}> <>
<rect {style.isFilled && (
rx={radius} <rect
ry={radius} rx={radius}
x={+styles.strokeWidth / 2} ry={radius}
y={+styles.strokeWidth / 2} x={+styles.strokeWidth / 2}
width={Math.max(0, size[0] - strokeWidth)} y={+styles.strokeWidth / 2}
height={Math.max(0, size[1] - strokeWidth)} width={Math.max(0, size[0] - strokeWidth)}
strokeWidth={0} height={Math.max(0, size[1] - strokeWidth)}
fill={styles.fill} strokeWidth={0}
stroke={styles.stroke} fill={styles.fill}
/> stroke={styles.stroke}
/>
)}
<path <path
d={pathData} d={pathData}
fill={styles.stroke} fill={styles.stroke}
stroke={styles.stroke} stroke={styles.stroke}
strokeWidth={styles.strokeWidth} strokeWidth={styles.strokeWidth}
filter={isHovered ? 'url(#expand)' : 'none'}
pointerEvents={style.isFilled ? 'all' : 'stroke'}
/> />
</g> </>
) )
} }
@ -97,17 +101,21 @@ const rectangle = registerShapeUtils<RectangleShape>({
}) })
return ( return (
<g id={id}> <>
<rect <rect
x={sw / 2} x={sw / 2}
y={sw / 2} y={sw / 2}
width={w} width={w}
height={h} height={h}
fill={styles.fill} fill={styles.fill}
stroke="none" stroke="transparent"
strokeWidth={sw}
pointerEvents={style.isFilled ? 'all' : 'stroke'}
/> />
{paths} <g filter={isHovered ? 'url(#expand)' : 'none'} pointerEvents="stroke">
</g> {paths}
</g>
</>
) )
}, },

View file

@ -1,4 +1,4 @@
import { uniqueId, isMobile, getFromCache } from 'utils/utils' import { uniqueId, getFromCache } from 'utils/utils'
import vec from 'utils/vec' import vec from 'utils/vec'
import TextAreaUtils from 'utils/text-area' import TextAreaUtils from 'utils/text-area'
import { TextShape, ShapeType } from 'types' import { TextShape, ShapeType } from 'types'
@ -106,7 +106,13 @@ const text = registerShapeUtils<TextShape>({
} }
} }
function handleBlur() { function handleBlur(e: React.FocusEvent<HTMLTextAreaElement>) {
if (isEditing) {
e.currentTarget.focus()
e.currentTarget.select()
return
}
setTimeout(() => state.send('BLURRED_EDITING_SHAPE', { id }), 0) setTimeout(() => state.send('BLURRED_EDITING_SHAPE', { id }), 0)
} }
@ -115,22 +121,18 @@ const text = registerShapeUtils<TextShape>({
state.send('FOCUSED_EDITING_SHAPE', { id }) state.send('FOCUSED_EDITING_SHAPE', { id })
} }
function handlePointerDown(e: React.PointerEvent<HTMLTextAreaElement>) { function handlePointerDown() {
if (e.currentTarget.selectionEnd !== 0) { if (ref.current.selectionEnd !== 0) {
e.currentTarget.selectionEnd = 0 ref.current.selectionEnd = 0
} }
} }
const fontSize = getFontSize(shape.style.size) * shape.scale const fontSize = getFontSize(shape.style.size) * shape.scale
const lineHeight = fontSize * 1.4 const lineHeight = fontSize * 1.4
if (ref === undefined) {
throw Error('This component should receive a ref.')
}
if (!isEditing) { if (!isEditing) {
return ( return (
<g id={id} pointerEvents="none"> <>
{text.split('\n').map((str, i) => ( {text.split('\n').map((str, i) => (
<text <text
key={i} key={i}
@ -138,13 +140,13 @@ const text = registerShapeUtils<TextShape>({
y={4 + fontSize / 2 + i * lineHeight} y={4 + fontSize / 2 + i * lineHeight}
fontFamily="Verveine Regular" fontFamily="Verveine Regular"
fontStyle="normal" fontStyle="normal"
fontWeight="regular" fontWeight="500"
fontSize={fontSize} fontSize={fontSize}
width={bounds.width} width={bounds.width}
height={bounds.height} height={bounds.height}
fill={styles.stroke} fill={styles.stroke}
color={styles.stroke} color={styles.stroke}
stroke={styles.stroke} stroke="none"
xmlSpace="preserve" xmlSpace="preserve"
dominantBaseline="mathematical" dominantBaseline="mathematical"
alignmentBaseline="mathematical" alignmentBaseline="mathematical"
@ -152,15 +154,20 @@ const text = registerShapeUtils<TextShape>({
{str} {str}
</text> </text>
))} ))}
</g> </>
) )
} }
if (ref === undefined) {
throw Error('This component should receive a ref when editing.')
}
return ( return (
<foreignObject <foreignObject
id={id}
width={bounds.width} width={bounds.width}
height={bounds.height} height={bounds.height}
pointerEvents="none" pointerEvents="none"
onPointerDown={(e) => e.stopPropagation()}
> >
<StyledTextArea <StyledTextArea
ref={ref as React.RefObject<HTMLTextAreaElement>} ref={ref as React.RefObject<HTMLTextAreaElement>}
@ -177,7 +184,7 @@ const text = registerShapeUtils<TextShape>({
autoSave="false" autoSave="false"
placeholder="" placeholder=""
color={styles.stroke} color={styles.stroke}
autoFocus={!!isMobile()} autoFocus={true}
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
@ -287,6 +294,7 @@ const StyledTextArea = styled('textarea', {
minWidth: 1, minWidth: 1,
lineHeight: 1.4, lineHeight: 1.4,
outline: 0, outline: 0,
fontWeight: '500',
backgroundColor: '$boundsBg', backgroundColor: '$boundsBg',
overflow: 'hidden', overflow: 'hidden',
pointerEvents: 'all', pointerEvents: 'all',

View file

@ -663,34 +663,45 @@ const state = createState({
onExit: ['completeSession', 'clearEditingId'], onExit: ['completeSession', 'clearEditingId'],
on: { on: {
EDITED_SHAPE: { do: 'updateEditSession' }, EDITED_SHAPE: { do: 'updateEditSession' },
BLURRED_EDITING_SHAPE: [ POINTED_SHAPE: [
{ unless: 'isEditingShape' },
{ {
unless: 'isPointingEditingShape',
if: 'isPointingTextShape',
do: [
'completeSession',
'clearEditingId',
'setPointedId',
'clearSelectedIds',
'pushPointedIdToSelectedIds',
'setEditingId',
'startEditSession',
],
},
],
BLURRED_EDITING_SHAPE: [
{
unless: 'isEditingShape',
get: 'editingShape', get: 'editingShape',
if: 'shouldDeleteShape', if: 'shouldDeleteShape',
do: ['cancelSession', 'deleteSelection'], do: ['cancelSession', 'deleteSelection'],
}, },
{ to: 'selecting' }, { to: 'selecting' },
], ],
POINTED_SHAPE: { POINTED_CANVAS: [
unless: 'isPointingEditingShape',
if: 'isPointingTextShape',
do: [
'completeSession',
'clearEditingId',
'setPointedId',
'clearSelectedIds',
'pushPointedIdToSelectedIds',
'setEditingId',
'startEditSession',
],
},
CANCELLED: [
{ {
unless: 'isEditingShape',
get: 'editingShape', get: 'editingShape',
if: 'shouldDeleteShape', if: 'shouldDeleteShape',
do: 'breakSession', do: ['cancelSession', 'deleteSelection'],
else: 'cancelSession', },
{ to: 'selecting' },
],
CANCELLED: [
{
unless: 'isEditingShape',
get: 'editingShape',
if: 'shouldDeleteShape',
do: ['cancelSession', 'deleteSelection'],
}, },
{ to: 'selecting' }, { to: 'selecting' },
], ],

View file

@ -607,8 +607,11 @@ export interface ShapeUtility<K extends Shape> {
render( render(
this: ShapeUtility<K>, this: ShapeUtility<K>,
shape: K, shape: K,
info: { info?: {
isEditing: boolean isEditing?: boolean
isHovered?: boolean
isSelected?: boolean
isCurrentParent?: boolean
ref?: React.MutableRefObject<HTMLTextAreaElement> ref?: React.MutableRefObject<HTMLTextAreaElement>
} }
): JSX.Element ): JSX.Element

View file

@ -65,6 +65,43 @@ export function decompress(s: string): string {
return s return s
} }
/**
* Get whether two objects are shallowly equal.
*
* ### Example
*
*```ts
* shallowEqual(objA, objB) // true
*```
*/
export function shallowEqual(
objA: Record<string, unknown>,
objB: Record<string, unknown>
): boolean {
if (objA === objB) return true
if (!objA || !objB) return false
const aKeys = Object.keys(objA)
const bKeys = Object.keys(objB)
const len = aKeys.length
if (bKeys.length !== len) return false
for (let i = 0; i < len; i++) {
const key = aKeys[i]
if (
objA[key] !== objB[key] ||
!Object.prototype.hasOwnProperty.call(objB, key)
) {
return false
}
}
return true
}
/** /**
* Recursively clone an object or array. * Recursively clone an object or array.
* @param obj * @param obj