Procházet zdrojové kódy

Merge pull request #4373 from vidartf/attachments

Preliminary support for attachments
Afshin Darian před 7 roky
rodič
revize
2f9aed860f

+ 2 - 0
dev_mode/package.json

@@ -14,6 +14,7 @@
     "@jupyterlab/application-extension": "^0.16.2",
     "@jupyterlab/apputils": "^0.16.3",
     "@jupyterlab/apputils-extension": "^0.16.2",
+    "@jupyterlab/attachments": "^0.16.0",
     "@jupyterlab/cells": "^0.16.2",
     "@jupyterlab/codeeditor": "^0.16.1",
     "@jupyterlab/codemirror": "^0.16.2",
@@ -209,6 +210,7 @@
       "@jupyterlab/application-extension": "../packages/application-extension",
       "@jupyterlab/apputils": "../packages/apputils",
       "@jupyterlab/apputils-extension": "../packages/apputils-extension",
+      "@jupyterlab/attachments": "../packages/attachments",
       "@jupyterlab/cells": "../packages/cells",
       "@jupyterlab/codeeditor": "../packages/codeeditor",
       "@jupyterlab/codemirror": "../packages/codemirror",

+ 4 - 0
packages/apputils/src/sanitizer.ts

@@ -87,6 +87,10 @@ class Sanitizer implements ISanitizer {
     transformTags: {
       // Set the "rel" attribute for <a> tags to "nofollow".
       'a': sanitize.simpleTransform('a', { 'rel': 'nofollow' })
+    },
+    allowedSchemesByTag: {
+      // Allow 'attachment:' img src (used for markdown cell attachments).
+      'img': sanitize.defaults.allowedSchemes.concat(['attachment']),
     }
   };
 }

+ 8 - 0
packages/attachments/README.md

@@ -0,0 +1,8 @@
+# @jupyterlab/attachments
+
+A JupyterLab package which provides an implementation of the Jupyter notebook cell attachments.
+These attachments can be connected to both markdown and raw [cells](../cells).
+
+Attachments are able to render several different mime types, which are implemented
+in the [rendermime](../rendermime) package. This list of mime types may be extended via
+the simplified mime-extension interface defined in [@jupyterlab/rendermime-interfaces](../rendermime-interfaces).

+ 44 - 0
packages/attachments/package.json

@@ -0,0 +1,44 @@
+{
+  "name": "@jupyterlab/attachments",
+  "version": "0.16.0",
+  "description": "JupyterLab - Notebook Cell Attachments",
+  "homepage": "https://github.com/jupyterlab/jupyterlab",
+  "bugs": {
+    "url": "https://github.com/jupyterlab/jupyterlab/issues"
+  },
+  "license": "BSD-3-Clause",
+  "author": "Project Jupyter",
+  "files": [
+    "lib/*.d.ts",
+    "lib/*.js.map",
+    "lib/*.js",
+    "style/*.css"
+  ],
+  "main": "lib/index.js",
+  "types": "lib/index.d.ts",
+  "directories": {
+    "lib": "lib/"
+  },
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/jupyterlab/jupyterlab.git"
+  },
+  "scripts": {
+    "build": "tsc",
+    "clean": "rimraf lib",
+    "prepublishOnly": "npm run build",
+    "watch": "tsc -w"
+  },
+  "dependencies": {
+    "@jupyterlab/coreutils": "^1.1.2",
+    "@jupyterlab/observables": "^1.0.9",
+    "@jupyterlab/rendermime": "^0.16.2",
+    "@jupyterlab/rendermime-interfaces": "^1.0.9",
+    "@phosphor/disposable": "^1.1.2",
+    "@phosphor/signaling": "^1.2.2"
+  },
+  "devDependencies": {
+    "rimraf": "~2.6.2",
+    "typescript": "~2.8.3"
+  }
+}

+ 6 - 0
packages/attachments/src/index.ts

@@ -0,0 +1,6 @@
+/*-----------------------------------------------------------------------------
+| Copyright (c) Jupyter Development Team.
+| Distributed under the terms of the Modified BSD License.
+|----------------------------------------------------------------------------*/
+
+export * from './model';

+ 453 - 0
packages/attachments/src/model.ts

@@ -0,0 +1,453 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+import {
+  nbformat
+} from '@jupyterlab/coreutils';
+
+import {
+  IObservableMap, ObservableMap,
+  IObservableValue, ObservableValue, IModelDB
+} from '@jupyterlab/observables';
+
+import {
+  IAttachmentModel, AttachmentModel, imageRendererFactory
+} from '@jupyterlab/rendermime';
+
+import {
+  IRenderMime
+} from '@jupyterlab/rendermime-interfaces';
+
+import {
+  IDisposable
+} from '@phosphor/disposable';
+
+import {
+  ISignal, Signal
+} from '@phosphor/signaling';
+
+
+/**
+ * The model for attachments.
+ */
+export
+interface IAttachmentsModel extends IDisposable {
+  /**
+   * A signal emitted when the model state changes.
+   */
+  readonly stateChanged: ISignal<IAttachmentsModel, void>;
+
+  /**
+   * A signal emitted when the model changes.
+   */
+  readonly changed: ISignal<IAttachmentsModel, IAttachmentsModel.ChangedArgs>;
+
+  /**
+   * The length of the items in the model.
+   */
+  readonly length: number;
+
+  /**
+   * The keys of the attachments in the model.
+   */
+  readonly keys: ReadonlyArray<string>;
+
+  /**
+   * The attachment content factory used by the model.
+   */
+  readonly contentFactory: IAttachmentsModel.IContentFactory;
+
+  /**
+   * Whether the specified key is set.
+   */
+  has(key: string): boolean;
+
+  /**
+   * Get an item for the specified key.
+   */
+  get(key: string): IAttachmentModel;
+
+  /**
+   * Set the value of the specified key.
+   */
+  set(key: string, attachment: nbformat.IMimeBundle): void;
+
+  /**
+   * Clear all of the attachments.
+   */
+  clear(): void;
+
+  /**
+   * Deserialize the model from JSON.
+   *
+   * #### Notes
+   * This will clear any existing data.
+   */
+  fromJSON(values: nbformat.IAttachments): void;
+
+  /**
+   * Serialize the model to JSON.
+   */
+  toJSON(): nbformat.IAttachments;
+}
+
+
+/**
+ * The namespace for IAttachmentsModel interfaces.
+ */
+export
+namespace IAttachmentsModel {
+  /**
+   * The options used to create a attachments model.
+   */
+  export
+  interface IOptions {
+    /**
+     * The initial values for the model.
+     */
+    values?: nbformat.IAttachments;
+
+    /**
+     * The attachment content factory used by the model.
+     *
+     * If not given, a default factory will be used.
+     */
+    contentFactory?: IContentFactory;
+
+    /**
+     * An optional IModelDB to store the attachments model.
+     */
+    modelDB?: IModelDB;
+  }
+
+  /**
+   * A type alias for changed args.
+   */
+  export
+  type ChangedArgs = IObservableMap.IChangedArgs<IAttachmentModel>;
+
+  /**
+   * The interface for an attachment content factory.
+   */
+  export
+  interface IContentFactory {
+    /**
+     * Create an attachment model.
+     */
+    createAttachmentModel(options: IAttachmentModel.IOptions): IAttachmentModel;
+  }
+}
+
+/**
+ * The default implementation of the IAttachmentsModel.
+ */
+export
+class AttachmentsModel implements IAttachmentsModel {
+  /**
+   * Construct a new observable outputs instance.
+   */
+  constructor(options: IAttachmentsModel.IOptions = {}) {
+    this.contentFactory = (options.contentFactory ||
+      AttachmentsModel.defaultContentFactory
+    );
+    this.map = new ObservableMap<IAttachmentModel>();
+    if (options.values) {
+      for (let key of Object.keys(options.values)) {
+        this._add(key, options.values[key]);
+      }
+    }
+    this.map.changed.connect(this._onMapChanged, this);
+
+    // If we are given a IModelDB, keep an up-to-date
+    // serialized copy of the AttachmentsModel in it.
+    if (options.modelDB) {
+      this._modelDB = options.modelDB;
+      this._serialized = this._modelDB.createValue('attachments');
+      if (this._serialized.get()) {
+        this.fromJSON(this._serialized.get() as nbformat.IAttachments);
+      } else {
+        this._serialized.set(this.toJSON());
+      }
+      this._serialized.changed.connect(this._onSerializedChanged, this);
+    }
+  }
+
+  /**
+   * A signal emitted when the model state changes.
+   */
+  get stateChanged(): ISignal<IAttachmentsModel, void> {
+    return this._stateChanged;
+  }
+
+  /**
+   * A signal emitted when the model changes.
+   */
+  get changed(): ISignal<this, IAttachmentsModel.ChangedArgs> {
+    return this._changed;
+  }
+
+  /**
+   * The keys of the attachments in the model.
+   */
+  get keys(): ReadonlyArray<string> {
+    return this.map.keys();
+  }
+
+  /**
+   * Get the length of the items in the model.
+   */
+  get length(): number {
+    return this.map ? Object.keys(this.map).length : 0;
+  }
+
+  /**
+   * The attachment content factory used by the model.
+   */
+  readonly contentFactory: IAttachmentsModel.IContentFactory;
+
+  /**
+   * Test whether the model is disposed.
+   */
+  get isDisposed(): boolean {
+    return this._isDisposed;
+  }
+
+  /**
+   * Dispose of the resources used by the model.
+   */
+  dispose(): void {
+    if (this.isDisposed) {
+      return;
+    }
+    this._isDisposed = true;
+    this.map.dispose();
+    Signal.clearData(this);
+  }
+
+  /**
+   * Whether the specified key is set.
+   */
+  has(key: string): boolean {
+    return this.map.has(key);
+  }
+
+  /**
+   * Get an item at the specified index.
+   */
+  get(key: string): IAttachmentModel {
+    return this.map.get(key);
+  }
+
+  /**
+   * Set the value at the specified index.
+   */
+  set(key: string, value: nbformat.IMimeBundle): void {
+    // Normalize stream data.
+    let item = this._createItem({ value });
+    this.map.set(key, item);
+  }
+
+  /**
+   * Clear all of the attachments.
+   */
+  clear(): void {
+    this.map.values().forEach((item: IAttachmentModel) => { item.dispose(); });
+    this.map.clear();
+  }
+
+  /**
+   * Deserialize the model from JSON.
+   *
+   * #### Notes
+   * This will clear any existing data.
+   */
+  fromJSON(values: nbformat.IAttachments) {
+    this.clear();
+    Object.keys(values).forEach((key) => { this._add(key, values[key]); });
+  }
+
+  /**
+   * Serialize the model to JSON.
+   */
+  toJSON(): nbformat.IAttachments {
+    let ret: nbformat.IAttachments = {};
+    for (let key of this.map.keys()) {
+      ret[key] = this.map.get(key).toJSON();
+    }
+    return ret;
+  }
+
+  /**
+   * Add an item to the list.
+   */
+  private _add(key: string, value: nbformat.IMimeBundle): void {
+    // Create the new item.
+    let item = this._createItem({ value });
+
+    // Add the item to our list and return the new length.
+    this.map.set(key, item);
+  }
+
+  protected map: IObservableMap<IAttachmentModel> = null;
+
+  /**
+   * Create an attachment item and hook up its signals.
+   */
+  private _createItem(options: IAttachmentModel.IOptions): IAttachmentModel {
+    let factory = this.contentFactory;
+    let item = factory.createAttachmentModel(options);
+    item.changed.connect(this._onGenericChange, this);
+    return item;
+  }
+
+  /**
+   * Handle a change to the list.
+   */
+  private _onMapChanged(sender: IObservableMap<IAttachmentModel>, args: IObservableMap.IChangedArgs<IAttachmentModel>) {
+    if (this._serialized && !this._changeGuard) {
+      this._changeGuard = true;
+      this._serialized.set(this.toJSON());
+      this._changeGuard = false;
+    }
+    this._changed.emit(args);
+    this._stateChanged.emit(void 0);
+  }
+
+  /**
+   * If the serialized version of the outputs have changed due to a remote
+   * action, then update the model accordingly.
+   */
+  private _onSerializedChanged(sender: IObservableValue, args: ObservableValue.IChangedArgs) {
+    if (!this._changeGuard) {
+      this._changeGuard = true;
+      this.fromJSON(args.newValue as nbformat.IAttachments);
+      this._changeGuard = false;
+    }
+  }
+
+  /**
+   * Handle a change to an item.
+   */
+  private _onGenericChange(): void {
+    this._stateChanged.emit(void 0);
+  }
+
+  private _isDisposed = false;
+  private _stateChanged = new Signal<IAttachmentsModel, void>(this);
+  private _changed = new Signal<this, IAttachmentsModel.ChangedArgs>(this);
+  private _modelDB: IModelDB = null;
+  private _serialized: IObservableValue = null;
+  private _changeGuard = false;
+}
+
+
+/**
+ * The namespace for AttachmentsModel class statics.
+ */
+export
+namespace AttachmentsModel {
+  /**
+   * The default implementation of a `IModelOutputFactory`.
+   */
+  export
+  class ContentFactory implements IAttachmentsModel.IContentFactory {
+    /**
+     * Create an attachment model.
+     */
+    createAttachmentModel(options: IAttachmentModel.IOptions): IAttachmentModel {
+      return new AttachmentModel(options);
+    }
+  }
+
+  /**
+   * The default attachment model factory.
+   */
+  export
+  const defaultContentFactory = new ContentFactory();
+}
+
+
+/**
+ * A resolver for cell attachments 'attchment:filename'.
+ *
+ * Will resolve to a data: url.
+ */
+export
+class AttachmentsResolver implements IRenderMime.IResolver {
+  /**
+   * Create an attachments resolver object.
+   */
+  constructor(options: AttachmentsResolver.IOptions) {
+    this._parent = options.parent || null;
+    this._model = options.model;
+  }
+  /**
+   * Resolve a relative url to a correct server path.
+   */
+  resolveUrl(url: string): Promise<string> {
+    if (this._parent && !url.startsWith('attachment:')) {
+      return this._parent.resolveUrl(url);
+    }
+    return Promise.resolve(url);
+  }
+
+  /**
+   * Get the download url of a given absolute server path.
+   */
+  getDownloadUrl(path: string): Promise<string> {
+    if (this._parent && !path.startsWith('attachment:')) {
+      return this._parent.getDownloadUrl(path);
+    }
+    // Return a data URL with the data of the url
+    const key = path.slice('attachment:'.length);
+    if (!this._model.has(key)) {
+      // Resolve with unprocessed path, to show as broken image
+      return Promise.resolve(path);
+    }
+    const {data} = this._model.get(key);
+    const mimeType = Object.keys(data)[0];
+    // Only support known safe types:
+    if (imageRendererFactory.mimeTypes.indexOf(mimeType) === -1) {
+      return Promise.reject(`Cannot render unknown image mime type "${mimeType}".`);
+    }
+    const dataUrl = `data:${mimeType};base64,${data[mimeType]}`;
+    return Promise.resolve(dataUrl);
+  }
+
+  /**
+   * Whether the URL should be handled by the resolver
+   * or not.
+   */
+  isLocal(url: string): boolean {
+    if (this._parent && !url.startsWith('attachment:')) {
+      return this._parent.isLocal(url);
+    }
+    return true;
+  }
+
+  private _model: IAttachmentsModel;
+  private _parent: IRenderMime.IResolver | null;
+}
+
+
+/**
+ * The namespace for `AttachmentsResolver` class statics.
+ */
+export
+namespace AttachmentsResolver {
+  /**
+   * The options used to create an AttachmentsResolver.
+   */
+  export
+  interface IOptions {
+    /**
+     * The attachments model to resolve against.
+     */
+    model: IAttachmentsModel;
+
+    /**
+     * A parent resolver to use if the URL/path is not for an attachment.
+     */
+    parent?: IRenderMime.IResolver;
+  }
+}

