open-collaboration-service-process
Version:
A service process for integrating non Typescript projects with the Open Collaboration Tools project
221 lines • 9.65 kB
JavaScript
// ******************************************************************************
// 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