UNPKG

open-collaboration-service-process

Version:

A service process for integrating non Typescript projects with the Open Collaboration Tools project

221 lines 9.65 kB
// ****************************************************************************** // Copyright 2024 TypeFox GmbH // This program and the accompanying materials are made available under the // terms of the MIT License, which is available in the project root. // ****************************************************************************** import * as types from 'open-collaboration-protocol'; import { DisposableCollection, Deferred } from 'open-collaboration-protocol'; import { LOCAL_ORIGIN, OpenCollaborationYjsProvider } from 'open-collaboration-yjs'; import * as Y from 'yjs'; import { Mutex } from 'async-mutex'; import * as awarenessProtocol from 'y-protocols/awareness'; import { BinaryResponse, JoinSessionRequest, OCPBroadCast, OCPNotification, OCPRequest, OnInitNotification, toEncodedOCPMessage, UpdateDocumentContent, UpdateTextSelection } from './messages.js'; export class CollaborationInstance { currentConnection; communicationHandler; host; peers = new Map(); hostInfo = new Deferred(); peerInfo; yjsProvider; YjsDoc; yjsAwareness; yjsMutex = new Mutex(); connectionDisposables = new DisposableCollection(); identity = new Deferred(); constructor(currentConnection, communicationHandler, host, workspace) { this.currentConnection = currentConnection; this.communicationHandler = communicationHandler; this.host = host; if (host && !workspace) { throw new Error('Host must provide workspace'); } this.YjsDoc = new Y.Doc(); this.yjsAwareness = new awarenessProtocol.Awareness(this.YjsDoc); this.yjsAwareness.on('change', ((_, origin) => { if (origin !== LOCAL_ORIGIN) { this.checkSelectionUpdated(); } })); this.connectionDisposables.push({ dispose: () => { this.YjsDoc.destroy(); this.yjsAwareness.destroy(); } }); this.yjsProvider = new OpenCollaborationYjsProvider(currentConnection, this.YjsDoc, this.yjsAwareness, { resyncTimer: 10_000 }); this.yjsProvider.connect(); this.connectionDisposables.push(currentConnection.onReconnect(() => { this.yjsProvider?.connect(); })); currentConnection.onDisconnect(() => { this.dispose(); }); currentConnection.onRequest(async (origin, method, ...params) => { const result = await this.communicationHandler.sendRequest(OCPRequest, toEncodedOCPMessage({ method, params })); return BinaryResponse.is(result) ? types.Encoding.decode(Uint8Array.from(Buffer.from(result.data, 'base64'))) : result; }); currentConnection.onNotification((origin, method, ...params) => { this.communicationHandler.sendNotification(OCPNotification, toEncodedOCPMessage({ method, params })); }); currentConnection.onBroadcast((origin, method, ...params) => { this.communicationHandler.sendNotification(OCPBroadCast, toEncodedOCPMessage({ method, params })); }); currentConnection.peer.onJoinRequest(async (_, user) => { const accepted = await this.communicationHandler.sendRequest(JoinSessionRequest, user); return accepted ? { workspace: workspace } : undefined; }); currentConnection.peer.onInfo((_, peer) => { this.yjsAwareness.setLocalStateField('peer', peer.id); this.identity.resolve(peer); }); currentConnection.room.onJoin(async (_, peer) => { if (host && workspace) { // Only initialize the user if we are the host const initData = { protocol: types.VERSION, host: await this.identity.promise, guests: Array.from(this.peers.values()), capabilities: {}, permissions: { readonly: false }, workspace: { name: workspace.name ?? 'Collaboration', folders: workspace.folders ?? [] } }; currentConnection.peer.init(peer.id, initData); } }); currentConnection.peer.onInit((_, initData) => { this.peers.set(initData.host.id, initData.host); this.hostInfo.resolve(initData.host); for (const guest of initData.guests) { this.peers.set(guest.id, guest); } this.communicationHandler.sendNotification(OnInitNotification, initData); }); } async registerYjsObject(type, documentPath, text) { if (type === 'text') { const yjsText = this.YjsDoc.getText(documentPath); if (this.host) { this.YjsDoc.transact(() => { yjsText.delete(0, yjsText.length); yjsText.insert(0, text); }); } else { this.currentConnection.editor.open((await this.hostInfo.promise).id, documentPath); } const observer = (textEvent) => { if (textEvent.transaction.local) { // Ignore own events or if the document is already in sync return; } const edits = []; let index = 0; textEvent.delta.forEach(delta => { if (typeof delta.retain === 'number') { index += delta.retain; } else if (typeof delta.insert === 'string') { edits.push({ startOffset: index, text: delta.insert, }); index += delta.insert.length; } else if (typeof delta.delete === 'number') { edits.push({ startOffset: index, endOffset: index + delta.delete, text: '', }); } }); this.communicationHandler.sendNotification(UpdateDocumentContent, documentPath, edits); }; yjsText.observe(observer); } } updateYjsObjectContent(documentPath, changes) { if (changes.length === 0) { return; } this.yjsMutex.runExclusive(async () => { const yjsText = this.YjsDoc.getText(documentPath); this.YjsDoc.transact(() => { for (const change of changes) { if (change.endOffset) { yjsText.delete(change.startOffset, change.endOffset - change.startOffset); } yjsText.insert(change.startOffset, change.text); } }); }); } selectionState = new Map(); checkSelectionUpdated() { const states = this.yjsAwareness.getStates(); const currentSelections = new Map(); for (const [clientID, state] of states.entries()) { if (types.ClientTextSelection.is(state.selection)) { const selections = state.selection.textSelections.map(s => ({ peer: state.peer, start: s.start.assoc, end: s.end.assoc, isReversed: s.direction === types.SelectionDirection.RightToLeft })); currentSelections.has(state.peer) ? currentSelections.get(state.peer).push(...selections) : currentSelections.set(clientID.toString(), selections); } } const documentUpdates = []; for (const [documentPath, selections] of currentSelections.entries()) { if (JSON.stringify(this.selectionState.get(documentPath)) !== JSON.stringify(selections)) { documentUpdates.push(documentPath); } } this.selectionState = currentSelections; for (const document of documentUpdates) { this.communicationHandler.sendNotification(UpdateTextSelection, document, this.selectionState.get(document) ?? []); } } updateYjsObjectSelection(documentPath, clientSelections) { if (documentPath) { const ytext = this.YjsDoc.getText(documentPath); const selections = []; for (const clientSelection of clientSelections) { selections.push({ direction: clientSelection.isReversed ? types.SelectionDirection.RightToLeft : types.SelectionDirection.LeftToRight, start: Y.createRelativePositionFromTypeIndex(ytext, clientSelection.start), end: Y.createRelativePositionFromTypeIndex(ytext, clientSelection.end) }); } const textSelection = { path: documentPath, textSelections: selections }; this.setSharedSelection(textSelection); } else { this.setSharedSelection(undefined); } } setSharedSelection(selection) { this.yjsAwareness.setLocalStateField('selection', selection); } dispose() { this.yjsProvider?.dispose(); this.connectionDisposables.dispose(); } } //# sourceMappingURL=collaboration-instance.js.map