浏览代码

Merge pull request #2139 from ian-r-rose/collaborator_cursors

Collaborator cursors
Steven Silvester 8 年之前
父节点
当前提交
24fbcda6dc

+ 17 - 1
packages/cells/src/widget.ts

@@ -26,7 +26,7 @@ import {
 } from '@jupyterlab/apputils';
 
 import {
-  IChangedArgs
+  IChangedArgs, ActivityMonitor
 } from '@jupyterlab/coreutils';
 
 import {
@@ -142,6 +142,10 @@ const NO_OUTPUTS_CLASS = 'jp-mod-noOutputs';
  */
 const DEFAULT_MARKDOWN_TEXT = 'Type Markdown and LaTeX: $ α^2 $';
 
+/**
+ * The timeout to wait for change activity to have ceased before rendering.
+ */
+const RENDER_TIMEOUT = 1000;
 
 /******************************************************************************
  * Cell
@@ -664,6 +668,17 @@ class MarkdownCell extends Cell {
     this.addClass(MARKDOWN_CELL_CLASS);
     this._rendermime = options.rendermime;
     this.editor.wordWrap = true;
+
+    // Throttle the rendering rate of the widget.
+    this._monitor = new ActivityMonitor({
+      signal: this.model.contentChanged,
+      timeout: RENDER_TIMEOUT
+    });
+    this._monitor.activityStopped.connect(() => {
+      if (this._rendered) {
+        this.update();
+      }
+    }, this);
   }
 
   /**
@@ -754,6 +769,7 @@ class MarkdownCell extends Cell {
     this._prevTrusted = trusted;
   }
 
+  private _monitor: ActivityMonitor<any, any> = null;
   private _rendermime: RenderMime = null;
   private _renderedInput: Widget = null;
   private _rendered = true;

+ 5 - 0
packages/codeeditor/src/editor.ts

@@ -105,6 +105,11 @@ namespace CodeEditor {
      * A display name added to a selection.
      */
     displayName?: string;
+
+    /**
+     * A color for UI elements.
+     */
+    color?: string;
   }
 
   /**

+ 61 - 9
packages/codemirror/src/editor.ts

@@ -4,6 +4,10 @@
 import * as CodeMirror
   from 'codemirror';
 
+import {
+  JSONExt
+} from '@phosphor/coreutils';
+
 import {
   ArrayExt
 } from '@phosphor/algorithm';
@@ -410,6 +414,12 @@ class CodeMirrorEditor implements CodeEditor.IEditor {
   setCursorPosition(position: CodeEditor.IPosition): void {
     const cursor = this._toCodeMirrorPosition(position);
     this.doc.setCursor(cursor);
+    // If the editor does not have focus, this cursor change
+    // will get screened out in _onCursorsChanged(). Make an
+    // exception for this method.
+    if (!this.editor.hasFocus()) {
+      this.model.selections.set(this.uuid, this.getSelections());
+    }
   }
 
   /**
@@ -514,7 +524,9 @@ class CodeMirrorEditor implements CodeEditor.IEditor {
     const uuid = args.key;
     if (uuid !== this.uuid) {
       this._cleanSelections(uuid);
-      this._markSelections(uuid, args.newValue);
+      if (args.type !== 'remove') {
+        this._markSelections(uuid, args.newValue);
+      }
     }
   }
 
@@ -535,9 +547,17 @@ class CodeMirrorEditor implements CodeEditor.IEditor {
   private _markSelections(uuid: string, selections: CodeEditor.ITextSelection[]) {
     const markers: CodeMirror.TextMarker[] = [];
     selections.forEach(selection => {
-      const { anchor, head } = this._toCodeMirrorSelection(selection);
-      const markerOptions = this._toTextMarkerOptions(selection);
-      this.doc.markText(anchor, head, markerOptions);
+      // Only render selections if the start is not equal to the end.
+      // In that case, we don't need to render the cursor.
+      if (!JSONExt.deepEqual(selection.start, selection.end)) {
+        const { anchor, head } = this._toCodeMirrorSelection(selection);
+        const markerOptions = this._toTextMarkerOptions(selection.style);
+        markers.push(this.doc.markText(anchor, head, markerOptions));
+      } else {
+        let caret = this._getCaret(selection.uuid);
+        markers.push(this.doc.setBookmark(
+          this._toCodeMirrorPosition(selection.end), {widget: caret}));
+      }
     });
     this.selectionMarkers[uuid] = markers;
   }
@@ -546,8 +566,12 @@ class CodeMirrorEditor implements CodeEditor.IEditor {
    * Handles a cursor activity event.
    */
   private _onCursorActivity(): void {
-    const selections = this.getSelections();
-    this.model.selections.set(this.uuid, selections);
+    // Only add selections if the editor has focus. This avoids unwanted
+    // triggering of cursor activity due to collaborator actions.
+    if (this._editor.hasFocus()) {
+      const selections = this.getSelections();
+      this.model.selections.set(this.uuid, selections);
+    }
   }
 
   /**
@@ -567,9 +591,17 @@ class CodeMirrorEditor implements CodeEditor.IEditor {
    */
   private _toTextMarkerOptions(style: CodeEditor.ISelectionStyle | undefined): CodeMirror.TextMarkerOptions | undefined {
     if (style) {
+      let css: string;
+      if (style.color) {
+        let r = parseInt(style.color.slice(1,3), 16);
+        let g  = parseInt(style.color.slice(3,5), 16);
+        let b  = parseInt(style.color.slice(5,7), 16);
+        css = `background-color: rgba( ${r}, ${g}, ${b}, 0.1)`;
+      }
       return {
         className: style.className,
-        title: style.displayName
+        title: style.displayName,
+        css
       };
     }
     return undefined;
@@ -579,9 +611,17 @@ class CodeMirrorEditor implements CodeEditor.IEditor {
    * Converts an editor selection to a code mirror selection.
    */
   private _toCodeMirrorSelection(selection: CodeEditor.IRange): CodeMirror.Selection {
+    // Selections only appear to render correctly if the anchor
+    // is before the head in the document. That is, reverse selections
+    // do not appear as intended.
+    let forward: boolean = (selection.start.line < selection.end.line) ||
+                           (selection.start.line === selection.end.line &&
+                            selection.start.column <= selection.end.column);
+    let anchor = forward ? selection.start : selection.end;
+    let head = forward ? selection.end : selection.start;
     return {
-      anchor: this._toCodeMirrorPosition(selection.start),
-      head: this._toCodeMirrorPosition(selection.end)
+      anchor: this._toCodeMirrorPosition(anchor),
+      head: this._toCodeMirrorPosition(head)
     };
   }
 
@@ -694,6 +734,18 @@ class CodeMirrorEditor implements CodeEditor.IEditor {
     }
   }
 
+  /**
+   * Construct a caret element representing the position
+   * of a collaborator's cursor.
+   */
+  private _getCaret(uuid: string): HTMLElement {
+    let caret: HTMLElement = document.createElement('span');
+    caret.className = 'jp-CollaboratorCursor';
+    caret.style.borderBottomColor=`${this._selectionStyle.color}`
+    caret.appendChild(document.createTextNode('\u00a0'));
+    return caret;
+  }
+
   private _model: CodeEditor.IModel;
   private _editor: CodeMirror.Editor;
   protected selectionMarkers: { [key: string]: CodeMirror.TextMarker[] | undefined } = {};

+ 9 - 0
packages/codemirror/style/index.css

@@ -66,6 +66,15 @@
   margin-left: calc(-1 * var(--jp-code-padding));
 }
 
