Adds arrows, handles
This commit is contained in:
parent
facd9e9845
commit
bcffee6458
27 changed files with 1120 additions and 292 deletions
|
@ -1,12 +1,18 @@
|
|||
import * as React from 'react'
|
||||
import { Edge, Corner } from 'types'
|
||||
import { Edge, Corner, LineShape, ArrowShape } from 'types'
|
||||
import { useSelector } from 'state'
|
||||
import { getPage, getSelectedShapes, isMobile } from 'utils/utils'
|
||||
import {
|
||||
deepCompareArrays,
|
||||
getPage,
|
||||
getSelectedShapes,
|
||||
isMobile,
|
||||
} from 'utils/utils'
|
||||
|
||||
import CenterHandle from './center-handle'
|
||||
import CornerHandle from './corner-handle'
|
||||
import EdgeHandle from './edge-handle'
|
||||
import RotateHandle from './rotate-handle'
|
||||
import Handles from './handles'
|
||||
|
||||
export default function Bounds() {
|
||||
const isBrushing = useSelector((s) => s.isIn('brushSelecting'))
|
||||
|
@ -14,20 +20,31 @@ export default function Bounds() {
|
|||
const zoom = useSelector((s) => s.data.camera.zoom)
|
||||
const bounds = useSelector((s) => s.values.selectedBounds)
|
||||
|
||||
const selectedIds = useSelector(
|
||||
(s) => Array.from(s.values.selectedIds.values()),
|
||||
deepCompareArrays
|
||||
)
|
||||
|
||||
const rotation = useSelector(({ data }) =>
|
||||
data.selectedIds.size === 1 ? getSelectedShapes(data)[0].rotation : 0
|
||||
)
|
||||
|
||||
const isAllLocked = useSelector((s) => {
|
||||
const page = getPage(s.data)
|
||||
return Array.from(s.data.selectedIds.values()).every(
|
||||
(id) => page.shapes[id].isLocked
|
||||
)
|
||||
return selectedIds.every((id) => page.shapes[id]?.isLocked)
|
||||
})
|
||||
|
||||
const isAllHandles = useSelector((s) => {
|
||||
const page = getPage(s.data)
|
||||
return selectedIds.every((id) => page.shapes[id]?.handles !== undefined)
|
||||
})
|
||||
|
||||
if (!bounds) return null
|
||||
|
||||
if (!isSelecting) return null
|
||||
|
||||
if (isAllHandles) return null
|
||||
|
||||
const size = (isMobile().any ? 10 : 8) / zoom // Touch target size
|
||||
|
||||
return (
|
||||
|
|
|
@ -2,7 +2,7 @@ import { useCallback, useRef } from 'react'
|
|||
import state, { useSelector } from 'state'
|
||||
import inputs from 'state/inputs'
|
||||
import styled from 'styles'
|
||||
import { getPage } from 'utils/utils'
|
||||
import { deepCompareArrays, getPage } from 'utils/utils'
|
||||
|
||||
function handlePointerDown(e: React.PointerEvent<SVGRectElement>) {
|
||||
if (e.buttons !== 1) return
|
||||
|
@ -22,18 +22,34 @@ function handlePointerUp(e: React.PointerEvent<SVGRectElement>) {
|
|||
|
||||
export default function BoundsBg() {
|
||||
const rBounds = useRef<SVGRectElement>(null)
|
||||
|
||||
const bounds = useSelector((state) => state.values.selectedBounds)
|
||||
|
||||
const isSelecting = useSelector((s) => s.isIn('selecting'))
|
||||
|
||||
const selectedIds = useSelector(
|
||||
(s) => Array.from(s.values.selectedIds.values()),
|
||||
deepCompareArrays
|
||||
)
|
||||
|
||||
const rotation = useSelector((s) => {
|
||||
if (s.data.selectedIds.size === 1) {
|
||||
if (selectedIds.length === 1) {
|
||||
const { shapes } = getPage(s.data)
|
||||
const selected = Array.from(s.data.selectedIds.values())[0]
|
||||
const selected = Array.from(s.values.selectedIds.values())[0]
|
||||
return shapes[selected].rotation
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
})
|
||||
|
||||
const isAllHandles = useSelector((s) => {
|
||||
const page = getPage(s.data)
|
||||
return Array.from(s.values.selectedIds.values()).every(
|
||||
(id) => page.shapes[id]?.handles !== undefined
|
||||
)
|
||||
})
|
||||
|
||||
if (isAllHandles) return null
|
||||
if (!bounds) return null
|
||||
if (!isSelecting) return null
|
||||
|
||||
|
|
79
components/canvas/bounds/handles.tsx
Normal file
79
components/canvas/bounds/handles.tsx
Normal file
|
@ -0,0 +1,79 @@
|
|||
import useHandleEvents from 'hooks/useHandleEvents'
|
||||
import { getShapeUtils } from 'lib/shape-utils'
|
||||
import { useRef } from 'react'
|
||||
import { useSelector } from 'state'
|
||||
import styled from 'styles'
|
||||
import { deepCompareArrays, getPage } from 'utils/utils'
|
||||
import * as vec from 'utils/vec'
|
||||
|
||||
export default function Handles() {
|
||||
const selectedIds = useSelector(
|
||||
(s) => Array.from(s.values.selectedIds.values()),
|
||||
deepCompareArrays
|
||||
)
|
||||
|
||||
const shape = useSelector(
|
||||
({ data }) =>
|
||||
selectedIds.length === 1 && getPage(data).shapes[selectedIds[0]]
|
||||
)
|
||||
|
||||
const isTranslatingHandles = useSelector((s) => s.isIn('translatingHandles'))
|
||||
|
||||
if (!shape.handles || isTranslatingHandles) return null
|
||||
|
||||
return (
|
||||
<g>
|
||||
{Object.values(shape.handles).map((handle) => (
|
||||
<Handle
|
||||
key={handle.id}
|
||||
shapeId={shape.id}
|
||||
id={handle.id}
|
||||
point={vec.add(handle.point, shape.point)}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
function Handle({
|
||||
shapeId,
|
||||
id,
|
||||
point,
|
||||
}: {
|
||||
shapeId: string
|
||||
id: string
|
||||
point: number[]
|
||||
}) {
|
||||
const rGroup = useRef<SVGGElement>(null)
|
||||
const events = useHandleEvents(id, rGroup)
|
||||
|
||||
const transform = `
|
||||
translate(${point})
|
||||
`
|
||||
|
||||
return (
|
||||
<g
|
||||
key={id}
|
||||
ref={rGroup}
|
||||
{...events}
|
||||
pointerEvents="all"
|
||||
transform={`translate(${point})`}
|
||||
>
|
||||
<HandleCircleOuter r={8} />
|
||||
<HandleCircle r={4} />
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
const HandleCircleOuter = styled('circle', {
|
||||
fill: 'transparent',
|
||||
pointerEvents: 'all',
|
||||
cursor: 'pointer',
|
||||
})
|
||||
|
||||
const HandleCircle = styled('circle', {
|
||||
zStrokeWidth: 2,
|
||||
stroke: '$text',
|
||||
fill: '$panel',
|
||||
pointerEvents: 'none',
|
||||
})
|
|
@ -10,6 +10,7 @@ import Brush from './brush'
|
|||
import Bounds from './bounds/bounding-box'
|
||||
import BoundsBg from './bounds/bounds-bg'
|
||||
import Selected from './selected'
|
||||
import Handles from './bounds/handles'
|
||||
|
||||
export default function Canvas() {
|
||||
const rCanvas = useRef<SVGSVGElement>(null)
|
||||
|
@ -60,6 +61,7 @@ export default function Canvas() {
|
|||
<Page />
|
||||
<Selected />
|
||||
<Bounds />
|
||||
<Handles />
|
||||
<Brush />
|
||||
</g>
|
||||
)}
|
||||
|
|
|
@ -26,7 +26,8 @@ function Shape({ id, isSelecting }: { id: string; isSelecting: boolean }) {
|
|||
const center = getShapeUtils(shape).getCenter(shape)
|
||||
const transform = `
|
||||
rotate(${shape.rotation * (180 / Math.PI)}, ${center})
|
||||
translate(${shape.point})`
|
||||
translate(${shape.point})
|
||||
`
|
||||
|
||||
return (
|
||||
<StyledGroup
|
||||
|
@ -54,22 +55,6 @@ const StyledShape = memo(
|
|||
}
|
||||
)
|
||||
|
||||
function Label({ text }: { text: string }) {
|
||||
return (
|
||||
<text
|
||||
y={4}
|
||||
x={4}
|
||||
fontSize={18}
|
||||
fill="black"
|
||||
stroke="none"
|
||||
alignmentBaseline="text-before-edge"
|
||||
pointerEvents="none"
|
||||
>
|
||||
{text}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
const HoverIndicator = styled('path', {
|
||||
fill: 'none',
|
||||
stroke: 'transparent',
|
||||
|
@ -133,6 +118,22 @@ const StyledGroup = styled('g', {
|
|||
],
|
||||
})
|
||||
|
||||
function Label({ text }: { text: string }) {
|
||||
return (
|
||||
<text
|
||||
y={4}
|
||||
x={4}
|
||||
fontSize={18}
|
||||
fill="black"
|
||||
stroke="none"
|
||||
alignmentBaseline="text-before-edge"
|
||||
pointerEvents="none"
|
||||
>
|
||||
{text}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
export { HoverIndicator }
|
||||
|
||||
export default memo(Shape)
|
||||
|
|
|
@ -1,96 +0,0 @@
|
|||
import state, { useSelector } from 'state'
|
||||
import styled from 'styles'
|
||||
import { Lock, Menu, RotateCcw, RotateCw, Unlock } from 'react-feather'
|
||||
import { IconButton } from './shared'
|
||||
|
||||
export default function Toolbar() {
|
||||
const activeTool = useSelector((state) =>
|
||||
state.whenIn({
|
||||
selecting: 'select',
|
||||
dot: 'dot',
|
||||
circle: 'circle',
|
||||
ellipse: 'ellipse',
|
||||
ray: 'ray',
|
||||
line: 'line',
|
||||
polyline: 'polyline',
|
||||
rectangle: 'rectangle',
|
||||
draw: 'draw',
|
||||
})
|
||||
)
|
||||
|
||||
const isToolLocked = useSelector((s) => s.data.settings.isToolLocked)
|
||||
|
||||
return (
|
||||
<ToolbarContainer>
|
||||
<Section>
|
||||
<Button>
|
||||
<Menu />
|
||||
</Button>
|
||||
<Button onClick={() => state.send('RESET_CAMERA')}>Reset Camera</Button>
|
||||
</Section>
|
||||
<Section>
|
||||
<Button title="Undo" onClick={() => state.send('UNDO')}>
|
||||
<RotateCcw />
|
||||
</Button>
|
||||
<Button title="Redo" onClick={() => state.send('REDO')}>
|
||||
<RotateCw />
|
||||
</Button>
|
||||
</Section>
|
||||
</ToolbarContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const ToolbarContainer = styled('div', {
|
||||
gridArea: 'toolbar',
|
||||
userSelect: 'none',
|
||||
borderBottom: '1px solid black',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
backgroundColor: '$panel',
|
||||
gap: 8,
|
||||
fontSize: '$1',
|
||||
zIndex: 200,
|
||||
})
|
||||
|
||||
const Section = styled('div', {
|
||||
whiteSpace: 'nowrap',
|
||||
overflowY: 'hidden',
|
||||
overflowX: 'auto',
|
||||
display: 'flex',
|
||||
scrollbarWidth: 'none',
|
||||
'&::-webkit-scrollbar': {
|
||||
'-webkit-appearance': 'none',
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const Button = styled('button', {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
font: '$ui',
|
||||
fontSize: '$ui',
|
||||
height: '40px',
|
||||
outline: 'none',
|
||||
borderRadius: 0,
|
||||
border: 'none',
|
||||
padding: '0 12px',
|
||||
background: 'none',
|
||||
'&:hover': {
|
||||
backgroundColor: '$hint',
|
||||
},
|
||||
'& svg': {
|
||||
height: 16,
|
||||
width: 16,
|
||||
},
|
||||
variants: {
|
||||
isSelected: {
|
||||
true: {
|
||||
color: '$selected',
|
||||
},
|
||||
false: {},
|
||||
},
|
||||
},
|
||||
})
|
|
@ -1,9 +1,9 @@
|
|||
import {
|
||||
ArrowTopRightIcon,
|
||||
CircleIcon,
|
||||
CursorArrowIcon,
|
||||
DividerHorizontalIcon,
|
||||
DotIcon,
|
||||
LineHeightIcon,
|
||||
LockClosedIcon,
|
||||
LockOpen1Icon,
|
||||
Pencil1Icon,
|
||||
|
@ -19,29 +19,31 @@ import { ShapeType } from 'types'
|
|||
import UndoRedo from './undo-redo'
|
||||
import Zoom from './zoom'
|
||||
|
||||
const selectSelectTool = () => state.send('SELECTED_SELECT_TOOL')
|
||||
const selectDrawTool = () => state.send('SELECTED_DRAW_TOOL')
|
||||
const selectDotTool = () => state.send('SELECTED_DOT_TOOL')
|
||||
const selectArrowTool = () => state.send('SELECTED_ARROW_TOOL')
|
||||
const selectCircleTool = () => state.send('SELECTED_CIRCLE_TOOL')
|
||||
const selectDotTool = () => state.send('SELECTED_DOT_TOOL')
|
||||
const selectDrawTool = () => state.send('SELECTED_DRAW_TOOL')
|
||||
const selectEllipseTool = () => state.send('SELECTED_ELLIPSE_TOOL')
|
||||
const selectRayTool = () => state.send('SELECTED_RAY_TOOL')
|
||||
const selectLineTool = () => state.send('SELECTED_LINE_TOOL')
|
||||
const selectPolylineTool = () => state.send('SELECTED_POLYLINE_TOOL')
|
||||
const selectRayTool = () => state.send('SELECTED_RAY_TOOL')
|
||||
const selectRectangleTool = () => state.send('SELECTED_RECTANGLE_TOOL')
|
||||
const selectSelectTool = () => state.send('SELECTED_SELECT_TOOL')
|
||||
const selectToolLock = () => state.send('TOGGLED_TOOL_LOCK')
|
||||
|
||||
export default function ToolsPanel() {
|
||||
const activeTool = useSelector((state) =>
|
||||
state.whenIn({
|
||||
selecting: 'select',
|
||||
dot: ShapeType.Dot,
|
||||
arrow: ShapeType.Arrow,
|
||||
circle: ShapeType.Circle,
|
||||
dot: ShapeType.Dot,
|
||||
draw: ShapeType.Draw,
|
||||
ellipse: ShapeType.Ellipse,
|
||||
ray: ShapeType.Ray,
|
||||
line: ShapeType.Line,
|
||||
polyline: ShapeType.Polyline,
|
||||
ray: ShapeType.Ray,
|
||||
rectangle: ShapeType.Rectangle,
|
||||
draw: ShapeType.Draw,
|
||||
selecting: 'select',
|
||||
})
|
||||
)
|
||||
|
||||
|
@ -83,19 +85,27 @@ export default function ToolsPanel() {
|
|||
<IconButton
|
||||
name={ShapeType.Circle}
|
||||
size={{ '@sm': 'small', '@md': 'large' }}
|
||||
onClick={selectCircleTool}
|
||||
isActive={activeTool === ShapeType.Circle}
|
||||
onClick={selectEllipseTool}
|
||||
isActive={activeTool === ShapeType.Ellipse}
|
||||
>
|
||||
<CircleIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
name={ShapeType.Ellipse}
|
||||
name={ShapeType.Arrow}
|
||||
size={{ '@sm': 'small', '@md': 'large' }}
|
||||
onClick={selectEllipseTool}
|
||||
isActive={activeTool === ShapeType.Ellipse}
|
||||
onClick={selectArrowTool}
|
||||
isActive={activeTool === ShapeType.Arrow}
|
||||
>
|
||||
<CircleIcon transform="rotate(-45) scale(1, .8)" />
|
||||
<ArrowTopRightIcon />
|
||||
</IconButton>
|
||||
{/* <IconButton
|
||||
name={ShapeType.Circle}
|
||||
size={{ '@sm': 'small', '@md': 'large' }}
|
||||
onClick={selectCircleTool}
|
||||
isActive={activeTool === ShapeType.Circle}
|
||||
>
|
||||
<CircleIcon />
|
||||
</IconButton> */}
|
||||
<IconButton
|
||||
name={ShapeType.Line}
|
||||
size={{ '@sm': 'small', '@md': 'large' }}
|
||||
|
|
60
hooks/useHandleEvents.ts
Normal file
60
hooks/useHandleEvents.ts
Normal file
|
@ -0,0 +1,60 @@
|
|||
import { MutableRefObject, useCallback } from 'react'
|
||||
import state from 'state'
|
||||
import inputs from 'state/inputs'
|
||||
|
||||
export default function useHandleEvents(
|
||||
id: string,
|
||||
rGroup: MutableRefObject<SVGElement>
|
||||
) {
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!inputs.canAccept(e.pointerId)) return
|
||||
e.stopPropagation()
|
||||
rGroup.current.setPointerCapture(e.pointerId)
|
||||
state.send('POINTED_HANDLE', inputs.pointerDown(e, id))
|
||||
},
|
||||
[id]
|
||||
)
|
||||
|
||||
const handlePointerUp = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!inputs.canAccept(e.pointerId)) return
|
||||
e.stopPropagation()
|
||||
rGroup.current.releasePointerCapture(e.pointerId)
|
||||
state.send('STOPPED_POINTING', inputs.pointerUp(e))
|
||||
},
|
||||
[id]
|
||||
)
|
||||
|
||||
const handlePointerEnter = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!inputs.canAccept(e.pointerId)) return
|
||||
state.send('HOVERED_HANDLE', inputs.pointerEnter(e, id))
|
||||
},
|
||||
[id]
|
||||
)
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!inputs.canAccept(e.pointerId)) return
|
||||
state.send('MOVED_OVER_HANDLE', inputs.pointerEnter(e, id))
|
||||
},
|
||||
[id]
|
||||
)
|
||||
|
||||
const handlePointerLeave = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!inputs.canAccept(e.pointerId)) return
|
||||
state.send('UNHOVERED_HANDLE', { target: id })
|
||||
},
|
||||
[id]
|
||||
)
|
||||
|
||||
return {
|
||||
onPointerDown: handlePointerDown,
|
||||
onPointerUp: handlePointerUp,
|
||||
onPointerEnter: handlePointerEnter,
|
||||
onPointerMove: handlePointerMove,
|
||||
onPointerLeave: handlePointerLeave,
|
||||
}
|
||||
}
|
|
@ -128,12 +128,6 @@ export default function useKeyboardEvents() {
|
|||
}
|
||||
break
|
||||
}
|
||||
case 'a': {
|
||||
if (metaKey(e)) {
|
||||
state.send('SELECTED_ALL', getKeyboardEventInfo(e))
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'v': {
|
||||
if (metaKey(e)) {
|
||||
state.send('PASTED', getKeyboardEventInfo(e))
|
||||
|
@ -142,6 +136,14 @@ export default function useKeyboardEvents() {
|
|||
}
|
||||
break
|
||||
}
|
||||
case 'a': {
|
||||
if (metaKey(e)) {
|
||||
state.send('SELECTED_ALL', getKeyboardEventInfo(e))
|
||||
} else {
|
||||
state.send('SELECTED_ARROW_TOOL', getKeyboardEventInfo(e))
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'd': {
|
||||
if (metaKey(e)) {
|
||||
state.send('DUPLICATED', getKeyboardEventInfo(e))
|
||||
|
|
305
lib/shape-utils/arrow.tsx
Normal file
305
lib/shape-utils/arrow.tsx
Normal file
|
@ -0,0 +1,305 @@
|
|||
import { v4 as uuid } from 'uuid'
|
||||
import * as vec from 'utils/vec'
|
||||
import * as svg from 'utils/svg'
|
||||
import { ArrowShape, ShapeHandle, ShapeType } from 'types'
|
||||
import { registerShapeUtils } from './index'
|
||||
import { circleFromThreePoints, clamp, getSweep } from 'utils/utils'
|
||||
import { boundsContained } from 'utils/bounds'
|
||||
import { intersectCircleBounds } from 'utils/intersections'
|
||||
import { getBoundsFromPoints, translateBounds } from 'utils/utils'
|
||||
import { pointInCircle } from 'utils/hitTests'
|
||||
|
||||
const ctpCache = new WeakMap<ArrowShape['handles'], number[]>()
|
||||
|
||||
const arrow = registerShapeUtils<ArrowShape>({
|
||||
boundsCache: new WeakMap([]),
|
||||
|
||||
create(props) {
|
||||
const {
|
||||
point = [0, 0],
|
||||
points = [
|
||||
[0, 0],
|
||||
[0, 1],
|
||||
],
|
||||
handles = {
|
||||
start: {
|
||||
id: 'start',
|
||||
index: 0,
|
||||
point: [0, 0],
|
||||
},
|
||||
end: {
|
||||
id: 'end',
|
||||
index: 1,
|
||||
point: [1, 1],
|
||||
},
|
||||
bend: {
|
||||
id: 'bend',
|
||||
index: 2,
|
||||
point: [0.5, 0.5],
|
||||
},
|
||||
},
|
||||
} = props
|
||||
|
||||
return {
|
||||
id: uuid(),
|
||||
type: ShapeType.Arrow,
|
||||
isGenerated: false,
|
||||
name: 'Arrow',
|
||||
parentId: 'page0',
|
||||
childIndex: 0,
|
||||
point,
|
||||
rotation: 0,
|
||||
isAspectRatioLocked: false,
|
||||
isLocked: false,
|
||||
isHidden: false,
|
||||
bend: 0,
|
||||
points,
|
||||
handles,
|
||||
decorations: {
|
||||
start: null,
|
||||
end: null,
|
||||
middle: null,
|
||||
},
|
||||
...props,
|
||||
style: {
|
||||
strokeWidth: 2,
|
||||
...props.style,
|
||||
fill: 'none',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
render({ id, bend, points, handles, style }) {
|
||||
const { start, end, bend: _bend } = handles
|
||||
|
||||
const arrowDist = vec.dist(start.point, end.point)
|
||||
const bendDist = arrowDist * bend
|
||||
|
||||
const showCircle = Math.abs(bendDist) > 20
|
||||
|
||||
const v = vec.rot(
|
||||
vec.mul(
|
||||
vec.neg(vec.uni(vec.sub(points[1], points[0]))),
|
||||
Math.min(arrowDist / 2, 16 + +style.strokeWidth * 2)
|
||||
),
|
||||
showCircle ? (bend * Math.PI) / 2 : 0
|
||||
)
|
||||
const b = vec.add(points[1], vec.rot(v, Math.PI / 6))
|
||||
const c = vec.add(points[1], vec.rot(v, -(Math.PI / 6)))
|
||||
|
||||
if (showCircle && !ctpCache.has(handles)) {
|
||||
ctpCache.set(
|
||||
handles,
|
||||
circleFromThreePoints(start.point, end.point, _bend.point)
|
||||
)
|
||||
}
|
||||
|
||||
const circle = showCircle && ctpCache.get(handles)
|
||||
|
||||
return (
|
||||
<g id={id}>
|
||||
{circle ? (
|
||||
<path
|
||||
d={[
|
||||
'M',
|
||||
start.point[0],
|
||||
start.point[1],
|
||||
'A',
|
||||
circle[2],
|
||||
circle[2],
|
||||
0,
|
||||
0,
|
||||
bend < 0 ? 0 : 1,
|
||||
end.point[0],
|
||||
end.point[1],
|
||||
].join(' ')}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
) : (
|
||||
<polyline
|
||||
points={[start.point, end.point].join(' ')}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
<circle
|
||||
cx={start.point[0]}
|
||||
cy={start.point[1]}
|
||||
r={+style.strokeWidth}
|
||||
fill={style.stroke}
|
||||
/>
|
||||
<polyline
|
||||
points={[b, points[1], c].join()}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
},
|
||||
|
||||
applyStyles(shape, style) {
|
||||
Object.assign(shape.style, style)
|
||||
return this
|
||||
},
|
||||
|
||||
getBounds(shape) {
|
||||
if (!this.boundsCache.has(shape)) {
|
||||
this.boundsCache.set(
|
||||
shape,
|
||||
getBoundsFromPoints([
|
||||
...shape.points,
|
||||
shape.handles['bend'].point,
|
||||
// vec.sub(shape.handles['bend'].point, shape.point),
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
return translateBounds(this.boundsCache.get(shape), shape.point)
|
||||
},
|
||||
|
||||
getRotatedBounds(shape) {
|
||||
return this.getBounds(shape)
|
||||
},
|
||||
|
||||
getCenter(shape) {
|
||||
const bounds = this.getBounds(shape)
|
||||
return [bounds.minX + bounds.width / 2, bounds.minY + bounds.height / 2]
|
||||
},
|
||||
|
||||
hitTest(shape, point) {
|
||||
const { start, end, bend } = shape.handles
|
||||
if (shape.bend === 0) {
|
||||
return (
|
||||
vec.distanceToLineSegment(
|
||||
start.point,
|
||||
end.point,
|
||||
vec.sub(point, shape.point)
|
||||
) < 4
|
||||
)
|
||||
}
|
||||
|
||||
if (!ctpCache.has(shape.handles)) {
|
||||
ctpCache.set(
|
||||
shape.handles,
|
||||
circleFromThreePoints(start.point, end.point, bend.point)
|
||||
)
|
||||
}
|
||||
|
||||
const [cx, cy, r] = ctpCache.get(shape.handles)
|
||||
|
||||
return !pointInCircle(point, vec.add(shape.point, [cx, cy]), r - 4)
|
||||
},
|
||||
|
||||
hitTestBounds(this, shape, brushBounds) {
|
||||
const shapeBounds = this.getBounds(shape)
|
||||
return (
|
||||
boundsContained(shapeBounds, brushBounds) ||
|
||||
intersectCircleBounds(shape.point, 4, brushBounds).length > 0
|
||||
)
|
||||
},
|
||||
|
||||
rotateTo(shape, rotation) {
|
||||
// const rot = rotation - shape.rotation
|
||||
// const center = this.getCenter(shape)
|
||||
// shape.points = shape.points.map((pt) => vec.rotWith(pt, shape.point, rot))
|
||||
shape.rotation = rotation
|
||||
return this
|
||||
},
|
||||
|
||||
translateTo(shape, point) {
|
||||
shape.point = vec.toPrecision(point)
|
||||
return this
|
||||
},
|
||||
|
||||
transform(shape, bounds, { initialShape, scaleX, scaleY }) {
|
||||
const initialShapeBounds = this.getBounds(initialShape)
|
||||
|
||||
shape.point = [bounds.minX, bounds.minY]
|
||||
|
||||
shape.points = shape.points.map((_, i) => {
|
||||
const [x, y] = initialShape.points[i]
|
||||
let nw = x / initialShapeBounds.width
|
||||
let nh = y / initialShapeBounds.height
|
||||
|
||||
if (i === 1) {
|
||||
let [x0, y0] = initialShape.points[0]
|
||||
if (x0 === x) nw = 1
|
||||
if (y0 === y) nh = 1
|
||||
}
|
||||
|
||||
return [
|
||||
bounds.width * (scaleX < 0 ? 1 - nw : nw),
|
||||
bounds.height * (scaleY < 0 ? 1 - nh : nh),
|
||||
]
|
||||
})
|
||||
|
||||
return this
|
||||
},
|
||||
|
||||
transformSingle(shape, bounds, info) {
|
||||
this.transform(shape, bounds, info)
|
||||
return this
|
||||
},
|
||||
|
||||
setProperty(shape, prop, value) {
|
||||
shape[prop] = value
|
||||
return this
|
||||
},
|
||||
|
||||
onHandleMove(shape, handles) {
|
||||
for (let id in handles) {
|
||||
const handle = handles[id]
|
||||
|
||||
shape.handles[handle.id] = handle
|
||||
|
||||
if (handle.index < 2) {
|
||||
shape.points[handle.index] = handle.point
|
||||
}
|
||||
|
||||
const { start, end, bend } = shape.handles
|
||||
|
||||
const midPoint = vec.med(start.point, end.point)
|
||||
const dist = vec.dist(start.point, end.point)
|
||||
|
||||
if (handle.id === 'bend') {
|
||||
const distance = vec.distanceToLineSegment(
|
||||
start.point,
|
||||
end.point,
|
||||
handle.point,
|
||||
true
|
||||
)
|
||||
shape.bend = clamp(distance / (dist / 2), -1, 1)
|
||||
if (!vec.clockwise(start.point, bend.point, end.point)) shape.bend *= -1
|
||||
}
|
||||
|
||||
const bendDist = (dist / 2) * shape.bend
|
||||
const u = vec.uni(vec.vec(start.point, end.point))
|
||||
|
||||
bend.point =
|
||||
Math.abs(bendDist) > 10
|
||||
? vec.add(midPoint, vec.mul(vec.per(u), bendDist))
|
||||
: midPoint
|
||||
}
|
||||
|
||||
return this
|
||||
},
|
||||
|
||||
canTransform: true,
|
||||
canChangeAspectRatio: true,
|
||||
})
|
||||
|
||||
export default arrow
|
||||
|
||||
function getArrowArcPath(
|
||||
cx: number,
|
||||
cy: number,
|
||||
r: number,
|
||||
start: number[],
|
||||
end: number[]
|
||||
) {
|
||||
return `
|
||||
A ${r},${r},0,
|
||||
${getSweep([cx, cy], start, end) > 0 ? '1' : '0'},
|
||||
0,${end[0]},${end[1]}`
|
||||
}
|
|
@ -1,14 +1,12 @@
|
|||
import {
|
||||
Bounds,
|
||||
BoundsSnapshot,
|
||||
Shape,
|
||||
Shapes,
|
||||
ShapeType,
|
||||
Corner,
|
||||
Edge,
|
||||
ShapeByType,
|
||||
ShapeStyles,
|
||||
PropsOfType,
|
||||
ShapeHandle,
|
||||
ShapeBinding,
|
||||
} from 'types'
|
||||
import circle from './circle'
|
||||
import dot from './dot'
|
||||
|
@ -18,6 +16,7 @@ import ellipse from './ellipse'
|
|||
import line from './line'
|
||||
import ray from './ray'
|
||||
import draw from './draw'
|
||||
import arrow from './arrow'
|
||||
|
||||
/*
|
||||
Shape Utiliies
|
||||
|
@ -90,6 +89,20 @@ export interface ShapeUtility<K extends Readonly<Shape>> {
|
|||
value: K[P]
|
||||
): ShapeUtility<K>
|
||||
|
||||
// Respond when a user moves one of the shape's bound elements.
|
||||
onBindingMove?(
|
||||
this: ShapeUtility<K>,
|
||||
shape: K,
|
||||
bindings: Record<string, ShapeBinding>
|
||||
): ShapeUtility<K>
|
||||
|
||||
// Respond when a user moves one of the shape's handles.
|
||||
onHandleMove?(
|
||||
this: ShapeUtility<K>,
|
||||
shape: K,
|
||||
handle: Partial<K['handles']>
|
||||
): ShapeUtility<K>
|
||||
|
||||
// Render a shape to JSX.
|
||||
render(this: ShapeUtility<K>, shape: K): JSX.Element
|
||||
|
||||
|
@ -119,6 +132,7 @@ const shapeUtilityMap: Record<ShapeType, ShapeUtility<Shape>> = {
|
|||
[ShapeType.Line]: line,
|
||||
[ShapeType.Ray]: ray,
|
||||
[ShapeType.Draw]: draw,
|
||||
[ShapeType.Arrow]: arrow,
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -43,8 +43,7 @@ const polyline = registerShapeUtils<PolylineShape>({
|
|||
|
||||
getBounds(shape) {
|
||||
if (!this.boundsCache.has(shape)) {
|
||||
const bounds = getBoundsFromPoints(shape.points)
|
||||
this.boundsCache.set(shape, bounds)
|
||||
this.boundsCache.set(shape, getBoundsFromPoints(shape.points))
|
||||
}
|
||||
|
||||
return translateBounds(this.boundsCache.get(shape), shape.point)
|
||||
|
@ -106,6 +105,7 @@ const polyline = registerShapeUtils<PolylineShape>({
|
|||
|
||||
transform(shape, bounds, { initialShape, scaleX, scaleY }) {
|
||||
const initialShapeBounds = this.getBounds(initialShape)
|
||||
|
||||
shape.points = shape.points.map((_, i) => {
|
||||
const [x, y] = initialShape.points[i]
|
||||
|
||||
|
|
43
state/commands/arrow.ts
Normal file
43
state/commands/arrow.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
import Command from './command'
|
||||
import history from '../history'
|
||||
import { ArrowShape, Data } from 'types'
|
||||
import { getPage } from 'utils/utils'
|
||||
import { ArrowSnapshot } from 'state/sessions/arrow-session'
|
||||
import { getShapeUtils } from 'lib/shape-utils'
|
||||
|
||||
export default function arrowCommand(
|
||||
data: Data,
|
||||
before: ArrowSnapshot,
|
||||
after: ArrowSnapshot
|
||||
) {
|
||||
history.execute(
|
||||
data,
|
||||
new Command({
|
||||
name: 'point_arrow',
|
||||
category: 'canvas',
|
||||
manualSelection: true,
|
||||
do(data, isInitial) {
|
||||
if (isInitial) return
|
||||
|
||||
const { initialShape, currentPageId } = after
|
||||
|
||||
getPage(data, currentPageId).shapes[initialShape.id] = initialShape
|
||||
|
||||
data.selectedIds.clear()
|
||||
data.selectedIds.add(initialShape.id)
|
||||
data.hoveredId = undefined
|
||||
data.pointedId = undefined
|
||||
},
|
||||
undo(data) {
|
||||
const { initialShape, currentPageId } = before
|
||||
const shapes = getPage(data, currentPageId).shapes
|
||||
|
||||
delete shapes[initialShape.id]
|
||||
|
||||
data.selectedIds.clear()
|
||||
data.hoveredId = undefined
|
||||
data.pointedId = undefined
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
36
state/commands/handle.ts
Normal file
36
state/commands/handle.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import Command from './command'
|
||||
import history from '../history'
|
||||
import { Data } from 'types'
|
||||
import { getPage } from 'utils/utils'
|
||||
import { HandleSnapshot } from 'state/sessions/handle-session'
|
||||
import { getShapeUtils } from 'lib/shape-utils'
|
||||
|
||||
export default function handleCommand(
|
||||
data: Data,
|
||||
before: HandleSnapshot,
|
||||
after: HandleSnapshot
|
||||
) {
|
||||
history.execute(
|
||||
data,
|
||||
new Command({
|
||||
name: 'moved_handle',
|
||||
category: 'canvas',
|
||||
do(data, isInitial) {
|
||||
if (isInitial) return
|
||||
|
||||
const { initialShape, currentPageId } = after
|
||||
|
||||
const shape = getPage(data, currentPageId).shapes[initialShape.id]
|
||||
|
||||
getShapeUtils(shape).onHandleMove(shape, initialShape.handles)
|
||||
},
|
||||
undo(data) {
|
||||
const { initialShape, currentPageId } = before
|
||||
|
||||
const shape = getPage(data, currentPageId).shapes[initialShape.id]
|
||||
|
||||
getShapeUtils(shape).onHandleMove(shape, initialShape.handles)
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
|
@ -1,39 +1,43 @@
|
|||
import align from './align'
|
||||
import arrow from './arrow'
|
||||
import deleteSelected from './delete-selected'
|
||||
import direct from './direct'
|
||||
import distribute from './distribute'
|
||||
import draw from './draw'
|
||||
import duplicate from './duplicate'
|
||||
import generate from './generate'
|
||||
import move from './move'
|
||||
import draw from './draw'
|
||||
import nudge from './nudge'
|
||||
import rotate from './rotate'
|
||||
import rotateCcw from './rotate-ccw'
|
||||
import stretch from './stretch'
|
||||
import style from './style'
|
||||
import toggle from './toggle'
|
||||
import transform from './transform'
|
||||
import transformSingle from './transform-single'
|
||||
import translate from './translate'
|
||||
import nudge from './nudge'
|
||||
import toggle from './toggle'
|
||||
import rotateCcw from './rotate-ccw'
|
||||
import handle from './handle'
|
||||
|
||||
const commands = {
|
||||
align,
|
||||
arrow,
|
||||
deleteSelected,
|
||||
direct,
|
||||
distribute,
|
||||
draw,
|
||||
duplicate,
|
||||
generate,
|
||||
move,
|
||||
draw,
|
||||
nudge,
|
||||
rotate,
|
||||
rotateCcw,
|
||||
stretch,
|
||||
style,
|
||||
toggle,
|
||||
transform,
|
||||
transformSingle,
|
||||
translate,
|
||||
nudge,
|
||||
toggle,
|
||||
rotateCcw,
|
||||
handle,
|
||||
}
|
||||
|
||||
export default commands
|
||||
|
|
|
@ -23,7 +23,7 @@ export default function transformSingleCommand(
|
|||
category: 'canvas',
|
||||
manualSelection: true,
|
||||
do(data) {
|
||||
const { id, type, initialShape, initialShapeBounds } = after
|
||||
const { id, type, initialShapeBounds } = after
|
||||
|
||||
const { shapes } = getPage(data, after.currentPageId)
|
||||
|
||||
|
@ -35,7 +35,7 @@ export default function transformSingleCommand(
|
|||
} else {
|
||||
getShapeUtils(shape).transformSingle(shape, initialShapeBounds, {
|
||||
type,
|
||||
initialShape,
|
||||
initialShape: before.initialShape,
|
||||
scaleX,
|
||||
scaleY,
|
||||
transformOrigin: [0.5, 0.5],
|
||||
|
|
|
@ -8,7 +8,9 @@ import { getPage } from 'utils/utils'
|
|||
export default function transformCommand(
|
||||
data: Data,
|
||||
before: TransformSnapshot,
|
||||
after: TransformSnapshot
|
||||
after: TransformSnapshot,
|
||||
scaleX: number,
|
||||
scaleY: number
|
||||
) {
|
||||
history.execute(
|
||||
data,
|
||||
|
@ -29,8 +31,8 @@ export default function transformCommand(
|
|||
type,
|
||||
initialShape,
|
||||
transformOrigin,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
scaleX,
|
||||
scaleY,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
@ -48,8 +50,8 @@ export default function transformCommand(
|
|||
type,
|
||||
initialShape,
|
||||
transformOrigin,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
scaleX,
|
||||
scaleY,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
|
207
state/data.ts
207
state/data.ts
|
@ -10,107 +10,124 @@ export const defaultDocument: Data['document'] = {
|
|||
name: 'Page 0',
|
||||
childIndex: 0,
|
||||
shapes: {
|
||||
shape3: shapeUtils[ShapeType.Dot].create({
|
||||
id: 'shape3',
|
||||
name: 'Shape 3',
|
||||
childIndex: 3,
|
||||
point: [400, 500],
|
||||
style: {
|
||||
stroke: shades.black,
|
||||
fill: shades.lightGray,
|
||||
strokeWidth: 1,
|
||||
},
|
||||
}),
|
||||
shape0: shapeUtils[ShapeType.Circle].create({
|
||||
id: 'shape0',
|
||||
name: 'Shape 0',
|
||||
childIndex: 1,
|
||||
point: [100, 600],
|
||||
radius: 50,
|
||||
style: {
|
||||
stroke: shades.black,
|
||||
fill: shades.lightGray,
|
||||
strokeWidth: 1,
|
||||
},
|
||||
}),
|
||||
shape5: shapeUtils[ShapeType.Ellipse].create({
|
||||
id: 'shape5',
|
||||
name: 'Shape 5',
|
||||
childIndex: 5,
|
||||
arrowShape0: shapeUtils[ShapeType.Arrow].create({
|
||||
id: 'arrowShape0',
|
||||
point: [200, 200],
|
||||
radiusX: 50,
|
||||
radiusY: 100,
|
||||
style: {
|
||||
stroke: shades.black,
|
||||
fill: shades.lightGray,
|
||||
strokeWidth: 1,
|
||||
},
|
||||
}),
|
||||
shape7: shapeUtils[ShapeType.Ellipse].create({
|
||||
id: 'shape7',
|
||||
name: 'Shape 7',
|
||||
childIndex: 7,
|
||||
point: [100, 100],
|
||||
radiusX: 50,
|
||||
radiusY: 30,
|
||||
style: {
|
||||
stroke: shades.black,
|
||||
fill: shades.lightGray,
|
||||
strokeWidth: 1,
|
||||
},
|
||||
}),
|
||||
shape6: shapeUtils[ShapeType.Line].create({
|
||||
id: 'shape6',
|
||||
name: 'Shape 6',
|
||||
childIndex: 1,
|
||||
point: [400, 400],
|
||||
direction: [0.2, 0.2],
|
||||
style: {
|
||||
stroke: shades.black,
|
||||
fill: shades.lightGray,
|
||||
strokeWidth: 1,
|
||||
},
|
||||
}),
|
||||
rayShape: shapeUtils[ShapeType.Ray].create({
|
||||
id: 'rayShape',
|
||||
name: 'Ray',
|
||||
childIndex: 3,
|
||||
point: [300, 100],
|
||||
direction: [0.5, 0.5],
|
||||
style: {
|
||||
stroke: shades.black,
|
||||
fill: shades.lightGray,
|
||||
strokeWidth: 1,
|
||||
},
|
||||
}),
|
||||
shape2: shapeUtils[ShapeType.Polyline].create({
|
||||
id: 'shape2',
|
||||
name: 'Shape 2',
|
||||
childIndex: 2,
|
||||
point: [200, 600],
|
||||
points: [
|
||||
[0, 0],
|
||||
[75, 200],
|
||||
[100, 50],
|
||||
[200, 200],
|
||||
],
|
||||
style: {
|
||||
stroke: shades.black,
|
||||
fill: shades.none,
|
||||
strokeWidth: 1,
|
||||
},
|
||||
}),
|
||||
shape1: shapeUtils[ShapeType.Rectangle].create({
|
||||
id: 'shape1',
|
||||
name: 'Shape 1',
|
||||
childIndex: 1,
|
||||
point: [400, 600],
|
||||
size: [200, 200],
|
||||
style: {
|
||||
stroke: shades.black,
|
||||
fill: shades.lightGray,
|
||||
strokeWidth: 1,
|
||||
},
|
||||
arrowShape1: shapeUtils[ShapeType.Arrow].create({
|
||||
id: 'arrowShape1',
|
||||
point: [100, 100],
|
||||
points: [
|
||||
[0, 0],
|
||||
[300, 0],
|
||||
],
|
||||
}),
|
||||
|
||||
// shape3: shapeUtils[ShapeType.Dot].create({
|
||||
// id: 'shape3',
|
||||
// name: 'Shape 3',
|
||||
// childIndex: 3,
|
||||
// point: [400, 500],
|
||||
// style: {
|
||||
// stroke: shades.black,
|
||||
// fill: shades.lightGray,
|
||||
// strokeWidth: 1,
|
||||
// },
|
||||
// }),
|
||||
// shape0: shapeUtils[ShapeType.Circle].create({
|
||||
// id: 'shape0',
|
||||
// name: 'Shape 0',
|
||||
// childIndex: 1,
|
||||
// point: [100, 600],
|
||||
// radius: 50,
|
||||
// style: {
|
||||
// stroke: shades.black,
|
||||
// fill: shades.lightGray,
|
||||
// strokeWidth: 1,
|
||||
// },
|
||||
// }),
|
||||
// shape5: shapeUtils[ShapeType.Ellipse].create({
|
||||
// id: 'shape5',
|
||||
// name: 'Shape 5',
|
||||
// childIndex: 5,
|
||||
// point: [200, 200],
|
||||
// radiusX: 50,
|
||||
// radiusY: 100,
|
||||
// style: {
|
||||
// stroke: shades.black,
|
||||
// fill: shades.lightGray,
|
||||
// strokeWidth: 1,
|
||||
// },
|
||||
// }),
|
||||
// shape7: shapeUtils[ShapeType.Ellipse].create({
|
||||
// id: 'shape7',
|
||||
// name: 'Shape 7',
|
||||
// childIndex: 7,
|
||||
// point: [100, 100],
|
||||
// radiusX: 50,
|
||||
// radiusY: 30,
|
||||
// style: {
|
||||
// stroke: shades.black,
|
||||
// fill: shades.lightGray,
|
||||
// strokeWidth: 1,
|
||||
// },
|
||||
// }),
|
||||
// shape6: shapeUtils[ShapeType.Line].create({
|
||||
// id: 'shape6',
|
||||
// name: 'Shape 6',
|
||||
// childIndex: 1,
|
||||
// point: [400, 400],
|
||||
// direction: [0.2, 0.2],
|
||||
// style: {
|
||||
// stroke: shades.black,
|
||||
// fill: shades.lightGray,
|
||||
// strokeWidth: 1,
|
||||
// },
|
||||
// }),
|
||||
// rayShape: shapeUtils[ShapeType.Ray].create({
|
||||
// id: 'rayShape',
|
||||
// name: 'Ray',
|
||||
// childIndex: 3,
|
||||
// point: [300, 100],
|
||||
// direction: [0.5, 0.5],
|
||||
// style: {
|
||||
// stroke: shades.black,
|
||||
// fill: shades.lightGray,
|
||||
// strokeWidth: 1,
|
||||
// },
|
||||
// }),
|
||||
// shape2: shapeUtils[ShapeType.Polyline].create({
|
||||
// id: 'shape2',
|
||||
// name: 'Shape 2',
|
||||
// childIndex: 2,
|
||||
// point: [200, 600],
|
||||
// points: [
|
||||
// [0, 0],
|
||||
// [75, 200],
|
||||
// [100, 50],
|
||||
// ],
|
||||
// style: {
|
||||
// stroke: shades.black,
|
||||
// fill: shades.none,
|
||||
// strokeWidth: 1,
|
||||
// },
|
||||
// }),
|
||||
// shape1: shapeUtils[ShapeType.Rectangle].create({
|
||||
// id: 'shape1',
|
||||
// name: 'Shape 1',
|
||||
// childIndex: 1,
|
||||
// point: [400, 600],
|
||||
// size: [200, 200],
|
||||
// style: {
|
||||
// stroke: shades.black,
|
||||
// fill: shades.lightGray,
|
||||
// strokeWidth: 1,
|
||||
// },
|
||||
// }),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
92
state/sessions/arrow-session.ts
Normal file
92
state/sessions/arrow-session.ts
Normal file
|
@ -0,0 +1,92 @@
|
|||
import { ArrowShape, Data, LineShape, RayShape } from 'types'
|
||||
import * as vec from 'utils/vec'
|
||||
import BaseSession from './base-session'
|
||||
import commands from 'state/commands'
|
||||
import { current } from 'immer'
|
||||
import { getPage } from 'utils/utils'
|
||||
import { getShapeUtils } from 'lib/shape-utils'
|
||||
|
||||
export default class PointsSession extends BaseSession {
|
||||
points: number[][]
|
||||
origin: number[]
|
||||
snapshot: ArrowSnapshot
|
||||
isLocked: boolean
|
||||
lockedDirection: 'horizontal' | 'vertical'
|
||||
|
||||
constructor(data: Data, id: string, point: number[], isLocked: boolean) {
|
||||
super(data)
|
||||
this.origin = point
|
||||
this.points = [[0, 0]]
|
||||
this.snapshot = getArrowSnapshot(data, id)
|
||||
}
|
||||
|
||||
update(data: Data, point: number[], isLocked = false) {
|
||||
const { id } = this.snapshot
|
||||
|
||||
const delta = vec.vec(this.origin, point)
|
||||
|
||||
if (isLocked) {
|
||||
if (!this.isLocked && this.points.length > 1) {
|
||||
this.isLocked = true
|
||||
|
||||
if (Math.abs(delta[0]) < Math.abs(delta[1])) {
|
||||
this.lockedDirection = 'vertical'
|
||||
} else {
|
||||
this.lockedDirection = 'horizontal'
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (this.isLocked) {
|
||||
this.isLocked = false
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isLocked) {
|
||||
if (this.lockedDirection === 'vertical') {
|
||||
point[0] = this.origin[0]
|
||||
} else {
|
||||
point[1] = this.origin[1]
|
||||
}
|
||||
}
|
||||
|
||||
const shape = getPage(data).shapes[id] as ArrowShape
|
||||
|
||||
getShapeUtils(shape).onHandleMove(shape, {
|
||||
end: {
|
||||
...shape.handles.end,
|
||||
point: vec.sub(point, shape.point),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
cancel(data: Data) {
|
||||
const { id, initialShape } = this.snapshot
|
||||
|
||||
const shape = getPage(data).shapes[id] as ArrowShape
|
||||
|
||||
getShapeUtils(shape)
|
||||
.onHandleMove(shape, { end: initialShape.handles.end })
|
||||
.translateTo(shape, initialShape.point)
|
||||
}
|
||||
|
||||
complete(data: Data) {
|
||||
commands.arrow(
|
||||
data,
|
||||
this.snapshot,
|
||||
getArrowSnapshot(data, this.snapshot.id)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function getArrowSnapshot(data: Data, id: string) {
|
||||
const initialShape = getPage(current(data)).shapes[id] as ArrowShape
|
||||
|
||||
return {
|
||||
id,
|
||||
initialShape,
|
||||
selectedIds: new Set(data.selectedIds),
|
||||
currentPageId: data.currentPageId,
|
||||
}
|
||||
}
|
||||
|
||||
export type ArrowSnapshot = ReturnType<typeof getArrowSnapshot>
|
|
@ -23,11 +23,6 @@ export default class BrushSession extends BaseSession {
|
|||
this.points = []
|
||||
this.snapshot = getDrawSnapshot(data, id)
|
||||
|
||||
// if (isLocked && prevEndPoint) {
|
||||
// const continuedPt = vec.sub([...prevEndPoint], this.origin)
|
||||
// this.points.push(continuedPt)
|
||||
// }
|
||||
|
||||
const page = getPage(data)
|
||||
const shape = page.shapes[id]
|
||||
getShapeUtils(shape).translateTo(shape, point)
|
||||
|
|
76
state/sessions/handle-session.ts
Normal file
76
state/sessions/handle-session.ts
Normal file
|
@ -0,0 +1,76 @@
|
|||
import { Data } from 'types'
|
||||
import * as vec from 'utils/vec'
|
||||
import BaseSession from './base-session'
|
||||
import commands from 'state/commands'
|
||||
import { current } from 'immer'
|
||||
import { getPage } from 'utils/utils'
|
||||
import { getShapeUtils } from 'lib/shape-utils'
|
||||
|
||||
export default class HandleSession extends BaseSession {
|
||||
delta = [0, 0]
|
||||
origin: number[]
|
||||
snapshot: HandleSnapshot
|
||||
|
||||
constructor(data: Data, shapeId: string, handleId: string, point: number[]) {
|
||||
super(data)
|
||||
this.origin = point
|
||||
this.snapshot = getHandleSnapshot(data, shapeId, handleId)
|
||||
}
|
||||
|
||||
update(data: Data, point: number[], isAligned: boolean) {
|
||||
const { currentPageId, handleId, initialShape } = this.snapshot
|
||||
const shape = getPage(data, currentPageId).shapes[initialShape.id]
|
||||
|
||||
const delta = vec.vec(this.origin, point)
|
||||
|
||||
if (isAligned) {
|
||||
if (Math.abs(delta[0]) < Math.abs(delta[1])) {
|
||||
delta[0] = 0
|
||||
} else {
|
||||
delta[1] = 0
|
||||
}
|
||||
}
|
||||
|
||||
const handles = initialShape.handles
|
||||
|
||||
getShapeUtils(shape).onHandleMove(shape, {
|
||||
[handleId]: {
|
||||
...handles[handleId],
|
||||
point: vec.add(handles[handleId].point, delta),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
cancel(data: Data) {
|
||||
const { currentPageId, handleId, initialShape } = this.snapshot
|
||||
const shape = getPage(data, currentPageId).shapes[initialShape.id]
|
||||
}
|
||||
|
||||
complete(data: Data) {
|
||||
commands.handle(
|
||||
data,
|
||||
this.snapshot,
|
||||
getHandleSnapshot(
|
||||
data,
|
||||
this.snapshot.initialShape.id,
|
||||
this.snapshot.handleId
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function getHandleSnapshot(
|
||||
data: Data,
|
||||
shapeId: string,
|
||||
handleId: string
|
||||
) {
|
||||
const initialShape = getPage(current(data)).shapes[shapeId]
|
||||
|
||||
return {
|
||||
currentPageId: data.currentPageId,
|
||||
handleId,
|
||||
initialShape,
|
||||
}
|
||||
}
|
||||
|
||||
export type HandleSnapshot = ReturnType<typeof getHandleSnapshot>
|
|
@ -1,13 +1,16 @@
|
|||
import BaseSession from "./base-session"
|
||||
import BrushSession from "./brush-session"
|
||||
import DirectionSession from "./direction-session"
|
||||
import DrawSession from "./draw-session"
|
||||
import RotateSession from "./rotate-session"
|
||||
import TransformSession from "./transform-session"
|
||||
import TransformSingleSession from "./transform-single-session"
|
||||
import TranslateSession from "./translate-session"
|
||||
import ArrowSession from './arrow-session'
|
||||
import BaseSession from './base-session'
|
||||
import BrushSession from './brush-session'
|
||||
import DirectionSession from './direction-session'
|
||||
import DrawSession from './draw-session'
|
||||
import RotateSession from './rotate-session'
|
||||
import TransformSession from './transform-session'
|
||||
import TransformSingleSession from './transform-single-session'
|
||||
import TranslateSession from './translate-session'
|
||||
import HandleSession from './handle-session'
|
||||
|
||||
export {
|
||||
ArrowSession,
|
||||
BaseSession,
|
||||
BrushSession,
|
||||
DirectionSession,
|
||||
|
@ -16,4 +19,5 @@ export {
|
|||
TransformSession,
|
||||
TransformSingleSession,
|
||||
TranslateSession,
|
||||
HandleSession,
|
||||
}
|
||||
|
|
|
@ -100,7 +100,9 @@ export default class TransformSession extends BaseSession {
|
|||
commands.transform(
|
||||
data,
|
||||
this.snapshot,
|
||||
getTransformSnapshot(data, this.transformType)
|
||||
getTransformSnapshot(data, this.transformType),
|
||||
this.scaleX,
|
||||
this.scaleY
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
120
state/state.ts
120
state/state.ts
|
@ -118,6 +118,7 @@ const state = createState({
|
|||
},
|
||||
SELECTED_SELECT_TOOL: { to: 'selecting' },
|
||||
SELECTED_DRAW_TOOL: { unless: 'isReadOnly', to: 'draw' },
|
||||
SELECTED_ARROW_TOOL: { unless: 'isReadOnly', to: 'arrow' },
|
||||
SELECTED_DOT_TOOL: { unless: 'isReadOnly', to: 'dot' },
|
||||
SELECTED_CIRCLE_TOOL: { unless: 'isReadOnly', to: 'circle' },
|
||||
SELECTED_ELLIPSE_TOOL: { unless: 'isReadOnly', to: 'ellipse' },
|
||||
|
@ -168,6 +169,7 @@ const state = createState({
|
|||
to: 'rotatingSelection',
|
||||
else: { to: 'transformingSelection' },
|
||||
},
|
||||
POINTED_HANDLE: { to: 'translatingHandles' },
|
||||
MOVED_OVER_SHAPE: {
|
||||
if: 'pointHitsShape',
|
||||
then: {
|
||||
|
@ -216,7 +218,7 @@ const state = createState({
|
|||
MOVED_POINTER: {
|
||||
unless: 'isReadOnly',
|
||||
if: 'distanceImpliesDrag',
|
||||
to: 'draggingSelection',
|
||||
to: 'translatingSelection',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -243,7 +245,7 @@ const state = createState({
|
|||
CANCELLED: { do: 'cancelSession', to: 'selecting' },
|
||||
},
|
||||
},
|
||||
draggingSelection: {
|
||||
translatingSelection: {
|
||||
onEnter: 'startTranslateSession',
|
||||
on: {
|
||||
MOVED_POINTER: 'updateTranslateSession',
|
||||
|
@ -256,6 +258,17 @@ const state = createState({
|
|||
CANCELLED: { do: 'cancelSession', to: 'selecting' },
|
||||
},
|
||||
},
|
||||
translatingHandles: {
|
||||
onEnter: 'startHandleSession',
|
||||
on: {
|
||||
MOVED_POINTER: 'updateHandleSession',
|
||||
PANNED_CAMERA: 'updateHandleSession',
|
||||
PRESSED_SHIFT_KEY: 'keyUpdateHandleSession',
|
||||
RELEASED_SHIFT_KEY: 'keyUpdateHandleSession',
|
||||
STOPPED_POINTING: { do: 'completeSession', to: 'selecting' },
|
||||
CANCELLED: { do: 'cancelSession', to: 'selecting' },
|
||||
},
|
||||
},
|
||||
brushSelecting: {
|
||||
onEnter: [
|
||||
{
|
||||
|
@ -399,6 +412,51 @@ const state = createState({
|
|||
},
|
||||
},
|
||||
},
|
||||
arrow: {
|
||||
initial: 'creating',
|
||||
states: {
|
||||
creating: {
|
||||
on: {
|
||||
CANCELLED: { to: 'selecting' },
|
||||
POINTED_SHAPE: {
|
||||
get: 'newArrow',
|
||||
do: 'createShape',
|
||||
to: 'arrow.editing',
|
||||
},
|
||||
POINTED_CANVAS: {
|
||||
get: 'newArrow',
|
||||
do: 'createShape',
|
||||
to: 'arrow.editing',
|
||||
},
|
||||
UNDO: { do: 'undo' },
|
||||
REDO: { do: 'redo' },
|
||||
},
|
||||
},
|
||||
editing: {
|
||||
onEnter: 'startArrowSession',
|
||||
on: {
|
||||
STOPPED_POINTING: [
|
||||
'completeSession',
|
||||
{
|
||||
if: 'isToolLocked',
|
||||
to: 'arrow.creating',
|
||||
else: { to: 'selecting' },
|
||||
},
|
||||
],
|
||||
CANCELLED: {
|
||||
do: 'breakSession',
|
||||
if: 'isToolLocked',
|
||||
to: 'arrow.creating',
|
||||
else: { to: 'selecting' },
|
||||
},
|
||||
PRESSED_SHIFT: 'keyUpdateArrowSession',
|
||||
RELEASED_SHIFT: 'keyUpdateArrowSession',
|
||||
MOVED_POINTER: 'updateArrowSession',
|
||||
PANNED_CAMERA: 'updateArrowSession',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
circle: {
|
||||
initial: 'creating',
|
||||
states: {
|
||||
|
@ -586,6 +644,9 @@ const state = createState({
|
|||
},
|
||||
},
|
||||
results: {
|
||||
newArrow() {
|
||||
return ShapeType.Arrow
|
||||
},
|
||||
newDraw() {
|
||||
return ShapeType.Draw
|
||||
},
|
||||
|
@ -749,7 +810,39 @@ const state = createState({
|
|||
)
|
||||
},
|
||||
|
||||
// Dragging / Translating
|
||||
// Dragging Handle
|
||||
startHandleSession(data, payload: PointerInfo) {
|
||||
const shapeId = Array.from(data.selectedIds.values())[0]
|
||||
const handleId = payload.target
|
||||
|
||||
session = new Sessions.HandleSession(
|
||||
data,
|
||||
shapeId,
|
||||
handleId,
|
||||
screenToWorld(inputs.pointer.origin, data)
|
||||
)
|
||||
},
|
||||
keyUpdateHandleSession(
|
||||
data,
|
||||
payload: { shiftKey: boolean; altKey: boolean }
|
||||
) {
|
||||
session.update(
|
||||
data,
|
||||
screenToWorld(inputs.pointer.point, data),
|
||||
payload.shiftKey,
|
||||
payload.altKey
|
||||
)
|
||||
},
|
||||
updateHandleSession(data, payload: PointerInfo) {
|
||||
session.update(
|
||||
data,
|
||||
screenToWorld(payload.point, data),
|
||||
payload.shiftKey,
|
||||
payload.altKey
|
||||
)
|
||||
},
|
||||
|
||||
// Transforming
|
||||
startTransformSession(
|
||||
data,
|
||||
payload: PointerInfo & { target: Corner | Edge }
|
||||
|
@ -817,6 +910,27 @@ const state = createState({
|
|||
session.update(data, screenToWorld(payload.point, data), payload.shiftKey)
|
||||
},
|
||||
|
||||
// Arrow
|
||||
startArrowSession(data, payload: PointerInfo) {
|
||||
const id = Array.from(data.selectedIds.values())[0]
|
||||
session = new Sessions.ArrowSession(
|
||||
data,
|
||||
id,
|
||||
screenToWorld(inputs.pointer.origin, data),
|
||||
payload.shiftKey
|
||||
)
|
||||
},
|
||||
keyUpdateArrowSession(data, payload: PointerInfo) {
|
||||
session.update(
|
||||
data,
|
||||
screenToWorld(inputs.pointer.point, data),
|
||||
payload.shiftKey
|
||||
)
|
||||
},
|
||||
updateArrowSession(data, payload: PointerInfo) {
|
||||
session.update(data, screenToWorld(payload.point, data), payload.shiftKey)
|
||||
},
|
||||
|
||||
// Nudges
|
||||
nudgeSelection(data, payload: { delta: number[]; shiftKey: boolean }) {
|
||||
commands.nudge(
|
||||
|
|
33
types.ts
33
types.ts
|
@ -58,6 +58,7 @@ export enum ShapeType {
|
|||
Polyline = 'polyline',
|
||||
Rectangle = 'rectangle',
|
||||
Draw = 'draw',
|
||||
Arrow = 'arrow',
|
||||
}
|
||||
|
||||
// Consider:
|
||||
|
@ -77,6 +78,8 @@ export interface BaseShape {
|
|||
name: string
|
||||
point: number[]
|
||||
rotation: number
|
||||
bindings?: Record<string, ShapeBinding>
|
||||
handles?: Record<string, ShapeHandle>
|
||||
style: ShapeStyles
|
||||
isLocked: boolean
|
||||
isHidden: boolean
|
||||
|
@ -124,6 +127,18 @@ export interface DrawShape extends BaseShape {
|
|||
points: number[][]
|
||||
}
|
||||
|
||||
export interface ArrowShape extends BaseShape {
|
||||
type: ShapeType.Arrow
|
||||
points: number[][]
|
||||
handles: Record<string, ShapeHandle>
|
||||
bend: number
|
||||
decorations?: {
|
||||
start: Decoration
|
||||
end: Decoration
|
||||
middle: Decoration
|
||||
}
|
||||
}
|
||||
|
||||
export type MutableShape =
|
||||
| DotShape
|
||||
| CircleShape
|
||||
|
@ -133,6 +148,7 @@ export type MutableShape =
|
|||
| PolylineShape
|
||||
| DrawShape
|
||||
| RectangleShape
|
||||
| ArrowShape
|
||||
|
||||
export type Shape = Readonly<MutableShape>
|
||||
|
||||
|
@ -145,6 +161,7 @@ export interface Shapes {
|
|||
[ShapeType.Polyline]: Readonly<PolylineShape>
|
||||
[ShapeType.Draw]: Readonly<DrawShape>
|
||||
[ShapeType.Rectangle]: Readonly<RectangleShape>
|
||||
[ShapeType.Arrow]: Readonly<ArrowShape>
|
||||
}
|
||||
|
||||
export type ShapeByType<T extends ShapeType> = Shapes[T]
|
||||
|
@ -155,6 +172,22 @@ export interface CodeFile {
|
|||
code: string
|
||||
}
|
||||
|
||||
export enum Decoration {
|
||||
Arrow,
|
||||
}
|
||||
|
||||
export interface ShapeBinding {
|
||||
id: string
|
||||
index: number
|
||||
point: number[]
|
||||
}
|
||||
|
||||
export interface ShapeHandle {
|
||||
id: string
|
||||
index: number
|
||||
point: number[]
|
||||
}
|
||||
|
||||
/* -------------------------------------------------- */
|
||||
/* Editor UI */
|
||||
/* -------------------------------------------------- */
|
||||
|
|
16
utils/svg.ts
16
utils/svg.ts
|
@ -1,7 +1,7 @@
|
|||
// Some helpers for drawing SVGs.
|
||||
|
||||
import * as vec from "./vec"
|
||||
import { getSweep } from "utils/utils"
|
||||
import * as vec from './vec'
|
||||
import { getSweep } from 'utils/utils'
|
||||
|
||||
// General
|
||||
|
||||
|
@ -37,24 +37,24 @@ export function bezierTo(A: number[], B: number[], C: number[]) {
|
|||
|
||||
export function arcTo(C: number[], r: number, A: number[], B: number[]) {
|
||||
return [
|
||||
// moveTo(A),
|
||||
"A",
|
||||
moveTo(A),
|
||||
'A',
|
||||
r,
|
||||
r,
|
||||
0,
|
||||
getSweep(C, A, B) > 0 ? "1" : "0",
|
||||
getSweep(C, A, B) > 0 ? '1' : '0',
|
||||
0,
|
||||
B[0],
|
||||
B[1],
|
||||
].join(" ")
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
export function closePath() {
|
||||
return "Z"
|
||||
return 'Z'
|
||||
}
|
||||
|
||||
export function rectTo(A: number[]) {
|
||||
return ["R", A[0], A[1]].join(" ")
|
||||
return ['R', A[0], A[1]].join(' ')
|
||||
}
|
||||
|
||||
export function getPointAtLength(path: SVGPathElement, length: number) {
|
||||
|
|
|
@ -750,7 +750,7 @@ export function det(
|
|||
* @param p0
|
||||
* @param p1
|
||||
* @param center
|
||||
* @returns
|
||||
* @returns [x, y, r]
|
||||
*/
|
||||
export function circleFromThreePoints(A: number[], B: number[], C: number[]) {
|
||||
const a = det(A[0], A[1], 1, B[0], B[1], 1, C[0], C[1], 1)
|
||||
|
@ -788,11 +788,12 @@ export function circleFromThreePoints(A: number[], B: number[], C: number[]) {
|
|||
C[0],
|
||||
C[1]
|
||||
)
|
||||
return [
|
||||
-bx / (2 * a),
|
||||
-by / (2 * a),
|
||||
Math.sqrt(bx * bx + by * by - 4 * a * c) / (2 * Math.abs(a)),
|
||||
]
|
||||
|
||||
const x = -bx / (2 * a)
|
||||
const y = -by / (2 * a)
|
||||
const r = Math.sqrt(bx * bx + by * by - 4 * a * c) / (2 * Math.abs(a))
|
||||
|
||||
return [x, y, r]
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
@ -993,7 +994,6 @@ export function getBoundsFromPoints(points: number[][], rotation = 0): Bounds {
|
|||
}
|
||||
|
||||
if (rotation !== 0) {
|
||||
console.log('returning rotated bounds')
|
||||
return getBoundsFromPoints(
|
||||
points.map((pt) =>
|
||||
vec.rotWith(pt, [(minX + maxX) / 2, (minY + maxY) / 2], rotation)
|
||||
|
@ -1304,8 +1304,8 @@ export function getTransformedBoundingBox(
|
|||
maxY: by1,
|
||||
width: bx1 - bx0,
|
||||
height: by1 - by0,
|
||||
scaleX: ((bx1 - bx0) / (ax1 - ax0)) * (flipX ? -1 : 1),
|
||||
scaleY: ((by1 - by0) / (ay1 - ay0)) * (flipY ? -1 : 1),
|
||||
scaleX: ((bx1 - bx0) / (ax1 - ax0 || 1)) * (flipX ? -1 : 1),
|
||||
scaleY: ((by1 - by0) / (ay1 - ay0 || 1)) * (flipY ? -1 : 1),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue