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';15import _ from 'lodash';1617import type { YorkieDocType, YorkiePresenceType } from './types';1819// Yorkie Client declaration20let client: yorkie.Client;2122// Yorkie Document declaration23let doc: yorkie.Document<YorkieDocType, YorkiePresenceType>;2425export function useMultiplayerState(roomId: string) {26 const [app, setApp] = useState<TldrawApp>();27 const [loading, setLoading] = useState(true);2829 // Callbacks --------------3031 const onMount = useCallback(32 (app: TldrawApp) => {33 app.loadRoom(roomId);34 app.setIsLoading(true);35 app.pause();36 setApp(app);3738 const randomName = uniqueNamesGenerator({39 dictionaries: [names],40 });4142 // On mount, create new user43 app.updateUsers([44 {45 id: app!.currentUser!.id,46 point: [0, 0],47 color: randomColor(),48 status: TDUserStatus.Connected,49 activeShapes: [],50 selectedIds: [],51 metadata: { name: randomName }, // <-- custom metadata52 },53 ]);54 },55 [roomId],56 );5758 // Update Yorkie doc when the app's shapes change.59 // Prevent overloading yorkie update api call by throttle60 const onChangePage = useThrottleCallback(61 (62 app: TldrawApp,63 shapes: Record<string, TDShape | undefined>,64 bindings: Record<string, TDBinding | undefined>,65 ) => {66 if (!app || client === undefined || doc === undefined) return;6768 const getUpdatedPropertyList = <T extends object>(69 source: T,70 target: T,71 ) => {72 return (Object.keys(source) as Array<keyof T>).filter(73 (key) => !_.isEqual(source[key], target[key]),74 );75 };7677 Object.entries(shapes).forEach(([id, shape]) => {78 doc.update((root) => {79 if (!shape) {80 delete root.shapes[id];81 } else if (!root.shapes[id]) {82 root.shapes[id] = shape;83 } else {84 const updatedPropertyList = getUpdatedPropertyList(85 shape,86 root.shapes[id]!.toJS!(),87 );8889 updatedPropertyList.forEach((key) => {90 const newValue = shape[key];91 (root.shapes[id][key] as typeof newValue) = newValue;92 });93 }94 });95 });9697 Object.entries(bindings).forEach(([id, binding]) => {98 doc.update((root) => {99 if (!binding) {100 delete root.bindings[id];101 } else if (!root.bindings[id]) {102 root.bindings[id] = binding;103 } else {104 const updatedPropertyList = getUpdatedPropertyList(105 binding,106 root.bindings[id]!.toJS!(),107 );108109 updatedPropertyList.forEach((key) => {110 const newValue = binding[key];111 (root.bindings[id][key] as typeof newValue) = newValue;112 });113 }114 });115 });116117 // Should store app.document.assets which is global asset storage referenced by inner page assets118 // Document key for assets should be asset.id (string), not index119 Object.entries(app.assets).forEach(([, asset]) => {120 doc.update((root) => {121 if (!asset.id) {122 delete root.assets[asset.id];123 } else if (root.assets[asset.id]) {124 root.assets[asset.id] = asset;125 } else {126 const updatedPropertyList = getUpdatedPropertyList(127 asset,128 root.assets[asset.id]!.toJS!(),129 );130131 updatedPropertyList.forEach((key) => {132 const newValue = asset[key];133 (root.assets[asset.id][key] as typeof newValue) = newValue;134 });135 }136 });137 });138 },139 60,140 false,141 );142143 // Handle presence updates when the user's pointer / selection changes144 const onChangePresence = useThrottleCallback(145 (app: TldrawApp, user: TDUser) => {146 if (!app || client === undefined || !client.isActive()) return;147148 doc.update((root, presence) => {149 presence.set({ tdUser: user });150 });151 },152 60,153 false,154 );155156 // Document Changes --------157158 useEffect(() => {159 if (!app) return;160161 // Subscribe to changes162 function handleChanges() {163 const root = doc.getRoot();164165 // Parse proxy object to record166 const shapeRecord: Record<string, TDShape> = JSON.parse(167 root.shapes.toJSON!(),168 );169 const bindingRecord: Record<string, TDBinding> = JSON.parse(170 root.bindings.toJSON!(),171 );172 const assetRecord: Record<string, TDAsset> = JSON.parse(173 root.assets.toJSON!(),174 );175176 // Replace page content with changed(propagated) records177 app?.replacePageContent(shapeRecord, bindingRecord, assetRecord);178 }179180 let stillAlive = true;181182 // Setup the document's storage and subscriptions183 async function setupDocument() {184 try {185 // 01. Create client with RPCAddr and options with apiKey if provided.186 // Then activate client.187 client = new yorkie.Client({188 rpcAddr: import.meta.env.VITE_YORKIE_API_ADDR,189 apiKey: import.meta.env.VITE_YORKIE_API_KEY,190 syncLoopDuration: 0,191 reconnectStreamDelay: 1000,192 });193 await client.activate();194195 // 02. Create document with tldraw custom object type.196 doc = new yorkie.Document<YorkieDocType, YorkiePresenceType>(roomId, {197 enableDevtools: true,198 });199200 // 02-1. Subscribe peers-changed event and update tldraw users state201 doc.subscribe('my-presence', (event) => {202 if (event.type === yorkie.DocEventType.Initialized) {203 const allPeers = doc204 .getPresences()205 .map((peer) => peer.presence.tdUser);206 app?.updateUsers(allPeers);207 }208 });209 doc.subscribe('others', (event) => {210 // remove leaved users211 if (event.type === yorkie.DocEventType.Unwatched) {212 app?.removeUser(event.value.presence.tdUser.id);213 }214215 // update users216 const allPeers = doc217 .getPresences()218 .map((peer) => peer.presence.tdUser);219 app?.updateUsers(allPeers);220 });221222 // 02-2. Attach document with initialPresence.223 const option = app?.currentUser && {224 initialPresence: { tdUser: app.currentUser },225 };226 await client.attach(doc, option);227228 // 03. Initialize document if document not exists.229 doc.update((root) => {230 if (!root.shapes) {231 root.shapes = {};232 }233 if (!root.bindings) {234 root.bindings = {};235 }236 if (!root.assets) {237 root.assets = {};238 }239 }, 'create shapes/bindings/assets object if not exists');240241 // 04. Subscribe document event and handle changes.242 doc.subscribe((event) => {243 if (event.type === 'remote-change') {244 handleChanges();245 }246 });247248 // 05. Sync client to sync document with other peers.249 await client.sync();250251 if (stillAlive) {252 // Update the document with initial content253 handleChanges();254255 // Zoom to fit the content & finish loading256 if (app) {257 app.zoomToFit();258 if (app.zoom > 1) {259 app.resetZoom();260 }261 app.setIsLoading(false);262 }263264 setLoading(false);265 }266 } catch (e) {267 console.error(e);268 }269 }270271 setupDocument();272273 return () => {274 stillAlive = false;275 };276 }, [app]);277278 return {279 onMount,280 onChangePage,281 loading,282 onChangePresence,283 };284}
- User 1
- User 2
- User 1
- User 2
Event Log