diff --git a/frontend/src/pages/excalidraw-editor/data/file-manager.js b/frontend/src/pages/excalidraw-editor/data/file-manager.js
index c7fe4188d6..e3ac8f14e8 100644
--- a/frontend/src/pages/excalidraw-editor/data/file-manager.js
+++ b/frontend/src/pages/excalidraw-editor/data/file-manager.js
@@ -41,7 +41,7 @@ class FileManager {
if (fileData && !this.isFileSavedOrBeingSaved(fileData)) {
addedFiles.set(element.fileId, files[element.fileId]);
- this.savedFiles.set(element.fileId, this.getFileVersion(fileData));
+ this.savingFiles.set(element.fileId, this.getFileVersion(fileData));
}
}
@@ -98,9 +98,10 @@ class FileManager {
};
shouldPreventUnload = (elements) => {
- return elements.some(element => {
+ const hasUnsavedImage = elements.some(element => {
return isInitializedImageElement(element) && !element.isDeleted && this.savingFiles.has(element.fileId);
});
+ return hasUnsavedImage;
};
shouldUpdateImageElementStatus = (element) => {
diff --git a/frontend/src/pages/excalidraw-editor/editor/index.js b/frontend/src/pages/excalidraw-editor/editor/index.js
index 505f856357..635c6f7a90 100644
--- a/frontend/src/pages/excalidraw-editor/editor/index.js
+++ b/frontend/src/pages/excalidraw-editor/editor/index.js
@@ -1,14 +1,17 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
-import { CaptureUpdateAction, Excalidraw, MainMenu, newElementWith, reconcileElements, restore, restoreElements, useHandleLibrary } from '@excalidraw/excalidraw';
+import { CaptureUpdateAction, Excalidraw, MainMenu, newElementWith, reconcileElements, restoreElements, useHandleLibrary } from '@excalidraw/excalidraw';
import { langList } from '../constants';
import { LibraryIndexedDBAdapter } from './library-adapter';
-import Collab from '../collaboration/collab';
import context from '../context';
import TipMessage from './tip-message';
import { importFromLocalStorage } from '../data/local-storage';
import { resolvablePromise, updateStaleImageStatuses } from '../utils/exdraw-utils';
import { getFilename, isInitializedImageElement } from '../utils/element-utils';
import LocalData from '../data/local-data';
+import SocketManager from '../socket/socket-manager';
+import { loadFromServerStorage } from '../data/server-storage';
+import { getSyncableElements } from '../data';
+import { gettext } from '../../../utils/constants';
import '@excalidraw/excalidraw/index.css';
@@ -22,32 +25,26 @@ const UIOptions = {
tools: { image: true },
};
-const initializeScene = async (collabAPI) => {
+const initializeScene = async () => {
// load local data from localstorage
const docUuid = context.getDocUuid();
const localDataState = importFromLocalStorage(docUuid); // {appState, elements}
let data = null;
// load remote data from server
- if (collabAPI) {
- const scene = await collabAPI.startCollaboration();
- const { elements } = scene;
- const restoredRemoteElements = restoreElements(elements, null);
-
- const reconciledElements = reconcileElements(
- localDataState.elements,
- restoredRemoteElements,
- localDataState.appState,
- );
- data = {
- elements: reconciledElements,
- appState: localDataState.appState,
- };
- } else {
- data = restore(localDataState || null, null, null, {
- repairBindings: true,
- });
- }
+ const scene = await loadFromServerStorage();
+ const { elements } = scene;
+ const restoredRemoteElements = restoreElements(elements, null);
+ const reconciledElements = reconcileElements(
+ localDataState.elements,
+ restoredRemoteElements,
+ localDataState.appState,
+ );
+ data = {
+ elements: reconciledElements,
+ appState: null,
+ version: scene.version,
+ };
return {
scene: data
@@ -55,7 +52,7 @@ const initializeScene = async (collabAPI) => {
};
const SimpleEditor = () => {
- const collabAPIRef = useRef(null);
+
const initialStatePromiseRef = useRef({ promise: null });
if (!initialStatePromiseRef.current.promise) {
initialStatePromiseRef.current.promise = resolvablePromise();
@@ -70,9 +67,10 @@ const SimpleEditor = () => {
const loadImages = (data, isInitialLoad) => {
if (!data.scene) return;
- if (collabAPIRef.current && collabAPIRef.current.getIsServerConnected()) {
+ const socketManager = SocketManager.getInstance();
+ if (socketManager) {
if (data.scene.elements) {
- collabAPIRef.current.fetchImageFilesFromServer({
+ socketManager.fetchImageFilesFromServer({
elements: data.scene.elements,
forceFetchFiles: true,
}).then(({ loadedFiles, erroredFiles }) => {
@@ -113,10 +111,9 @@ const SimpleEditor = () => {
context.initSettings().then(() => {
const config = context.getSettings();
- const collabAPI = new Collab(excalidrawAPI, config);
- collabAPIRef.current = collabAPI;
-
- initializeScene(collabAPI).then(async (data) => {
+ initializeScene().then(async (data) => {
+ // init socket
+ SocketManager.getInstance(excalidrawAPI, data.scene, config);
loadImages(data, /* isInitialLoad */true);
initialStatePromiseRef.current.promise.resolve(data.scene);
});
@@ -125,9 +122,8 @@ const SimpleEditor = () => {
}, [excalidrawAPI]);
const handleChange = useCallback((elements, appState, files) => {
- if (collabAPIRef.current) {
- collabAPIRef.current.syncElements(elements);
- }
+ const socketManager = SocketManager.getInstance();
+ socketManager.syncLocalElementsToOthers(elements);
const docUuid = context.getDocUuid();
if (!LocalData.isSavePaused()) {
@@ -158,20 +154,30 @@ const SimpleEditor = () => {
}, [excalidrawAPI]);
const handlePointerUpdate = useCallback((payload) => {
- if (!collabAPIRef.current) return;
- collabAPIRef.current.syncPointer(payload);
+ const socketManager = SocketManager.getInstance();
+ socketManager.syncMouseLocationToOthers(payload);
}, []);
+ const beforeUnload = useCallback((event) => {
+ const socketManager = SocketManager.getInstance();
+ const fileManager = socketManager.fileManager;
+ const elements = excalidrawAPI.getSceneElementsIncludingDeleted();
+ const syncableElements = getSyncableElements(elements);
+ if (fileManager.shouldPreventUnload(syncableElements)) {
+ // eslint-disable-next-line no-console
+ console.warn('The uploaded image has not been saved yet. Please close this page later.');
+ event.preventDefault();
+ event.returnValue = gettext('The uploaded image has not been saved yet. Please close this page later.');
+ }
+ return;
+ }, [excalidrawAPI]);
+
useEffect(() => {
- const beforeUnload = (event) => {
- // event.preventDefault();
- LocalData.flushSave();
- };
window.addEventListener('beforeunload', beforeUnload);
return () => {
window.removeEventListener('beforeunload', beforeUnload);
};
- }, []);
+ }, [beforeUnload]);
return (
diff --git a/frontend/src/pages/excalidraw-editor/editor/tip-message.js b/frontend/src/pages/excalidraw-editor/editor/tip-message.js
index 6b117216b8..8b9a764da4 100644
--- a/frontend/src/pages/excalidraw-editor/editor/tip-message.js
+++ b/frontend/src/pages/excalidraw-editor/editor/tip-message.js
@@ -1,7 +1,8 @@
import React from 'react';
-import { withTranslation } from 'react-i18next';
import dayjs from 'dayjs';
-import EventBus from '../../../components/common/event-bus';
+import EventBus from '../utils/event-bus';
+import { gettext } from '../../../utils/constants';
+import toaster from '../../../components/toast';
class TipMessage extends React.Component {
constructor(props) {
@@ -18,13 +19,95 @@ class TipMessage extends React.Component {
const eventBus = EventBus.getInstance();
this.unsubscribeSavingEvent = eventBus.subscribe('is-saving', this.onDocumentSaving);
this.unsubscribeSavedEvent = eventBus.subscribe('saved', this.onDocumentSaved);
+
+ // offline reconnect
+ this.unsubscribeDisconnectEvent = eventBus.subscribe('disconnect', this.onDisconnect);
+ this.unsubscribeReconnectErrorEvent = eventBus.subscribe('reconnect_error', this.onReconnectError);
+ this.unsubscribeReconnectEvent = eventBus.subscribe('reconnect', this.onReconnect);
+
+ // server return error
+ this.unsubscribeOpExecError = eventBus.subscribe('execute_client_operations_error', this.onOperationExecuteError);
+ this.unsubscribeSyncServerOpError = eventBus.subscribe('sync_server_operations_error', this.onSyncServerOperationError);
+ this.unsubscribeDocumentLoadError = eventBus.subscribe('load_document_content_error', this.onInternalServerExecError);
+ this.unsubscribeOperationsSaveError = eventBus.subscribe('token_expired', this.onTokenExpiredError);
+
+ // local error
+ this.unsubscribePendingOpExceedLimit = eventBus.subscribe('pending_operations_exceed_limit', this.onPendingOpExceedLimit);
}
componentWillUnmount() {
this.unsubscribeSavingEvent();
this.unsubscribeSavedEvent();
+
+ this.unsubscribeDisconnectEvent();
+ this.unsubscribeReconnectErrorEvent();
+ this.unsubscribeReconnectEvent();
+
+ this.unsubscribeOpExecError();
+ this.unsubscribeSyncServerOpError();
+ this.unsubscribePendingOpExceedLimit();
+ this.unsubscribeDocumentLoadError();
+ this.unsubscribeOperationsSaveError();
+
+ clearTimeout(this.saveTimer);
}
+ onOperationExecuteError = () => {
+ const copyright = 'Failed to execute operation on server, the current operation has been withdrawn';
+ const message = gettext(copyright);
+ toaster.warning(message, { hasCloseButton: true });
+ };
+
+ onSyncServerOperationError = () => {
+ const copyright = 'Synchronization with the server failed, please refresh the page';
+ const message = gettext(copyright);
+ toaster.danger(message, { hasCloseButton: false, duration: null });
+ };
+
+ onInternalServerExecError = () => {
+ const copyright = 'An exception occurred on the server, please refresh the page and try again';
+ const message = gettext(copyright);
+ toaster.danger(message, { hasCloseButton: false, duration: null });
+ };
+
+ onTokenExpiredError = (msg) => {
+ const copyright = 'Token expired. Please refresh the page.';
+ const message = gettext(copyright);
+ toaster.closeAll();
+ toaster.danger(message, { duration: null });
+ };
+
+ onPendingOpExceedLimit = () => {
+ toaster.closeAll();
+ const copyright = 'There are multiple operations not synced to the server. Please check your network.';
+ const message = gettext(copyright);
+ toaster.warning(message, { duration: 5 });
+ };
+
+ onDisconnect = () => {
+ const copyright = 'Server is not connected. Operation will be sent to server later.';
+ const message = gettext(copyright);
+ toaster.warning(message, { hasCloseButton: true, duration: null });
+ };
+
+ onReconnectError = () => {
+ if (!this.isConnectError) {
+ this.isConnectError = true;
+ const copyright = 'Server is disconnected. Reconnecting...';
+ const message = gettext(copyright);
+ toaster.closeAll();
+ toaster.warning(message, { hasCloseButton: true, duration: null });
+ }
+ };
+
+ onReconnect = () => {
+ this.isConnectError = false;
+ const copyright = 'Server is reconnected.';
+ const message = gettext(copyright);
+ toaster.closeAll();
+ toaster.success(message); // close after serval seconds
+ };
+
onDocumentSaving = () => {
this.setState({
isSaving: true,
@@ -51,15 +134,14 @@ class TipMessage extends React.Component {
};
render = () => {
- const { t } = this.props;
const { isSaved, isSaving, lastSavedAt } = this.state;
if (isSaving && !isSaved) {
- return {t('Saving')};
+ return {gettext('Saving...')};
}
if (!isSaving && isSaved) {
- return {t('All_changes_saved')};
+ return {gettext('All changes saved')};
}
if (lastSavedAt) {
return (
@@ -74,4 +156,4 @@ class TipMessage extends React.Component {
};
}
-export default withTranslation('sdoc-editor')(TipMessage);
+export default TipMessage;
diff --git a/frontend/src/pages/excalidraw-editor/socket/socket-client.js b/frontend/src/pages/excalidraw-editor/socket/socket-client.js
new file mode 100644
index 0000000000..f707d7c6b2
--- /dev/null
+++ b/frontend/src/pages/excalidraw-editor/socket/socket-client.js
@@ -0,0 +1,199 @@
+import { CaptureUpdateAction, newElementWith } from '@excalidraw/excalidraw';
+import io from 'socket.io-client';
+import throttle from 'lodash.throttle';
+import { isSyncableElement } from '../data';
+import { clientDebug, serverDebug } from '../utils/debug';
+import SocketManager from './socket-manager';
+import { getFilename } from '../utils/element-utils';
+import { FILE_UPLOAD_TIMEOUT } from '../constants';
+
+class SocketClient {
+ constructor(config) {
+ this.config = config;
+ this.isReconnect = false;
+ this.broadcastedElementVersions = new Map();
+ this.socket = io(`${config.exdrawServer}/exdraw`, {
+ reconnection: true,
+ auth: { token: config.accessToken },
+ query: {
+ 'doc_uuid': config.docUuid,
+ }
+ });
+ this.socket.on('connect', this.onConnected);
+ this.socket.on('disconnect', this.onDisconnected);
+ this.socket.on('connect_error', this.onConnectError);
+
+ this.socket.on('init-room', this.onInitRoom);
+ this.socket.on('room-user-change', this.onRoomUserChanged);
+ this.socket.on('leave-room', this.onLeaveRoom);
+
+ this.socket.on('elements-updated', this.onReceiveRemoteElementsUpdate);
+ this.socket.on('mouse-location-updated', this.onReceiveRemoteMouseLocationUpdate);
+
+ this.socket.io.on('reconnect', this.onReconnect);
+ this.socket.io.on('reconnect_attempt', this.onReconnectAttempt);
+ this.socket.io.on('reconnect_error', this.onReconnectError);
+ }
+
+ getParams = (payload = {}) => {
+ const { docUuid, user } = this.config;
+ return {
+ docUuid,
+ user,
+ ...payload,
+ };
+ };
+
+ onConnected = () => {
+ if (this.isReconnect) {
+ this.isReconnect = false;
+ }
+ };
+
+ onDisconnected = (data) => {
+ if (data === 'ping timeout') {
+ clientDebug('Disconnected due to ping timeout, trying to reconnect...');
+ this.socket.connect();
+ return;
+ }
+
+ clientDebug('disconnect message: %s', data);
+ const socketManager = SocketManager.getInstance();
+ socketManager.dispatchConnectState('disconnect');
+ };
+
+ onConnectError = () => {
+ clientDebug('connect_error.');
+ const socketManager = SocketManager.getInstance();
+ socketManager.dispatchConnectState('connect_error');
+ };
+
+ queueFileUpload = throttle(async () => {
+ const socketManager = SocketManager.getInstance();
+ let savedFiles = new Map();
+ try {
+ ({ savedFiles } = await socketManager.fileManager.saveFiles({
+ elements: socketManager.excalidrawAPI.getSceneElementsIncludingDeleted(),
+ files: socketManager.excalidrawAPI.getFiles()
+ }));
+ } catch (error) {
+ if (error.name !== 'AbortError') {
+ socketManager.excalidrawAPI.updateScene({
+ appState: {
+ errorMessage: error.message,
+ },
+ });
+ }
+ }
+
+ let isChanged = false;
+ const oldElements = socketManager.excalidrawAPI.getSceneElementsIncludingDeleted();
+ const newElements = oldElements.map(element => {
+ if (socketManager.fileManager.shouldUpdateImageElementStatus(element)) {
+ isChanged = true;
+ const fileData = savedFiles.get(element.fileId);
+ if (fileData) {
+ const filename = getFilename(element.fileId, fileData);
+ return newElementWith(element, { status: 'saved', filename });
+ }
+ return element;
+ }
+ return element;
+ });
+
+ if (isChanged) {
+ socketManager.excalidrawAPI.updateScene({
+ elements: newElements,
+ captureUpdate: CaptureUpdateAction.NEVER,
+ });
+ }
+ }, FILE_UPLOAD_TIMEOUT);
+
+ broadcastSceneElements = (elements, version, callback) => {
+ const syncableElements = elements.reduce((acc, element) => {
+ const isAddedOrUpdated = !this.broadcastedElementVersions.has(element.id) || element.version > this.broadcastedElementVersions.get(element.id);
+ if (isAddedOrUpdated && isSyncableElement(element)) {
+ acc.push(element);
+ }
+ return acc;
+ }, []);
+
+ for (const syncableElement of syncableElements) {
+ this.broadcastedElementVersions.set(
+ syncableElement.id,
+ syncableElement.version,
+ );
+ }
+
+ this.queueFileUpload();
+
+ const payload = {
+ elements: syncableElements,
+ version: version,
+ };
+ const params = this.getParams(payload);
+ this.socket.emit('elements-updated', params, (result) => {
+ callback && callback(result);
+ });
+ };
+
+ broadcastMouseLocation = (payload) => {
+ const params = this.getParams(payload);
+ this.socket.emit('mouse-location-updated', params);
+ };
+
+ onInitRoom = () => {
+ serverDebug('join-room message');
+ this.socket.emit('join-room', this.getParams());
+ };
+
+ onRoomUserChanged = (users) => {
+ serverDebug('room users changed. all users count: %s', users.length);
+ const socketManager = SocketManager.getInstance();
+ socketManager.receiveRoomUserChanged(users);
+ };
+
+ onLeaveRoom = (userInfo) => {
+ serverDebug('%s leaved room success.', userInfo.name);
+ const socketManager = SocketManager.getInstance();
+ socketManager.receiveLeaveRoom(userInfo);
+ };
+
+ onReceiveRemoteElementsUpdate = (params) => {
+ serverDebug('sync elements by another updated, %O', params);
+ const socketManager = SocketManager.getInstance();
+ socketManager.handleRemoteSceneUpdated(params);
+ };
+
+ onReceiveRemoteMouseLocationUpdate = (params) => {
+ serverDebug('sync another\'s mouse location, %O', params);
+ const socketManager = SocketManager.getInstance();
+ socketManager.handleRemoteMouseLocationUpdated(params);
+ };
+
+ onReconnect = () => {
+ clientDebug('reconnect.');
+ this.isReconnect = true;
+ const socketManager = SocketManager.getInstance();
+ socketManager.dispatchConnectState('reconnect');
+ };
+
+ onReconnectAttempt = (attemptNumber) => {
+ clientDebug('reconnect_attempt. %s', attemptNumber);
+ const socketManager = SocketManager.getInstance();
+ socketManager.dispatchConnectState('reconnect_attempt', attemptNumber);
+ };
+
+ onReconnectError = () => {
+ clientDebug('reconnect_error.');
+ const socketManager = SocketManager.getInstance();
+ socketManager.dispatchConnectState('reconnect_error');
+ };
+
+ close = () => {
+ this.socket.close();
+ };
+
+}
+
+export default SocketClient;
diff --git a/frontend/src/pages/excalidraw-editor/socket/socket-manager.js b/frontend/src/pages/excalidraw-editor/socket/socket-manager.js
new file mode 100644
index 0000000000..8b009de902
--- /dev/null
+++ b/frontend/src/pages/excalidraw-editor/socket/socket-manager.js
@@ -0,0 +1,316 @@
+import { stateDebug } from '../utils/debug';
+import SocketClient from './socket-client';
+import { CaptureUpdateAction, getSceneVersion, reconcileElements, restoreElements } from '@excalidraw/excalidraw';
+import { CURSOR_SYNC_TIMEOUT, LOAD_IMAGES_TIMEOUT } from '../constants';
+import throttle from 'lodash.throttle';
+import EventBus from '../utils/event-bus';
+import FileManager from '../data/file-manager';
+import { loadFilesFromServer, saveFilesToServer } from '../data/server-storage';
+import { updateStaleImageStatuses } from '../utils/exdraw-utils';
+import { isInitializedImageElement } from '../utils/element-utils';
+
+const STATE = {
+ IDLE: 'idle',
+ SENDING: 'sending',
+ CONFLICT: 'conflict',
+ DISCONNECT: 'disconnect',
+ NEED_RELOAD: 'need_reload',
+};
+
+class SocketManager {
+
+ constructor(excalidrawAPI, document, config) {
+ this.config = config;
+ this.document = document;
+ this.excalidrawAPI = excalidrawAPI;
+ this.state = STATE.IDLE;
+
+ this.pendingOperationList = [];
+ this.pendingOperationBeginTimeList = [];
+ this.collaborators = new Map();
+ const { user } = config;
+ this.collaborators.set(user._username, user, { isCurrentUser: true });
+ this.excalidrawAPI.updateScene({ collaborators: this.collaborators });
+
+ this.eventBus = EventBus.getInstance();
+
+ this.socketClient = new SocketClient(config);
+ this.lastBroadcastedOrReceivedSceneVersion = 0; // used check is need sync or not
+ if (document && document.elements) {
+ this.setLastBroadcastedOrReceivedSceneVersion(document.elements);
+ }
+ this.fileManager = new FileManager({
+ getFiles: async (ids) => {
+ return loadFilesFromServer(ids);
+ },
+ saveFiles: async ({ addedFiles }) => {
+ const { savedFiles, erroredFiles } = await saveFilesToServer(addedFiles);
+ return {
+ savedFiles: savedFiles.reduce((acc, id) => {
+ const fileData = addedFiles.get(id);
+ if (fileData) {
+ acc.set(id, fileData);
+ }
+ return acc;
+ }, new Map()),
+ erroredFiles: erroredFiles.reduce((acc, id) => {
+ const fileData = addedFiles.get(id);
+ if (fileData) {
+ acc.set(id, fileData);
+ }
+ return acc;
+ }, new Map())
+ };
+ }
+ });
+ }
+
+ static getInstance = (excalidrawAPI, document, socketConfig) => {
+ if (this.instance) {
+ return this.instance;
+ }
+
+ if (!excalidrawAPI || !document || !socketConfig) {
+ throw new Error('SocketManager init params is invalid. Place check your code to fix it.');
+ }
+
+ this.instance = new SocketManager(excalidrawAPI, document, socketConfig);
+ return this.instance;
+ };
+
+ getVersion = () => {
+ return this.document.version;
+ };
+
+ setVersion = (version) => {
+ this.document.version = version;
+ };
+
+ setLastBroadcastedOrReceivedSceneVersion = (elements) => {
+ const version = getSceneVersion(elements);
+ this.lastBroadcastedOrReceivedSceneVersion = version;
+ };
+
+ getLastBroadcastedOrReceivedSceneVersion = () => {
+ return this.lastBroadcastedOrReceivedSceneVersion;
+ };
+
+ fetchImageFilesFromServer = async (opts) => {
+ const fileIds = opts.elements.filter(element => {
+ return (
+ isInitializedImageElement(element) &&
+ !this.fileManager.isFileTracked(element.fileId) &&
+ !element.isDeleted &&
+ (opts.forceFetchFiles ? element.status !== 'pending' || Date.now() - element.updated > 10000 : element.status === 'saved')
+ );
+ }).map(element => {
+ return element.filename || element.fileId;
+ });
+
+ return await this.fileManager.getFiles(fileIds);
+ };
+
+ loadImageFiles = throttle(async () => {
+ const { loadedFiles, erroredFiles } =
+ await this.fetchImageFilesFromServer({
+ elements: this.excalidrawAPI.getSceneElementsIncludingDeleted(),
+ });
+
+ this.excalidrawAPI.addFiles(loadedFiles);
+
+ updateStaleImageStatuses({
+ excalidrawAPI: this.excalidrawAPI,
+ erroredFiles,
+ elements: this.excalidrawAPI.getSceneElementsIncludingDeleted(),
+ });
+ }, LOAD_IMAGES_TIMEOUT);
+
+ isNeedToSync = (elements) => {
+ const currentVersion = getSceneVersion(elements);
+ if (currentVersion > this.lastBroadcastedOrReceivedSceneVersion) {
+ return true;
+ }
+ return false;
+ };
+
+ syncLocalElementsToOthers = (elements) => {
+ if (!this.isNeedToSync(elements)) {
+ return;
+ }
+ this.pendingOperationList.push(elements);
+
+ const lastOpBeginTime = new Date().getTime();
+ this.pendingOperationBeginTimeList.push(lastOpBeginTime);
+ const firstOpBeginTime = this.pendingOperationBeginTimeList[0];
+
+ const isExceedExecuteTime = (lastOpBeginTime - firstOpBeginTime) / 1000 > 30 ? true : false;
+ if (isExceedExecuteTime || this.pendingOperationList.length > 50) {
+ this.dispatchConnectState('pending_operations_exceed_limit');
+ }
+
+ this.sendOperations();
+ };
+
+ sendOperations = () => {
+ if (this.state !== STATE.IDLE) return;
+ stateDebug(`State changed: ${this.state} -> ${STATE.SENDING}`);
+ this.state = STATE.SENDING;
+ this.sendNextOperations();
+ };
+
+ sendNextOperations = () => {
+ if (this.state !== STATE.SENDING) return;
+ if (this.pendingOperationList.length === 0) {
+ stateDebug(`State Changed: ${this.state} -> ${STATE.IDLE}`);
+ this.state = STATE.IDLE;
+ return;
+ }
+
+ this.dispatchConnectState('is-saving');
+ const version = this.document.version;
+ const elements = this.pendingOperationList.shift();
+ this._sendingOperation = elements;
+
+ this.socketClient.broadcastSceneElements(elements, version, this.sendOperationsCallback);
+ };
+
+ sendOperationsCallback = (result) => {
+ if (result && result.success) {
+ const { version: serverVersion } = result;
+ this.setVersion(serverVersion);
+ const lastSavedAt = new Date().getTime();
+ this.dispatchConnectState('saved', lastSavedAt);
+
+ this.setLastBroadcastedOrReceivedSceneVersion(this._sendingOperation);
+
+ // send next operations
+ this.pendingOperationBeginTimeList.shift(); // remove current operation's begin time
+ this._sendingOperation = null;
+ this.sendNextOperations();
+ return;
+ }
+ // Operations are execute failure
+ const { error_type } = result;
+ if (error_type === 'load_document_content_error' || error_type === 'token_expired') {
+ // load_document_content_error: After a short-term reconnection, the content of the document fails to load
+ this.dispatchConnectState(error_type);
+
+ // reset sending control
+ stateDebug(`State Changed: ${this.state} -> ${STATE.NEED_RELOAD}`);
+ this.state = STATE.NEED_RELOAD;
+ this._sendingOperation = null;
+ } else if (error_type === 'version_behind_server') {
+ // Put the failed operation into the pending list and re-execute it
+ this.pendingOperationList.unshift(this._sendingOperation);
+
+ stateDebug(`State Changed: ${this.state} -> ${STATE.CONFLICT}`);
+ this.state = STATE.CONFLICT;
+ this.resolveConflicting(result);
+ }
+ };
+
+ resolveConflicting = (result) => {
+ const { elements, version } = result;
+
+ this.updateLocalDataByRemoteData(elements, version);
+
+ this.pendingOperationBeginTimeList.shift();
+ this._sendingOperation = null;
+ this.state = STATE.SENDING;
+ this.sendNextOperations();
+ };
+
+
+ syncMouseLocationToOthers = throttle((payload) => {
+ if (payload.pointersMap.size < 2) {
+ const { pointer, button } = payload;
+ this.socketClient.broadcastMouseLocation({ pointer, button });
+ }
+ }, CURSOR_SYNC_TIMEOUT);
+
+ updateLocalDataByRemoteData = (remoteElements, remoteVersion) => {
+ const localElements = this.excalidrawAPI.getSceneElementsIncludingDeleted();
+ const appState = this.excalidrawAPI.getAppState();
+ const restoredRemoteElements = restoreElements(remoteElements, null);
+ const reconciledElements = reconcileElements(localElements, restoredRemoteElements, appState);
+
+ this.setLastBroadcastedOrReceivedSceneVersion(reconciledElements);
+ this.setVersion(remoteVersion);
+
+ this.excalidrawAPI.updateScene({
+ elements: reconciledElements,
+ captureUpdate: CaptureUpdateAction.NEVER,
+ });
+
+ // sync images from another user
+ this.loadImageFiles();
+ };
+
+ handleRemoteSceneUpdated = (params) => {
+ const { elements, version } = params;
+ this.updateLocalDataByRemoteData(elements, version);
+ };
+
+ handleRemoteMouseLocationUpdated = (params) => {
+ const collaborators = new Map(this.collaborators);
+ const { user, ...updates } = params;
+ if (!collaborators.get(user._username)) return;
+
+ const newUser = Object.assign({}, collaborators.get(user._username), updates);
+ collaborators.set(newUser._username, newUser);
+ this.collaborators = collaborators;
+
+ this.excalidrawAPI.updateScene({ collaborators });
+ return;
+ };
+
+ receiveRoomUserChanged = (users) => {
+ const collaborators = new Map(this.collaborators);
+ if (users && Array.isArray(users)) {
+ users.forEach(user => {
+ if (!collaborators.get(user._username)) {
+ collaborators.set(user._username, user);
+ }
+ });
+ this.collaborators = collaborators;
+ setTimeout(() => {
+ this.excalidrawAPI.updateScene({ collaborators });
+ }, 100);
+ }
+ };
+
+ receiveLeaveRoom = (userInfo) => {
+ const collaborators = new Map(this.collaborators);
+ if (collaborators.get(userInfo._username)) {
+ collaborators.delete(userInfo._username);
+ this.collaborators = collaborators;
+ this.excalidrawAPI.updateScene({ collaborators });
+ }
+ };
+
+ dispatchConnectState = (type, message) => {
+ if (type === 'reconnect') {
+ this.state = STATE.IDLE;
+ }
+
+ if (type === 'disconnect') {
+ // current state is sending
+ if (this._sendingOperation) {
+ this.pendingOperationList.unshift(this._sendingOperations.slice());
+ this._sendingOperation = null;
+ }
+ stateDebug(`State Changed: ${this.state} -> ${STATE.DISCONNECT}`);
+ this.state = STATE.DISCONNECT;
+ }
+
+ this.eventBus.dispatch(type, message);
+ };
+
+ static destroy = () => {
+ this.instance = null;
+ this.socketClient.close();
+ };
+
+}
+
+export default SocketManager;