+ 16 - 0
packages/attachments/tsconfig.json

@@ -0,0 +1,16 @@
+{
+  "compilerOptions": {
+    "declaration": true,
+    "noImplicitAny": true,
+    "noEmitOnError": true,
+    "noUnusedLocals": true,
+    "module": "commonjs",
+    "moduleResolution": "node",
+    "target": "ES5",
+    "outDir": "./lib",
+    "lib": [
+      "ES5", "ES2015.Promise", "DOM", "ES2015.Collection", "ES2016", "ES6"
+    ]
+  },
+  "include": ["src/*"]
+}

+ 2 - 0
packages/cells/README.md

@@ -4,3 +4,5 @@ A JupyterLab package which provides an implementation of a Jupyter notebook cell
 These cells are used in both the [notebook](../notebook) and the [code console](../console).
 The result of cell execution is shown in an output area,
 which is implemented in [@jupyterlab/outputarea](../outputarea).
+Markdown and raw cells can have attachments,
+which is implemented in [@jupyterlab/attachments](../attachments).

+ 1 - 0
packages/cells/package.json

@@ -31,6 +31,7 @@
   },
   "dependencies": {
     "@jupyterlab/apputils": "^0.16.3",
+    "@jupyterlab/attachments": "^0.16.0",
     "@jupyterlab/codeeditor": "^0.16.1",
     "@jupyterlab/codemirror": "^0.16.2",
     "@jupyterlab/coreutils": "^1.1.2",

+ 121 - 10
packages/cells/src/model.ts

@@ -11,6 +11,10 @@ import {
   ISignal, Signal
 } from '@phosphor/signaling';
 
+import {
+  IAttachmentsModel, AttachmentsModel
+} from '@jupyterlab/attachments';
+
 import {
   CodeEditor
 } from '@jupyterlab/codeeditor';
@@ -69,6 +73,17 @@ interface ICellModel extends CodeEditor.IModel {
   toJSON(): nbformat.ICell;
 }
 
+/**
+ * The definition of a model cell object for a cell with attachments.
+ */
+export
+interface IAttachmentsCellModel extends ICellModel {
+  /**
+   * The cell attachments
+   */
+  readonly attachments: IAttachmentsModel;
+}
+
 
 /**
  * The definition of a code cell.
@@ -81,7 +96,7 @@ interface ICodeCellModel extends ICellModel {
    * #### Notes
    * This is a read-only property.
    */
-  type: 'code';
+  readonly type: 'code';
 
   /**
    * The code cell's prompt number. Will be null if the cell has not been run.
@@ -91,7 +106,7 @@ interface ICodeCellModel extends ICellModel {
   /**
    * The cell outputs.
    */
-  outputs: IOutputAreaModel;
+  readonly outputs: IOutputAreaModel;
 }
 
 