+.jp-CollaboratorCursor {
+  border-left: 5px solid transparent;
+  border-right: 5px solid transparent;
+  border-top: none;
+  border-bottom: 3px solid;
+  position: absolute;
+  width: 0;
+}
+
 
 /*
   Here is our jupyter theme for CodeMirror syntax highlighting

+ 30 - 0
packages/fileeditor/src/widget.ts

@@ -48,6 +48,22 @@ class FileEditor extends CodeEditorWrapper {
     context.pathChanged.connect(this._onPathChanged, this);
     context.ready.then(() => { this._onContextReady(); });
     this._onPathChanged();
+
+    if (context.model.modelDB.isCollaborative) {
+      let modelDB = context.model.modelDB;
+      modelDB.connected.then(() => {
+        // Setup the selection style for collaborators
+        let localCollaborator = modelDB.collaborators.localCollaborator;
+        this.editor.uuid = localCollaborator.sessionId;
+        this.editor.selectionStyle = {
+          color: localCollaborator.color
+        };
+
+        modelDB.collaborators.changed.connect(this._onCollaboratorsChanged, this);
+        // Trigger an initial onCollaboratorsChanged event.
+        this._onCollaboratorsChanged();
+      });
+    }
   }
 
   /**
@@ -124,6 +140,20 @@ class FileEditor extends CodeEditorWrapper {
     this.title.label = path.split('/').pop();
   }
 
+  /**
+   * Handle a change to the collaborators on the model
+   * by updating UI elements associated with them.
+   */
+  private _onCollaboratorsChanged(): void {
+    // If there are selections corresponding to non-collaborators,
+    // they are stale and should be removed.
+    for (let key of this.editor.model.selections.keys()) {
+      if (!this._context.model.modelDB.collaborators.has(key)) {
+        this.editor.model.selections.delete(key);
+      }
+    }
+  }
+
   protected _context: DocumentRegistry.Context;
   private _mimeTypeService: IEditorMimeTypeService;
 }

