tldraw
This is a real-time collaborative example of the tldraw whiteboard editor with CreateReactApp and Yorkie JS SDK.
useMultiplayerState.ts
1/* eslint-disable jsdoc/require-jsdoc */2import { useCallback, useEffect, useState } from 'react';3import {4 TDUserStatus,5 TDAsset,6 TDBinding,7 TDShape,8 TDUser,9 TldrawApp,10} from '@tldraw/tldraw';11import { useThrottleCallback } from '@react-hook/throttle';12import * as yorkie from 'yorkie-js-sdk';13import randomColor from 'randomcolor';14import { uniqueNamesGenerator, names } from 'unique-names-generator';1516import type { Options, YorkieDocType } from './types';1718// Yorkie Client declaration19let client: yorkie.Client<yorkie.Indexable>;2021// Yorkie Document declaration22let doc: yorkie.Document<yorkie.Indexable>;2324export function useMultiplayerState(roomId: string) {25 const [app, setApp] = useState<TldrawApp>();26 const [loading, setLoading] = useState(true);2728 // Callbacks --------------2930 const onMount = useCallback(31 (app: TldrawApp) => {32 app.loadRoom(roomId);33 app.setIsLoading(true);34 app.pause();35 setApp(app);3637 const randomName = uniqueNamesGenerator({38 dictionaries: [names],39 });4041 // On mount, create new user42 app.updateUsers([43 {44 id: app!.currentUser!.id,45 point: [0, 0],46 color: randomColor(),47 status: TDUserStatus.Connected,48 activeShapes: [],49 selectedIds: [],50 metadata: { name: randomName }, // <-- custom metadata51 },52 ]);53 },54 [roomId],55 );5657 // Update Yorkie doc when the app's shapes change.58 // Prevent overloading yorkie update api call by throttle59 const onChangePage = useThrottleCallback(60 (61 app: TldrawApp,62 shapes: Record<string, TDShape | undefined>,63 bindings: Record<string, TDBinding | undefined>,64 ) => {65 if (!app || client === undefined || doc === undefined) return;6667 doc.update((root) => {68 Object.entries(shapes).forEach(([id, shape]) => {69 if (!shape) {70 delete root.shapes[id];71 } else {72 root.shapes[id] = shape;73 }74 });7576 Object.entries(bindings).forEach(([id, binding]) => {77 if (!binding) {78 delete root.bindings[id];79 } else {80 root.bindings[id] = binding;81 }82 });8384 // Should store app.document.assets which is global asset storage referenced by inner page assets85 // Document key for assets should be asset.id (string), not index86 Object.entries(app.assets).forEach(([, asset]) => {87 if (!asset.id) {88 delete root.assets[asset.id];89 } else {90 root.assets[asset.id] = asset;91 }92 });93 });94 },95 60,96 false,97 );9899 // Handle presence updates when the user's pointer / selection changes100 const onChangePresence = useThrottleCallback(101 (app: TldrawApp, user: TDUser) => {102 if (!app || client === undefined || !client.isActive()) return;103104 client.updatePresence('user', user);105 },106 60,107 false,108 );109110 // Document Changes --------111112 useEffect(() => {113 if (!app) return;114115 // Detach & deactive yorkie client before unload116 function handleDisconnect() {117 if (client === undefined || doc === undefined) return;118119 client.detach(doc);120 client.deactivate();121 }122123 window.addEventListener('beforeunload', handleDisconnect);124125 // Subscribe to changes126 function handleChanges() {127 const root = doc.getRoot();128129 // Parse proxy object to record130 const shapeRecord: Record<string, TDShape> = JSON.parse(131 root.shapes.toJSON(),132 );133 const bindingRecord: Record<string, TDBinding> = JSON.parse(134 root.bindings.toJSON(),135 );136 const assetRecord: Record<string, TDAsset> = JSON.parse(137 root.assets.toJSON(),138 );139140 // Replace page content with changed(propagated) records141 app?.replacePageContent(shapeRecord, bindingRecord, assetRecord);142 }143144 let stillAlive = true;145146 // Setup the document's storage and subscriptions147 async function setupDocument() {148 try {149 // 01. Create client with RPCAddr(envoy) and options with presence and apiKey if provided.150 // Then activate client.151 const options: Options = {152 apiKey: import.meta.env.VITE_YORKIE_API_KEY,153 presence: {154 user: app?.currentUser,155 },156 syncLoopDuration: 0,157 reconnectStreamDelay: 1000,158 };159160 client = new yorkie.Client(161 import.meta.env.VITE_YORKIE_API_ADDR,162 options,163 );164 await client.activate();165166 // 01-1. Subscribe peers-changed event and update tldraw users state167 client.subscribe((event) => {168 if (event.type !== 'peers-changed') return;169170 const { type, peers } = event.value;171 // remove leaved users172 if (type === 'unwatched') {173 peers[doc.getKey()].map((peer) => {174 app?.removeUser(peer.presence.user.id);175 });176 }177178 // update users179 const allPeers = client180 .getPeersByDocKey(doc.getKey())181 .map((peer) => peer.presence.user);182 app?.updateUsers(allPeers);183 });184185 // 02. Create document with tldraw custom object type, then attach it into the client.186 doc = new yorkie.Document<YorkieDocType>(roomId);187 await client.attach(doc);188189 // 03. Initialize document if document not exists.190 doc.update((root) => {191 if (!root.shapes) {192 root.shapes = {};193 }194 if (!root.bindings) {195 root.bindings = {};196 }197 if (!root.assets) {198 root.assets = {};199 }200 }, 'create shapes/bindings/assets object if not exists');201202 // 04. Subscribe document event and handle changes.203 doc.subscribe((event) => {204 if (event.type === 'remote-change') {205 handleChanges();206 }207 });208209 // 05. Sync client to sync document with other peers.210 await client.sync();211212 if (stillAlive) {213 // Update the document with initial content214 handleChanges();215216 // Zoom to fit the content & finish loading217 if (app) {218 app.zoomToFit();219 if (app.zoom > 1) {220 app.resetZoom();221 }222 app.setIsLoading(false);223 }224225 setLoading(false);226 }227 } catch (e) {228 console.error(e);229 }230 }231232 setupDocument();233234 return () => {235 window.removeEventListener('beforeunload', handleDisconnect);236 stillAlive = false;237 };238 }, [app]);239240 return {241 onMount,242 onChangePage,243 loading,244 onChangePresence,245 };246}
- User 1
- User 2
- User 1
- User 2
Event Log