mirror of
https://github.com/haiwen/seahub.git
synced 2025-10-22 03:16:34 +00:00
* update backend api code * optimize exdraw editor code * add shared edit mode * fix code wraning --------- Co-authored-by: 小强 <shuntian@Mac.lan>
324 lines
10 KiB
JavaScript
324 lines
10 KiB
JavaScript
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())
|
|
};
|
|
}
|
|
});
|
|
}
|
|
|
|
updateUserInfo = (newUser) => {
|
|
const collaborators = new Map(this.collaborators);
|
|
this.config.user = newUser;
|
|
collaborators.set(newUser._username, newUser, { isCurrentUser: true });
|
|
this.collaborators = collaborators;
|
|
|
|
this.excalidrawAPI.updateScene({ collaborators });
|
|
};
|
|
|
|
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 elements = 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')
|
|
);
|
|
});
|
|
|
|
return await this.fileManager.getFiles(elements);
|
|
};
|
|
|
|
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 > 200) {
|
|
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, username: user.username });
|
|
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;
|