Просмотр исходного кода

Merge pull request #1376 from blink1073/codeeditor-cleanup

Clean up code editor
Afshin Darian 8 лет назад
Родитель
Сommit
98ce3d79fa

+ 114 - 396
src/codeeditor/editor.ts

@@ -5,22 +5,18 @@ import {
   IDisposable
 } from 'phosphor/lib/core/disposable';
 
-import {
-  Message
-} from 'phosphor/lib/core/messaging';
-
 import {
   ISignal, clearSignalData, defineSignal
 } from 'phosphor/lib/core/signaling';
 
-import {
-  Widget
-} from 'phosphor/lib/ui/widget';
-
 import {
   IChangedArgs
 } from '../common/interfaces';
 
+import {
+  IObservableString
+} from '../common/observablestring';
+
 
 /**
  * A namespace for code editors.
@@ -40,12 +36,12 @@ namespace CodeEditor {
     /**
      * The cursor line number.
      */
-    line: number;
+    readonly line: number;
 
     /**
      * The cursor column number.
      */
-    column: number;
+    readonly column: number;
   }
 
   /**
@@ -54,14 +50,14 @@ namespace CodeEditor {
   export
   interface IDimension {
     /**
-     * The width of an element in pixels.  
+     * The width of an element in pixels.
      */
-    width: number;
+    readonly width: number;
 
     /**
      * The height of an element in pixels.
      */
-    height: number;
+    readonly height: number;
   }
 
   /**
@@ -102,7 +98,7 @@ namespace CodeEditor {
      * If this position is greater than [end] then the range is considered
      * to be backward.
      */
-    start: IPosition;
+    readonly start: IPosition;
 
     /**
      * The position of the last character in the current range.
@@ -111,7 +107,7 @@ namespace CodeEditor {
      * If this position is less than [start] then the range is considered
      * to be backward.
      */
-    end: IPosition;
+    readonly end: IPosition;
   }
 
   /**
@@ -138,24 +134,24 @@ namespace CodeEditor {
     /**
      * The uuid of the text selection owner.
      */
-    uuid: string;
+    readonly uuid: string;
 
     /**
      * The style of this selection.
      */
-    style?: ISelectionStyle;
+    readonly style?: ISelectionStyle;
   }
 
   /**
    * An interface to manage selections by selection owners.
-   * 
+   *
    * #### Definitions
    * - a user code that has an associated uuid is called a selection owner, see `CodeEditor.ISelectionOwner`
    * - a selection belongs to a selection owner only if it is associated with the owner by an uuid, see `CodeEditor.ITextSelection`
-   * 
+   *
    * #### Read access
    * - any user code can observe any selection
-   * 
+   *
    * #### Write access
    * - if a user code is a selection owner then:
    *   - it can change selections beloging to it
@@ -176,12 +172,20 @@ namespace CodeEditor {
     readonly uuids: string[];
 
     /**
-     * Gets the selections for all the cursors in ascending order. 
+     * Gets the selections for all the cursors in ascending order.
+     *
+     * @param uuid - The id of the selection owner.
+     *
+     * @returns A new array of text selections.
      */
     getSelections(uuid: string): ITextSelection[];
 
     /**
      * Sets the selections for all the cursors.
+     *
+     * @param uuid - The id of the selection owner.
+     *
+     * @param newSelections - The replacement text selections.
      */
     setSelections(uuid: string, newSelections: ITextSelection[]): void;
   }
