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;