Browse Source

DnD attachments via rich contents mime

Adds a rich contents mime type to allow extensions to more directly use the already fetched content models of the filebrowser. This is then used in the attachment cell widgets to synchronously insert the name (which will initially resolve to a broken image), and then start the async fetch.

Also reefactors `updateCellSourceWithAttachment`.
Vidar Tonaas Fauske 5 years ago
parent
commit
fde26560b6
2 changed files with 137 additions and 85 deletions
  1. 99 66
      packages/cells/src/widget.ts
  2. 38 19
      packages/filebrowser/src/listing.ts

+ 99 - 66
packages/cells/src/widget.ts

@@ -16,6 +16,8 @@ import {
 
 import { CodeEditor, CodeEditorWrapper } from '@jupyterlab/codeeditor';
 
+import { DirListing } from '@jupyterlab/filebrowser';
+
 import { IObservableMap } from '@jupyterlab/observables';
 
 import {
@@ -34,9 +36,16 @@ import {
   imageRendererFactory
 } from '@jupyterlab/rendermime';
 
-import { KernelMessage, Kernel, Contents } from '@jupyterlab/services';
+import { KernelMessage, Kernel } from '@jupyterlab/services';
+
+import {
+  JSONValue,
+  PromiseDelegate,
+  JSONObject,
+  UUID
+} from '@phosphor/coreutils';
 
-import { JSONValue, PromiseDelegate, JSONObject } from '@phosphor/coreutils';
+import { some, filter, toArray } from '@phosphor/algorithm';
 
 import { IDragEvent } from '@phosphor/dragdrop';
 
@@ -64,7 +73,6 @@ import {
 } from './model';
 
 import { InputPlaceholder, OutputPlaceholder } from './placeholder';
-import { toArray, filter, map, IIterator, find } from '@phosphor/algorithm';
 
 /**
  * The CSS class added to cell widgets.
@@ -154,9 +162,9 @@ const DEFAULT_MARKDOWN_TEXT = 'Type Markdown and LaTeX: $ α^2 $';
 const RENDER_TIMEOUT = 1000;
 
 /**
- * Type for attachment thunks.
+ * The mime type for a rich contents drag object.
  */
-type AttachmentThunk = () => Promise<Contents.IModel>;
+const CONTENTS_MIME_RICH = 'application/x-jupyter-icontentsrich';
 
 /******************************************************************************
  * Cell
@@ -1075,7 +1083,7 @@ export namespace CodeCell {
  * `AttachmentsCell` - A base class for a cell widget that allows
  *  attachments to be drag/drop'd or pasted onto it
  */
-export class AttachmentsCell extends Cell {
+export abstract class AttachmentsCell extends Cell {
   /**
    * Handle the DOM events for the widget.
    *
@@ -1111,10 +1119,51 @@ export class AttachmentsCell extends Cell {
     }
   }
 
+  /**
+   * Modify the cell source to include a reference to the attachment.
+   */
+  protected abstract updateCellSourceWithAttachment(
+    attachmentName: string
+  ): void;
+
+  /**
+   * Handle `after-attach` messages for the widget.
+   */
+  protected onAfterAttach(msg: Message): void {
+    super.onAfterAttach(msg);
+    let node = this.node;
+    node.addEventListener('p-dragover', this);
+    node.addEventListener('p-drop', this);
+    node.addEventListener('dragenter', this);
+    node.addEventListener('dragover', this);
+    node.addEventListener('drop', this);
+    node.addEventListener('paste', this);
+  }
+
+  /**
+   * A message handler invoked on a `'before-detach'`
+   * message
+   */
+  protected onBeforeDetach(msg: Message): void {
+    let node = this.node;
+    node.removeEventListener('drop', this);
+    node.removeEventListener('dragover', this);
+    node.removeEventListener('dragenter', this);
+    node.removeEventListener('paste', this);
+    node.removeEventListener('p-dragover', this);
+    node.removeEventListener('p-drop', this);
+  }
+
   private _evtDragOver(event: IDragEvent) {
-    const supportedMimeType = find(imageRendererFactory.mimeTypes, mimeType =>
-      event.mimeData.hasData(`${mimeType};closure=true`)
-    );
+    const supportedMimeType = some(imageRendererFactory.mimeTypes, mimeType => {
+      if (!event.mimeData.hasData(CONTENTS_MIME_RICH)) {
+        return false;
+      }
+      const data = event.mimeData.getData(
+        CONTENTS_MIME_RICH
+      ) as DirListing.IContentsThunk;
+      return data.model.mimetype === mimeType;
+    });
     if (!supportedMimeType) {
       return;
     }
@@ -1128,6 +1177,7 @@ export class AttachmentsCell extends Cell {
    */
   private _evtPaste(event: ClipboardEvent): void {
     this._attachFiles(event.clipboardData.items);
+    event.preventDefault();
   }
 
   /**
@@ -1144,11 +1194,15 @@ export class AttachmentsCell extends Cell {
   private _evtDrop(event: IDragEvent): void {
     const supportedMimeTypes = toArray(
       filter(event.mimeData.types(), mimeType => {
-        const parsedMimeType = /(.*);closure=true/.exec(mimeType);
-        return (
-          parsedMimeType !== null &&
-          imageRendererFactory.mimeTypes.indexOf(parsedMimeType[1]) !== -1
-        );
+        if (mimeType === CONTENTS_MIME_RICH) {
+          const data = event.mimeData.getData(
+            CONTENTS_MIME_RICH
+          ) as DirListing.IContentsThunk;
+          return (
+            imageRendererFactory.mimeTypes.indexOf(data.model.mimetype) !== -1
+          );
+        }
+        return imageRendererFactory.mimeTypes.indexOf(mimeType) !== -1;
       })
     );
     if (supportedMimeTypes.length === 0) {
@@ -1160,50 +1214,30 @@ export class AttachmentsCell extends Cell {
       event.dropAction = 'none';
       return;
     }
-
-    const thunks: IIterator<AttachmentThunk> = map(
-      supportedMimeTypes,
-      mimeType => event.mimeData.getData(mimeType)
-    );
-    const promises = toArray(map(thunks, thunk => thunk()));
-    void Promise.all(promises).then(models => {
-      models.forEach(model => {
-        if (model.type === 'file' && model.format === 'base64') {
-          this.model.attachments.set(model.name, {
-            [model.mimetype]: model.content
+    event.dropAction = 'copy';
+
+    for (const mimeType of supportedMimeTypes) {
+      if (mimeType === CONTENTS_MIME_RICH) {
+        const { model, withContent } = event.mimeData.getData(
+          CONTENTS_MIME_RICH
+        ) as DirListing.IContentsThunk;
+        if (model.type === 'file') {
+          this.updateCellSourceWithAttachment(model.name);
+          void withContent().then(fullModel => {
+            this.model.attachments.set(fullModel.name, {
+              [fullModel.mimetype]: fullModel.content
+            });
           });
-          this._updateCellSourceWithAttachment(model.name);
         }
-      });
-    });
-  }
-
-  /**
-   * Handle `after-attach` messages for the widget.
-   */
-  protected onAfterAttach(msg: Message): void {
-    super.onAfterAttach(msg);
-    let node = this.node;
-    node.addEventListener('p-dragover', this);
-    node.addEventListener('p-drop', this);
-    node.addEventListener('dragenter', this);
-    node.addEventListener('dragover', this);
-    node.addEventListener('drop', this);
-    node.addEventListener('paste', this);
-  }
-
-  /**
-   * A message handler invoked on a `'before-detach'`
-   * message
-   */
-  protected onBeforeDetach(msg: Message): void {
-    let node = this.node;
-    node.removeEventListener('drop', this);
-    node.removeEventListener('dragover', this);
-    node.removeEventListener('dragenter', this);
-    node.removeEventListener('paste', this);
-    node.removeEventListener('p-dragover', this);
-    node.removeEventListener('p-drop', this);
+      } else {
+        // Pure mimetype, no useful name to infer
+        const name = UUID.uuid4();
+        this.model.attachments.set(name, {
+          [mimeType]: event.mimeData.getData(mimeType)
+        });
+        this.updateCellSourceWithAttachment(name);
+      }
+    }
   }
 
   /**
@@ -1240,7 +1274,7 @@ export class AttachmentsCell extends Cell {
       const encodedData = matches[3];
       const bundle: nbformat.IMimeBundle = { [mimeType]: encodedData };
       this.model.attachments.set(blob.name, bundle);
-      this._updateCellSourceWithAttachment(blob.name);
+      this.updateCellSourceWithAttachment(blob.name);
     };
     reader.onerror = evt => {
       console.error(`Failed to attach ${blob.name}` + evt);
@@ -1248,15 +1282,6 @@ export class AttachmentsCell extends Cell {
     reader.readAsDataURL(blob);
   }
 
-  /**
-   * Appends the text ![attachment](attachment: <attachmentName>)
-   * to the cell source
-   */
-  private _updateCellSourceWithAttachment(attachmentName: string) {
-    const textToBeAppended = `![${attachmentName}](attachment:${attachmentName})`;
-    this.model.value.insert(this.model.value.text.length, textToBeAppended);
-  }
-
   /**
    * The model used by the widget.
    */
@@ -1365,6 +1390,14 @@ export class MarkdownCell extends AttachmentsCell {
     super.onUpdateRequest(msg);
   }
 
+  /**
+   * Modify the cell source to include a reference to the attachment.
+   */
+  protected updateCellSourceWithAttachment(attachmentName: string) {
+    const textToBeAppended = `![${attachmentName}](attachment:${attachmentName})`;
+    this.model.value.insert(this.model.value.text.length, textToBeAppended);
+  }
+
   /**
    * Handle the rendered state.
    */

+ 38 - 19
packages/filebrowser/src/listing.ts

@@ -113,10 +113,15 @@ const NAME_ID_CLASS = 'jp-id-name';
 const MODIFIED_ID_CLASS = 'jp-id-modified';
 
 /**
- * The mime type for a con tents drag object.
+ * The mime type for a contents drag object.
  */
 const CONTENTS_MIME = 'application/x-jupyter-icontents';
 
+/**
+ * The mime type for a rich contents drag object.
+ */
+const CONTENTS_MIME_RICH = 'application/x-jupyter-icontentsrich';
+
 /**
  * The class name added to drop targets.
  */
@@ -1158,15 +1163,18 @@ export class DirListing extends Widget {
     let selectedNames = Object.keys(this._selection);
     let source = this._items[index];
     let items = this._sortedItems;
+    let selectedItems: Contents.IModel[];
     let item: Contents.IModel | undefined;
 
     // If the source node is not selected, use just that node.
     if (!source.classList.contains(SELECTED_CLASS)) {
       item = items[index];
       selectedNames = [item.name];
+      selectedItems = [item];
     } else {
       let name = selectedNames[0];
       item = find(items, value => value.name === name);
+      selectedItems = toArray(this.selectedItems());
     }
 
     if (!item) {
@@ -1197,26 +1205,18 @@ export class DirListing extends Widget {
     );
     this._drag.mimeData.setData(CONTENTS_MIME, paths);
 
-    let services = this.model.manager.services;
-    const selectedItems: Contents.IModel[] = [];
-    Object.keys(this._selection).forEach(itemname => {
-      if (this._selection[itemname]) {
-        const item = find(items, item => item.name === itemname);
-        if (item.mimetype !== null) {
-          selectedItems.push(item);
-        }
-      }
-    });
-    // We thunk this so we don't try to make a network call
+    // Add thunks for getting mime data content.
+    // We thunk the content so we don't try to make a network call
     // when it's not needed. E.g. just moving files around
     // in a filebrowser
-    if (selectedItems.length > 0) {
-      for (let item of selectedItems) {
-        const path = PathExt.join(basePath, item.name);
-        this._drag.mimeData.setData(`${item.mimetype};closure=true`, () =>
-          services.contents.get(path)
-        );
-      }
+    let services = this.model.manager.services;
+    for (const item of selectedItems) {
+      this._drag.mimeData.setData(CONTENTS_MIME_RICH, {
+        model: item,
+        withContent: async () => {
+          return await services.contents.get(item.path);
+        }
+      } as DirListing.IContentsThunk);
     }
 
     if (item && item.type !== 'directory') {
@@ -1589,6 +1589,25 @@ export namespace DirListing {
     key: 'name' | 'last_modified';
   }
 
+  /**
+   * A file contents model thunk.
+   *
+   * Note: The content of the model will be empty.
+   * To get the contents, call and await the `withContent`
+   * method.
+   */
+  export interface IContentsThunk {
+    /**
+     * The contents model.
+     */
+    model: Contents.IModel;
+
+    /**
+     * Fetches the model with contents.
+     */
+    withContent: () => Promise<Contents.IModel>;
+  }
+
   /**
    * The render interface for file browser listing options.
    */