Skip to content

Commit f64d251

Browse files
committed
Support suggestions
1 parent b8fafc0 commit f64d251

File tree

7 files changed

+567
-29
lines changed

7 files changed

+567
-29
lines changed

packages/collaboration-extension/src/collaboration.ts

+238-4
Original file line numberDiff line numberDiff line change
@@ -5,28 +5,44 @@
55
* @module collaboration-extension
66
*/
77

8+
import {
9+
DocumentRegistry
10+
} from '@jupyterlab/docregistry';
11+
12+
import {
13+
NotebookPanel, INotebookModel
14+
} from '@jupyterlab/notebook';
15+
16+
import {
17+
IDisposable, DisposableDelegate
18+
} from '@lumino/disposable';
19+
20+
import { CommandRegistry } from '@lumino/commands';
21+
822
import {
923
JupyterFrontEnd,
1024
JupyterFrontEndPlugin
1125
} from '@jupyterlab/application';
12-
import { IToolbarWidgetRegistry } from '@jupyterlab/apputils';
26+
import { Dialog, IToolbarWidgetRegistry } from '@jupyterlab/apputils';
1327
import {
1428
EditorExtensionRegistry,
1529
IEditorExtensionRegistry
1630
} from '@jupyterlab/codemirror';
1731
import {
32+
requestDocDelete,
33+
requestDocMerge,
1834
IGlobalAwareness,
1935
WebSocketAwarenessProvider
2036
} from '@jupyter/docprovider';
21-
import { SidePanel, usersIcon } from '@jupyterlab/ui-components';
37+
import { SidePanel, usersIcon, caretDownIcon } from '@jupyterlab/ui-components';
2238
import { URLExt } from '@jupyterlab/coreutils';
2339
import { ServerConnection } from '@jupyterlab/services';
2440
import { IStateDB, StateDB } from '@jupyterlab/statedb';
25-
import { ITranslator, nullTranslator } from '@jupyterlab/translation';
41+
import { ITranslator, nullTranslator, TranslationBundle } from '@jupyterlab/translation';
2642

2743
import { Menu, MenuBar } from '@lumino/widgets';
2844

29-
import { IAwareness } from '@jupyter/ydoc';
45+
import { IAwareness, ISharedNotebook, NotebookChange } from '@jupyter/ydoc';
3046

