mirror of
https://github.com/haiwen/seahub.git
synced 2025-09-25 14:50:29 +00:00
Optimize exdraw module 4 (#8129)
* add sync module * optimize code * optimize saveImage module * optimize code --------- Co-authored-by: 小强 <shuntian@Mac.lan>
This commit is contained in:
@@ -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) => {
|
||||
|
@@ -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 (
|
||||
<div className='excali-container'>
|
||||
|
@@ -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 <span className="tip-message">{t('Saving')}</span>;
|
||||
return <span className="tip-message">{gettext('Saving...')}</span>;
|
||||
}
|
||||
|
||||
if (!isSaving && isSaved) {
|
||||
return <span className="tip-message">{t('All_changes_saved')}</span>;
|
||||
return <span className="tip-message">{gettext('All changes saved')}</span>;
|
||||
}
|
||||
if (lastSavedAt) {
|
||||
return (
|
||||
@@ -74,4 +156,4 @@ class TipMessage extends React.Component {
|
||||
};
|
||||
}
|
||||
|
||||
export default withTranslation('sdoc-editor')(TipMessage);
|
||||
export default TipMessage;
|
||||
|
199
frontend/src/pages/excalidraw-editor/socket/socket-client.js
Normal file
199
frontend/src/pages/excalidraw-editor/socket/socket-client.js
Normal file
@@ -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;
|
316
frontend/src/pages/excalidraw-editor/socket/socket-manager.js
Normal file
316
frontend/src/pages/excalidraw-editor/socket/socket-manager.js
Normal file
@@ -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;
|
Reference in New Issue
Block a user