@@ -99,11 +114,11 @@ interface ICodeCellModel extends ICellModel {
  * The definition of a markdown cell.
  */
 export
-interface IMarkdownCellModel extends ICellModel {
+interface IMarkdownCellModel extends IAttachmentsCellModel {
   /**
    * The type of the cell.
    */
-  type: 'markdown';
+  readonly type: 'markdown';
  }
 
 
@@ -111,11 +126,11 @@ interface IMarkdownCellModel extends ICellModel {
  * The definition of a raw cell.
  */
 export
-interface IRawCellModel extends ICellModel {
+interface IRawCellModel extends IAttachmentsCellModel {
   /**
    * The type of the cell.
    */
-  type: 'raw';
+  readonly type: 'raw';
 }
 
 
@@ -277,11 +292,107 @@ namespace CellModel {
 }
 
 
+/**
+ * A base implementation for cell models with attachments.
+ */
+export
+class AttachmentsCellModel extends CellModel {
+
+  /**
+   * Construct a new cell with optional attachments.
+   */
+  constructor(options: AttachmentsCellModel.IOptions) {
+    super(options);
+    let factory = (options.contentFactory ||
+      AttachmentsCellModel.defaultContentFactory
+    );
+    let attachments: nbformat.IAttachments | undefined;
+    let cell = options.cell;
+    if (cell && (cell.cell_type === 'raw' || cell.cell_type === 'markdown')) {
+      attachments = (cell as (nbformat.IRawCell | nbformat.IMarkdownCell)).attachments;
+    }
+
+    this._attachments = factory.createAttachmentsModel({
+      values: attachments,
+      modelDB: this.modelDB
+    });
+    this._attachments.stateChanged.connect(this.onGenericChange, this);
+  }
+
+  /**
+   * Get the attachments of the model.
+   */
+  get attachments(): IAttachmentsModel {
+    return this._attachments;
+  }
+
+  /**
+   * Serialize the model to JSON.
+   */
+  toJSON(): nbformat.IRawCell | nbformat.IMarkdownCell {
+    let cell = super.toJSON() as (nbformat.IRawCell | nbformat.IMarkdownCell);
+    cell.attachments = this.attachments.toJSON();
+    return cell;
+  }
+
+  private _attachments: IAttachmentsModel | null = null;
+
+}
+
+
+/**
+ * The namespace for `AttachmentsCellModel` statics.
+ */
+export
+namespace AttachmentsCellModel {
+  /**
+   * The options used to initialize a `AttachmentsCellModel`.
+   */
+  export
+  interface IOptions extends CellModel.IOptions {
+    /**
+     * The factory for attachment model creation.
+     */
+    contentFactory?: IContentFactory;
+  }
+
+  /**
+   * A factory for creating code cell model content.
+   */
+  export
+  interface IContentFactory {
+    /**
+     * Create an output area.
+     */
+    createAttachmentsModel(options: IAttachmentsModel.IOptions): IAttachmentsModel;
+  }
+
+  /**
+   * The default implementation of an `IContentFactory`.
+   */
+  export
+  class ContentFactory implements IContentFactory {
+    /**
+     * Create an attachments model.
+     */
+    createAttachmentsModel(options: IAttachmentsModel.IOptions): IAttachmentsModel {
+      return new AttachmentsModel(options);
+    }
+  }
+
+  /**
+   * The shared `ContentFactory` instance.
+   */
+  export
+  const defaultContentFactory = new ContentFactory();
+}
+
+
 /**
  * An implementation of a raw cell model.
  */
 export
-class RawCellModel extends CellModel {
+class RawCellModel extends AttachmentsCellModel {
   /**
    * The type of the cell.
    */
@@ -295,7 +406,7 @@ class RawCellModel extends CellModel {
  * An implementation of a markdown cell model.
  */
 export
-class MarkdownCellModel extends CellModel {
+class MarkdownCellModel extends AttachmentsCellModel {
   /**
    * Construct a markdown cell model from optional cell content.
    */
@@ -460,7 +571,7 @@ namespace CodeCellModel {
    * The default implementation of an `IContentFactory`.
    */
   export
-  class ContentFactory {
+  class ContentFactory implements IContentFactory {
     /**
      * Create an output area.
      */
@@ -470,7 +581,7 @@ namespace CodeCellModel {
   }
 
   /**
-   * The shared `ConetntFactory` instance.
+   * The shared `ContentFactory` instance.
    */
   export
   const defaultContentFactory = new ContentFactory();

+ 36 - 26
packages/cells/src/widget.ts

@@ -4,20 +4,8 @@
 |----------------------------------------------------------------------------*/
 
 import {
-  KernelMessage
-} from '@jupyterlab/services';
-
-import {
-  JSONValue, PromiseDelegate
-} from '@phosphor/coreutils';
-
-import {
-  Message
-} from '@phosphor/messaging';
-
-import {
-  PanelLayout, Panel, Widget
-} from '@phosphor/widgets';
+  AttachmentsResolver
+} from '@jupyterlab/attachments';
 
 import {
   IClientSession
@@ -31,10 +19,6 @@ import {
   CodeEditor, CodeEditorWrapper
 } from '@jupyterlab/codeeditor';
 
-import {
-  IRenderMime, MimeModel, RenderMimeRegistry
-} from '@jupyterlab/rendermime';
-
 import {
   IObservableMap
 } from '@jupyterlab/observables';
@@ -44,25 +28,45 @@ import {
 } from '@jupyterlab/outputarea';
 
 import {
-  ICellModel, ICodeCellModel,
-  IMarkdownCellModel, IRawCellModel
-} from './model';
+  IRenderMime, MimeModel, RenderMimeRegistry
+} from '@jupyterlab/rendermime';
+
+import {
+  KernelMessage
+} from '@jupyterlab/services';
+
+import {
+  JSONValue, PromiseDelegate
+} from '@phosphor/coreutils';
+
+import {
+  Message
+} from '@phosphor/messaging';
+
+import {
+  PanelLayout, Panel, Widget
+} from '@phosphor/widgets';
 
 import {
   InputCollapser, OutputCollapser
 } from './collapser';
 
+import {
+  CellHeader, CellFooter, ICellHeader, ICellFooter
+} from './headerfooter';
+
 import {
   InputArea, IInputPrompt, InputPrompt
 } from './inputarea';
 
 import {
-  InputPlaceholder, OutputPlaceholder
-} from './placeholder';
+  ICellModel, ICodeCellModel,
+  IMarkdownCellModel, IRawCellModel
+} from './model';
 
 import {
-  CellHeader, CellFooter, ICellHeader, ICellFooter
-} from './headerfooter';
+  InputPlaceholder, OutputPlaceholder
+} from './placeholder';
 
 
 /**
@@ -846,7 +850,13 @@ class MarkdownCell extends Cell {
   constructor(options: MarkdownCell.IOptions) {
     super(options);
     this.addClass(MARKDOWN_CELL_CLASS);
-    this._rendermime = options.rendermime;
+    // Ensure we can resolve attachments:
+    this._rendermime = options.rendermime.clone({
+      resolver: new AttachmentsResolver({
+        parent: options.rendermime.resolver,
+        model: this.model.attachments,
+      })
+    });
 
     // Throttle the rendering rate of the widget.
     this._monitor = new ActivityMonitor({

+ 1 - 0
packages/metapackage/package.json

@@ -36,6 +36,7 @@
     "@jupyterlab/application-extension": "^0.16.2",
     "@jupyterlab/apputils": "^0.16.3",
     "@jupyterlab/apputils-extension": "^0.16.2",
+    "@jupyterlab/attachments": "^0.16.0",
     "@jupyterlab/cells": "^0.16.2",
     "@jupyterlab/codeeditor": "^0.16.1",
     "@jupyterlab/codemirror": "^0.16.2",

+ 1 - 0
packages/metapackage/src/index.ts

@@ -5,6 +5,7 @@ import '@jupyterlab/application';
 import '@jupyterlab/application-extension';
 import '@jupyterlab/apputils';
 import '@jupyterlab/apputils-extension';
+import '@jupyterlab/attachments';
 import '@jupyterlab/cells';
 import '@jupyterlab/codeeditor';
 import '@jupyterlab/codemirror';

+ 246 - 0
packages/rendermime/src/attachmentmodel.ts

@@ -0,0 +1,246 @@
+/*-----------------------------------------------------------------------------
+| Copyright (c) Jupyter Development Team.
+| Distributed under the terms of the Modified BSD License.
+|----------------------------------------------------------------------------*/
+
+import {
+  nbformat
+} from '@jupyterlab/coreutils';
+
+import {
+  IObservableJSON, ObservableJSON
+} from '@jupyterlab/observables';
+
+import {
+  IRenderMime
+} from '@jupyterlab/rendermime-interfaces';
+
+import {
+  JSONExt, JSONObject, JSONValue, ReadonlyJSONObject
+} from '@phosphor/coreutils';
+
+import {
+  ISignal, Signal
+} from '@phosphor/signaling';
+
+import {
+  MimeModel
+} from './mimemodel';
+
+/**
+ * The interface for an attachment model.
+ */
+export
+interface IAttachmentModel extends IRenderMime.IMimeModel {
+  /**
+   * A signal emitted when the attachment model changes.
+   */
+  readonly changed: ISignal<this, void>;
+
+  /**
+   * Dispose of the resources used by the attachment model.
+   */
+  dispose(): void;
+
+  /**
+   * Serialize the model to JSON.
+   */
+  toJSON(): nbformat.IMimeBundle;
+}
+
+
+/**
+ * The namespace for IAttachmentModel sub-interfaces.
+ */
+export
+namespace IAttachmentModel {
+  /**
+   * The options used to create a notebook attachment model.
+   */
+  export
+  interface IOptions {
+    /**
+     * The raw attachment value.
+     */
+    value: nbformat.IMimeBundle;
+  }
+}
+
+
+/**
+ * The default implementation of a notebook attachment model.
+ */
+export
+class AttachmentModel implements IAttachmentModel {
+  /**
+   * Construct a new attachment model.
+   */
+  constructor(options: IAttachmentModel.IOptions) {
+    let { data } = Private.getBundleOptions(options);
+    this._data = new ObservableJSON({ values: data as JSONObject });
+    this._rawData = data;
+    // Make a copy of the data.
+    let value = options.value;
+    for (let key in value) {
+      // Ignore data and metadata that were stripped.
+      switch (key) {
+      case 'data':
+        break;
+      default:
+        this._raw[key] = Private.extract(value, key);
+      }
+    }
+  }
+
+  /**
+   * A signal emitted when the attachment model changes.
+   */
+  get changed(): ISignal<this, void> {
+    return this._changed;
+  }
+
+  /**
+   * Dispose of the resources used by the attachment model.
+   */
+  dispose(): void {
+    this._data.dispose();
+    Signal.clearData(this);
+  }
+
+  /**
+   * The data associated with the model.
+   */
+  get data(): ReadonlyJSONObject {
+    return this._rawData;
+  }
+
+  /**
+   * The metadata associated with the model.
+   */
+  get metadata(): ReadonlyJSONObject {
+    return undefined;
+  }
+
+  /**
+   * Set the data associated with the model.
+   *
+   * #### Notes
+   * Depending on the implementation of the mime model,
+   * this call may or may not have deferred effects,
+   */
+  setData(options: IRenderMime.IMimeModel.ISetDataOptions): void {
+    if (options.data) {
+      this._updateObservable(this._data, options.data);
+      this._rawData = options.data;
+    }
+    this._changed.emit(void 0);
+  }
+
+  /**
+   * Serialize the model to JSON.
+   */
+  toJSON(): nbformat.IMimeBundle {
+    let attachment: JSONValue = {};
+    for (let key in this._raw) {
+      attachment[key] = Private.extract(this._raw, key);
+    }
+    return attachment as nbformat.IMimeBundle;
+  }
+
+  // All attachments are untrusted
+  readonly trusted = false;
+
+  /**
+   * Update an observable JSON object using a readonly JSON object.
+   */
+  private _updateObservable(observable: IObservableJSON, data: ReadonlyJSONObject) {
+    let oldKeys = observable.keys();
+    let newKeys = Object.keys(data);
+
+    // Handle removed keys.
+    for (let key of oldKeys) {
+      if (newKeys.indexOf(key) === -1) {
+        observable.delete(key);
+      }
+    }
+
+    // Handle changed data.
+    for (let key of newKeys) {
+      let oldValue = observable.get(key);
+      let newValue = data[key];
+      if (oldValue !== newValue) {
+        observable.set(key, newValue as JSONValue);
+      }
+    }
+  }
+
+  private _changed = new Signal<this, void>(this);
+  private _raw: JSONObject = {};
+  private _rawData: ReadonlyJSONObject;
+  private _data: IObservableJSON;
+}
+
+
+/**
+ * The namespace for AttachmentModel statics.
+ */
+export
+namespace AttachmentModel {
+  /**
+   * Get the data for an attachment.
+   *
+   * @params bundle - A kernel attachment MIME bundle.
+   *
+   * @returns - The data for the payload.
+   */
+  export
+  function getData(bundle: nbformat.IMimeBundle): JSONObject {
+    return Private.getData(bundle);
+  }
+}
+
+
+/**
+ * The namespace for module private data.
+ */
+namespace Private {
+  /**
+   * Get the data from a notebook attachment.
+   */
+  export
+  function getData(bundle: nbformat.IMimeBundle): JSONObject {
+    return convertBundle(bundle);
+  }
+
+  /**
+   * Get the bundle options given attachment model options.
+   */
+  export
+  function getBundleOptions(options: IAttachmentModel.IOptions): MimeModel.IOptions {
+    let data = getData(options.value);
+    return { data };
+  }
+
+  /**
+   * Extract a value from a JSONObject.
+   */
+  export
+  function extract(value: JSONObject, key: string): JSONValue {
+    let item = value[key];
+    if (JSONExt.isPrimitive(item)) {
+      return item;
+    }
+    return JSONExt.deepCopy(item);
+  }
+
+  /**
+   * Convert a mime bundle to mime data.
+   */
+  function convertBundle(bundle: nbformat.IMimeBundle): JSONObject {
+    let map: JSONObject = Object.create(null);
+    for (let mimeType in bundle) {
+      map[mimeType] = extract(bundle, mimeType);
+    }
+    return map;
+  }
+}

+ 1 - 0
packages/rendermime/src/index.ts

@@ -5,6 +5,7 @@
 import '../style/index.css';  // Why is this first?
 
 export * from '@jupyterlab/rendermime-interfaces';
+export * from './attachmentmodel';
 export * from './factories';
 export * from './latex';
 export * from './mimemodel';

+ 6 - 3
packages/rendermime/src/renderers.ts

@@ -729,9 +729,12 @@ namespace Private {
     return resolver.resolveUrl(source).then(path => {
       return resolver.getDownloadUrl(path);
     }).then(url => {
-      // Bust caching for local src attrs.
-      // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/Using_XMLHttpRequest#Bypassing_the_cache
-      url += ((/\?/).test(url) ? '&' : '?') + (new Date()).getTime();
+      // Check protocol again in case it changed:
+      if (URLExt.parse(url).protocol !== 'data:') {
+        // Bust caching for local src attrs.
+        // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/Using_XMLHttpRequest#Bypassing_the_cache
+        url += ((/\?/).test(url) ? '&' : '?') + (new Date()).getTime();
+      }
       node.setAttribute(name, url);
     }).catch(err => {
       // If there was an error getting the url,