11import { useEffect , useRef , useCallback , useState } from "react" ;
22import { throttle } from "throttle-debounce" ; // TODO: REVIEW [stability] replace custom throttle with lib
3- import { LoroDoc , EphemeralStore , LoroEventBatch , LoroMap } from "loro-crdt" ;
3+ import {
4+ LoroDoc ,
5+ EphemeralStore ,
6+ LoroEventBatch ,
7+ LoroMap ,
8+ type Value ,
9+ } from "loro-crdt" ;
410import { LoroWebsocketClient } from "loro-websocket/client" ;
511import { LoroAdaptor , LoroEphemeralAdaptor } from "loro-adaptors" ;
6- import { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types/types" ;
12+ import type {
13+ AppState as ExcalidrawAppState ,
14+ ExcalidrawImperativeAPI ,
15+ } from "@excalidraw/excalidraw/types/types" ;
16+ import type { ExcalidrawElement } from "@excalidraw/excalidraw/types/element/types" ;
717
818interface UseLoroSyncOptions {
919 roomId : string ;
@@ -14,35 +24,28 @@ interface UseLoroSyncOptions {
1424 excalidrawAPI : React . RefObject < ExcalidrawImperativeAPI > ;
1525}
1626
17- interface Collaborator {
27+ interface PresenceEntry extends Record < string , Value > {
1828 userId : string ;
1929 userName : string ;
2030 userColor : string ;
21- cursor ?: { x : number ; y : number } ;
31+ cursor ?: CursorPosition ;
2232 selectedElementIds ?: string [ ] ;
2333 lastActive : number ;
2434}
2535
26- interface CursorPosition {
36+ interface CursorPosition extends Record < string , Value > {
2737 x : number ;
2838 y : number ;
2939}
40+ interface Collaborator extends PresenceEntry { }
41+ type AppState = ExcalidrawAppState ;
3042
31- // Minimal type definitions for Excalidraw (to avoid import issues)
32- export interface ExcalidrawElement {
33- id : string ;
34- type : string ;
35- x : number ;
36- y : number ;
37- width : number ;
38- height : number ;
39- version : number ;
40- [ key : string ] : any ;
41- }
42-
43- export interface AppState {
44- [ key : string ] : any ;
45- }
43+ type SceneUpdateArgs = Parameters <
44+ ExcalidrawImperativeAPI [ "updateScene" ]
45+ > [ 0 ] ;
46+ type SceneElements = NonNullable < SceneUpdateArgs [ "elements" ] > ;
47+ type SceneAppStateUpdate = NonNullable < SceneUpdateArgs [ "appState" ] > ;
48+ type PresenceStoreState = Record < string , PresenceEntry > ;
4649
4750export function useLoroSync ( {
4851 roomId,
@@ -54,7 +57,8 @@ export function useLoroSync({
5457} : UseLoroSyncOptions ) {
5558 const docRef = useRef < LoroDoc | null > ( null ) ;
5659 const clientRef = useRef < LoroWebsocketClient | null > ( null ) ;
57- const ephemeralRef = useRef < EphemeralStore < Record < string , any > > | null > ( null ) ;
60+ const ephemeralRef =
61+ useRef < EphemeralStore < PresenceStoreState > | null > ( null ) ;
5862
5963 const [ isConnected , setIsConnected ] = useState ( false ) ;
6064 const [ collaborators , setCollaborators ] = useState < Map < string , Collaborator > > ( new Map ( ) ) ;
@@ -65,7 +69,7 @@ export function useLoroSync({
6569 useEffect ( ( ) => {
6670 const doc = new LoroDoc ( ) ;
6771 const client = new LoroWebsocketClient ( { url : wsUrl } ) ;
68- const ephemeral = new EphemeralStore < Record < string , any > > ( 30000 ) ; // 30 second timeout
72+ const ephemeral = new EphemeralStore < PresenceStoreState > ( 30000 ) ; // 30 second timeout
6973
7074 docRef . current = doc ;
7175 clientRef . current = client ;
@@ -81,7 +85,8 @@ export function useLoroSync({
8185 if ( event . by !== "local" ) {
8286 // Build scene data from doc and apply to Excalidraw. Avoid echo via flag.
8387 // TODO: REVIEW [avoid echo] We set a guard so the next Excalidraw onChange from updateScene is ignored.
84- const newElements = ( elementsContainer . toJSON ( ) || [ ] ) as ExcalidrawElement [ ] ;
88+ const newElements =
89+ ( elementsContainer . toJSON ( ) || [ ] ) as SceneElements ;
8590 const newAppState : Partial < AppState > = { } ;
8691 for ( const [ key , value ] of appStateContainer . entries ( ) ) {
8792 newAppState [ key as keyof AppState ] = value ;
@@ -90,7 +95,11 @@ export function useLoroSync({
9095 // Update checksum to match scene state
9196 const checksum = newElements . reduce ( ( acc , e ) => acc + ( e ?. version || 0 ) , 0 ) ;
9297 lastChecksumRef . current = checksum ;
93- excalidrawAPI . current ?. updateScene ( { elements : newElements as any , appState : newAppState as any } ) ;
98+ const sceneUpdate : SceneUpdateArgs = { elements : newElements } ;
99+ if ( Object . keys ( newAppState ) . length > 0 ) {
100+ sceneUpdate . appState = newAppState as SceneAppStateUpdate ;
101+ }
102+ excalidrawAPI . current ?. updateScene ( sceneUpdate ) ;
94103 }
95104 } ) ;
96105
@@ -197,6 +206,17 @@ export function useLoroSync({
197206
198207 const doc = docRef . current ;
199208 const list = doc . getList ( "elements" ) ;
209+ const getMapAt = ( index : number ) : LoroMap | undefined => {
210+ const value = list . get ( index ) ;
211+ return value instanceof LoroMap ? value : undefined ;
212+ } ;
213+ const ensureMapAt = ( index : number ) : LoroMap => {
214+ const map = getMapAt ( index ) ;
215+ if ( ! map ) {
216+ throw new Error ( `Expected LoroMap at index ${ index } ` ) ;
217+ }
218+ return map ;
219+ } ;
200220
201221 // Filter out deleted
202222 const filtered = elements . filter ( e => ! e . isDeleted ) ;
@@ -205,9 +225,9 @@ export function useLoroSync({
205225 const buildIndex = ( ) => {
206226 const idx = new Map < string , number > ( ) ;
207227 for ( let i = 0 ; i < list . length ; i ++ ) {
208- const m = list . get ( i ) as unknown as LoroMap | undefined ;
209- if ( ! m ) continue ;
210- const id = m . get ( "id" ) as string | undefined ;
228+ const map = getMapAt ( i ) ;
229+ if ( ! map ) continue ;
230+ const id = map . get ( "id" ) as string | undefined ;
211231 if ( id ) idx . set ( id , i ) ;
212232 }
213233 return idx ;
@@ -223,9 +243,9 @@ export function useLoroSync({
223243 if ( pos == null ) {
224244 // New element: insert at the desired position
225245 list . insertContainer ( i , new LoroMap ( ) ) ;
226- const m = list . get ( i ) as unknown as LoroMap ;
246+ const map = ensureMapAt ( i ) ;
227247 for ( const [ k , v ] of Object . entries ( target ) ) {
228- m . set ( k , v ) ;
248+ map . set ( k , v ) ;
229249 }
230250 changed = true ;
231251 indexMap = buildIndex ( ) ;
@@ -237,21 +257,21 @@ export function useLoroSync({
237257 list . delete ( pos , 1 ) ;
238258 const adjI = pos < i ? i - 1 : i ;
239259 list . insertContainer ( adjI , new LoroMap ( ) ) ;
240- const m = list . get ( adjI ) as unknown as LoroMap ;
260+ const map = ensureMapAt ( adjI ) ;
241261 for ( const [ k , v ] of Object . entries ( target ) ) {
242- m . set ( k , v ) ;
262+ map . set ( k , v ) ;
243263 }
244264 changed = true ;
245265 indexMap = buildIndex ( ) ;
246266 continue ;
247267 }
248268
249269 // Same position: update only if version changed
250- const m = list . get ( i ) as unknown as LoroMap ;
251- const prevVersion = m . get ( "version" ) ;
270+ const map = ensureMapAt ( i ) ;
271+ const prevVersion = map . get ( "version" ) ;
252272 if ( prevVersion !== target . version ) {
253273 for ( const [ k , v ] of Object . entries ( target ) ) {
254- m . set ( k , v ) ;
274+ map . set ( k , v ) ;
255275 }
256276 changed = true ;
257277 }
0 commit comments