[feature] Live cursors MVP (#137)
* Adds very basic live cursors * Adds ability to hide pages / menu
This commit is contained in:
parent
99730b4fe2
commit
d6b38ed79e
22 changed files with 455 additions and 154 deletions
|
@ -8,11 +8,12 @@ import {
|
|||
useCameraCss,
|
||||
useKeyEvents,
|
||||
} 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 { ErrorBoundary } from '+components/error-boundary'
|
||||
import { Brush } from '+components/brush'
|
||||
import { Page } from '+components/page'
|
||||
import { Users } from '+components/users'
|
||||
import { useResizeObserver } from '+hooks/useResizeObserver'
|
||||
import { inputs } from '+inputs'
|
||||
|
||||
|
@ -23,6 +24,8 @@ function resetError() {
|
|||
interface CanvasProps<T extends TLShape, M extends Record<string, unknown>> {
|
||||
page: TLPage<T, TLBinding>
|
||||
pageState: TLPageState
|
||||
users?: TLUsers
|
||||
userId?: string
|
||||
hideBounds?: boolean
|
||||
hideHandles?: boolean
|
||||
hideIndicators?: boolean
|
||||
|
@ -34,6 +37,8 @@ export function Canvas<T extends TLShape, M extends Record<string, unknown>>({
|
|||
id,
|
||||
page,
|
||||
pageState,
|
||||
users,
|
||||
userId,
|
||||
meta,
|
||||
hideHandles = false,
|
||||
hideBounds = false,
|
||||
|
@ -83,6 +88,7 @@ export function Canvas<T extends TLShape, M extends Record<string, unknown>>({
|
|||
meta={meta}
|
||||
/>
|
||||
{pageState.brush && <Brush brush={pageState.brush} />}
|
||||
{users && <Users userId={userId} users={users} />}
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
|
|
|
@ -12,7 +12,7 @@ import type {
|
|||
import { Canvas } from '../canvas'
|
||||
import { Inputs } from '../../inputs'
|
||||
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>
|
||||
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.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
shapeUtils: Record<T['type'], TLShapeUtil<T, E, M>>
|
||||
/**
|
||||
* 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
|
||||
/**
|
||||
* 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>
|
||||
/**
|
||||
* 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
|
||||
/**
|
||||
* 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
|
||||
/**
|
||||
* 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,
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
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
|
||||
/**
|
||||
|
@ -82,6 +89,8 @@ export function Renderer<T extends TLShape, E extends Element, M extends Record<
|
|||
shapeUtils,
|
||||
page,
|
||||
pageState,
|
||||
users,
|
||||
userId,
|
||||
theme,
|
||||
meta,
|
||||
hideHandles = false,
|
||||
|
@ -118,6 +127,8 @@ export function Renderer<T extends TLShape, E extends Element, M extends Record<
|
|||
id={id}
|
||||
page={page}
|
||||
pageState={pageState}
|
||||
users={users}
|
||||
userId={userId}
|
||||
hideBounds={hideBounds}
|
||||
hideIndicators={hideIndicators}
|
||||
hideHandles={hideHandles}
|
||||
|
|
1
packages/core/src/components/user/index.ts
Normal file
1
packages/core/src/components/user/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './user'
|
21
packages/core/src/components/user/user.tsx
Normal file
21
packages/core/src/components/user/user.tsx
Normal 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)`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
1
packages/core/src/components/users/index.ts
Normal file
1
packages/core/src/components/users/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './users'
|
20
packages/core/src/components/users/users.tsx
Normal file
20
packages/core/src/components/users/users.tsx
Normal 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} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -233,6 +233,15 @@ const tlcss = css`
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tl-user {
|
||||
left: -4px;
|
||||
top: -4px;
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
border-radius: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tl-selected {
|
||||
fill: transparent;
|
||||
stroke: var(--tl-selectStroke);
|
||||
|
|
|
@ -16,6 +16,8 @@ export interface TLPage<T extends TLShape, B extends TLBinding> {
|
|||
bindings: Record<string, B>
|
||||
}
|
||||
|
||||
export type TLUsers<U extends TLUser = TLUser> = Record<string, U>
|
||||
|
||||
export interface TLPageState {
|
||||
id: string
|
||||
selectedIds: string[]
|
||||
|
@ -32,6 +34,12 @@ export interface TLPageState {
|
|||
currentParentId?: string | null
|
||||
}
|
||||
|
||||
export interface TLUser {
|
||||
id: string
|
||||
color: string
|
||||
point: number[]
|
||||
}
|
||||
|
||||
export interface TLHandle {
|
||||
id: string
|
||||
index: number
|
||||
|
|
1
packages/dev/.env.local
Normal file
1
packages/dev/.env.local
Normal file
|
@ -0,0 +1 @@
|
|||
LIVEBLOCKS_PUBLIC_API_KEY=pk_live_1LJGGaqBSNLjLT-4Jalkl-U9
|
|
@ -23,6 +23,7 @@ esbuild
|
|||
incremental: isDevServer,
|
||||
target: ['chrome58', 'firefox57', 'safari11', 'edge18'],
|
||||
define: {
|
||||
'process.env.LIVEBLOCKS_PUBLIC_API_KEY': process.env.LIVEBLOCKS_PUBLIC_API_KEY,
|
||||
'process.env.NODE_ENV': isDevServer ? '"development"' : '"production"',
|
||||
},
|
||||
watch: isDevServer && {
|
||||
|
|
|
@ -4,8 +4,8 @@ import Basic from './basic'
|
|||
import Controlled from './controlled'
|
||||
import Imperative from './imperative'
|
||||
import Embedded from './embedded'
|
||||
import NoSizeEmbedded from '+no-size-embedded'
|
||||
import LiveBlocks from './liveblocks'
|
||||
import NoSizeEmbedded from './no-size-embedded'
|
||||
import { Multiplayer } from './multiplayer'
|
||||
import ChangingId from './changing-id'
|
||||
import Core from './core'
|
||||
import './styles.css'
|
||||
|
@ -35,8 +35,8 @@ export default function App(): JSX.Element {
|
|||
<Route path="/no-size-embedded">
|
||||
<NoSizeEmbedded />
|
||||
</Route>
|
||||
<Route path="/liveblocks">
|
||||
<LiveBlocks />
|
||||
<Route path="/multiplayer">
|
||||
<Multiplayer />
|
||||
</Route>
|
||||
<Route path="/">
|
||||
<ul>
|
||||
|
@ -62,7 +62,7 @@ export default function App(): JSX.Element {
|
|||
<Link to="/no-size-embedded">embedded (no size)</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/liveblocks">liveblocks</Link>
|
||||
<Link to="/multiplayer">multiplayer</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</Route>
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
3
packages/dev/src/multiplayer/cursors.tsx
Normal file
3
packages/dev/src/multiplayer/cursors.tsx
Normal file
|
@ -0,0 +1,3 @@
|
|||
export function Cursors() {
|
||||
return <div>hi</div>
|
||||
}
|
1
packages/dev/src/multiplayer/index.ts
Normal file
1
packages/dev/src/multiplayer/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './multiplayer'
|
164
packages/dev/src/multiplayer/multiplayer.tsx
Normal file
164
packages/dev/src/multiplayer/multiplayer.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -31,6 +31,8 @@ const isSelectedShapeWithHandlesSelector = (s: Data) => {
|
|||
|
||||
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 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.
|
||||
*/
|
||||
id?: string
|
||||
|
||||
/**
|
||||
* (optional) The document to load or update from.
|
||||
*/
|
||||
document?: TLDrawDocument
|
||||
|
||||
/**
|
||||
* (optional) The current page id.
|
||||
*/
|
||||
|
@ -55,10 +59,22 @@ export interface TLDrawProps {
|
|||
* (optional) Whether the editor should immediately receive focus. Defaults to true.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
onMount?: (state: TLDrawState) => void
|
||||
|
||||
/**
|
||||
* (optional) A callback to run when the component's state changes.
|
||||
*/
|
||||
|
@ -70,6 +86,8 @@ export function TLDraw({
|
|||
document,
|
||||
currentPageId,
|
||||
autofocus = true,
|
||||
showMenu = true,
|
||||
showPages = true,
|
||||
onMount,
|
||||
onChange,
|
||||
}: TLDrawProps) {
|
||||
|
@ -98,6 +116,8 @@ export function TLDraw({
|
|||
currentPageId={currentPageId}
|
||||
document={document}
|
||||
autofocus={autofocus}
|
||||
showPages={showPages}
|
||||
showMenu={showMenu}
|
||||
/>
|
||||
</IdProvider>
|
||||
</TLDrawContext.Provider>
|
||||
|
@ -108,11 +128,15 @@ function InnerTldraw({
|
|||
id,
|
||||
currentPageId,
|
||||
autofocus,
|
||||
showPages,
|
||||
showMenu,
|
||||
document,
|
||||
}: {
|
||||
id?: string
|
||||
currentPageId?: string
|
||||
autofocus?: boolean
|
||||
autofocus: boolean
|
||||
showPages: boolean
|
||||
showMenu: boolean
|
||||
document?: TLDrawDocument
|
||||
}) {
|
||||
const { tlstate, useSelector } = useTLDrawContext()
|
||||
|
@ -125,6 +149,8 @@ function InnerTldraw({
|
|||
|
||||
const pageState = useSelector(pageStateSelector)
|
||||
|
||||
const users = useSelector(usersSelector)
|
||||
|
||||
const isDarkMode = useSelector(isDarkModeSelector)
|
||||
|
||||
const isFocusMode = useSelector(isFocusModeSelector)
|
||||
|
@ -188,6 +214,8 @@ function InnerTldraw({
|
|||
id={id}
|
||||
page={page}
|
||||
pageState={pageState}
|
||||
users={users}
|
||||
userId={tlstate.state.room.userId}
|
||||
shapeUtils={tldrawShapeUtils}
|
||||
theme={theme}
|
||||
meta={meta}
|
||||
|
@ -253,8 +281,8 @@ function InnerTldraw({
|
|||
) : (
|
||||
<>
|
||||
<div className={menuButtons()}>
|
||||
<Menu />
|
||||
<PagePanel />
|
||||
{showMenu && <Menu />}
|
||||
{showPages && <PagePanel />}
|
||||
</div>
|
||||
<div className={spacer()} />
|
||||
<StylePanel />
|
||||
|
|
|
@ -37,6 +37,7 @@ import {
|
|||
TLDrawBinding,
|
||||
GroupShape,
|
||||
TLDrawCommand,
|
||||
TLDrawUser,
|
||||
} from '~types'
|
||||
import { TLDR } from './tldr'
|
||||
import { defaultStyle } from '~shape'
|
||||
|
@ -66,6 +67,8 @@ const defaultDocument: TLDrawDocument = {
|
|||
},
|
||||
}
|
||||
|
||||
const uuid = Utils.uniqueId()
|
||||
|
||||
const defaultState: Data = {
|
||||
settings: {
|
||||
isPenMode: false,
|
||||
|
@ -94,6 +97,17 @@ const defaultState: Data = {
|
|||
},
|
||||
},
|
||||
document: defaultDocument,
|
||||
room: {
|
||||
id: 'local',
|
||||
userId: uuid,
|
||||
users: {
|
||||
[uuid]: {
|
||||
id: uuid,
|
||||
color: 'dodgerBlue',
|
||||
point: [100, 100],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export class TLDrawState extends StateManager<Data> {
|
||||
|
@ -144,15 +158,10 @@ export class TLDrawState extends StateManager<Data> {
|
|||
onChange?: (tlstate: TLDrawState, data: Data, reason: string) => void,
|
||||
onMount?: (tlstate: TLDrawState) => void
|
||||
) {
|
||||
super(defaultState, id, 2, (prev, next, prevVersion) => {
|
||||
const state = { ...prev }
|
||||
if (prevVersion === 1)
|
||||
state.settings = {
|
||||
...state.settings,
|
||||
isZoomSnap: next.settings.isZoomSnap,
|
||||
}
|
||||
return state
|
||||
})
|
||||
super(defaultState, id, 2.3, (prev, next) => ({
|
||||
...next,
|
||||
...prev,
|
||||
}))
|
||||
|
||||
this._onChange = onChange
|
||||
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
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
|
@ -543,6 +555,31 @@ export class TLDrawState extends StateManager<Data> {
|
|||
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.
|
||||
* @param document
|
||||
|
@ -619,15 +656,6 @@ export class TLDrawState extends StateManager<Data> {
|
|||
currentPageStates[this.currentPageId].selectedIds = [editingId]
|
||||
}
|
||||
|
||||
console.log('next state', {
|
||||
...this.state,
|
||||
appState: nextAppState,
|
||||
document: {
|
||||
...document,
|
||||
pageStates: currentPageStates,
|
||||
},
|
||||
})
|
||||
|
||||
return this.replaceState(
|
||||
{
|
||||
...this.state,
|
||||
|
@ -2567,6 +2595,20 @@ export class TLDrawState extends StateManager<Data> {
|
|||
/* ----------------- Pointer Events ----------------- */
|
||||
|
||||
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
|
||||
this.updateOnPointerMove(info)
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
/* eslint-disable @typescript-eslint/ban-types */
|
||||
import type { TLBinding, TLShapeProps } 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 { Command, Patch } from 'rko'
|
||||
|
||||
|
@ -29,10 +29,21 @@ export interface TLDrawSettings {
|
|||
isFocusMode: boolean
|
||||
}
|
||||
|
||||
export enum TLUserStatus {
|
||||
Idle = 'idle',
|
||||
Connecting = 'connecting',
|
||||
Connected = 'connected',
|
||||
Disconnected = 'disconnected',
|
||||
}
|
||||
|
||||
export interface TLDrawMeta {
|
||||
isDarkMode: boolean
|
||||
}
|
||||
|
||||
export interface TLDrawUser extends TLUser {
|
||||
id: string
|
||||
}
|
||||
|
||||
export type TLDrawShapeProps<T extends TLDrawShape, E extends Element> = TLShapeProps<
|
||||
T,
|
||||
E,
|
||||
|
@ -40,7 +51,6 @@ export type TLDrawShapeProps<T extends TLDrawShape, E extends Element> = TLShape
|
|||
>
|
||||
|
||||
export interface Data {
|
||||
document: TLDrawDocument
|
||||
settings: TLDrawSettings
|
||||
appState: {
|
||||
selectedStyle: ShapeStyles
|
||||
|
@ -55,6 +65,12 @@ export interface Data {
|
|||
isEmptyCanvas: boolean
|
||||
status: { current: TLDrawStatus; previous: TLDrawStatus }
|
||||
}
|
||||
document: TLDrawDocument
|
||||
room: {
|
||||
id: string
|
||||
userId: string
|
||||
users: Record<string, TLDrawUser>
|
||||
}
|
||||
}
|
||||
|
||||
export type TLDrawPatch = Patch<Data>
|
||||
|
|
|
@ -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 React from 'react'
|
||||
import { createClient } from '@liveblocks/client'
|
||||
import { createClient, Presence } from '@liveblocks/client'
|
||||
import { LiveblocksProvider, RoomProvider, useObject, useErrorListener } from '@liveblocks/react'
|
||||
import { Utils } from '@tldraw/core'
|
||||
|
||||
interface TLDrawPresence extends Presence {
|
||||
user: TLDrawUser
|
||||
}
|
||||
|
||||
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 }) {
|
||||
|
@ -20,19 +24,18 @@ export default function MultiplayerEditor({ id }: { id: string }) {
|
|||
}
|
||||
|
||||
function Editor({ id }: { id: string }) {
|
||||
const [uuid] = React.useState(() => Utils.uniqueId())
|
||||
const [error, setError] = React.useState<Error>(null)
|
||||
const [docId] = React.useState(() => Utils.uniqueId())
|
||||
|
||||
const [error, setError] = React.useState<Error>()
|
||||
|
||||
const [tlstate, setTlstate] = React.useState<TLDrawState>()
|
||||
|
||||
useErrorListener((err) => {
|
||||
console.log(err)
|
||||
setError(err)
|
||||
})
|
||||
useErrorListener((err) => setError(err))
|
||||
|
||||
const doc = useObject<{ uuid: string; document: TLDrawDocument }>('doc', {
|
||||
uuid,
|
||||
uuid: docId,
|
||||
document: {
|
||||
id,
|
||||
id: 'test-room',
|
||||
pages: {
|
||||
page: {
|
||||
id: 'page',
|
||||
|
@ -61,9 +64,9 @@ function Editor({ id }: { id: string }) {
|
|||
setTlstate(tlstate)
|
||||
}, [])
|
||||
|
||||
// Send events to gtag as actions.
|
||||
const handleChange = React.useCallback(
|
||||
(_tlstate: TLDrawState, state: Data, reason: string) => {
|
||||
// If the client updates its document, update the room's document / gtag
|
||||
if (reason.startsWith('command')) {
|
||||
gtag.event({
|
||||
action: reason,
|
||||
|
@ -72,37 +75,86 @@ function Editor({ id }: { id: string }) {
|
|||
value: 0,
|
||||
})
|
||||
|
||||
if (doc) {
|
||||
doc.update({ uuid, document: state.document })
|
||||
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(id)
|
||||
if (!room) return
|
||||
const { userId, users } = state.room
|
||||
room.updatePresence({ id: userId, user: users[userId] })
|
||||
}
|
||||
},
|
||||
[uuid, doc]
|
||||
[docId, doc, id]
|
||||
)
|
||||
|
||||
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 (!tlstate) return
|
||||
|
||||
function updateState() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
updateState()
|
||||
doc.subscribe(updateState)
|
||||
function handleExit() {
|
||||
room?.broadcastEvent({ name: 'exit', userId: tlstate?.state.room.userId })
|
||||
}
|
||||
|
||||
return () => doc.unsubscribe(updateState)
|
||||
}, [doc, uuid, tlstate])
|
||||
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, id])
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>
|
||||
|
||||
if (doc === null) return <div>loading...</div>
|
||||
if (doc === null) return <div>Loading...</div>
|
||||
|
||||
return (
|
||||
<div className="tldraw">
|
||||
<TLDraw onMount={handleMount} onChange={handleChange} autofocus />
|
||||
<TLDraw onMount={handleMount} onChange={handleChange} showPages={false} autofocus />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue