')
+ .addClass('item-buttons pull-right')
+ .append(btn)
+ )
+ );
+ if (deleted) {
+ row.find('.att-name')
+ .css('text-decoration', 'line-through');
+ }
+
+ attachments_list.append($('
')
+ .addClass('list_item row')
+ .append(row)
+ );
+ }
+ };
+ refresh_attachments_list();
+ }
+
+ var dialogform = $('
')
+ .attr('title', 'Edit attachments')
+ .append(message)
+ .append('
')
+ .append(attachments_list)
+ var modal_obj = modal({
+ title: "Edit " + options.name + " Attachments",
+ body: dialogform,
+ buttons: {
+ Apply: { class : "btn-primary",
+ click: function() {
+ for (var key in to_delete) {
+ delete options.attachments[key];
+ }
+ options.callback(options.attachments);
+ }
+ },
+ Cancel: {}
+ },
+ notebook: options.notebook,
+ keyboard_manager: options.keyboard_manager,
+ });
+ };
+
+ var insert_image = function (options) {
+ var message =
+ "Select a file to insert.";
+ var file_input = $('
')
+ .attr('type', 'file')
+ .attr('accept', 'image/*')
+ .attr('name', 'file')
+ .on('change', function(file) {
+ var $btn = $(modal_obj).find('#btn_ok');
+ if (this.files.length > 0) {
+ $btn.removeClass('disabled');
+ } else {
+ $btn.addClass('disabled');
+ }
+ });
+ var dialogform = $('
').attr('title', 'Edit attachments')
+ .append(
+ $('
').append(
+ $('
').append(
+ $('
')
+ .attr('for','file')
+ .text(message)
+ )
+ .append($('
'))
+ .append(file_input)
+ )
+ );
+ var modal_obj = modal({
+ title: "Pick a file",
+ body: dialogform,
+ buttons: {
+ OK: {
+ id : 'btn_ok',
+ class : "btn-primary disabled",
+ click: function() {
+ options.callback(file_input[0].files[0]);
+ }
+ },
+ Cancel: {}
+ },
+ notebook: options.notebook,
+ keyboard_manager: options.keyboard_manager,
+ });
+ };
+
var dialog = {
modal : modal,
kernel_modal : kernel_modal,
edit_metadata : edit_metadata,
+ edit_attachments : edit_attachments,
+ insert_image : insert_image
};
return dialog;
diff --git a/notebook/static/base/js/security.js b/notebook/static/base/js/security.js
index 6e2b34345..5f6961f83 100644
--- a/notebook/static/base/js/security.js
+++ b/notebook/static/base/js/security.js
@@ -30,6 +30,12 @@ define([
}
}
}
+ // Caja doesn't allow data uri for img::src, see
+ // https://github.com/google/caja/issues/1558
+ // This is not a security issue for browser post ie6 though, so we
+ // disable the check
+ // https://www.owasp.org/index.php/Script_in_IMG_tags
+ ATTRIBS['img::src'] = 0;
return caja.sanitizeAttribs(tagName, attribs, opt_naiveUriRewriter, opt_nmTokenPolicy, opt_logger);
};
diff --git a/notebook/static/base/js/utils.js b/notebook/static/base/js/utils.js
index f4514180c..6c15fd766 100644
--- a/notebook/static/base/js/utils.js
+++ b/notebook/static/base/js/utils.js
@@ -788,6 +788,27 @@ define([
return MathJax.Hub.Queue(["Typeset", MathJax.Hub, this]);
});
};
+
+ var parse_b64_data_uri = function(uri) {
+ /**
+ * Parses a base64 encoded data-uri to extract mimetype and the
+ * base64 string.
+ *
+ * For example, given 'data:image/png;base64,iVBORw', it will return
+ * ["image/png", "iVBORw"]
+ *
+ * Parameters
+ */
+ // For performance reasons, the non-greedy ? qualifiers are crucial so
+ // that the matcher stops early on big blobs. Without them, it will try
+ // to match the whole blob which can take ages
+ var regex = /^data:(.+?\/.+?);base64,/;
+ var matches = uri.match(regex);
+ var mime = matches[1];
+ // matches[0] contains the whole data-uri prefix
+ var b64_data = uri.slice(matches[0].length);
+ return [mime, b64_data];
+ };
var time = {};
time.milliseconds = {};
@@ -877,6 +898,7 @@ define([
resolve_promises_dict: resolve_promises_dict,
reject: reject,
typeset: typeset,
+ parse_b64_data_uri: parse_b64_data_uri,
time: time,
format_datetime: format_datetime,
datetime_sort_helper: datetime_sort_helper,
diff --git a/notebook/static/notebook/js/actions.js b/notebook/static/notebook/js/actions.js
index 152b6fbfc..83df33bee 100644
--- a/notebook/static/notebook/js/actions.js
+++ b/notebook/static/notebook/js/actions.js
@@ -153,6 +153,34 @@ define(function(require){
env.notebook.command_mode();
}
},
+ 'insert-image': {
+ help : 'insert image',
+ help_index : 'dz',
+ handler : function (env) {
+ env.notebook.insert_image();
+ }
+ },
+ 'cut-cell-attachments': {
+ help : 'cut cell attachments',
+ help_index : 'dza',
+ handler: function (env) {
+ env.notebook.cut_cell_attachments();
+ }
+ },
+ 'copy-cell-attachments': {
+ help : 'copy cell attachments',
+ help_index: 'dzb',
+ handler: function (env) {
+ env.notebook.copy_cell_attachments();
+ }
+ },
+ 'paste-cell-attachments': {
+ help : 'paste cell attachments',
+ help_index: 'dzc',
+ handler: function (env) {
+ env.notebook.paste_cell_attachments();
+ }
+ },
'split-cell-at-cursor': {
help : 'split cell',
help_index : 'ea',
diff --git a/notebook/static/notebook/js/cell.js b/notebook/static/notebook/js/cell.js
index 2a792cae9..2c98082a6 100644
--- a/notebook/static/notebook/js/cell.js
+++ b/notebook/static/notebook/js/cell.js
@@ -70,6 +70,7 @@ define([
}
});
+
// backward compat.
Object.defineProperty(this, 'cm_config', {
get: function() {
@@ -99,6 +100,12 @@ define([
this.cell_type = this.cell_type || null;
this.code_mirror = null;
+ // The nbformat only specifies attachments for textcell, but to avoid
+ // data loss when switching between cell types in the UI, all cells
+ // have an attachments property here. It is only saved to disk
+ // for textcell though (in toJSON)
+ this.attachments = {};
+
this.create_element();
if (this.element !== null) {
this.element.data("cell", this);
@@ -272,6 +279,9 @@ define([
this.element.addClass('selected');
this.element.removeClass('unselected');
this.selected = true;
+ // disable 'insert image' menu item (specific cell types will enable
+ // it in their override select())
+ this.notebook.set_insert_image_enabled(false);
return true;
} else {
return false;
@@ -340,6 +350,16 @@ define([
}
};
+ /**
+ * Garbage collects unused attachments in this cell
+ * @method remove_unused_attachments
+ */
+ Cell.prototype.remove_unused_attachments = function () {
+ // Cell subclasses which support attachments should override this
+ // and keep them when needed
+ this.attachments = {};
+ };
+
/**
* Delegates keyboard shortcut handling to either Jupyter keyboard
* manager when in command mode, or CodeMirror when in edit mode
@@ -735,7 +755,12 @@ define([
cell.append(inner_cell);
this.element = cell;
};
-
+
+ UnrecognizedCell.prototype.remove_unused_attachments = function () {
+ // Do nothing to avoid removing attachments from a possible future
+ // attachment-supporting cell type
+ };
+
UnrecognizedCell.prototype.bind_events = function () {
Cell.prototype.bind_events.apply(this, arguments);
var cell = this;
diff --git a/notebook/static/notebook/js/celltoolbarpresets/attachments.js b/notebook/static/notebook/js/celltoolbarpresets/attachments.js
new file mode 100644
index 000000000..5dd37c860
--- /dev/null
+++ b/notebook/static/notebook/js/celltoolbarpresets/attachments.js
@@ -0,0 +1,50 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+define([
+ 'notebook/js/celltoolbar',
+ 'base/js/dialog',
+], function(celltoolbar, dialog) {
+ "use strict";
+
+ var CellToolbar = celltoolbar.CellToolbar;
+
+ var edit_attachments_dialog = function(cell) {
+ dialog.edit_attachments({
+ attachments: cell.attachments,
+ callback: function(attachments) {
+ cell.attachments = attachments;
+ // Force cell refresh
+ cell.unrender();
+ cell.render();
+ },
+ name: 'cell',
+ notebook: cell.notebook,
+ keyboard_manager: cell.keyboard_manager
+ });
+ };
+
+ var add_dialog_button = function(div, cell) {
+ var button_container = $(div);
+ var button = $('
')
+ .addClass('btn btn-default btn-xs')
+ .text('Edit Attachments')
+ .click( function() {
+ edit_attachments_dialog(cell);
+ return false;
+ });
+ button_container.append(button);
+ };
+
+ var register = function(notebook) {
+ CellToolbar.register_callback('attachments.edit', add_dialog_button);
+
+ var attachments_preset = [];
+ attachments_preset.push('attachments.edit');
+
+ CellToolbar.register_preset('Attachments', attachments_preset, notebook);
+ console.log('Attachments editing toolbar loaded.');
+
+ };
+ return {'register' : register}
+});
diff --git a/notebook/static/notebook/js/menubar.js b/notebook/static/notebook/js/menubar.js
index 32ca35238..04984803a 100644
--- a/notebook/static/notebook/js/menubar.js
+++ b/notebook/static/notebook/js/menubar.js
@@ -228,6 +228,10 @@ define([
'#toggle_all_output': 'toggle-all-cells-output-collapsed',
'#toggle_all_output_scroll': 'toggle-all-cells-output-scrolled',
'#clear_all_output': 'clear-all-cells-output',
+ '#cut_cell_attachments': 'cut-cell-attachments',
+ '#copy_cell_attachments': 'copy-cell-attachments',
+ '#paste_cell_attachments': 'paste-cell-attachments',
+ '#insert_image': 'insert-image',
};
for(var idx in id_actions_dict){
diff --git a/notebook/static/notebook/js/notebook.js b/notebook/static/notebook/js/notebook.js
index 020d0779a..ce2d24235 100644
--- a/notebook/static/notebook/js/notebook.js
+++ b/notebook/static/notebook/js/notebook.js
@@ -26,6 +26,7 @@ define(function (require) {
var default_celltoolbar = require('notebook/js/celltoolbarpresets/default');
var rawcell_celltoolbar = require('notebook/js/celltoolbarpresets/rawcell');
var slideshow_celltoolbar = require('notebook/js/celltoolbarpresets/slideshow');
+ var attachments_celltoolbar = require('notebook/js/celltoolbarpresets/attachments');
var scrollmanager = require('notebook/js/scrollmanager');
var commandpalette = require('notebook/js/commandpalette');
@@ -125,8 +126,10 @@ define(function (require) {
this.kernel = null;
this.kernel_busy = false;
this.clipboard = null;
+ this.clipboard_attachments = null;
this.undelete_backup_stack = [];
this.paste_enabled = false;
+ this.paste_attachments_enabled = false;
this.writable = false;
// It is important to start out in command mode to match the intial mode
// of the KeyboardManager.
@@ -142,7 +145,7 @@ define(function (require) {
this.minimum_autosave_interval = 120000;
this.notebook_name_blacklist_re = /[\/\\:]/;
this.nbformat = 4; // Increment this when changing the nbformat
- this.nbformat_minor = this.current_nbformat_minor = 0; // Increment this when changing the nbformat
+ this.nbformat_minor = this.current_nbformat_minor = 1; // Increment this when changing the nbformat
this.codemirror_mode = 'text';
this.create_elements();
this.bind_events();
@@ -155,6 +158,7 @@ define(function (require) {
default_celltoolbar.register(this);
rawcell_celltoolbar.register(this);
slideshow_celltoolbar.register(this);
+ attachments_celltoolbar.register(this);
// prevent assign to miss-typed properties.
Object.seal(this);
@@ -1296,6 +1300,9 @@ define(function (require) {
}
//metadata
target_cell.metadata = source_cell.metadata;
+ // attachments (we transfer them so they aren't lost if the
+ // cell is turned back into markdown)
+ target_cell.attachments = source_cell.attachments;
target_cell.set_text(text);
// make this value the starting point, so that we can only undo
@@ -1344,6 +1351,8 @@ define(function (require) {
}
// metadata
target_cell.metadata = source_cell.metadata;
+ target_cell.attachments = source_cell.attachments;
+
// We must show the editor before setting its contents
target_cell.unrender();
target_cell.set_text(text);
@@ -1396,6 +1405,10 @@ define(function (require) {
}
//metadata
target_cell.metadata = source_cell.metadata;
+ // attachments (we transfer them so they aren't lost if the
+ // cell is turned back into markdown)
+ target_cell.attachments = source_cell.attachments;
+
// We must show the editor before setting its contents
target_cell.unrender();
target_cell.set_text(text);
@@ -1662,6 +1675,112 @@ define(function (require) {
this.merge_cells([index, index+1], false);
};
+ // Attachments handling
+
+ /**
+ * Shows a dialog letting the user pick an image from her computer and
+ * insert it into the edited markdown cell
+ */
+ Notebook.prototype.insert_image = function () {
+ var that = this;
+ var cell = this.get_selected_cell();
+ // The following should not happen as the menu item is greyed out
+ // when those conditions are not fullfilled (see MarkdownCell
+ // unselect/select/unrender handlers)
+ if (cell.cell_type != 'markdown') {
+ console.log('Error: insert_image called on non-markdown cell');
+ return;
+ }
+ if (cell.rendered) {
+ console.log('Error: insert_image called on rendered cell');
+ return;
+ }
+ dialog.insert_image({
+ callback: function(file) {
+ cell.edit_mode();
+ cell.insert_inline_image_from_blob(file);
+ },
+ notebook: this,
+ keyboard_manager: this.keyboard_manager
+ });
+ };
+
+ /**
+ * Cut the attachments of a cell
+ */
+ Notebook.prototype.cut_cell_attachments = function() {
+ var cell = this.get_selected_cell();
+ if (cell.attachments !== undefined) {
+ this.clipboard_attachments = cell.attachments;
+ this.enable_attachments_paste();
+ delete cell.attachments;
+ cell.unrender();
+ cell.render();
+ }
+ };
+
+ /**
+ * Copy the attachments of a cell
+ */
+ Notebook.prototype.copy_cell_attachments = function() {
+ var cell = this.get_selected_cell();
+ if (cell.attachments !== undefined) {
+ // Do a deep copy of attachments to avoid subsequent modification
+ // to the cell to modify the clipboard
+ this.clipboard_attachments = $.extend(true, {}, cell.attachments);
+ this.enable_attachments_paste();
+ }
+ };
+
+ /**
+ * Paste the attachments in the clipboard into the currently selected
+ * cell
+ */
+ Notebook.prototype.paste_cell_attachments = function() {
+ if (this.clipboard_attachments !== null &&
+ this.paste_attachments_enabled) {
+ var cell = this.get_selected_cell();
+ if (cell.attachments === undefined) {
+ cell.attachments = {};
+ }
+ // Do a deep copy so we can paste multiple times
+ $.extend(true, cell.attachments, this.clipboard_attachments);
+ cell.unrender();
+ cell.render();
+ }
+ };
+
+ /**
+ * Disable the "Paste Cell Attachments" menu item
+ */
+ Notebook.prototype.disable_attachments_paste = function () {
+ if (this.paste_attachments_enabled) {
+ $('#paste_cell_attachments').addClass('disabled');
+ this.paste_attachments_enabled = false;
+ }
+ };
+
+ /**
+ * Enable the "Paste Cell Attachments" menu item
+ */
+ Notebook.prototype.enable_attachments_paste = function () {
+ var that = this;
+ if (!this.paste_attachments_enabled) {
+ $('#paste_cell_attachments').removeClass('disabled');
+ this.paste_attachments_enabled = true;
+ }
+ };
+
+ /**
+ * Enable/disable the "Insert image" menu item
+ */
+ Notebook.prototype.set_insert_image_enabled = function(enabled) {
+ if (enabled) {
+ $('#insert_image').removeClass('disabled');
+ } else {
+ $('#insert_image').addClass('disabled');
+ }
+ };
// Cell collapsing and output clearing
@@ -2282,6 +2401,17 @@ define(function (require) {
}
};
+ /**
+ * Garbage collects unused attachments in all the cells
+ */
+ Notebook.prototype.remove_unused_attachments = function() {
+ var cells = this.get_cells();
+ for (var i = 0; i < cells.length; i++) {
+ var cell = cells[i];
+ cell.remove_unused_attachments();
+ }
+ }
+
/**
* Load a notebook from JSON (.ipynb).
*
@@ -2395,11 +2525,17 @@ define(function (require) {
/**
* Save this notebook on the server. This becomes a notebook instance's
* .save_notebook method *after* the entire notebook has been loaded.
+ *
+ * manual_save will be true if the save was manually trigered by the user
*/
- Notebook.prototype.save_notebook = function (check_last_modified) {
+ Notebook.prototype.save_notebook = function (check_last_modified,
+ manual_save) {
if (check_last_modified === undefined) {
check_last_modified = true;
}
+ if (manual_save === undefined) {
+ manual_save = false;
+ }
var error;
if (!this._fully_loaded) {
@@ -2416,6 +2552,13 @@ define(function (require) {
// the notebook as needed.
this.events.trigger('before_save.Notebook');
+ // Garbage collect unused attachments. Only do this for manual save
+ // to avoid removing unused attachments while the user is editing if
+ // an autosave gets triggered in the midle of an edit
+ if (manual_save) {
+ this.remove_unused_attachments();
+ }
+
// Create a JSON model to be sent to the server.
var model = {
type : "notebook",
@@ -2599,7 +2742,7 @@ define(function (require) {
var parent = utils.url_path_split(this.notebook_path)[0];
var p;
if (this.dirty) {
- p = this.save_notebook();
+ p = this.save_notebook(true, true);
} else {
p = Promise.resolve();
}
@@ -2869,7 +3012,7 @@ define(function (require) {
*/
Notebook.prototype.save_checkpoint = function () {
this._checkpoint_after_save = true;
- this.save_notebook();
+ this.save_notebook(true, true);
};
/**
diff --git a/notebook/static/notebook/js/textcell.js b/notebook/static/notebook/js/textcell.js
index ddab2bdc1..b3c5dcf6c 100644
--- a/notebook/static/notebook/js/textcell.js
+++ b/notebook/static/notebook/js/textcell.js
@@ -51,6 +51,7 @@ define([
this.notebook = options.notebook;
this.events = options.events;
this.config = options.config;
+ this.notebook = options.notebook;
// we cannot put this as a class key as it has handle to "this".
var config = utils.mergeopt(TextCell, this.config);
@@ -113,7 +114,53 @@ define([
// Cell level actions
-
+
+ TextCell.prototype.add_attachment = function (key, mime_type, b64_data) {
+ /**
+ * Add a new attachment to this cell
+ */
+ this.attachments[key] = {};
+ this.attachments[key][mime_type] = [b64_data];
+ };
+
+ TextCell.prototype.remove_unused_attachments = function () {
+ // The general idea is to render the text, find attachment like when
+ // we substitute them in render() and mark used attachments by adding
+ // a temporary .used property to them.
+ if (Object.keys(this.attachments).length > 0) {
+ var that = this;
+ // To find unused attachments, rendering to HTML is easier than
+ // searching in the markdown source for the multiple ways you can
+ // reference an image in markdown (using []() or a HTML
tag)
+ var text = this.get_text();
+ marked(text, function (err, html) {
+ html = security.sanitize_html(html);
+ html = $($.parseHTML(html));
+ html.find('img[src^="attachment:"]').each(function (i, h) {
+ h = $(h);
+ var key = h.attr('src').replace(/^attachment:/, '');
+ if (key in that.attachments) {
+ that.attachments[key].used = true;
+ }
+
+ // This is to avoid having the browser do a GET request
+ // on the invalid attachment: URL
+ h.attr('src', '');
+ });
+ });
+
+ for (var key in this.attachments) {
+ if (this.attachments[key].used === undefined) {
+ console.log('Dropping unused attachment ' + key);
+ delete this.attachments[key];
+ } else {
+ // Remove temporary property
+ delete this.attachments[key].used;
+ }
+ }
+ }
+ }
+
TextCell.prototype.select = function () {
var cont = Cell.prototype.select.apply(this, arguments);
if (cont) {
@@ -183,7 +230,12 @@ define([
*/
TextCell.prototype.fromJSON = function (data) {
Cell.prototype.fromJSON.apply(this, arguments);
+ console.log('data cell_type : ' + data.cell_type + ' this.cell_type : ' + this.cell_type);
if (data.cell_type === this.cell_type) {
+ if (data.attachments !== undefined) {
+ this.attachments = data.attachments;
+ }
+
if (data.source !== undefined) {
this.set_text(data.source);
// make this value the starting point, so that we can only undo
@@ -207,6 +259,11 @@ define([
if (data.source == this.placeholder) {
data.source = "";
}
+ // Deepcopy the attachments so copied cells don't share the same
+ // objects
+ if (Object.keys(this.attachments).length > 0) {
+ data.attachments = JSON.parse(JSON.stringify(this.attachments));
+ }
return data;
};
@@ -256,6 +313,57 @@ define([
}
};
+ MarkdownCell.prototype.select = function () {
+ var cont = TextCell.prototype.select.apply(this);
+ if (cont) {
+ this.notebook.set_insert_image_enabled(!this.rendered);
+ }
+ };
+
+ MarkdownCell.prototype.unrender = function () {
+ var cont = TextCell.prototype.unrender.apply(this);
+ this.notebook.set_insert_image_enabled(true);
+ };
+
+ MarkdownCell.prototype.insert_inline_image_from_blob = function(blob) {
+ /**
+ * Insert markup for an inline image at the current cursor position.
+ * This works as follow :
+ * - We insert the base64-encoded blob data into the cell attachments
+ * dictionary, keyed by the filename.
+ * - We insert an img tag with a 'attachment:key' src that refers to
+ * the attachments entry.
+ *
+ * Parameters:
+ * file: Blob
+ * The JS Blob object (e.g. from the DataTransferItem)
+ */
+ var that = this;
+ var pos = this.code_mirror.getCursor();
+ var reader = new FileReader;
+ // We can get either a named file (drag'n'drop) or a blob (copy/paste)
+ // We generate names for blobs
+ var key;
+ if (blob.name !== undefined) {
+ key = blob.name;
+ } else {
+ key = '_auto_' + Object.keys(that.attachments).length;
+ }
+
+ reader.onloadend = function() {
+ var d = utils.parse_b64_data_uri(reader.result);
+ if (blob.type != d[0]) {
+ // TODO(julienr): Not sure what we should do in this case
+ console.log('File type (' + blob.type + ') != data-uri ' +
+ 'type (' + d[0] + ')');
+ }
+ that.add_attachment(key, blob.type, d[1]);
+ var img_md = '![attachment:' + key + '](attachment:' + key + ')';
+ that.code_mirror.replaceRange(img_md, pos);
+ }
+ reader.readAsDataURL(blob);
+ };
+
/**
* @method render
*/
@@ -290,6 +398,20 @@ define([
});
// links in markdown cells should open in new tabs
html.find("a[href]").not('[href^="#"]').attr("target", "_blank");
+ // replace attachment:
by the corresponding entry
+ // in the cell's attachments
+ html.find('img[src^="attachment:"]').each(function (i, h) {
+ h = $(h);
+ var key = h.attr('src').replace(/^attachment:/, '');
+
+ if (key in that.attachments) {
+ var att = that.attachments[key];
+ var mime = Object.keys(att)[0];
+ h.attr('src', 'data:' + mime + ';base64,' + att[mime][0]);
+ } else {
+ h.attr('src', '');
+ }
+ });
that.set_rendered(html);
that.typeset();
that.events.trigger("rendered.MarkdownCell", {cell: that});
@@ -309,8 +431,58 @@ define([
that.focus_editor();
}
});
+
+ var attachment_regex = /^image\/.*$/;
+
+ // Event handlers to allow users to insert image using either
+ // drag'n'drop or copy/paste
+ var div = that.code_mirror.getWrapperElement();
+ $(div).on('paste', function(evt) {
+ var data = evt.originalEvent.clipboardData;
+ var items = data.items;
+ if (data.items !== undefined) {
+ for (var i = 0; i < items.length; ++i) {
+ var item = items[i];
+ if (item.kind == 'file' && attachment_regex.test(item.type)) {
+ // TODO(julienr): This does not stop code_mirror from pasting
+ // the filename.
+ evt.stopPropagation();
+ evt.preventDefault();
+ that.insert_inline_image_from_blob(item.getAsFile());
+ }
+ }
+ }
+ });
+
+ // Allow drop event if the dragged file can be used as an attachment
+ this.code_mirror.on("dragstart", function(cm, evt) {
+ var files = evt.dataTransfer.files;
+ for (var i = 0; i < files.length; ++i) {
+ var file = files[i];
+ if (attachment_regex.test(file.type)) {
+ return false;
+ }
+ }
+ return true;
+ });
+
+ this.code_mirror.on("drop", function(cm, evt) {
+ var files = evt.dataTransfer.files;
+ for (var i = 0; i < files.length; ++i) {
+ var file = files[i];
+ if (attachment_regex.test(file.type)) {
+ // Prevent the default code_mirror 'drop' event handler
+ // (which inserts the file content) if this is a
+ // recognized media file
+ evt.stopPropagation();
+ evt.preventDefault();
+ that.insert_inline_image_from_blob(file);
+ }
+ }
+ });
};
+
var RawCell = function (options) {
/**
* Constructor
diff --git a/notebook/templates/notebook.html b/notebook/templates/notebook.html
index 31fd510f6..c14d38ab1 100644
--- a/notebook/templates/notebook.html
+++ b/notebook/templates/notebook.html
@@ -143,6 +143,12 @@ data-notebook-path="{{notebook_path | urlencode}}"
Edit Notebook Metadata
Find and Replace
+
+ Cut Cell Attachments
+ Copy Cell Attachments
+ Paste Cell Attachments
+
+ Insert Image
View
diff --git a/notebook/tests/_testdata/black_square_22.png b/notebook/tests/_testdata/black_square_22.png
new file mode 100644
index 000000000..371f52044
Binary files /dev/null and b/notebook/tests/_testdata/black_square_22.png differ
diff --git a/notebook/tests/notebook/attachments.js b/notebook/tests/notebook/attachments.js
new file mode 100644
index 000000000..cbeff5308
--- /dev/null
+++ b/notebook/tests/notebook/attachments.js
@@ -0,0 +1,123 @@
+//
+// Test cell attachments
+//
+var fs = require('fs');
+casper.notebook_test(function () {
+ // -- Test the Edit->Insert Image menu to insert new attachments
+ "use strict";
+ casper.test.info("Testing attachments insertion through the menuitem");
+
+ this.viewport(1024, 768);
+
+ // Click on menuitem
+ var selector = '#insert_image > a';
+ this.waitForSelector(selector);
+ this.thenEvaluate(function(sel) {
+ IPython.notebook.to_markdown();
+ var cell = IPython.notebook.get_selected_cell();
+ cell.set_text("");
+ cell.unrender();
+
+ $(sel).click();
+ }, selector);
+ // Wait for the dialog to be shown
+ this.waitUntilVisible(".modal-body");
+ this.wait(200);
+
+ // Select the image file to insert
+
+ // For some reason, this doesn't seem to work in a reliable way in
+ // phantomjs. So we manually set the input's files attribute
+ //this.page.uploadFile('.modal-body input[name=file]', 'test.png')
+ this.then(function() {
+ var fname = 'notebook/tests/_testdata/black_square_22.png';
+ if (!fs.exists(fname)) {
+ this.test.fail(
+ " does not exist, are you running the tests " +
+ "from the root directory ? "
+ );
+ }
+ this.fill('form#insert-image-form', {'file': fname});
+ });
+
+ // Validate and render the markdown cell
+ this.thenClick('#btn_ok');
+ this.thenEvaluate(function() {
+ IPython.notebook.get_cell(0).render();
+ });
+ this.wait(300);
+ // Check that an tag has been inserted and that it contains the
+ // image
+ this.then(function() {
+ var img = this.evaluate(function() {
+ var cell = IPython.notebook.get_cell(0);
+ var img = $("div.text_cell_render").find("img");
+ return {
+ src: img.attr("src"),
+ width: img.width(),
+ height: img.height(),
+ };
+ });
+ this.test.assertType(img, "object", "Image('image/png')");
+ this.test.assertEquals(img.src.split(',')[0],
+ "data:image/png;base64",
+ "Image data-uri prefix");
+ this.test.assertEquals(img.width, 2, "width == 2");
+ this.test.assertEquals(img.height, 2, "height == 2");
+ });
+
+ //this.then(function() {
+ //this.capture('test.png');
+ //});
+
+ // -- Use the Edit->Copy/Paste Cell Attachments menu items
+ selector = '#copy_cell_attachments > a';
+ this.waitForSelector(selector);
+ this.thenClick(selector);
+
+ // append a new cell
+ this.append_cell('', 'markdown');
+ this.thenEvaluate(function() {
+ IPython.notebook.select_next();
+ });
+
+ // and paste the attachments into it
+ selector = '#paste_cell_attachments > a';
+ this.waitForSelector(selector);
+ this.thenClick(selector);
+
+ // check that the new cell has attachments
+ this.then(function() {
+ var cell = this.evaluate(function() {
+ return IPython.notebook.get_selected_cell();
+ });
+ var orig_cell = this.evaluate(function() {
+ return IPython.notebook.get_cell(0);
+ });
+ var clip = this.evaluate(function() { return IPython.notebook.clipboard_attachments; });
+ // Check that the two cells have the same attachments
+ this.test.assertEquals(cell.attachments, orig_cell.attachments,
+ "both cells have the attachments");
+ });
+
+ // -- Save the notebook. This should cause garbage collection for the
+ // second cell (since we just pasted the attachments but there is no
+ // markdown referencing them)
+ this.thenEvaluate(function() {
+ IPython.notebook.save_checkpoint();
+ });
+
+ this.then(function() {
+ var cell0 = this.evaluate(function() {
+ return IPython.notebook.get_cell(0);
+ });
+ var cell1 = this.evaluate(function() {
+ return IPython.notebook.get_cell(1);
+ });
+ this.test.assert('black_square_22.png' in cell0.attachments,
+ 'cell0 has kept its attachments');
+ this.test.assertEquals(Object.keys(cell1.attachments).length, 0,
+ 'cell1 attachments have been garbage collected');
+ });
+});
+