diff --git a/src/collaboration/Portal.ts b/src/collaboration/Portal.ts index fcbcc2ad..c8636a02 100644 --- a/src/collaboration/Portal.ts +++ b/src/collaboration/Portal.ts @@ -15,6 +15,7 @@ import { generateUrl } from '@nextcloud/router' enum BroadcastType { SceneInit = 'SCENE_INIT', MouseLocation = 'MOUSE_LOCATION', + ViewportUpdate = 'VIEWPORT_UPDATE', } export class Portal { @@ -151,6 +152,14 @@ export class Portal { case BroadcastType.MouseLocation: this.collab.updateCursor(decoded.payload) break + case BroadcastType.ViewportUpdate: + this.collab.updateCollaboratorViewport( + decoded.payload.userId, + decoded.payload.scrollX, + decoded.payload.scrollY, + decoded.payload.zoom, + ) + break } } @@ -247,4 +256,17 @@ export class Portal { }) } + async broadcastViewport(scrollX: number, scrollY: number, zoom: number) { + const data = { + type: BroadcastType.ViewportUpdate, + payload: { + userId: this.socket?.id, + scrollX, + scrollY, + zoom, + }, + } + await this._broadcastSocketData(data, true) + } + } diff --git a/src/collaboration/collab.ts b/src/collaboration/collab.ts index 8e1a471e..84d6e85b 100644 --- a/src/collaboration/collab.ts +++ b/src/collaboration/collab.ts @@ -4,7 +4,14 @@ */ import type { ExcalidrawElement } from '@excalidraw/excalidraw/types/element/types' -import type { AppState, BinaryFileData, BinaryFiles, Collaborator, ExcalidrawImperativeAPI, Gesture } from '@excalidraw/excalidraw/types/types' +import type { + AppState, + BinaryFileData, + BinaryFiles, + Collaborator, + ExcalidrawImperativeAPI, + Gesture, +} from '@excalidraw/excalidraw/types/types' import { Portal } from './Portal' import { restoreElements } from '@excalidraw/excalidraw' import { throttle } from 'lodash' @@ -20,8 +27,19 @@ export class Collab { lastBroadcastedOrReceivedSceneVersion: number = -1 private collaborators = new Map() private files = new Map() + private followedUserId: string | null = null + private lastBroadcastedViewport = { + scrollX: 0, + scrollY: 0, + zoom: 1, + } - constructor(excalidrawAPI: ExcalidrawImperativeAPI, fileId: number, publicSharingToken: string | null, setViewModeEnabled: React.Dispatch>) { + constructor( + excalidrawAPI: ExcalidrawImperativeAPI, + fileId: number, + publicSharingToken: string | null, + setViewModeEnabled: React.Dispatch>, + ) { this.excalidrawAPI = excalidrawAPI this.fileId = fileId this.publicSharingToken = publicSharingToken @@ -36,6 +54,8 @@ export class Collab { this.portal.connectSocket() this.excalidrawAPI.onChange(this.onChange) + + window.collab = this } getSceneElementsIncludingDeleted = () => { @@ -47,14 +67,17 @@ export class Collab { const localElements = this.getSceneElementsIncludingDeleted() const appState = this.excalidrawAPI.getAppState() - return reconcileElements(localElements, restoredRemoteElements, appState) + return reconcileElements( + localElements, + restoredRemoteElements, + appState, + ) } handleRemoteSceneUpdate = (elements: ExcalidrawElement[]) => { this.excalidrawAPI.updateScene({ elements, - }, - ) + }) } private getLastBroadcastedOrReceivedSceneVersion = () => { @@ -62,44 +85,74 @@ export class Collab { } // eslint-disable-next-line @typescript-eslint/no-unused-vars - private onChange = (elements: readonly ExcalidrawElement[], _state: AppState, files: BinaryFiles) => { - if (hashElementsVersion(elements) + private onChange = ( + elements: readonly ExcalidrawElement[], + state: AppState, + files: BinaryFiles, + ) => { + if ( + hashElementsVersion(elements) > this.getLastBroadcastedOrReceivedSceneVersion() ) { - this.lastBroadcastedOrReceivedSceneVersion = hashElementsVersion(elements) + this.lastBroadcastedOrReceivedSceneVersion + = hashElementsVersion(elements) throttle(() => { this.portal.broadcastScene('SCENE_INIT', elements) const syncedFiles = Array.from(this.files.keys()) - const newFiles = Object.keys(files).filter((id) => !syncedFiles.includes(id)).reduce((acc, id) => { - acc[id] = files[id] - return acc - }, {} as BinaryFiles) + const newFiles = Object.keys(files) + .filter((id) => !syncedFiles.includes(id)) + .reduce((acc, id) => { + acc[id] = files[id] + return acc + }, {} as BinaryFiles) if (Object.keys(newFiles).length > 0) { this.portal.sendImageFiles(newFiles) } })() } + + this.broadcastViewportIfChanged(state) + } + + private broadcastViewportIfChanged(state: AppState) { + const { scrollX, scrollY, zoom } = state + if ( + scrollX !== this.lastBroadcastedViewport.scrollX + || scrollY !== this.lastBroadcastedViewport.scrollY + || zoom.value !== this.lastBroadcastedViewport.zoom + ) { + this.lastBroadcastedViewport = { + scrollX, + scrollY, + zoom: zoom.value, + } + this.portal.broadcastViewport(scrollX, scrollY, zoom.value) + } } onPointerUpdate = (payload: { - pointersMap: Gesture['pointers'], - pointer: { x: number; y: number; tool: 'laser' | 'pointer' }, + pointersMap: Gesture['pointers'] + pointer: { x: number; y: number; tool: 'laser' | 'pointer' } button: 'down' | 'up' }) => { - payload.pointersMap.size < 2 && this.portal.socket && this.portal.broadcastMouseLocation(payload) + payload.pointersMap.size < 2 + && this.portal.socket + && this.portal.broadcastMouseLocation(payload) } - updateCollaborators = (users: { - user: { - id: string, - name: string - }, - socketId: string, - pointer: { x: number, y: number, tool: 'pointer' | 'laser' }, - button: 'down' | 'up', - selectedElementIds: AppState['selectedElementIds'] - }[]) => { + updateCollaborators = ( + users: { + user: { + id: string + name: string + } + socketId: string + pointer: { x: number; y: number; tool: 'pointer' | 'laser' } + button: 'down' | 'up' + selectedElementIds: AppState['selectedElementIds'] + }[], + ) => { const collaborators = new Map() users.forEach((payload) => { @@ -115,12 +168,12 @@ export class Collab { } updateCursor = (payload: { - socketId: string, - pointer: { x: number, y: number, tool: 'pointer' | 'laser' }, - button: 'down' | 'up', - selectedElementIds: AppState['selectedElementIds'], + socketId: string + pointer: { x: number; y: number; tool: 'pointer' | 'laser' } + button: 'down' | 'up' + selectedElementIds: AppState['selectedElementIds'] user: { - id: string, + id: string name: string } }) => { @@ -152,4 +205,58 @@ export class Collab { this.excalidrawAPI.addFiles([file]) } + followUser(userId: string) { + this.followedUserId = userId + const collabInfo = this.collaborators.get(userId) + if (collabInfo?.viewport) { + this.applyViewport(collabInfo.viewport) + } + } + + unfollowUser() { + this.followedUserId = null + } + + updateCollaboratorViewport = ( + userId: string, + scrollX: number, + scrollY: number, + zoom: number, + ) => { + const collaborator = this.collaborators.get(userId) + if (!collaborator) return + + const updated = { + ...collaborator, + viewport: { scrollX, scrollY, zoom }, + } + this.collaborators.set(userId, updated) + + if (this.followedUserId === userId) { + this.applyViewport({ scrollX, scrollY, zoom }) + } + + this.excalidrawAPI.updateScene({ collaborators: this.collaborators }) + } + + private applyViewport({ + scrollX, + scrollY, + zoom, + }: { + scrollX: number + scrollY: number + zoom: number + }) { + const appState = this.excalidrawAPI.getAppState() + this.excalidrawAPI.updateScene({ + appState: { + ...appState, + scrollX, + scrollY, + zoom: { value: zoom }, + }, + }) + } + } diff --git a/websocket_server/SocketManager.js b/websocket_server/SocketManager.js index 7ede85e1..a5391d8c 100644 --- a/websocket_server/SocketManager.js +++ b/websocket_server/SocketManager.js @@ -233,7 +233,8 @@ export default class SocketManager { Utils.convertArrayBufferToString(encryptedData), ) - if (payload.type === 'MOUSE_LOCATION') { + switch (payload.type) { + case 'MOUSE_LOCATION': { const socketData = await this.socketDataManager.getSocketData( socket.id, ) @@ -252,6 +253,28 @@ export default class SocketManager { 'client-broadcast', Utils.convertStringToArrayBuffer(JSON.stringify(eventData)), ) + break + } + + case 'VIEWPORT_UPDATE': { + const socketData = await this.socketDataManager.getSocketData(socket.id) + if (!socketData) return + const eventData = { + type: 'VIEWPORT_UPDATE', + payload: { + ...payload.payload, + userId: socketData.user.id, + }, + } + + socket.volatile.broadcast + .to(roomID) + .emit( + 'client-broadcast', + Utils.convertStringToArrayBuffer(JSON.stringify(eventData)), + ) + break + } } }