[feature] Live cursors MVP (#137)

* Adds very basic live cursors

* Adds ability to hide pages / menu
This commit is contained in:
Steve Ruiz 2021-10-09 14:57:44 +01:00 committed by GitHub
parent 99730b4fe2
commit d6b38ed79e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 455 additions and 154 deletions

View file

@ -8,11 +8,12 @@ import {
useCameraCss, useCameraCss,
useKeyEvents, useKeyEvents,
} from '+hooks' } from '+hooks'
import type { TLBinding, TLPage, TLPageState, TLShape } from '+types' import type { TLBinding, TLPage, TLPageState, TLShape, TLUser, TLUsers } from '+types'
import { ErrorFallback } from '+components/error-fallback' import { ErrorFallback } from '+components/error-fallback'
import { ErrorBoundary } from '+components/error-boundary' import { ErrorBoundary } from '+components/error-boundary'
import { Brush } from '+components/brush' import { Brush } from '+components/brush'
import { Page } from '+components/page' import { Page } from '+components/page'
import { Users } from '+components/users'
import { useResizeObserver } from '+hooks/useResizeObserver' import { useResizeObserver } from '+hooks/useResizeObserver'
import { inputs } from '+inputs' import { inputs } from '+inputs'
@ -23,6 +24,8 @@ function resetError() {
interface CanvasProps<T extends TLShape, M extends Record<string, unknown>> { interface CanvasProps<T extends TLShape, M extends Record<string, unknown>> {
page: TLPage<T, TLBinding> page: TLPage<T, TLBinding>
pageState: TLPageState pageState: TLPageState
users?: TLUsers
userId?: string
hideBounds?: boolean hideBounds?: boolean
hideHandles?: boolean hideHandles?: boolean
hideIndicators?: boolean hideIndicators?: boolean
@ -34,6 +37,8 @@ export function Canvas<T extends TLShape, M extends Record<string, unknown>>({
id, id,
page, page,
pageState, pageState,
users,
userId,
meta, meta,
hideHandles = false, hideHandles = false,
hideBounds = false, hideBounds = false,
@ -83,6 +88,7 @@ export function Canvas<T extends TLShape, M extends Record<string, unknown>>({
meta={meta} meta={meta}
/> />
{pageState.brush && <Brush brush={pageState.brush} />} {pageState.brush && <Brush brush={pageState.brush} />}
{users && <Users userId={userId} users={users} />}
</div> </div>
</ErrorBoundary> </ErrorBoundary>
</div> </div>

View file

@ -12,7 +12,7 @@ import type {
import { Canvas } from '../canvas' import { Canvas } from '../canvas'
import { Inputs } from '../../inputs' import { Inputs } from '../../inputs'
import { useTLTheme, TLContext, TLContextType } from '../../hooks' import { useTLTheme, TLContext, TLContextType } from '../../hooks'
import type { TLShapeUtil } from '+index' import type { TLShapeUtil, TLUser, TLUsers } from '+index'
export interface RendererProps<T extends TLShape, E extends Element = any, M = any> export interface RendererProps<T extends TLShape, E extends Element = any, M = any>
extends Partial<TLCallbacks<T>> { extends Partial<TLCallbacks<T>> {
@ -23,7 +23,6 @@ export interface RendererProps<T extends TLShape, E extends Element = any, M = a
/** /**
* An object containing instances of your shape classes. * An object containing instances of your shape classes.
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
shapeUtils: Record<T['type'], TLShapeUtil<T, E, M>> shapeUtils: Record<T['type'], TLShapeUtil<T, E, M>>
/** /**
* The current page, containing shapes and bindings. * The current page, containing shapes and bindings.
@ -34,29 +33,37 @@ export interface RendererProps<T extends TLShape, E extends Element = any, M = a
*/ */
pageState: TLPageState pageState: TLPageState
/** /**
* An object of custom theme colors. * (optional) The current users to render.
*/
users?: TLUsers
/**
* (optional) The current user's id, used to identify the user.
*/
userId?: string
/**
* (optional) An object of custom theme colors.
*/ */
theme?: Partial<TLTheme> theme?: Partial<TLTheme>
/** /**
* When true, the renderer will not show the bounds for selected objects. * (optional) When true, the renderer will not show the bounds for selected objects.
*/ */
hideBounds?: boolean hideBounds?: boolean
/** /**
* When true, the renderer will not show the handles of shapes with handles. * (optional) When true, the renderer will not show the handles of shapes with handles.
*/ */
hideHandles?: boolean hideHandles?: boolean
/** /**
* When true, the renderer will not show indicators for selected or * (optional) When true, the renderer will not show indicators for selected or
* hovered objects, * hovered objects,
*/ */
hideIndicators?: boolean hideIndicators?: boolean
/** /**
* When true, the renderer will ignore all inputs that were not made * (optional) hen true, the renderer will ignore all inputs that were not made
* by a stylus or pen-type device. * by a stylus or pen-type device.
*/ */
isPenMode?: boolean isPenMode?: boolean
/** /**
* An object of custom options that should be passed to rendered shapes. * (optional) An object of custom options that should be passed to rendered shapes.
*/ */
meta?: M meta?: M
/** /**
@ -82,6 +89,8 @@ export function Renderer<T extends TLShape, E extends Element, M extends Record<
shapeUtils, shapeUtils,
page, page,
pageState, pageState,
users,
userId,
theme, theme,
meta, meta,
hideHandles = false, hideHandles = false,
@ -118,6 +127,8 @@ export function Renderer<T extends TLShape, E extends Element, M extends Record<
id={id} id={id}
page={page} page={page}
pageState={pageState} pageState={pageState}
users={users}
userId={userId}
hideBounds={hideBounds} hideBounds={hideBounds}
hideIndicators={hideIndicators} hideIndicators={hideIndicators}
hideHandles={hideHandles} hideHandles={hideHandles}

View file

@ -0,0 +1 @@
export * from './user'

View file

@ -0,0 +1,21 @@
import * as React from 'react'
import type { TLUser } from '+types'
interface UserProps {
user: TLUser
}
export function User({ user }: UserProps) {
const rUser = React.useRef<HTMLDivElement>(null)
return (
<div
ref={rUser}
className="tl-absolute tl-user"
style={{
backgroundColor: user.color,
transform: `translate(${user.point[0]}px, ${user.point[1]}px)`,
}}
/>
)
}

View file

@ -0,0 +1 @@
export * from './users'

View file

@ -0,0 +1,20 @@
import * as React from 'react'
import { User } from '+components/user/user'
import type { TLUsers } from '+types'
export interface UserProps {
userId?: string
users: TLUsers
}
export function Users({ userId, users }: UserProps) {
return (
<>
{Object.values(users)
.filter((user) => user.id !== userId)
.map((user) => (
<User key={user.id} user={user} />
))}
</>
)
}

View file

@ -233,6 +233,15 @@ const tlcss = css`
pointer-events: none; pointer-events: none;
} }
.tl-user {
left: -4px;
top: -4px;
height: 8px;
width: 8px;
border-radius: 100%;
pointer-events: none;
}
.tl-selected { .tl-selected {
fill: transparent; fill: transparent;
stroke: var(--tl-selectStroke); stroke: var(--tl-selectStroke);

View file

@ -16,6 +16,8 @@ export interface TLPage<T extends TLShape, B extends TLBinding> {
bindings: Record<string, B> bindings: Record<string, B>
} }
export type TLUsers<U extends TLUser = TLUser> = Record<string, U>
export interface TLPageState { export interface TLPageState {
id: string id: string
selectedIds: string[] selectedIds: string[]
@ -32,6 +34,12 @@ export interface TLPageState {
currentParentId?: string | null currentParentId?: string | null
} }
export interface TLUser {
id: string
color: string
point: number[]
}
export interface TLHandle { export interface TLHandle {
id: string id: string
index: number index: number

1
packages/dev/.env.local Normal file
View file

@ -0,0 +1 @@
LIVEBLOCKS_PUBLIC_API_KEY=pk_live_1LJGGaqBSNLjLT-4Jalkl-U9

View file

@ -23,6 +23,7 @@ esbuild
incremental: isDevServer, incremental: isDevServer,
target: ['chrome58', 'firefox57', 'safari11', 'edge18'], target: ['chrome58', 'firefox57', 'safari11', 'edge18'],
define: { define: {
'process.env.LIVEBLOCKS_PUBLIC_API_KEY': process.env.LIVEBLOCKS_PUBLIC_API_KEY,
'process.env.NODE_ENV': isDevServer ? '"development"' : '"production"', 'process.env.NODE_ENV': isDevServer ? '"development"' : '"production"',
}, },
watch: isDevServer && { watch: isDevServer && {

View file

@ -4,8 +4,8 @@ import Basic from './basic'
import Controlled from './controlled' import Controlled from './controlled'
import Imperative from './imperative' import Imperative from './imperative'
import Embedded from './embedded' import Embedded from './embedded'
import NoSizeEmbedded from '+no-size-embedded' import NoSizeEmbedded from './no-size-embedded'
import LiveBlocks from './liveblocks' import { Multiplayer } from './multiplayer'
import ChangingId from './changing-id' import ChangingId from './changing-id'
import Core from './core' import Core from './core'
import './styles.css' import './styles.css'
@ -35,8 +35,8 @@ export default function App(): JSX.Element {
<Route path="/no-size-embedded"> <Route path="/no-size-embedded">
<NoSizeEmbedded /> <NoSizeEmbedded />
</Route> </Route>
<Route path="/liveblocks"> <Route path="/multiplayer">
<LiveBlocks /> <Multiplayer />
</Route> </Route>
<Route path="/"> <Route path="/">
<ul> <ul>
@ -62,7 +62,7 @@ export default function App(): JSX.Element {
<Link to="/no-size-embedded">embedded (no size)</Link> <Link to="/no-size-embedded">embedded (no size)</Link>
</li> </li>
<li> <li>
<Link to="/liveblocks">liveblocks</Link> <Link to="/multiplayer">multiplayer</Link>
</li> </li>
</ul> </ul>
</Route> </Route>

View file

@ -1,84 +0,0 @@
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

@ -0,0 +1,3 @@
export function Cursors() {
return <div>hi</div>
}

View file

@ -0,0 +1 @@
export * from './multiplayer'

View file

@ -0,0 +1,164 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import * as React from 'react'
import { TLDraw, TLDrawState, TLDrawDocument, TLDrawUser, Data } from '@tldraw/tldraw'
import { createClient, Presence } from '@liveblocks/client'
import {
LiveblocksProvider,
RoomProvider,
useErrorListener,
useObject,
useSelf,
useOthers,
useMyPresence,
} from '@liveblocks/react'
import { Utils } from '@tldraw/core'
interface TLDrawUserPresence extends Presence {
user: TLDrawUser
}
const publicAPIKey = 'pk_live_1LJGGaqBSNLjLT-4Jalkl-U9'
const client = createClient({
publicApiKey: publicAPIKey,
})
const ROOM_ID = 'mp-test-1'
export function Multiplayer() {
return (
<LiveblocksProvider client={client}>
<RoomProvider id={ROOM_ID}>
<TLDrawWrapper />
</RoomProvider>
</LiveblocksProvider>
)
}
function TLDrawWrapper() {
const [docId] = React.useState(() => Utils.uniqueId())
const [error, setError] = React.useState<Error>()
const [tlstate, setTlstate] = React.useState<TLDrawState>()
useErrorListener((err) => setError(err))
const doc = useObject<{ uuid: string; document: TLDrawDocument }>('doc', {
uuid: docId,
document: {
id: 'test-room',
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)
}, [])
const handleChange = React.useCallback(
(_tlstate: TLDrawState, state: Data, reason: string) => {
// If the client updates its document, update the room's document
if (reason.startsWith('command')) {
doc?.update({ uuid: docId, document: state.document })
}
// When the client updates its presence, update the room
if (reason === 'patch:room:self:update' && state.room) {
const room = client.getRoom(ROOM_ID)
if (!room) return
const { userId, users } = state.room
room.updatePresence({ id: userId, user: users[userId] })
}
},
[docId, doc]
)
React.useEffect(() => {
const room = client.getRoom(ROOM_ID)
if (!room) return
if (!doc) return
if (!tlstate) return
// Update the user's presence with the user from state
const { users, userId } = tlstate.state.room
room.updatePresence({ id: userId, user: users[userId] })
// Subscribe to presence changes; when others change, update the state
room.subscribe<TLDrawUserPresence>('others', (others) => {
tlstate.updateUsers(
others
.toArray()
.filter((other) => other.presence)
.map((other) => other.presence!.user)
.filter(Boolean)
)
})
room.subscribe('event', (event) => {
if (event.event?.name === 'exit') {
tlstate.removeUser(event.event.userId)
}
})
function handleDocumentUpdates() {
if (!doc) return
if (!tlstate) return
const docObject = doc.toObject()
// Only merge the change if it caused by someone else
if (docObject.uuid !== docId) {
tlstate.mergeDocument(docObject.document)
}
}
function handleExit() {
room?.broadcastEvent({ name: 'exit', userId: tlstate?.state.room.userId })
}
window.addEventListener('beforeunload', handleExit)
// When the shared document changes, update the state
doc.subscribe(handleDocumentUpdates)
// Load the shared document
tlstate.loadDocument(doc.toObject().document)
return () => {
window.removeEventListener('beforeunload', handleExit)
doc.unsubscribe(handleDocumentUpdates)
}
}, [doc, docId, tlstate])
if (error) return <div>Error: {error.message}</div>
if (doc === null) return <div>Loading...</div>
return (
<div className="tldraw">
<TLDraw onChange={handleChange} onMount={handleMount} showPages={false} />
</div>
)
}

View file

@ -31,6 +31,8 @@ const isSelectedShapeWithHandlesSelector = (s: Data) => {
const pageSelector = (s: Data) => s.document.pages[s.appState.currentPageId] const pageSelector = (s: Data) => s.document.pages[s.appState.currentPageId]
const usersSelector = (s: Data) => s.room?.users
const pageStateSelector = (s: Data) => s.document.pageStates[s.appState.currentPageId] const pageStateSelector = (s: Data) => s.document.pageStates[s.appState.currentPageId]
const isDarkModeSelector = (s: Data) => s.settings.isDarkMode const isDarkModeSelector = (s: Data) => s.settings.isDarkMode
@ -42,10 +44,12 @@ export interface TLDrawProps {
* (optional) If provided, the component will load / persist state under this key. * (optional) If provided, the component will load / persist state under this key.
*/ */
id?: string id?: string
/** /**
* (optional) The document to load or update from. * (optional) The document to load or update from.
*/ */
document?: TLDrawDocument document?: TLDrawDocument
/** /**
* (optional) The current page id. * (optional) The current page id.
*/ */
@ -55,10 +59,22 @@ export interface TLDrawProps {
* (optional) Whether the editor should immediately receive focus. Defaults to true. * (optional) Whether the editor should immediately receive focus. Defaults to true.
*/ */
autofocus?: boolean autofocus?: boolean
/**
* (optional) Whether to show the menu UI.
*/
showMenu?: boolean
/**
* (optional) Whether to show the pages UI.
*/
showPages?: boolean
/** /**
* (optional) A callback to run when the component mounts. * (optional) A callback to run when the component mounts.
*/ */
onMount?: (state: TLDrawState) => void onMount?: (state: TLDrawState) => void
/** /**
* (optional) A callback to run when the component's state changes. * (optional) A callback to run when the component's state changes.
*/ */
@ -70,6 +86,8 @@ export function TLDraw({
document, document,
currentPageId, currentPageId,
autofocus = true, autofocus = true,
showMenu = true,
showPages = true,
onMount, onMount,
onChange, onChange,
}: TLDrawProps) { }: TLDrawProps) {
@ -98,6 +116,8 @@ export function TLDraw({
currentPageId={currentPageId} currentPageId={currentPageId}
document={document} document={document}
autofocus={autofocus} autofocus={autofocus}
showPages={showPages}
showMenu={showMenu}
/> />
</IdProvider> </IdProvider>
</TLDrawContext.Provider> </TLDrawContext.Provider>
@ -108,11 +128,15 @@ function InnerTldraw({
id, id,
currentPageId, currentPageId,
autofocus, autofocus,
showPages,
showMenu,
document, document,
}: { }: {
id?: string id?: string
currentPageId?: string currentPageId?: string
autofocus?: boolean autofocus: boolean
showPages: boolean
showMenu: boolean
document?: TLDrawDocument document?: TLDrawDocument
}) { }) {
const { tlstate, useSelector } = useTLDrawContext() const { tlstate, useSelector } = useTLDrawContext()
@ -125,6 +149,8 @@ function InnerTldraw({
const pageState = useSelector(pageStateSelector) const pageState = useSelector(pageStateSelector)
const users = useSelector(usersSelector)
const isDarkMode = useSelector(isDarkModeSelector) const isDarkMode = useSelector(isDarkModeSelector)
const isFocusMode = useSelector(isFocusModeSelector) const isFocusMode = useSelector(isFocusModeSelector)
@ -188,6 +214,8 @@ function InnerTldraw({
id={id} id={id}
page={page} page={page}
pageState={pageState} pageState={pageState}
users={users}
userId={tlstate.state.room.userId}
shapeUtils={tldrawShapeUtils} shapeUtils={tldrawShapeUtils}
theme={theme} theme={theme}
meta={meta} meta={meta}
@ -253,8 +281,8 @@ function InnerTldraw({
) : ( ) : (
<> <>
<div className={menuButtons()}> <div className={menuButtons()}>
<Menu /> {showMenu && <Menu />}
<PagePanel /> {showPages && <PagePanel />}
</div> </div>
<div className={spacer()} /> <div className={spacer()} />
<StylePanel /> <StylePanel />

View file

@ -37,6 +37,7 @@ import {
TLDrawBinding, TLDrawBinding,
GroupShape, GroupShape,
TLDrawCommand, TLDrawCommand,
TLDrawUser,
} from '~types' } from '~types'
import { TLDR } from './tldr' import { TLDR } from './tldr'
import { defaultStyle } from '~shape' import { defaultStyle } from '~shape'
@ -66,6 +67,8 @@ const defaultDocument: TLDrawDocument = {
}, },
} }
const uuid = Utils.uniqueId()
const defaultState: Data = { const defaultState: Data = {
settings: { settings: {
isPenMode: false, isPenMode: false,
@ -94,6 +97,17 @@ const defaultState: Data = {
}, },
}, },
document: defaultDocument, document: defaultDocument,
room: {
id: 'local',
userId: uuid,
users: {
[uuid]: {
id: uuid,
color: 'dodgerBlue',
point: [100, 100],
},
},
},
} }
export class TLDrawState extends StateManager<Data> { export class TLDrawState extends StateManager<Data> {
@ -144,15 +158,10 @@ export class TLDrawState extends StateManager<Data> {
onChange?: (tlstate: TLDrawState, data: Data, reason: string) => void, onChange?: (tlstate: TLDrawState, data: Data, reason: string) => void,
onMount?: (tlstate: TLDrawState) => void onMount?: (tlstate: TLDrawState) => void
) { ) {
super(defaultState, id, 2, (prev, next, prevVersion) => { super(defaultState, id, 2.3, (prev, next) => ({
const state = { ...prev } ...next,
if (prevVersion === 1) ...prev,
state.settings = { }))
...state.settings,
isZoomSnap: next.settings.isZoomSnap,
}
return state
})
this._onChange = onChange this._onChange = onChange
this._onMount = onMount this._onMount = onMount
@ -327,6 +336,15 @@ export class TLDrawState extends StateManager<Data> {
}) })
} }
// Remove any exited users
if (data.room !== prev.room) {
Object.values(prev.room.users).forEach((user) => {
if (data.room.users[user.id] === undefined) {
delete data.room.users[user.id]
}
})
}
const currentPageId = data.appState.currentPageId const currentPageId = data.appState.currentPageId
// Apply selected style change, if any // Apply selected style change, if any
@ -340,12 +358,6 @@ export class TLDrawState extends StateManager<Data> {
} }
} }
// Check that the correct page id is active (delete me?)
if (data.document.pageStates[currentPageId].id !== currentPageId) {
throw Error('Current page id is not the current page state!')
}
return data return data
} }
@ -543,6 +555,31 @@ export class TLDrawState extends StateManager<Data> {
return this return this
} }
/**
*
* @param document
*/
updateUsers = (users: TLDrawUser[], isOwnUpdate = false) => {
this.patchState(
{
room: {
users: Object.fromEntries(users.map((user) => [user.id, user])),
},
},
isOwnUpdate ? 'room:self:update' : 'room:user:update'
)
}
removeUser = (userId: string) => {
this.patchState({
room: {
users: {
[userId]: undefined,
},
},
})
}
/** /**
* Merge a new document patch into the current document. * Merge a new document patch into the current document.
* @param document * @param document
@ -619,15 +656,6 @@ export class TLDrawState extends StateManager<Data> {
currentPageStates[this.currentPageId].selectedIds = [editingId] currentPageStates[this.currentPageId].selectedIds = [editingId]
} }
console.log('next state', {
...this.state,
appState: nextAppState,
document: {
...document,
pageStates: currentPageStates,
},
})
return this.replaceState( return this.replaceState(
{ {
...this.state, ...this.state,
@ -2567,6 +2595,20 @@ export class TLDrawState extends StateManager<Data> {
/* ----------------- Pointer Events ----------------- */ /* ----------------- Pointer Events ----------------- */
onPointerMove: TLPointerEventHandler = (info) => { onPointerMove: TLPointerEventHandler = (info) => {
if (this.state.room) {
const { users, userId } = this.state.room
this.updateUsers(
[
{
...users[userId],
point: this.getPagePoint(info.point),
},
],
true
)
}
// Several events (e.g. pan) can trigger the same "pointer move" behavior // Several events (e.g. pan) can trigger the same "pointer move" behavior
this.updateOnPointerMove(info) this.updateOnPointerMove(info)
} }

View file

@ -2,7 +2,7 @@
/* eslint-disable @typescript-eslint/ban-types */ /* eslint-disable @typescript-eslint/ban-types */
import type { TLBinding, TLShapeProps } from '@tldraw/core' import type { TLBinding, TLShapeProps } from '@tldraw/core'
import type { TLShape, TLShapeUtil, TLHandle } from '@tldraw/core' import type { TLShape, TLShapeUtil, TLHandle } from '@tldraw/core'
import type { TLPage, TLPageState } from '@tldraw/core' import type { TLPage, TLUser, TLPageState } from '@tldraw/core'
import type { StoreApi } from 'zustand' import type { StoreApi } from 'zustand'
import type { Command, Patch } from 'rko' import type { Command, Patch } from 'rko'
@ -29,10 +29,21 @@ export interface TLDrawSettings {
isFocusMode: boolean isFocusMode: boolean
} }
export enum TLUserStatus {
Idle = 'idle',
Connecting = 'connecting',
Connected = 'connected',
Disconnected = 'disconnected',
}
export interface TLDrawMeta { export interface TLDrawMeta {
isDarkMode: boolean isDarkMode: boolean
} }
export interface TLDrawUser extends TLUser {
id: string
}
export type TLDrawShapeProps<T extends TLDrawShape, E extends Element> = TLShapeProps< export type TLDrawShapeProps<T extends TLDrawShape, E extends Element> = TLShapeProps<
T, T,
E, E,
@ -40,7 +51,6 @@ export type TLDrawShapeProps<T extends TLDrawShape, E extends Element> = TLShape
> >
export interface Data { export interface Data {
document: TLDrawDocument
settings: TLDrawSettings settings: TLDrawSettings
appState: { appState: {
selectedStyle: ShapeStyles selectedStyle: ShapeStyles
@ -55,6 +65,12 @@ export interface Data {
isEmptyCanvas: boolean isEmptyCanvas: boolean
status: { current: TLDrawStatus; previous: TLDrawStatus } status: { current: TLDrawStatus; previous: TLDrawStatus }
} }
document: TLDrawDocument
room: {
id: string
userId: string
users: Record<string, TLDrawUser>
}
} }
export type TLDrawPatch = Patch<Data> export type TLDrawPatch = Patch<Data>

View file

@ -1,12 +1,16 @@
import { TLDraw, TLDrawState, Data, TLDrawDocument } from '@tldraw/tldraw' import { TLDraw, TLDrawState, Data, TLDrawDocument, TLDrawUser } from '@tldraw/tldraw'
import * as gtag from '-utils/gtag' import * as gtag from '-utils/gtag'
import * as React from 'react' import * as React from 'react'
import { createClient } from '@liveblocks/client' import { createClient, Presence } from '@liveblocks/client'
import { LiveblocksProvider, RoomProvider, useObject, useErrorListener } from '@liveblocks/react' import { LiveblocksProvider, RoomProvider, useObject, useErrorListener } from '@liveblocks/react'
import { Utils } from '@tldraw/core' import { Utils } from '@tldraw/core'
interface TLDrawPresence extends Presence {
user: TLDrawUser
}
const client = createClient({ const client = createClient({
publicApiKey: 'pk_live_1LJGGaqBSNLjLT-4Jalkl-U9', publicApiKey: process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_API_KEY,
}) })
export default function MultiplayerEditor({ id }: { id: string }) { export default function MultiplayerEditor({ id }: { id: string }) {
@ -20,19 +24,18 @@ export default function MultiplayerEditor({ id }: { id: string }) {
} }
function Editor({ id }: { id: string }) { function Editor({ id }: { id: string }) {
const [uuid] = React.useState(() => Utils.uniqueId()) const [docId] = React.useState(() => Utils.uniqueId())
const [error, setError] = React.useState<Error>(null)
const [error, setError] = React.useState<Error>()
const [tlstate, setTlstate] = React.useState<TLDrawState>() const [tlstate, setTlstate] = React.useState<TLDrawState>()
useErrorListener((err) => { useErrorListener((err) => setError(err))
console.log(err)
setError(err)
})
const doc = useObject<{ uuid: string; document: TLDrawDocument }>('doc', { const doc = useObject<{ uuid: string; document: TLDrawDocument }>('doc', {
uuid, uuid: docId,
document: { document: {
id, id: 'test-room',
pages: { pages: {
page: { page: {
id: 'page', id: 'page',
@ -61,9 +64,9 @@ function Editor({ id }: { id: string }) {
setTlstate(tlstate) setTlstate(tlstate)
}, []) }, [])
// Send events to gtag as actions.
const handleChange = React.useCallback( const handleChange = React.useCallback(
(_tlstate: TLDrawState, state: Data, reason: string) => { (_tlstate: TLDrawState, state: Data, reason: string) => {
// If the client updates its document, update the room's document / gtag
if (reason.startsWith('command')) { if (reason.startsWith('command')) {
gtag.event({ gtag.event({
action: reason, action: reason,
@ -72,37 +75,86 @@ function Editor({ id }: { id: string }) {
value: 0, value: 0,
}) })
if (doc) { doc?.update({ uuid: docId, document: state.document })
doc.update({ uuid, document: state.document })
} }
// When the client updates its presence, update the room
if (reason === 'patch:room:self:update' && state.room) {
const room = client.getRoom(id)
if (!room) return
const { userId, users } = state.room
room.updatePresence({ id: userId, user: users[userId] })
} }
}, },
[uuid, doc] [docId, doc, id]
) )
React.useEffect(() => { React.useEffect(() => {
const room = client.getRoom(id)
if (!room) return
if (!doc) return
if (!tlstate) return
// Update the user's presence with the user from state
const { users, userId } = tlstate.state.room
room.updatePresence({ id: userId, user: users[userId] })
// Subscribe to presence changes; when others change, update the state
room.subscribe<TLDrawPresence>('others', (others) => {
tlstate.updateUsers(
others
.toArray()
.filter((other) => other.presence)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
.map((other) => other.presence!.user)
.filter(Boolean)
)
})
room.subscribe('event', (event) => {
if (event.event?.name === 'exit') {
tlstate.removeUser(event.event.userId)
}
})
function handleDocumentUpdates() {
if (!doc) return if (!doc) return
if (!tlstate) return if (!tlstate) return
function updateState() {
const docObject = doc.toObject() const docObject = doc.toObject()
if (docObject.uuid === uuid) return
// Only merge the change if it caused by someone else
if (docObject.uuid !== docId) {
tlstate.mergeDocument(docObject.document) tlstate.mergeDocument(docObject.document)
} }
}
updateState() function handleExit() {
doc.subscribe(updateState) room?.broadcastEvent({ name: 'exit', userId: tlstate?.state.room.userId })
}
return () => doc.unsubscribe(updateState) window.addEventListener('beforeunload', handleExit)
}, [doc, uuid, tlstate])
// When the shared document changes, update the state
doc.subscribe(handleDocumentUpdates)
// Load the shared document
tlstate.loadDocument(doc.toObject().document)
return () => {
window.removeEventListener('beforeunload', handleExit)
doc.unsubscribe(handleDocumentUpdates)
}
}, [doc, docId, tlstate, id])
if (error) return <div>Error: {error.message}</div> if (error) return <div>Error: {error.message}</div>
if (doc === null) return <div>loading...</div> if (doc === null) return <div>Loading...</div>
return ( return (
<div className="tldraw"> <div className="tldraw">
<TLDraw onMount={handleMount} onChange={handleChange} autofocus /> <TLDraw onMount={handleMount} onChange={handleChange} showPages={false} autofocus />
</div> </div>
) )
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long