Updates cursor logic

This commit is contained in:
Steve Ruiz 2021-06-30 21:31:29 +01:00
parent 467424d93e
commit 49baa47a0e
17 changed files with 377 additions and 100 deletions

View file

@ -1,19 +1,18 @@
import styled from 'styles'
import { ErrorBoundary } from 'react-error-boundary'
import state, { useSelector } from 'state'
import React, { useRef } from 'react'
import useZoomEvents from 'hooks/useZoomEvents'
import useCamera from 'hooks/useCamera'
import Defs from './defs'
import Page from './page'
import Brush from './brush'
import Cursor from './cursor'
import Bounds from './bounds/bounding-box'
import BoundsBg from './bounds/bounds-bg'
import Handles from './bounds/handles'
import useCanvasEvents from 'hooks/useCanvasEvents'
import Brush from './brush'
import ContextMenu from './context-menu/context-menu'
import { deepCompareArrays } from 'utils'
import Coop from './coop/coop'
import Defs from './defs'
import Handles from './bounds/handles'
import Page from './page'
import React, { useRef } from 'react'
import state, { useSelector } from 'state'
import styled from 'styles'
import useCamera from 'hooks/useCamera'
import useCanvasEvents from 'hooks/useCanvasEvents'
import useZoomEvents from 'hooks/useZoomEvents'
function resetError() {
null
@ -40,10 +39,10 @@ export default function Canvas(): JSX.Element {
<g ref={rGroup} id="shapes">
<BoundsBg />
<Page />
<Coop />
<Bounds />
<Handles />
<Brush />
<Peers />
</g>
)}
</ErrorBoundary>
@ -52,32 +51,6 @@ export default function Canvas(): JSX.Element {
)
}
function Peers(): JSX.Element {
const peerIds = useSelector((s) => {
return s.data.room ? Object.keys(s.data.room?.peers) : []
}, deepCompareArrays)
return (
<>
{peerIds.map((id) => (
<Peer key={id} id={id} />
))}
</>
)
}
function Peer({ id }: { id: string }): JSX.Element {
const hasPeer = useSelector((s) => {
return s.data.room && s.data.room.peers[id] !== undefined
})
const point = useSelector(
(s) => hasPeer && s.data.room.peers[id].cursor.point
)
return <Cursor point={point} />
}
const MainSVG = styled('svg', {
position: 'fixed',
overflow: 'hidden',

View file

@ -0,0 +1,27 @@
import Cursor from './cursor'
import { useCoopSelector } from 'state/coop/coop-state'
export default function Presence(): JSX.Element {
const others = useCoopSelector((s) => s.data.others)
return (
<>
{Object.values(others).map(({ connectionId, presence }) => {
if (presence == null) {
return null
}
return (
<Cursor
key={`cursor-${connectionId}`}
color={'red'}
duration={presence.duration}
times={presence.times}
bufferedXs={presence.bufferedXs}
bufferedYs={presence.bufferedYs}
/>
)
})}
</>
)
}

View file

@ -1,25 +1,45 @@
import React from 'react'
import styled from 'styles'
import { motion } from 'framer-motion'
// const transition = {
// type: 'spring',
// mass: 2,
// damping: 20,
// }
export default function Cursor({
color = 'dodgerblue',
point = [0, 0],
duration = 0,
bufferedXs = [],
bufferedYs = [],
times = [],
}: {
color?: string
point: number[]
color: string
duration: number
bufferedXs: number[]
bufferedYs: number[]
times: number[]
}): JSX.Element {
const transform = `translate(${point[0] - 12} ${point[1] - 10})`
return (
<StyledCursor
color={color}
transform={transform}
initial={false}
animate={{
x: bufferedXs,
y: bufferedYs,
transition: {
type: 'tween',
ease: 'linear',
duration,
times,
},
}}
width="35px"
height="35px"
viewBox="0 0 35 35"
version="1.1"
pointerEvents="none"
opacity="0"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
>
@ -43,7 +63,7 @@ export default function Cursor({
)
}
const StyledCursor = styled('g', {
const StyledCursor = styled(motion.g, {
position: 'absolute',
zIndex: 1000,
top: 0,

View file

@ -1,11 +1,14 @@
import { useStateDesigner } from '@state-designer/react'
import state from 'state'
import { useCoopSelector } from 'state/coop/coop-state'
import styled from 'styles'
const size: any = { '@sm': 'small' }
export default function StatusBar(): JSX.Element {
const local = useStateDesigner(state)
const status = useCoopSelector((s) => s.data.status)
const others = useCoopSelector((s) => s.data.others)
const active = local.active.slice(1).map((s) => {
const states = s.split('.')
@ -17,7 +20,8 @@ export default function StatusBar(): JSX.Element {
return (
<StatusBarContainer size={size}>
<Section>
{active.join(' | ')} | {log} | {local.data.room?.status}
{active.join(' | ')} | {log} | {status} (
{Object.values(others).length || 0})
</Section>
</StatusBarContainer>
)

View file

@ -31,22 +31,12 @@ export default function useCanvasEvents(
if (state.isIn('draw.editing')) {
fastDrawUpdate(info)
return
}
if (state.isIn('brushSelecting')) {
} else if (state.isIn('brushSelecting')) {
fastBrushSelect(info.point)
return
}
if (state.isIn('translatingSelection')) {
} else if (state.isIn('translatingSelection')) {
fastTranslate(info)
return
}
if (state.isIn('transformingSelection')) {
} else if (state.isIn('transformingSelection')) {
fastTransform(info)
return
}
state.send('MOVED_POINTER', info)

View file

@ -29,6 +29,9 @@
]
},
"dependencies": {
"@liveblocks/client": "^0.8.1",
"@liveblocks/node": "^0.3.0",
"@liveblocks/react": "^0.8.0",
"@monaco-editor/react": "^4.2.1",
"@radix-ui/react-checkbox": "^0.0.16",
"@radix-ui/react-context-menu": "^0.0.22",

View file

@ -0,0 +1,48 @@
import { authorize } from '@liveblocks/node'
import { NextApiHandler, NextApiRequest, NextApiResponse } from 'next'
const API_KEY = process.env.LIVEBLOCKS_SECRET_KEY
const Auth: NextApiHandler = async (
req: NextApiRequest,
res: NextApiResponse
) => {
if (!API_KEY) {
return res.status(403).end()
}
const room = req.body.room
if (room === 'example-live-cursors-avatars') {
const response = await authorize({
room,
secret: API_KEY,
userInfo: {
name: NAMES[Math.floor(Math.random() * NAMES.length)],
picture: `/assets/avatars/${Math.floor(Math.random() * 10)}.png`,
},
})
return res.status(response.status).end(response.body)
}
const response = await authorize({
room,
secret: API_KEY,
})
return res.status(response.status).end(response.body)
}
export default Auth
const NAMES = [
'Charlie Layne',
'Mislav Abha',
'Tatum Paolo',
'Anjali Wanda',
'Jody Hekla',
'Emil Joyce',
'Jory Quispe',
'Quinn Elton',
]

0
pages/api/nodes.md Normal file
View file

View file

@ -1,10 +1,18 @@
import dynamic from 'next/dynamic'
import { GetServerSideProps } from 'next'
import { getSession } from 'next-auth/client'
import { useEffect } from 'react'
import coopState from 'state/coop/coop-state'
const Editor = dynamic(() => import('components/editor'), { ssr: false })
export default function Room({ id }: { id: string }): JSX.Element {
useEffect(() => {
return () => {
coopState.send('LEFT_ROOM')
}
}, [])
return <Editor roomId={id} />
}

View file

@ -0,0 +1,131 @@
/* eslint-disable prefer-const */
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Client, Room, createClient } from '@liveblocks/client'
import coopState from './coop-state'
import { CoopPresence } from 'types'
import {
ConnectionCallback,
MyPresenceCallback,
OthersEventCallback,
} from '@liveblocks/client/lib/cjs/types'
import { uniqueId } from 'utils'
class CoopClient {
id = uniqueId()
roomId: string
lastCursorEventTime = 0
client: Client
room: Room
bufferedXs: number[] = []
bufferedYs: number[] = []
bufferedTs: number[] = []
constructor() {
this.client = createClient({
authEndpoint: '/api/auth-liveblocks',
})
}
private handleConnectionEvent: ConnectionCallback = (status) => {
coopState.send('CHANGED_CONNECTION_STATUS', { status })
}
private handleMyPresenceEvent: MyPresenceCallback<CoopPresence> = () => {
null
}
private handleOthersEvent: OthersEventCallback<CoopPresence> = (_, event) => {
switch (event.type) {
case 'enter': {
coopState.send('OTHER_USER_ENTERED', event)
break
}
case 'leave': {
coopState.send('OTHER_USER_LEFT', event)
break
}
case 'update': {
coopState.send('OTHER_USER_UPDATED', event)
break
}
case 'reset': {
coopState.send('RESET_OTHER_USERS', event)
break
}
}
}
connect(roomId: string) {
if (this.roomId) {
this.client.leave(this.roomId)
}
this.roomId = roomId
this.room = this.client.enter(roomId, { cursor: null })
this.room.subscribe('connection', this.handleConnectionEvent)
this.room.subscribe('my-presence', this.handleMyPresenceEvent)
this.room.subscribe('others', this.handleOthersEvent)
coopState.send('JOINED_ROOM', { others: this.room.getOthers().toArray() })
}
disconnect() {
this.room.unsubscribe('connection', this.handleConnectionEvent)
this.room.unsubscribe('my-presence', this.handleMyPresenceEvent)
this.room.unsubscribe('others', this.handleOthersEvent)
this.client.leave(this.roomId)
}
reconnect() {
this.connect(this.roomId)
}
moveCursor(pageId: string, point: number[]) {
if (!this.room) return
const now = Date.now()
let elapsed = now - this.lastCursorEventTime
if (elapsed > 200) {
// The animation's total duration (in seconds)
const duration = this.bufferedTs[this.bufferedTs.length - 1]
// Normalized times (0 - 1)
const times = this.bufferedTs.map((t) => t / duration)
// Make sure the array includes both a 0 and a 1
if (times.length === 1) {
this.bufferedXs.unshift(this.bufferedXs[0])
this.bufferedYs.unshift(this.bufferedYs[0])
times.unshift(0)
}
// Send the event to the service
this.room.updatePresence<CoopPresence>({
bufferedXs: this.bufferedXs,
bufferedYs: this.bufferedYs,
times,
duration,
})
// Reset data for next update
this.lastCursorEventTime = now
this.bufferedXs = []
this.bufferedYs = []
this.bufferedTs = []
elapsed = 0
}
this.bufferedXs.push(point[0])
this.bufferedYs.push(point[1])
this.bufferedTs.push(elapsed / 1000)
}
clearCursor() {
this.room.updatePresence({ cursor: null })
}
}
export default new CoopClient()

64
state/coop/coop-state.ts Normal file
View file

@ -0,0 +1,64 @@
import { createSelectorHook, createState } from '@state-designer/react'
import { CoopPresence } from 'types'
import { User } from '@liveblocks/client'
import client from 'state/coop/client-liveblocks'
type ConnectionState =
| 'closed'
| 'authenticating'
| 'unavailable'
| 'failed'
| 'open'
| 'connecting'
const coopState = createState({
data: {
status: 'closed' as ConnectionState,
others: {} as Record<string, User<CoopPresence>>,
},
on: {
JOINED_ROOM: 'setOthers',
LEFT_ROOM: 'disconnectFromRoom',
CHANGED_CONNECTION_STATUS: 'setStatus',
OTHER_USER_ENTERED: 'addOtherUser',
OTHER_USER_LEFT: 'removeOtherUser',
OTHER_USER_UPDATED: 'updateOtherUser',
RESET_OTHER_USERS: 'resetOtherUsers',
},
actions: {
connectToRoom(data, payload: { id: string }) {
client.connect(payload.id)
},
disconnectFromRoom() {
client.disconnect()
},
setStatus(data, payload: { status: ConnectionState }) {
data.status = payload.status
},
setOthers(data, payload: { others: User<CoopPresence>[] }) {
const { others } = payload
data.others = Object.fromEntries(
others.map((user) => [user.connectionId, user])
)
},
addOtherUser(data, payload: { user: User<CoopPresence> }) {
const { user } = payload
data.others[user.connectionId] = user
},
removeOtherUser(data, payload: { user: User<CoopPresence> }) {
const { user } = payload
delete data.others[user.connectionId]
},
updateOtherUser(data, payload: { user: User<CoopPresence>; changes: any }) {
const { user } = payload
data.others[user.connectionId] = user
},
resetOtherUsers(data) {
data.others = {}
},
},
})
export const useCoopSelector = createSelectorHook(coopState)
export default coopState

View file

@ -3,6 +3,7 @@ import { deepClone, setToArray } from 'utils'
import tld from 'utils/tld'
import { freeze } from 'immer'
import session from './session'
import coopClient from 'state/coop/client-liveblocks'
import state from './state'
import vec from 'utils/vec'
import * as Session from './sessions'
@ -17,6 +18,8 @@ import * as Session from './sessions'
export function fastDrawUpdate(info: PointerInfo): void {
const data = { ...state.data }
coopClient.moveCursor(data.currentPageId, info.point)
session.update<Session.DrawSession>(
data,
tld.screenToWorld(info.point, data),

View file

@ -7,7 +7,7 @@ import history from './history'
import storage from './storage'
import clipboard from './clipboard'
import * as Sessions from './sessions'
import coopClient from './coop/client-pusher'
import coopClient from './coop/client-liveblocks'
import commands from './commands'
import {
getCommonBounds,
@ -163,18 +163,17 @@ const state = createState({
},
on: {
// Network-Related
// RT_LOADED_ROOM: [
// 'clearRoom',
// { if: 'hasRoom', do: ['clearDocument', 'connectToRoom'] },
// ],
RT_LOADED_ROOM: [
'clearRoom',
{ if: 'hasRoom', do: ['clearDocument', 'connectToRoom'] },
],
RT_CHANGED_STATUS: 'setRtStatus',
MOVED_POINTER: { secretlyDo: 'sendRtCursorMove' },
// RT_UNLOADED_ROOM: ['clearRoom', 'clearDocument'],
// RT_DISCONNECTED_ROOM: ['clearRoom', 'clearDocument'],
// RT_CREATED_SHAPE: 'addRtShape',
// RT_CHANGED_STATUS: 'setRtStatus',
// RT_DELETED_SHAPE: 'deleteRtShape',
// RT_EDITED_SHAPE: 'editRtShape',
// RT_MOVED_CURSOR: 'moveRtCursor',
// MOVED_POINTER: { secretlyDo: 'sendRtCursorMove' },
// Client
RESIZED_WINDOW: 'resetPageState',
RESET_PAGE: 'resetPage',
@ -554,7 +553,7 @@ const state = createState({
onEnter: 'startTransformSession',
onExit: 'completeSession',
on: {
// MOVED_POINTER: 'updateTransformSession', using hacks.fastTransform
// MOVED_POINTER: 'updateTransformSession', (see hacks)
PANNED_CAMERA: 'updateTransformSession',
PRESSED_SHIFT_KEY: 'keyUpdateTransformSession',
RELEASED_SHIFT_KEY: 'keyUpdateTransformSession',
@ -567,7 +566,7 @@ const state = createState({
onExit: 'completeSession',
on: {
STARTED_PINCHING: { to: 'pinching' },
MOVED_POINTER: 'updateTranslateSession',
// MOVED_POINTER: 'updateTranslateSession', (see hacks)
PANNED_CAMERA: 'updateTranslateSession',
PRESSED_SHIFT_KEY: 'keyUpdateTranslateSession',
RELEASED_SHIFT_KEY: 'keyUpdateTranslateSession',
@ -604,7 +603,7 @@ const state = createState({
'startBrushSession',
],
on: {
// MOVED_POINTER: 'updateBrushSession', using hacks.fastBrushSelect
// MOVED_POINTER: 'updateBrushSession', (see hacks)
PANNED_CAMERA: 'updateBrushSession',
STOPPED_POINTING: { to: 'selecting' },
STARTED_PINCHING: { to: 'pinching' },
@ -640,7 +639,7 @@ const state = createState({
},
pinching: {
on: {
// PINCHED: { do: 'pinchCamera' }, using hacks.fastPinchCamera
// PINCHED: { do: 'pinchCamera' }, (see hacks)
},
initial: 'selectPinching',
onExit: { secretlyDo: 'updateZoomCSS' },
@ -701,7 +700,7 @@ const state = createState({
},
PRESSED_SHIFT: 'keyUpdateDrawSession',
RELEASED_SHIFT: 'keyUpdateDrawSession',
// MOVED_POINTER: 'updateDrawSession',
// MOVED_POINTER: 'updateDrawSession', (see hacks)
PANNED_CAMERA: 'updateDrawSession',
},
},
@ -1164,23 +1163,6 @@ const state = createState({
const point = tld.screenToWorld(payload.point, data)
coopClient.moveCursor(data.currentPageId, point)
},
moveRtCursor(
data,
payload: { id: string; pageId: string; point: number[] }
) {
const { room } = data
if (room.peers[payload.id] === undefined) {
room.peers[payload.id] = {
id: payload.id,
cursor: {
point: payload.point,
},
}
}
room.peers[payload.id].cursor.point = payload.point
},
clearRoom(data) {
data.room = undefined
},

View file

@ -17,7 +17,7 @@ export interface Data {
room?: {
id: string
status: string
peers: Record<string, Peer>
peers: Record<string, CoopPresence>
}
currentStyle: ShapeStyles
activeTool: ShapeType | 'select'
@ -38,11 +38,12 @@ export interface Data {
/* Document */
/* -------------------------------------------------- */
export interface Peer {
export type CoopPresence = {
id: string
cursor: {
point: number[]
}
bufferedXs: number[]
bufferedYs: number[]
times: number[]
duration: number
}
export interface TLDocument {

View file

@ -142,6 +142,7 @@ export default class ProjectUtils {
/**
* Get the next child index above a shape.
* TODO: Make work for grouped shapes, make faster.
* @param data
* @param id
*/
@ -158,9 +159,7 @@ export default class ProjectUtils {
const nextSibling = siblings[index + 1]
if (!nextSibling) {
return shape.childIndex + 1
}
if (!nextSibling) return shape.childIndex + 1
let nextIndex = (shape.childIndex + nextSibling.childIndex) / 2

View file

@ -1206,6 +1206,30 @@
"@types/yargs" "^16.0.0"
chalk "^4.0.0"
"@liveblocks/client@0.8.0":
version "0.8.0"
resolved "https://registry.yarnpkg.com/@liveblocks/client/-/client-0.8.0.tgz#b2cd1cc197d1ada76f4083d3a9065ee9f8fa1dc4"
integrity sha512-p7h7ZZpkyNjC/asdzjcZOzyTjINpQkgI5zrZGT7323VLXbn9ge/a+YL83N5sUosMtbjycfGLGHNN8fpSRgl7pA==
"@liveblocks/client@^0.8.1":
version "0.8.1"
resolved "https://registry.yarnpkg.com/@liveblocks/client/-/client-0.8.1.tgz#4220542c84473d71fb4032442e6a9861a5983ba3"
integrity sha512-+5LNtyOUA7RyxsK2uRupEZ6SzNhi1p9119fuWFrbrgP0dMabV40U7SVuvMnMxIsGzFqC+RoDQWEQ+iJFcuBVaQ==
"@liveblocks/node@^0.3.0":
version "0.3.0"
resolved "https://registry.yarnpkg.com/@liveblocks/node/-/node-0.3.0.tgz#f22ff0c3415502af2baf22250431852e198b12e0"
integrity sha512-3IJ6uN3QU71z6WXiDM97wW17fVVvrG9zMy4G4PY3zYzmeRfMnA+KBSBT1uPvlfgWm2D3d6/HNIXWxhwyv7bkfw==
dependencies:
node-fetch "^2.6.1"
"@liveblocks/react@^0.8.0":
version "0.8.0"
resolved "https://registry.yarnpkg.com/@liveblocks/react/-/react-0.8.0.tgz#e538be3e0e3bcb5fe47e2f8ad5fcd2e9c379fc5d"
integrity sha512-eCCVOz15ldmeDIT0AB08ExQrBROeAig6EBI0hY9tUe5iehADDDSAJmrkujg0K/lwPvtvzjjrzGq1qW79x2ggkg==
dependencies:
"@liveblocks/client" "0.8.0"
"@monaco-editor/loader@^1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@monaco-editor/loader/-/loader-1.1.1.tgz#37db648c81a86946d0febd391de00df9c28a0a3d"