[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:
parent
51dbede313
commit
99730b4fe2
16 changed files with 414 additions and 27 deletions
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
|
|
84
packages/dev/src/liveblocks.tsx
Normal file
84
packages/dev/src/liveblocks.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -15,7 +15,8 @@ export default function Editor({ id = 'home' }: EditorProps) {
|
|||
}, [])
|
||||
|
||||
// Send events to gtag as actions.
|
||||
const handleChange = React.useCallback((_tlstate: TLDrawState, _state: Data, reason: string) => {
|
||||
const handleChange = React.useCallback(
|
||||
(_tlstate: TLDrawState, _state: Data, reason: string) => {
|
||||
if (reason.startsWith('command')) {
|
||||
gtag.event({
|
||||
action: reason,
|
||||
|
@ -24,7 +25,9 @@ export default function Editor({ id = 'home' }: EditorProps) {
|
|||
value: 0,
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
},
|
||||
[id]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="tldraw">
|
||||
|
|
108
packages/www/components/multiplayer-editor.tsx
Normal file
108
packages/www/components/multiplayer-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
|
|
30
packages/www/pages/k/[id].tsx
Normal file
30
packages/www/pages/k/[id].tsx
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
34
packages/www/pages/k/index.tsx
Normal file
34
packages/www/pages/k/index.tsx
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
15
packages/www/pages/shhhmp.tsx
Normal file
15
packages/www/pages/shhhmp.tsx
Normal 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
14
yarn.lock
14
yarn.lock
|
@ -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==
|
||||
|
|
Loading…
Reference in a new issue