mirror of
https://github.com/jupyter/notebook.git
synced 2024-12-09 03:50:45 +08:00
Merge pull request #621 from julienr/inline_images
Implement markdown cell attachments. Allow drag’n’drop of images into…
This commit is contained in:
commit
233f04428d
@ -80,6 +80,9 @@ define(function(require) {
|
||||
.addClass("btn btn-default btn-sm")
|
||||
.attr("data-dismiss", "modal")
|
||||
.text(label);
|
||||
if (btn_opts.id) {
|
||||
button.attr('id', btn_opts.id);
|
||||
}
|
||||
if (btn_opts.click) {
|
||||
button.click($.proxy(btn_opts.click, dialog_content));
|
||||
}
|
||||
@ -207,11 +210,185 @@ define(function(require) {
|
||||
|
||||
modal_obj.on('shown.bs.modal', function(){ editor.refresh(); });
|
||||
};
|
||||
|
||||
var edit_attachments = function (options) {
|
||||
// This shows the Edit Attachments dialog. This dialog allows the
|
||||
// user to delete attachments. We show a list of attachments to
|
||||
// the user and he can mark some of them for deletion. The deletion
|
||||
// is applied when the 'Apply' button of this dialog is pressed.
|
||||
var message;
|
||||
var attachments_list;
|
||||
if (Object.keys(options.attachments).length == 0) {
|
||||
message = "There are no attachments for this cell.";
|
||||
attachments_list = $('<div>');
|
||||
} else {
|
||||
message = "Current cell attachments";
|
||||
|
||||
attachments_list = $('<div>')
|
||||
.addClass('list_container')
|
||||
.append(
|
||||
$('<div>')
|
||||
.addClass('row list_header')
|
||||
.append(
|
||||
$('<div>')
|
||||
.text('Attachments')
|
||||
)
|
||||
);
|
||||
|
||||
// This is a set containing keys of attachments to be deleted when
|
||||
// the Apply button is clicked
|
||||
var to_delete = {};
|
||||
|
||||
var refresh_attachments_list = function() {
|
||||
$(attachments_list).find('.row').remove();
|
||||
for (var key in options.attachments) {
|
||||
var mime = Object.keys(options.attachments[key])[0];
|
||||
var deleted = key in to_delete;
|
||||
|
||||
// This ensures the current value of key is captured since
|
||||
// javascript only has function scope
|
||||
var btn;
|
||||
// Trash/restore button
|
||||
(function(){
|
||||
var _key = key;
|
||||
btn = $('<button>')
|
||||
.addClass('btn btn-default btn-xs')
|
||||
.css('display', 'inline-block');
|
||||
if (deleted) {
|
||||
btn.attr('title', 'Restore')
|
||||
.append(
|
||||
$('<i>')
|
||||
.addClass('fa fa-plus')
|
||||
);
|
||||
btn.click(function() {
|
||||
delete to_delete[_key];
|
||||
refresh_attachments_list();
|
||||
});
|
||||
} else {
|
||||
btn.attr('title', 'Delete')
|
||||
.addClass('btn-danger')
|
||||
.append(
|
||||
$('<i>')
|
||||
.addClass('fa fa-trash')
|
||||
);
|
||||
btn.click(function() {
|
||||
to_delete[_key] = true;
|
||||
refresh_attachments_list();
|
||||
});
|
||||
}
|
||||
return btn;
|
||||
})();
|
||||
var row = $('<div>')
|
||||
.addClass('col-md-12 att_row')
|
||||
.append(
|
||||
$('<div>')
|
||||
.addClass('row')
|
||||
.append(
|
||||
$('<div>')
|
||||
.addClass('att-name col-xs-4')
|
||||
.text(key)
|
||||
)
|
||||
.append(
|
||||
$('<div>')
|
||||
.addClass('col-xs-4 text-muted')
|
||||
.text(mime)
|
||||
)
|
||||
.append(
|
||||
$('<div>')
|
||||
.addClass('item-buttons pull-right')
|
||||
.append(btn)
|
||||
)
|
||||
);
|
||||
if (deleted) {
|
||||
row.find('.att-name')
|
||||
.css('text-decoration', 'line-through');
|
||||
}
|
||||
|
||||
attachments_list.append($('<div>')
|
||||
.addClass('list_item row')
|
||||
.append(row)
|
||||
);
|
||||
}
|
||||
};
|
||||
refresh_attachments_list();
|
||||
}
|
||||
|
||||
var dialogform = $('<div/>')
|
||||
.attr('title', 'Edit attachments')
|
||||
.append(message)
|
||||
.append('<br />')
|
||||
.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 = $('<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 = $('<div/>').attr('title', 'Edit attachments')
|
||||
.append(
|
||||
$('<form id="insert-image-form" />').append(
|
||||
$('<fieldset/>').append(
|
||||
$('<label/>')
|
||||
.attr('for','file')
|
||||
.text(message)
|
||||
)
|
||||
.append($('<br/>'))
|
||||
.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;
|
||||
|
@ -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);
|
||||
};
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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',
|
||||
|
@ -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;
|
||||
|
@ -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 = $('<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}
|
||||
});
|
@ -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){
|
||||
|
@ -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);
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -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 <img> 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:<key> 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
|
||||
|
@ -143,6 +143,12 @@ data-notebook-path="{{notebook_path | urlencode}}"
|
||||
<li id="edit_nb_metadata"><a href="#">Edit Notebook Metadata</a></li>
|
||||
<li class="divider"></li>
|
||||
<li id="find_and_replace"><a href="#"> Find and Replace </a></li>
|
||||
<li class="divider"></li>
|
||||
<li id="cut_cell_attachments"><a href="#">Cut Cell Attachments</a></li>
|
||||
<li id="copy_cell_attachments"><a href="#">Copy Cell Attachments</a></li>
|
||||
<li id="paste_cell_attachments" class="disabled"><a href="#">Paste Cell Attachments</a></li>
|
||||
<li class="divider"></li>
|
||||
<li id="insert_image" class="disabled"><a href="#"> Insert Image </a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="dropdown"><a href="#" class="dropdown-toggle" data-toggle="dropdown">View</a>
|
||||
|
BIN
notebook/tests/_testdata/black_square_22.png
Normal file
BIN
notebook/tests/_testdata/black_square_22.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 145 B |
123
notebook/tests/notebook/attachments.js
Normal file
123
notebook/tests/notebook/attachments.js
Normal file
@ -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 <img> 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');
|
||||
});
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user