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 { Options, 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 // Detach & deactive yorkie client before unload162 function handleDisconnect() {163 if (client === undefined || doc === undefined) return;164165 client.detach(doc);166 client.deactivate();167 }168169 window.addEventListener('beforeunload', handleDisconnect);170171 // Subscribe to changes172 function handleChanges() {173 const root = doc.getRoot();174175 // Parse proxy object to record176 const shapeRecord: Record<string, TDShape> = JSON.parse(177 root.shapes.toJSON!(),178 );179 const bindingRecord: Record<string, TDBinding> = JSON.parse(180 root.bindings.toJSON!(),181 );182 const assetRecord: Record<string, TDAsset> = JSON.parse(183 root.assets.toJSON!(),184 );185186 // Replace page content with changed(propagated) records187 app?.replacePageContent(shapeRecord, bindingRecord, assetRecord);188 }189190 let stillAlive = true;191192 // Setup the document's storage and subscriptions193 async function setupDocument() {194 try {195 // 01. Create client with RPCAddr and options with apiKey if provided.196 // Then activate client.197 const options: Options = {198 apiKey: import.meta.env.VITE_YORKIE_API_KEY,199 syncLoopDuration: 0,200 reconnectStreamDelay: 1000,201 };202203 client = new yorkie.Client(204 import.meta.env.VITE_YORKIE_API_ADDR,205 options,206 );207 await client.activate();208209 // 02. Create document with tldraw custom object type.210 doc = new yorkie.Document<YorkieDocType, YorkiePresenceType>(roomId, {211 enableDevtools: true,212 });213214 // 02-1. Subscribe peers-changed event and update tldraw users state215 doc.subscribe('my-presence', (event) => {216 if (event.type === yorkie.DocEventType.Initialized) {217 const allPeers = doc218 .getPresences()219 .map((peer) => peer.presence.tdUser);220 app?.updateUsers(allPeers);221 }222 });223 doc.subscribe('others', (event) => {224 // remove leaved users225 if (event.type === yorkie.DocEventType.Unwatched) {226 app?.removeUser(event.value.presence.tdUser.id);227 }228229 // update users230 const allPeers = doc231 .getPresences()232 .map((peer) => peer.presence.tdUser);233 app?.updateUsers(allPeers);234 });235236 // 02-2. Attach document with initialPresence.237 const option = app?.currentUser && {238 initialPresence: { tdUser: app.currentUser },239 };240 await client.attach(doc, option);241242 // 03. Initialize document if document not exists.243 doc.update((root) => {244 if (!root.shapes) {245 root.shapes = {};246 }247 if (!root.bindings) {248 root.bindings = {};249 }250 if (!root.assets) {251 root.assets = {};252 }253 }, 'create shapes/bindings/assets object if not exists');254255 // 04. Subscribe document event and handle changes.256 doc.subscribe((event) => {257 if (event.type === 'remote-change') {258 handleChanges();259 }260 });261262 // 05. Sync client to sync document with other peers.263 await client.sync();264265 if (stillAlive) {266 // Update the document with initial content267 handleChanges();268269 // Zoom to fit the content & finish loading270 if (app) {271 app.zoomToFit();272 if (app.zoom > 1) {273 app.resetZoom();274 }275 app.setIsLoading(false);276 }277278 setLoading(false);279 }280 } catch (e) {281 console.error(e);282 }283 }284285 setupDocument();286287 return () => {288 window.removeEventListener('beforeunload', handleDisconnect);289 stillAlive = false;290 };291 }, [app]);292293 return {294 onMount,295 onChangePage,296 loading,297 onChangePresence,298 };299}
- User 1
- User 2
- User 1
- User 2
Event Log