Bläddra i källkod

Adds arrows, handles

main
Steve Ruiz 4 år sedan
förälder
incheckning
bcffee6458

+ 22
- 5
components/canvas/bounds/bounding-box.tsx Visa fil

@@ -1,12 +1,18 @@
1 1
 import * as React from 'react'
2
-import { Edge, Corner } from 'types'
2
+import { Edge, Corner, LineShape, ArrowShape } from 'types'
3 3
 import { useSelector } from 'state'
4
-import { getPage, getSelectedShapes, isMobile } from 'utils/utils'
4
+import {
5
+  deepCompareArrays,
6
+  getPage,
7
+  getSelectedShapes,
8
+  isMobile,
9
+} from 'utils/utils'
5 10
 
6 11
 import CenterHandle from './center-handle'
7 12
 import CornerHandle from './corner-handle'
8 13
 import EdgeHandle from './edge-handle'
9 14
 import RotateHandle from './rotate-handle'
15
+import Handles from './handles'
10 16
 
11 17
 export default function Bounds() {
12 18
   const isBrushing = useSelector((s) => s.isIn('brushSelecting'))
@@ -14,20 +20,31 @@ export default function Bounds() {
14 20
   const zoom = useSelector((s) => s.data.camera.zoom)
15 21
   const bounds = useSelector((s) => s.values.selectedBounds)
16 22
 
23
+  const selectedIds = useSelector(
24
+    (s) => Array.from(s.values.selectedIds.values()),
25
+    deepCompareArrays
26
+  )
27
+
17 28
   const rotation = useSelector(({ data }) =>
18 29
     data.selectedIds.size === 1 ? getSelectedShapes(data)[0].rotation : 0
19 30
   )
20 31
 
21 32
   const isAllLocked = useSelector((s) => {
22 33
     const page = getPage(s.data)
23
-    return Array.from(s.data.selectedIds.values()).every(
24
-      (id) => page.shapes[id].isLocked
25
-    )
34
+    return selectedIds.every((id) => page.shapes[id]?.isLocked)
35
+  })
36
+
37
+  const isAllHandles = useSelector((s) => {
38
+    const page = getPage(s.data)
39
+    return selectedIds.every((id) => page.shapes[id]?.handles !== undefined)
26 40
   })
27 41
 
28 42
   if (!bounds) return null
43
+
29 44
   if (!isSelecting) return null
30 45
 
46
+  if (isAllHandles) return null
47
+
31 48
   const size = (isMobile().any ? 10 : 8) / zoom // Touch target size
32 49
 
33 50
   return (

+ 19
- 3
components/canvas/bounds/bounds-bg.tsx Visa fil

@@ -2,7 +2,7 @@ import { useCallback, useRef } from 'react'
2 2
 import state, { useSelector } from 'state'
3 3
 import inputs from 'state/inputs'
4 4
 import styled from 'styles'
5
-import { getPage } from 'utils/utils'
5
+import { deepCompareArrays, getPage } from 'utils/utils'
6 6
 
7 7
 function handlePointerDown(e: React.PointerEvent<SVGRectElement>) {
8 8
   if (e.buttons !== 1) return
@@ -22,18 +22,34 @@ function handlePointerUp(e: React.PointerEvent<SVGRectElement>) {
22 22
 
23 23
 export default function BoundsBg() {
24 24
   const rBounds = useRef<SVGRectElement>(null)
25
+
25 26
   const bounds = useSelector((state) => state.values.selectedBounds)
27
+
26 28
   const isSelecting = useSelector((s) => s.isIn('selecting'))
29
+
30
+  const selectedIds = useSelector(
31
+    (s) => Array.from(s.values.selectedIds.values()),
32
+    deepCompareArrays
33
+  )
34
+
27 35
   const rotation = useSelector((s) => {
28
-    if (s.data.selectedIds.size === 1) {
36
+    if (selectedIds.length === 1) {
29 37
       const { shapes } = getPage(s.data)
30
-      const selected = Array.from(s.data.selectedIds.values())[0]
38
+      const selected = Array.from(s.values.selectedIds.values())[0]
31 39
       return shapes[selected].rotation
32 40
     } else {
33 41
       return 0
34 42
     }
35 43
   })
36 44
 
45
+  const isAllHandles = useSelector((s) => {
46
+    const page = getPage(s.data)
47
+    return Array.from(s.values.selectedIds.values()).every(
48
+      (id) => page.shapes[id]?.handles !== undefined
49
+    )
50
+  })
51
+
52
+  if (isAllHandles) return null
37 53
   if (!bounds) return null
38 54
   if (!isSelecting) return null
39 55
 

+ 79
- 0
components/canvas/bounds/handles.tsx Visa fil

@@ -0,0 +1,79 @@
1
+import useHandleEvents from 'hooks/useHandleEvents'
2
+import { getShapeUtils } from 'lib/shape-utils'
3
+import { useRef } from 'react'
4
+import { useSelector } from 'state'
5
+import styled from 'styles'
6
+import { deepCompareArrays, getPage } from 'utils/utils'
7
+import * as vec from 'utils/vec'
8
+
9
+export default function Handles() {
10
+  const selectedIds = useSelector(
11
+    (s) => Array.from(s.values.selectedIds.values()),
12
+    deepCompareArrays
13
+  )
14
+
15
+  const shape = useSelector(
16
+    ({ data }) =>
17
+      selectedIds.length === 1 && getPage(data).shapes[selectedIds[0]]
18
+  )
19
+
20
+  const isTranslatingHandles = useSelector((s) => s.isIn('translatingHandles'))
21
+
22
+  if (!shape.handles || isTranslatingHandles) return null
23
+
24
+  return (
25
+    <g>
26
+      {Object.values(shape.handles).map((handle) => (
27
+        <Handle
28
+          key={handle.id}
29
+          shapeId={shape.id}
30
+          id={handle.id}
31
+          point={vec.add(handle.point, shape.point)}
32
+        />
33
+      ))}
34
+    </g>
35
+  )
36
+}
37
+
38
+function Handle({
39
+  shapeId,
40
+  id,
41
+  point,
42
+}: {
43
+  shapeId: string
44
+  id: string
45
+  point: number[]
46
+}) {
47
+  const rGroup = useRef<SVGGElement>(null)
48
+  const events = useHandleEvents(id, rGroup)
49
+
50
+  const transform = `
51
+  translate(${point})
52
+  `
53
+
54
+  return (
55
+    <g
56
+      key={id}
57
+      ref={rGroup}
58
+      {...events}
59
+      pointerEvents="all"
60
+      transform={`translate(${point})`}
61
+    >
62
+      <HandleCircleOuter r={8} />
63
+      <HandleCircle r={4} />
64
+    </g>
65
+  )
66
+}
67
+
68
+const HandleCircleOuter = styled('circle', {
69
+  fill: 'transparent',
70
+  pointerEvents: 'all',
71
+  cursor: 'pointer',
72
+})
73
+
74
+const HandleCircle = styled('circle', {
75
+  zStrokeWidth: 2,
76
+  stroke: '$text',
77
+  fill: '$panel',
78
+  pointerEvents: 'none',
79
+})

+ 2
- 0
components/canvas/canvas.tsx Visa fil

@@ -10,6 +10,7 @@ import Brush from './brush'
10 10
 import Bounds from './bounds/bounding-box'
11 11
 import BoundsBg from './bounds/bounds-bg'
12 12
 import Selected from './selected'
13
+import Handles from './bounds/handles'
13 14
 
14 15
 export default function Canvas() {
15 16
   const rCanvas = useRef<SVGSVGElement>(null)
@@ -60,6 +61,7 @@ export default function Canvas() {
60 61
           <Page />
61 62
           <Selected />
62 63
           <Bounds />
64
+          <Handles />
63 65
           <Brush />
64 66
         </g>
65 67
       )}

+ 18
- 17
components/canvas/shape.tsx Visa fil

@@ -26,7 +26,8 @@ function Shape({ id, isSelecting }: { id: string; isSelecting: boolean }) {
26 26
   const center = getShapeUtils(shape).getCenter(shape)
27 27
   const transform = `
28 28
   rotate(${shape.rotation * (180 / Math.PI)}, ${center})
29
-  translate(${shape.point})`
29
+  translate(${shape.point})
30
+  `
30 31
 
31 32
   return (
32 33
     <StyledGroup
@@ -54,22 +55,6 @@ const StyledShape = memo(
54 55
   }
55 56
 )
56 57
 
57
-function Label({ text }: { text: string }) {
58
-  return (
59
-    <text
60
-      y={4}
61
-      x={4}
62
-      fontSize={18}
63
-      fill="black"
64
-      stroke="none"
65
-      alignmentBaseline="text-before-edge"
66
-      pointerEvents="none"
67
-    >
68
-      {text}
69
-    </text>
70
-  )
71
-}
72
-
73 58
 const HoverIndicator = styled('path', {
74 59
   fill: 'none',
75 60
   stroke: 'transparent',
@@ -133,6 +118,22 @@ const StyledGroup = styled('g', {
133 118
   ],
134 119
 })
135 120
 
121
+function Label({ text }: { text: string }) {
122
+  return (
123
+    <text
124
+      y={4}
125
+      x={4}
126
+      fontSize={18}
127
+      fill="black"
128
+      stroke="none"
129
+      alignmentBaseline="text-before-edge"
130
+      pointerEvents="none"
131
+    >
132
+      {text}
133
+    </text>
134
+  )
135
+}
136
+
136 137
 export { HoverIndicator }
137 138
 
138 139
 export default memo(Shape)

+ 0
- 96
components/toolbar.tsx Visa fil

@@ -1,96 +0,0 @@
1
-import state, { useSelector } from 'state'
2
-import styled from 'styles'
3
-import { Lock, Menu, RotateCcw, RotateCw, Unlock } from 'react-feather'
4
-import { IconButton } from './shared'
5
-
6
-export default function Toolbar() {
7
-  const activeTool = useSelector((state) =>
8
-    state.whenIn({
9
-      selecting: 'select',
10
-      dot: 'dot',
11
-      circle: 'circle',
12
-      ellipse: 'ellipse',
13
-      ray: 'ray',
14
-      line: 'line',
15
-      polyline: 'polyline',
16
-      rectangle: 'rectangle',
17
-      draw: 'draw',
18
-    })
19
-  )
20
-
21
-  const isToolLocked = useSelector((s) => s.data.settings.isToolLocked)
22
-
23
-  return (
24
-    <ToolbarContainer>
25
-      <Section>
26
-        <Button>
27
-          <Menu />
28
-        </Button>
29
-        <Button onClick={() => state.send('RESET_CAMERA')}>Reset Camera</Button>
30
-      </Section>
31
-      <Section>
32
-        <Button title="Undo" onClick={() => state.send('UNDO')}>
33
-          <RotateCcw />
34
-        </Button>
35
-        <Button title="Redo" onClick={() => state.send('REDO')}>
36
-          <RotateCw />
37
-        </Button>
38
-      </Section>
39
-    </ToolbarContainer>
40
-  )
41
-}
42
-
43
-const ToolbarContainer = styled('div', {
44
-  gridArea: 'toolbar',
45
-  userSelect: 'none',
46
-  borderBottom: '1px solid black',
47
-  display: 'flex',
48
-  alignItems: 'center',
49
-  justifyContent: 'space-between',
50
-  backgroundColor: '$panel',
51
-  gap: 8,
52
-  fontSize: '$1',
53
-  zIndex: 200,
54
-})
55
-
56
-const Section = styled('div', {
57
-  whiteSpace: 'nowrap',
58
-  overflowY: 'hidden',
59
-  overflowX: 'auto',
60
-  display: 'flex',
61
-  scrollbarWidth: 'none',
62
-  '&::-webkit-scrollbar': {
63
-    '-webkit-appearance': 'none',
64
-    width: 0,
65
-    height: 0,
66
-  },
67
-})
68
-
69
-const Button = styled('button', {
70
-  display: 'flex',
71
-  alignItems: 'center',
72
-  cursor: 'pointer',
73
-  font: '$ui',
74
-  fontSize: '$ui',
75
-  height: '40px',
76
-  outline: 'none',
77
-  borderRadius: 0,
78
-  border: 'none',
79
-  padding: '0 12px',
80
-  background: 'none',
81
-  '&:hover': {
82
-    backgroundColor: '$hint',
83
-  },
84
-  '& svg': {
85
-    height: 16,
86
-    width: 16,
87
-  },
88
-  variants: {
89
-    isSelected: {
90
-      true: {
91
-        color: '$selected',
92
-      },
93
-      false: {},
94
-    },
95
-  },
96
-})

+ 25
- 15
components/tools-panel/tools-panel.tsx Visa fil

@@ -1,9 +1,9 @@
1 1
 import {
2
+  ArrowTopRightIcon,
2 3
   CircleIcon,
3 4
   CursorArrowIcon,
4 5
   DividerHorizontalIcon,
5 6
   DotIcon,
6
-  LineHeightIcon,
7 7
   LockClosedIcon,
8 8
   LockOpen1Icon,
9 9
   Pencil1Icon,
@@ -19,29 +19,31 @@ import { ShapeType } from 'types'
19 19
 import UndoRedo from './undo-redo'
20 20
 import Zoom from './zoom'
21 21
 
22
-const selectSelectTool = () => state.send('SELECTED_SELECT_TOOL')
23
-const selectDrawTool = () => state.send('SELECTED_DRAW_TOOL')
24
-const selectDotTool = () => state.send('SELECTED_DOT_TOOL')
22
+const selectArrowTool = () => state.send('SELECTED_ARROW_TOOL')
25 23
 const selectCircleTool = () => state.send('SELECTED_CIRCLE_TOOL')
24
+const selectDotTool = () => state.send('SELECTED_DOT_TOOL')
25
+const selectDrawTool = () => state.send('SELECTED_DRAW_TOOL')
26 26
 const selectEllipseTool = () => state.send('SELECTED_ELLIPSE_TOOL')
27
-const selectRayTool = () => state.send('SELECTED_RAY_TOOL')
28 27
 const selectLineTool = () => state.send('SELECTED_LINE_TOOL')
29 28
 const selectPolylineTool = () => state.send('SELECTED_POLYLINE_TOOL')
29
+const selectRayTool = () => state.send('SELECTED_RAY_TOOL')
30 30
 const selectRectangleTool = () => state.send('SELECTED_RECTANGLE_TOOL')
31
+const selectSelectTool = () => state.send('SELECTED_SELECT_TOOL')
31 32
 const selectToolLock = () => state.send('TOGGLED_TOOL_LOCK')
32 33
 
33 34
 export default function ToolsPanel() {
34 35
   const activeTool = useSelector((state) =>
35 36
     state.whenIn({
36
-      selecting: 'select',
37
-      dot: ShapeType.Dot,
37
+      arrow: ShapeType.Arrow,
38 38
       circle: ShapeType.Circle,
39
+      dot: ShapeType.Dot,
40
+      draw: ShapeType.Draw,
39 41
       ellipse: ShapeType.Ellipse,
40
-      ray: ShapeType.Ray,
41 42
       line: ShapeType.Line,
42 43
       polyline: ShapeType.Polyline,
44
+      ray: ShapeType.Ray,
43 45
       rectangle: ShapeType.Rectangle,
44
-      draw: ShapeType.Draw,
46
+      selecting: 'select',
45 47
     })
46 48
   )
47 49
 
@@ -83,19 +85,27 @@ export default function ToolsPanel() {
83 85
           <IconButton
84 86
             name={ShapeType.Circle}
85 87
             size={{ '@sm': 'small', '@md': 'large' }}
86
-            onClick={selectCircleTool}
87
-            isActive={activeTool === ShapeType.Circle}
88
+            onClick={selectEllipseTool}
89
+            isActive={activeTool === ShapeType.Ellipse}
88 90
           >
89 91
             <CircleIcon />
90 92
           </IconButton>
91 93
           <IconButton
92
-            name={ShapeType.Ellipse}
94
+            name={ShapeType.Arrow}
93 95
             size={{ '@sm': 'small', '@md': 'large' }}
94
-            onClick={selectEllipseTool}
95
-            isActive={activeTool === ShapeType.Ellipse}
96
+            onClick={selectArrowTool}
97
+            isActive={activeTool === ShapeType.Arrow}
96 98
           >
97
-            <CircleIcon transform="rotate(-45) scale(1, .8)" />
99
+            <ArrowTopRightIcon />
98 100
           </IconButton>
101
+          {/* <IconButton
102
+            name={ShapeType.Circle}
103
+            size={{ '@sm': 'small', '@md': 'large' }}
104
+            onClick={selectCircleTool}
105
+            isActive={activeTool === ShapeType.Circle}
106
+          >
107
+            <CircleIcon />
108
+          </IconButton> */}
99 109
           <IconButton
100 110
             name={ShapeType.Line}
101 111
             size={{ '@sm': 'small', '@md': 'large' }}

+ 60
- 0
hooks/useHandleEvents.ts Visa fil

@@ -0,0 +1,60 @@
1
+import { MutableRefObject, useCallback } from 'react'
2
+import state from 'state'
3
+import inputs from 'state/inputs'
4
+
5
+export default function useHandleEvents(
6
+  id: string,
7
+  rGroup: MutableRefObject<SVGElement>
8
+) {
9
+  const handlePointerDown = useCallback(
10
+    (e: React.PointerEvent) => {
11
+      if (!inputs.canAccept(e.pointerId)) return
12
+      e.stopPropagation()
13
+      rGroup.current.setPointerCapture(e.pointerId)
14
+      state.send('POINTED_HANDLE', inputs.pointerDown(e, id))
15
+    },
16
+    [id]
17
+  )
18
+
19
+  const handlePointerUp = useCallback(
20
+    (e: React.PointerEvent) => {
21
+      if (!inputs.canAccept(e.pointerId)) return
22
+      e.stopPropagation()
23
+      rGroup.current.releasePointerCapture(e.pointerId)
24
+      state.send('STOPPED_POINTING', inputs.pointerUp(e))
25
+    },
26
+    [id]
27
+  )
28
+
29
+  const handlePointerEnter = useCallback(
30
+    (e: React.PointerEvent) => {
31
+      if (!inputs.canAccept(e.pointerId)) return
32
+      state.send('HOVERED_HANDLE', inputs.pointerEnter(e, id))
33
+    },
34
+    [id]
35
+  )
36
+
37
+  const handlePointerMove = useCallback(
38
+    (e: React.PointerEvent) => {
39
+      if (!inputs.canAccept(e.pointerId)) return
40
+      state.send('MOVED_OVER_HANDLE', inputs.pointerEnter(e, id))
41
+    },
42
+    [id]
43
+  )
44
+
45
+  const handlePointerLeave = useCallback(
46
+    (e: React.PointerEvent) => {
47
+      if (!inputs.canAccept(e.pointerId)) return
48
+      state.send('UNHOVERED_HANDLE', { target: id })
49
+    },
50
+    [id]
51
+  )
52
+
53
+  return {
54
+    onPointerDown: handlePointerDown,
55
+    onPointerUp: handlePointerUp,
56
+    onPointerEnter: handlePointerEnter,
57
+    onPointerMove: handlePointerMove,
58
+    onPointerLeave: handlePointerLeave,
59
+  }
60
+}

+ 7
- 5
hooks/useKeyboardEvents.ts Visa fil

@@ -128,17 +128,19 @@ export default function useKeyboardEvents() {
128 128
           }
129 129
           break
130 130
         }
131
-        case 'a': {
131
+        case 'v': {
132 132
           if (metaKey(e)) {
133
-            state.send('SELECTED_ALL', getKeyboardEventInfo(e))
133
+            state.send('PASTED', getKeyboardEventInfo(e))
134
+          } else {
135
+            state.send('SELECTED_SELECT_TOOL', getKeyboardEventInfo(e))
134 136
           }
135 137
           break
136 138
         }
137
-        case 'v': {
139
+        case 'a': {
138 140
           if (metaKey(e)) {
139
-            state.send('PASTED', getKeyboardEventInfo(e))
141
+            state.send('SELECTED_ALL', getKeyboardEventInfo(e))
140 142
           } else {
141
-            state.send('SELECTED_SELECT_TOOL', getKeyboardEventInfo(e))
143
+            state.send('SELECTED_ARROW_TOOL', getKeyboardEventInfo(e))
142 144
           }
143 145
           break
144 146
         }

+ 305
- 0
lib/shape-utils/arrow.tsx Visa fil

@@ -0,0 +1,305 @@
1
+import { v4 as uuid } from 'uuid'
2
+import * as vec from 'utils/vec'
3
+import * as svg from 'utils/svg'
4
+import { ArrowShape, ShapeHandle, ShapeType } from 'types'
5
+import { registerShapeUtils } from './index'
6
+import { circleFromThreePoints, clamp, getSweep } from 'utils/utils'
7
+import { boundsContained } from 'utils/bounds'
8
+import { intersectCircleBounds } from 'utils/intersections'
9
+import { getBoundsFromPoints, translateBounds } from 'utils/utils'
10
+import { pointInCircle } from 'utils/hitTests'
11
+
12
+const ctpCache = new WeakMap<ArrowShape['handles'], number[]>()
13
+
14
+const arrow = registerShapeUtils<ArrowShape>({
15
+  boundsCache: new WeakMap([]),
16
+
17
+  create(props) {
18
+    const {
19
+      point = [0, 0],
20
+      points = [
21
+        [0, 0],
22
+        [0, 1],
23
+      ],
24
+      handles = {
25
+        start: {
26
+          id: 'start',
27
+          index: 0,
28
+          point: [0, 0],
29
+        },
30
+        end: {
31
+          id: 'end',
32
+          index: 1,
33
+          point: [1, 1],
34
+        },
35
+        bend: {
36
+          id: 'bend',
37
+          index: 2,
38
+          point: [0.5, 0.5],
39
+        },
40
+      },
41
+    } = props
42
+
43
+    return {
44
+      id: uuid(),
45
+      type: ShapeType.Arrow,
46
+      isGenerated: false,
47
+      name: 'Arrow',
48
+      parentId: 'page0',
49
+      childIndex: 0,
50
+      point,
51
+      rotation: 0,
52
+      isAspectRatioLocked: false,
53
+      isLocked: false,
54
+      isHidden: false,
55
+      bend: 0,
56
+      points,
57
+      handles,
58
+      decorations: {
59
+        start: null,
60
+        end: null,
61
+        middle: null,
62
+      },
63
+      ...props,
64
+      style: {
65
+        strokeWidth: 2,
66
+        ...props.style,
67
+        fill: 'none',
68
+      },
69
+    }
70
+  },
71
+
72
+  render({ id, bend, points, handles, style }) {
73
+    const { start, end, bend: _bend } = handles
74
+
75
+    const arrowDist = vec.dist(start.point, end.point)
76
+    const bendDist = arrowDist * bend
77
+
78
+    const showCircle = Math.abs(bendDist) > 20
79
+
80
+    const v = vec.rot(
81
+      vec.mul(
82
+        vec.neg(vec.uni(vec.sub(points[1], points[0]))),
83
+        Math.min(arrowDist / 2, 16 + +style.strokeWidth * 2)
84
+      ),
85
+      showCircle ? (bend * Math.PI) / 2 : 0
86
+    )
87
+    const b = vec.add(points[1], vec.rot(v, Math.PI / 6))
88
+    const c = vec.add(points[1], vec.rot(v, -(Math.PI / 6)))
89
+
90
+    if (showCircle && !ctpCache.has(handles)) {
91
+      ctpCache.set(
92
+        handles,
93
+        circleFromThreePoints(start.point, end.point, _bend.point)
94
+      )
95
+    }
96
+
97
+    const circle = showCircle && ctpCache.get(handles)
98
+
99
+    return (
100
+      <g id={id}>
101
+        {circle ? (
102
+          <path
103
+            d={[
104
+              'M',
105
+              start.point[0],
106
+              start.point[1],
107
+              'A',
108
+              circle[2],
109
+              circle[2],
110
+              0,
111
+              0,
112
+              bend < 0 ? 0 : 1,
113
+              end.point[0],
114
+              end.point[1],
115
+            ].join(' ')}
116
+            fill="none"
117
+            strokeLinecap="round"
118
+          />
119
+        ) : (
120
+          <polyline
121
+            points={[start.point, end.point].join(' ')}
122
+            strokeLinecap="round"
123
+          />
124
+        )}
125
+        <circle
126
+          cx={start.point[0]}
127
+          cy={start.point[1]}
128
+          r={+style.strokeWidth}
129
+          fill={style.stroke}
130
+        />
131
+        <polyline
132
+          points={[b, points[1], c].join()}
133
+          strokeLinecap="round"
134
+          strokeLinejoin="round"
135
+          fill="none"
136
+        />
137
+      </g>
138
+    )
139
+  },
140
+
141
+  applyStyles(shape, style) {
142
+    Object.assign(shape.style, style)
143
+    return this
144
+  },
145
+
146
+  getBounds(shape) {
147
+    if (!this.boundsCache.has(shape)) {
148
+      this.boundsCache.set(
149
+        shape,
150
+        getBoundsFromPoints([
151
+          ...shape.points,
152
+          shape.handles['bend'].point,
153
+          // vec.sub(shape.handles['bend'].point, shape.point),
154
+        ])
155
+      )
156
+    }
157
+
158
+    return translateBounds(this.boundsCache.get(shape), shape.point)
159
+  },
160
+
161
+  getRotatedBounds(shape) {
162
+    return this.getBounds(shape)
163
+  },
164
+
165
+  getCenter(shape) {
166
+    const bounds = this.getBounds(shape)
167
+    return [bounds.minX + bounds.width / 2, bounds.minY + bounds.height / 2]
168
+  },
169
+
170
+  hitTest(shape, point) {
171
+    const { start, end, bend } = shape.handles
172
+    if (shape.bend === 0) {
173
+      return (
174
+        vec.distanceToLineSegment(
175
+          start.point,
176
+          end.point,
177
+          vec.sub(point, shape.point)
178
+        ) < 4
179
+      )
180
+    }
181
+
182
+    if (!ctpCache.has(shape.handles)) {
183
+      ctpCache.set(
184
+        shape.handles,
185
+        circleFromThreePoints(start.point, end.point, bend.point)
186
+      )
187
+    }
188
+
189
+    const [cx, cy, r] = ctpCache.get(shape.handles)
190
+
191
+    return !pointInCircle(point, vec.add(shape.point, [cx, cy]), r - 4)
192
+  },
193
+
194
+  hitTestBounds(this, shape, brushBounds) {
195
+    const shapeBounds = this.getBounds(shape)
196
+    return (
197
+      boundsContained(shapeBounds, brushBounds) ||
198
+      intersectCircleBounds(shape.point, 4, brushBounds).length > 0
199
+    )
200
+  },
201
+
202
+  rotateTo(shape, rotation) {
203
+    // const rot = rotation - shape.rotation
204
+    // const center = this.getCenter(shape)
205
+    // shape.points = shape.points.map((pt) => vec.rotWith(pt, shape.point, rot))
206
+    shape.rotation = rotation
207
+    return this
208
+  },
209
+
210
+  translateTo(shape, point) {
211
+    shape.point = vec.toPrecision(point)
212
+    return this
213
+  },
214
+
215
+  transform(shape, bounds, { initialShape, scaleX, scaleY }) {
216
+    const initialShapeBounds = this.getBounds(initialShape)
217
+
218
+    shape.point = [bounds.minX, bounds.minY]
219
+
220
+    shape.points = shape.points.map((_, i) => {
221
+      const [x, y] = initialShape.points[i]
222
+      let nw = x / initialShapeBounds.width
223
+      let nh = y / initialShapeBounds.height
224
+
225
+      if (i === 1) {
226
+        let [x0, y0] = initialShape.points[0]
227
+        if (x0 === x) nw = 1
228
+        if (y0 === y) nh = 1
229
+      }
230
+
231
+      return [
232
+        bounds.width * (scaleX < 0 ? 1 - nw : nw),
233
+        bounds.height * (scaleY < 0 ? 1 - nh : nh),
234
+      ]
235
+    })
236
+
237
+    return this
238
+  },
239
+
240
+  transformSingle(shape, bounds, info) {
241
+    this.transform(shape, bounds, info)
242
+    return this
243
+  },
244
+
245
+  setProperty(shape, prop, value) {
246
+    shape[prop] = value
247
+    return this
248
+  },
249
+
250
+  onHandleMove(shape, handles) {
251
+    for (let id in handles) {
252
+      const handle = handles[id]
253
+
254
+      shape.handles[handle.id] = handle
255
+
256
+      if (handle.index < 2) {
257
+        shape.points[handle.index] = handle.point
258
+      }
259
+
260
+      const { start, end, bend } = shape.handles
261
+
262
+      const midPoint = vec.med(start.point, end.point)
263
+      const dist = vec.dist(start.point, end.point)
264
+
265
+      if (handle.id === 'bend') {
266
+        const distance = vec.distanceToLineSegment(
267
+          start.point,
268
+          end.point,
269
+          handle.point,
270
+          true
271
+        )
272
+        shape.bend = clamp(distance / (dist / 2), -1, 1)
273
+        if (!vec.clockwise(start.point, bend.point, end.point)) shape.bend *= -1
274
+      }
275
+
276
+      const bendDist = (dist / 2) * shape.bend
277
+      const u = vec.uni(vec.vec(start.point, end.point))
278
+
279
+      bend.point =
280
+        Math.abs(bendDist) > 10
281
+          ? vec.add(midPoint, vec.mul(vec.per(u), bendDist))
282
+          : midPoint
283
+    }
284
+
285
+    return this
286
+  },
287
+
288
+  canTransform: true,
289
+  canChangeAspectRatio: true,
290
+})
291
+
292
+export default arrow
293
+
294
+function getArrowArcPath(
295
+  cx: number,
296
+  cy: number,
297
+  r: number,
298
+  start: number[],
299
+  end: number[]
300
+) {
301
+  return `
302
+      A ${r},${r},0,
303
+      ${getSweep([cx, cy], start, end) > 0 ? '1' : '0'},
304
+      0,${end[0]},${end[1]}`
305
+}

+ 18
- 4
lib/shape-utils/index.tsx Visa fil

@@ -1,14 +1,12 @@
1 1
 import {
2 2
   Bounds,
3
-  BoundsSnapshot,
4 3
   Shape,
5
-  Shapes,
6 4
   ShapeType,
7 5
   Corner,
8 6
   Edge,
9
-  ShapeByType,
10 7
   ShapeStyles,
11
-  PropsOfType,
8
+  ShapeHandle,
9
+  ShapeBinding,
12 10
 } from 'types'
13 11
 import circle from './circle'
14 12
 import dot from './dot'
@@ -18,6 +16,7 @@ import ellipse from './ellipse'
18 16
 import line from './line'
19 17
 import ray from './ray'
20 18
 import draw from './draw'
19
+import arrow from './arrow'
21 20
 
22 21
 /*
23 22
 Shape Utiliies
@@ -90,6 +89,20 @@ export interface ShapeUtility<K extends Readonly<Shape>> {
90 89
     value: K[P]
91 90
   ): ShapeUtility<K>
92 91
 
92
+  // Respond when a user moves one of the shape's bound elements.
93
+  onBindingMove?(
94
+    this: ShapeUtility<K>,
95
+    shape: K,
96
+    bindings: Record<string, ShapeBinding>
97
+  ): ShapeUtility<K>
98
+
99
+  // Respond when a user moves one of the shape's handles.
100
+  onHandleMove?(
101
+    this: ShapeUtility<K>,
102
+    shape: K,
103
+    handle: Partial<K['handles']>
104
+  ): ShapeUtility<K>
105
+
93 106
   // Render a shape to JSX.
94 107
   render(this: ShapeUtility<K>, shape: K): JSX.Element
95 108
 
@@ -119,6 +132,7 @@ const shapeUtilityMap: Record<ShapeType, ShapeUtility<Shape>> = {
119 132
   [ShapeType.Line]: line,
120 133
   [ShapeType.Ray]: ray,
121 134
   [ShapeType.Draw]: draw,
135
+  [ShapeType.Arrow]: arrow,
122 136
 }
123 137
 
124 138
 /**

+ 2
- 2
lib/shape-utils/polyline.tsx Visa fil

@@ -43,8 +43,7 @@ const polyline = registerShapeUtils<PolylineShape>({
43 43
 
44 44
   getBounds(shape) {
45 45
     if (!this.boundsCache.has(shape)) {
46
-      const bounds = getBoundsFromPoints(shape.points)
47
-      this.boundsCache.set(shape, bounds)
46
+      this.boundsCache.set(shape, getBoundsFromPoints(shape.points))
48 47
     }
49 48
 
50 49
     return translateBounds(this.boundsCache.get(shape), shape.point)
@@ -106,6 +105,7 @@ const polyline = registerShapeUtils<PolylineShape>({
106 105
 
107 106
   transform(shape, bounds, { initialShape, scaleX, scaleY }) {
108 107
     const initialShapeBounds = this.getBounds(initialShape)
108
+
109 109
     shape.points = shape.points.map((_, i) => {
110 110
       const [x, y] = initialShape.points[i]
111 111
 

+ 43
- 0
state/commands/arrow.ts Visa fil

@@ -0,0 +1,43 @@
1
+import Command from './command'
2
+import history from '../history'
3
+import { ArrowShape, Data } from 'types'
4
+import { getPage } from 'utils/utils'
5
+import { ArrowSnapshot } from 'state/sessions/arrow-session'
6
+import { getShapeUtils } from 'lib/shape-utils'
7
+
8
+export default function arrowCommand(
9
+  data: Data,
10
+  before: ArrowSnapshot,
11
+  after: ArrowSnapshot
12
+) {
13
+  history.execute(
14
+    data,
15
+    new Command({
16
+      name: 'point_arrow',
17
+      category: 'canvas',
18
+      manualSelection: true,
19
+      do(data, isInitial) {
20
+        if (isInitial) return
21
+
22
+        const { initialShape, currentPageId } = after
23
+
24
+        getPage(data, currentPageId).shapes[initialShape.id] = initialShape
25
+
26
+        data.selectedIds.clear()
27
+        data.selectedIds.add(initialShape.id)
28
+        data.hoveredId = undefined
29
+        data.pointedId = undefined
30
+      },
31
+      undo(data) {
32
+        const { initialShape, currentPageId } = before
33
+        const shapes = getPage(data, currentPageId).shapes
34
+
35
+        delete shapes[initialShape.id]
36
+
37
+        data.selectedIds.clear()
38
+        data.hoveredId = undefined
39
+        data.pointedId = undefined
40
+      },
41
+    })
42
+  )
43
+}

+ 36
- 0
state/commands/handle.ts Visa fil

@@ -0,0 +1,36 @@
1
+import Command from './command'
2
+import history from '../history'
3
+import { Data } from 'types'
4
+import { getPage } from 'utils/utils'
5
+import { HandleSnapshot } from 'state/sessions/handle-session'
6
+import { getShapeUtils } from 'lib/shape-utils'
7
+
8
+export default function handleCommand(
9
+  data: Data,
10
+  before: HandleSnapshot,
11
+  after: HandleSnapshot
12
+) {
13
+  history.execute(
14
+    data,
15
+    new Command({
16
+      name: 'moved_handle',
17
+      category: 'canvas',
18
+      do(data, isInitial) {
19
+        if (isInitial) return
20
+
21
+        const { initialShape, currentPageId } = after
22
+
23
+        const shape = getPage(data, currentPageId).shapes[initialShape.id]
24
+
25
+        getShapeUtils(shape).onHandleMove(shape, initialShape.handles)
26
+      },
27
+      undo(data) {
28
+        const { initialShape, currentPageId } = before
29
+
30
+        const shape = getPage(data, currentPageId).shapes[initialShape.id]
31
+
32
+        getShapeUtils(shape).onHandleMove(shape, initialShape.handles)
33
+      },
34
+    })
35
+  )
36
+}

+ 12
- 8
state/commands/index.ts Visa fil

@@ -1,39 +1,43 @@
1 1
 import align from './align'
2
+import arrow from './arrow'
2 3
 import deleteSelected from './delete-selected'
3 4
 import direct from './direct'
4 5
 import distribute from './distribute'
6
+import draw from './draw'
5 7
 import duplicate from './duplicate'
6 8
 import generate from './generate'
7 9
 import move from './move'
8
-import draw from './draw'
10
+import nudge from './nudge'
9 11
 import rotate from './rotate'
12
+import rotateCcw from './rotate-ccw'
10 13
 import stretch from './stretch'
11 14
 import style from './style'
15
+import toggle from './toggle'
12 16
 import transform from './transform'
13 17
 import transformSingle from './transform-single'
14 18
 import translate from './translate'
15
-import nudge from './nudge'
16
-import toggle from './toggle'
17
-import rotateCcw from './rotate-ccw'
19
+import handle from './handle'
18 20
 
19 21
 const commands = {
20 22
   align,
23
+  arrow,
21 24
   deleteSelected,
22 25
   direct,
23 26
   distribute,
27
+  draw,
24 28
   duplicate,
25 29
   generate,
26 30
   move,
27
-  draw,
31
+  nudge,
28 32
   rotate,
33
+  rotateCcw,
29 34
   stretch,
30 35
   style,
36
+  toggle,
31 37
   transform,
32 38
   transformSingle,
33 39
   translate,
34
-  nudge,
35
-  toggle,
36
-  rotateCcw,
40
+  handle,
37 41
 }
38 42
 
39 43
 export default commands

+ 2
- 2
state/commands/transform-single.ts Visa fil

@@ -23,7 +23,7 @@ export default function transformSingleCommand(
23 23
       category: 'canvas',
24 24
       manualSelection: true,
25 25
       do(data) {
26
-        const { id, type, initialShape, initialShapeBounds } = after
26
+        const { id, type, initialShapeBounds } = after
27 27
 
28 28
         const { shapes } = getPage(data, after.currentPageId)
29 29
 
@@ -35,7 +35,7 @@ export default function transformSingleCommand(
35 35
         } else {
36 36
           getShapeUtils(shape).transformSingle(shape, initialShapeBounds, {
37 37
             type,
38
-            initialShape,
38
+            initialShape: before.initialShape,
39 39
             scaleX,
40 40
             scaleY,
41 41
             transformOrigin: [0.5, 0.5],

+ 7
- 5
state/commands/transform.ts Visa fil

@@ -8,7 +8,9 @@ import { getPage } from 'utils/utils'
8 8
 export default function transformCommand(
9 9
   data: Data,
10 10
   before: TransformSnapshot,
11
-  after: TransformSnapshot
11
+  after: TransformSnapshot,
12
+  scaleX: number,
13
+  scaleY: number
12 14
 ) {
13 15
   history.execute(
14 16
     data,
@@ -29,8 +31,8 @@ export default function transformCommand(
29 31
             type,
30 32
             initialShape,
31 33
             transformOrigin,
32
-            scaleX: 1,
33
-            scaleY: 1,
34
+            scaleX,
35
+            scaleY,
34 36
           })
35 37
         }
36 38
       },
@@ -48,8 +50,8 @@ export default function transformCommand(
48 50
             type,
49 51
             initialShape,
50 52
             transformOrigin,
51
-            scaleX: 1,
52
-            scaleY: 1,
53
+            scaleX,
54
+            scaleY,
53 55
           })
54 56
         }
55 57
       },

+ 111
- 94
state/data.ts Visa fil

@@ -10,107 +10,124 @@ export const defaultDocument: Data['document'] = {
10 10
       name: 'Page 0',
11 11
       childIndex: 0,
12 12
       shapes: {
13
-        shape3: shapeUtils[ShapeType.Dot].create({
14
-          id: 'shape3',
15
-          name: 'Shape 3',
16
-          childIndex: 3,
17
-          point: [400, 500],
18
-          style: {
19
-            stroke: shades.black,
20
-            fill: shades.lightGray,
21
-            strokeWidth: 1,
22
-          },
23
-        }),
24
-        shape0: shapeUtils[ShapeType.Circle].create({
25
-          id: 'shape0',
26
-          name: 'Shape 0',
27
-          childIndex: 1,
28
-          point: [100, 600],
29
-          radius: 50,
30
-          style: {
31
-            stroke: shades.black,
32
-            fill: shades.lightGray,
33
-            strokeWidth: 1,
34
-          },
35
-        }),
36
-        shape5: shapeUtils[ShapeType.Ellipse].create({
37
-          id: 'shape5',
38
-          name: 'Shape 5',
39
-          childIndex: 5,
13
+        arrowShape0: shapeUtils[ShapeType.Arrow].create({
14
+          id: 'arrowShape0',
40 15
           point: [200, 200],
41
-          radiusX: 50,
42
-          radiusY: 100,
43
-          style: {
44
-            stroke: shades.black,
45
-            fill: shades.lightGray,
46
-            strokeWidth: 1,
47
-          },
16
+          points: [
17
+            [0, 0],
18
+            [200, 200],
19
+          ],
48 20
         }),
49
-        shape7: shapeUtils[ShapeType.Ellipse].create({
50
-          id: 'shape7',
51
-          name: 'Shape 7',
52
-          childIndex: 7,
21
+        arrowShape1: shapeUtils[ShapeType.Arrow].create({
22
+          id: 'arrowShape1',
53 23
           point: [100, 100],
54
-          radiusX: 50,
55
-          radiusY: 30,
56
-          style: {
57
-            stroke: shades.black,
58
-            fill: shades.lightGray,
59
-            strokeWidth: 1,
60
-          },
61
-        }),
62
-        shape6: shapeUtils[ShapeType.Line].create({
63
-          id: 'shape6',
64
-          name: 'Shape 6',
65
-          childIndex: 1,
66
-          point: [400, 400],
67
-          direction: [0.2, 0.2],
68
-          style: {
69
-            stroke: shades.black,
70
-            fill: shades.lightGray,
71
-            strokeWidth: 1,
72
-          },
73
-        }),
74
-        rayShape: shapeUtils[ShapeType.Ray].create({
75
-          id: 'rayShape',
76
-          name: 'Ray',
77
-          childIndex: 3,
78
-          point: [300, 100],
79
-          direction: [0.5, 0.5],
80
-          style: {
81
-            stroke: shades.black,
82
-            fill: shades.lightGray,
83
-            strokeWidth: 1,
84
-          },
85
-        }),
86
-        shape2: shapeUtils[ShapeType.Polyline].create({
87
-          id: 'shape2',
88
-          name: 'Shape 2',
89
-          childIndex: 2,
90
-          point: [200, 600],
91 24
           points: [
92 25
             [0, 0],
93
-            [75, 200],
94
-            [100, 50],
26
+            [300, 0],
95 27
           ],
96
-          style: {
97
-            stroke: shades.black,
98
-            fill: shades.none,
99
-            strokeWidth: 1,
100
-          },
101
-        }),
102
-        shape1: shapeUtils[ShapeType.Rectangle].create({
103
-          id: 'shape1',
104
-          name: 'Shape 1',
105
-          childIndex: 1,
106
-          point: [400, 600],
107
-          size: [200, 200],
108
-          style: {
109
-            stroke: shades.black,
110
-            fill: shades.lightGray,
111
-            strokeWidth: 1,
112
-          },
113 28
         }),
29
+
30
+        // shape3: shapeUtils[ShapeType.Dot].create({
31
+        //   id: 'shape3',
32
+        //   name: 'Shape 3',
33
+        //   childIndex: 3,
34
+        //   point: [400, 500],
35
+        //   style: {
36
+        //     stroke: shades.black,
37
+        //     fill: shades.lightGray,
38
+        //     strokeWidth: 1,
39
+        //   },
40
+        // }),
41
+        // shape0: shapeUtils[ShapeType.Circle].create({
42
+        //   id: 'shape0',
43
+        //   name: 'Shape 0',
44
+        //   childIndex: 1,
45
+        //   point: [100, 600],
46
+        //   radius: 50,
47
+        //   style: {
48
+        //     stroke: shades.black,
49
+        //     fill: shades.lightGray,
50
+        //     strokeWidth: 1,
51
+        //   },
52
+        // }),
53
+        // shape5: shapeUtils[ShapeType.Ellipse].create({
54
+        //   id: 'shape5',
55
+        //   name: 'Shape 5',
56
+        //   childIndex: 5,
57
+        //   point: [200, 200],
58
+        //   radiusX: 50,
59
+        //   radiusY: 100,
60
+        //   style: {
61
+        //     stroke: shades.black,
62
+        //     fill: shades.lightGray,
63
+        //     strokeWidth: 1,
64
+        //   },
65
+        // }),
66
+        // shape7: shapeUtils[ShapeType.Ellipse].create({
67
+        //   id: 'shape7',
68
+        //   name: 'Shape 7',
69
+        //   childIndex: 7,
70
+        //   point: [100, 100],
71
+        //   radiusX: 50,
72
+        //   radiusY: 30,
73
+        //   style: {
74
+        //     stroke: shades.black,
75
+        //     fill: shades.lightGray,
76
+        //     strokeWidth: 1,
77
+        //   },
78
+        // }),
79
+        // shape6: shapeUtils[ShapeType.Line].create({
80
+        //   id: 'shape6',
81
+        //   name: 'Shape 6',
82
+        //   childIndex: 1,
83
+        //   point: [400, 400],
84
+        //   direction: [0.2, 0.2],
85
+        //   style: {
86
+        //     stroke: shades.black,
87
+        //     fill: shades.lightGray,
88
+        //     strokeWidth: 1,
89
+        //   },
90
+        // }),
91
+        // rayShape: shapeUtils[ShapeType.Ray].create({
92
+        //   id: 'rayShape',
93
+        //   name: 'Ray',
94
+        //   childIndex: 3,
95
+        //   point: [300, 100],
96
+        //   direction: [0.5, 0.5],
97
+        //   style: {
98
+        //     stroke: shades.black,
99
+        //     fill: shades.lightGray,
100
+        //     strokeWidth: 1,
101
+        //   },
102
+        // }),
103
+        // shape2: shapeUtils[ShapeType.Polyline].create({
104
+        //   id: 'shape2',
105
+        //   name: 'Shape 2',
106
+        //   childIndex: 2,
107
+        //   point: [200, 600],
108
+        //   points: [
109
+        //     [0, 0],
110
+        //     [75, 200],
111
+        //     [100, 50],
112
+        //   ],
113
+        //   style: {
114
+        //     stroke: shades.black,
115
+        //     fill: shades.none,
116
+        //     strokeWidth: 1,
117
+        //   },
118
+        // }),
119
+        // shape1: shapeUtils[ShapeType.Rectangle].create({
120
+        //   id: 'shape1',
121
+        //   name: 'Shape 1',
122
+        //   childIndex: 1,
123
+        //   point: [400, 600],
124
+        //   size: [200, 200],
125
+        //   style: {
126
+        //     stroke: shades.black,
127
+        //     fill: shades.lightGray,
128
+        //     strokeWidth: 1,
129
+        //   },
130
+        // }),
114 131
       },
115 132
     },
116 133
   },

+ 92
- 0
state/sessions/arrow-session.ts Visa fil

@@ -0,0 +1,92 @@
1
+import { ArrowShape, Data, LineShape, RayShape } from 'types'
2
+import * as vec from 'utils/vec'
3
+import BaseSession from './base-session'
4
+import commands from 'state/commands'
5
+import { current } from 'immer'
6
+import { getPage } from 'utils/utils'
7
+import { getShapeUtils } from 'lib/shape-utils'
8
+
9
+export default class PointsSession extends BaseSession {
10
+  points: number[][]
11
+  origin: number[]
12
+  snapshot: ArrowSnapshot
13
+  isLocked: boolean
14
+  lockedDirection: 'horizontal' | 'vertical'
15
+
16
+  constructor(data: Data, id: string, point: number[], isLocked: boolean) {
17
+    super(data)
18
+    this.origin = point
19
+    this.points = [[0, 0]]
20
+    this.snapshot = getArrowSnapshot(data, id)
21
+  }
22
+
23
+  update(data: Data, point: number[], isLocked = false) {
24
+    const { id } = this.snapshot
25
+
26
+    const delta = vec.vec(this.origin, point)
27
+
28
+    if (isLocked) {
29
+      if (!this.isLocked && this.points.length > 1) {
30
+        this.isLocked = true
31
+
32
+        if (Math.abs(delta[0]) < Math.abs(delta[1])) {
33
+          this.lockedDirection = 'vertical'
34
+        } else {
35
+          this.lockedDirection = 'horizontal'
36
+        }
37
+      }
38
+    } else {
39
+      if (this.isLocked) {
40
+        this.isLocked = false
41
+      }
42
+    }
43
+
44
+    if (this.isLocked) {
45
+      if (this.lockedDirection === 'vertical') {
46
+        point[0] = this.origin[0]
47
+      } else {
48
+        point[1] = this.origin[1]
49
+      }
50
+    }
51
+
52
+    const shape = getPage(data).shapes[id] as ArrowShape
53
+
54
+    getShapeUtils(shape).onHandleMove(shape, {
55
+      end: {
56
+        ...shape.handles.end,
57
+        point: vec.sub(point, shape.point),
58
+      },
59
+    })
60
+  }
61
+
62
+  cancel(data: Data) {
63
+    const { id, initialShape } = this.snapshot
64
+
65
+    const shape = getPage(data).shapes[id] as ArrowShape
66
+
67
+    getShapeUtils(shape)
68
+      .onHandleMove(shape, { end: initialShape.handles.end })
69
+      .translateTo(shape, initialShape.point)
70
+  }
71
+
72
+  complete(data: Data) {
73
+    commands.arrow(
74
+      data,
75
+      this.snapshot,
76
+      getArrowSnapshot(data, this.snapshot.id)
77
+    )
78
+  }
79
+}
80
+
81
+export function getArrowSnapshot(data: Data, id: string) {
82
+  const initialShape = getPage(current(data)).shapes[id] as ArrowShape
83
+
84
+  return {
85
+    id,
86
+    initialShape,
87
+    selectedIds: new Set(data.selectedIds),
88
+    currentPageId: data.currentPageId,
89
+  }
90
+}
91
+
92
+export type ArrowSnapshot = ReturnType<typeof getArrowSnapshot>

+ 0
- 5
state/sessions/draw-session.ts Visa fil

@@ -23,11 +23,6 @@ export default class BrushSession extends BaseSession {
23 23
     this.points = []
24 24
     this.snapshot = getDrawSnapshot(data, id)
25 25
 
26
-    // if (isLocked && prevEndPoint) {
27
-    //   const continuedPt = vec.sub([...prevEndPoint], this.origin)
28
-    //   this.points.push(continuedPt)
29
-    // }
30
-
31 26
     const page = getPage(data)
32 27
     const shape = page.shapes[id]
33 28
     getShapeUtils(shape).translateTo(shape, point)

+ 76
- 0
state/sessions/handle-session.ts Visa fil

@@ -0,0 +1,76 @@
1
+import { Data } from 'types'
2
+import * as vec from 'utils/vec'
3
+import BaseSession from './base-session'
4
+import commands from 'state/commands'
5
+import { current } from 'immer'
6
+import { getPage } from 'utils/utils'
7
+import { getShapeUtils } from 'lib/shape-utils'
8
+
9
+export default class HandleSession extends BaseSession {
10
+  delta = [0, 0]
11
+  origin: number[]
12
+  snapshot: HandleSnapshot
13
+
14
+  constructor(data: Data, shapeId: string, handleId: string, point: number[]) {
15
+    super(data)
16
+    this.origin = point
17
+    this.snapshot = getHandleSnapshot(data, shapeId, handleId)
18
+  }
19
+
20
+  update(data: Data, point: number[], isAligned: boolean) {
21
+    const { currentPageId, handleId, initialShape } = this.snapshot
22
+    const shape = getPage(data, currentPageId).shapes[initialShape.id]
23
+
24
+    const delta = vec.vec(this.origin, point)
25
+
26
+    if (isAligned) {
27
+      if (Math.abs(delta[0]) < Math.abs(delta[1])) {
28
+        delta[0] = 0
29
+      } else {
30
+        delta[1] = 0
31
+      }
32
+    }
33
+
34
+    const handles = initialShape.handles
35
+
36
+    getShapeUtils(shape).onHandleMove(shape, {
37
+      [handleId]: {
38
+        ...handles[handleId],
39
+        point: vec.add(handles[handleId].point, delta),
40
+      },
41
+    })
42
+  }
43
+
44
+  cancel(data: Data) {
45
+    const { currentPageId, handleId, initialShape } = this.snapshot
46
+    const shape = getPage(data, currentPageId).shapes[initialShape.id]
47
+  }
48
+
49
+  complete(data: Data) {
50
+    commands.handle(
51
+      data,
52
+      this.snapshot,
53
+      getHandleSnapshot(
54
+        data,
55
+        this.snapshot.initialShape.id,
56
+        this.snapshot.handleId
57
+      )
58
+    )
59
+  }
60
+}
61
+
62
+export function getHandleSnapshot(
63
+  data: Data,
64
+  shapeId: string,
65
+  handleId: string
66
+) {
67
+  const initialShape = getPage(current(data)).shapes[shapeId]
68
+
69
+  return {
70
+    currentPageId: data.currentPageId,
71
+    handleId,
72
+    initialShape,
73
+  }
74
+}
75
+
76
+export type HandleSnapshot = ReturnType<typeof getHandleSnapshot>

+ 12
- 8
state/sessions/index.ts Visa fil

@@ -1,13 +1,16 @@
1
-import BaseSession from "./base-session"
2
-import BrushSession from "./brush-session"
3
-import DirectionSession from "./direction-session"
4
-import DrawSession from "./draw-session"
5
-import RotateSession from "./rotate-session"
6
-import TransformSession from "./transform-session"
7
-import TransformSingleSession from "./transform-single-session"
8
-import TranslateSession from "./translate-session"
1
+import ArrowSession from './arrow-session'
2
+import BaseSession from './base-session'
3
+import BrushSession from './brush-session'
4
+import DirectionSession from './direction-session'
5
+import DrawSession from './draw-session'
6
+import RotateSession from './rotate-session'
7
+import TransformSession from './transform-session'
8
+import TransformSingleSession from './transform-single-session'
9
+import TranslateSession from './translate-session'
10
+import HandleSession from './handle-session'
9 11
 
10 12
 export {
13
+  ArrowSession,
11 14
   BaseSession,
12 15
   BrushSession,
13 16
   DirectionSession,
@@ -16,4 +19,5 @@ export {
16 19
   TransformSession,
17 20
   TransformSingleSession,
18 21
   TranslateSession,
22
+  HandleSession,
19 23
 }

+ 3
- 1
state/sessions/transform-session.ts Visa fil

@@ -100,7 +100,9 @@ export default class TransformSession extends BaseSession {
100 100
     commands.transform(
101 101
       data,
102 102
       this.snapshot,
103
-      getTransformSnapshot(data, this.transformType)
103
+      getTransformSnapshot(data, this.transformType),
104
+      this.scaleX,
105
+      this.scaleY
104 106
     )
105 107
   }
106 108
 }

+ 117
- 3
state/state.ts Visa fil

@@ -118,6 +118,7 @@ const state = createState({
118 118
         },
119 119
         SELECTED_SELECT_TOOL: { to: 'selecting' },
120 120
         SELECTED_DRAW_TOOL: { unless: 'isReadOnly', to: 'draw' },
121
+        SELECTED_ARROW_TOOL: { unless: 'isReadOnly', to: 'arrow' },
121 122
         SELECTED_DOT_TOOL: { unless: 'isReadOnly', to: 'dot' },
122 123
         SELECTED_CIRCLE_TOOL: { unless: 'isReadOnly', to: 'circle' },
123 124
         SELECTED_ELLIPSE_TOOL: { unless: 'isReadOnly', to: 'ellipse' },
@@ -168,6 +169,7 @@ const state = createState({
168 169
                   to: 'rotatingSelection',
169 170
                   else: { to: 'transformingSelection' },
170 171
                 },
172
+                POINTED_HANDLE: { to: 'translatingHandles' },
171 173
                 MOVED_OVER_SHAPE: {
172 174
                   if: 'pointHitsShape',
173 175
                   then: {
@@ -216,7 +218,7 @@ const state = createState({
216 218
                 MOVED_POINTER: {
217 219
                   unless: 'isReadOnly',
218 220
                   if: 'distanceImpliesDrag',
219
-                  to: 'draggingSelection',
221
+                  to: 'translatingSelection',
220 222
                 },
221 223
               },
222 224
             },
@@ -243,7 +245,7 @@ const state = createState({
243 245
                 CANCELLED: { do: 'cancelSession', to: 'selecting' },
244 246
               },
245 247
             },
246
-            draggingSelection: {
248
+            translatingSelection: {
247 249
               onEnter: 'startTranslateSession',
248 250
               on: {
249 251
                 MOVED_POINTER: 'updateTranslateSession',
@@ -256,6 +258,17 @@ const state = createState({
256 258
                 CANCELLED: { do: 'cancelSession', to: 'selecting' },
257 259
               },
258 260
             },
261
+            translatingHandles: {
262
+              onEnter: 'startHandleSession',
263
+              on: {
264
+                MOVED_POINTER: 'updateHandleSession',
265
+                PANNED_CAMERA: 'updateHandleSession',
266
+                PRESSED_SHIFT_KEY: 'keyUpdateHandleSession',
267
+                RELEASED_SHIFT_KEY: 'keyUpdateHandleSession',
268
+                STOPPED_POINTING: { do: 'completeSession', to: 'selecting' },
269
+                CANCELLED: { do: 'cancelSession', to: 'selecting' },
270
+              },
271
+            },
259 272
             brushSelecting: {
260 273
               onEnter: [
261 274
                 {
@@ -399,6 +412,51 @@ const state = createState({
399 412
                 },
400 413
               },
401 414
             },
415
+            arrow: {
416
+              initial: 'creating',
417
+              states: {
418
+                creating: {
419
+                  on: {
420
+                    CANCELLED: { to: 'selecting' },
421
+                    POINTED_SHAPE: {
422
+                      get: 'newArrow',
423
+                      do: 'createShape',
424
+                      to: 'arrow.editing',
425
+                    },
426
+                    POINTED_CANVAS: {
427
+                      get: 'newArrow',
428
+                      do: 'createShape',
429
+                      to: 'arrow.editing',
430
+                    },
431
+                    UNDO: { do: 'undo' },
432
+                    REDO: { do: 'redo' },
433
+                  },
434
+                },
435
+                editing: {
436
+                  onEnter: 'startArrowSession',
437
+                  on: {
438
+                    STOPPED_POINTING: [
439
+                      'completeSession',
440
+                      {
441
+                        if: 'isToolLocked',
442
+                        to: 'arrow.creating',
443
+                        else: { to: 'selecting' },
444
+                      },
445
+                    ],
446
+                    CANCELLED: {
447
+                      do: 'breakSession',
448
+                      if: 'isToolLocked',
449
+                      to: 'arrow.creating',
450
+                      else: { to: 'selecting' },
451
+                    },
452
+                    PRESSED_SHIFT: 'keyUpdateArrowSession',
453
+                    RELEASED_SHIFT: 'keyUpdateArrowSession',
454
+                    MOVED_POINTER: 'updateArrowSession',
455
+                    PANNED_CAMERA: 'updateArrowSession',
456
+                  },
457
+                },
458
+              },
459
+            },
402 460
             circle: {
403 461
               initial: 'creating',
404 462
               states: {
@@ -586,6 +644,9 @@ const state = createState({
586 644
     },
587 645
   },
588 646
   results: {
647
+    newArrow() {
648
+      return ShapeType.Arrow
649
+    },
589 650
     newDraw() {
590 651
       return ShapeType.Draw
591 652
     },
@@ -749,7 +810,39 @@ const state = createState({
749 810
       )
750 811
     },
751 812
 
752
-    // Dragging / Translating
813
+    // Dragging Handle
814
+    startHandleSession(data, payload: PointerInfo) {
815
+      const shapeId = Array.from(data.selectedIds.values())[0]
816
+      const handleId = payload.target
817
+
818
+      session = new Sessions.HandleSession(
819
+        data,
820
+        shapeId,
821
+        handleId,
822
+        screenToWorld(inputs.pointer.origin, data)
823
+      )
824
+    },
825
+    keyUpdateHandleSession(
826
+      data,
827
+      payload: { shiftKey: boolean; altKey: boolean }
828
+    ) {
829
+      session.update(
830
+        data,
831
+        screenToWorld(inputs.pointer.point, data),
832
+        payload.shiftKey,
833
+        payload.altKey
834
+      )
835
+    },
836
+    updateHandleSession(data, payload: PointerInfo) {
837
+      session.update(
838
+        data,
839
+        screenToWorld(payload.point, data),
840
+        payload.shiftKey,
841
+        payload.altKey
842
+      )
843
+    },
844
+
845
+    // Transforming
753 846
     startTransformSession(
754 847
       data,
755 848
       payload: PointerInfo & { target: Corner | Edge }
@@ -817,6 +910,27 @@ const state = createState({
817 910
       session.update(data, screenToWorld(payload.point, data), payload.shiftKey)
818 911
     },
819 912
 
913
+    // Arrow
914
+    startArrowSession(data, payload: PointerInfo) {
915
+      const id = Array.from(data.selectedIds.values())[0]
916
+      session = new Sessions.ArrowSession(
917
+        data,
918
+        id,
919
+        screenToWorld(inputs.pointer.origin, data),
920
+        payload.shiftKey
921
+      )
922
+    },
923
+    keyUpdateArrowSession(data, payload: PointerInfo) {
924
+      session.update(
925
+        data,
926
+        screenToWorld(inputs.pointer.point, data),
927
+        payload.shiftKey
928
+      )
929
+    },
930
+    updateArrowSession(data, payload: PointerInfo) {
931
+      session.update(data, screenToWorld(payload.point, data), payload.shiftKey)
932
+    },
933
+
820 934
     // Nudges
821 935
     nudgeSelection(data, payload: { delta: number[]; shiftKey: boolean }) {
822 936
       commands.nudge(

+ 33
- 0
types.ts Visa fil

@@ -58,6 +58,7 @@ export enum ShapeType {
58 58
   Polyline = 'polyline',
59 59
   Rectangle = 'rectangle',
60 60
   Draw = 'draw',
61
+  Arrow = 'arrow',
61 62
 }
62 63
 
63 64
 // Consider:
@@ -77,6 +78,8 @@ export interface BaseShape {
77 78
   name: string
78 79
   point: number[]
79 80
   rotation: number
81
+  bindings?: Record<string, ShapeBinding>
82
+  handles?: Record<string, ShapeHandle>
80 83
   style: ShapeStyles
81 84
   isLocked: boolean
82 85
   isHidden: boolean
@@ -124,6 +127,18 @@ export interface DrawShape extends BaseShape {
124 127
   points: number[][]
125 128
 }
126 129
 
130
+export interface ArrowShape extends BaseShape {
131
+  type: ShapeType.Arrow
132
+  points: number[][]
133
+  handles: Record<string, ShapeHandle>
134
+  bend: number
135
+  decorations?: {
136
+    start: Decoration
137
+    end: Decoration
138
+    middle: Decoration
139
+  }
140
+}
141
+
127 142
 export type MutableShape =
128 143
   | DotShape
129 144
   | CircleShape
@@ -133,6 +148,7 @@ export type MutableShape =
133 148
   | PolylineShape
134 149
   | DrawShape
135 150
   | RectangleShape
151
+  | ArrowShape
136 152
 
137 153
 export type Shape = Readonly<MutableShape>
138 154
 
@@ -145,6 +161,7 @@ export interface Shapes {
145 161
   [ShapeType.Polyline]: Readonly<PolylineShape>
146 162
   [ShapeType.Draw]: Readonly<DrawShape>
147 163
   [ShapeType.Rectangle]: Readonly<RectangleShape>
164
+  [ShapeType.Arrow]: Readonly<ArrowShape>
148 165
 }
149 166
 
150 167
 export type ShapeByType<T extends ShapeType> = Shapes[T]
@@ -155,6 +172,22 @@ export interface CodeFile {
155 172
   code: string
156 173
 }
157 174
 
175
+export enum Decoration {
176
+  Arrow,
177
+}
178
+
179
+export interface ShapeBinding {
180
+  id: string
181
+  index: number
182
+  point: number[]
183
+}
184
+
185
+export interface ShapeHandle {
186
+  id: string
187
+  index: number
188
+  point: number[]
189
+}
190
+
158 191
 /* -------------------------------------------------- */
159 192
 /*                      Editor UI                     */
160 193
 /* -------------------------------------------------- */

+ 8
- 8
utils/svg.ts Visa fil

@@ -1,7 +1,7 @@
1 1
 // Some helpers for drawing SVGs.
2 2
 
3
-import * as vec from "./vec"
4
-import { getSweep } from "utils/utils"
3
+import * as vec from './vec'
4
+import { getSweep } from 'utils/utils'
5 5
 
6 6
 // General
7 7
 
@@ -37,24 +37,24 @@ export function bezierTo(A: number[], B: number[], C: number[]) {
37 37
 
38 38
 export function arcTo(C: number[], r: number, A: number[], B: number[]) {
39 39
   return [
40
-    // moveTo(A),
41
-    "A",
40
+    moveTo(A),
41
+    'A',
42 42
     r,
43 43
     r,
44 44
     0,
45
-    getSweep(C, A, B) > 0 ? "1" : "0",
45
+    getSweep(C, A, B) > 0 ? '1' : '0',
46 46
     0,
47 47
     B[0],
48 48
     B[1],
49
-  ].join(" ")
49
+  ].join(' ')
50 50
 }
51 51
 
52 52
 export function closePath() {
53
-  return "Z"
53
+  return 'Z'
54 54
 }
55 55
 
56 56
 export function rectTo(A: number[]) {
57
-  return ["R", A[0], A[1]].join(" ")
57
+  return ['R', A[0], A[1]].join(' ')
58 58
 }
59 59
 
60 60
 export function getPointAtLength(path: SVGPathElement, length: number) {

+ 9
- 9
utils/utils.ts Visa fil

@@ -750,7 +750,7 @@ export function det(
750 750
  * @param p0
751 751
  * @param p1
752 752
  * @param center
753
- * @returns
753
+ * @returns [x, y, r]
754 754
  */
755 755
 export function circleFromThreePoints(A: number[], B: number[], C: number[]) {
756 756
   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[]) {
788 788
     C[0],
789 789
     C[1]
790 790
   )
791
-  return [
792
-    -bx / (2 * a),
793
-    -by / (2 * a),
794
-    Math.sqrt(bx * bx + by * by - 4 * a * c) / (2 * Math.abs(a)),
795
-  ]
791
+
792
+  const x = -bx / (2 * a)
793
+  const y = -by / (2 * a)
794
+  const r = Math.sqrt(bx * bx + by * by - 4 * a * c) / (2 * Math.abs(a))
795
+
796
+  return [x, y, r]
796 797
 }
797 798
 
798 799
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -993,7 +994,6 @@ export function getBoundsFromPoints(points: number[][], rotation = 0): Bounds {
993 994
   }
994 995
 
995 996
   if (rotation !== 0) {
996
-    console.log('returning rotated bounds')
997 997
     return getBoundsFromPoints(
998 998
       points.map((pt) =>
999 999
         vec.rotWith(pt, [(minX + maxX) / 2, (minY + maxY) / 2], rotation)
@@ -1304,8 +1304,8 @@ export function getTransformedBoundingBox(
1304 1304
     maxY: by1,
1305 1305
     width: bx1 - bx0,
1306 1306
     height: by1 - by0,
1307
-    scaleX: ((bx1 - bx0) / (ax1 - ax0)) * (flipX ? -1 : 1),
1308
-    scaleY: ((by1 - by0) / (ay1 - ay0)) * (flipY ? -1 : 1),
1307
+    scaleX: ((bx1 - bx0) / (ax1 - ax0 || 1)) * (flipX ? -1 : 1),
1308
+    scaleY: ((by1 - by0) / (ay1 - ay0 || 1)) * (flipY ? -1 : 1),
1309 1309
   }
1310 1310
 }
1311 1311
 

Laddar…
Avbryt
Spara