diff --git a/components/canvas/shape.tsx b/components/canvas/shape.tsx index 1f6bebba4..3bbeb9370 100644 --- a/components/canvas/shape.tsx +++ b/components/canvas/shape.tsx @@ -1,5 +1,5 @@ -import React, { useRef, memo, useEffect } from 'react' -import { useSelector } from 'state' +import React, { useRef, memo, useEffect, useState } from 'react' +import state, { useSelector } from 'state' import styled from 'styles' import { getShapeUtils } from 'state/shape-utils' import { deepCompareArrays, getPage, getShape } from 'utils' @@ -16,34 +16,30 @@ interface ShapeProps { function Shape({ id, isSelecting }: ShapeProps): JSX.Element { const rGroup = useRef(null) - const shapeUtils = useSelector((s) => { - const shape = getShape(s.data, id) - return getShapeUtils(shape) - }) - const isHidden = useSelector((s) => { const shape = getShape(s.data, id) - return shape.isHidden + return shape?.isHidden || false }) const children = useSelector((s) => { const shape = getShape(s.data, id) - return shape.children + return shape?.children || [] }, deepCompareArrays) - const isParent = shapeUtils.isParent - - const isForeignObject = shapeUtils.isForeignObject - const strokeWidth = useSelector((s) => { const shape = getShape(s.data, id) const style = getShapeStyle(shape.style) return +style.strokeWidth }) + const shapeUtils = useSelector((s) => { + const shape = getShape(s.data, id) + return getShapeUtils(shape) + }) + const transform = useSelector((s) => { 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 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 state.onUpdate((s) => { + if (isShape && !getShape(s.data, id)) { + setIsShape(false) + } + }) + }, [isShape, id]) + + return isShape +} diff --git a/components/editor.tsx b/components/editor.tsx index b1b4a37ce..362c76252 100644 --- a/components/editor.tsx +++ b/components/editor.tsx @@ -9,9 +9,9 @@ import PagePanel from './page-panel/page-panel' import CodePanel from './code-panel/code-panel' import ControlsPanel from './controls-panel/controls-panel' -export default function Editor(): JSX.Element { +export default function Editor({ roomId }: { roomId?: string }): JSX.Element { useKeyboardEvents() - useLoadOnMount() + useLoadOnMount(roomId) return ( diff --git a/components/status-bar.tsx b/components/status-bar.tsx index df4b74a37..92b95984a 100644 --- a/components/status-bar.tsx +++ b/components/status-bar.tsx @@ -2,6 +2,8 @@ import { useStateDesigner } from '@state-designer/react' import state from 'state' import styled from 'styles' +const size: any = { '@sm': 'small' } + export default function StatusBar(): JSX.Element { const local = useStateDesigner(state) @@ -9,16 +11,13 @@ export default function StatusBar(): JSX.Element { const states = s.split('.') return states[states.length - 1] }) + const log = local.log[0] return ( - +
- {active.join(' | ')} | {log} + {active.join(' | ')} | {log} | {local.data.room?.status}
) diff --git a/hooks/useLoadOnMount.ts b/hooks/useLoadOnMount.ts index c6f8c6744..8c1a55830 100644 --- a/hooks/useLoadOnMount.ts +++ b/hooks/useLoadOnMount.ts @@ -2,16 +2,18 @@ import { useEffect } from 'react' import state from 'state' -export default function useLoadOnMount() { +export default function useLoadOnMount(roomId: string) { useEffect(() => { const fonts = (document as any).fonts - fonts - .load('12px Verveine Regular', 'Fonts are loaded!') - .then(() => state.send('MOUNTED')) + fonts.load('12px Verveine Regular', 'Fonts are loaded!').then(() => { + state.send('MOUNTED') + state.send('RT_LOADED_ROOM', { id: roomId }) + }) return () => { state.send('UNMOUNTED') + state.send('RT_UNLOADED_ROOM', { id: roomId }) } - }, []) + }, [roomId]) } diff --git a/package.json b/package.json index 04328076f..f9942344e 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "next-auth": "^3.27.0", "next-pwa": "^5.2.21", "perfect-freehand": "^0.4.91", + "pusher-js": "^7.0.3", "react": "^17.0.2", "react-dom": "^17.0.2", "react-error-boundary": "^3.1.3", diff --git a/pages/room/[id].tsx b/pages/room/[id].tsx new file mode 100644 index 000000000..e42e1aa5f --- /dev/null +++ b/pages/room/[id].tsx @@ -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 +} + +export const getServerSideProps: GetServerSideProps = async (context) => { + const session = await getSession(context) + + const { id } = context.query + + return { + props: { + session, + id, + }, + } +} diff --git a/pages/room/index.tsx b/pages/room/index.tsx new file mode 100644 index 000000000..73f5c1769 --- /dev/null +++ b/pages/room/index.tsx @@ -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
You should not see this one
+} + +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: {}, + } +} diff --git a/state/pusher/client.ts b/state/pusher/client.ts new file mode 100644 index 000000000..24c080a26 --- /dev/null +++ b/state/pusher/client.ts @@ -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 }) => { + 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() diff --git a/state/pusher/server.ts b/state/pusher/server.ts new file mode 100644 index 000000000..336ce12bb --- /dev/null +++ b/state/pusher/server.ts @@ -0,0 +1 @@ +export {} diff --git a/state/state.ts b/state/state.ts index 6671bec84..620c5b8b0 100644 --- a/state/state.ts +++ b/state/state.ts @@ -7,6 +7,7 @@ import history from './history' import storage from './storage' import clipboard from './clipboard' import * as Sessions from './sessions' +import pusher from './pusher/client' import commands from './commands' import { getChildren, @@ -28,6 +29,7 @@ import { setToArray, deepClone, pointInBounds, + uniqueId, } from 'utils' import { @@ -173,6 +175,19 @@ const state = createState({ else: ['zoomCameraToActual'], }, 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', RESET_PAGE: 'resetPage', TOGGLED_READ_ONLY: 'toggleReadOnly', @@ -1032,6 +1047,9 @@ const state = createState({ }, }, conditions: { + hasRoom(_, payload: { id?: string }) { + return payload.id !== undefined + }, shouldDeleteShape(data, payload, shape: Shape) { return getShapeUtils(shape).shouldDelete(shape) }, @@ -1124,6 +1142,68 @@ const state = createState({ }, }, 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) { const pageState = data.pageStates[data.currentPageId] data.pageStates[data.currentPageId] = { ...pageState } @@ -1893,7 +1973,7 @@ const state = createState({ .sort((a, b) => a.childIndex - b.childIndex) }, selectedStyle(data) { - const selectedIds = Array.from(getSelectedIds(data).values()) + const selectedIds = setToArray(getSelectedIds(data)) const { currentStyle } = data if (selectedIds.length === 0) { diff --git a/types.ts b/types.ts index f8c040e47..1fcd26d00 100644 --- a/types.ts +++ b/types.ts @@ -14,6 +14,10 @@ export interface Data { isToolLocked: boolean isPenLocked: boolean } + room?: { + id: string + status: string + } currentStyle: ShapeStyles activeTool: ShapeType | 'select' brush?: Bounds diff --git a/yarn.lock b/yarn.lock index eff4975fc..4d249429a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6651,6 +6651,13 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" 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: version "0.2.1" 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" 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: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"