[feature] MVP multiplayer support (#135)

* Adds multiplayer support

* Update liveblocks.tsx

* Update liveblocks.tsx

* Create chaos.tsx

* Fix undo redo, add merge state

* Update multiplayer-editor.tsx

* Adds secret room

* Update chaos.tsx

* Moves shhh to shhhmp

* Fix accidentally deleting the editing shape

* Fix bug where a selected shape is deleted by another user.

* Remove relative path

* Tweak editor

* Remove chaos endpoint

* Adds error state for maximum connections, fixes selectedIds bug on new rooms
This commit is contained in:
Steve Ruiz 2021-10-09 00:05:24 +01:00 committed by GitHub
parent 51dbede313
commit 99730b4fe2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 414 additions and 27 deletions

View file

@ -19,6 +19,8 @@
],
"sideEffects": false,
"dependencies": {
"@liveblocks/client": "^0.12.0",
"@liveblocks/react": "^0.12.0",
"@tldraw/tldraw": "^0.0.109",
"idb": "^6.1.2",
"react": ">=16.8",

View file

@ -5,6 +5,7 @@ import Controlled from './controlled'
import Imperative from './imperative'
import Embedded from './embedded'
import NoSizeEmbedded from '+no-size-embedded'
import LiveBlocks from './liveblocks'
import ChangingId from './changing-id'
import Core from './core'
import './styles.css'
@ -34,6 +35,9 @@ export default function App(): JSX.Element {
<Route path="/no-size-embedded">
<NoSizeEmbedded />
</Route>
<Route path="/liveblocks">
<LiveBlocks />
</Route>
<Route path="/">
<ul>
<li>
@ -57,6 +61,9 @@ export default function App(): JSX.Element {
<li>
<Link to="/no-size-embedded">embedded (no size)</Link>
</li>
<li>
<Link to="/liveblocks">liveblocks</Link>
</li>
</ul>
</Route>
</Switch>

View file

@ -0,0 +1,84 @@
import * as React from 'react'
import {
TLDraw,
ColorStyle,
DashStyle,
TLDrawState,
SizeStyle,
TLDrawDocument,
TLDrawShapeType,
} from '@tldraw/tldraw'
import { createClient } from '@liveblocks/client'
import { LiveblocksProvider, RoomProvider, useObject } from '@liveblocks/react'
const publicAPIKey = process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_API_KEY as string
const client = createClient({
publicApiKey: publicAPIKey,
})
export default function LiveBlocks() {
return (
<LiveblocksProvider client={client}>
<RoomProvider id="room1">
<TLDrawWrapper />
</RoomProvider>
</LiveblocksProvider>
)
}
function TLDrawWrapper() {
const doc = useObject<TLDrawDocument>('doc', {
id: 'doc',
pages: {
page1: {
id: 'page1',
shapes: {
rect1: {
id: 'rect1',
type: TLDrawShapeType.Rectangle,
parentId: 'page1',
name: 'Rectangle',
childIndex: 1,
point: [100, 100],
size: [100, 100],
style: {
dash: DashStyle.Draw,
size: SizeStyle.Medium,
color: ColorStyle.Blue,
},
},
},
bindings: {},
},
},
pageStates: {
page1: {
id: 'page1',
selectedIds: ['rect1'],
camera: {
point: [0, 0],
zoom: 1,
},
},
},
})
const handleChange = React.useCallback(
(state: TLDrawState, patch, reason) => {
if (!doc) return
if (reason.startsWith('command')) {
doc.update(patch.document)
}
},
[doc]
)
if (doc === null) return <div>loading...</div>
return (
<div className="tldraw">
<TLDraw document={doc.toObject()} onChange={handleChange} />
</div>
)
}

View file

@ -543,6 +543,104 @@ export class TLDrawState extends StateManager<Data> {
return this
}
/**
* Merge a new document patch into the current document.
* @param document
*/
mergeDocument = (document: TLDrawDocument): this => {
// If it's a new document, do a full change.
if (this.document.id !== document.id) {
this.replaceState({
...this.state,
appState: {
...this.appState,
currentPageId: Object.keys(document.pages)[0],
},
document,
})
return this
}
// Have we deleted any pages? If so, drop everything and change
// to the first page. This is an edge case.
const currentPageStates = { ...this.document.pageStates }
// Update the app state's current page id if needed
const nextAppState = {
...this.appState,
currentPageId: document.pages[this.currentPageId]
? this.currentPageId
: Object.keys(document.pages)[0],
pages: Object.values(document.pages).map((page, i) => ({
id: page.id,
name: page.name,
childIndex: page.childIndex || i,
})),
}
// Reset the history (for now)
this.resetHistory()
Object.keys(this.document.pages).forEach((pageId) => {
if (!document.pages[pageId]) {
if (pageId === this.appState.currentPageId) {
this.cancelSession()
this.deselectAll()
}
currentPageStates[pageId] = undefined as unknown as TLPageState
}
})
// Don't allow the selected ids to be deleted during a session—if
// they've been removed, put them back in the client's document.
if (this.session) {
this.selectedIds
.filter((id) => !document.pages[this.currentPageId].shapes[id])
.forEach((id) => (document.pages[this.currentPageId].shapes[id] = this.page.shapes[id]))
}
// For other pages, remove any selected ids that were deleted.
Object.entries(currentPageStates).forEach(([pageId, pageState]) => {
pageState.selectedIds = pageState.selectedIds.filter(
(id) => !!document.pages[pageId].shapes[id]
)
})
// If the user is currently creating a shape (ie drawing), then put that
// shape back onto the page for the client.
const { editingId } = this.pageState
if (editingId) {
console.warn('A change occured while creating a shape')
if (!editingId) throw Error('Huh?')
document.pages[this.currentPageId].shapes[editingId] = this.page.shapes[editingId]
currentPageStates[this.currentPageId].selectedIds = [editingId]
}
console.log('next state', {
...this.state,
appState: nextAppState,
document: {
...document,
pageStates: currentPageStates,
},
})
return this.replaceState(
{
...this.state,
appState: nextAppState,
document: {
...document,
pageStates: currentPageStates,
},
},
'merge'
)
}
/**
* Update the current document.
* @param document

View file

@ -15,16 +15,19 @@ export default function Editor({ id = 'home' }: EditorProps) {
}, [])
// Send events to gtag as actions.
const handleChange = React.useCallback((_tlstate: TLDrawState, _state: Data, reason: string) => {
if (reason.startsWith('command')) {
gtag.event({
action: reason,
category: 'editor',
label: `page:${id}`,
value: 0,
})
}
}, [])
const handleChange = React.useCallback(
(_tlstate: TLDrawState, _state: Data, reason: string) => {
if (reason.startsWith('command')) {
gtag.event({
action: reason,
category: 'editor',
label: `page:${id}`,
value: 0,
})
}
},
[id]
)
return (
<div className="tldraw">

View file

@ -0,0 +1,108 @@
import { TLDraw, TLDrawState, Data, TLDrawDocument } from '@tldraw/tldraw'
import * as gtag from '-utils/gtag'
import * as React from 'react'
import { createClient } from '@liveblocks/client'
import { LiveblocksProvider, RoomProvider, useObject, useErrorListener } from '@liveblocks/react'
import { Utils } from '@tldraw/core'
const client = createClient({
publicApiKey: 'pk_live_1LJGGaqBSNLjLT-4Jalkl-U9',
})
export default function MultiplayerEditor({ id }: { id: string }) {
return (
<LiveblocksProvider client={client}>
<RoomProvider id={id}>
<Editor id={id} />
</RoomProvider>
</LiveblocksProvider>
)
}
function Editor({ id }: { id: string }) {
const [uuid] = React.useState(() => Utils.uniqueId())
const [error, setError] = React.useState<Error>(null)
const [tlstate, setTlstate] = React.useState<TLDrawState>()
useErrorListener((err) => {
console.log(err)
setError(err)
})
const doc = useObject<{ uuid: string; document: TLDrawDocument }>('doc', {
uuid,
document: {
id,
pages: {
page: {
id: 'page',
shapes: {},
bindings: {},
},
},
pageStates: {
page: {
id: 'page',
selectedIds: [],
camera: {
point: [0, 0],
zoom: 1,
},
},
},
},
})
// Put the tlstate into the window, for debugging.
const handleMount = React.useCallback((tlstate: TLDrawState) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
window.tlstate = tlstate
setTlstate(tlstate)
}, [])
// Send events to gtag as actions.
const handleChange = React.useCallback(
(_tlstate: TLDrawState, state: Data, reason: string) => {
if (reason.startsWith('command')) {
gtag.event({
action: reason,
category: 'editor',
label: '',
value: 0,
})
if (doc) {
doc.update({ uuid, document: state.document })
}
}
},
[uuid, doc]
)
React.useEffect(() => {
if (!doc) return
if (!tlstate) return
function updateState() {
const docObject = doc.toObject()
if (docObject.uuid === uuid) return
tlstate.mergeDocument(docObject.document)
}
updateState()
doc.subscribe(updateState)
return () => doc.unsubscribe(updateState)
}, [doc, uuid, tlstate])
if (error) return <div>Error: {error.message}</div>
if (doc === null) return <div>loading...</div>
return (
<div className="tldraw">
<TLDraw onMount={handleMount} onChange={handleChange} autofocus />
</div>
)
}

View file

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const withPWA = require('next-pwa')
const SentryWebpackPlugin = require('@sentry/webpack-plugin')
const withTM = require('next-transpile-modules')(['@tldraw/tldraw'])
const withTM = require('next-transpile-modules')(['@tldraw/tldraw', '@tldraw/core'])
const {
GITHUB_ID,
@ -22,8 +22,8 @@ const isProduction = NODE_ENV === 'production'
const basePath = ''
module.exports = withTM(
withPWA({
module.exports = withPWA(
withTM({
reactStrictMode: true,
pwa: {
disable: !isProduction,

View file

@ -16,14 +16,16 @@
"lint": "next lint"
},
"dependencies": {
"@liveblocks/client": "^0.12.0",
"@liveblocks/react": "^0.12.0",
"@sentry/integrations": "^6.13.2",
"@sentry/node": "^6.13.2",
"@sentry/react": "^6.13.2",
"@sentry/tracing": "^6.13.2",
"@stitches/react": "^1.0.0",
"@tldraw/tldraw": "^0.0.109",
"next": "11.1.2",
"next-auth": "3.29.0",
"next": "^11.1.2",
"next-auth": "^3.29.0",
"next-pwa": "^5.2.23",
"next-themes": "^0.0.15",
"next-transpile-modules": "^8.0.0",

View file

@ -0,0 +1,30 @@
import * as React from 'react'
import type { GetServerSideProps } from 'next'
import Head from 'next/head'
import dynamic from 'next/dynamic'
const MultiplayerEditor = dynamic(() => import('components/multiplayer-editor'), { ssr: false })
interface RoomProps {
id: string
}
export default function Room({ id }: RoomProps): JSX.Element {
return (
<>
<Head>
<title>tldraw</title>
</Head>
<MultiplayerEditor id={id} />
</>
)
}
export const getServerSideProps: GetServerSideProps = async (context) => {
const id = context.query.id?.toString()
return {
props: {
id,
},
}
}

View file

@ -0,0 +1,34 @@
import * as React from 'react'
import type { GetServerSideProps } from 'next'
import Head from 'next/head'
interface RoomProps {
id?: string
}
export default function RandomRoomPage({ id }: RoomProps): JSX.Element {
return (
<>
<Head>
<title>tldraw</title>
</Head>
<div>Should have routed to room: {id}</div>
</>
)
}
export const getServerSideProps: GetServerSideProps = async (context) => {
// Generate random id
const id = Date.now().toString()
// Route to a room with that id
context.res.setHeader('Location', `/r/${id}`)
context.res.statusCode = 307
// Return id (though it shouldn't matter)
return {
props: {
id,
},
}
}

View file

@ -3,7 +3,7 @@ import type { GetServerSideProps } from 'next'
import Head from 'next/head'
import { getSession } from 'next-auth/client'
import dynamic from 'next/dynamic'
const Editor = dynamic(() => import('components/editor'), { ssr: false })
const MultiplayerEditor = dynamic(() => import('components/multiplayer-editor'), { ssr: false })
interface RoomProps {
id: string
@ -15,7 +15,7 @@ export default function Room({ id }: RoomProps): JSX.Element {
<Head>
<title>tldraw</title>
</Head>
<Editor id={id} />
<MultiplayerEditor id={id} />
</>
)
}
@ -30,12 +30,6 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
const id = context.query.id?.toString()
// Get document from database
// If document does not exist, create an empty document
// Return the document
return {
props: {
id,

View file

@ -0,0 +1,15 @@
import * as React from 'react'
import Head from 'next/head'
import dynamic from 'next/dynamic'
const MultiplayerEditor = dynamic(() => import('components/multiplayer-editor'), { ssr: false })
export default function Room(): JSX.Element {
return (
<>
<Head>
<title>tldraw</title>
</Head>
<MultiplayerEditor id={'shhhmp'} />
</>
)
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -2697,6 +2697,16 @@
npmlog "^4.1.2"
write-file-atomic "^3.0.3"
"@liveblocks/client@^0.12.0":
version "0.12.0"
resolved "https://registry.yarnpkg.com/@liveblocks/client/-/client-0.12.0.tgz#426c9312b43933f31d4c72611d846325579da651"
integrity sha512-kcNPjzwqzDJBCXvLh/lPg86Xoi9quIymav3dbxgVrtufWN40m0WUPderMIiCBFkF5BIWYdBAPd70+YmHLLYjBA==
"@liveblocks/react@^0.12.0":
version "0.12.0"
resolved "https://registry.yarnpkg.com/@liveblocks/react/-/react-0.12.0.tgz#76fdcbfb411a65e97c59a138ec876c75e11ee57b"
integrity sha512-4LVBRKYKS/vOt2N8OJ1t3R55RJ9o8e/iQY79WQzCFBrzZU+oXUcqSeK1wAcayhnATqJNvyUNYzE8xq9+OvhzXg==
"@mrmlnc/readdir-enhanced@^2.2.1":
version "2.2.1"
resolved "https://registry.yarnpkg.com/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz#524af240d1a360527b730475ecfa1344aa540dde"
@ -9924,7 +9934,7 @@ neo-async@^2.6.0:
resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
next-auth@3.29.0:
next-auth@^3.29.0:
version "3.29.0"
resolved "https://registry.yarnpkg.com/next-auth/-/next-auth-3.29.0.tgz#60ddbfc7ed8ae7d43ebb02c16dc58eebf5dcb337"
integrity sha512-B//4QTv/1Of0D+roZ82URmI6L2JSbkKgeaKI7Mdrioq8lAzp9ff8NdmouvZL/7zwrPe2cUyM6MLYlasfuI3ZIQ==
@ -9967,7 +9977,7 @@ next-transpile-modules@^8.0.0:
enhanced-resolve "^5.7.0"
escalade "^3.1.1"
next@11.1.2:
next@^11.1.2:
version "11.1.2"
resolved "https://registry.yarnpkg.com/next/-/next-11.1.2.tgz#527475787a9a362f1bc916962b0c0655cc05bc91"
integrity sha512-azEYL0L+wFjv8lstLru3bgvrzPvK0P7/bz6B/4EJ9sYkXeW8r5Bjh78D/Ol7VOg0EIPz0CXoe72hzAlSAXo9hw==