@@ -215,8 +219,7 @@ namespace CodeEditor {
    * Default implementation of `ISelections`.
    */
   export
-  class Selections implements ISelections {
-
+  class Selections implements ISelections, IDisposable {
     /**
      * A signal emitted when selections changes.
      */
@@ -230,7 +233,30 @@ namespace CodeEditor {
     }
 
     /**
-     * Gets the selections for all the cursors in ascending order. 
+     * Test whether the selections are disposed.
+     */
+    get isDisposed(): boolean {
+      return this._isDisposed;
+    }
+
+    /**
+     * Dispose of the resources used by the selections.
+     */
+    dispose(): void {
+      if (this.isDisposed) {
+        return;
+      }
+      this._selections = {};
+      this._isDisposed = true;
+      clearSignalData(this);
+    }
+
+    /**
+     * Gets the selections for all the cursors in ascending order.
+     *
+     * @param uuid - The id of the selection owner.
+     *
+     * @returns A new array of text selections.
      */
     getSelections(uuid: string): ITextSelection[] {
       const selections = this._selections[uuid];
@@ -239,6 +265,10 @@ namespace CodeEditor {
 
     /**
      * Sets the selections for all the cursors.
+     *
+     * @param uuid - The id of the selection owner.
+     *
+     * @param newSelections - The replacement text selections.
      */
     setSelections(uuid: string, newSelections: ITextSelection[]): void {
       const oldSelections = this.getSelections(uuid);
@@ -268,11 +298,15 @@ namespace CodeEditor {
       delete this._selections[uuid];
     }
 
+    private _isDisposed = false;
     private _selections: {
       [key: string]: ITextSelection[] | null
     } = {};
   }
 
+  /**
+   * Define the signals for the `Selections` class.
+   */
   defineSignal(Selections.prototype, 'changed');
 
   /**
@@ -280,11 +314,6 @@ namespace CodeEditor {
    */
   export
   interface IModel extends IDisposable {
-    /**
-     * A signal emitted when the value changes.
-     */
-    valueChanged: ISignal<IModel, IChangedArgs<string>>;
-
     /**
      * A signal emitted when a property changes.
      */
@@ -293,11 +322,11 @@ namespace CodeEditor {
     /**
      * The text stored in the model.
      */
-    value: string;  // TODO: this should be an iobservablestring.
+    readonly value: IObservableString;
 
     /**
      * A mime type of the model.
-     * 
+     *
      * #### Notes
      * It is never `null`, the default mime type is `text/plain`.
      */
@@ -315,16 +344,28 @@ namespace CodeEditor {
 
     /**
      * Returns the content for the given line number.
+     *
+     * @param line - The line of interest.
+     *
+     * @returns The value of the line.
      */
     getLine(line: number): string;
 
     /**
      * Find an offset for the given position.
+     *
+     * @param position - The position of interest.
+     *
+     * @returns The offset at the position.
      */
     getOffsetAt(position: IPosition): number;
 
     /**
      * Find a position for the given offset.
+     *
+     * @param offset - The offset of interest.
+     *
+     * @returns The position at the offset.
      */
     getPositionAt(offset: number): IPosition;
 
@@ -353,37 +394,58 @@ namespace CodeEditor {
      * The uuid of this selection owner.
      */
     readonly uuid: string;
+
     /**
      * Returns the primary position of the cursor, never `null`.
      */
     getCursorPosition(): IPosition;
+
     /**
-     * Set the primary position of the cursor. This will remove any secondary cursors.
+     * Set the primary position of the cursor.
+     *
+     * @param position - The new primary position.
+     *
+     * #### Notes
+     * This will remove any secondary cursors.
      */
     setCursorPosition(position: IPosition): void;
+
     /**
      * Returns the primary selection, never `null`.
      */
     getSelection(): IRange;
+
     /**
-     * Set the primary selection. This will remove any secondary cursors.
+     * Set the primary selection.
+     *
+     * @param selection - The desired selection range.
+     *
+     * #### Notes
+     * This will remove any secondary cursors.
      */
     setSelection(selection: IRange): void;
+
     /**
      * Gets the selections for all the cursors, never `null` or empty.
      */
     getSelections(): IRange[];
+
     /**
      * Sets the selections for all the cursors.
+     *
+     * @param selections - The new selections.
+     *
+     * #### Notes
      * Cursors will be removed or added, as necessary.
-     * Passing an empty array resets a cursor position to the start of a document.
+     * Passing an empty array resets a cursor position to the start of a
+     * document.
      */
     setSelections(selections: IRange[]): void;
   }
 
   /**
    * A keydown handler type.
-   * 
+   *
    * #### Notes
    * Return `true` to prevent the default handling of the event by the
    * editor.
@@ -442,30 +504,40 @@ namespace CodeEditor {
     hasFocus(): boolean;
 
     /**
-     * Repaint editor. 
+     * Repaint the editor.
      */
     refresh(): void;
 
     /**
-     * Sets the size of the editor.
-     * 
+     * Set the size of the editor.
+     *
+     * @param size - The desired size.
+     *
      * #### Notes
-     * Sets null if the size is unknown.
+     * Use `null` if the size is unknown.
      */
     setSize(size: IDimension | null): void;
 
     /**
      * Reveals the given position in the editor.
+     *
+     * @param position - The desired position to reveal.
      */
     revealPosition(position: IPosition): void;
 
     /**
      * Reveals the given selection in the editor.
+     *
+     * @param position - The desired selection to reveal.
      */
     revealSelection(selection: IRange): void;
 
     /**
      * Get the window coordinates given a cursor position.
+     *
+     * @param position - The desired position.
+     *
+     * @returns The coordinates of the position.
      */
     getCoordinate(position: IPosition): ICoordinate;
   }
@@ -476,17 +548,17 @@ namespace CodeEditor {
   export
   interface IOptions {
     /**
-     * Whether line numbers should be displayed. Defaults to false.
+     * Whether line numbers should be displayed. Defaults to `false`.
      */
     lineNumbers?: boolean;
 
     /**
-     * Set to false for horizontal scrolling. Defaults to true.
+     * Set to false for horizontal scrolling. Defaults to `true`.
      */
     wordWrap?: boolean;
 
     /**
-     * Whether the editor is read-only. Defaults to false.
+     * Whether the editor is read-only. Defaults to `false`.
      */
     readOnly?: boolean;
 
@@ -495,358 +567,4 @@ namespace CodeEditor {
      */
     extra?: { [key: string]: any };
   }
-
-  /**
-   * The default implementation of the code editor model.
-   */
-  export
-  class Model implements IModel {
-    /**
-     * A signal emitted when a content of the model changed.
-     */
-    valueChanged: ISignal<this, IChangedArgs<string>>;
-
-    /**
-     * A signal emitted when a mimetype changes.
-     */
-    mimeTypeChanged: ISignal<this, IChangedArgs<string>>;
-
-    readonly selections = new Selections();
-
-    /**
-     * Whether the model is disposed.
-     */
-    get isDisposed(): boolean {
-      return this._isDisposed;
-    }
-
-    /**
-     * Dipose of the resources used by the model.
-     */
-    dispose(): void {
-      if (this._isDisposed) {
-        return;
-      }
-      this._isDisposed = true;
-      clearSignalData(this);
-    }
-
-    /**
-     * A mime type of the model.
-     */
-    get mimeType(): string {
-      return this._mimetype;
-    }
-    set mimeType(newValue: string) {
-      let oldValue = this._mimetype;
-      if (oldValue === newValue) {
-        return;
-      }
-      this._mimetype = newValue;
-      this.mimeTypeChanged.emit({
-        name: 'mimeType',
-        oldValue,
-        newValue
-      });
-    }
-
-    /**
-     * The text stored in the model.
-     */
-    get value(): string {
-      return this._value;
-    }
-    set value(newValue: string) {
-      let oldValue = this._value;
-      if (oldValue === newValue) {
-        return;
-      }
-      this._value = newValue;
-      this.valueChanged.emit({
-        name: 'value',
-        oldValue,
-        newValue
-      });
-    }
-
-    /**
-     * Get the number of lines in the model.
-     */
-    get lineCount(): number {
-      return this._value.split('\n').length;
-    }
-
-    /**
-     * Returns the content for the given line number.
-     */
-    getLine(line: number): string {
-      return this._value.split('\n')[line];
-    }
-
-    /**
-     * Find an offset for the given position.
-     */
-    getOffsetAt(position: CodeEditor.IPosition): number {
-      let lines = this._value.split('\n');
-      let before = lines.slice(0, position.line).join('\n').length;
-      return before + position.column;
-    }
-
-    /**
-     * Find a position fot the given offset.
-     */
-    getPositionAt(offset: number): CodeEditor.IPosition {
-      let text = this._value.slice(0, offset);
-      let lines = text.split('\n');
-      let column = lines[lines.length - 1].length;
-      return { line: lines.length - 1, column };
-    }
-
-    /**
-     * Undo one edit (if any undo events are stored).
-     */
-    undo(): void { /* no-op */ }
-
-    /**
-     * Redo one undone edit.
-     */
-    redo(): void { /* no-op */ }
-
-    /**
-     * Clear the undo history.
-     */
-    clearHistory(): void { /* no-op */ }
-
-    private _mimetype = '';
-    private _value = '';
-    private _isDisposed = false;
-  }
-}
-
-defineSignal(CodeEditor.Model.prototype, 'valueChanged');
-defineSignal(CodeEditor.Model.prototype, 'mimeTypeChanged');
-
-
-/**
- * An implementation of an editor for an html text area.
- */
-class TextAreaEditor extends Widget implements CodeEditor.IEditor {
-  readonly uuid = '1';
-  /**
-   * Construct a new text editor.
-   */
-  constructor(options: CodeEditor.IOptions, model: CodeEditor.IModel) {
-    super({ node: document.createElement('textarea') });
-    this._model = model;
-    let node = this.node as HTMLTextAreaElement;
-    node.readOnly = options.readOnly || false;
-    node.wrap = options.wordWrap ? 'hard' : 'soft';
-    model.selections.changed.connect(this.onModelSelectionsChanged, this);
-    model.valueChanged.connect(this.onModelValueChanged, this);
-    this.updateSelections();
-  }
-
-  get lineNumbers(): boolean {
-    return false;
-  }
-  set lineNumbers(value: boolean) {
-    /* no-op*/
-  }
-
-  /**
-   * Set to false for horizontal scrolling. Defaults to true.
-   */
-  get wordWrap(): boolean {
-    return (this.node as HTMLTextAreaElement).wrap === 'hard';
-  }
-  set wordWrap(value: boolean) {
-    (this.node as HTMLTextAreaElement).wrap = value ? 'hard' : 'soft';
-  }
-
-  /**
-   * Whether the editor is read-only.  Defaults to false.
-   */
-  get readOnly(): boolean {
-    return (this.node as HTMLTextAreaElement).readOnly;
-  }
-  set readOnly(value: boolean) {
-    (this.node as HTMLTextAreaElement).readOnly = value;
-  }
-
-  get model(): CodeEditor.IModel {
-    return this._model;
-  }
-
-  get onKeyDown(): CodeEditor.KeydownHandler | null {
-    return this._handler;
-  }
-  set onKeyDown(value: CodeEditor.KeydownHandler | null) {
-    this._handler = value;
-  }
-
-  get charWidth(): number {
-    // TODO css measurement
-    return -1;
-  }
-
-  get lineHeight(): number {
-    // TODO css measurement
-    return -1;
-  }
-
-  /**
-   * Brings browser focus to this editor text.
-   */
-  focus(): void {
-    this.node.focus();
-  }
-
-  /**
-   * Test whether the editor has keyboard focus.
-   */
-  hasFocus(): boolean {
-    return document.activeElement === this.node;
-  }
-
-  /**
-   * Repaint the editor.
-   */
-  refresh(): void { /* no-op */ }
-
-  /**
-   * Set the size of the editor in pixels.
-   */
-  setSize(dimension: CodeEditor.IDimension | null): void {
-    // override css here
-  }
-
-  /**
-   * Scroll the given cursor position into view.
-   */
-  revealPosition(pos: CodeEditor.IPosition): void {
-    // set node scroll position here.
-  }
-
-  /**
-   * Scroll the given cursor position into view.
-   */
-  revealSelection(selection: CodeEditor.IRange): void {
-    // set node scroll position here.
-  }
-
-  /**
-   * Get the window coordinates given a cursor position.
-   */
-  getCoordinate(position: CodeEditor.IPosition): CodeEditor.ICoordinate {
-    // more css measurements required
-    return void 0;
-  }
-
-  handleEvent(event: Event): void {
-    switch (event.type) {
-    case 'keydown':
-      this._evtKeydown(event as KeyboardEvent);
-      break;
-    case 'mouseup':
-      this._evtMouseUp(event as MouseEvent);
-      break;
-    case 'input':
-      this._evtInput(event);
-      break;
-    default:
-      break;
-    }
-  }
-
-  protected onAfterAttach(msg: Message): void {
-    super.onAfterAttach(msg);
-    this.node.addEventListener('keydown', this);
-    this.node.addEventListener('mouseup', this);
-    this.node.addEventListener('input', this);
-  }
-
-  protected onBeforeDetach(msg: Message): void {
-    this.node.removeEventListener('keydown', this);
-    this.node.removeEventListener('mouseup', this);
-    this.node.removeEventListener('input', this);
-  }
-
-  protected onModelValueChanged(sender: CodeEditor.IModel, args: IChangedArgs<string>): void {
-    if (this._changeGuard) {
-      return;
-    }
-    (this.node as HTMLTextAreaElement).value = args.newValue;
-  }
-
-  setCursorPosition(position: CodeEditor.IPosition): void {
-    this.setSelection({
-      start: position,
-      end: position
-    });
-  }
-
-  getCursorPosition(): CodeEditor.IPosition {
-    return this.getSelection().start;
-  }
-
-  setSelection(selection: CodeEditor.IRange | null): void {
-    const node = this.node as HTMLTextAreaElement;
-    if (selection) {
-      const start = this.model.getOffsetAt(selection.start);
-      const end = this.model.getOffsetAt(selection.end);
-      node.setSelectionRange(start, end);
-    } else {
-      node.setSelectionRange(0, 0);
-    }
-  }
-
-  getSelection(): CodeEditor.ITextSelection {
-    const node = this.node as HTMLTextAreaElement;
-    return {
-      uuid: this.uuid,
-      start: this.model.getPositionAt(node.selectionStart),
-      end: this.model.getPositionAt(node.selectionEnd)
-    };
-  }
-
-  getSelections(): CodeEditor.ITextSelection[] {
-    return [this.getSelection()];
-  }
-
-  setSelections(selections: CodeEditor.IRange[]): void {
-    this.setSelection(selections.length > 0 ? selections[0] : null);
-  }
-
-  protected onModelSelectionsChanged(sender: CodeEditor.ISelections, args: CodeEditor.ISelections.IChangedArgs) {
-    // display foreign cursors
-  }
-
-  private _evtKeydown(event: KeyboardEvent): void {
-    let handler = this._handler;
-    if (handler) {
-      handler(this, event);
-    }
-  }
-
-  private _evtMouseUp(event: MouseEvent): void {
-    this.updateSelections();
-  }
-
-  protected updateSelections() {
-    this._changeGuard = true;
-    const selections = this.getSelections();
-    this._model.selections.setSelections(this.uuid, selections);
-    this._changeGuard = false;
-  }
-
-  private _evtInput(event: Event): void {
-    let node = this.node as HTMLTextAreaElement;
-    this._changeGuard = true;
-    this.model.value = node.value;
-    this._changeGuard = false;
-  }
-
-  private _model: CodeEditor.IModel;
-  private _handler: CodeEditor.KeydownHandler | null = null;
-  private _changeGuard = false;
 }

+ 1 - 2
src/codeeditor/factory.ts

@@ -5,12 +5,12 @@ import {
   CodeEditor
 } from './editor';
 
+
 /**
  * The editor factory interface.
  */
 export
 interface IEditorFactory {
-
   /**
    * Create a new editor for inline code.
    */
@@ -20,5 +20,4 @@ interface IEditorFactory {
    * Create a new editor for a full document.
    */
   newDocumentEditor(host: HTMLElement, options: CodeEditor.IOptions): CodeEditor.IEditor;
-
 }

+ 3 - 0
src/codeeditor/index.ts

@@ -18,6 +18,7 @@ export * from './widget';
 export * from './factory';
 export * from './mimetype';
 
+
 /* tslint:disable */
 /**
  * Code editor services token.
@@ -26,6 +27,7 @@ export
 const IEditorServices = new Token<IEditorServices>('jupyter.services.editorservices');
 /* tslint:enable */
 
+
 /**
  * Code editor services.
  */
@@ -35,6 +37,7 @@ interface IEditorServices {
    * The code editor factory.
    */
   readonly factory: IEditorFactory;
+
   /**
    * The editor mime type service.
    */

+ 16 - 5
src/codeeditor/mimetype.ts

@@ -5,27 +5,38 @@ import {
   nbformat
 } from '@jupyterlab/services';
 
+
 /**
  * The mime type service of a code editor.
  */
 export
 interface IEditorMimeTypeService {
   /**
-   * Returns a mime type for the given language info.
-   * 
+   * Get a mime type for the given language info.
+   *
+   * @param info - The language information.
+   *
+   * @returns A valid mimetype.
+   *
    * #### Notes
-   * If a mime type cannot be found returns the defaul mime type `text/plain`, never `null`.  
+   * If a mime type cannot be found returns the defaul mime type `text/plain`, never `null`.
    */
   getMimeTypeByLanguage(info: nbformat.ILanguageInfoMetadata): string;
+
   /**
-   * Returns a mime type for the given file path.
-   * 
+   * Get a mime type for the given file path.
+   *
+   * @param filePath - The full path to the file.
+   *
+   * @returns A valid mimetype.
+   *
    * #### Notes
    * If a mime type cannot be found returns the defaul mime type `text/plain`, never `null`.
    */
   getMimeTypeByFilePath(filePath: string): string;
 }
 
+
 /**
  * A namespace for `IEditorMimeTypeService`.
  */

+ 16 - 5
src/codeeditor/widget.ts

@@ -13,12 +13,17 @@ import {
   CodeEditor
 } from './';
 
+
 /**
  * A widget which hosts a code editor.
  */
 export
 class CodeEditorWidget extends Widget {
-
+  /**
+   * Construct a new code editor widget.
+   *
+   * @param editorFactory - The factory used to create a code editor.
+   */
   constructor(editorFactory: (host: Widget) => CodeEditor.IEditor) {
     super();
     this._editor = editorFactory(this);
@@ -26,9 +31,6 @@ class CodeEditorWidget extends Widget {
 
   /**
    * Get the editor wrapped by the widget.
-   *
-   * #### Notes
-   * This is a ready-only property.
    */
    get editor(): CodeEditor.IEditor {
     return this._editor;
@@ -102,6 +104,16 @@ class CodeEditorWidget extends Widget {
     this._needsRefresh = true;
   }
 
+  /**
+   * Handle the DOM events for the widget.
+   *
+   * @param event - The DOM event sent to the widget.
+   *
+   * #### Notes
+   * This method implements the DOM `EventListener` interface and is
+   * called in response to events on the panel's DOM node. It should
+   * not be called directly by user code.
+   */
   handleEvent(event: Event): void {
     switch (event.type) {
     case 'focus':
@@ -125,5 +137,4 @@ class CodeEditorWidget extends Widget {
   private _editor: CodeEditor.IEditor = null;
   private _needsRefresh = true;
   private _resizing = -1;
-
 }

+ 5 - 2
src/codemirror/editor.ts

@@ -20,6 +20,7 @@ import {
   CodeMirrorModel
 } from './model';
 
+
 /**
  * The class name added to CodeMirrorWidget instances.
  */
@@ -31,12 +32,12 @@ const EDITOR_CLASS = 'jp-CodeMirrorWidget';
 export
 const DEFAULT_CODEMIRROR_THEME = 'jupyter';
 
+
 /**
  * CodeMirror editor.
  */
 export
 class CodeMirrorEditor implements CodeEditor.IEditor {
-
   /**
    * The uuid of this editor;
    */
@@ -88,7 +89,8 @@ class CodeMirrorEditor implements CodeEditor.IEditor {
       return;
     }
     this._isDisposed = true;
-    // FIXME: dispose selections
+    this._model.dispose();
+    this._model = null;
     this._editor = null;
   }
 
@@ -419,6 +421,7 @@ namespace CodeMirrorEditor {
      * The uuid of an editor.
      */
     readonly uuid: string;
+
     /**
      * A selection style.
      */

+ 50 - 36
src/codemirror/model.ts

@@ -16,37 +16,44 @@ import {
   IChangedArgs
 } from '../common/interfaces';
 
+import {
+  IObservableString, ObservableString
+} from '../common/observablestring';
+
 
 /**
  * An implementation of the code editor model using code mirror.
  */
 export
 class CodeMirrorModel implements CodeEditor.IModel {
-
-  /**
-   * A signal emitted when a content of the model changed.
-   */
-  readonly valueChanged: ISignal<this, IChangedArgs<string>>;
-
   /**
    * A signal emitted when a mimetype changes.
    */
   readonly mimeTypeChanged: ISignal<this, IChangedArgs<string>>;
 
-  /**
-   * Get the selections for the model.
-   */
-  readonly selections: CodeEditor.ISelections = new CodeEditor.Selections();
-
   /**
    * Construct a new codemirror model.
    */
   constructor(doc: CodeMirror.Doc = new CodeMirror.Doc('')) {
     this._doc = doc;
-    this._value = this.value;
     CodeMirror.on(this.doc, 'change', (instance, change) => {
       this._onDocChange(instance, change);
     });
+    this._value.changed.connect(this._onValueChanged, this);
+  }
+
+  /**
+   * Get the value of the model.
+   */
+  get value(): IObservableString {
+    return this._value;
+  }
+
+  /**
+   * Get the selections for the model.
+   */
+  get selections(): CodeEditor.ISelections {
+    return this._selections;
   }
 
   /**
@@ -71,6 +78,8 @@ class CodeMirrorModel implements CodeEditor.IModel {
       return;
     }
     this._isDisposed = true;
+    this._selections.dispose();
+    this._value.dispose();
     clearSignalData(this);
   }
 
@@ -93,16 +102,6 @@ class CodeMirrorModel implements CodeEditor.IModel {
     });
   }
 
-  /**
-   * The text stored in the model.
-   */
-  get value(): string {
-    return this._doc.getValue();
-  }
-  set value(value: string) {
-    this._doc.setValue(value);
-  }
-
   /**
    * Get the number of lines in the model.
    */
@@ -156,31 +155,46 @@ class CodeMirrorModel implements CodeEditor.IModel {
     this._doc.clearHistory();
   }
 
+  /**
+   * Handle value changes.
+   */
+  private _onValueChanged(value: IObservableString, change: ObservableString.IChangedArgs): void {
+    if (this._changeGuard) {
+      return;
+    }
+    let doc = this._doc;
+    switch (change.type) {
+    case 'set':
+      doc.setValue(change.value);
+      break;
+    default:
+      let from = doc.posFromIndex(change.start);
+      let to = doc.posFromIndex(change.end);
+      doc.replaceRange(change.value, from, to);
+    }
+  }
+
   /**
    * Handles document changes.
    */
   protected _onDocChange(doc: CodeMirror.Doc, change: CodeMirror.EditorChange) {
-    const oldValue = this._value;
-    const newValue = this.value;
-    if (oldValue !== newValue) {
-      this._value = newValue;
-      this.valueChanged.emit({
-        name: 'value',
-        oldValue,
-        newValue
-      });
+    if (change.origin !== 'setValue') {
+      this._changeGuard = true;
+      this._value.text = doc.getValue();
+      this._changeGuard = false;
     }
   }
 
   private _mimetype = '';
-  /**
-   * A snapshot of a document value before the change, see `_onDocChange`
-   */
-  private _value: string;
+  private _value = new ObservableString();
   private _isDisposed = false;
   private _doc: CodeMirror.Doc;
+  private _changeGuard = false;
+  private _selections = new CodeEditor.Selections();
 }
 
 
-defineSignal(CodeMirrorModel.prototype, 'valueChanged');
+/**
+ * The signals for the `CodeMirrorModel` class.
+ */
 defineSignal(CodeMirrorModel.prototype, 'mimeTypeChanged');

+ 3 - 3
src/common/observablestring.ts

@@ -54,8 +54,9 @@ interface IObservableString extends IDisposable {
   dispose(): void;
 }
 
+
 /**
- * A concrete implementation of [[IObservableString]] 
+ * A concrete implementation of [[IObservableString]]
  */
 export
 class ObservableString implements IObservableString {
@@ -69,8 +70,7 @@ class ObservableString implements IObservableString {
   /**
    * A signal emitted when the string has changed.
    */
-  changed: ISignal<IObservableString, ObservableString.IChangedArgs>;
-
+  readonly changed: ISignal<IObservableString, ObservableString.IChangedArgs>;
 
   /**
    * Set the value of the string.

+ 1 - 1
src/editorwidget/plugin.ts

@@ -181,7 +181,7 @@ function activateEditorHandler(app: JupyterLab, registry: IDocumentRegistry, sta
       const selection = widget.editor.getSelection();
       const start = editorModel.getOffsetAt(selection.start);
       const end = editorModel.getOffsetAt(selection.end);
-      const code = editorModel.value.substring(start, end);
+      const code = editorModel.value.text.substring(start, end);
       commands.execute('console:inject', { id, code });
     },
     label: 'Run Code',

+ 8 - 7
src/editorwidget/widget.ts

@@ -25,6 +25,7 @@ import {
   Widget
 } from 'phosphor/lib/ui/widget';
 
+
 /**
  * The class name added to a dirty widget.
  */
@@ -69,15 +70,15 @@ class EditorWidget extends CodeEditorWidget {
     this._context = context;
     let model = context.model;
     let editor = this.editor;
-
+    let value = editor.model.value;
 
     // Prevent the initial loading from disk from being in the editor history.
     context.ready.then( () => {
-      editor.model.value = model.toString();
+      value.text = model.toString();
       editor.model.clearHistory();
     });
 
-    editor.model.value = model.toString();
+    value.text = model.toString();
 
     this.title.label = context.path.split('/').pop();
     model.stateChanged.connect((m, args) => {
@@ -90,14 +91,14 @@ class EditorWidget extends CodeEditorWidget {
       }
     });
     model.contentChanged.connect(() => {
-      let old = editor.model.value;
+      let old = value.text;
       let text = model.toString();
       if (old !== text) {
-        editor.model.value = text;
+        value.text = text;
       }
     });
-    this.editor.model.valueChanged.connect((sender, args) => {
-      model.fromString(args.newValue);
+    this.editor.model.value.changed.connect((sender, args) => {
+      model.fromString(value.text);
     });
     editor.model.mimeType = editorMimeTypeService.getMimeTypeByFilePath(context.path);
     context.pathChanged.connect((c, path) => {

+ 16 - 17
src/notebook/cells/editor.ts

@@ -29,6 +29,10 @@ import {
   IChangedArgs
 } from '../../common/interfaces';
 
+import {
+  ObservableString
+} from '../../common/observablestring';
+
 
 /**
  * The location of requested edges.
@@ -105,11 +109,6 @@ interface IEditorState extends JSONObject {
  */
 export
 interface ITextChange extends IEditorState {
-  /**
-   * The old value of the editor text.
-   */
-  readonly oldValue: string;
-
   /**
    * The new value of the editor text.
    */
@@ -239,8 +238,8 @@ class CodeCellEditorWidget extends CodeEditorWidget implements ICellEditorWidget
     super(editorFactory);
     this.addClass(CELL_EDITOR_CLASS);
 
-    this.editor.model.valueChanged.connect((editorModel, valueChange) => {
-      this.onEditorModelChange(this.editor, editorModel, valueChange);
+    this.editor.model.value.changed.connect(() => {
+      this.onEditorModelChange();
     });
     this.editor.onKeyDown = (editor, event) => {
       return this.onEditorKeydown(editor, event);
@@ -374,11 +373,11 @@ class CodeCellEditorWidget extends CodeEditorWidget implements ICellEditorWidget
     }
 
     if (newValue) {
-      this.editor.model.value = newValue.source || '';
+      this.editor.model.value.text = newValue.source || '';
       this.editor.model.clearHistory();
       newValue.stateChanged.connect(this.onModelStateChanged, this);
     } else {
-      this.editor.model.value = '';
+      this.editor.model.value.text = '';
     }
   }
 
@@ -389,8 +388,8 @@ class CodeCellEditorWidget extends CodeEditorWidget implements ICellEditorWidget
     switch (args.name) {
     case 'source':
       let editorModel = this.editor.model;
-      if (editorModel.value !== args.newValue) {
-        editorModel.value = args.newValue;
+      if (editorModel.value.text !== args.newValue) {
+        editorModel.value.text = args.newValue;
       }
       break;
     default:
@@ -401,12 +400,12 @@ class CodeCellEditorWidget extends CodeEditorWidget implements ICellEditorWidget
   /**
    * Handle change events from the editor model.
    */
-  protected onEditorModelChange(editor: CodeEditor.IEditor, editorModel: CodeEditor.IModel, valueChange: IChangedArgs<string>): void {
+  protected onEditorModelChange(): void {
+    let editor = this.editor;
     let model = this.model;
-    let oldValue = valueChange.oldValue;
-    let newValue = valueChange.newValue;
+    let newValue = editor.model.value.text;
     let cursorPosition = editor.getCursorPosition();
-    let position = editorModel.getOffsetAt(cursorPosition);
+    let position = editor.model.getOffsetAt(cursorPosition);
     let line = cursorPosition.line;
     let ch = cursorPosition.column;
     let coords = editor.getCoordinate(cursorPosition) as ICoords;
@@ -416,7 +415,7 @@ class CodeCellEditorWidget extends CodeEditorWidget implements ICellEditorWidget
       model.source = newValue;
     }
     this.textChanged.emit({
-      line, ch, chHeight, chWidth, coords, position, oldValue, newValue
+      line, ch, chHeight, chWidth, coords, position, newValue
     });
   }
 
@@ -469,7 +468,7 @@ class CodeCellEditorWidget extends CodeEditorWidget implements ICellEditorWidget
       return;
     }
 
-    let currentValue = editorModel.value;
+    let currentValue = editorModel.value.text;
     let currentLine = currentValue.split('\n')[line];
     let chHeight = editor.lineHeight;
     let chWidth = editor.charWidth;

+ 4 - 14
test/src/notebook/cells/editor.spec.ts

@@ -62,8 +62,8 @@ class LogEditorWidget extends CodeCellEditorWidget {
     this.methods.push('onModelStateChanged');
   }
 
-  protected onEditorModelChange(editor: CodeEditor.IEditor, editorModel: CodeEditor.IModel, valueChange: IChangedArgs<string>): void {
-    super.onEditorModelChange(editor, editorModel, valueChange);
+  protected onEditorModelChange(): void {
+    super.onEditorModelChange();
     this.methods.push('onEditorModelChange');
   }
 
@@ -153,9 +153,8 @@ describe('notebook/cells/editor', () => {
         // method, so for this test, the `replaceRange` method is being used to
         // generate the text change.
         expect(change).to.not.be.ok();
-        widget.editor.model.value = want.newValue;
+        widget.editor.model.value.text = want.newValue;
         expect(change).to.be.ok();
-        expect(change.oldValue).to.equal(want.oldValue);
         expect(change.newValue).to.equal(want.newValue);
       });
 
@@ -175,7 +174,7 @@ describe('notebook/cells/editor', () => {
         widget.completionRequested.connect(listener);
 
         expect(request).to.not.be.ok();
-        widget.editor.model.value = want.currentValue;
+        widget.editor.model.value.text = want.currentValue;
         widget.setCursorPosition(3);
 
         let editor = (widget.editor as any).editor as CodeMirror.Editor;
@@ -206,15 +205,6 @@ describe('notebook/cells/editor', () => {
         expect(widget.model).to.be(model);
       });
 
-      it('should empty the code mirror if set to null', () => {
-        let widget = createCellEditor();
-        widget.model = new CellModel();
-        widget.model.source = 'foo';
-        expect(widget.editor.model.value).to.be('foo');
-        widget.model = null;
-        expect(widget.editor.model.value).to.be.empty();
-      });
-
     });
 
     describe('#dispose()', () => {