3147
import {
3248
CollaboratorsPanel,
@@ -192,3 +208,221 @@ export const userEditorCursors: JupyterFrontEndPlugin<void> = {
192208
});
193209
}
194210
};
211+
212+
/**
213+
* A plugin to add editing mode to the notebook page
214+
*/
215+
export const editingMode: JupyterFrontEndPlugin<void> = {
216+
id: '@jupyter/collaboration-extension:editingMode',
217+
description: 'A plugin to add editing mode to the notebook page.',
218+
autoStart: true,
219+
optional: [ITranslator],
220+
activate: (
221+
app: JupyterFrontEnd,
222+
translator: ITranslator | null
223+
) => {
224+
app.docRegistry.addWidgetExtension('Notebook', new EditingModeExtension(translator));
225+
},
226+
};
227+
228+
export class EditingModeExtension implements DocumentRegistry.IWidgetExtension<NotebookPanel, INotebookModel> {
229+
private _trans: TranslationBundle;
230+
231+
constructor(translator: ITranslator | null) {
232+
this._trans = (translator ?? nullTranslator).load('jupyter_collaboration');
233+
}
234+
235+
createNew(
236+
panel: NotebookPanel,
237+
context: DocumentRegistry.IContext<INotebookModel>
238+
): IDisposable {
239+
const editingMenubar = new MenuBar();
240+
const suggestionMenubar = new MenuBar();
241+
const reviewMenubar = new MenuBar();
242+
243+
const editingCommands = new CommandRegistry();
244+
const suggestionCommands = new CommandRegistry();
245+
const reviewCommands = new CommandRegistry();
246+
247+
const editingMenu = new Menu({ commands: editingCommands });
248+
const suggestionMenu = new Menu({ commands: suggestionCommands });
249+
const reviewMenu = new Menu({ commands: reviewCommands });
250+
251+
const sharedModel = context.model.sharedModel;
252+
const suggestions: {[key: string]: Menu.IItem} = {};
253+
var myForkId = ''; // curently allows only one suggestion per user
254+
255+
editingMenu.title.label = 'Editing';
256+
editingMenu.title.icon = caretDownIcon;
257+
258+
suggestionMenu.title.label = 'Root';
259+
suggestionMenu.title.icon = caretDownIcon;
260+
261+
reviewMenu.title.label = 'Review';
262+
reviewMenu.title.icon = caretDownIcon;
263+
264+
editingCommands.addCommand('editing', {
265+
label: 'Editing',
266+
execute: () => {
267+
editingMenu.title.label = 'Editing';
268+
suggestionMenu.title.label = 'Root';
269+
open_dialog('Editing', this._trans);
270+
}
271+
});
272+
editingCommands.addCommand('suggesting', {
273+
label: 'Suggesting',
274+
execute: () => {
275+
editingMenu.title.label = 'Suggesting';
276+
reviewMenu.clearItems();
277+
if (myForkId === '') {
278+
myForkId = 'pending';
279+
sharedModel.provider.fork().then(newForkId => {
280+
myForkId = newForkId;
281+
sharedModel.provider.connect(newForkId);
282+
suggestionMenu.title.label = newForkId;
283+
});
284+
}
285+
else {
286+
suggestionMenu.title.label = myForkId;
287+
sharedModel.provider.connect(myForkId);
288+
}
289+
open_dialog('Suggesting', this._trans);
290+
}
291+
});
292+
293+
suggestionCommands.addCommand('root', {
294+
label: 'Root',
295+
execute: () => {
296+
// we cannot review the root document
297+
reviewMenu.clearItems();
298+
suggestionMenu.title.label = 'Root';
299+
editingMenu.title.label = 'Editing';
300+
sharedModel.provider.connect(sharedModel.rootRoomId);
301+
open_dialog('Editing', this._trans);
302+
}
303+
});
304+
305+
reviewCommands.addCommand('merge', {
306+
label: 'Merge',
307+
execute: () => {
308+
requestDocMerge(sharedModel.currentRoomId, sharedModel.rootRoomId);
309+
}
310+
});
311+
reviewCommands.addCommand('discard', {
312+
label: 'Discard',
313+
execute: () => {
314+
requestDocDelete(sharedModel.currentRoomId, sharedModel.rootRoomId);
315+
}
316+
});
317+
318+
editingMenu.addItem({type: 'command', command: 'editing'});
319+
editingMenu.addItem({type: 'command', command: 'suggesting'});
320+
321+
suggestionMenu.addItem({type: 'command', command: 'root'});
322+
323+
const _onStateChanged = (sender: ISharedNotebook, changes: NotebookChange) => {
324+
if (changes.stateChange) {
325+
changes.stateChange.forEach(value => {
326+
const forkPrefix = 'fork_';
327+
if (value.name === 'merge' || value.name === 'delete') {
328+
// we are on fork
329+
if (sharedModel.currentRoomId === value.newValue) {
330+
reviewMenu.clearItems();
331+
const merge = value.name === 'merge';
332+
sharedModel.provider.connect(sharedModel.rootRoomId, merge);
333+
open_dialog('Editing', this._trans);
334+
myForkId = '';
335+
}
336+
}
337+
else if (value.name.startsWith(forkPrefix)) {
338+
// we are on root
339+
const forkId = value.name.slice(forkPrefix.length);
340+
if (value.newValue === 'new') {
341+
suggestionCommands.addCommand(forkId, {
342+
label: forkId,
343+
execute: () => {
344+
editingMenu.title.label = 'Suggesting';
345+
reviewMenu.clearItems();
346+
reviewMenu.addItem({type: 'command', command: 'merge'});
347+
reviewMenu.addItem({type: 'command', command: 'discard'});
348+
suggestionMenu.title.label = forkId;
349+
sharedModel.provider.connect(forkId);
350+
open_dialog('Suggesting', this._trans);
351+
}
352+
});
353+
const item = suggestionMenu.addItem({type: 'command', command: forkId});
354+
suggestions[forkId] = item;
355+
if (myForkId !== forkId) {
356+
if (myForkId !== 'pending') {
357+
const dialog = new Dialog({
358+
title: this._trans.__('New suggestion'),
359+
body: this._trans.__('View suggestion?'),
360+
buttons: [
361+
Dialog.okButton({ label: 'View' }),
362+
Dialog.cancelButton({ label: 'Discard' }),
363+
],
364+
});
365+
dialog.launch().then(resp => {
366+
dialog.close();
367+
if (resp.button.label === 'View') {
368+
sharedModel.provider.connect(forkId);
369+
suggestionMenu.title.label = forkId;
370+
editingMenu.title.label = 'Suggesting';
371+
reviewMenu.clearItems();
372+
reviewMenu.addItem({type: 'command', command: 'merge'});
373+
reviewMenu.addItem({type: 'command', command: 'discard'});
374+
}
375+
});
376+
}
377+
else {
378+
reviewMenu.clearItems();
379+
reviewMenu.addItem({type: 'command', command: 'merge'});
380+
reviewMenu.addItem({type: 'command', command: 'discard'});
381+
}
382+
}
383+
}
384+
else if (value.newValue === undefined) {
385+
editingMenu.title.label = 'Editing';
386+
suggestionMenu.title.label = 'Root';
387+
const item: Menu.IItem = suggestions[value.oldValue];
388+
delete suggestions[value.oldValue];
389+
suggestionMenu.removeItem(item);
390+
}
391+
}
392+
});
393+
}
394+
};
395+
396+
sharedModel.changed.connect(_onStateChanged, this);
397+
398+
editingMenubar.addMenu(editingMenu);
399+
suggestionMenubar.addMenu(suggestionMenu);
400+
reviewMenubar.addMenu(reviewMenu);
401+
402+
panel.toolbar.insertItem(997, 'editingMode', editingMenubar);
403+
panel.toolbar.insertItem(998, 'suggestions', suggestionMenubar);
404+
panel.toolbar.insertItem(999, 'review', reviewMenubar);
405+
return new DisposableDelegate(() => {
406+
editingMenubar.dispose();
407+
suggestionMenubar.dispose();
408+
reviewMenubar.dispose();
409+
});
410+
}
411+
}
412+
413+
414+
function open_dialog(title: string, trans: TranslationBundle) {
415+
var body: string;
416+
if (title === 'Editing') {
417+
body = 'You are now directly editing the document.'
418+
}
419+
else {
420+
body = 'Your edits now become suggestions to the document.'
421+
}
422+
const dialog = new Dialog({
423+
title: trans.__(title),
424+
body: trans.__(body),
425+
buttons: [Dialog.okButton({ label: 'OK' })],
426+
});
427+
dialog.launch().then(resp => { dialog.close(); });
428+
}

packages/collaboration-extension/src/index.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import {
1212
menuBarPlugin,
1313
rtcGlobalAwarenessPlugin,
1414
rtcPanelPlugin,
15-
userEditorCursors
15+
userEditorCursors,
16+
editingMode
1617
} from './collaboration';
1718
import { sharedLink } from './sharedlink';
1819

@@ -25,7 +26,8 @@ const plugins: JupyterFrontEndPlugin<any>[] = [
2526
rtcGlobalAwarenessPlugin,
2627
rtcPanelPlugin,
2728
sharedLink,
28-
userEditorCursors
29+
userEditorCursors,
30+
editingMode
2931
];
3032

3133
export default plugins;

0 commit comments

Comments
 (0)