11import React , { PureComponent } from 'react' ;
22import type { ExcalidrawImperativeAPI , AppState , SocketId , Collaborator as ExcalidrawCollaboratorType } from '@atyrode/excalidraw/types' ;
33import type { ExcalidrawElement as ExcalidrawElementType } from '@atyrode/excalidraw/element/types' ;
4- import {
5- viewportCoordsToSceneCoords ,
6- getSceneVersion ,
7- reconcileElements ,
4+ import {
5+ viewportCoordsToSceneCoords ,
6+ getSceneVersion ,
7+ reconcileElements ,
88 restoreElements
99} from '@atyrode/excalidraw' ;
1010import throttle from 'lodash.throttle' ;
@@ -66,8 +66,8 @@ class Collab extends PureComponent<CollabProps, CollabState> {
6666 private portal : Portal ;
6767 private debouncedBroadcastAppState : DebouncedFunction < [ AppState ] > ;
6868 private lastSentAppState : AppState | null = null ;
69-
70- private throttledOnPointerMove : any ;
69+
70+ private throttledOnPointerMove : any ;
7171 private unsubExcalidrawPointerDown : ( ( ) => void ) | null = null ;
7272 private unsubExcalidrawPointerUp : ( ( ) => void ) | null = null ;
7373 private unsubExcalidrawSceneChange : ( ( ) => void ) | null = null ;
@@ -102,23 +102,91 @@ class Collab extends PureComponent<CollabProps, CollabState> {
102102 this . debouncedBroadcastAppState = debounce ( ( appState : AppState ) => {
103103 if ( this . portal . isOpen ( ) && this . props . isOnline ) {
104104 if ( ! this . lastSentAppState || ! isEqual ( this . lastSentAppState , appState ) ) {
105- this . portal . broadcastAppStateUpdate ( appState ) ;
106- // It's important to store a deep clone if AppState might be mutated elsewhere
107- // or if `isEqual` relies on reference equality for nested objects that might change.
108- // For now, assuming AppState from Excalidraw is a new object or `isEqual` handles it.
109- this . lastSentAppState = { ...appState } ; // Store a shallow copy, or deep clone if necessary
110- } else {
111- console . debug ( '[pad.ws] App state update skipped (no change).' ) ;
105+ if ( this . lastSentAppState ) {
106+ const changes = this . detectAppStateChanges ( this . lastSentAppState , appState ) ;
107+ if ( Object . keys ( changes ) . length > 0 ) {
108+ console . debug ( '[pad.ws] AppState changes detected:' , changes ) ;
109+ this . portal . broadcastAppStateUpdate ( appState ) ;
110+ this . lastSentAppState = { ...appState } ;
111+ }
112+ } else {
113+ this . portal . broadcastAppStateUpdate ( appState ) ;
114+ this . lastSentAppState = { ...appState } ;
115+ }
112116 }
113117 }
114118 } , 500 ) ;
115119 }
116120
121+ /* AppState Change Detection */
122+
123+ private detectAppStateChanges = ( oldState : AppState , newState : AppState ) : Record < string , { old : any , new : any } > => {
124+ const changes : Record < string , { old : any , new : any } > = { } ;
125+
126+ // Get all unique keys from both old and new state
127+ const allKeys = new Set ( [
128+ ...Object . keys ( oldState ) ,
129+ ...Object . keys ( newState )
130+ ] ) ;
131+
132+ // Compare each field dynamically, but exclude collaborators field
133+ allKeys . forEach ( field => {
134+ // Skip collaborators field - those are updates about other users, not changes by this user
135+ if ( field === 'collaborators' ) return ;
136+
137+ const oldValue = oldState [ field as keyof AppState ] ;
138+ const newValue = newState [ field as keyof AppState ] ;
139+
140+ if ( this . hasChanged ( oldValue , newValue ) ) {
141+ changes [ field ] = {
142+ old : this . serializeValue ( oldValue ) ,
143+ new : this . serializeValue ( newValue )
144+ } ;
145+ }
146+ } ) ;
147+
148+ return changes ;
149+ } ;
150+
151+ private hasChanged = ( oldValue : any , newValue : any ) : boolean => {
152+ // Handle Maps (like selectedElementIds)
153+ if ( oldValue instanceof Map && newValue instanceof Map ) {
154+ if ( oldValue . size !== newValue . size ) return true ;
155+ const oldEntries = Array . from ( oldValue . entries ( ) ) ;
156+ for ( const [ key , value ] of oldEntries ) {
157+ if ( ! newValue . has ( key ) || newValue . get ( key ) !== value ) return true ;
158+ }
159+ return false ;
160+ }
161+
162+ // Handle objects with zoom value
163+ if ( typeof oldValue === 'object' && typeof newValue === 'object' && oldValue !== null && newValue !== null ) {
164+ // Special handling for zoom object
165+ if ( 'value' in oldValue && 'value' in newValue ) {
166+ return oldValue . value !== newValue . value ;
167+ }
168+ return JSON . stringify ( oldValue ) !== JSON . stringify ( newValue ) ;
169+ }
170+
171+ // Handle primitives
172+ return oldValue !== newValue ;
173+ } ;
174+
175+ private serializeValue = ( value : any ) : any => {
176+ if ( value instanceof Map ) {
177+ return Object . fromEntries ( value ) ;
178+ }
179+ if ( typeof value === 'object' && value !== null ) {
180+ return { ...value } ;
181+ }
182+ return value ;
183+ } ;
184+
117185 /* Component Lifecycle */
118186
119187 componentDidMount ( ) {
120188 if ( this . portal ) {
121- this . portal . initiate ( ) ;
189+ this . portal . initiate ( ) ;
122190 }
123191
124192 if ( this . props . user ) {
@@ -133,10 +201,10 @@ class Collab extends PureComponent<CollabProps, CollabState> {
133201 const initialElements = this . props . excalidrawAPI . getSceneElementsIncludingDeleted ( ) ;
134202 // Set initial broadcast version.
135203 this . lastBroadcastedSceneVersion = getSceneVersion ( initialElements ) ;
136- // Also set the initial processed version from local state
137- this . setState ( { lastProcessedSceneVersion : this . lastBroadcastedSceneVersion } ) ;
204+ // Also set the initial processed version from local state
205+ this . setState ( { lastProcessedSceneVersion : this . lastBroadcastedSceneVersion } ) ;
138206 }
139- if ( this . props . isOnline && this . props . padId ) {
207+ if ( this . props . isOnline && this . props . padId ) {
140208 // Potentially call a method to broadcast initial scene if this client is the first or needs to sync
141209 // this.broadcastFullSceneUpdate(true); // Example: true for SCENE_INIT
142210 }
@@ -178,7 +246,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
178246 this . setState ( { lastProcessedSceneVersion : this . lastBroadcastedSceneVersion } ) ;
179247 }
180248 }
181-
249+
182250 if ( this . state . collaborators !== prevState . collaborators ) {
183251 if ( this . updateExcalidrawCollaborators ) this . updateExcalidrawCollaborators ( ) ;
184252 }
@@ -253,7 +321,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
253321 const pointerData : PointerData = { x : sceneCoords . x , y : sceneCoords . y , tool : displayTool , button : button } ;
254322 this . portal . broadcastMouseLocation ( pointerData , button ) ;
255323 } ;
256-
324+
257325 private handlePointerMove = ( event : PointerEvent ) => {
258326 if ( ! this . props . excalidrawAPI || ! this . portal . isOpen ( ) || ! this . props . isOnline ) return ;
259327 const appState = this . props . excalidrawAPI . getAppState ( ) ;
@@ -328,13 +396,13 @@ class Collab extends PureComponent<CollabProps, CollabState> {
328396 if ( this . props . user && this . props . user . id === collab . id ) return ;
329397
330398 excalidrawCollaborators . set ( id , {
331- id : collab . id ,
332- pointer : collab . pointer ,
399+ id : collab . id ,
400+ pointer : collab . pointer ,
333401 username : collab . username ,
334- button : collab . button ,
335- selectedElementIds :
336- collab . selectedElementIds ,
337- color : collab . color ,
402+ button : collab . button ,
403+ selectedElementIds :
404+ collab . selectedElementIds ,
405+ color : collab . color ,
338406 avatarUrl : collab . avatarUrl ,
339407 } ) ;
340408 } ) ;
@@ -348,10 +416,10 @@ class Collab extends PureComponent<CollabProps, CollabState> {
348416 this . setState ( { connectionStatus : status } ) ;
349417 // Potentially update UI or take actions based on status
350418 if ( status === 'Failed' || ( status === 'Closed' && ! this . portal . isOpen ( ) ) ) {
351- // Clear collaborators if connection is definitively lost
352- this . setState ( { collaborators : new Map ( ) } , ( ) => {
353- if ( this . updateExcalidrawCollaborators ) this . updateExcalidrawCollaborators ( ) ;
354- } ) ;
419+ // Clear collaborators if connection is definitively lost
420+ this . setState ( { collaborators : new Map ( ) } , ( ) => {
421+ if ( this . updateExcalidrawCollaborators ) this . updateExcalidrawCollaborators ( ) ;
422+ } ) ;
355423 }
356424 } ;
357425
@@ -370,10 +438,10 @@ class Collab extends PureComponent<CollabProps, CollabState> {
370438 this . setState ( prevState => {
371439 if ( prevState . collaborators . has ( senderId ) || ( this . props . user ?. id && senderIdString === this . props . user . id ) ) return null ;
372440 const newCollaborator : Collaborator = {
373- id : user_id as SocketId ,
374- username : username ,
441+ id : user_id as SocketId ,
442+ username : username ,
375443 pointer : { x : 0 , y : 0 , tool : 'pointer' } ,
376- color : getRandomCollaboratorColor ( ) ,
444+ color : getRandomCollaboratorColor ( ) ,
377445 userState : 'active' ,
378446 } ;
379447 const newCollaborators = new Map ( prevState . collaborators ) ;
@@ -419,13 +487,13 @@ class Collab extends PureComponent<CollabProps, CollabState> {
419487
420488 // Ensure elements are properly restored (e.g., if they are plain objects from JSON)
421489 const restoredRemoteElements = restoreElements ( remoteElements , null ) ;
422-
490+
423491 const reconciled = reconcileElements (
424492 localElements ,
425493 restoredRemoteElements as any [ ] , // Cast as any if type conflicts, ensure it matches Excalidraw's expected RemoteExcalidrawElement[]
426494 currentAppState
427495 ) ;
428-
496+
429497 this . props . excalidrawAPI . updateScene ( { elements : reconciled as ExcalidrawElementType [ ] , commitToHistory : false } ) ;
430498 this . setState ( { lastProcessedSceneVersion : getSceneVersion ( reconciled ) } ) ;
431499 }
@@ -439,7 +507,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
439507 this . setState ( prevState => {
440508 const newCollaborators = new Map < SocketId , Collaborator > ( ) ;
441509 collaboratorsList . forEach ( collabData => {
442-
510+
443511 console . debug ( `[pad.ws] Collaborator data: ${ JSON . stringify ( collabData ) } ` ) ;
444512 if ( collabData . user_id && collabData . user_id !== this . props . user ?. id ) {
445513
0 commit comments