ソースを参照

cursors!

main
Steve Ruiz 3年前
コミット
80d2ba5f00
9個のファイルの変更179行の追加50行の削除
  1. 28
    0
      components/canvas/canvas.tsx
  2. 15
    28
      components/canvas/cursor.tsx
  3. 1
    1
      components/canvas/shape.tsx
  4. 1
    0
      package.json
  5. 33
    0
      pages/api/pusher-auth.ts
  6. 28
    15
      state/pusher/client.ts
  7. 30
    4
      state/state.ts
  8. 8
    0
      types.ts
  9. 35
    2
      yarn.lock

+ 28
- 0
components/canvas/canvas.tsx ファイルの表示

@@ -7,11 +7,13 @@ import useCamera from 'hooks/useCamera'
7 7
 import Defs from './defs'
8 8
 import Page from './page'
9 9
 import Brush from './brush'
10
+import Cursor from './cursor'
10 11
 import Bounds from './bounds/bounding-box'
11 12
 import BoundsBg from './bounds/bounds-bg'
12 13
 import Handles from './bounds/handles'
13 14
 import useCanvasEvents from 'hooks/useCanvasEvents'
14 15
 import ContextMenu from './context-menu/context-menu'
16
+import { deepCompareArrays } from 'utils'
15 17
 
16 18
 function resetError() {
17 19
   null
@@ -41,6 +43,7 @@ export default function Canvas(): JSX.Element {
41 43
               <Bounds />
42 44
               <Handles />
43 45
               <Brush />
46
+              <Peers />
44 47
             </g>
45 48
           )}
46 49
         </ErrorBoundary>
@@ -49,6 +52,31 @@ export default function Canvas(): JSX.Element {
49 52
   )
50 53
 }
51 54
 
