瀏覽代碼

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
-import * as json from './__mocks__/document.json'
2
 import state from 'state'
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
 describe('project', () => {
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 查看文件

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
+import { Data } from 'types'
2
+import { getSelectedIds } from 'utils/utils'
3
+
1
 interface PointerOptions {
4
 interface PointerOptions {
2
   id?: string
5
   id?: string
3
   x?: number
6
   x?: number
4
   y?: number
7
   y?: number
5
   shiftKey?: boolean
8
   shiftKey?: boolean
6
   altKey?: boolean
9
   altKey?: boolean
7
-  metaKey?: boolean
10
+  ctrlKey?: boolean
8
 }
11
 }
9
 
12
 
10
 export function point(
13
 export function point(
16
     y = 0,
19
     y = 0,
17
     shiftKey = false,
20
     shiftKey = false,
18
     altKey = false,
21
     altKey = false,
19
-    metaKey = false,
22
+    ctrlKey = false,
20
   } = options
23
   } = options
21
 
24
 
22
   return {
25
   return {
23
     shiftKey,
26
     shiftKey,
24
     altKey,
27
     altKey,
25
-    metaKey,
28
+    ctrlKey,
26
     pointerId: id,
29
     pointerId: id,
27
     clientX: x,
30
     clientX: x,
28
     clientY: y,
31
     clientY: y,
29
   } as any
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
   if (!inputs.canAccept(e.pointerId)) return
21
   if (!inputs.canAccept(e.pointerId)) return
22
   e.stopPropagation()
22
   e.stopPropagation()
23
   e.currentTarget.releasePointerCapture(e.pointerId)
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
 export default function BoundsBg(): JSX.Element {
27
 export default function BoundsBg(): JSX.Element {

+ 1
- 1
components/canvas/defs.tsx 查看文件

39
 
39
 
40
   return React.cloneElement(
40
   return React.cloneElement(
41
     getShapeUtils(shape).render(shape, { isEditing: false }),
41
     getShapeUtils(shape).render(shape, { isEditing: false }),
42
-    { ...style }
42
+    { id, ...style }
43
   )
43
   )
44
 })
44
 })

+ 9
- 7
components/canvas/shape.tsx 查看文件

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

+ 1
- 1
hooks/useBoundsEvents.ts 查看文件

50
     e.stopPropagation()
50
     e.stopPropagation()
51
     e.currentTarget.releasePointerCapture(e.pointerId)
51
     e.currentTarget.releasePointerCapture(e.pointerId)
52
     e.currentTarget.replaceWith(e.currentTarget)
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
   return { onPointerDown, onPointerMove, onPointerUp }
56
   return { onPointerDown, onPointerMove, onPointerUp }

+ 7
- 1
hooks/useCanvasEvents.ts 查看文件

54
 
54
 
55
   const handlePointerUp = useCallback((e: React.PointerEvent) => {
55
   const handlePointerUp = useCallback((e: React.PointerEvent) => {
56
     if (!inputs.canAccept(e.pointerId)) return
56
     if (!inputs.canAccept(e.pointerId)) return
57
+    e.stopPropagation()
58
+
57
     rCanvas.current.releasePointerCapture(e.pointerId)
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
   const handleTouchStart = useCallback(() => {
67
   const handleTouchStart = useCallback(() => {

+ 1
- 1
hooks/useHandleEvents.ts 查看文件

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

+ 1
- 1
hooks/useShapeEvents.ts 查看文件

35
       if (!inputs.canAccept(e.pointerId)) return
35
       if (!inputs.canAccept(e.pointerId)) return
36
       e.stopPropagation()
36
       e.stopPropagation()
37
       rGroup.current.releasePointerCapture(e.pointerId)
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
     [id]
40
     [id]
41
   )
41
   )

+ 8
- 8
state/shape-utils/rectangle.tsx 查看文件

70
 
70
 
71
     const sw = strokeWidth * 1.618
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
     const strokes: [number[], number[], number][] = [
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
     const paths = strokes.map(([start, end, length], i) => {
83
     const paths = strokes.map(([start, end, length], i) => {
108
         <rect
108
         <rect
109
           x={sw / 2}
109
           x={sw / 2}
110
           y={sw / 2}
110
           y={sw / 2}
111
-          width={size[0] - sw}
112
-          height={size[1] - sw}
111
+          width={w}
112
+          height={h}
113
           fill={styles.fill}
113
           fill={styles.fill}
114
           stroke="none"
114
           stroke="none"
115
         />
115
         />

+ 17
- 9
state/state.ts 查看文件

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

Loading…
取消
儲存