ソースを参照

Fixes events with shapes, adds test for selection

main
Steve Ruiz 3年前
コミット
d5fe5612e1

+ 13287
- 0
__tests__/__snapshots__/project.test.ts.snap
ファイル差分が大きすぎるため省略します
ファイルの表示


+ 11
- 48
__tests__/project.test.ts ファイルの表示

@@ -1,56 +1,19 @@
1
-import * as json from './__mocks__/document.json'
2 1
 import state from 'state'
3
-import { point } from './test-utils'
4
-import inputs from 'state/inputs'
5
-import { getSelectedIds, setToArray } from 'utils/utils'
6
-
7
-const rectangleId = '1f6c251c-e12e-40b4-8dd2-c1847d80b72f'
8
-const arrowId = '5ca167d7-54de-47c9-aa8f-86affa25e44d'
2
+import * as json from './__mocks__/document.json'
9 3
 
10 4
 describe('project', () => {
11
-  it('mounts the state', () => {
12
-    state.enableLog(true)
13
-
14
-    state
15
-      .send('MOUNTED')
16
-      .send('LOADED_FROM_FILE', { json: JSON.stringify(json) })
17
-  })
18
-
19
-  it('selects and deselects a shape', () => {
20
-    expect(setToArray(getSelectedIds(state.data))).toStrictEqual([])
21
-
22
-    state
23
-      .send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId))
24
-      .send('STOPPED_POINTING', inputs.pointerUp(point()))
5
+  state.reset()
6
+  state.enableLog(true)
25 7
 
26
-    expect(setToArray(getSelectedIds(state.data))).toStrictEqual([rectangleId])
27
-
28
-    state
29
-      .send('POINTED_CANVAS', inputs.pointerDown(point(), 'canvas'))
30
-      .send('STOPPED_POINTING', inputs.pointerUp(point()))
31
-
32
-    expect(setToArray(getSelectedIds(state.data))).toStrictEqual([])
8
+  it('mounts the state', () => {
9
+    state.send('MOUNTED')
10
+    expect(state.data.document).toMatchSnapshot('data after initial mount')
11
+    expect(state.isIn('ready')).toBe(true)
33 12
   })
34 13
 
35
-  it('selects multiple shapes', () => {
36
-    expect(setToArray(getSelectedIds(state.data))).toStrictEqual([])
37
-
38
-    state
39
-      .send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId))
40
-      .send('STOPPED_POINTING', inputs.pointerUp(point()))
41
-
42
-    expect(setToArray(getSelectedIds(state.data))).toStrictEqual([rectangleId])
43
-
44
-    state.send(
45
-      'POINTED_SHAPE',
46
-      inputs.pointerDown(point({ shiftKey: true }), arrowId)
47
-    )
48
-
49
-    // state.send('STOPPED_POINTING', inputs.pointerUp(point()))
50
-
51
-    expect(setToArray(getSelectedIds(state.data))).toStrictEqual([
52
-      rectangleId,
53
-      arrowId,
54
-    ])
14
+  it('loads file from json', () => {
15
+    state.send('LOADED_FROM_FILE', { json: JSON.stringify(json) })
16
+    expect(state.isIn('ready')).toBe(true)
17
+    expect(state.data.document).toMatchSnapshot('data after mount from file')
55 18
   })
56 19
 })

+ 142
- 0
__tests__/selection.test.ts ファイルの表示