55
+function Peers(): JSX.Element {
56
+  const peerIds = useSelector((s) => {
57
+    return s.data.room ? Object.keys(s.data.room?.peers) : []
58
+  }, deepCompareArrays)
59
+
60
+  return (
61
+    <>
62
+      {peerIds.map((id) => (
63
+        <Peer key={id} id={id} />
64
+      ))}
65
+    </>
66
+  )
67
+}
68
+
69
+function Peer({ id }: { id: string }): JSX.Element {
70
+  const hasPeer = useSelector((s) => {
71
+    return s.data.room && s.data.room.peers[id] !== undefined
72
+  })
73
+
74
+  const point = useSelector(
75
+    (s) => hasPeer && s.data.room.peers[id].cursor.point
76
+  )
77
+
78
+  return <Cursor point={point} />
79
+}
52 80
 const MainSVG = styled('svg', {
53 81
   position: 'fixed',
54 82
   overflow: 'hidden',

+ 15
- 28
components/canvas/cursor.tsx ファイルの表示

@@ -1,28 +1,19 @@
1
-import React, { useEffect, useRef } from 'react'
1
+import React from 'react'
2 2
 import styled from 'styles'
3 3
 
4
-export default function Cursor(): JSX.Element {
5
-  const rCursor = useRef<SVGSVGElement>(null)
6
-
7
-  useEffect(() => {
8
-    function updatePosition(e: PointerEvent) {
9
-      const cursor = rCursor.current
10
-
11
-      cursor.setAttribute(
12
-        'transform',
13
-        `translate(${e.clientX - 12} ${e.clientY - 10})`
14
-      )
15
-    }
16
-
17
-    document.body.addEventListener('pointermove', updatePosition)
18
-    return () => {
19
-      document.body.removeEventListener('pointermove', updatePosition)
20
-    }
21
-  }, [])
4
+export default function Cursor({
5
+  color = 'dodgerblue',
6
+  point = [0, 0],
7
+}: {
8
+  color?: string
9
+  point: number[]
10
+}): JSX.Element {
11
+  const transform = `translate(${point[0] - 12} ${point[1] - 10})`
22 12
 
23 13
   return (
24 14
     <StyledCursor
25
-      ref={rCursor}
15
+      color={color}
16
+      transform={transform}
26 17
       width="35px"
27 18
       height="35px"
28 19
       viewBox="0 0 35 35"
@@ -33,23 +24,19 @@ export default function Cursor(): JSX.Element {
33 24
     >
34 25
       <path
35 26
         d="M12,24.4219 L12,8.4069 L23.591,20.0259 L16.81,20.0259 L16.399,20.1499 L12,24.4219 Z"
36
-        id="point-border"
37
-        fill="#FFFFFF"
27
+        fill="#ffffff"
38 28
       />
39 29
       <path
40 30
         d="M21.0845,25.0962 L17.4795,26.6312 L12.7975,15.5422 L16.4835,13.9892 L21.0845,25.0962 Z"
41
-        id="stem-border"
42
-        fill="#FFFFFF"
31
+        fill="#ffffff"
43 32
       />
44 33
       <path
45 34
         d="M19.751,24.4155 L17.907,25.1895 L14.807,17.8155 L16.648,17.0405 L19.751,24.4155 Z"
46
-        id="stem"
47
-        fill="#000000"
35
+        fill="currentColor"
48 36
       />
49 37
       <path
50 38
         d="M13,10.814 L13,22.002 L15.969,19.136 L16.397,18.997 L21.165,18.997 L13,10.814 Z"
51
-        id="point"
52
-        fill="#000000"
39
+        fill="currentColor"
53 40
       />
54 41
     </StyledCursor>
55 42
   )

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

@@ -28,7 +28,7 @@ function Shape({ id, isSelecting }: ShapeProps): JSX.Element {
28 28
 
29 29
   const strokeWidth = useSelector((s) => {
30 30
     const shape = getShape(s.data, id)
31
-    const style = getShapeStyle(shape.style)
31
+    const style = getShapeStyle(shape?.style)
32 32
     return +style.strokeWidth
33 33
   })
34 34
 

+ 1
- 0
package.json ファイルの表示

@@ -57,6 +57,7 @@
57 57
     "next-auth": "^3.27.0",
58 58
     "next-pwa": "^5.2.21",
59 59
     "perfect-freehand": "^0.4.91",
60
+    "pusher": "^5.0.0",
60 61
     "pusher-js": "^7.0.3",
61 62
     "react": "^17.0.2",
62 63
     "react-dom": "^17.0.2",

+ 33
- 0
pages/api/pusher-auth.ts ファイルの表示

@@ -0,0 +1,33 @@
1
+import { NextApiHandler } from 'next'
2
+import Pusher from 'pusher'
3
+import { v4 as uuid } from 'uuid'
4
+
5
+const pusher = new Pusher({
6
+  key: '5dc87c88b8684bda655a',
7
+  appId: '1226484',
8
+  secret: process.env.PUSHER_SECRET,
9
+  cluster: 'eu',
10
+})
11
+
12
+const PusherAuth: NextApiHandler = (req, res) => {
13
+  try {
14
+    const { socket_id, channel_name } = req.body
15
+
16
+    const presenceData = {
17
+      user_id: uuid(),
18
+      user_info: { name: 'Anonymous' },
19
+    }
20
+
21
+    const auth = pusher.authenticate(
22
+      socket_id.toString(),
23
+      channel_name.toString(),
24
+      presenceData
25
+    )
26
+
27
+    return res.send(auth)
28
+  } catch (err) {
29
+    res.status(403).end()
30
+  }
31
+}
32
+
33
+export default PusherAuth

+ 28
- 15
state/pusher/client.ts ファイルの表示

@@ -2,19 +2,21 @@ import Pusher from 'pusher-js'
2 2
 import * as PusherTypes from 'pusher-js'
3 3
 import state from 'state/state'
4 4
 import { Shape } from 'types'
5
-import { v4 as uuid } from 'uuid'
6 5
 
7 6
 class RoomClient {
8 7
   room: string
9 8
   pusher: Pusher
10
-  channel: PusherTypes.Channel
9
+  channel: PusherTypes.PresenceChannel
11 10
   lastCursorEventTime = 0
12
-  id = uuid()
11
+  id: string
13 12
 
14 13
   constructor() {
15 14
     // Create pusher instance and bind events
16 15
 
17
-    this.pusher = new Pusher('5dc87c88b8684bda655a', { cluster: 'eu' })
16
+    this.pusher = new Pusher('5dc87c88b8684bda655a', {
17
+      cluster: 'eu',
18
+      authEndpoint: 'http://localhost:3000/api/pusher-auth',
19
+    })
18 20
 
19 21
     this.pusher.connection.bind('connecting', () =>
20 22
       state.send('RT_CHANGED_STATUS', { status: 'connecting' })
@@ -28,33 +30,42 @@ class RoomClient {
28 30
       state.send('RT_CHANGED_STATUS', { status: 'unavailable' })
29 31
     )
30 32
 
31
-    this.pusher.connection.bind('failed', () =>
33
+    this.pusher.connection.bind('failed', () => {
32 34
       state.send('RT_CHANGED_STATUS', { status: 'failed' })
33
-    )
35
+    })
34 36
 
35
-    this.pusher.connection.bind('disconnected', () =>
37
+    this.pusher.connection.bind('disconnected', () => {
36 38
       state.send('RT_CHANGED_STATUS', { status: 'disconnected' })
37
-    )
39
+    })
38 40
   }
39 41
 
40
-  connect(room: string) {
41
-    this.room = room
42
+  connect(roomId: string) {
43
+    this.room = 'presence-' + roomId
42 44
 
43 45
     // Subscribe to channel
44 46
 
45
-    this.channel = this.pusher.subscribe(this.room)
47
+    this.channel = this.pusher.subscribe(
48
+      this.room
49
+    ) as PusherTypes.PresenceChannel
46 50
 
47
-    this.channel.bind('pusher:subscription_error', () => {
51
+    this.channel.bind('pusher:subscription_error', (err: string) => {
52
+      console.warn(err)
48 53
       state.send('RT_CHANGED_STATUS', { status: 'subscription-error' })
49 54
     })
50 55
 
51 56
     this.channel.bind('pusher:subscription_succeeded', () => {
57
+      const me = this.channel.members.me
58
+      const userId = me.id
59
+
60
+      this.id = userId
61
+
52 62
       state.send('RT_CHANGED_STATUS', { status: 'subscribed' })
53 63
     })
54 64
 
55 65
     this.channel.bind(
56 66
       'created_shape',
57 67
       (payload: { id: string; pageId: string; shape: Shape }) => {
68
+        if (payload.id === this.id) return
58 69
         state.send('RT_CREATED_SHAPE', payload)
59 70
       }
60 71
     )
@@ -62,6 +73,7 @@ class RoomClient {
62 73
     this.channel.bind(
63 74
       'deleted_shape',
64 75
       (payload: { id: string; pageId: string; shape: Shape }) => {
76
+        if (payload.id === this.id) return
65 77
         state.send('RT_DELETED_SHAPE', payload)
66 78
       }
67 79
     )
@@ -69,12 +81,13 @@ class RoomClient {
69 81
     this.channel.bind(
70 82
       'edited_shape',
71 83
       (payload: { id: string; pageId: string; change: Partial<Shape> }) => {
84
+        if (payload.id === this.id) return
72 85
         state.send('RT_EDITED_SHAPE', payload)
73 86
       }
74 87
     )
75 88
 
76 89
     this.channel.bind(
77
-      'moved_cursor',
90
+      'client-moved-cursor',
78 91
       (payload: { id: string; pageId: string; point: number[] }) => {
79 92
         if (payload.id === this.id) return
80 93
         state.send('RT_MOVED_CURSOR', payload)
@@ -95,10 +108,10 @@ class RoomClient {
95 108
 
96 109
     const now = Date.now()
97 110
 
98
-    if (now - this.lastCursorEventTime > 42) {
111
+    if (now - this.lastCursorEventTime > 200) {
99 112
       this.lastCursorEventTime = now
100 113
 
101
-      this.channel?.trigger('RT_MOVED_CURSOR', {
114
+      this.channel?.trigger('client-moved-cursor', {
102 115
         id: this.id,
103 116
         pageId,
104 117
         point,

+ 30
- 4
state/state.ts ファイルの表示

@@ -188,6 +188,7 @@ const state = createState({
188 188
         RT_EDITED_SHAPE: 'editRtShape',
189 189
         RT_MOVED_CURSOR: 'moveRtCursor',
190 190
         // Client
191
+        MOVED_POINTER: { secretlyDo: 'sendRtCursorMove' },
191 192
         RESIZED_WINDOW: 'resetPageState',
192 193
         RESET_PAGE: 'resetPage',
193 194
         TOGGLED_READ_ONLY: 'toggleReadOnly',
@@ -1145,10 +1146,16 @@ const state = createState({
1145 1146
     // Networked Room
1146 1147
     setRtStatus(data, payload: { id: string; status: string }) {
1147 1148
       const { status } = payload
1149
+
1148 1150
       if (!data.room) {
1149
-        data.room = { id: null, status: '' }
1151
+        data.room = {
1152
+          id: null,
1153
+          status: '',
1154
+          peers: {},
1155
+        }
1150 1156
       }
1151 1157
 
1158
+      data.room.peers = {}
1152 1159
       data.room.status = status
1153 1160
     },
1154 1161
     addRtShape(data, payload: { pageId: string; shape: Shape }) {
@@ -1166,8 +1173,26 @@ const state = createState({
1166 1173
       // What if the page is in storage?
1167 1174
       Object.assign(data.document[pageId].shapes[shape.id], shape)
1168 1175
     },
1169
-    moveRtCursor() {
1170
-      null
1176
+    sendRtCursorMove(data, payload: PointerInfo) {
1177
+      const point = screenToWorld(payload.point, data)
1178
+      pusher.moveCursor(data.currentPageId, point)
1179
+    },
1180
+    moveRtCursor(
1181
+      data,
1182
+      payload: { id: string; pageId: string; point: number[] }
1183
+    ) {
1184
+      const { room } = data
1185
+
1186
+      if (room.peers[payload.id] === undefined) {
1187
+        room.peers[payload.id] = {
1188
+          id: payload.id,
1189
+          cursor: {
1190
+            point: payload.point,
1191
+          },
1192
+        }
1193
+      }
1194
+
1195
+      room.peers[payload.id].cursor.point = payload.point
1171 1196
     },
1172 1197
     clearRoom(data) {
1173 1198
       data.room = undefined
@@ -1201,9 +1226,10 @@ const state = createState({
1201 1226
       }
1202 1227
     },
1203 1228
     connectToRoom(data, payload: { id: string }) {
1204
-      data.room = { id: payload.id, status: 'connecting' }
1229
+      data.room = { id: payload.id, status: 'connecting', peers: {} }
1205 1230
       pusher.connect(payload.id)
1206 1231
     },
1232
+
1207 1233
     resetPageState(data) {
1208 1234
       const pageState = data.pageStates[data.currentPageId]
1209 1235
       data.pageStates[data.currentPageId] = { ...pageState }

+ 8
- 0
types.ts ファイルの表示

@@ -17,6 +17,7 @@ export interface Data {
17 17
   room?: {
18 18
     id: string
19 19
     status: string
20
+    peers: Record<string, Peer>
20 21
   }
21 22
   currentStyle: ShapeStyles
22 23
   activeTool: ShapeType | 'select'
@@ -37,6 +38,13 @@ export interface Data {
37 38
 /*                      Document                      */
38 39
 /* -------------------------------------------------- */
39 40
 
41
+export interface Peer {
42
+  id: string
43
+  cursor: {
44
+    point: number[]
45
+  }
46
+}
47
+
40 48
 export interface TLDocument {
41 49
   id: string
42 50
   name: string

+ 35
- 2
yarn.lock ファイルの表示

@@ -2243,6 +2243,13 @@ abab@^2.0.3, abab@^2.0.5:
2243 2243
   resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.5.tgz#c0b678fb32d60fc1219c784d6a826fe385aeb79a"
2244 2244
   integrity sha512-9IK9EadsbHo6jLWIpxpR6pL0sazTXV6+SQv25ZB+F7Bj9mJNaOc4nCRabwd5M/JwmUa8idz6Eci6eKfJryPs6Q==
2245 2245
 
2246
+abort-controller@^3.0.0:
2247
+  version "3.0.0"
2248
+  resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392"
2249
+  integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==
2250
+  dependencies:
2251
+    event-target-shim "^5.0.0"
2252
+
2246 2253
 acorn-globals@^6.0.0:
2247 2254
   version "6.0.0"
2248 2255
   resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-6.0.0.tgz#46cdd39f0f8ff08a876619b55f5ac8a6dc770b45"
@@ -3831,6 +3838,11 @@ etag@1.8.1:
3831 3838
   resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
3832 3839
   integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=
3833 3840
 
3841
+event-target-shim@^5.0.0:
3842
+  version "5.0.1"
3843
+  resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789"
3844
+  integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==
3845
+
3834 3846
 events@^3.0.0:
3835 3847
   version "3.3.0"
3836 3848
   resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400"
@@ -4546,6 +4558,11 @@ is-arrayish@^0.2.1:
4546 4558
   resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
4547 4559
   integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=
4548 4560
 
4561
+is-base64@^1.1.0:
4562
+  version "1.1.0"
4563
+  resolved "https://registry.yarnpkg.com/is-base64/-/is-base64-1.1.0.tgz#8ce1d719895030a457c59a7dcaf39b66d99d56b4"
4564
+  integrity sha512-Nlhg7Z2dVC4/PTvIFkgVVNvPHSO2eR/Yd0XzhGiXCXEvWnptXlXa/clQ8aePPiMuxEGcWfzWbGw2Fe3d+Y3v1g==
4565
+
4549 4566
 is-bigint@^1.0.1:
4550 4567
   version "1.0.2"
4551 4568
   resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.2.tgz#ffb381442503235ad245ea89e45b3dbff040ee5a"
@@ -5971,7 +5988,7 @@ next@^11.0.1:
5971 5988
     vm-browserify "1.1.2"
5972 5989
     watchpack "2.1.1"
5973 5990
 
5974
-node-fetch@2.6.1, node-fetch@^2.6.0:
5991
+node-fetch@2.6.1, node-fetch@^2.6.0, node-fetch@^2.6.1:
5975 5992
   version "2.6.1"
5976 5993
   resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052"
5977 5994
   integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==
@@ -6658,6 +6675,17 @@ pusher-js@^7.0.3:
6658 6675
   dependencies:
6659 6676
     tweetnacl "^1.0.3"
6660 6677
 
6678
+pusher@^5.0.0:
6679
+  version "5.0.0"
6680
+  resolved "https://registry.yarnpkg.com/pusher/-/pusher-5.0.0.tgz#3dc39ff527637a4b4597652357b0ec562514c8e6"
6681
+  integrity sha512-YaSZHkukytHR9+lklJp4yefwfR4685kfS6pqrSDUxPj45Ga29lIgyN7Jcnsz+bN5WKwXaf2+4c/x/j3pzWIAkw==
6682
+  dependencies:
6683
+    abort-controller "^3.0.0"
6684
+    is-base64 "^1.1.0"
6685
+    node-fetch "^2.6.1"
6686
+    tweetnacl "^1.0.0"
6687
+    tweetnacl-util "^0.15.0"
6688
+
6661 6689
 querystring-es3@0.2.1, querystring-es3@^0.2.0:
6662 6690
   version "0.2.1"
6663 6691
   resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73"
@@ -7822,7 +7850,12 @@ tty-browserify@0.0.1:
7822 7850
   resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.1.tgz#3f05251ee17904dfd0677546670db9651682b811"
7823 7851
   integrity sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw==
7824 7852
 
7825
-tweetnacl@^1.0.3:
7853
+tweetnacl-util@^0.15.0:
7854
+  version "0.15.1"
7855
+  resolved "https://registry.yarnpkg.com/tweetnacl-util/-/tweetnacl-util-0.15.1.tgz#b80fcdb5c97bcc508be18c44a4be50f022eea00b"
7856
+  integrity sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==
7857
+
7858
+tweetnacl@^1.0.0, tweetnacl@^1.0.3:
7826 7859
   version "1.0.3"
7827 7860
   resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-1.0.3.tgz#ac0af71680458d8a6378d0d0d050ab1407d35596"
7828 7861
   integrity sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==

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