diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000000..9d8d516562 --- /dev/null +++ b/.babelrc @@ -0,0 +1 @@ +{ "presets": ["es2015"] } diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000000..cbdddcaaa5 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,14 @@ +{ + "parserOptions": { + "ecmaVersion": 6, + "sourceType": "module", + "ecmaFeatures": { + "jsx": true + } + }, + "rules": { + "semi": 2, + "comma-dangle": 2, + "no-unreachable" : 2 + } +} diff --git a/.travis.yml b/.travis.yml index fad8af7b99..0561f3d47b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -26,6 +26,7 @@ before_install: - nvm install 5.6 - nvm use 5.6 - npm upgrade -g npm + - npm install - 'if [[ $GROUP == js* ]]; then npm install -g casperjs; fi' - git clone --quiet --depth 1 https://github.com/minrk/travis-wheels travis-wheels diff --git a/notebook/static/base/js/keyboard.js b/notebook/static/base/js/keyboard.js index 32c8dceca0..e214a5f035 100644 --- a/notebook/static/base/js/keyboard.js +++ b/notebook/static/base/js/keyboard.js @@ -10,7 +10,7 @@ define([ 'base/js/utils', - 'underscore', + 'underscore' ], function(utils, _) { "use strict"; @@ -42,7 +42,7 @@ define([ 'backspace': 8, 'tab': 9, 'enter': 13, 'shift': 16, 'ctrl': 17, 'alt': 18, 'meta': 91, 'capslock': 20, 'esc': 27, 'space': 32, 'pageup': 33, 'pagedown': 34, 'end': 35, 'home': 36, 'left': 37, 'up': 38, 'right': 39, 'down': 40, - 'insert': 45, 'delete': 46, 'numlock': 144, + 'insert': 45, 'delete': 46, 'numlock': 144 }; // These apply to Firefox and Opera @@ -168,20 +168,26 @@ define([ // Shortcut manager class - var ShortcutManager = function (delay, events, actions, env) { + var ShortcutManager = function (delay, events, actions, env, config, mode='command') { /** * A class to deal with keyboard event and shortcut * * @class ShortcutManager * @constructor + * + * :config: configobjet on which to call `update(....)` to persist the config. + * :mode: mode of this shortcut manager where to persist config. */ this._shortcuts = {}; + this._defaults_bindings = []; this.delay = delay || 800; // delay in milliseconds this.events = events; this.actions = actions; this.actions.extend_env(env); this._queue = []; this._cleartimeout = null; + this._config = config; + this._mode = mode; Object.seal(this); }; @@ -334,8 +340,30 @@ define([ } }; - ShortcutManager.prototype._set_leaf = function(shortcut_array, action_name, tree){ + ShortcutManager.prototype.is_available_shortcut = function(shortcut){ + const shortcut_array = shortcut.split(','); + return this._is_available_shortcut(shortcut_array, this._shortcuts); + }; + + ShortcutManager.prototype._is_available_shortcut = function(shortcut_array, tree){ var current_node = tree[shortcut_array[0]]; + if(!shortcut_array[0]){ + return false; + } + if(current_node === undefined){ + return true; + } else { + if (typeof(current_node) == 'string'){ + return false; + } else { // assume is a sub-shortcut tree + return this._is_available_shortcut(shortcut_array.slice(1), current_node); + } + } + }; + + ShortcutManager.prototype._set_leaf = function(shortcut_array, action_name, tree){ + const current_node = tree[shortcut_array[0]]; + if(shortcut_array.length === 1){ if(current_node !== undefined && typeof(current_node) !== 'string'){ console.warn('[warning], you are overriting a long shortcut with a shorter one'); @@ -356,6 +384,51 @@ define([ } }; + ShortcutManager.prototype._persist_shortcut = function(shortcut, data) { + /** + * add a shortcut to this manager and persist it to the config file. + **/ + shortcut = shortcut.toLowerCase() + this.add_shortcut(shortcut, data); + const patch = {keys:{}}; + const b = {bind:{}} + patch.keys[this._mode] = {bind:{}}; + patch.keys[this._mode].bind[shortcut] = data; + this._config.update(patch); + } + + ShortcutManager.prototype._persist_remove_shortcut = function(shortcut){ + /** + * Remove a shortcut from this manager and persist its removal. + */ + + shortcut = shortcut.toLowerCase() + this.remove_shortcut(shortcut); + const patch = {keys:{}}; + const b = {bind:{}} + patch.keys[this._mode] = {bind:{}}; + patch.keys[this._mode].bind[shortcut] = null; + this._config.update(patch); + console.info(patch) + + // if the shortcut we unbind is a default one, we add it to the list of + // things to unbind at startup + + if(this._defaults_bindings.indexOf(shortcut) !== -1){ + const cnf = (this._config.data.keys||{})[this._mode] + const unbind_array = cnf.unbind||[]; + + // unless it's already there (like if we have remapped a default + // shortcut to another command, and unbind it) + if(unbind_array.indexOf(shortcut) !== -1){ + unbind_array.concat(shortcut); + const unbind_patch = {keys:{unbind:unbind_array}}; + this._config._update(unbind_patch) + } + } + } + + ShortcutManager.prototype.add_shortcut = function (shortcut, data, suppress_help_update) { /** * Add a action to be handled by shortcut manager. @@ -369,8 +442,8 @@ define([ if (! action_name){ throw new Error('does not know how to deal with : ' + data); } - shortcut = normalize_shortcut(shortcut); - this.set_shortcut(shortcut, action_name); + const _shortcut = normalize_shortcut(shortcut); + this.set_shortcut(_shortcut, action_name); if (!suppress_help_update) { // update the keyboard shortcuts notebook help @@ -391,6 +464,16 @@ define([ this.events.trigger('rebuild.QuickHelp'); }; + ShortcutManager.prototype._add_default_shortcuts = function (data) { + /** + * same as add_shortcuts, but register them as "default" that if persistently unbound, with + * persist_remove_shortcut, need to be on the "unbind" list. + **/ + this._defaults_bindings = this._defaults_bindings.concat(Object.keys(data)) + this.add_shortcuts(data); + + } + ShortcutManager.prototype.remove_shortcut = function (shortcut, suppress_help_update) { /** * Remove the binding of shortcut `sortcut` with its action. @@ -415,7 +498,7 @@ define([ this.events.trigger('rebuild.QuickHelp'); } } catch (ex) { - throw new Error('trying to remove a non-existent shortcut', shortcut); + throw new Error('trying to remove a non-existent shortcut', shortcut, typeof shortcut); } }; @@ -470,7 +553,7 @@ define([ normalize_key : normalize_key, normalize_shortcut : normalize_shortcut, shortcut_to_event : shortcut_to_event, - event_to_shortcut : event_to_shortcut, + event_to_shortcut : event_to_shortcut }; return keyboard; diff --git a/notebook/static/index.js b/notebook/static/index.js index 108600704a..020b6aedf9 100644 --- a/notebook/static/index.js +++ b/notebook/static/index.js @@ -55,6 +55,7 @@ module.exports = { 'notebook/js/outputarea': require('./notebook/js/outputarea.js'), 'notebook/js/celltoolbar': require('./notebook/js/celltoolbar.js'), 'notebook/js/commandpalette': require('./notebook/js/commandpalette.js'), + 'notebook/js/shortcuts_editor': require('./notebook/js/shortcuts_editor.js'), 'tree/js/sessionlist': require('./tree/js/sessionlist.js'), // 'tree/js/main': require('./tree/js/main.js'), 'tree/js/kernellist': require('./tree/js/kernellist.js'), diff --git a/notebook/static/notebook/js/actions.js b/notebook/static/notebook/js/actions.js index 83df33beeb..2eca87f6e6 100644 --- a/notebook/static/notebook/js/actions.js +++ b/notebook/static/notebook/js/actions.js @@ -62,11 +62,17 @@ define(function(require){ * **/ var _actions = { + 'edit-command-mode-keyboard-shortcuts': { + help: 'Open a dialog to edit the command mode keyboard shortcuts', + handler: function (env) { + env.notebook.show_shortcuts_editor(); + } + }, 'restart-kernel': { help: 'restart the kernel (no confirmation dialog)', handler: function (env) { env.notebook.restart_kernel({confirm: false}); - }, + } }, 'confirm-restart-kernel':{ icon: 'fa-repeat', @@ -479,7 +485,7 @@ define(function(require){ env.pager.collapse(); } } - }, + } }; /** @@ -543,7 +549,7 @@ define(function(require){ event.preventDefault(); } return env.notebook.scroll_manager.scroll(1); - }, + } }, 'scroll-notebook-up': { handler: function(env, event) { @@ -551,7 +557,7 @@ define(function(require){ event.preventDefault(); } return env.notebook.scroll_manager.scroll(-1); - }, + } }, 'scroll-cell-center': { help: "Scroll the current cell to the center", @@ -621,7 +627,7 @@ define(function(require){ } return false; } - }, + } }; // private stuff that prepend `jupyter-notebook:` to actions names diff --git a/notebook/static/notebook/js/keyboardmanager.js b/notebook/static/notebook/js/keyboardmanager.js index 4edb299357..ba44f364fe 100644 --- a/notebook/static/notebook/js/keyboardmanager.js +++ b/notebook/static/notebook/js/keyboardmanager.js @@ -10,7 +10,7 @@ define([ 'base/js/utils', - 'base/js/keyboard', + 'base/js/keyboard' ], function(utils, keyboard) { "use strict"; @@ -36,12 +36,12 @@ define([ this.bind_events(); this.env = {pager:this.pager}; this.actions = options.actions; - this.command_shortcuts = new keyboard.ShortcutManager(undefined, options.events, this.actions, this.env ); - this.command_shortcuts.add_shortcuts(this.get_default_common_shortcuts()); - this.command_shortcuts.add_shortcuts(this.get_default_command_shortcuts()); + this.command_shortcuts = new keyboard.ShortcutManager(undefined, options.events, this.actions, this.env, options.config, 'command'); + this.command_shortcuts._add_default_shortcuts(this.get_default_common_shortcuts()); + this.command_shortcuts._add_default_shortcuts(this.get_default_command_shortcuts()); this.edit_shortcuts = new keyboard.ShortcutManager(undefined, options.events, this.actions, this.env); - this.edit_shortcuts.add_shortcuts(this.get_default_common_shortcuts()); - this.edit_shortcuts.add_shortcuts(this.get_default_edit_shortcuts()); + this.edit_shortcuts._add_default_shortcuts(this.get_default_common_shortcuts()); + this.edit_shortcuts._add_default_shortcuts(this.get_default_edit_shortcuts()); this.config = options.config; @@ -106,7 +106,7 @@ define([ 'ctrl-enter' : 'jupyter-notebook:run-cell', 'alt-enter' : 'jupyter-notebook:run-cell-and-insert-below', // cmd on mac, ctrl otherwise - 'cmdtrl-s' : 'jupyter-notebook:save-notebook', + 'cmdtrl-s' : 'jupyter-notebook:save-notebook' }; }; @@ -117,7 +117,7 @@ define([ 'ctrl-m' : 'jupyter-notebook:enter-command-mode', 'up' : 'jupyter-notebook:move-cursor-up', 'down' : 'jupyter-notebook:move-cursor-down', - 'ctrl-shift--' : 'jupyter-notebook:split-cell-at-cursor', + 'ctrl-shift--' : 'jupyter-notebook:split-cell-at-cursor' }; }; @@ -161,7 +161,7 @@ define([ 'l' : 'jupyter-notebook:toggle-cell-line-numbers', 'h' : 'jupyter-notebook:show-keyboard-shortcuts', 'z' : 'jupyter-notebook:undo-cell-deletion', - 'q' : 'jupyter-notebook:close-pager', + 'q' : 'jupyter-notebook:close-pager' }; }; diff --git a/notebook/static/notebook/js/notebook.js b/notebook/static/notebook/js/notebook.js index 30ea63362c..6e94053fe3 100644 --- a/notebook/static/notebook/js/notebook.js +++ b/notebook/static/notebook/js/notebook.js @@ -29,6 +29,7 @@ define(function (require) { var attachments_celltoolbar = require('notebook/js/celltoolbarpresets/attachments'); var scrollmanager = require('notebook/js/scrollmanager'); var commandpalette = require('notebook/js/commandpalette'); + var shortcuts_editor = require('notebook/js/shortcuts_editor'); var _SOFT_SELECTION_CLASS = 'jupyter-soft-selected'; @@ -248,7 +249,7 @@ define(function (require) { that.metadata.kernelspec = { name: data.name, display_name: data.spec.display_name, - language: data.spec.language, + language: data.spec.language }; if (!existing_spec || ! _.isEqual(existing_spec, that.metadata.kernelspec)) { that.set_dirty(true); @@ -361,6 +362,10 @@ define(function (require) { var x = new commandpalette.CommandPalette(this); }; + Notebook.prototype.show_shortcuts_editor = function() { + var x = new shortcuts_editor.ShortcutEditor(this); + }; + /** * Trigger a warning dialog about missing functionality from newer minor versions */ diff --git a/notebook/static/notebook/js/quickhelp.js b/notebook/static/notebook/js/quickhelp.js index f816bb31af..d2edbb2cc3 100644 --- a/notebook/static/notebook/js/quickhelp.js +++ b/notebook/static/notebook/js/quickhelp.js @@ -153,13 +153,22 @@ define([ return hum; } - function humanize_shortcut(shortcut){ + function _humanize_sequence(sequence){ + var joinchar = ','; + var hum = _.map(sequence.replace(/meta/g, 'cmd').split(','), _humanize_shortcut).join(joinchar); + return hum; + } + + function _humanize_shortcut(shortcut){ var joinchar = '-'; if (platform === 'MacOS'){ joinchar = ''; } - var sh = _.map(shortcut.split('-'), humanize_key ).join(joinchar); - return ''+sh+''; + return _.map(shortcut.split('-'), humanize_key ).join(joinchar); + } + + function humanize_shortcut(shortcut){ + return ''+_humanize_shortcut(shortcut)+''; } @@ -301,6 +310,7 @@ define([ return {'QuickHelp': QuickHelp, humanize_shortcut: humanize_shortcut, - humanize_sequence: humanize_sequence + humanize_sequence: humanize_sequence, + _humanize_sequence: _humanize_sequence, }; }); diff --git a/notebook/static/notebook/js/shortcuts_editor.js b/notebook/static/notebook/js/shortcuts_editor.js new file mode 100644 index 0000000000..ef54460759 --- /dev/null +++ b/notebook/static/notebook/js/shortcuts_editor.js @@ -0,0 +1,161 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +define(function(require){ + "use strict"; + + const QH = require("notebook/js/quickhelp"); + const dialog = require("base/js/dialog"); + const React = require("React"); + const ReactDom = require("react-dom"); + + /** + * Humanize the action name to be consumed by user. + * internally the actions name are of the form + * : + * we drop and replace dashes for space. + */ + const humanize_action_id = function(str) { + return str.split(':')[1].replace(/-/g, ' ').replace(/_/g, '-'); + }; + + /** + * given an action id return 'command-shortcut', 'edit-shortcut' or 'no-shortcut' + * for the action. This allows us to tag UI in order to visually distinguish + * Wether an action have a keybinding or not. + **/ + + const KeyBinding = React.createClass({ + displayName: 'KeyBindings', + getInitialState: function() { + return {shrt:''}; + }, + handleShrtChange: function (element){ + this.setState({shrt:element.target.value}); + }, + render: function(){ + const that = this; + const av = this.props.available(this.state.shrt); + const empty = this.state.shrt === ''; + return React.createElement('div',{style:{borderBottom: '1px solid gray'}, className:'jupyter-keybindings'}, + this.props.shortcut? + React.createElement('i', {className: "pull-right fa fa-times", alt: 'remove title'+this.props.shortcut, + onClick:()=>{ + that.props.unbind(this.props.rawshrt); + }}): + React.createElement('i', {className: "pull-right fa fa-plus", alt: 'add-keyboard-shortcut', + onClick:()=>{ + av?that.props.onAddBindings(that.state.shrt, that.props.ckey):null; + } + }), + this.props.shortcut? null: + React.createElement('input', { + type:'text', + placeholder:'add shortcut', + className:'pull-right'+((av||empty)?'':' alert alert-danger'), + value:this.state.shrt, + onChange:this.handleShrtChange + }), + this.props.shortcut? React.createElement('span', {className: 'pull-right'}, React.createElement('kbd', {}, this.props.shortcut)): null, + React.createElement('div', {title: '(' +this.props.ckey + ')' , className:'jupyter-keybindings-text'}, this.props.display ) + ); + } + }); + + const KeyBindingList = React.createClass({ + displayName: 'KeyBindingList', + getInitialState: function(){ + return {data:[]}; + }, + componentDidMount: function(){ + this.setState({data:this.props.callback()}); + }, + render: function() { + const childrens = this.state.data.map((binding)=>{ + return React.createElement(KeyBinding, Object.assign({}, binding, {onAddBindings:(shortcut, action)=>{ + this.props.bind(shortcut, action); + this.setState({data:this.props.callback()}); + }, + available:this.props.available, + unbind: (shortcut) => { + this.props.unbind(shortcut); + this.setState({data:this.props.callback()}); + } + })); + }); + + childrens.unshift(React.createElement('div', {className:'alert alert-info', key:'disclamer'}, + "This dialog shoudl allow you to modify your keymap, and persist the changes."+ + "This functionality is not feature complete, and will likely not function in all the cases."+ + "You can define many type of shorctuts or sequence of keys. Here are various valid shortcuts sequences: a,a"+ + "-- Shift-A,Shift-B -- Shift-A,a -- "+ + "Casing will have no effect, you need to explicitelty write the `Shift` modifier,"+ + " `Cmd`, `Ctrl`, `Meta`, `Cmdtrl` are various valid modifier. Refer to developper docs for their signification depending on teh platform")); + return React.createElement('div',{}, childrens); + } + }); + + const get_shortcuts_data = function(notebook) { + const actions = Object.keys(notebook.keyboard_manager.actions._actions); + const src = []; + + for (let i = 0; i < actions.length; i++) { + const action_id = actions[i]; + const action = notebook.keyboard_manager.actions.get(action_id); + + let shortcut = notebook.keyboard_manager.command_shortcuts.get_action_shortcut(action_id); + let hshortcut; + if (shortcut) { + hshortcut = QH._humanize_sequence(shortcut); + } + + src.push({ + display: humanize_action_id(action_id), + shortcut: hshortcut, + rawshrt: shortcut, + key:action_id, // react specific thing + ckey: action_id + }); + } + return src; + }; + + + const ShortcutEditor = function(notebook) { + + if(!notebook){ + throw new Error("CommandPalette takes a notebook non-null mandatory arguement"); + } + + const body = $('
'); + const mod = dialog.modal({ + notebook: notebook, + keyboard_manager: notebook.keyboard_manager, + title : "Edit Command mode Shortcuts", + body : body, + buttons : { + OK : {} + } + }); + + const src = get_shortcuts_data(notebook); + + mod.addClass("modal_stretch"); + + mod.modal('show'); + ReactDom.render( + React.createElement(KeyBindingList, { + callback:()=>{ return get_shortcuts_data(notebook);}, + bind: (shortcut, command) => { + return notebook.keyboard_manager.command_shortcuts._persist_shortcut(shortcut, command); + }, + unbind: (shortcut) => { + return notebook.keyboard_manager.command_shortcuts._persist_remove_shortcut(shortcut); + }, + available: (shrt) => { return notebook.keyboard_manager.command_shortcuts.is_available_shortcut(shrt);} + }), + body.get(0) + ); + }; + return {'ShortcutEditor': ShortcutEditor}; +}); diff --git a/notebook/static/notebook/less/notebook.less b/notebook/static/notebook/less/notebook.less index 2c3e8e6a3f..90c7a57b24 100644 --- a/notebook/static/notebook/less/notebook.less +++ b/notebook/static/notebook/less/notebook.less @@ -99,3 +99,18 @@ kbd { padding-top: 1px; padding-bottom: 1px; } + +.jupyter-keybindings { + padding: 0px; + line-height: 24px; +} + +.jupyter-keybindings input { + margin: 0; + padding: 0; + border: none; +} + +.jupyter-keybindings i { + padding: 6px; +} diff --git a/package.json b/package.json index 2ba8b0f5f6..f2816fd246 100644 --- a/package.json +++ b/package.json @@ -15,15 +15,20 @@ "build:css": "python setup.py css", "build:css:watch": "echo Not implemented yet...", "build:js": "webpack", - "build:js:watch": "npm run build:js -- --watch" + "build:js:watch": "npm run build:js -- --watch --progress --color" }, "devDependencies": { + "babel-core": "^6.7.4", + "babel-loader": "^6.2.4", + "babel-preset-es2015": "^6.6.0", "bower": "*", "concurrently": "^1.0.0", "less": "~2", "requirejs": "^2.1.17", "underscore": "^1.8.3", - "webpack": "^1.12.13" + "webpack": "^1.12.13", + "react": "^0.14.7", + "react-dom": "^0.14.7" }, "dependencies": { "moment": "^2.8.4" diff --git a/webpack.config.js b/webpack.config.js index 1c6fa8e28b..56e0f53a08 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -14,6 +14,7 @@ var commonConfig = { }, module: { loaders: [ + { test: /\.js$/, exclude: /node_modules|\/notebook\/static\/component/, loader: "babel-loader"}, { test: /\.css$/, loader: "style-loader!css-loader" }, { test: /\.json$/, loader: "json-loader" }, // jquery-ui loads some images