@@ -0,0 +1,142 @@
1
+import state from 'state'
2
+import inputs from 'state/inputs'
3
+import { idsAreSelected, point } from './test-utils'
4
+import * as json from './__mocks__/document.json'
5
+
6
+const rectangleId = '1f6c251c-e12e-40b4-8dd2-c1847d80b72f'
7
+const arrowId = '5ca167d7-54de-47c9-aa8f-86affa25e44d'
8
+
9
+// Mount the state and load the test file from json
10
+state.reset()
11
+state.send('MOUNTED').send('LOADED_FROM_FILE', { json: JSON.stringify(json) })
12
+
13
+describe('selection', () => {
14
+  it('selects a shape', () => {
15
+    state
16
+      .send('CANCELED')
17
+      .send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId))
18
+      .send('STOPPED_POINTING', inputs.pointerUp(point(), rectangleId))
19
+
20
+    expect(idsAreSelected(state.data, [rectangleId])).toBe(true)
21
+  })
22
+
23
+  it('selects and deselects a shape', () => {
24
+    state
25
+      .send('CANCELED')
26
+      .send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId))
27
+      .send('STOPPED_POINTING', inputs.pointerUp(point(), rectangleId))
28
+
29
+    expect(idsAreSelected(state.data, [rectangleId])).toBe(true)
30
+
31
+    state
32
+      .send('POINTED_CANVAS', inputs.pointerDown(point(), 'canvas'))
33
+      .send('STOPPED_POINTING', inputs.pointerUp(point(), 'canvas'))
34
+
35
+    expect(idsAreSelected(state.data, [])).toBe(true)
36
+  })
37
+
38
+  it('selects multiple shapes', () => {
39
+    expect(idsAreSelected(state.data, [])).toBe(true)
40
+
41
+    state
42
+      .send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId))
43
+      .send('STOPPED_POINTING', inputs.pointerUp(point(), rectangleId))
44
+      .send(
45
+        'POINTED_SHAPE',
46
+        inputs.pointerDown(point({ shiftKey: true }), arrowId)
47
+      )
48
+      .send(
49
+        'STOPPED_POINTING',
50
+        inputs.pointerUp(point({ shiftKey: true }), arrowId)
51
+      )
52
+
53
+    expect(idsAreSelected(state.data, [rectangleId, arrowId])).toBe(true)
54
+  })
55
+
56
+  it('shift-selects to deselect shapes', () => {
57
+    state
58
+      .send('CANCELLED')
59
+      .send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId))
60
+      .send('STOPPED_POINTING', inputs.pointerUp(point(), rectangleId))
61
+      .send(
62
+        'POINTED_SHAPE',
63
+        inputs.pointerDown(point({ shiftKey: true }), arrowId)
64
+      )
65
+      .send(
66
+        'STOPPED_POINTING',
67
+        inputs.pointerUp(point({ shiftKey: true }), arrowId)
68
+      )
69
+      .send(
70
+        'POINTED_SHAPE',
71
+        inputs.pointerDown(point({ shiftKey: true }), rectangleId)
72
+      )
73
+      .send(
74
+        'STOPPED_POINTING',
75
+        inputs.pointerUp(point({ shiftKey: true }), rectangleId)
76
+      )
77
+
78
+    expect(idsAreSelected(state.data, [arrowId])).toBe(true)
79
+  })
80
+
81
+  it('single-selects shape in selection on pointerup', () => {
82
+    state
83
+      .send('CANCELLED')
84
+      .send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId))
85
+      .send('STOPPED_POINTING', inputs.pointerUp(point(), rectangleId))
86
+      .send(
87
+        'POINTED_SHAPE',
88
+        inputs.pointerDown(point({ shiftKey: true }), arrowId)
89
+      )
90
+      .send(
91
+        'STOPPED_POINTING',
92
+        inputs.pointerUp(point({ shiftKey: true }), arrowId)
93
+      )
94
+
95
+    expect(idsAreSelected(state.data, [rectangleId, arrowId])).toBe(true)
96
+
97
+    state.send('POINTED_SHAPE', inputs.pointerDown(point(), arrowId))
98
+
99
+    expect(idsAreSelected(state.data, [rectangleId, arrowId])).toBe(true)
100
+
101
+    state.send('STOPPED_POINTING', inputs.pointerUp(point(), arrowId))
102
+
103
+    expect(idsAreSelected(state.data, [arrowId])).toBe(true)
104
+  })
105
+
106
+  it('selects shapes if shift key is lifted before pointerup', () => {
107
+    state
108
+      .send('CANCELLED')
109
+      .send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId))
110
+      .send('STOPPED_POINTING', inputs.pointerUp(point(), rectangleId))
111
+      .send(
112
+        'POINTED_SHAPE',
113
+        inputs.pointerDown(point({ shiftKey: true }), arrowId)
114
+      )
115
+      .send(
116
+        'STOPPED_POINTING',
117
+        inputs.pointerUp(point({ shiftKey: true }), arrowId)
118
+      )
119
+      .send(
120
+        'POINTED_SHAPE',
121
+        inputs.pointerDown(point({ shiftKey: true }), arrowId)
122
+      )
123
+      .send('STOPPED_POINTING', inputs.pointerUp(point(), arrowId))
124
+
125
+    expect(idsAreSelected(state.data, [arrowId])).toBe(true)
126
+  })
127
+
128
+  it('does not select on meta-click', () => {
129
+    state
130
+      .send('CANCELLED')
131
+      .send(
132
+        'POINTED_SHAPE',
133
+        inputs.pointerDown(point({ ctrlKey: true }), rectangleId)
134
+      )
135
+      .send(
136
+        'STOPPED_POINTING',
137
+        inputs.pointerUp(point({ ctrlKey: true }), rectangleId)
138
+      )
139
+
140
+    expect(idsAreSelected(state.data, [])).toBe(true)
141
+  })
142
+})

