commit
0bf450b01d
24 changed files with 476 additions and 417 deletions
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
|
@ -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)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
|
@ -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)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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>
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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" />
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
</>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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' },
|
||||||
],
|
],
|
||||||
|
|
7
types.ts
7
types.ts
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue