diff --git a/docs/api.md b/docs/api.md index 9595848d7..04aca4656 100644 --- a/docs/api.md +++ b/docs/api.md @@ -180,6 +180,95 @@ Constructs a new JSONEditor. - Can return an object `{startFrom: number, options: string[]}`. Here `startFrom` determines the start character from where the existing text will be replaced. `startFrom` is `0` by default, replacing the whole text. - Can return a `Promise` resolving one of the return types above to support asynchronously retrieving a list with options. +- `{Object[]} toolbarPlugins` + + Adds custom buttons to the toolbar. Contains **subelements**: + + - `{string} title` + + The button's title text, for example `Expand to fullscreen`. + + - `{string} className` + + To be consistent with standard buttons, use `jsoneditor-`, for example `jsoneditor-fullscreen`. + To add a spacer on the left, include class `jsoneditor-separator`, for example `jsoneditor-fullscreen jsoneditor-separator`. + + - `{Function} onclick` + + The callback function when the custom button is clicked. Called without parameters. + +- `{Object[]} contextMenuPlugins` + + Adds custom actions to the context menu when a single node is selected. Contains **subelements**: + + - `{string} type` + + If type === 'separator', this object represents a separator in the context menu. All other properties will be ignored. + + - `{string} text` + + The action's text, for example `Action Name`. + + - `{string} title` + + The action's title (tooltip) text, for example `A longer description of the action`. + + - `{string} className` + + To be consistent with standard buttons, use `jsoneditor-`, for example `jsoneditor-fullscreen`. + + - `{Function} enabled (node)` + + The optional callback function to enable/disable this plugin (enabled by default). Called with the selected node. + + - `{Function} click (node)` + + The callback function when the custom action is clicked. Called with the selected node. + + - `{string} submenuTitle` + + The submenu expander's title (tooltip) text, for example `A longer description of the submenu`. + + - `{Object[]} submenu` + + Submenu items of the same type as `contextMenuPlugins`. + +- `{Object[]} multiContextMenuPlugins` + + Adds custom actions to the context menu when multiple nodes are selected. Contains **subelements**: + + - `{string} type` + + If type === 'separator', this object represents a separator in the context menu. All other properties will be ignored. + + - `{string} text` + + The action's text, for example `Action Name`. + + - `{string} title` + + The action's title (tooltip) text, for example `A longer description of the action`. + + - `{string} className` + + To be consistent with standard buttons, use `jsoneditor-`, for example `jsoneditor-fullscreen`. + + - `{Function} enabled (nodes)` + + The optional callback function to enable/disable this plugin (enabled by default). Called with the selected nodes. + + - `{Function} click (nodes)` + + The callback function when the custom action is clicked. Called with the selected nodes. + + - `{string} submenuTitle` + + The submenu expander's title (tooltip) text, for example `A longer description of the submenu`. + + - `{Object[]} submenu` + + Submenu items of the same type as `multiContextMenuPlugins`. + ### Methods diff --git a/src/css/contextmenu.css b/src/css/contextmenu.css index 85b7d27b0..405e53400 100644 --- a/src/css/contextmenu.css +++ b/src/css/contextmenu.css @@ -89,6 +89,10 @@ div.jsoneditor-contextmenu div.jsoneditor-icon { background-image: url('img/jsoneditor-icons.svg'); } +div.jsoneditor-contextmenu button.jsoneditor-plugin div.jsoneditor-icon { + background: transparent; +} + div.jsoneditor-contextmenu ul li ul div.jsoneditor-icon { margin-left: 24px; } diff --git a/src/css/menu.css b/src/css/menu.css index 6a4f44c99..a9aab1356 100644 --- a/src/css/menu.css +++ b/src/css/menu.css @@ -31,6 +31,10 @@ div.jsoneditor-menu > div.jsoneditor-modes > button { float: left; } +div.jsoneditor-menu > button.jsoneditor-plugin { + background: #d3d3d3; +} + div.jsoneditor-menu > button:hover, div.jsoneditor-menu > div.jsoneditor-modes > button:hover { background-color: rgba(255,255,255,0.2); diff --git a/src/js/ContextMenu.js b/src/js/ContextMenu.js index 813b4c29b..1d6d925dc 100644 --- a/src/js/ContextMenu.js +++ b/src/js/ContextMenu.js @@ -9,9 +9,12 @@ var util = require('./util'); * @param {Object} [options] Object with options. Available options: * {function} close Callback called when the * context menu is being closed. + * @param {Object or Object[]} [selectedElements] + * the selected element or elements to which + * this ContextMenu applies * @constructor */ -function ContextMenu (items, options) { +function ContextMenu (items, options, selectedElements) { this.dom = {}; var me = this; @@ -50,7 +53,7 @@ function ContextMenu (items, options) { li.appendChild(focusButton); list.appendChild(li); - function createMenuItems (list, domItems, items) { + function createMenuItems (list, domItems, items, selectedElements) { items.forEach(function (item) { if (item.type == 'separator') { // create a separator @@ -61,6 +64,11 @@ function ContextMenu (items, options) { list.appendChild(li); } else { + // check if the item should be enabled + if (item.enabled && item.enabled instanceof Function && !item.enabled(selectedElements)) { + return false; + } + var domItem = {}; // create a menu item @@ -79,7 +87,7 @@ function ContextMenu (items, options) { button.onclick = function (event) { event.preventDefault(); me.hide(); - item.click(); + item.click(selectedElements); }; } li.appendChild(button); @@ -137,7 +145,7 @@ function ContextMenu (items, options) { ul.className = 'jsoneditor-menu'; ul.style.height = '0'; li.appendChild(ul); - createMenuItems(ul, domSubItems, item.submenu); + createMenuItems(ul, domSubItems, item.submenu, selectedElements); } else { // no submenu, just a button with clickhandler @@ -149,7 +157,7 @@ function ContextMenu (items, options) { } }); } - createMenuItems(list, this.dom.items, items); + createMenuItems(list, this.dom.items, items, selectedElements); // TODO: when the editor is small, show the submenu on the right instead of inline? diff --git a/src/js/JSONEditor.js b/src/js/JSONEditor.js index 9b0a33156..9b02a0b33 100644 --- a/src/js/JSONEditor.js +++ b/src/js/JSONEditor.js @@ -44,6 +44,20 @@ var util = require('./util'); * {boolean} sortObjectKeys If true, object keys are * sorted before display. * false by default. + * {Object[]} toolbarPlugins Array of custom toolbar + * buttons. Must contain + * `title`, `className`, and + * `onclick` properties. + * {Object[]} contextMenuPlugins Array of custom toolbar + * buttons. Must contain + * 'text', `title`, `className`, + * and either `click` or + * `submenu` properties. + * {Object[]} multiContextMenuPlugins Array of custom toolbar + * buttons. Must contain + * 'text', `title`, `className`, + * and either `click` or + * `submenu` properties. * @param {Object | undefined} json JSON object */ function JSONEditor (container, options, json) { @@ -82,7 +96,8 @@ function JSONEditor (container, options, json) { 'ajv', 'schema', 'schemaRefs','templates', 'ace', 'theme','autocomplete', 'onChange', 'onEditable', 'onError', 'onModeChange', - 'escapeUnicode', 'history', 'search', 'mode', 'modes', 'name', 'indentation', 'sortObjectKeys' + 'escapeUnicode', 'history', 'search', 'mode', 'modes', 'name', 'indentation', 'sortObjectKeys', + 'toolbarPlugins', 'contextMenuPlugins', 'multiContextMenuPlugins' ]; Object.keys(options).forEach(function (option) { diff --git a/src/js/Node.js b/src/js/Node.js index f7a077ac6..4a13cbe4c 100644 --- a/src/js/Node.js +++ b/src/js/Node.js @@ -3419,10 +3419,64 @@ Node.prototype.showContextMenu = function (anchor, onClose) { } } - var menu = new ContextMenu(items, {close: onClose}); + // create custom context menu buttons + if (this.editor.options && this.editor.options.contextMenuPlugins && this.editor.options.contextMenuPlugins.length) { + for (var i in this.editor.options.contextMenuPlugins) { + // recursively validate and process the plugin configurations + var pluginConfig = this._processContextMenuPlugin(this.editor.options.contextMenuPlugins[i]); + + // add the action + if (pluginConfig) { + items.push(pluginConfig); + } + } + } + + var menu = new ContextMenu(items, {close: onClose}, this); menu.show(anchor, this.editor.content); }; +/** + * Recursively process a Context Menu plugin configuration + * @param {Object} pluginConfig + * @return {Object} plugin config or null + * @private + */ +Node.prototype._processContextMenuPlugin = function(pluginConfig) { + // skip this plugin if these properties don't exist + if (pluginConfig.type == 'separator') { + // separators don't have mandatory properties + } else if (!pluginConfig.text || !pluginConfig.title || !pluginConfig.className) { + console.error("Context Menu plugin is being skipped for missing mandatory properties (text, title, className): " + + JSON.stringify(pluginConfig)); + return null; + } else if (!pluginConfig.click && !pluginConfig.submenu) { + console.error("Context Menu plugin is being skipped for not including at least on of the properties (click, submenu): " + + JSON.stringify(pluginConfig)); + return null; + } + + // add a plugin class to hide the icon + if (!pluginConfig.className || pluginConfig.className.indexOf('jsoneditor-plugin ') === -1) { + pluginConfig.className = 'jsoneditor-plugin ' + (pluginConfig.className || ''); + } + + // recursively process submenus + if (pluginConfig.submenu instanceof Array) { + var processedSubMenu = []; + for (var i in pluginConfig.submenu) { + var submenuPlugin = this._processContextMenuPlugin(pluginConfig.submenu[i]); + + if (submenuPlugin) { + processedSubMenu.push(submenuPlugin); + } + } + pluginConfig.submenu = processedSubMenu; + } + + return pluginConfig; +}; + /** * get the type of a value * @param {*} value diff --git a/src/js/appendNodeFactory.js b/src/js/appendNodeFactory.js index 7d9bb8a86..554bcbc4b 100644 --- a/src/js/appendNodeFactory.js +++ b/src/js/appendNodeFactory.js @@ -132,14 +132,13 @@ function appendNodeFactory(Node) { * is being closed. */ AppendNode.prototype.showContextMenu = function (anchor, onClose) { - var node = this; var titles = Node.TYPE_TITLES; var appendSubmenu = [ { text: 'Auto', className: 'jsoneditor-type-auto', title: titles.auto, - click: function () { + click: function (node) { node._onAppend('', '', 'auto'); } }, @@ -147,7 +146,7 @@ function appendNodeFactory(Node) { text: 'Array', className: 'jsoneditor-type-array', title: titles.array, - click: function () { + click: function (node) { node._onAppend('', []); } }, @@ -155,7 +154,7 @@ function appendNodeFactory(Node) { text: 'Object', className: 'jsoneditor-type-object', title: titles.object, - click: function () { + click: function (node) { node._onAppend('', {}); } }, @@ -163,7 +162,7 @@ function appendNodeFactory(Node) { text: 'String', className: 'jsoneditor-type-string', title: titles.string, - click: function () { + click: function (node) { node._onAppend('', '', 'string'); } } @@ -176,14 +175,14 @@ function appendNodeFactory(Node) { 'title': 'Append a new field with type \'auto\' (Ctrl+Shift+Ins)', 'submenuTitle': 'Select the type of the field to be appended', 'className': 'jsoneditor-insert', - 'click': function () { + 'click': function (node) { node._onAppend('', '', 'auto'); }, 'submenu': appendSubmenu } ]; - var menu = new ContextMenu(items, {close: onClose}); + var menu = new ContextMenu(items, {close: onClose}, this); menu.show(anchor, this.editor.content); }; diff --git a/src/js/treemode.js b/src/js/treemode.js index 1414f6d1d..f598f3ace 100644 --- a/src/js/treemode.js +++ b/src/js/treemode.js @@ -753,6 +753,34 @@ treemode._createFrame = function () { }); } + // create custom toolbar buttons + if (this.options && this.options.toolbarPlugins && this.options.toolbarPlugins.length) { + for (var i in this.options.toolbarPlugins) { + var customButtonOpts = this.options.toolbarPlugins[i]; + + // skip this plugin if these properties don't exist + if (!customButtonOpts.title || !customButtonOpts.className || !customButtonOpts.onclick) { + console.error("Toolbar plugin is being skipped for missing mandatory properties (title, className, onclick): " + + JSON.stringify(customButtonOpts)); + continue; + } + + // create the basic button + var customButton = document.createElement('button'); + customButton.type = 'button'; + + // merge the provided options to the button + for (var option in customButtonOpts) { + customButton[option] = customButtonOpts[option]; + } + + // add a plugin class to hide the icon + customButton.className = 'jsoneditor-plugin ' + (customButton.className || ''); + + this.menu.appendChild(customButton); + } + } + // create search box if (this.options.search) { this.searchBox = new SearchBox(this, this.menu); @@ -1210,8 +1238,8 @@ treemode.showContextMenu = function (anchor, onClose) { text: 'Duplicate', title: 'Duplicate selected fields (Ctrl+D)', className: 'jsoneditor-duplicate', - click: function () { - Node.onDuplicate(editor.multiselection.nodes); + click: function (nodes) { + Node.onDuplicate(); } }); @@ -1220,15 +1248,71 @@ treemode.showContextMenu = function (anchor, onClose) { text: 'Remove', title: 'Remove selected fields (Ctrl+Del)', className: 'jsoneditor-remove', - click: function () { - Node.onRemove(editor.multiselection.nodes); + click: function (nodes) { + Node.onRemove(nodes); } }); - var menu = new ContextMenu(items, {close: onClose}); + // create custom multi-select context menu buttons + if (this.options && this.options.multiContextMenuPlugins && this.options.multiContextMenuPlugins.length) { + for (var i in this.options.multiContextMenuPlugins) { + var pluginConfig = this.options.multiContextMenuPlugins[i]; + + // recursively validate and process the plugin configurations + pluginConfig = this._processContextMenuPlugin(pluginConfig); + + // add the action + if (pluginConfig) { + items.push(pluginConfig); + } + } + } + + var menu = new ContextMenu(items, {close: onClose}, editor.multiselection.nodes); menu.show(anchor, this.content); }; +/** + * Recursively process a Context Menu plugin configuration + * @param {Object} pluginConfig + * @return {Object} plugin config or null + * @private + */ +treemode._processContextMenuPlugin = function(pluginConfig) { + // skip this plugin if these properties don't exist + if (pluginConfig.type == 'separator') { + // separators don't have mandatory properties + } else if (!pluginConfig.text || !pluginConfig.title || !pluginConfig.className) { + console.error("Context Menu plugin is being skipped for missing mandatory properties (text, title, className): " + + JSON.stringify(pluginConfig)); + return null; + } else if (!pluginConfig.click && !pluginConfig.submenu) { + console.error("Context Menu plugin is being skipped for not including at least on of the properties (click, submenu): " + + JSON.stringify(pluginConfig)); + return null; + } + + // add a plugin class to hide the icon + if (!pluginConfig.className || pluginConfig.className.indexOf('jsoneditor-plugin ') === -1) { + pluginConfig.className = 'jsoneditor-plugin ' + (pluginConfig.className || ''); + } + + // recursively process submenus + if (pluginConfig.submenu instanceof Array) { + var processedSubMenu = []; + for (var i in pluginConfig.submenu) { + var submenuPlugin = this._processContextMenuPlugin(pluginConfig.submenu[i]); + + if (submenuPlugin) { + processedSubMenu.push(submenuPlugin); + } + } + pluginConfig.submenu = processedSubMenu; + } + + return pluginConfig; +}; + // define modes module.exports = [ diff --git a/test/test_plugins.html b/test/test_plugins.html new file mode 100644 index 000000000..ef714d27b --- /dev/null +++ b/test/test_plugins.html @@ -0,0 +1,218 @@ + + + + + + + + + + + + +

+ Switch editor mode using the mode box. + Note that the mode can be changed programmatically as well using the method + editor.setMode(mode), try it in the console of your browser. +

+ +
+ + + +