+ 18
- 3
__tests__/test-utils.ts ファイルの表示

@@ -1,10 +1,13 @@
1
+import { Data } from 'types'
2
+import { getSelectedIds } from 'utils/utils'
3
+
1 4
 interface PointerOptions {
2 5
   id?: string
3 6
   x?: number
4 7
   y?: number
5 8
   shiftKey?: boolean
6 9
   altKey?: boolean
7
-  metaKey?: boolean
10
+  ctrlKey?: boolean
8 11
 }
9 12
 
10 13
 export function point(
@@ -16,15 +19,27 @@ export function point(
16 19
     y = 0,
17 20
     shiftKey = false,
18 21
     altKey = false,
19
-    metaKey = false,
22
+    ctrlKey = false,
20 23
   } = options
21 24
 
22 25
   return {
23 26
     shiftKey,
24 27
     altKey,
25
-    metaKey,
28
+    ctrlKey,
26 29
     pointerId: id,
27 30
     clientX: x,
28 31
     clientY: y,
29 32
   } as any
30 33
 }
34
+
35
+export function idsAreSelected(
36
+  data: Data,
37
+  ids: string[],
38
+  strict = true
39
+): boolean {
40
+  const selectedIds = getSelectedIds(data)
41
+  return (
42
+    (strict ? selectedIds.size === ids.length : true) &&
43
+    ids.every((id) => selectedIds.has(id))
44
+  )
45
+}

+ 1
- 1
components/canvas/bounds/bounds-bg.tsx ファイルの表示

@@ -21,7 +21,7 @@ function handlePointerUp(e: React.PointerEvent<SVGRectElement>) {
21 21
   if (!inputs.canAccept(e.pointerId)) return
22 22
   e.stopPropagation()
23 23
   e.currentTarget.releasePointerCapture(e.pointerId)
24
-  state.send('STOPPED_POINTING', inputs.pointerUp(e))
24
+  state.send('STOPPED_POINTING', inputs.pointerUp(e, 'bounds'))
25 25
 }
26 26
 
