diff --git a/package.json b/package.json index 8351e69..6249525 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "type": "module", "main": "src/main.tsx", "scripts": { - "build": "vite --mode production build", + "build": "NODE_OPTIONS='--max-old-space-size=4096' vite --mode production build", "dev": "vite --mode development build", "watch": "vite --mode development build --watch", "lint": "eslint --ext .js,.mjs,.ts,.tsx,.vue src websocket_server tests/integration ", @@ -89,4 +89,4 @@ "node": "^20", "npm": "^10" } -} \ No newline at end of file +} diff --git a/src/App.tsx b/src/App.tsx index 55b6b0d..2e5e72f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,6 +29,7 @@ import type { ResolvablePromise } from '@excalidraw/excalidraw/types/utils' import type { NonDeletedExcalidrawElement } from '@excalidraw/excalidraw/types/element/types' import { getLinkWithPicker } from '@nextcloud/vue/dist/Components/NcRichText.js' import { useExcalidrawLang } from './hooks/useExcalidrawLang' +import SaveStatus from './SaveStatus' interface WhiteboardAppProps { fileId: number @@ -46,6 +47,7 @@ export default function App({ const fileNameWithoutExtension = fileName.split('.').slice(0, -1).join('.') const [viewModeEnabled, setViewModeEnabled] = useState(isEmbedded) + const [roomDataSaved, setRoomDataSaved] = useState(true) const [zenModeEnabled] = useState(isEmbedded) const [gridModeEnabled] = useState(false) @@ -91,10 +93,17 @@ export default function App({ const [excalidrawAPI, setExcalidrawAPI] = useState(null) const [collab, setCollab] = useState(null) + const [collabStarted, setCollabStarted] = useState(false) - if (excalidrawAPI && !collab) { setCollab(new Collab(excalidrawAPI, fileId, publicSharingToken, setViewModeEnabled)) } - if (collab && !collab.portal.socket) collab.startCollab() + if (excalidrawAPI && !collab) { setCollab(new Collab(excalidrawAPI, fileId, publicSharingToken, setViewModeEnabled, setRoomDataSaved)) } + if (collab && !collabStarted) { + setCollabStarted(true) + collab.startCollab() + } + + const [isSmartPickerInserted, setIsSmartPickerInserted] = useState(false) useEffect(() => { + if (isSmartPickerInserted) return const extraTools = document.getElementsByClassName( 'App-toolbar__extra-tools-trigger', )[0] @@ -107,6 +116,7 @@ export default function App({ ) const root = createRoot(smartPick) root.render(renderSmartPicker()) + setIsSmartPickerInserted(true) } }) @@ -236,12 +246,26 @@ export default function App({ ) } + const renderTopRightUI = () => { + if (collab?.portal.socket) { + return ( + { + collab?.portal.requestStoreToServer() + } + } + /> + ) + } + } + return (
true} renderEmbeddable={Embeddable} + renderTopRightUI={renderTopRightUI} excalidrawAPI={(api: ExcalidrawImperativeAPI) => { console.log(api) console.log('Setting API') diff --git a/src/SaveStatus.tsx b/src/SaveStatus.tsx new file mode 100644 index 0000000..11c622e --- /dev/null +++ b/src/SaveStatus.tsx @@ -0,0 +1,10 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import SaveStatus from './SaveStatus.vue' +import VueWrapper from './VueWrapper' + +export default function(props:{saving: boolean}) { + return React.createElement(VueWrapper, { componentProps: props, component: SaveStatus }) +} diff --git a/src/SaveStatus.vue b/src/SaveStatus.vue new file mode 100644 index 0000000..9147542 --- /dev/null +++ b/src/SaveStatus.vue @@ -0,0 +1,32 @@ +/** +* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +* SPDX-License-Identifier: AGPL-3.0-or-later +*/ + + + diff --git a/src/collaboration/Portal.ts b/src/collaboration/Portal.ts index fcbcc2a..f4977f4 100644 --- a/src/collaboration/Portal.ts +++ b/src/collaboration/Portal.ts @@ -114,6 +114,12 @@ export class Portal { this.socket.on('client-broadcast', (data) => this.handleClientBroadcast(data), ) + + this.socket.on('room-data-saved', () => this.handleRoomDataSaved()) + } + + async handleRoomDataSaved() { + this.collab.setRoomDataSaved(true) } async handleReadOnlySocket() { @@ -240,6 +246,10 @@ export class Portal { await this._broadcastSocketData(data, true) } + async requestStoreToServer() { + this.socket?.emit('store-to-server', this.roomId) + } + async sendImageFiles(files: BinaryFiles) { Object.values(files).forEach(file => { this.collab.addFile(file) diff --git a/src/collaboration/collab.ts b/src/collaboration/collab.ts index 8e1a471..063d4e0 100644 --- a/src/collaboration/collab.ts +++ b/src/collaboration/collab.ts @@ -17,15 +17,17 @@ export class Collab { portal: Portal publicSharingToken: string | null setViewModeEnabled: React.Dispatch> + setRoomDataSaved: React.Dispatch> lastBroadcastedOrReceivedSceneVersion: number = -1 private collaborators = new Map() private files = new Map() - constructor(excalidrawAPI: ExcalidrawImperativeAPI, fileId: number, publicSharingToken: string | null, setViewModeEnabled: React.Dispatch>) { + constructor(excalidrawAPI: ExcalidrawImperativeAPI, fileId: number, publicSharingToken: string | null, setViewModeEnabled: React.Dispatch>, setRoomDataSaved: React.Dispatch>) { this.excalidrawAPI = excalidrawAPI this.fileId = fileId this.publicSharingToken = publicSharingToken this.setViewModeEnabled = setViewModeEnabled + this.setRoomDataSaved = setRoomDataSaved this.portal = new Portal(`${fileId}`, this, publicSharingToken) } @@ -55,6 +57,7 @@ export class Collab { elements, }, ) + this.setRoomDataSaved(false) } private getLastBroadcastedOrReceivedSceneVersion = () => { @@ -69,6 +72,7 @@ export class Collab { this.lastBroadcastedOrReceivedSceneVersion = hashElementsVersion(elements) throttle(() => { this.portal.broadcastScene('SCENE_INIT', elements) + this.setRoomDataSaved(false) const syncedFiles = Array.from(this.files.keys()) const newFiles = Object.keys(files).filter((id) => !syncedFiles.includes(id)).reduce((acc, id) => { diff --git a/websocket_server/SocketManager.js b/websocket_server/SocketManager.js index de45e32..e98f3b9 100644 --- a/websocket_server/SocketManager.js +++ b/websocket_server/SocketManager.js @@ -5,7 +5,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { Server as SocketIO } from 'socket.io' +import { Socket, Server as SocketIO } from 'socket.io' import prometheusMetrics from 'socket.io-prometheus' import jwt from 'jsonwebtoken' import dotenv from 'dotenv' @@ -92,7 +92,7 @@ export default class SocketManager { }, ) next(new Error('Connection verified')) - } catch (e) {} + } catch (e) { } next(new Error('Authentication error')) } @@ -107,6 +107,7 @@ export default class SocketManager { socket.on('server-volatile-broadcast', (roomID, encryptedData) => this.serverVolatileBroadcastHandler(socket, roomID, encryptedData), ) + socket.on('store-to-server', (roomID) => this.storeToServerHandler(roomID, socket)) socket.on('image-add', (roomID, id, data) => this.imageAddHandler(socket, roomID, id, data)) socket.on('image-remove', (roomID, id, data) => this.imageRemoveHandler(socket, roomID, id, data)) socket.on('image-get', (roomID, id, data) => this.imageGetHandler(socket, roomID, id, data)) @@ -117,6 +118,17 @@ export default class SocketManager { socket.on('disconnect', () => this.handleDisconnect(socket)) } + /** + * @param {number} roomID roomID + * @param {Socket} socket socket + */ + async storeToServerHandler(roomID, socket) { + this.storageManager.saveRoomDataToServer(roomID).then(() => { + socket.emit('room-data-saved', roomID) + socket.broadcast.to(roomID).emit('room-data-saved', roomID) + }) + } + async handleDisconnect(socket) { await this.socketDataManager.deleteSocketData(socket.id) socket.removeAllListeners() diff --git a/websocket_server/StorageManager.js b/websocket_server/StorageManager.js index e58255c..ef3c7c6 100644 --- a/websocket_server/StorageManager.js +++ b/websocket_server/StorageManager.js @@ -8,11 +8,17 @@ import StorageStrategy from './StorageStrategy.js' import LRUCacheStrategy from './LRUCacheStrategy.js' import RedisStrategy from './RedisStrategy.js' +import ApiService from './ApiService.js' export default class StorageManager { - constructor(strategy) { + /** + * @param {StorageStrategy} strategy StorageStrategy + * @param {ApiService} apiService ApiService + */ + constructor(strategy, apiService) { this.setStrategy(strategy) + this.apiService = apiService } setStrategy(strategy) { @@ -39,6 +45,14 @@ export default class StorageManager { await this.strategy.clear() } + /** + * @param { number } roomId roomId + */ + async saveRoomDataToServer(roomId) { + const room = await this.get(roomId) + this.apiService.saveRoomDataToServer(roomId, room.data, room.lastEditedUser, room.files) + } + getRooms() { return this.strategy.getRooms() } @@ -58,7 +72,7 @@ export default class StorageManager { throw new Error('Invalid storage strategy type') } - return new StorageManager(strategy) + return new StorageManager(strategy, apiService) } }