Stubs initial API for coop with Pusher

This commit is contained in:
Steve Ruiz 2021-06-28 21:45:06 +01:00
parent ad0aff9fea
commit 36fc386269
12 changed files with 301 additions and 29 deletions

View file

@ -1,5 +1,5 @@
import React, { useRef, memo, useEffect } from 'react' import React, { useRef, memo, useEffect, useState } from 'react'
import { useSelector } from 'state' import state, { useSelector } from 'state'
import styled from 'styles' import styled from 'styles'
import { getShapeUtils } from 'state/shape-utils' import { getShapeUtils } from 'state/shape-utils'
import { deepCompareArrays, getPage, getShape } from 'utils' import { deepCompareArrays, getPage, getShape } from 'utils'
@ -16,34 +16,30 @@ interface ShapeProps {
function Shape({ id, isSelecting }: ShapeProps): JSX.Element { function Shape({ id, isSelecting }: ShapeProps): JSX.Element {
const rGroup = useRef<SVGGElement>(null) const rGroup = useRef<SVGGElement>(null)
const shapeUtils = useSelector((s) => {
const shape = getShape(s.data, id)
return getShapeUtils(shape)
})
const isHidden = useSelector((s) => { const isHidden = useSelector((s) => {
const shape = getShape(s.data, id) const shape = getShape(s.data, id)
return shape.isHidden return shape?.isHidden || false
}) })
const children = useSelector((s) => { const children = useSelector((s) => {
const shape = getShape(s.data, id) const shape = getShape(s.data, id)
return shape.children return shape?.children || []
}, deepCompareArrays) }, deepCompareArrays)
const isParent = shapeUtils.isParent
const isForeignObject = shapeUtils.isForeignObject
const strokeWidth = useSelector((s) => { const strokeWidth = useSelector((s) => {
const shape = getShape(s.data, id) const shape = getShape(s.data, id)
const style = getShapeStyle(shape.style) const style = getShapeStyle(shape.style)
return +style.strokeWidth return +style.strokeWidth
}) })
const shapeUtils = useSelector((s) => {
const shape = getShape(s.data, id)
return getShapeUtils(shape)
})
const transform = useSelector((s) => { const transform = useSelector((s) => {
const shape = getShape(s.data, id) const shape = getShape(s.data, id)
const center = shapeUtils.getCenter(shape) const center = getShapeUtils(shape).getCenter(shape)
const rotation = shape.rotation * (180 / Math.PI) const rotation = shape.rotation * (180 / Math.PI)
const parentPoint = getShape(s.data, shape.parentId)?.point || [0, 0] const parentPoint = getShape(s.data, shape.parentId)?.point || [0, 0]
@ -54,7 +50,15 @@ function Shape({ id, isSelecting }: ShapeProps): JSX.Element {
` `
}) })
const events = useShapeEvents(id, isParent, rGroup) const events = useShapeEvents(id, shapeUtils?.isParent, rGroup)
const hasShape = useMissingShapeTest(id)
if (!hasShape) return null
const isParent = shapeUtils.isParent
const isForeignObject = shapeUtils.isForeignObject
return ( return (
<StyledGroup <StyledGroup
@ -192,3 +196,17 @@ const StyledGroup = styled('g', {
}) })
export default memo(Shape) export default memo(Shape)
function useMissingShapeTest(id: string) {
const [isShape, setIsShape] = useState(true)
useEffect(() => {
return state.onUpdate((s) => {
if (isShape && !getShape(s.data, id)) {
setIsShape(false)
}
})
}, [isShape, id])
return isShape
}

View file

@ -9,9 +9,9 @@ import PagePanel from './page-panel/page-panel'
import CodePanel from './code-panel/code-panel' import CodePanel from './code-panel/code-panel'
import ControlsPanel from './controls-panel/controls-panel' import ControlsPanel from './controls-panel/controls-panel'
export default function Editor(): JSX.Element { export default function Editor({ roomId }: { roomId?: string }): JSX.Element {
useKeyboardEvents() useKeyboardEvents()
useLoadOnMount() useLoadOnMount(roomId)
return ( return (
<Layout> <Layout>

View file

@ -2,6 +2,8 @@ import { useStateDesigner } from '@state-designer/react'
import state from 'state' import state from 'state'
import styled from 'styles' import styled from 'styles'
const size: any = { '@sm': 'small' }
export default function StatusBar(): JSX.Element { export default function StatusBar(): JSX.Element {
const local = useStateDesigner(state) const local = useStateDesigner(state)
@ -9,16 +11,13 @@ export default function StatusBar(): JSX.Element {
const states = s.split('.') const states = s.split('.')
return states[states.length - 1] return states[states.length - 1]
}) })
const log = local.log[0] const log = local.log[0]
return ( return (
<StatusBarContainer <StatusBarContainer size={size}>
size={{
'@sm': 'small',
}}
>
<Section> <Section>
{active.join(' | ')} | {log} {active.join(' | ')} | {log} | {local.data.room?.status}
</Section> </Section>
</StatusBarContainer> </StatusBarContainer>
) )

View file

@ -2,16 +2,18 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import state from 'state' import state from 'state'
export default function useLoadOnMount() { export default function useLoadOnMount(roomId: string) {
useEffect(() => { useEffect(() => {
const fonts = (document as any).fonts const fonts = (document as any).fonts
fonts fonts.load('12px Verveine Regular', 'Fonts are loaded!').then(() => {
.load('12px Verveine Regular', 'Fonts are loaded!') state.send('MOUNTED')
.then(() => state.send('MOUNTED')) state.send('RT_LOADED_ROOM', { id: roomId })
})
return () => { return () => {
state.send('UNMOUNTED') state.send('UNMOUNTED')
state.send('RT_UNLOADED_ROOM', { id: roomId })
} }
}, []) }, [roomId])
} }

View file

@ -57,6 +57,7 @@
"next-auth": "^3.27.0", "next-auth": "^3.27.0",
"next-pwa": "^5.2.21", "next-pwa": "^5.2.21",
"perfect-freehand": "^0.4.91", "perfect-freehand": "^0.4.91",
"pusher-js": "^7.0.3",
"react": "^17.0.2", "react": "^17.0.2",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-error-boundary": "^3.1.3", "react-error-boundary": "^3.1.3",

22
pages/room/[id].tsx Normal file
View file

@ -0,0 +1,22 @@
import dynamic from 'next/dynamic'
import { GetServerSideProps } from 'next'
import { getSession } from 'next-auth/client'
const Editor = dynamic(() => import('components/editor'), { ssr: false })
export default function Room({ id }: { id: string }): JSX.Element {
return <Editor roomId={id} />
}
export const getServerSideProps: GetServerSideProps = async (context) => {
const session = await getSession(context)
const { id } = context.query
return {
props: {
session,
id,
},
}
}

23
pages/room/index.tsx Normal file
View file

@ -0,0 +1,23 @@
import { GetServerSideProps } from 'next'
import { getSession } from 'next-auth/client'
import { v4 as uuid } from 'uuid'
export default function Home(): JSX.Element {
return <div>You should not see this one</div>
}
export const getServerSideProps: GetServerSideProps = async (context) => {
const session = await getSession(context)
if (!session?.user) {
context.res.setHeader('Location', `/sponsorware`)
context.res.statusCode = 307
}
context.res.setHeader('Location', `/room/${uuid()}`)
context.res.statusCode = 307
return {
props: {},
}
}

110
state/pusher/client.ts Normal file
View file

@ -0,0 +1,110 @@
import Pusher from 'pusher-js'
import * as PusherTypes from 'pusher-js'
import state from 'state/state'
import { Shape } from 'types'
import { v4 as uuid } from 'uuid'
class RoomClient {
room: string
pusher: Pusher
channel: PusherTypes.Channel
lastCursorEventTime = 0
id = uuid()
constructor() {
// Create pusher instance and bind events
this.pusher = new Pusher('5dc87c88b8684bda655a', { cluster: 'eu' })
this.pusher.connection.bind('connecting', () =>
state.send('RT_CHANGED_STATUS', { status: 'connecting' })
)
this.pusher.connection.bind('connected', () =>
state.send('RT_CHANGED_STATUS', { status: 'connected' })
)
this.pusher.connection.bind('unavailable', () =>
state.send('RT_CHANGED_STATUS', { status: 'unavailable' })
)
this.pusher.connection.bind('failed', () =>
state.send('RT_CHANGED_STATUS', { status: 'failed' })
)
this.pusher.connection.bind('disconnected', () =>
state.send('RT_CHANGED_STATUS', { status: 'disconnected' })
)
}
connect(room: string) {
this.room = room
// Subscribe to channel
this.channel = this.pusher.subscribe(this.room)
this.channel.bind('pusher:subscription_error', () => {
state.send('RT_CHANGED_STATUS', { status: 'subscription-error' })
})
this.channel.bind('pusher:subscription_succeeded', () => {
state.send('RT_CHANGED_STATUS', { status: 'subscribed' })
})
this.channel.bind(
'created_shape',
(payload: { id: string; pageId: string; shape: Shape }) => {
state.send('RT_CREATED_SHAPE', payload)
}
)
this.channel.bind(
'deleted_shape',
(payload: { id: string; pageId: string; shape: Shape }) => {
state.send('RT_DELETED_SHAPE', payload)
}
)
this.channel.bind(
'edited_shape',
(payload: { id: string; pageId: string; change: Partial<Shape> }) => {
state.send('RT_EDITED_SHAPE', payload)
}
)
this.channel.bind(
'moved_cursor',
(payload: { id: string; pageId: string; point: number[] }) => {
if (payload.id === this.id) return
state.send('RT_MOVED_CURSOR', payload)
}
)
}
disconnect() {
this.pusher.unsubscribe(this.room)
}
reconnect() {
this.pusher.subscribe(this.room)
}
moveCursor(pageId: string, point: number[]) {
if (!this.channel) return
const now = Date.now()
if (now - this.lastCursorEventTime > 42) {
this.lastCursorEventTime = now
this.channel?.trigger('RT_MOVED_CURSOR', {
id: this.id,
pageId,
point,
})
}
}
}
export default new RoomClient()

1
state/pusher/server.ts Normal file
View file

@ -0,0 +1 @@
export {}

View file

@ -7,6 +7,7 @@ import history from './history'
import storage from './storage' import storage from './storage'
import clipboard from './clipboard' import clipboard from './clipboard'
import * as Sessions from './sessions' import * as Sessions from './sessions'
import pusher from './pusher/client'
import commands from './commands' import commands from './commands'
import { import {
getChildren, getChildren,
@ -28,6 +29,7 @@ import {
setToArray, setToArray,
deepClone, deepClone,
pointInBounds, pointInBounds,
uniqueId,
} from 'utils' } from 'utils'
import { import {
@ -173,6 +175,19 @@ const state = createState({
else: ['zoomCameraToActual'], else: ['zoomCameraToActual'],
}, },
on: { on: {
// Network-Related
RT_LOADED_ROOM: [
'clearRoom',
{ if: 'hasRoom', do: ['clearDocument', 'connectToRoom'] },
],
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',
// Client
RESIZED_WINDOW: 'resetPageState', RESIZED_WINDOW: 'resetPageState',
RESET_PAGE: 'resetPage', RESET_PAGE: 'resetPage',
TOGGLED_READ_ONLY: 'toggleReadOnly', TOGGLED_READ_ONLY: 'toggleReadOnly',
@ -1032,6 +1047,9 @@ const state = createState({
}, },
}, },
conditions: { conditions: {
hasRoom(_, payload: { id?: string }) {
return payload.id !== undefined
},
shouldDeleteShape(data, payload, shape: Shape) { shouldDeleteShape(data, payload, shape: Shape) {
return getShapeUtils(shape).shouldDelete(shape) return getShapeUtils(shape).shouldDelete(shape)
}, },
@ -1124,6 +1142,68 @@ const state = createState({
}, },
}, },
actions: { actions: {
// Networked Room
setRtStatus(data, payload: { id: string; status: string }) {
const { status } = payload
if (!data.room) {
data.room = { id: null, status: '' }
}
data.room.status = status
},
addRtShape(data, payload: { pageId: string; shape: Shape }) {
const { pageId, shape } = payload
// What if the page is in storage?
data.document.pages[pageId].shapes[shape.id] = shape
},
deleteRtShape(data, payload: { pageId: string; shapeId: string }) {
const { pageId, shapeId } = payload
// What if the page is in storage?
delete data.document[pageId].shapes[shapeId]
},
editRtShape(data, payload: { pageId: string; shape: Shape }) {
const { pageId, shape } = payload
// What if the page is in storage?
Object.assign(data.document[pageId].shapes[shape.id], shape)
},
moveRtCursor() {
null
},
clearRoom(data) {
data.room = undefined
},
clearDocument(data) {
data.document.id = uniqueId()
const newId = 'page1'
data.currentPageId = newId
data.document.pages = {
[newId]: {
id: newId,
name: 'Page 1',
type: 'page',
shapes: {},
childIndex: 1,
},
}
data.pageStates = {
[newId]: {
id: newId,
selectedIds: new Set(),
camera: {
point: [0, 0],
zoom: 1,
},
},
}
},
connectToRoom(data, payload: { id: string }) {
data.room = { id: payload.id, status: 'connecting' }
pusher.connect(payload.id)
},
resetPageState(data) { resetPageState(data) {
const pageState = data.pageStates[data.currentPageId] const pageState = data.pageStates[data.currentPageId]
data.pageStates[data.currentPageId] = { ...pageState } data.pageStates[data.currentPageId] = { ...pageState }
@ -1893,7 +1973,7 @@ const state = createState({
.sort((a, b) => a.childIndex - b.childIndex) .sort((a, b) => a.childIndex - b.childIndex)
}, },
selectedStyle(data) { selectedStyle(data) {
const selectedIds = Array.from(getSelectedIds(data).values()) const selectedIds = setToArray(getSelectedIds(data))
const { currentStyle } = data const { currentStyle } = data
if (selectedIds.length === 0) { if (selectedIds.length === 0) {

View file

@ -14,6 +14,10 @@ export interface Data {
isToolLocked: boolean isToolLocked: boolean
isPenLocked: boolean isPenLocked: boolean
} }
room?: {
id: string
status: string
}
currentStyle: ShapeStyles currentStyle: ShapeStyles
activeTool: ShapeType | 'select' activeTool: ShapeType | 'select'
brush?: Bounds brush?: Bounds

View file

@ -6651,6 +6651,13 @@ punycode@^2.1.0, punycode@^2.1.1:
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
pusher-js@^7.0.3:
version "7.0.3"
resolved "https://registry.yarnpkg.com/pusher-js/-/pusher-js-7.0.3.tgz#f81c78cdf2ad32f546caa7532ec7f9081ef00b8d"
integrity sha512-HIfCvt00CAqgO4W0BrdpPsDcAwy51rB6DN0VMC+JeVRRbo8mn3XTeUeIFjmmlRLZLX8rPhUtLRo7vPag6b8GCw==
dependencies:
tweetnacl "^1.0.3"
querystring-es3@0.2.1, querystring-es3@^0.2.0: querystring-es3@0.2.1, querystring-es3@^0.2.0:
version "0.2.1" version "0.2.1"
resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73"
@ -7815,6 +7822,11 @@ tty-browserify@0.0.1:
resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.1.tgz#3f05251ee17904dfd0677546670db9651682b811" resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.1.tgz#3f05251ee17904dfd0677546670db9651682b811"
integrity sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw== integrity sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw==
tweetnacl@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-1.0.3.tgz#ac0af71680458d8a6378d0d0d050ab1407d35596"
integrity sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==
type-check@^0.4.0, type-check@~0.4.0: type-check@^0.4.0, type-check@~0.4.0:
version "0.4.0" version "0.4.0"
resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"