27 27
 export default function BoundsBg(): JSX.Element {

+ 1
- 1
components/canvas/defs.tsx ファイルの表示

@@ -39,6 +39,6 @@ const Def = memo(function Def({ id }: { id: string }) {
39 39
 
40 40
   return React.cloneElement(
41 41
     getShapeUtils(shape).render(shape, { isEditing: false }),
42
-    { ...style }
42
+    { id, ...style }
43 43
   )
44 44
 })

+ 9
- 7
components/canvas/shape.tsx ファイルの表示

@@ -2,7 +2,7 @@ import React, { useRef, memo, useEffect } from 'react'
2 2
 import { useSelector } from 'state'
3 3
 import styled from 'styles'
4 4
 import { getShapeUtils } from 'state/shape-utils'
5
-import { getPage, isMobile } from 'utils/utils'
5
+import { getPage, getSelectedIds, isMobile } from 'utils/utils'
6 6
 import { Shape as _Shape } from 'types'
7 7
 import useShapeEvents from 'hooks/useShapeEvents'
8 8
 import vec from 'utils/vec'
@@ -22,6 +22,8 @@ function Shape({ id, isSelecting, parentPoint }: ShapeProps): JSX.Element {
22 22
 
23 23
   const isEditing = useSelector((s) => s.data.editingId === id)
24 24
 
25
+  const isSelected = useSelector((s) => getSelectedIds(s.data).has(id))
26
+
25 27
   const shape = useSelector((s) => getPage(s.data).shapes[id])
26 28
 
27 29
   const events = useShapeEvents(id, getShapeUtils(shape)?.isParent, rGroup)
@@ -62,9 +64,11 @@ function Shape({ id, isSelecting, parentPoint }: ShapeProps): JSX.Element {
62 64
       id={id + '-group'}
63 65
       ref={rGroup}
64 66
       transform={transform}
67
+      isSelected={isSelected}
65 68
       device={isMobileDevice ? 'mobile' : 'desktop'}
69
+      {...events}
66 70
     >
67
-      {isSelecting && !isShy && (
71
+      {!isShy && (
68 72
         <>
69 73
           {isForeignObject ? (
70 74
             <HoverIndicator
@@ -73,15 +77,13 @@ function Shape({ id, isSelecting, parentPoint }: ShapeProps): JSX.Element {
73 77
               height={bounds.height}
74 78
               strokeWidth={1.5}
75 79
               variant={'ghost'}
76
-              {...events}
77 80
             />
78 81
           ) : (
79 82
             <HoverIndicator
80 83
               as="use"
81 84
               href={'#' + id}
82
-              strokeWidth={+style.strokeWidth + 4}
85
+              strokeWidth={+style.strokeWidth + 5}
83 86
               variant={getShapeUtils(shape).canStyleFill ? 'filled' : 'hollow'}
84
-              {...events}
85 87
             />
86 88
           )}
87 89
         </>
@@ -201,10 +203,10 @@ const StyledGroup = styled('g', {
201 203
       isSelected: 'true',
202 204
       css: {
203 205
         [`&:hover ${HoverIndicator}`]: {
204
-          opacity: '0.3',
206
+          opacity: '0.25',
205 207
         },
206 208
         [`&:active ${HoverIndicator}`]: {
207
-          opacity: '0.3',
209
+          opacity: '0.25',
208 210
         },
209 211
       },
210 212
     },

+ 1
- 1
hooks/useBoundsEvents.ts ファイルの表示

@@ -50,7 +50,7 @@ export default function useBoundsEvents(handle: Edge | Corner | 'rotate') {
50 50
     e.stopPropagation()
51 51
     e.currentTarget.releasePointerCapture(e.pointerId)
52 52
     e.currentTarget.replaceWith(e.currentTarget)
53
-    state.send('STOPPED_POINTING', inputs.pointerUp(e))
53
+    state.send('STOPPED_POINTING', inputs.pointerUp(e, 'bounds'))
54 54
   }, [])
55 55
 
56 56
   return { onPointerDown, onPointerMove, onPointerUp }

+ 7
- 1
hooks/useCanvasEvents.ts ファイルの表示

@@ -54,8 +54,14 @@ export default function useCanvasEvents(
54 54
 
55 55
   const handlePointerUp = useCallback((e: React.PointerEvent) => {
56 56
     if (!inputs.canAccept(e.pointerId)) return
57
+    e.stopPropagation()
58
+
57 59
     rCanvas.current.releasePointerCapture(e.pointerId)
58
-    state.send('STOPPED_POINTING', { id: 'canvas', ...inputs.pointerUp(e) })
60
+
61
+    state.send('STOPPED_POINTING', {
62
+      id: 'canvas',
63
+      ...inputs.pointerUp(e, 'canvas'),
64
+    })
59 65
   }, [])
60 66
 
61 67
   const handleTouchStart = useCallback(() => {

+ 1
- 1
hooks/useHandleEvents.ts ファイルの表示

@@ -30,7 +30,7 @@ export default function useHandleEvents(
30 30
       if (isDoubleClick && !(info.altKey || info.metaKey)) {
31 31
         state.send('DOUBLE_POINTED_HANDLE', info)
32 32
       } else {
33
-        state.send('STOPPED_POINTING', inputs.pointerUp(e))
33
+        state.send('STOPPED_POINTING', inputs.pointerUp(e, id))
34 34
       }
35 35
     },
36 36
     [id]

+ 1
- 1
hooks/useShapeEvents.ts ファイルの表示

@@ -35,7 +35,7 @@ export default function useShapeEvents(
35 35
       if (!inputs.canAccept(e.pointerId)) return
36 36
       e.stopPropagation()
37 37
       rGroup.current.releasePointerCapture(e.pointerId)
38
-      state.send('STOPPED_POINTING', inputs.pointerUp(e))
38
+      state.send('STOPPED_POINTING', inputs.pointerUp(e, id))
39 39
     },
40 40
     [id]
41 41
   )

+ 8
- 8
state/shape-utils/rectangle.tsx ファイルの表示

@@ -70,14 +70,14 @@ const rectangle = registerShapeUtils<RectangleShape>({
70 70
 
71 71
     const sw = strokeWidth * 1.618
72 72
 
73
-    const w = Math.max(0, size[0])
74
-    const h = Math.max(0, size[1])
73
+    const w = Math.max(0, size[0] - sw / 2)
74
+    const h = Math.max(0, size[1] - sw / 2)
75 75
 
76 76
     const strokes: [number[], number[], number][] = [
77
-      [[sw / 2, sw / 2], [w - sw, sw / 2], w - sw],
78
-      [[w - sw / 2, sw / 2], [w - sw / 2, h - sw / 2], h - sw],
79
-      [[w - sw / 2, h - sw / 2], [sw / 2, h - sw / 2], w - sw],
80
-      [[sw / 2, h - sw / 2], [sw / 2, sw / 2], h - sw],
77
+      [[sw / 2, sw / 2], [w, sw / 2], w - sw / 2],
78
+      [[w, sw / 2], [w, h], h - sw / 2],
79
+      [[w, h], [sw / 2, h], w - sw / 2],
80
+      [[sw / 2, h], [sw / 2, sw / 2], h - sw / 2],
81 81
     ]
82 82
 
83 83
     const paths = strokes.map(([start, end, length], i) => {
@@ -108,8 +108,8 @@ const rectangle = registerShapeUtils<RectangleShape>({
108 108
         <rect
109 109
           x={sw / 2}
110 110
           y={sw / 2}
111
-          width={size[0] - sw}
112
-          height={size[1] - sw}
111
+          width={w}
112
+          height={h}
113 113
           fill={styles.fill}
114 114
           stroke="none"
115 115
         />

+ 17
- 9
state/state.ts ファイルの表示

@@ -229,6 +229,7 @@ const state = createState({
229 229
           initial: 'notPointing',
230 230
           states: {
231 231
             notPointing: {
232
+              onEnter: 'clearPointedId',
232 233
               on: {
233 234
                 CANCELLED: 'clearSelectedIds',
234 235
                 STARTED_PINCHING: { to: 'pinching' },
@@ -282,7 +283,7 @@ const state = createState({
282 283
                     unless: 'isPointedShapeSelected',
283 284
                     then: {
284 285
                       if: 'isPressingShiftKey',
285
-                      do: 'pushPointedIdToSelectedIds',
286
+                      do: ['pushPointedIdToSelectedIds', 'clearPointedId'],
286 287
                       else: ['clearSelectedIds', 'pushPointedIdToSelectedIds'],
287 288
                     },
288 289
                   },
@@ -334,6 +335,7 @@ const state = createState({
334 335
             },
335 336
             pointingBounds: {
336 337
               on: {
338
+                CANCELLED: { to: 'notPointing' },
337 339
                 STOPPED_POINTING_BOUNDS: [],
338 340
                 STOPPED_POINTING: [
339 341
                   {
@@ -342,15 +344,17 @@ const state = createState({
342 344
                   },
343 345
                   {
344 346
                     if: 'isPressingShiftKey',
345
-                    then: [
346
-                      {
347
-                        if: 'isPointedShapeSelected',
348
-                        do: 'pullPointedIdFromSelectedIds',
349
-                      },
350
-                    ],
347
+                    then: {
348
+                      if: 'isPointedShapeSelected',
349
+                      do: 'pullPointedIdFromSelectedIds',
350
+                    },
351 351
                     else: {
352
-                      unless: 'isPointingBounds',
353
-                      do: ['clearSelectedIds', 'pushPointedIdToSelectedIds'],
352
+                      if: 'isPointingShape',
353
+                      do: [
354
+                        'clearSelectedIds',
355
+                        'setPointedId',
356
+                        'pushPointedIdToSelectedIds',
357
+                      ],
354 358
                     },
355 359
                   },
356 360
                   { to: 'notPointing' },
@@ -915,6 +919,9 @@ const state = createState({
915 919
         screenToWorld(payload.point, data)
916 920
       )
917 921
     },
922
+    hasPointedId(data, payload: PointerInfo) {
923
+      return getShape(data, payload.target) !== undefined
924
+    },
918 925
     isPointingRotationHandle(
919 926
       data,
920 927
       payload: { target: Edge | Corner | 'rotate' }
@@ -1743,6 +1750,7 @@ function getParentId(data: Data, id: string) {
1743 1750
 
1744 1751
 function getPointedId(data: Data, id: string) {
1745 1752
   const shape = getPage(data).shapes[id]
1753
+  if (!shape) return id
1746 1754
 
1747 1755
   return shape.parentId === data.currentParentId ||
1748 1756
     shape.parentId === data.currentPageId

読み込み中…
キャンセル
保存