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;164 client.deactivate({ keepalive: true });165 }166167 window.addEventListener('beforeunload', handleDisconnect);168169 // Subscribe to changes170 function handleChanges() {171 const root = doc.getRoot();172173 // Parse proxy object to record174 const shapeRecord: Record<string, TDShape> = JSON.parse(175 root.shapes.toJSON!(),176 );177 const bindingRecord: Record<string, TDBinding> = JSON.parse(178 root.bindings.toJSON!(),179 );180 const assetRecord: Record<string, TDAsset> = JSON.parse(181 root.assets.toJSON!(),182 );183184 // Replace page content with changed(propagated) records185 app?.replacePageContent(shapeRecord, bindingRecord, assetRecord);186 }187188 let stillAlive = true;189190 // Setup the document's storage and subscriptions191 async function setupDocument() {192 try {193 // 01. Create client with RPCAddr and options with apiKey if provided.194 // Then activate client.195 const options: Options = {196 apiKey: import.meta.env.VITE_YORKIE_API_KEY,197 syncLoopDuration: 0,198 reconnectStreamDelay: 1000,199 };200201 client = new yorkie.Client(202 import.meta.env.VITE_YORKIE_API_ADDR,203 options,204 );205 await client.activate();206207 // 02. Create document with tldraw custom object type.208 doc = new yorkie.Document<YorkieDocType, YorkiePresenceType>(roomId, {209 enableDevtools: true,210 });211212 // 02-1. Subscribe peers-changed event and update tldraw users state213 doc.subscribe('my-presence', (event) => {214 if (event.type === yorkie.DocEventType.Initialized) {215 const allPeers = doc216 .getPresences()217 .map((peer) => peer.presence.tdUser);218 app?.updateUsers(allPeers);219 }220 });221 doc.subscribe('others', (event) => {222 // remove leaved users223 if (event.type === yorkie.DocEventType.Unwatched) {224 app?.removeUser(event.value.presence.tdUser.id);225 }226227 // update users228 const allPeers = doc229 .getPresences()230 .map((peer) => peer.presence.tdUser);231 app?.updateUsers(allPeers);232 });233234 // 02-2. Attach document with initialPresence.235 const option = app?.currentUser && {236 initialPresence: { tdUser: app.currentUser },237 };238 await client.attach(doc, option);239240 // 03. Initialize document if document not exists.241 doc.update((root) => {242 if (!root.shapes) {243 root.shapes = {};244 }245 if (!root.bindings) {246 root.bindings = {};247 }248 if (!root.assets) {249 root.assets = {};250 }251 }, 'create shapes/bindings/assets object if not exists');252253 // 04. Subscribe document event and handle changes.254 doc.subscribe((event) => {255 if (event.type === 'remote-change') {256 handleChanges();257 }258 });259260 // 05. Sync client to sync document with other peers.261 await client.sync();262263 if (stillAlive) {264 // Update the document with initial content265 handleChanges();266267 // Zoom to fit the content & finish loading268 if (app) {269 app.zoomToFit();270 if (app.zoom > 1) {271 app.resetZoom();272 }273 app.setIsLoading(false);274 }275276 setLoading(false);277 }278 } catch (e) {279 console.error(e);280 }281 }282283 setupDocument();284285 return () => {286 window.removeEventListener('beforeunload', handleDisconnect);287 stillAlive = false;288 };289 }, [app]);290291 return {292 onMount,293 onChangePage,294 loading,295 onChangePresence,296 };297}
- User 1
- User 2
- User 1
- User 2
Event Log