@hereugo/open-collaboration-monaco 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,133 @@
1
+ // ******************************************************************************
2
+ // Copyright 2024 TypeFox GmbH
3
+ // This program and the accompanying materials are made available under the
4
+ // terms of the MIT License, which is available in the project root.
5
+ // ******************************************************************************
6
+
7
+ import * as types from '@hereugo/open-collaboration-protocol';
8
+ import * as awarenessProtocol from 'y-protocols/awareness';
9
+
10
+ type PeerDecorationOptions = {
11
+ selectionClassName: string;
12
+ cursorClassName: string;
13
+ cursorInvertedClassName: string;
14
+ };
15
+
16
+ export class DisposablePeer {
17
+
18
+ readonly peer: types.Peer;
19
+ color: string | undefined;
20
+
21
+ private yjsAwareness: awarenessProtocol.Awareness;
22
+
23
+ readonly decoration: PeerDecorationOptions;
24
+
25
+ get clientId(): number | undefined {
26
+ const states = this.yjsAwareness.getStates() as Map<number, types.ClientAwareness>;
27
+ for (const [clientID, state] of states.entries()) {
28
+ if (state.peer === this.peer.id) {
29
+ return clientID;
30
+ }
31
+ }
32
+ return undefined;
33
+ }
34
+
35
+ get lastUpdated(): number | undefined {
36
+ const clientId = this.clientId;
37
+ if (clientId !== undefined) {
38
+ const meta = this.yjsAwareness.meta.get(clientId);
39
+ if (meta) {
40
+ return meta.lastUpdated;
41
+ }
42
+ }
43
+ return undefined;
44
+ }
45
+
46
+ constructor(yAwareness: awarenessProtocol.Awareness, peer: types.Peer) {
47
+ this.peer = peer;
48
+ this.yjsAwareness = yAwareness;
49
+ this.decoration = this.createDecorations();
50
+ }
51
+
52
+ private createDecorations(): PeerDecorationOptions {
53
+ const color = createColor();
54
+ const colorCss = typeof color === 'string' ? color : `rgb(${color[0]}, ${color[1]}, ${color[2]})`;
55
+ this.color = colorCss;
56
+ const className = `peer-${this.peer.id}`;
57
+ const cursorClassName = `${className}-cursor`;
58
+ const cursorInvertedClassName = `${className}-cursor-inverted`;
59
+ const selectionClassName = `${className}-selection`;
60
+ const cursorCss = `.${cursorClassName} {
61
+ background-color: ${colorCss} !important;
62
+ border-color: ${colorCss} !important;
63
+ position: absolute;
64
+ border-right: solid 2px;
65
+ border-top: solid 2px;
66
+ border-bottom: solid 2px;
67
+ height: 100%;
68
+ box-sizing: border-box;
69
+ }`;
70
+ generateCSS(cursorCss);
71
+ const cursorAfterCss = `.${cursorClassName}::after {
72
+ content: "${this.peer.name}";
73
+ position: absolute;
74
+ transform: translateY(-100%);
75
+ padding: 0 4px;
76
+ border-radius: 4px 4px 4px 0px;
77
+ background-color: ${colorCss};
78
+ }`;
79
+ generateCSS(cursorAfterCss);
80
+ const cursorAfterInvertedCss = `.${cursorClassName}.${cursorInvertedClassName}::after {
81
+ transform: translateY(100%);
82
+ margin-top: -2px;
83
+ border-radius: 0px 4px 4px 4px;
84
+ z-index: 1;
85
+ }`;
86
+ generateCSS(cursorAfterInvertedCss);
87
+ const selectionCss = `.${selectionClassName} {
88
+ background: ${colorCss} !important;
89
+ opacity: 0.25;
90
+ }`;
91
+ generateCSS(selectionCss);
92
+ return {
93
+ cursorClassName,
94
+ cursorInvertedClassName,
95
+ selectionClassName
96
+ };
97
+ }
98
+
99
+ }
100
+
101
+ let colorIndex = 0;
102
+ const defaultColors: Array<[number, number, number] | string> = [
103
+ 'yellow', // Yellow
104
+ 'green', // Green
105
+ 'magenta', // Magenta
106
+ 'lightGreen', // Light green
107
+ [255, 178, 123], // Light orange
108
+ [255, 157, 242], // Light magenta
109
+ [92, 45, 145], // Purple
110
+ [0, 178, 148], // Light teal
111
+ [255, 241, 0], // Light yellow
112
+ [180, 160, 255] // Light purple
113
+ ];
114
+
115
+ const knownColors = new Set<string>();
116
+ function createColor(): [number, number, number] | string {
117
+ if (colorIndex < defaultColors.length) {
118
+ return defaultColors[colorIndex++];
119
+ }
120
+ const o = Math.round, r = Math.random, s = 255;
121
+ let color: [number, number, number];
122
+ do {
123
+ color = [o(r() * s), o(r() * s), o(r() * s)];
124
+ } while (knownColors.has(JSON.stringify(color)));
125
+ knownColors.add(JSON.stringify(color));
126
+ return color;
127
+ }
128
+
129
+ function generateCSS(cssText: string) {
130
+ const style: HTMLStyleElement = document.createElement('style');
131
+ style.textContent = cssText;
132
+ document.head.appendChild(style);
133
+ }
package/src/example.ts ADDED
@@ -0,0 +1,96 @@
1
+ // ******************************************************************************
2
+ // Copyright 2024 TypeFox GmbH
3
+ // This program and the accompanying materials are made available under the
4
+ // terms of the MIT License, which is available in the project root.
5
+ // ******************************************************************************
6
+
7
+ import * as monaco from 'monaco-editor';
8
+ import { monacoCollab } from './monaco-api.js';
9
+ import { User } from '@hereugo/open-collaboration-protocol';
10
+
11
+ const value = `function sayHello(): string {
12
+ return "Hello";
13
+ };`;
14
+
15
+ export type WorkerLoader = () => Worker
16
+ const workerLoaders: Partial<Record<string, WorkerLoader>> = {
17
+ editorWorkerService: () =>
18
+ new Worker(new URL('monaco-editor/esm/vs/editor/editor.worker.js', import.meta.url), {
19
+ type: 'module'
20
+ }),
21
+ typescript: () =>
22
+ new Worker(new URL('monaco-editor/esm/vs/language/typescript/ts.worker.js', import.meta.url), {
23
+ type: 'module'
24
+ })
25
+ };
26
+
27
+ window.MonacoEnvironment = {
28
+ getWorker: function(moduleId, label) {
29
+ const workerFactory = workerLoaders[label];
30
+ if (workerFactory !== undefined && workerFactory !== null) {
31
+ return workerFactory();
32
+ }
33
+ throw new Error(`Unimplemented worker ${label} (${moduleId})`);
34
+ }
35
+ };
36
+
37
+ const container = document.getElementById('container');
38
+ if (container) {
39
+ const myEditor = monaco.editor.create(container, {
40
+ value,
41
+ language: 'typescript'
42
+ });
43
+
44
+ const monacoCollabApi = monacoCollab({
45
+ serverUrl: 'http://localhost:8100',
46
+ callbacks: {
47
+ onUserRequestsAccess: (user: User) => {
48
+ console.log('User requests access', user);
49
+ return Promise.resolve(true);
50
+ }
51
+ }
52
+ });
53
+
54
+ // on click of button with id create create room, call createRoom, take the value from response and set it in textfield with id token
55
+ const createRoomButton = document.getElementById('create');
56
+ createRoomButton?.addEventListener('click', () => {
57
+ monacoCollabApi.createRoom().then(token => {
58
+ if (token) {
59
+ monacoCollabApi.setEditor(myEditor);
60
+ (document.getElementById('token') as HTMLInputElement).value = token ?? '';
61
+ }
62
+ });
63
+ });
64
+
65
+ // on click of join room button take value from textfield with id room and call joinRoom
66
+ const joinRoomButton = document.getElementById('join');
67
+ joinRoomButton?.addEventListener('click', () => {
68
+ const roomToken = (document.getElementById('room') as HTMLInputElement).value;
69
+ monacoCollabApi.joinRoom(roomToken).then(state => {
70
+ if (state) {
71
+ monacoCollabApi.setEditor(myEditor);
72
+ monacoCollabApi.onUsersChanged(() => {
73
+ monacoCollabApi.getUserData().then(userData => {
74
+ const host = userData?.others.find(u => u.peer.host);
75
+ if (host && monacoCollabApi.getFollowedUser() === undefined) {
76
+ monacoCollabApi.followUser(host.peer.id);
77
+ }
78
+ });
79
+ });
80
+ }
81
+ console.log('Joined room');
82
+ });
83
+ });
84
+
85
+ // on click of button with id login call login
86
+ const loginButton = document.getElementById('login');
87
+ loginButton?.addEventListener('click', () => {
88
+ monacoCollabApi.login().then((userAuthToken?: string) => {
89
+ let loginText = 'Failed to login';
90
+ if (userAuthToken) {
91
+ loginText = 'Successfully logged in';
92
+ }
93
+ document.getElementById('user')!.innerText = loginText;
94
+ });
95
+ });
96
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ // ******************************************************************************
2
+ // Copyright 2024 TypeFox GmbH
3
+ // This program and the accompanying materials are made available under the
4
+ // terms of the MIT License, which is available in the project root.
5
+ // ******************************************************************************
6
+
7
+ export * from './collaboration-connection.js';
8
+ export * from './collaboration-instance.js';
9
+ export * from './collaboration-peer.js';
10
+ export * from './types.js';
11
+ export * from './monaco-api.js';
@@ -0,0 +1,235 @@
1
+ // ******************************************************************************
2
+ // Copyright 2024 TypeFox GmbH
3
+ // This program and the accompanying materials are made available under the
4
+ // terms of the MIT License, which is available in the project root.
5
+ // ******************************************************************************
6
+
7
+ import { ConnectionProvider, SocketIoTransportProvider } from '@hereugo/open-collaboration-protocol';
8
+ import { CollaborationInstance, UsersChangeEvent, FileNameChangeEvent } from './collaboration-instance.js';
9
+ import * as types from '@hereugo/open-collaboration-protocol';
10
+ import { createRoom, joinRoom, login } from './collaboration-connection.js';
11
+ import * as monaco from 'monaco-editor';
12
+
13
+ let connectionProvider: ConnectionProvider | undefined;
14
+ let instance: CollaborationInstance | undefined;
15
+
16
+ types.initializeProtocol({
17
+ cryptoModule: globalThis.crypto
18
+ });
19
+
20
+ export type MonacoCollabCallbacks = {
21
+ onUserRequestsAccess: (user: types.User) => Promise<boolean>;
22
+ /**
23
+ * reports the status when joining or creating a room
24
+ * @param info information about the changed status
25
+ */
26
+ statusReporter?: (info: types.Info) => void;
27
+ }
28
+
29
+ export type MonacoCollabOptions = {
30
+ serverUrl: string;
31
+ callbacks: MonacoCollabCallbacks;
32
+ userToken?: string;
33
+ roomToken?: string;
34
+ useCookieAuth?: boolean;
35
+ loginPageOpener?: (token: string, authenticationMetadata: types.AuthMetadata) => Promise<boolean>;
36
+ };
37
+
38
+ export type OtherUserData = {peer: types.Peer, color: string};
39
+ export type UserData = {me: types.Peer, others: OtherUserData[]};
40
+
41
+ export type MonacoCollabApi = {
42
+ createRoom: () => Promise<string | undefined>
43
+ joinRoom: (roomToken: string) => Promise<string | undefined>
44
+ leaveRoom: () => void
45
+ login: () => Promise<string | undefined>
46
+ logout: () => Promise<void | undefined>
47
+ isLoggedIn: () => Promise<boolean>
48
+ setEditor: (editor: monaco.editor.IStandaloneCodeEditor) => void
49
+ getUserData: () => Promise<UserData | undefined>
50
+ onUsersChanged: (evt: UsersChangeEvent) => void
51
+ onFileNameChange: (callback: FileNameChangeEvent) => void
52
+ getCurrentConnection: () => types.ProtocolBroadcastConnection | undefined
53
+ followUser: (id?: string) => void
54
+ getFollowedUser: () => string | undefined
55
+ setFileName: (fileName: string) => void
56
+ getFileName: () => string | undefined
57
+ setWorkspaceName: (workspaceName: string) => void
58
+ getWorkspaceName: () => string | undefined
59
+ }
60
+
61
+ export function monacoCollab(options: MonacoCollabOptions): MonacoCollabApi {
62
+ connectionProvider = new ConnectionProvider({
63
+ url: options.serverUrl,
64
+ authenticationHandler: options.loginPageOpener ?? (async (_token, metaData) => {
65
+ // If this returns null, it means the window could not be opened and the authentication failed
66
+ return window.open(metaData.loginPageUrl, '_blank') !== null;
67
+ }),
68
+ transports: [SocketIoTransportProvider],
69
+ userToken: options.userToken,
70
+ useCookieAuth: options.useCookieAuth,
71
+ fetch: async (url, options) => {
72
+ const response = await fetch(url, options);
73
+ return {
74
+ ok: response.ok,
75
+ status: response.status,
76
+ json: async () => response.json(),
77
+ text: async () => response.text()
78
+ };
79
+ }
80
+ });
81
+
82
+ const doCreateRoom = async () => {
83
+ console.log('Creating room');
84
+
85
+ if (!connectionProvider) {
86
+ console.log('No OCT Server configured.');
87
+ throw new Error('No OCT Server configured.');
88
+ }
89
+
90
+ instance = await createRoom(connectionProvider, options.callbacks);
91
+ if (instance) {
92
+ return instance.roomId;
93
+ }
94
+ throw new Error('Failed to create room');
95
+ };
96
+
97
+ const doJoinRoom = async (roomToken: string) => {
98
+ console.log('Joining room', roomToken);
99
+
100
+ if (!connectionProvider) {
101
+ console.log('No OCT Server configured.');
102
+ throw new Error('No OCT Server configured.');
103
+ }
104
+
105
+ const res = await joinRoom(connectionProvider, options.callbacks, roomToken);
106
+ if (res && 'message' in res) {
107
+ console.log('Failed to join room: ', res.message);
108
+ throw new Error('Failed to join room: ' + res.message);
109
+ } else {
110
+ instance = res;
111
+ return instance.roomId;
112
+ }
113
+ };
114
+
115
+ const doLogin = async () => {
116
+ if (!connectionProvider) {
117
+ console.log('No OCT Server configured.');
118
+ throw new Error('No OCT Server configured.');
119
+ }
120
+ await login(connectionProvider);
121
+ return connectionProvider.authToken;
122
+ };
123
+
124
+ const doSetEditor = (editor: monaco.editor.IStandaloneCodeEditor) => {
125
+ if (instance) {
126
+ instance.setEditor(editor);
127
+ }
128
+ };
129
+
130
+ const doGetUserData = async () => {
131
+ let data: UserData | undefined;
132
+ if (instance) {
133
+ const me: types.Peer = await instance.ownUserData;
134
+ const others = instance.connectedUsers.map(
135
+ user => ({
136
+ peer: user.peer,
137
+ color: user.color ?? 'rgba(0, 0, 0, 0.5)'
138
+ }));
139
+ data = {me, others};
140
+ }
141
+ return data;
142
+ };
143
+
144
+ const registerUserChangeHandler = (evt: UsersChangeEvent) => {
145
+ if (instance) {
146
+ instance.onUsersChanged(evt);
147
+ }
148
+ };
149
+
150
+ const doFollowUser = (id?: string) => {
151
+ if (instance) {
152
+ instance.followUser(id);
153
+ }
154
+ };
155
+
156
+ const doGetFollowedUser = () => {
157
+ if (instance) {
158
+ return instance.following;
159
+ }
160
+ return undefined;
161
+ };
162
+
163
+ const doSetFileName = (fileName: string) => {
164
+ if (instance) {
165
+ instance.setFileName(fileName);
166
+ }
167
+ };
168
+
169
+ const doGetWorkspaceName = () => {
170
+ if (instance) {
171
+ return instance.workspaceName;
172
+ }
173
+ return undefined;
174
+ };
175
+
176
+ const doGetFileName = () => {
177
+ if (instance) {
178
+ return instance.fileName;
179
+ }
180
+ return undefined;
181
+ };
182
+
183
+ const registerFileNameChangeHandler = (callback: FileNameChangeEvent) => {
184
+ if (instance) {
185
+ instance.onFileNameChange(callback);
186
+ }
187
+ };
188
+
189
+ const doSetWorkspaceName = (workspaceName: string) => {
190
+ if (instance) {
191
+ instance.workspaceName = workspaceName;
192
+ }
193
+ };
194
+
195
+ const isLoggedIn = async () => {
196
+ if (!connectionProvider) {
197
+ return false;
198
+ }
199
+
200
+ if (options.useCookieAuth) {
201
+ const valid = await fetch(options.serverUrl + '/api/login/validate', {
202
+ credentials: 'include',
203
+ method: 'POST',
204
+ });
205
+ return valid.ok && (await valid.json())?.valid;
206
+ } else {
207
+ return !!connectionProvider.authToken;
208
+ }
209
+ };
210
+
211
+ return {
212
+ createRoom: doCreateRoom,
213
+ joinRoom: doJoinRoom,
214
+ leaveRoom: () => instance?.leaveRoom(),
215
+ login: doLogin,
216
+ logout: async () => connectionProvider?.logout(),
217
+ isLoggedIn: isLoggedIn,
218
+ setEditor: doSetEditor,
219
+ getUserData: doGetUserData,
220
+ onUsersChanged: registerUserChangeHandler,
221
+ onFileNameChange: registerFileNameChangeHandler,
222
+ followUser: doFollowUser,
223
+ getFollowedUser: doGetFollowedUser,
224
+ getCurrentConnection: () => instance?.getCurrentConnection(),
225
+ setFileName: doSetFileName,
226
+ getFileName: doGetFileName,
227
+ getWorkspaceName: doGetWorkspaceName,
228
+ setWorkspaceName: doSetWorkspaceName
229
+ };
230
+
231
+ }
232
+
233
+ export function deactivate() {
234
+ instance?.dispose();
235
+ }
package/src/types.ts ADDED
@@ -0,0 +1,9 @@
1
+ // ******************************************************************************
2
+ // Copyright 2025 TypeFox GmbH
3
+ // This program and the accompanying materials are made available under the
4
+ // terms of the MIT License, which is available in the project root.
5
+ // ******************************************************************************
6
+
7
+ export type { Peer } from '@hereugo/open-collaboration-protocol';
8
+ export type { User } from '@hereugo/open-collaboration-protocol';
9
+ export type { AuthMetadata } from '@hereugo/open-collaboration-protocol';