Adds arrows, handles

This commit is contained in:
Steve Ruiz 2021-05-31 20:13:43 +01:00
parent facd9e9845
commit bcffee6458
27 changed files with 1120 additions and 292 deletions

View file

@ -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 (

View file

@ -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

View 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',
})

View file

@ -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>
)}

View file

@ -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)

View file

@ -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: {},
},
},
})

View file

@ -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
View 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,
}
}

View file

@ -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
View 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]}`
}

View file

@ -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,
}
/**

View file

@ -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
View 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
View 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)
},
})
)
}

View file

@ -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

View file

@ -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],

View file

@ -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,
})
}
},

View file

@ -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,
// },
// }),
},
},
},

View 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>

View file

@ -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)

View 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>

View file

@ -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,
}

View file

@ -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
)
}
}

View file

@ -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(

View file

@ -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 */
/* -------------------------------------------------- */

View file

@ -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) {

View file

@ -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),
}
}