+ 59 - 0
packages/notebook/src/widget.ts

@@ -203,6 +203,20 @@ class StaticNotebook extends Widget {
     }
     let oldValue = this._model;
     this._model = newValue;
+
+    if (oldValue && oldValue.modelDB.isCollaborative) {
+      oldValue.modelDB.connected.then(() => {
+        oldValue.modelDB.collaborators.changed.disconnect(
+          this._onCollaboratorsChanged, this);
+      });
+    }
+    if (newValue && newValue.modelDB.isCollaborative) {
+      newValue.modelDB.connected.then(() => {
+        newValue.modelDB.collaborators.changed.connect(
+          this._onCollaboratorsChanged, this);
+      });
+    }
+
     // Trigger private, protected, and public changes.
     this._onModelChanged(oldValue, newValue);
     this.onModelChanged(oldValue, newValue);
@@ -453,6 +467,23 @@ class StaticNotebook extends Widget {
     });
   }
 
+  /**
+   * Handle an update to the collaborators.
+   */
+  private _onCollaboratorsChanged(): void {
+    // If there are selections corresponding to non-collaborators,
+    // they are stale and should be removed.
+    for (let i = 0; i < this.widgets.length; i++) {
+      let cell = this.widgets[i];
+      for (let key of cell.model.selections.keys()) {
+        if (!this._model.modelDB.collaborators.has(key)) {
+          cell.model.selections.delete(key);
+        }
+      }
+    }
+  }
+
+
   private _mimetype = 'text/plain';
   private _model: INotebookModel = null;
   private _mimetypeService: IEditorMimeTypeService;
@@ -695,6 +726,7 @@ class Notebook extends StaticNotebook {
     if (newValue === oldValue) {
       return;
     }
+    this._trimSelections();
     this._stateChanged.emit({ name: 'activeCellIndex', oldValue, newValue });
   }
 
@@ -928,6 +960,19 @@ class Notebook extends StaticNotebook {
    * Handle a cell being inserted.
    */
   protected onCellInserted(index: number, cell: Cell): void {
+    if (this.model && this.model.modelDB.isCollaborative) {
+      let modelDB = this.model.modelDB;
+      modelDB.connected.then(() => {
+        if (!cell.isDisposed) {
+          // Setup the selection style for collaborators.
+          let localCollaborator = modelDB.collaborators.localCollaborator;
+          cell.editor.uuid = localCollaborator.sessionId;
+          cell.editor.selectionStyle = {
+            color: localCollaborator.color
+          };
+        }
+      });
+    }
     cell.editor.edgeRequested.connect(this._onEdgeRequest, this);
     // If the insertion happened above, increment the active cell
     // index, otherwise it stays the same.
@@ -1384,6 +1429,20 @@ class Notebook extends StaticNotebook {
     this._selectionChanged.emit(void 0);
   }
 
+  /**
+   * Remove selections from inactive cells to avoid
+   * spurious cursors.
+   */
+  private _trimSelections(): void {
+    for (let i = 0; i < this.widgets.length; i++) {
+      if (i !== this._activeCellIndex) {
+        let cell = this.widgets[i];
+        cell.model.selections.delete(cell.editor.uuid);
+      }
+    }
+  }
+
+
   private _activeCellIndex = -1;
   private _activeCell: Cell = null;
   private _mode: NotebookMode = 'command';