Skip to content

Commit c657bdb

Browse files
committed
Ignore collaborators updates for appstate changes
1 parent 5167bac commit c657bdb

File tree

1 file changed

+103
-35
lines changed

1 file changed

+103
-35
lines changed

src/frontend/src/lib/collab/Collab.tsx

Lines changed: 103 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import React, { PureComponent } from 'react';
22
import type { ExcalidrawImperativeAPI, AppState, SocketId, Collaborator as ExcalidrawCollaboratorType } from '@atyrode/excalidraw/types';
33
import 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';
1010
import 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

Comments
 (0)