/*! * jsoneditor.js * * @brief * JSONEditor is a web-based tool to view, edit, and format JSON. * It shows data a clear, editable treeview. * * Supported browsers: Chrome, Firefox, Safari, Opera, Internet Explorer 8+ * * @license * This json editor is open sourced with the intention to use the editor as * a component in your own application. Not to just copy and monetize the editor * as it is. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy * of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations under * the License. * * Copyright (c) 2011-2013 Jos de Jong, http://jsoneditoronline.org * * @author Jos de Jong, * @version 2.3.2 * @date 2013-09-26 */ (function () { /** * @constructor JSONEditor * @param {Element} container Container element * @param {Object} [options] Object with options. available options: * {String} mode Editor mode. Available values: * 'tree' (default), 'view', * 'form', 'text', and 'code'. * {function} change Callback method, triggered * on change of contents * {Boolean} search Enable search box. * True by default * Only applicable for modes * 'tree', 'view', and 'form' * {Boolean} history Enable history (undo/redo). * True by default * Only applicable for modes * 'tree', 'view', and 'form' * {String} name Field name for the root node. * Only applicable for modes * 'tree', 'view', and 'form' * {Number} indentation Number of indentation * spaces. 4 by default. * Only applicable for * modes 'text' and 'code' * @param {Object | undefined} json JSON object */ function JSONEditor (container, options, json) { if (!(this instanceof JSONEditor)) { throw new Error('JSONEditor constructor called without "new".'); } if (arguments.length) { this._create(container, options, json); } } /** * Configuration for all registered modes. Example: * { * tree: { * editor: TreeEditor, * data: 'json' * }, * text: { * editor: TextEditor, * data: 'text' * } * } * * @type { Object. } */ JSONEditor.modes = {}; /** * Create the JSONEditor * @param {Element} container Container element * @param {Object} [options] See description in constructor * @param {Object | undefined} json JSON object * @private */ JSONEditor.prototype._create = function (container, options, json) { this.container = container; this.options = options || {}; this.json = json || {}; var mode = this.options.mode || 'tree'; this.setMode(mode); }; /** * Detach the editor from the DOM * @private */ JSONEditor.prototype._delete = function () {}; /** * Set JSON object in editor * @param {Object | undefined} json JSON data */ JSONEditor.prototype.set = function (json) { this.json = json; }; /** * Get JSON from the editor * @returns {Object} json */ JSONEditor.prototype.get = function () { return this.json; }; /** * Set string containing JSON for the editor * @param {String | undefined} jsonText */ JSONEditor.prototype.setText = function (jsonText) { this.json = util.parse(jsonText); }; /** * Get stringified JSON contents from the editor * @returns {String} jsonText */ JSONEditor.prototype.getText = function () { return JSON.stringify(this.json); }; /** * Set a field name for the root node. * @param {String | undefined} name */ JSONEditor.prototype.setName = function (name) { if (!this.options) { this.options = {}; } this.options.name = name; }; /** * Get the field name for the root node. * @return {String | undefined} name */ JSONEditor.prototype.getName = function () { return this.options && this.options.name; }; /** * Change the mode of the editor. * JSONEditor will be extended with all methods needed for the chosen mode. * @param {String} mode Available modes: 'tree' (default), 'view', 'form', * 'text', and 'code'. */ JSONEditor.prototype.setMode = function (mode) { var container = this.container, options = util.extend({}, this.options), data, name; options.mode = mode; var config = JSONEditor.modes[mode]; if (config) { try { if (config.data == 'text') { // text name = this.getName(); data = this.getText(); this._delete(); util.clear(this); util.extend(this, config.editor.prototype); this._create(container, options); this.setName(name); this.setText(data); } else { // json name = this.getName(); data = this.get(); this._delete(); util.clear(this); util.extend(this, config.editor.prototype); this._create(container, options); this.setName(name); this.set(data); } if (typeof config.load === 'function') { try { config.load.call(this); } catch (err) {} } } catch (err) { this._onError(err); } } else { throw new Error('Unknown mode "' + options.mode + '"'); } }; /** * Throw an error. If an error callback is configured in options.error, this * callback will be invoked. Else, a regular error is thrown. * @param {Error} err * @private */ JSONEditor.prototype._onError = function(err) { // TODO: onError is deprecated since version 2.2.0. cleanup some day if (typeof this.onError === 'function') { util.log('WARNING: JSONEditor.onError is deprecated. ' + 'Use options.error instead.'); this.onError(err); } if (this.options && typeof this.options.error === 'function') { this.options.error(err); } else { throw err; } }; /** * @constructor TreeEditor * @param {Element} container Container element * @param {Object} [options] Object with options. available options: * {String} mode Editor mode. Available values: * 'tree' (default), 'view', * and 'form'. * {Boolean} search Enable search box. * True by default * {Boolean} history Enable history (undo/redo). * True by default * {function} change Callback method, triggered * on change of contents * {String} name Field name for the root node. * @param {Object | undefined} json JSON object */ function TreeEditor(container, options, json) { if (!(this instanceof TreeEditor)) { throw new Error('TreeEditor constructor called without "new".'); } this._create(container, options, json); } /** * Create the TreeEditor * @param {Element} container Container element * @param {Object} [options] See description in constructor * @param {Object | undefined} json JSON object * @private */ TreeEditor.prototype._create = function (container, options, json) { // check availability of JSON parser (not available in IE7 and older) if (typeof(JSON) == 'undefined') { throw new Error ('Your browser does not support JSON. \n\n' + 'Please install the newest version of your browser.\n' + '(all modern browsers support JSON).'); } if (!container) { throw new Error('No container element provided.'); } this.container = container; this.dom = {}; this.highlighter = new Highlighter(); this.selection = undefined; // will hold the last input selection this._setOptions(options); if (this.options.history && !this.mode.view) { this.history = new History(this); } this._createFrame(); this._createTable(); this.set(json || {}); }; /** * Detach the editor from the DOM * @private */ TreeEditor.prototype._delete = function () { if (this.frame && this.container && this.frame.parentNode == this.container) { this.container.removeChild(this.frame); } }; /** * Initialize and set default options * @param {Object} [options] See description in constructor * @private */ TreeEditor.prototype._setOptions = function (options) { this.options = { search: true, history: true, mode: 'tree', name: undefined // field name of root node }; // copy all options if (options) { for (var prop in options) { if (options.hasOwnProperty(prop)) { this.options[prop] = options[prop]; } } // check for deprecated options if (options['enableSearch']) { // deprecated since version 1.6.0, 2012-11-03 this.options.search = options['enableSearch']; util.log('WARNING: Option "enableSearch" is deprecated. Use "search" instead.'); } if (options['enableHistory']) { // deprecated since version 1.6.0, 2012-11-03 this.options.history = options['enableHistory']; util.log('WARNING: Option "enableHistory" is deprecated. Use "history" instead.'); } if (options['mode'] == 'editor') { // deprecated since version 2.2.0, 2013-04-30 this.options.mode = 'tree'; util.log('WARNING: Mode "editor" is deprecated. Use "tree" instead.'); } if (options['mode'] == 'viewer') { // deprecated since version 2.2.0, 2013-04-30 this.options.mode = 'view'; util.log('WARNING: Mode "viewer" is deprecated. Use "view" instead.'); } } // interpret the mode options this.mode = { edit: (this.options.mode != 'view' && this.options.mode != 'form'), view: (this.options.mode == 'view'), form: (this.options.mode == 'form') }; }; // node currently being edited TreeEditor.focusNode = undefined; /** * Set JSON object in editor * @param {Object | undefined} json JSON data * @param {String} [name] Optional field name for the root node. * Can also be set using setName(name). */ TreeEditor.prototype.set = function (json, name) { // adjust field name for root node if (name) { // TODO: deprecated since version 2.2.0. Cleanup some day. util.log('Warning: second parameter "name" is deprecated. ' + 'Use setName(name) instead.'); this.options.name = name; } // verify if json is valid JSON, ignore when a function if (json instanceof Function || (json === undefined)) { this.clear(); } else { this.content.removeChild(this.table); // Take the table offline // replace the root node var params = { 'field': this.options.name, 'value': json }; var node = new Node(this, params); this._setRoot(node); // expand var recurse = false; this.node.expand(recurse); this.content.appendChild(this.table); // Put the table online again } // TODO: maintain history, store last state and previous document if (this.history) { this.history.clear(); } }; /** * Get JSON object from editor * @return {Object | undefined} json */ TreeEditor.prototype.get = function () { // remove focus from currently edited node if (TreeEditor.focusNode) { TreeEditor.focusNode.blur(); } if (this.node) { return this.node.getValue(); } else { return undefined; } }; /** * Get the text contents of the TreeEditor * @return {String} jsonText */ TreeEditor.prototype.getText = function() { return JSON.stringify(this.get()); }; /** * Set the text contents of the TreeEditor * @param {String} jsonText */ TreeEditor.prototype.setText = function(jsonText) { this.set(util.parse(jsonText)); }; /** * Set a field name for the root node. * @param {String | undefined} name */ TreeEditor.prototype.setName = function (name) { this.options.name = name; if (this.node) { this.node.updateField(this.options.name); } }; /** * Get the field name for the root node. * @return {String | undefined} name */ TreeEditor.prototype.getName = function () { return this.options.name; }; /** * Remove the root node from the editor */ TreeEditor.prototype.clear = function () { if (this.node) { this.node.collapse(); this.tbody.removeChild(this.node.getDom()); delete this.node; } }; /** * Set the root node for the json editor * @param {Node} node * @private */ TreeEditor.prototype._setRoot = function (node) { this.clear(); this.node = node; // append to the dom this.tbody.appendChild(node.getDom()); }; /** * Search text in all nodes * The nodes will be expanded when the text is found one of its childs, * else it will be collapsed. Searches are case insensitive. * @param {String} text * @return {Object[]} results Array with nodes containing the search results * The result objects contains fields: * - {Node} node, * - {String} elem the dom element name where * the result is found ('field' or * 'value') */ TreeEditor.prototype.search = function (text) { var results; if (this.node) { this.content.removeChild(this.table); // Take the table offline results = this.node.search(text); this.content.appendChild(this.table); // Put the table online again } else { results = []; } return results; }; /** * Expand all nodes */ TreeEditor.prototype.expandAll = function () { if (this.node) { this.content.removeChild(this.table); // Take the table offline this.node.expand(); this.content.appendChild(this.table); // Put the table online again } }; /** * Collapse all nodes */ TreeEditor.prototype.collapseAll = function () { if (this.node) { this.content.removeChild(this.table); // Take the table offline this.node.collapse(); this.content.appendChild(this.table); // Put the table online again } }; /** * The method onChange is called whenever a field or value is changed, created, * deleted, duplicated, etc. * @param {String} action Change action. Available values: "editField", * "editValue", "changeType", "appendNode", * "removeNode", "duplicateNode", "moveNode", "expand", * "collapse". * @param {Object} params Object containing parameters describing the change. * The parameters in params depend on the action (for * example for "editValue" the Node, old value, and new * value are provided). params contains all information * needed to undo or redo the action. * @private */ TreeEditor.prototype._onAction = function (action, params) { // add an action to the history if (this.history) { this.history.add(action, params); } // trigger the onChange callback if (this.options.change) { try { this.options.change(); } catch (err) { util.log('Error in change callback: ', err); } } }; /** * Start autoscrolling when given mouse position is above the top of the * editor contents, or below the bottom. * @param {Number} mouseY Absolute mouse position in pixels */ TreeEditor.prototype.startAutoScroll = function (mouseY) { var me = this; var content = this.content; var top = util.getAbsoluteTop(content); var height = content.clientHeight; var bottom = top + height; var margin = 24; var interval = 50; // ms if ((mouseY < top + margin) && content.scrollTop > 0) { this.autoScrollStep = ((top + margin) - mouseY) / 3; } else if (mouseY > bottom - margin && height + content.scrollTop < content.scrollHeight) { this.autoScrollStep = ((bottom - margin) - mouseY) / 3; } else { this.autoScrollStep = undefined; } if (this.autoScrollStep) { if (!this.autoScrollTimer) { this.autoScrollTimer = setInterval(function () { if (me.autoScrollStep) { content.scrollTop -= me.autoScrollStep; } else { me.stopAutoScroll(); } }, interval); } } else { this.stopAutoScroll(); } }; /** * Stop auto scrolling. Only applicable when scrolling */ TreeEditor.prototype.stopAutoScroll = function () { if (this.autoScrollTimer) { clearTimeout(this.autoScrollTimer); delete this.autoScrollTimer; } if (this.autoScrollStep) { delete this.autoScrollStep; } }; /** * Set the focus to an element in the TreeEditor, set text selection, and * set scroll position. * @param {Object} selection An object containing fields: * {Element | undefined} dom The dom element * which has focus * {Range | TextRange} range A text selection * {Number} scrollTop Scroll position */ TreeEditor.prototype.setSelection = function (selection) { if (!selection) { return; } if ('scrollTop' in selection && this.content) { // TODO: animated scroll this.content.scrollTop = selection.scrollTop; } if (selection.range) { util.setSelectionOffset(selection.range); } if (selection.dom) { selection.dom.focus(); } }; /** * Get the current focus * @return {Object} selection An object containing fields: * {Element | undefined} dom The dom element * which has focus * {Range | TextRange} range A text selection * {Number} scrollTop Scroll position */ TreeEditor.prototype.getSelection = function () { return { dom: TreeEditor.domFocus, scrollTop: this.content ? this.content.scrollTop : 0, range: util.getSelectionOffset() }; }; /** * Adjust the scroll position such that given top position is shown at 1/4 * of the window height. * @param {Number} top * @param {function(boolean)} [callback] Callback, executed when animation is * finished. The callback returns true * when animation is finished, or false * when not. */ TreeEditor.prototype.scrollTo = function (top, callback) { var content = this.content; if (content) { var editor = this; // cancel any running animation if (editor.animateTimeout) { clearTimeout(editor.animateTimeout); delete editor.animateTimeout; } if (editor.animateCallback) { editor.animateCallback(false); delete editor.animateCallback; } // calculate final scroll position var height = content.clientHeight; var bottom = content.scrollHeight - height; var finalScrollTop = Math.min(Math.max(top - height / 4, 0), bottom); // animate towards the new scroll position var animate = function () { var scrollTop = content.scrollTop; var diff = (finalScrollTop - scrollTop); if (Math.abs(diff) > 3) { content.scrollTop += diff / 3; editor.animateCallback = callback; editor.animateTimeout = setTimeout(animate, 50); } else { // finished if (callback) { callback(true); } content.scrollTop = finalScrollTop; delete editor.animateTimeout; delete editor.animateCallback; } }; animate(); } else { if (callback) { callback(false); } } }; /** * Create main frame * @private */ TreeEditor.prototype._createFrame = function () { // create the frame this.frame = document.createElement('div'); this.frame.className = 'jsoneditor'; this.container.appendChild(this.frame); // create one global event listener to handle all events from all nodes var editor = this; var onEvent = function (event) { editor._onEvent(event); }; this.frame.onclick = function (event) { event = event || window.event; var target = event.target || event.srcElement; onEvent(event); // prevent default submit action of buttons when TreeEditor is located // inside a form if (target.nodeName == 'BUTTON') { util.preventDefault(event); } }; this.frame.oninput = onEvent; this.frame.onchange = onEvent; this.frame.onkeydown = onEvent; this.frame.onkeyup = onEvent; this.frame.oncut = onEvent; this.frame.onpaste = onEvent; this.frame.onmousedown = onEvent; this.frame.onmouseup = onEvent; this.frame.onmouseover = onEvent; this.frame.onmouseout = onEvent; // Note: focus and blur events do not propagate, therefore they defined // using an eventListener with useCapture=true // see http://www.quirksmode.org/blog/archives/2008/04/delegating_the.html util.addEventListener(this.frame, 'focus', onEvent, true); util.addEventListener(this.frame, 'blur', onEvent, true); this.frame.onfocusin = onEvent; // for IE this.frame.onfocusout = onEvent; // for IE // create menu this.menu = document.createElement('div'); this.menu.className = 'menu'; this.frame.appendChild(this.menu); // create expand all button var expandAll = document.createElement('button'); expandAll.className = 'expand-all'; expandAll.title = 'Expand all fields'; expandAll.onclick = function () { editor.expandAll(); }; this.menu.appendChild(expandAll); // create expand all button var collapseAll = document.createElement('button'); collapseAll.title = 'Collapse all fields'; collapseAll.className = 'collapse-all'; collapseAll.onclick = function () { editor.collapseAll(); }; this.menu.appendChild(collapseAll); // create undo/redo buttons if (this.history) { // create undo button var undo = document.createElement('button'); undo.className = 'undo separator'; undo.title = 'Undo last action (Ctrl+Z)'; undo.onclick = function () { editor._onUndo(); }; this.menu.appendChild(undo); this.dom.undo = undo; // create redo button var redo = document.createElement('button'); redo.className = 'redo'; redo.title = 'Redo (Ctrl+Shift+Z)'; redo.onclick = function () { editor._onRedo(); }; this.menu.appendChild(redo); this.dom.redo = redo; // register handler for onchange of history this.history.onChange = function () { undo.disabled = !editor.history.canUndo(); redo.disabled = !editor.history.canRedo(); }; this.history.onChange(); } // create mode box if (this.options && this.options.modes && this.options.modes.length) { var modeBox = createModeBox(this, this.options.modes, this.options.mode); this.menu.appendChild(modeBox); this.dom.modeBox = modeBox; } // create search box if (this.options.search) { this.searchBox = new SearchBox(this, this.menu); } }; /** * Perform an undo action * @private */ TreeEditor.prototype._onUndo = function () { if (this.history) { // undo last action this.history.undo(); // trigger change callback if (this.options.change) { this.options.change(); } } }; /** * Perform a redo action * @private */ TreeEditor.prototype._onRedo = function () { if (this.history) { // redo last action this.history.redo(); // trigger change callback if (this.options.change) { this.options.change(); } } }; /** * Event handler * @param event * @private */ TreeEditor.prototype._onEvent = function (event) { event = event || window.event; var target = event.target || event.srcElement; if (event.type == 'keydown') { this._onKeyDown(event); } if (event.type == 'focus') { TreeEditor.domFocus = target; } var node = Node.getNodeFromTarget(target); if (node) { node.onEvent(event); } }; /** * Event handler for keydown. Handles shortcut keys * @param {Event} event * @private */ TreeEditor.prototype._onKeyDown = function (event) { var keynum = event.which || event.keyCode; var ctrlKey = event.ctrlKey; var shiftKey = event.shiftKey; var handled = false; if (keynum == 9) { // Tab or Shift+Tab // FIXME: selecting all text on tab key does not work on IE8 (-> put selectContentEditable() in keyup too?) //Node.select(TreeEditor.domFocus); setTimeout(function () { // select all text when moving focus to an editable div util.selectContentEditable(TreeEditor.domFocus); }, 0); } if (this.searchBox) { if (ctrlKey && keynum == 70) { // Ctrl+F this.searchBox.dom.search.focus(); this.searchBox.dom.search.select(); handled = true; } else if (keynum == 114 || (ctrlKey && keynum == 71)) { // F3 or Ctrl+G var focus = true; if (!shiftKey) { // select next search result (F3 or Ctrl+G) this.searchBox.next(focus); } else { // select previous search result (Shift+F3 or Ctrl+Shift+G) this.searchBox.previous(focus); } handled = true; } } if (this.history) { if (ctrlKey && !shiftKey && keynum == 90) { // Ctrl+Z // undo this._onUndo(); handled = true; } else if (ctrlKey && shiftKey && keynum == 90) { // Ctrl+Shift+Z // redo this._onRedo(); handled = true; } } if (handled) { util.preventDefault(event); util.stopPropagation(event); } }; /** * Create main table * @private */ TreeEditor.prototype._createTable = function () { var contentOuter = document.createElement('div'); contentOuter.className = 'outer'; this.contentOuter = contentOuter; this.content = document.createElement('div'); this.content.className = 'content'; contentOuter.appendChild(this.content); this.table = document.createElement('table'); this.table.className = 'content'; this.content.appendChild(this.table); // IE8 does not handle overflow='auto' correctly. // Therefore, set overflow to 'scroll' var ieVersion = util.getInternetExplorerVersion(); if (ieVersion == 8) { this.content.style.overflow = 'scroll'; } // create colgroup where the first two columns don't have a fixed // width, and the edit columns do have a fixed width var col; this.colgroupContent = document.createElement('colgroup'); if (this.mode.edit) { col = document.createElement('col'); col.width = "24px"; this.colgroupContent.appendChild(col); } col = document.createElement('col'); col.width = "24px"; this.colgroupContent.appendChild(col); col = document.createElement('col'); this.colgroupContent.appendChild(col); this.table.appendChild(this.colgroupContent); this.tbody = document.createElement('tbody'); this.table.appendChild(this.tbody); this.frame.appendChild(contentOuter); }; // register modes at the JSONEditor JSONEditor.modes.tree = { editor: TreeEditor, data: 'json' }; JSONEditor.modes.view = { editor: TreeEditor, data: 'json' }; JSONEditor.modes.form = { editor: TreeEditor, data: 'json' }; // Deprecated modes (deprecated since version 2.2.0) JSONEditor.modes.editor = { editor: TreeEditor, data: 'json' }; JSONEditor.modes.viewer = { editor: TreeEditor, data: 'json' }; /** * Create a TextEditor and attach it to given container * @constructor TextEditor * @param {Element} container * @param {Object} [options] Object with options. available options: * {String} mode Available values: * "text" (default) * or "code". * {Number} indentation Number of indentation * spaces. 4 by default. * {function} change Callback method * triggered on change * @param {JSON | String} [json] initial contents of the formatter */ function TextEditor(container, options, json) { if (!(this instanceof TextEditor)) { throw new Error('TextEditor constructor called without "new".'); } this._create(container, options, json); } /** * Create a TextEditor and attach it to given container * @constructor TextEditor * @param {Element} container * @param {Object} [options] See description in constructor * @param {JSON | String} [json] initial contents of the formatter * @private */ TextEditor.prototype._create = function (container, options, json) { // check availability of JSON parser (not available in IE7 and older) if (typeof(JSON) == 'undefined') { throw new Error('Your browser does not support JSON. \n\n' + 'Please install the newest version of your browser.\n' + '(all modern browsers support JSON).'); } // read options options = options || {}; this.options = options; if (options.indentation) { this.indentation = Number(options.indentation); } else { this.indentation = 4; // number of spaces } this.mode = (options.mode == 'code') ? 'code' : 'text'; if (this.mode == 'code') { // verify whether Ace editor is available and supported if (typeof ace === 'undefined') { this.mode = 'text'; util.log('WARNING: Cannot load code editor, Ace library not loaded. ' + 'Falling back to plain text editor'); } if (util.getInternetExplorerVersion() == 8) { this.mode = 'text'; util.log('WARNING: Cannot load code editor, Ace is not supported on IE8. ' + 'Falling back to plain text editor'); } } var me = this; this.container = container; this.dom = {}; this.editor = undefined; // ace code editor this.textarea = undefined; // plain text editor (fallback when Ace is not available) this.width = container.clientWidth; this.height = container.clientHeight; this.frame = document.createElement('div'); this.frame.className = 'jsoneditor'; this.frame.onclick = function (event) { // prevent default submit action when TextEditor is located inside a form util.preventDefault(event); }; // create menu this.menu = document.createElement('div'); this.menu.className = 'menu'; this.frame.appendChild(this.menu); // create format button var buttonFormat = document.createElement('button'); buttonFormat.className = 'format'; buttonFormat.title = 'Format JSON data, with proper indentation and line feeds'; this.menu.appendChild(buttonFormat); buttonFormat.onclick = function () { try { me.format(); } catch (err) { me._onError(err); } }; // create compact button var buttonCompact = document.createElement('button'); buttonCompact.className = 'compact'; buttonCompact.title = 'Compact JSON data, remove all whitespaces'; this.menu.appendChild(buttonCompact); buttonCompact.onclick = function () { try { me.compact(); } catch (err) { me._onError(err); } }; // create mode box if (this.options && this.options.modes && this.options.modes.length) { var modeBox = createModeBox(this, this.options.modes, this.options.mode); this.menu.appendChild(modeBox); this.dom.modeBox = modeBox; } this.content = document.createElement('div'); this.content.className = 'outer'; this.frame.appendChild(this.content); this.container.appendChild(this.frame); if (this.mode == 'code') { this.editorDom = document.createElement('div'); this.editorDom.style.height = '100%'; // TODO: move to css this.editorDom.style.width = '100%'; // TODO: move to css this.content.appendChild(this.editorDom); var editor = ace.edit(this.editorDom); editor.setTheme('ace/theme/jsoneditor'); editor.setShowPrintMargin(false); editor.setFontSize(13); editor.getSession().setMode('ace/mode/json'); editor.getSession().setUseSoftTabs(true); editor.getSession().setUseWrapMode(true); this.editor = editor; var poweredBy = document.createElement('a'); poweredBy.appendChild(document.createTextNode('powered by ace')); poweredBy.href = 'http://ace.ajax.org'; poweredBy.target = '_blank'; poweredBy.className = 'poweredBy'; poweredBy.onclick = function () { // TODO: this anchor falls below the margin of the content, // therefore the normal a.href does not work. We use a click event // for now, but this should be fixed. window.open(poweredBy.href, poweredBy.target); }; this.menu.appendChild(poweredBy); if (options.change) { // register onchange event editor.on('change', function () { options.change(); }); } } else { // load a plain text textarea var textarea = document.createElement('textarea'); textarea.className = 'content'; textarea.spellcheck = false; this.content.appendChild(textarea); this.textarea = textarea; if (options.change) { // register onchange event if (this.textarea.oninput === null) { this.textarea.oninput = function () { options.change(); } } else { // oninput is undefined. For IE8- this.textarea.onchange = function () { options.change(); } } } } // load initial json object or string if (typeof(json) == 'string') { this.setText(json); } else { this.set(json); } }; /** * Detach the editor from the DOM * @private */ TextEditor.prototype._delete = function () { if (this.frame && this.container && this.frame.parentNode == this.container) { this.container.removeChild(this.frame); } }; /** * Throw an error. If an error callback is configured in options.error, this * callback will be invoked. Else, a regular error is thrown. * @param {Error} err * @private */ TextEditor.prototype._onError = function(err) { // TODO: onError is deprecated since version 2.2.0. cleanup some day if (typeof this.onError === 'function') { util.log('WARNING: JSONEditor.onError is deprecated. ' + 'Use options.error instead.'); this.onError(err); } if (this.options && typeof this.options.error === 'function') { this.options.error(err); } else { throw err; } }; /** * Compact the code in the formatter */ TextEditor.prototype.compact = function () { var json = util.parse(this.getText()); this.setText(JSON.stringify(json)); }; /** * Format the code in the formatter */ TextEditor.prototype.format = function () { var json = util.parse(this.getText()); this.setText(JSON.stringify(json, null, this.indentation)); }; /** * Set focus to the formatter */ TextEditor.prototype.focus = function () { if (this.textarea) { this.textarea.focus(); } if (this.editor) { this.editor.focus(); } }; /** * Resize the formatter */ TextEditor.prototype.resize = function () { if (this.editor) { var force = false; this.editor.resize(force); } }; /** * Set json data in the formatter * @param {Object} json */ TextEditor.prototype.set = function(json) { this.setText(JSON.stringify(json, null, this.indentation)); }; /** * Get json data from the formatter * @return {Object} json */ TextEditor.prototype.get = function() { return util.parse(this.getText()); }; /** * Get the text contents of the TextEditor * @return {String} jsonText */ TextEditor.prototype.getText = function() { if (this.textarea) { return this.textarea.value; } if (this.editor) { return this.editor.getValue(); } return ''; }; /** * Set the text contents of the TextEditor * @param {String} jsonText */ TextEditor.prototype.setText = function(jsonText) { if (this.textarea) { this.textarea.value = jsonText; } if (this.editor) { this.editor.setValue(jsonText, -1); } }; // register modes at the JSONEditor JSONEditor.modes.text = { editor: TextEditor, data: 'text', load: TextEditor.prototype.format }; JSONEditor.modes.code = { editor: TextEditor, data: 'text', load: TextEditor.prototype.format }; /** * @constructor Node * Create a new Node * @param {TreeEditor} editor * @param {Object} [params] Can contain parameters: * {string} field * {boolean} fieldEditable * {*} value * {String} type Can have values 'auto', 'array', * 'object', or 'string'. */ function Node (editor, params) { /** @type {TreeEditor} */ this.editor = editor; this.dom = {}; this.expanded = false; if(params && (params instanceof Object)) { this.setField(params.field, params.fieldEditable); this.setValue(params.value, params.type); } else { this.setField(''); this.setValue(null); } }; /** * Set parent node * @param {Node} parent */ Node.prototype.setParent = function(parent) { this.parent = parent; }; /** * Set field * @param {String} field * @param {boolean} [fieldEditable] */ Node.prototype.setField = function(field, fieldEditable) { this.field = field; this.fieldEditable = (fieldEditable == true); }; /** * Get field * @return {String} */ Node.prototype.getField = function() { if (this.field === undefined) { this._getDomField(); } return this.field; }; /** * Set value. Value is a JSON structure or an element String, Boolean, etc. * @param {*} value * @param {String} [type] Specify the type of the value. Can be 'auto', * 'array', 'object', or 'string' */ Node.prototype.setValue = function(value, type) { var childValue, child; // first clear all current childs (if any) var childs = this.childs; if (childs) { while (childs.length) { this.removeChild(childs[0]); } } // TODO: remove the DOM of this Node this.type = this._getType(value); // check if type corresponds with the provided type if (type && type != this.type) { if (type == 'string' && this.type == 'auto') { this.type = type; } else { throw new Error('Type mismatch: ' + 'cannot cast value of type "' + this.type + ' to the specified type "' + type + '"'); } } if (this.type == 'array') { // array this.childs = []; for (var i = 0, iMax = value.length; i < iMax; i++) { childValue = value[i]; if (childValue !== undefined && !(childValue instanceof Function)) { // ignore undefined and functions child = new Node(this.editor, { 'value': childValue }); this.appendChild(child); } } this.value = ''; } else if (this.type == 'object') { // object this.childs = []; for (var childField in value) { if (value.hasOwnProperty(childField)) { childValue = value[childField]; if (childValue !== undefined && !(childValue instanceof Function)) { // ignore undefined and functions child = new Node(this.editor, { 'field': childField, 'value': childValue }); this.appendChild(child); } } } this.value = ''; } else { // value this.childs = undefined; this.value = value; /* TODO if (typeof(value) == 'string') { var escValue = JSON.stringify(value); this.value = escValue.substring(1, escValue.length - 1); util.log('check', value, this.value); } else { this.value = value; } */ } }; /** * Get value. Value is a JSON structure * @return {*} value */ Node.prototype.getValue = function() { //var childs, i, iMax; if (this.type == 'array') { var arr = []; this.childs.forEach (function (child) { arr.push(child.getValue()); }); return arr; } else if (this.type == 'object') { var obj = {}; this.childs.forEach (function (child) { obj[child.getField()] = child.getValue(); }); return obj; } else { if (this.value === undefined) { this._getDomValue(); } return this.value; } }; /** * Get the nesting level of this node * @return {Number} level */ Node.prototype.getLevel = function() { return (this.parent ? this.parent.getLevel() + 1 : 0); }; /** * Create a clone of a node * The complete state of a clone is copied, including whether it is expanded or * not. The DOM elements are not cloned. * @return {Node} clone */ Node.prototype.clone = function() { var clone = new Node(this.editor); clone.type = this.type; clone.field = this.field; clone.fieldInnerText = this.fieldInnerText; clone.fieldEditable = this.fieldEditable; clone.value = this.value; clone.valueInnerText = this.valueInnerText; clone.expanded = this.expanded; if (this.childs) { // an object or array var cloneChilds = []; this.childs.forEach(function (child) { var childClone = child.clone(); childClone.setParent(clone); cloneChilds.push(childClone); }); clone.childs = cloneChilds; } else { // a value clone.childs = undefined; } return clone; }; /** * Expand this node and optionally its childs. * @param {boolean} [recurse] Optional recursion, true by default. When * true, all childs will be expanded recursively */ Node.prototype.expand = function(recurse) { if (!this.childs) { return; } // set this node expanded this.expanded = true; if (this.dom.expand) { this.dom.expand.className = 'expanded'; } this.showChilds(); if (recurse != false) { this.childs.forEach(function (child) { child.expand(recurse); }); } }; /** * Collapse this node and optionally its childs. * @param {boolean} [recurse] Optional recursion, true by default. When * true, all childs will be collapsed recursively */ Node.prototype.collapse = function(recurse) { if (!this.childs) { return; } this.hideChilds(); // collapse childs in case of recurse if (recurse != false) { this.childs.forEach(function (child) { child.collapse(recurse); }); } // make this node collapsed if (this.dom.expand) { this.dom.expand.className = 'collapsed'; } this.expanded = false; }; /** * Recursively show all childs when they are expanded */ Node.prototype.showChilds = function() { var childs = this.childs; if (!childs) { return; } if (!this.expanded) { return; } var tr = this.dom.tr; var table = tr ? tr.parentNode : undefined; if (table) { // show row with append button var append = this.getAppend(); var nextTr = tr.nextSibling; if (nextTr) { table.insertBefore(append, nextTr); } else { table.appendChild(append); } // show childs this.childs.forEach(function (child) { table.insertBefore(child.getDom(), append); child.showChilds(); }); } }; /** * Hide the node with all its childs */ Node.prototype.hide = function() { var tr = this.dom.tr; var table = tr ? tr.parentNode : undefined; if (table) { table.removeChild(tr); } this.hideChilds(); }; /** * Recursively hide all childs */ Node.prototype.hideChilds = function() { var childs = this.childs; if (!childs) { return; } if (!this.expanded) { return; } // hide append row var append = this.getAppend(); if (append.parentNode) { append.parentNode.removeChild(append); } // hide childs this.childs.forEach(function (child) { child.hide(); }); }; /** * Add a new child to the node. * Only applicable when Node value is of type array or object * @param {Node} node */ Node.prototype.appendChild = function(node) { if (this._hasChilds()) { // adjust the link to the parent node.setParent(this); node.fieldEditable = (this.type == 'object'); if (this.type == 'array') { node.index = this.childs.length; } this.childs.push(node); if (this.expanded) { // insert into the DOM, before the appendRow var newTr = node.getDom(); var appendTr = this.getAppend(); var table = appendTr ? appendTr.parentNode : undefined; if (appendTr && table) { table.insertBefore(newTr, appendTr); } node.showChilds(); } this.updateDom({'updateIndexes': true}); node.updateDom({'recurse': true}); } }; /** * Move a node from its current parent to this node * Only applicable when Node value is of type array or object * @param {Node} node * @param {Node} beforeNode */ Node.prototype.moveBefore = function(node, beforeNode) { if (this._hasChilds()) { // create a temporary row, to prevent the scroll position from jumping // when removing the node var tbody = (this.dom.tr) ? this.dom.tr.parentNode : undefined; if (tbody) { var trTemp = document.createElement('tr'); trTemp.style.height = tbody.clientHeight + 'px'; tbody.appendChild(trTemp); } if (node.parent) { node.parent.removeChild(node); } if (beforeNode instanceof AppendNode) { this.appendChild(node); } else { this.insertBefore(node, beforeNode); } if (tbody) { tbody.removeChild(trTemp); } } }; /** * Move a node from its current parent to this node * Only applicable when Node value is of type array or object. * If index is out of range, the node will be appended to the end * @param {Node} node * @param {Number} index */ Node.prototype.moveTo = function (node, index) { if (node.parent == this) { // same parent var currentIndex = this.childs.indexOf(node); if (currentIndex < index) { // compensate the index for removal of the node itself index++; } } var beforeNode = this.childs[index] || this.append; this.moveBefore(node, beforeNode); }; /** * Insert a new child before a given node * Only applicable when Node value is of type array or object * @param {Node} node * @param {Node} beforeNode */ Node.prototype.insertBefore = function(node, beforeNode) { if (this._hasChilds()) { if (beforeNode == this.append) { // append to the child nodes // adjust the link to the parent node.setParent(this); node.fieldEditable = (this.type == 'object'); this.childs.push(node); } else { // insert before a child node var index = this.childs.indexOf(beforeNode); if (index == -1) { throw new Error('Node not found'); } // adjust the link to the parent node.setParent(this); node.fieldEditable = (this.type == 'object'); this.childs.splice(index, 0, node); } if (this.expanded) { // insert into the DOM var newTr = node.getDom(); var nextTr = beforeNode.getDom(); var table = nextTr ? nextTr.parentNode : undefined; if (nextTr && table) { table.insertBefore(newTr, nextTr); } node.showChilds(); } this.updateDom({'updateIndexes': true}); node.updateDom({'recurse': true}); } }; /** * Insert a new child before a given node * Only applicable when Node value is of type array or object * @param {Node} node * @param {Node} afterNode */ Node.prototype.insertAfter = function(node, afterNode) { if (this._hasChilds()) { var index = this.childs.indexOf(afterNode); var beforeNode = this.childs[index + 1]; if (beforeNode) { this.insertBefore(node, beforeNode); } else { this.appendChild(node); } } }; /** * Search in this node * The node will be expanded when the text is found one of its childs, else * it will be collapsed. Searches are case insensitive. * @param {String} text * @return {Node[]} results Array with nodes containing the search text */ Node.prototype.search = function(text) { var results = []; var index; var search = text ? text.toLowerCase() : undefined; // delete old search data delete this.searchField; delete this.searchValue; // search in field if (this.field != undefined) { var field = String(this.field).toLowerCase(); index = field.indexOf(search); if (index != -1) { this.searchField = true; results.push({ 'node': this, 'elem': 'field' }); } // update dom this._updateDomField(); } // search in value if (this._hasChilds()) { // array, object // search the nodes childs if (this.childs) { var childResults = []; this.childs.forEach(function (child) { childResults = childResults.concat(child.search(text)); }); results = results.concat(childResults); } // update dom if (search != undefined) { var recurse = false; if (childResults.length == 0) { this.collapse(recurse); } else { this.expand(recurse); } } } else { // string, auto if (this.value != undefined ) { var value = String(this.value).toLowerCase(); index = value.indexOf(search); if (index != -1) { this.searchValue = true; results.push({ 'node': this, 'elem': 'value' }); } } // update dom this._updateDomValue(); } return results; }; /** * Move the scroll position such that this node is in the visible area. * The node will not get the focus * @param {function(boolean)} [callback] */ Node.prototype.scrollTo = function(callback) { if (!this.dom.tr || !this.dom.tr.parentNode) { // if the node is not visible, expand its parents var parent = this.parent; var recurse = false; while (parent) { parent.expand(recurse); parent = parent.parent; } } if (this.dom.tr && this.dom.tr.parentNode) { this.editor.scrollTo(this.dom.tr.offsetTop, callback); } }; // stores the element name currently having the focus Node.focusElement = undefined; /** * Set focus to this node * @param {String} [elementName] The field name of the element to get the * focus available values: 'drag', 'menu', * 'expand', 'field', 'value' (default) */ Node.prototype.focus = function(elementName) { Node.focusElement = elementName; if (this.dom.tr && this.dom.tr.parentNode) { var dom = this.dom; switch (elementName) { case 'drag': if (dom.drag) { dom.drag.focus(); } else { dom.menu.focus(); } break; case 'menu': dom.menu.focus(); break; case 'expand': if (this._hasChilds()) { dom.expand.focus(); } else if (dom.field && this.fieldEditable) { dom.field.focus(); util.selectContentEditable(dom.field); } else if (dom.value && !this._hasChilds()) { dom.value.focus(); util.selectContentEditable(dom.value); } else { dom.menu.focus(); } break; case 'field': if (dom.field && this.fieldEditable) { dom.field.focus(); util.selectContentEditable(dom.field); } else if (dom.value && !this._hasChilds()) { dom.value.focus(); util.selectContentEditable(dom.value); } else if (this._hasChilds()) { dom.expand.focus(); } else { dom.menu.focus(); } break; case 'value': default: if (dom.value && !this._hasChilds()) { dom.value.focus(); util.selectContentEditable(dom.value); } else if (dom.field && this.fieldEditable) { dom.field.focus(); util.selectContentEditable(dom.field); } else if (this._hasChilds()) { dom.expand.focus(); } else { dom.menu.focus(); } break; } } }; /** * Select all text in an editable div after a delay of 0 ms * @param {Element} editableDiv */ Node.select = function(editableDiv) { setTimeout(function () { util.selectContentEditable(editableDiv); }, 0); }; /** * Update the values from the DOM field and value of this node */ Node.prototype.blur = function() { // retrieve the actual field and value from the DOM. this._getDomValue(false); this._getDomField(false); }; /** * Duplicate given child node * new structure will be added right before the cloned node * @param {Node} node the childNode to be duplicated * @return {Node} clone the clone of the node * @private */ Node.prototype._duplicate = function(node) { var clone = node.clone(); /* TODO: adjust the field name (to prevent equal field names) if (this.type == 'object') { } */ this.insertAfter(clone, node); return clone; }; /** * Check if given node is a child. The method will check recursively to find * this node. * @param {Node} node * @return {boolean} containsNode */ Node.prototype.containsNode = function(node) { if (this == node) { return true; } var childs = this.childs; if (childs) { // TODO: use the js5 Array.some() here? for (var i = 0, iMax = childs.length; i < iMax; i++) { if (childs[i].containsNode(node)) { return true; } } } return false; }; /** * Move given node into this node * @param {Node} node the childNode to be moved * @param {Node} beforeNode node will be inserted before given * node. If no beforeNode is given, * the node is appended at the end * @private */ Node.prototype._move = function(node, beforeNode) { if (node == beforeNode) { // nothing to do... return; } // check if this node is not a child of the node to be moved here if (node.containsNode(this)) { throw new Error('Cannot move a field into a child of itself'); } // remove the original node if (node.parent) { node.parent.removeChild(node); } // create a clone of the node var clone = node.clone(); node.clearDom(); // insert or append the node if (beforeNode) { this.insertBefore(clone, beforeNode); } else { this.appendChild(clone); } /* TODO: adjust the field name (to prevent equal field names) if (this.type == 'object') { } */ }; /** * Remove a child from the node. * Only applicable when Node value is of type array or object * @param {Node} node The child node to be removed; * @return {Node | undefined} node The removed node on success, * else undefined */ Node.prototype.removeChild = function(node) { if (this.childs) { var index = this.childs.indexOf(node); if (index != -1) { node.hide(); // delete old search results delete node.searchField; delete node.searchValue; var removedNode = this.childs.splice(index, 1)[0]; this.updateDom({'updateIndexes': true}); return removedNode; } } return undefined; }; /** * Remove a child node node from this node * This method is equal to Node.removeChild, except that _remove firex an * onChange event. * @param {Node} node * @private */ Node.prototype._remove = function (node) { this.removeChild(node); }; /** * Change the type of the value of this Node * @param {String} newType */ Node.prototype.changeType = function (newType) { var oldType = this.type; if (oldType == newType) { // type is not changed return; } if ((newType == 'string' || newType == 'auto') && (oldType == 'string' || oldType == 'auto')) { // this is an easy change this.type = newType; } else { // change from array to object, or from string/auto to object/array var table = this.dom.tr ? this.dom.tr.parentNode : undefined; var lastTr; if (this.expanded) { lastTr = this.getAppend(); } else { lastTr = this.getDom(); } var nextTr = (lastTr && lastTr.parentNode) ? lastTr.nextSibling : undefined; // hide current field and all its childs this.hide(); this.clearDom(); // adjust the field and the value this.type = newType; // adjust childs if (newType == 'object') { if (!this.childs) { this.childs = []; } this.childs.forEach(function (child, index) { child.clearDom(); delete child.index; child.fieldEditable = true; if (child.field == undefined) { child.field = ''; } }); if (oldType == 'string' || oldType == 'auto') { this.expanded = true; } } else if (newType == 'array') { if (!this.childs) { this.childs = []; } this.childs.forEach(function (child, index) { child.clearDom(); child.fieldEditable = false; child.index = index; }); if (oldType == 'string' || oldType == 'auto') { this.expanded = true; } } else { this.expanded = false; } // create new DOM if (table) { if (nextTr) { table.insertBefore(this.getDom(), nextTr); } else { table.appendChild(this.getDom()); } } this.showChilds(); } if (newType == 'auto' || newType == 'string') { // cast value to the correct type if (newType == 'string') { this.value = String(this.value); } else { this.value = this._stringCast(String(this.value)); } this.focus(); } this.updateDom({'updateIndexes': true}); }; /** * Retrieve value from DOM * @param {boolean} [silent] If true (default), no errors will be thrown in * case of invalid data * @private */ Node.prototype._getDomValue = function(silent) { if (this.dom.value && this.type != 'array' && this.type != 'object') { this.valueInnerText = util.getInnerText(this.dom.value); } if (this.valueInnerText != undefined) { try { // retrieve the value var value; if (this.type == 'string') { value = this._unescapeHTML(this.valueInnerText); } else { var str = this._unescapeHTML(this.valueInnerText); value = this._stringCast(str); } if (value !== this.value) { var oldValue = this.value; this.value = value; this.editor._onAction('editValue', { 'node': this, 'oldValue': oldValue, 'newValue': value, 'oldSelection': this.editor.selection, 'newSelection': this.editor.getSelection() }); } } catch (err) { this.value = undefined; // TODO: sent an action with the new, invalid value? if (silent != true) { throw err; } } } }; /** * Update dom value: * - the text color of the value, depending on the type of the value * - the height of the field, depending on the width * - background color in case it is empty * @private */ Node.prototype._updateDomValue = function () { var domValue = this.dom.value; if (domValue) { // set text color depending on value type // TODO: put colors in css var v = this.value; var t = (this.type == 'auto') ? util.type(v) : this.type; var isUrl = (t == 'string' && util.isUrl(v)); var color = ''; if (isUrl && !this.editor.mode.edit) { color = ''; } else if (t == 'string') { color = 'green'; } else if (t == 'number') { color = 'red'; } else if (t == 'boolean') { color = 'darkorange'; } else if (this._hasChilds()) { color = ''; } else if (v === null) { color = '#004ED0'; // blue } else { // invalid value color = 'black'; } domValue.style.color = color; // make background color light-gray when empty var isEmpty = (String(this.value) == '' && this.type != 'array' && this.type != 'object'); if (isEmpty) { util.addClassName(domValue, 'empty'); } else { util.removeClassName(domValue, 'empty'); } // underline url if (isUrl) { util.addClassName(domValue, 'url'); } else { util.removeClassName(domValue, 'url'); } // update title if (t == 'array' || t == 'object') { var count = this.childs ? this.childs.length : 0; domValue.title = this.type + ' containing ' + count + ' items'; } else if (t == 'string' && util.isUrl(v)) { if (this.editor.mode.edit) { domValue.title = 'Ctrl+Click or Ctrl+Enter to open url in new window'; } } else { domValue.title = ''; } // highlight when there is a search result if (this.searchValueActive) { util.addClassName(domValue, 'highlight-active'); } else { util.removeClassName(domValue, 'highlight-active'); } if (this.searchValue) { util.addClassName(domValue, 'highlight'); } else { util.removeClassName(domValue, 'highlight'); } // strip formatting from the contents of the editable div util.stripFormatting(domValue); } }; /** * Update dom field: * - the text color of the field, depending on the text * - the height of the field, depending on the width * - background color in case it is empty * @private */ Node.prototype._updateDomField = function () { var domField = this.dom.field; if (domField) { // make backgound color lightgray when empty var isEmpty = (String(this.field) == '' && this.parent.type != 'array'); if (isEmpty) { util.addClassName(domField, 'empty'); } else { util.removeClassName(domField, 'empty'); } // highlight when there is a search result if (this.searchFieldActive) { util.addClassName(domField, 'highlight-active'); } else { util.removeClassName(domField, 'highlight-active'); } if (this.searchField) { util.addClassName(domField, 'highlight'); } else { util.removeClassName(domField, 'highlight'); } // strip formatting from the contents of the editable div util.stripFormatting(domField); } }; /** * Retrieve field from DOM * @param {boolean} [silent] If true (default), no errors will be thrown in * case of invalid data * @private */ Node.prototype._getDomField = function(silent) { if (this.dom.field && this.fieldEditable) { this.fieldInnerText = util.getInnerText(this.dom.field); } if (this.fieldInnerText != undefined) { try { var field = this._unescapeHTML(this.fieldInnerText); if (field !== this.field) { var oldField = this.field; this.field = field; this.editor._onAction('editField', { 'node': this, 'oldValue': oldField, 'newValue': field, 'oldSelection': this.editor.selection, 'newSelection': this.editor.getSelection() }); } } catch (err) { this.field = undefined; // TODO: sent an action here, with the new, invalid value? if (silent != true) { throw err; } } } }; /** * Clear the dom of the node */ Node.prototype.clearDom = function() { // TODO: hide the node first? //this.hide(); // TODO: recursively clear dom? this.dom = {}; }; /** * Get the HTML DOM TR element of the node. * The dom will be generated when not yet created * @return {Element} tr HTML DOM TR Element */ Node.prototype.getDom = function() { var dom = this.dom; if (dom.tr) { return dom.tr; } // create row dom.tr = document.createElement('tr'); dom.tr.node = this; if (this.editor.mode.edit) { // create draggable area var tdDrag = document.createElement('td'); if (this.parent) { var domDrag = document.createElement('button'); dom.drag = domDrag; domDrag.className = 'dragarea'; domDrag.title = 'Drag to move this field (Alt+Shift+Arrows)'; tdDrag.appendChild(domDrag); } dom.tr.appendChild(tdDrag); // create context menu var tdMenu = document.createElement('td'); var menu = document.createElement('button'); dom.menu = menu; menu.className = 'contextmenu'; menu.title = 'Click to open the actions menu (Ctrl+M)'; tdMenu.appendChild(dom.menu); dom.tr.appendChild(tdMenu); } // create tree and field var tdField = document.createElement('td'); dom.tr.appendChild(tdField); dom.tree = this._createDomTree(); tdField.appendChild(dom.tree); this.updateDom({'updateIndexes': true}); return dom.tr; }; /** * DragStart event, fired on mousedown on the dragarea at the left side of a Node * @param {Event} event * @private */ Node.prototype._onDragStart = function (event) { event = event || window.event; var node = this; if (!this.mousemove) { this.mousemove = util.addEventListener(document, 'mousemove', function (event) { node._onDrag(event); }); } if (!this.mouseup) { this.mouseup = util.addEventListener(document, 'mouseup', function (event ) { node._onDragEnd(event); }); } this.editor.highlighter.lock(); this.drag = { 'oldCursor': document.body.style.cursor, 'startParent': this.parent, 'startIndex': this.parent.childs.indexOf(this), 'mouseX': util.getMouseX(event), 'level': this.getLevel() }; document.body.style.cursor = 'move'; util.preventDefault(event); }; /** * Drag event, fired when moving the mouse while dragging a Node * @param {Event} event * @private */ Node.prototype._onDrag = function (event) { // TODO: this method has grown too large. Split it in a number of methods event = event || window.event; var mouseY = util.getMouseY(event); var mouseX = util.getMouseX(event); var trThis, trPrev, trNext, trFirst, trLast, trRoot; var nodePrev, nodeNext; var topThis, topPrev, topFirst, heightThis, bottomNext, heightNext; var moved = false; // TODO: add an ESC option, which resets to the original position // move up/down trThis = this.dom.tr; topThis = util.getAbsoluteTop(trThis); heightThis = trThis.offsetHeight; if (mouseY < topThis) { // move up trPrev = trThis; do { trPrev = trPrev.previousSibling; nodePrev = Node.getNodeFromTarget(trPrev); topPrev = trPrev ? util.getAbsoluteTop(trPrev) : 0; } while (trPrev && mouseY < topPrev); if (nodePrev && !nodePrev.parent) { nodePrev = undefined; } if (!nodePrev) { // move to the first node trRoot = trThis.parentNode.firstChild; trPrev = trRoot ? trRoot.nextSibling : undefined; nodePrev = Node.getNodeFromTarget(trPrev); if (nodePrev == this) { nodePrev = undefined; } } if (nodePrev) { // check if mouseY is really inside the found node trPrev = nodePrev.dom.tr; topPrev = trPrev ? util.getAbsoluteTop(trPrev) : 0; if (mouseY > topPrev + heightThis) { nodePrev = undefined; } } if (nodePrev) { nodePrev.parent.moveBefore(this, nodePrev); moved = true; } } else { // move down trLast = (this.expanded && this.append) ? this.append.getDom() : this.dom.tr; trFirst = trLast ? trLast.nextSibling : undefined; if (trFirst) { topFirst = util.getAbsoluteTop(trFirst); trNext = trFirst; do { nodeNext = Node.getNodeFromTarget(trNext); if (trNext) { bottomNext = trNext.nextSibling ? util.getAbsoluteTop(trNext.nextSibling) : 0; heightNext = trNext ? (bottomNext - topFirst) : 0; if (nodeNext.parent.childs.length == 1 && nodeNext.parent.childs[0] == this) { // We are about to remove the last child of this parent, // which will make the parents appendNode visible. topThis += 24 - 1; // TODO: dangerous to suppose the height of the appendNode a constant of 24-1 px. } } trNext = trNext.nextSibling; } while (trNext && mouseY > topThis + heightNext); if (nodeNext && nodeNext.parent) { // calculate the desired level var diffX = (mouseX - this.drag.mouseX); var diffLevel = Math.round(diffX / 24 / 2); var level = this.drag.level + diffLevel; // desired level var levelNext = nodeNext.getLevel(); // level to be // find the best fitting level (move upwards over the append nodes) trPrev = nodeNext.dom.tr.previousSibling; while (levelNext < level && trPrev) { nodePrev = Node.getNodeFromTarget(trPrev); if (nodePrev == this || nodePrev._isChildOf(this)) { // neglect itself and its childs } else if (nodePrev instanceof AppendNode) { var childs = nodePrev.parent.childs; if (childs.length > 1 || (childs.length == 1 && childs[0] != this)) { // non-visible append node of a list of childs // consisting of not only this node (else the // append node will change into a visible "empty" // text when removing this node). nodeNext = Node.getNodeFromTarget(trPrev); levelNext = nodeNext.getLevel(); } else { break; } } else { break; } trPrev = trPrev.previousSibling; } // move the node when its position is changed if (trLast.nextSibling != nodeNext.dom.tr) { nodeNext.parent.moveBefore(this, nodeNext); moved = true; } } } } if (moved) { // update the dragging parameters when moved this.drag.mouseX = mouseX; this.drag.level = this.getLevel(); } // auto scroll when hovering around the top of the editor this.editor.startAutoScroll(mouseY); util.preventDefault(event); }; /** * Drag event, fired on mouseup after having dragged a node * @param {Event} event * @private */ Node.prototype._onDragEnd = function (event) { event = event || window.event; var params = { 'node': this, 'startParent': this.drag.startParent, 'startIndex': this.drag.startIndex, 'endParent': this.parent, 'endIndex': this.parent.childs.indexOf(this) }; if ((params.startParent != params.endParent) || (params.startIndex != params.endIndex)) { // only register this action if the node is actually moved to another place this.editor._onAction('moveNode', params); } document.body.style.cursor = this.drag.oldCursor; this.editor.highlighter.unlock(); delete this.drag; if (this.mousemove) { util.removeEventListener(document, 'mousemove', this.mousemove); delete this.mousemove;} if (this.mouseup) { util.removeEventListener(document, 'mouseup', this.mouseup); delete this.mouseup; } // Stop any running auto scroll this.editor.stopAutoScroll(); util.preventDefault(event); }; /** * Test if this node is a child of an other node * @param {Node} node * @return {boolean} isChild * @private */ Node.prototype._isChildOf = function (node) { var n = this.parent; while (n) { if (n == node) { return true; } n = n.parent; } return false; }; /** * Create an editable field * @return {Element} domField * @private */ Node.prototype._createDomField = function () { return document.createElement('div'); }; /** * Set highlighting for this node and all its childs. * Only applied to the currently visible (expanded childs) * @param {boolean} highlight */ Node.prototype.setHighlight = function (highlight) { if (this.dom.tr) { this.dom.tr.className = (highlight ? 'highlight' : ''); if (this.append) { this.append.setHighlight(highlight); } if (this.childs) { this.childs.forEach(function (child) { child.setHighlight(highlight); }); } } }; /** * Update the value of the node. Only primitive types are allowed, no Object * or Array is allowed. * @param {String | Number | Boolean | null} value */ Node.prototype.updateValue = function (value) { this.value = value; this.updateDom(); }; /** * Update the field of the node. * @param {String} field */ Node.prototype.updateField = function (field) { this.field = field; this.updateDom(); }; /** * Update the HTML DOM, optionally recursing through the childs * @param {Object} [options] Available parameters: * {boolean} [recurse] If true, the * DOM of the childs will be updated recursively. * False by default. * {boolean} [updateIndexes] If true, the childs * indexes of the node will be updated too. False by * default. */ Node.prototype.updateDom = function (options) { // update level indentation var domTree = this.dom.tree; if (domTree) { domTree.style.marginLeft = this.getLevel() * 24 + 'px'; } // update field var domField = this.dom.field; if (domField) { if (this.fieldEditable == true) { // parent is an object domField.contentEditable = this.editor.mode.edit; domField.spellcheck = false; domField.className = 'field'; } else { // parent is an array this is the root node domField.className = 'readonly'; } var field; if (this.index != undefined) { field = this.index; } else if (this.field != undefined) { field = this.field; } else if (this._hasChilds()) { field = this.type; } else { field = ''; } domField.innerHTML = this._escapeHTML(field); } // update value var domValue = this.dom.value; if (domValue) { var count = this.childs ? this.childs.length : 0; if (this.type == 'array') { domValue.innerHTML = '[' + count + ']'; } else if (this.type == 'object') { domValue.innerHTML = '{' + count + '}'; } else { domValue.innerHTML = this._escapeHTML(this.value); } } // update field and value this._updateDomField(); this._updateDomValue(); // update childs indexes if (options && options.updateIndexes == true) { // updateIndexes is true or undefined this._updateDomIndexes(); } if (options && options.recurse == true) { // recurse is true or undefined. update childs recursively if (this.childs) { this.childs.forEach(function (child) { child.updateDom(options); }); } } // update row with append button if (this.append) { this.append.updateDom(); } }; /** * Update the DOM of the childs of a node: update indexes and undefined field * names. * Only applicable when structure is an array or object * @private */ Node.prototype._updateDomIndexes = function () { var domValue = this.dom.value; var childs = this.childs; if (domValue && childs) { if (this.type == 'array') { childs.forEach(function (child, index) { child.index = index; var childField = child.dom.field; if (childField) { childField.innerHTML = index; } }); } else if (this.type == 'object') { childs.forEach(function (child) { if (child.index != undefined) { delete child.index; if (child.field == undefined) { child.field = ''; } } }); } } }; /** * Create an editable value * @private */ Node.prototype._createDomValue = function () { var domValue; if (this.type == 'array') { domValue = document.createElement('div'); domValue.className = 'readonly'; domValue.innerHTML = '[...]'; } else if (this.type == 'object') { domValue = document.createElement('div'); domValue.className = 'readonly'; domValue.innerHTML = '{...}'; } else { if (!this.editor.mode.edit && util.isUrl(this.value)) { // create a link in case of read-only editor and value containing an url domValue = document.createElement('a'); domValue.className = 'value'; domValue.href = this.value; domValue.target = '_blank'; domValue.innerHTML = this._escapeHTML(this.value); } else { // create and editable or read-only div domValue = document.createElement('div'); domValue.contentEditable = !this.editor.mode.view; domValue.spellcheck = false; domValue.className = 'value'; domValue.innerHTML = this._escapeHTML(this.value); } } return domValue; }; /** * Create an expand/collapse button * @return {Element} expand * @private */ Node.prototype._createDomExpandButton = function () { // create expand button var expand = document.createElement('button'); if (this._hasChilds()) { expand.className = this.expanded ? 'expanded' : 'collapsed'; expand.title = 'Click to expand/collapse this field (Ctrl+E). \n' + 'Ctrl+Click to expand/collapse including all childs.'; } else { expand.className = 'invisible'; expand.title = ''; } return expand; }; /** * Create a DOM tree element, containing the expand/collapse button * @return {Element} domTree * @private */ Node.prototype._createDomTree = function () { var dom = this.dom; var domTree = document.createElement('table'); var tbody = document.createElement('tbody'); domTree.style.borderCollapse = 'collapse'; // TODO: put in css domTree.className = 'values'; domTree.appendChild(tbody); var tr = document.createElement('tr'); tbody.appendChild(tr); // create expand button var tdExpand = document.createElement('td'); tdExpand.className = 'tree'; tr.appendChild(tdExpand); dom.expand = this._createDomExpandButton(); tdExpand.appendChild(dom.expand); dom.tdExpand = tdExpand; // create the field var tdField = document.createElement('td'); tdField.className = 'tree'; tr.appendChild(tdField); dom.field = this._createDomField(); tdField.appendChild(dom.field); dom.tdField = tdField; // create a separator var tdSeparator = document.createElement('td'); tdSeparator.className = 'tree'; tr.appendChild(tdSeparator); if (this.type != 'object' && this.type != 'array') { tdSeparator.appendChild(document.createTextNode(':')); tdSeparator.className = 'separator'; } dom.tdSeparator = tdSeparator; // create the value var tdValue = document.createElement('td'); tdValue.className = 'tree'; tr.appendChild(tdValue); dom.value = this._createDomValue(); tdValue.appendChild(dom.value); dom.tdValue = tdValue; return domTree; }; /** * Handle an event. The event is catched centrally by the editor * @param {Event} event */ Node.prototype.onEvent = function (event) { var type = event.type, target = event.target || event.srcElement, dom = this.dom, node = this, focusNode, expandable = this._hasChilds(); // check if mouse is on menu or on dragarea. // If so, highlight current row and its childs if (target == dom.drag || target == dom.menu) { if (type == 'mouseover') { this.editor.highlighter.highlight(this); } else if (type == 'mouseout') { this.editor.highlighter.unhighlight(); } } // drag events if (type == 'mousedown' && target == dom.drag) { this._onDragStart(event); } // context menu events if (type == 'click' && target == dom.menu) { var highlighter = node.editor.highlighter; highlighter.highlight(node); highlighter.lock(); util.addClassName(dom.menu, 'selected'); this.showContextMenu(dom.menu, function () { util.removeClassName(dom.menu, 'selected'); highlighter.unlock(); highlighter.unhighlight(); }); } // expand events if (type == 'click' && target == dom.expand) { if (expandable) { var recurse = event.ctrlKey; // with ctrl-key, expand/collapse all this._onExpand(recurse); } } // value events var domValue = dom.value; if (target == domValue) { //noinspection FallthroughInSwitchStatementJS switch (type) { case 'focus': focusNode = this; break; case 'blur': case 'change': this._getDomValue(true); this._updateDomValue(); if (this.value) { domValue.innerHTML = this._escapeHTML(this.value); } break; case 'input': this._getDomValue(true); this._updateDomValue(); break; case 'keydown': case 'mousedown': this.editor.selection = this.editor.getSelection(); break; case 'click': if (event.ctrlKey && this.editor.mode.edit) { if (util.isUrl(this.value)) { window.open(this.value, '_blank'); } } break; case 'keyup': this._getDomValue(true); this._updateDomValue(); break; case 'cut': case 'paste': setTimeout(function () { node._getDomValue(true); node._updateDomValue(); }, 1); break; } } // field events var domField = dom.field; if (target == domField) { switch (type) { case 'focus': focusNode = this; break; case 'blur': case 'change': this._getDomField(true); this._updateDomField(); if (this.field) { domField.innerHTML = this._escapeHTML(this.field); } break; case 'input': this._getDomField(true); this._updateDomField(); break; case 'keydown': case 'mousedown': this.editor.selection = this.editor.getSelection(); break; case 'keyup': this._getDomField(true); this._updateDomField(); break; case 'cut': case 'paste': setTimeout(function () { node._getDomField(true); node._updateDomField(); }, 1); break; } } // focus // when clicked in whitespace left or right from the field or value, set focus var domTree = dom.tree; if (target == domTree.parentNode) { switch (type) { case 'click': var left = (event.offsetX != undefined) ? (event.offsetX < (this.getLevel() + 1) * 24) : (util.getMouseX(event) < util.getAbsoluteLeft(dom.tdSeparator));// for FF if (left || expandable) { // node is expandable when it is an object or array if (domField) { util.setEndOfContentEditable(domField); domField.focus(); } } else { if (domValue) { util.setEndOfContentEditable(domValue); domValue.focus(); } } break; } } if ((target == dom.tdExpand && !expandable) || target == dom.tdField || target == dom.tdSeparator) { switch (type) { case 'click': if (domField) { util.setEndOfContentEditable(domField); domField.focus(); } break; } } if (type == 'keydown') { this.onKeyDown(event); } }; /** * Key down event handler * @param {Event} event */ Node.prototype.onKeyDown = function (event) { var keynum = event.which || event.keyCode; var target = event.target || event.srcElement; var ctrlKey = event.ctrlKey; var shiftKey = event.shiftKey; var altKey = event.altKey; var handled = false; var prevNode, nextNode, nextDom, nextDom2; // util.log(ctrlKey, keynum, event.charCode); // TODO: cleanup if (keynum == 13) { // Enter if (target == this.dom.value) { if (!this.editor.mode.edit || event.ctrlKey) { if (util.isUrl(this.value)) { window.open(this.value, '_blank'); handled = true; } } } else if (target == this.dom.expand) { var expandable = this._hasChilds(); if (expandable) { var recurse = event.ctrlKey; // with ctrl-key, expand/collapse all this._onExpand(recurse); target.focus(); handled = true; } } } else if (keynum == 68) { // D if (ctrlKey) { // Ctrl+D this._onDuplicate(); handled = true; } } else if (keynum == 69) { // E if (ctrlKey) { // Ctrl+E and Ctrl+Shift+E this._onExpand(shiftKey); // recurse = shiftKey target.focus(); // TODO: should restore focus in case of recursing expand (which takes DOM offline) handled = true; } } else if (keynum == 77) { // M if (ctrlKey) { // Ctrl+M this.showContextMenu(target); handled = true; } } else if (keynum == 46) { // Del if (ctrlKey) { // Ctrl+Del this._onRemove(); handled = true; } } else if (keynum == 45) { // Ins if (ctrlKey && !shiftKey) { // Ctrl+Ins this._onInsertBefore(); handled = true; } else if (ctrlKey && shiftKey) { // Ctrl+Shift+Ins this._onInsertAfter(); handled = true; } } else if (keynum == 35) { // End if (altKey) { // Alt+End // find the last node var lastNode = this._lastNode(); if (lastNode) { lastNode.focus(Node.focusElement || this._getElementName(target)); } handled = true; } } else if (keynum == 36) { // Home if (altKey) { // Alt+Home // find the first node var firstNode = this._firstNode(); if (firstNode) { firstNode.focus(Node.focusElement || this._getElementName(target)); } handled = true; } } else if (keynum == 37) { // Arrow Left if (altKey && !shiftKey) { // Alt + Arrow Left // move to left element var prevElement = this._previousElement(target); if (prevElement) { this.focus(this._getElementName(prevElement)); } handled = true; } else if (altKey && shiftKey) { // Alt + Shift Arrow left if (this.expanded) { var appendDom = this.getAppend(); nextDom = appendDom ? appendDom.nextSibling : undefined; } else { var dom = this.getDom(); nextDom = dom.nextSibling; } if (nextDom) { nextNode = Node.getNodeFromTarget(nextDom); nextDom2 = nextDom.nextSibling; nextNode2 = Node.getNodeFromTarget(nextDom2); if (nextNode && nextNode instanceof AppendNode && !(this.parent.childs.length == 1) && nextNode2 && nextNode2.parent) { nextNode2.parent.moveBefore(this, nextNode2); this.focus(Node.focusElement || this._getElementName(target)); } } } } else if (keynum == 38) { // Arrow Up if (altKey && !shiftKey) { // Alt + Arrow Up // find the previous node prevNode = this._previousNode(); if (prevNode) { prevNode.focus(Node.focusElement || this._getElementName(target)); } handled = true; } else if (altKey && shiftKey) { // Alt + Shift + Arrow Up // find the previous node prevNode = this._previousNode(); if (prevNode && prevNode.parent) { prevNode.parent.moveBefore(this, prevNode); this.focus(Node.focusElement || this._getElementName(target)); } handled = true; } } else if (keynum == 39) { // Arrow Right if (altKey && !shiftKey) { // Alt + Arrow Right // move to right element var nextElement = this._nextElement(target); if (nextElement) { this.focus(this._getElementName(nextElement)); } handled = true; } else if (altKey && shiftKey) { // Alt + Shift Arrow Right dom = this.getDom(); var prevDom = dom.previousSibling; if (prevDom) { prevNode = Node.getNodeFromTarget(prevDom); if (prevNode && prevNode.parent && (prevNode instanceof AppendNode) && !prevNode.isVisible()) { prevNode.parent.moveBefore(this, prevNode); this.focus(Node.focusElement || this._getElementName(target)); } } } } else if (keynum == 40) { // Arrow Down if (altKey && !shiftKey) { // Alt + Arrow Down // find the next node nextNode = this._nextNode(); if (nextNode) { nextNode.focus(Node.focusElement || this._getElementName(target)); } handled = true; } else if (altKey && shiftKey) { // Alt + Shift + Arrow Down // find the 2nd next node and move before that one if (this.expanded) { nextNode = this.append ? this.append._nextNode() : undefined; } else { nextNode = this._nextNode(); } nextDom = nextNode ? nextNode.getDom() : undefined; if (this.parent.childs.length == 1) { nextDom2 = nextDom; } else { nextDom2 = nextDom ? nextDom.nextSibling : undefined; } var nextNode2 = Node.getNodeFromTarget(nextDom2); if (nextNode2 && nextNode2.parent) { nextNode2.parent.moveBefore(this, nextNode2); this.focus(Node.focusElement || this._getElementName(target)); } handled = true; } } if (handled) { util.preventDefault(event); util.stopPropagation(event); } }; /** * Handle the expand event, when clicked on the expand button * @param {boolean} recurse If true, child nodes will be expanded too * @private */ Node.prototype._onExpand = function (recurse) { if (recurse) { // Take the table offline var table = this.dom.tr.parentNode; // TODO: not nice to access the main table like this var frame = table.parentNode; var scrollTop = frame.scrollTop; frame.removeChild(table); } if (this.expanded) { this.collapse(recurse); } else { this.expand(recurse); } if (recurse) { // Put the table online again frame.appendChild(table); frame.scrollTop = scrollTop; } }; /** * Remove this node * @private */ Node.prototype._onRemove = function() { this.editor.highlighter.unhighlight(); var childs = this.parent.childs; var index = childs.indexOf(this); // adjust the focus var oldSelection = this.editor.getSelection(); if (childs[index + 1]) { childs[index + 1].focus(); } else if (childs[index - 1]) { childs[index - 1].focus(); } else { this.parent.focus(); } var newSelection = this.editor.getSelection(); // remove the node this.parent._remove(this); // store history action this.editor._onAction('removeNode', { 'node': this, 'parent': this.parent, 'index': index, 'oldSelection': oldSelection, 'newSelection': newSelection }); }; /** * Duplicate this node * @private */ Node.prototype._onDuplicate = function() { var oldSelection = this.editor.getSelection(); var clone = this.parent._duplicate(this); clone.focus(); var newSelection = this.editor.getSelection(); this.editor._onAction('duplicateNode', { 'node': this, 'clone': clone, 'parent': this.parent, 'oldSelection': oldSelection, 'newSelection': newSelection }); }; /** * Handle insert before event * @param {String} [field] * @param {*} [value] * @param {String} [type] Can be 'auto', 'array', 'object', or 'string' * @private */ Node.prototype._onInsertBefore = function (field, value, type) { var oldSelection = this.editor.getSelection(); var newNode = new Node(this.editor, { 'field': (field != undefined) ? field : '', 'value': (value != undefined) ? value : '', 'type': type }); newNode.expand(true); this.parent.insertBefore(newNode, this); this.editor.highlighter.unhighlight(); newNode.focus('field'); var newSelection = this.editor.getSelection(); this.editor._onAction('insertBeforeNode', { 'node': newNode, 'beforeNode': this, 'parent': this.parent, 'oldSelection': oldSelection, 'newSelection': newSelection }); }; /** * Handle insert after event * @param {String} [field] * @param {*} [value] * @param {String} [type] Can be 'auto', 'array', 'object', or 'string' * @private */ Node.prototype._onInsertAfter = function (field, value, type) { var oldSelection = this.editor.getSelection(); var newNode = new Node(this.editor, { 'field': (field != undefined) ? field : '', 'value': (value != undefined) ? value : '', 'type': type }); newNode.expand(true); this.parent.insertAfter(newNode, this); this.editor.highlighter.unhighlight(); newNode.focus('field'); var newSelection = this.editor.getSelection(); this.editor._onAction('insertAfterNode', { 'node': newNode, 'afterNode': this, 'parent': this.parent, 'oldSelection': oldSelection, 'newSelection': newSelection }); }; /** * Handle append event * @param {String} [field] * @param {*} [value] * @param {String} [type] Can be 'auto', 'array', 'object', or 'string' * @private */ Node.prototype._onAppend = function (field, value, type) { var oldSelection = this.editor.getSelection(); var newNode = new Node(this.editor, { 'field': (field != undefined) ? field : '', 'value': (value != undefined) ? value : '', 'type': type }); newNode.expand(true); this.parent.appendChild(newNode); this.editor.highlighter.unhighlight(); newNode.focus('field'); var newSelection = this.editor.getSelection(); this.editor._onAction('appendNode', { 'node': newNode, 'parent': this.parent, 'oldSelection': oldSelection, 'newSelection': newSelection }); }; /** * Change the type of the node's value * @param {String} newType * @private */ Node.prototype._onChangeType = function (newType) { var oldType = this.type; if (newType != oldType) { var oldSelection = this.editor.getSelection(); this.changeType(newType); var newSelection = this.editor.getSelection(); this.editor._onAction('changeType', { 'node': this, 'oldType': oldType, 'newType': newType, 'oldSelection': oldSelection, 'newSelection': newSelection }); } }; /** * Sort the childs of the node. Only applicable when the node has type 'object' * or 'array'. * @param {String} direction Sorting direction. Available values: "asc", "desc" * @private */ Node.prototype._onSort = function (direction) { if (this._hasChilds()) { var order = (direction == 'desc') ? -1 : 1; var prop = (this.type == 'array') ? 'value': 'field'; this.hideChilds(); var oldChilds = this.childs; var oldSort = this.sort; // copy the array (the old one will be kept for an undo action this.childs = this.childs.concat(); // sort the arrays this.childs.sort(function (a, b) { if (a[prop] > b[prop]) return order; if (a[prop] < b[prop]) return -order; return 0; }); this.sort = (order == 1) ? 'asc' : 'desc'; this.editor._onAction('sort', { 'node': this, 'oldChilds': oldChilds, 'oldSort': oldSort, 'newChilds': this.childs, 'newSort': this.sort }); this.showChilds(); } }; /** * Create a table row with an append button. * @return {HTMLElement | undefined} buttonAppend or undefined when inapplicable */ Node.prototype.getAppend = function () { if (!this.append) { this.append = new AppendNode(this.editor); this.append.setParent(this); } return this.append.getDom(); }; /** * Find the node from an event target * @param {Node} target * @return {Node | undefined} node or undefined when not found * @static */ Node.getNodeFromTarget = function (target) { while (target) { if (target.node) { return target.node; } target = target.parentNode; } return undefined; }; /** * Get the previously rendered node * @return {Node | null} previousNode * @private */ Node.prototype._previousNode = function () { var prevNode = null; var dom = this.getDom(); if (dom && dom.parentNode) { // find the previous field var prevDom = dom; do { prevDom = prevDom.previousSibling; prevNode = Node.getNodeFromTarget(prevDom); } while (prevDom && (prevNode instanceof AppendNode && !prevNode.isVisible())); } return prevNode; }; /** * Get the next rendered node * @return {Node | null} nextNode * @private */ Node.prototype._nextNode = function () { var nextNode = null; var dom = this.getDom(); if (dom && dom.parentNode) { // find the previous field var nextDom = dom; do { nextDom = nextDom.nextSibling; nextNode = Node.getNodeFromTarget(nextDom); } while (nextDom && (nextNode instanceof AppendNode && !nextNode.isVisible())); } return nextNode; }; /** * Get the first rendered node * @return {Node | null} firstNode * @private */ Node.prototype._firstNode = function () { var firstNode = null; var dom = this.getDom(); if (dom && dom.parentNode) { var firstDom = dom.parentNode.firstChild; firstNode = Node.getNodeFromTarget(firstDom); } return firstNode; }; /** * Get the last rendered node * @return {Node | null} lastNode * @private */ Node.prototype._lastNode = function () { var lastNode = null; var dom = this.getDom(); if (dom && dom.parentNode) { var lastDom = dom.parentNode.lastChild; lastNode = Node.getNodeFromTarget(lastDom); while (lastDom && (lastNode instanceof AppendNode && !lastNode.isVisible())) { lastDom = lastDom.previousSibling; lastNode = Node.getNodeFromTarget(lastDom); } } return lastNode; }; /** * Get the next element which can have focus. * @param {Element} elem * @return {Element | null} nextElem * @private */ Node.prototype._previousElement = function (elem) { var dom = this.dom; // noinspection FallthroughInSwitchStatementJS switch (elem) { case dom.value: if (this.fieldEditable) { return dom.field; } // intentional fall through case dom.field: if (this._hasChilds()) { return dom.expand; } // intentional fall through case dom.expand: return dom.menu; case dom.menu: if (dom.drag) { return dom.drag; } // intentional fall through default: return null; } }; /** * Get the next element which can have focus. * @param {Element} elem * @return {Element | null} nextElem * @private */ Node.prototype._nextElement = function (elem) { var dom = this.dom; // noinspection FallthroughInSwitchStatementJS switch (elem) { case dom.drag: return dom.menu; case dom.menu: if (this._hasChilds()) { return dom.expand; } // intentional fall through case dom.expand: if (this.fieldEditable) { return dom.field; } // intentional fall through case dom.field: if (!this._hasChilds()) { return dom.value; } default: return null; } }; /** * Get the dom name of given element. returns null if not found. * For example when element == dom.field, "field" is returned. * @param {Element} element * @return {String | null} elementName Available elements with name: 'drag', * 'menu', 'expand', 'field', 'value' * @private */ Node.prototype._getElementName = function (element) { var dom = this.dom; for (var name in dom) { if (dom.hasOwnProperty(name)) { if (dom[name] == element) { return name; } } } return null; }; /** * Test if this node has childs. This is the case when the node is an object * or array. * @return {boolean} hasChilds * @private */ Node.prototype._hasChilds = function () { return this.type == 'array' || this.type == 'object'; }; // titles with explanation for the different types Node.TYPE_TITLES = { 'auto': 'Field type "auto". ' + 'The field type is automatically determined from the value ' + 'and can be a string, number, boolean, or null.', 'object': 'Field type "object". ' + 'An object contains an unordered set of key/value pairs.', 'array': 'Field type "array". ' + 'An array contains an ordered collection of values.', 'string': 'Field type "string". ' + 'Field type is not determined from the value, ' + 'but always returned as string.' }; /** * Show a contextmenu for this node * @param {HTMLElement} anchor Anchor element to attache the context menu to. * @param {function} [onClose] Callback method called when the context menu * is being closed. */ Node.prototype.showContextMenu = function (anchor, onClose) { var node = this; var titles = Node.TYPE_TITLES; var items = []; items.push({ 'text': 'Type', 'title': 'Change the type of this field', 'className': 'type-' + this.type, 'submenu': [ { 'text': 'Auto', 'className': 'type-auto' + (this.type == 'auto' ? ' selected' : ''), 'title': titles.auto, 'click': function () { node._onChangeType('auto'); } }, { 'text': 'Array', 'className': 'type-array' + (this.type == 'array' ? ' selected' : ''), 'title': titles.array, 'click': function () { node._onChangeType('array'); } }, { 'text': 'Object', 'className': 'type-object' + (this.type == 'object' ? ' selected' : ''), 'title': titles.object, 'click': function () { node._onChangeType('object'); } }, { 'text': 'String', 'className': 'type-string' + (this.type == 'string' ? ' selected' : ''), 'title': titles.string, 'click': function () { node._onChangeType('string'); } } ] }); if (this._hasChilds()) { var direction = ((this.sort == 'asc') ? 'desc': 'asc'); items.push({ 'text': 'Sort', 'title': 'Sort the childs of this ' + this.type, 'className': 'sort-' + direction, 'click': function () { node._onSort(direction); }, 'submenu': [ { 'text': 'Ascending', 'className': 'sort-asc', 'title': 'Sort the childs of this ' + this.type + ' in ascending order', 'click': function () { node._onSort('asc'); } }, { 'text': 'Descending', 'className': 'sort-desc', 'title': 'Sort the childs of this ' + this.type +' in descending order', 'click': function () { node._onSort('desc'); } } ] }); } if (this.parent && this.parent._hasChilds()) { // create a separator items.push({ 'type': 'separator' }); // create append button (for last child node only) var childs = node.parent.childs; if (node == childs[childs.length - 1]) { items.push({ 'text': 'Append', 'title': 'Append a new field with type \'auto\' after this field (Ctrl+Shift+Ins)', 'submenuTitle': 'Select the type of the field to be appended', 'className': 'append', 'click': function () { node._onAppend('', '', 'auto'); }, 'submenu': [ { 'text': 'Auto', 'className': 'type-auto', 'title': titles.auto, 'click': function () { node._onAppend('', '', 'auto'); } }, { 'text': 'Array', 'className': 'type-array', 'title': titles.array, 'click': function () { node._onAppend('', []); } }, { 'text': 'Object', 'className': 'type-object', 'title': titles.object, 'click': function () { node._onAppend('', {}); } }, { 'text': 'String', 'className': 'type-string', 'title': titles.string, 'click': function () { node._onAppend('', '', 'string'); } } ] }); } // create insert button items.push({ 'text': 'Insert', 'title': 'Insert a new field with type \'auto\' before this field (Ctrl+Ins)', 'submenuTitle': 'Select the type of the field to be inserted', 'className': 'insert', 'click': function () { node._onInsertBefore('', '', 'auto'); }, 'submenu': [ { 'text': 'Auto', 'className': 'type-auto', 'title': titles.auto, 'click': function () { node._onInsertBefore('', '', 'auto'); } }, { 'text': 'Array', 'className': 'type-array', 'title': titles.array, 'click': function () { node._onInsertBefore('', []); } }, { 'text': 'Object', 'className': 'type-object', 'title': titles.object, 'click': function () { node._onInsertBefore('', {}); } }, { 'text': 'String', 'className': 'type-string', 'title': titles.string, 'click': function () { node._onInsertBefore('', '', 'string'); } } ] }); // create duplicate button items.push({ 'text': 'Duplicate', 'title': 'Duplicate this field (Ctrl+D)', 'className': 'duplicate', 'click': function () { node._onDuplicate(); } }); // create remove button items.push({ 'text': 'Remove', 'title': 'Remove this field (Ctrl+Del)', 'className': 'remove', 'click': function () { node._onRemove(); } }); } var menu = new ContextMenu(items, {close: onClose}); menu.show(anchor); }; /** * get the type of a value * @param {*} value * @return {String} type Can be 'object', 'array', 'string', 'auto' * @private */ Node.prototype._getType = function(value) { if (value instanceof Array) { return 'array'; } if (value instanceof Object) { return 'object'; } if (typeof(value) == 'string' && typeof(this._stringCast(value)) != 'string') { return 'string'; } return 'auto'; }; /** * cast contents of a string to the correct type. This can be a string, * a number, a boolean, etc * @param {String} str * @return {*} castedStr * @private */ Node.prototype._stringCast = function(str) { var lower = str.toLowerCase(), num = Number(str), // will nicely fail with '123ab' numFloat = parseFloat(str); // will nicely fail with ' ' if (str == '') { return ''; } else if (lower == 'null') { return null; } else if (lower == 'true') { return true; } else if (lower == 'false') { return false; } else if (!isNaN(num) && !isNaN(numFloat)) { return num; } else { return str; } }; /** * escape a text, such that it can be displayed safely in an HTML element * @param {String} text * @return {String} escapedText * @private */ Node.prototype._escapeHTML = function (text) { var htmlEscaped = String(text) .replace(//g, '>') .replace(/ /g, '  ') // replace double space with an nbsp and space .replace(/^ /, ' ') // space at start .replace(/ $/, ' '); // space at end var json = JSON.stringify(htmlEscaped); return json.substring(1, json.length - 1); }; /** * unescape a string. * @param {String} escapedText * @return {String} text * @private */ Node.prototype._unescapeHTML = function (escapedText) { var json = '"' + this._escapeJSON(escapedText) + '"'; var htmlEscaped = util.parse(json); return htmlEscaped .replace(/</g, '<') .replace(/>/g, '>') .replace(/ /g, ' '); }; /** * escape a text to make it a valid JSON string. The method will: * - replace unescaped double quotes with '\"' * - replace unescaped backslash with '\\' * - replace returns with '\n' * @param {String} text * @return {String} escapedText * @private */ Node.prototype._escapeJSON = function (text) { // TODO: replace with some smart regex (only when a new solution is faster!) var escaped = ''; var i = 0, iMax = text.length; while (i < iMax) { var c = text.charAt(i); if (c == '\n') { escaped += '\\n'; } else if (c == '\\') { escaped += c; i++; c = text.charAt(i); if ('"\\/bfnrtu'.indexOf(c) == -1) { escaped += '\\'; // no valid escape character } escaped += c; } else if (c == '"') { escaped += '\\"'; } else { escaped += c; } i++; } return escaped; }; /** * @constructor AppendNode * @extends Node * @param {TreeEditor} editor * Create a new AppendNode. This is a special node which is created at the * end of the list with childs for an object or array */ function AppendNode (editor) { /** @type {TreeEditor} */ this.editor = editor; this.dom = {}; } AppendNode.prototype = new Node(); /** * Return a table row with an append button. * @return {Element} dom TR element */ AppendNode.prototype.getDom = function () { // TODO: implement a new solution for the append node var dom = this.dom; if (dom.tr) { return dom.tr; } // a row for the append button var trAppend = document.createElement('tr'); trAppend.node = this; dom.tr = trAppend; // TODO: consistent naming if (this.editor.mode.edit) { // a cell for the dragarea column dom.tdDrag = document.createElement('td'); // create context menu var tdMenu = document.createElement('td'); dom.tdMenu = tdMenu; var menu = document.createElement('button'); menu.className = 'contextmenu'; menu.title = 'Click to open the actions menu (Ctrl+M)'; dom.menu = menu; tdMenu.appendChild(dom.menu); } // a cell for the contents (showing text 'empty') var tdAppend = document.createElement('td'); var domText = document.createElement('div'); domText.innerHTML = '(empty)'; domText.className = 'readonly'; tdAppend.appendChild(domText); dom.td = tdAppend; dom.text = domText; this.updateDom(); return trAppend; }; /** * Update the HTML dom of the Node */ AppendNode.prototype.updateDom = function () { var dom = this.dom; var tdAppend = dom.td; if (tdAppend) { tdAppend.style.paddingLeft = (this.getLevel() * 24 + 26) + 'px'; // TODO: not so nice hard coded offset } var domText = dom.text; if (domText) { domText.innerHTML = '(empty ' + this.parent.type + ')'; } // attach or detach the contents of the append node: // hide when the parent has childs, show when the parent has no childs var trAppend = dom.tr; if (!this.isVisible()) { if (dom.tr.firstChild) { if (dom.tdDrag) { trAppend.removeChild(dom.tdDrag); } if (dom.tdMenu) { trAppend.removeChild(dom.tdMenu); } trAppend.removeChild(tdAppend); } } else { if (!dom.tr.firstChild) { if (dom.tdDrag) { trAppend.appendChild(dom.tdDrag); } if (dom.tdMenu) { trAppend.appendChild(dom.tdMenu); } trAppend.appendChild(tdAppend); } } }; /** * Check whether the AppendNode is currently visible. * the AppendNode is visible when its parent has no childs (i.e. is empty). * @return {boolean} isVisible */ AppendNode.prototype.isVisible = function () { return (this.parent.childs.length == 0); }; /** * Show a contextmenu for this node * @param {HTMLElement} anchor The element to attach the menu to. * @param {function} [onClose] Callback method called when the context menu * is being closed. */ AppendNode.prototype.showContextMenu = function (anchor, onClose) { var node = this; var titles = Node.TYPE_TITLES; var items = [ // create append button { 'text': 'Append', 'title': 'Append a new field with type \'auto\' (Ctrl+Shift+Ins)', 'submenuTitle': 'Select the type of the field to be appended', 'className': 'insert', 'click': function () { node._onAppend('', '', 'auto'); }, 'submenu': [ { 'text': 'Auto', 'className': 'type-auto', 'title': titles.auto, 'click': function () { node._onAppend('', '', 'auto'); } }, { 'text': 'Array', 'className': 'type-array', 'title': titles.array, 'click': function () { node._onAppend('', []); } }, { 'text': 'Object', 'className': 'type-object', 'title': titles.object, 'click': function () { node._onAppend('', {}); } }, { 'text': 'String', 'className': 'type-string', 'title': titles.string, 'click': function () { node._onAppend('', '', 'string'); } } ] } ]; var menu = new ContextMenu(items, {close: onClose}); menu.show(anchor); }; /** * Handle an event. The event is catched centrally by the editor * @param {Event} event */ AppendNode.prototype.onEvent = function (event) { var type = event.type; var target = event.target || event.srcElement; var dom = this.dom; // highlight the append nodes parent var menu = dom.menu; if (target == menu) { if (type == 'mouseover') { this.editor.highlighter.highlight(this.parent); } else if (type == 'mouseout') { this.editor.highlighter.unhighlight(); } } // context menu events if (type == 'click' && target == dom.menu) { var highlighter = this.editor.highlighter; highlighter.highlight(this.parent); highlighter.lock(); util.addClassName(dom.menu, 'selected'); this.showContextMenu(dom.menu, function () { util.removeClassName(dom.menu, 'selected'); highlighter.unlock(); highlighter.unhighlight(); }); } if (type == 'keydown') { this.onKeyDown(event); } }; /** * A context menu * @param {Object[]} items Array containing the menu structure * TODO: describe structure * @param {Object} [options] Object with options. Available options: * {function} close Callback called when the * context menu is being closed. * @constructor */ function ContextMenu (items, options) { this.dom = {}; var me = this; var dom = this.dom; this.anchor = undefined; this.items = items; this.eventListeners = {}; this.selection = undefined; // holds the selection before the menu was opened this.visibleSubmenu = undefined; this.onClose = options ? options.close : undefined; // create a container element var menu = document.createElement('div'); menu.className = 'jsoneditor-contextmenu'; dom.menu = menu; // create a list to hold the menu items var list = document.createElement('ul'); list.className = 'menu'; menu.appendChild(list); dom.list = list; dom.items = []; // list with all buttons // create a (non-visible) button to set the focus to the menu var focusButton = document.createElement('button'); dom.focusButton = focusButton; var li = document.createElement('li'); li.style.overflow = 'hidden'; li.style.height = '0'; li.appendChild(focusButton); list.appendChild(li); function createMenuItems (list, domItems, items) { items.forEach(function (item) { if (item.type == 'separator') { // create a separator var separator = document.createElement('div'); separator.className = 'separator'; li = document.createElement('li'); li.appendChild(separator); list.appendChild(li); } else { var domItem = {}; // create a menu item var li = document.createElement('li'); list.appendChild(li); // create a button in the menu item var button = document.createElement('button'); button.className = item.className; domItem.button = button; if (item.title) { button.title = item.title; } if (item.click) { button.onclick = function () { me.hide(); item.click(); }; } li.appendChild(button); // create the contents of the button if (item.submenu) { // add the icon to the button var divIcon = document.createElement('div'); divIcon.className = 'icon'; button.appendChild(divIcon); button.appendChild(document.createTextNode(item.text)); var buttonSubmenu; if (item.click) { // submenu and a button with a click handler button.className += ' default'; var buttonExpand = document.createElement('button'); domItem.buttonExpand = buttonExpand; buttonExpand.className = 'expand'; buttonExpand.innerHTML = '
'; li.appendChild(buttonExpand); if (item.submenuTitle) { buttonExpand.title = item.submenuTitle; } buttonSubmenu = buttonExpand; } else { // submenu and a button without a click handler var divExpand = document.createElement('div'); divExpand.className = 'expand'; button.appendChild(divExpand); buttonSubmenu = button; } // attach a handler to expand/collapse the submenu buttonSubmenu.onclick = function () { me._onExpandItem(domItem); buttonSubmenu.focus(); }; // create the submenu var domSubItems = []; domItem.subItems = domSubItems; var ul = document.createElement('ul'); domItem.ul = ul; ul.className = 'menu'; ul.style.height = '0'; li.appendChild(ul); createMenuItems(ul, domSubItems, item.submenu); } else { // no submenu, just a button with clickhandler button.innerHTML = '
' + item.text; } domItems.push(domItem); } }); } createMenuItems(list, this.dom.items, items); // TODO: when the editor is small, show the submenu on the right instead of inline? // calculate the max height of the menu with one submenu expanded this.maxHeight = 0; // height in pixels items.forEach(function (item) { var height = (items.length + (item.submenu ? item.submenu.length : 0)) * 24; me.maxHeight = Math.max(me.maxHeight, height); }); } /** * Get the currently visible buttons * @return {Array.} buttons * @private */ ContextMenu.prototype._getVisibleButtons = function () { var buttons = []; var me = this; this.dom.items.forEach(function (item) { buttons.push(item.button); if (item.buttonExpand) { buttons.push(item.buttonExpand); } if (item.subItems && item == me.expandedItem) { item.subItems.forEach(function (subItem) { buttons.push(subItem.button); if (subItem.buttonExpand) { buttons.push(subItem.buttonExpand); } // TODO: change to fully recursive method }); } }); return buttons; }; // currently displayed context menu, a singleton. We may only have one visible context menu ContextMenu.visibleMenu = undefined; /** * Attach the menu to an anchor * @param {HTMLElement} anchor */ ContextMenu.prototype.show = function (anchor) { this.hide(); // calculate whether the menu fits below the anchor var windowHeight = util.getWindowHeight(); var anchorHeight = anchor.offsetHeight; var menuHeight = this.maxHeight; // position the menu var left = util.getAbsoluteLeft(anchor); var top = util.getAbsoluteTop(anchor); if (top + anchorHeight + menuHeight < windowHeight) { // display the menu below the anchor this.dom.menu.style.left = left + 'px'; this.dom.menu.style.top = (top + anchorHeight) + 'px'; this.dom.menu.style.bottom = ''; } else { // display the menu above the anchor this.dom.menu.style.left = left + 'px'; this.dom.menu.style.top = ''; this.dom.menu.style.bottom = (windowHeight - top) + 'px'; } // attach the menu to the document document.body.appendChild(this.dom.menu); // create and attach event listeners var me = this; var list = this.dom.list; this.eventListeners.mousedown = util.addEventListener( document, 'mousedown', function (event) { // hide menu on click outside of the menu event = event || window.event; var target = event.target || event.srcElement; if ((target != list) && !me._isChildOf(target, list)) { me.hide(); util.stopPropagation(event); util.preventDefault(event); } }); this.eventListeners.mousewheel = util.addEventListener( document, 'mousewheel', function () { // hide the menu on mouse scroll util.stopPropagation(event); util.preventDefault(event); }); this.eventListeners.keydown = util.addEventListener( document, 'keydown', function (event) { me._onKeyDown(event); }); // move focus to the first button in the context menu this.selection = util.getSelection(); this.anchor = anchor; setTimeout(function () { me.dom.focusButton.focus(); }, 0); if (ContextMenu.visibleMenu) { ContextMenu.visibleMenu.hide(); } ContextMenu.visibleMenu = this; }; /** * Hide the context menu if visible */ ContextMenu.prototype.hide = function () { // remove the menu from the DOM if (this.dom.menu.parentNode) { this.dom.menu.parentNode.removeChild(this.dom.menu); if (this.onClose) { this.onClose(); } } // remove all event listeners // all event listeners are supposed to be attached to document. for (var name in this.eventListeners) { if (this.eventListeners.hasOwnProperty(name)) { var fn = this.eventListeners[name]; if (fn) { util.removeEventListener(document, name, fn); } delete this.eventListeners[name]; } } if (ContextMenu.visibleMenu == this) { ContextMenu.visibleMenu = undefined; } }; /** * Expand a submenu * Any currently expanded submenu will be hided. * @param {Object} domItem * @private */ ContextMenu.prototype._onExpandItem = function (domItem) { var me = this; var alreadyVisible = (domItem == this.expandedItem); // hide the currently visible submenu var expandedItem = this.expandedItem; if (expandedItem) { //var ul = expandedItem.ul; expandedItem.ul.style.height = '0'; expandedItem.ul.style.padding = ''; setTimeout(function () { if (me.expandedItem != expandedItem) { expandedItem.ul.style.display = ''; util.removeClassName(expandedItem.ul.parentNode, 'selected'); } }, 300); // timeout duration must match the css transition duration this.expandedItem = undefined; } if (!alreadyVisible) { var ul = domItem.ul; ul.style.display = 'block'; var height = ul.clientHeight; // force a reflow in Firefox setTimeout(function () { if (me.expandedItem == domItem) { ul.style.height = (ul.childNodes.length * 24) + 'px'; ul.style.padding = '5px 10px'; } }, 0); util.addClassName(ul.parentNode, 'selected'); this.expandedItem = domItem; } }; /** * Handle onkeydown event * @param {Event} event * @private */ ContextMenu.prototype._onKeyDown = function (event) { event = event || window.event; var target = event.target || event.srcElement; var keynum = event.which || event.keyCode; var handled = false; var buttons, targetIndex, prevButton, nextButton; if (keynum == 27) { // ESC // hide the menu on ESC key // restore previous selection and focus if (this.selection) { util.setSelection(this.selection); } if (this.anchor) { this.anchor.focus(); } this.hide(); handled = true; } else if (keynum == 9) { // Tab if (!event.shiftKey) { // Tab buttons = this._getVisibleButtons(); targetIndex = buttons.indexOf(target); if (targetIndex == buttons.length - 1) { // move to first button buttons[0].focus(); handled = true; } } else { // Shift+Tab buttons = this._getVisibleButtons(); targetIndex = buttons.indexOf(target); if (targetIndex == 0) { // move to last button buttons[buttons.length - 1].focus(); handled = true; } } } else if (keynum == 37) { // Arrow Left if (target.className == 'expand') { buttons = this._getVisibleButtons(); targetIndex = buttons.indexOf(target); prevButton = buttons[targetIndex - 1]; if (prevButton) { prevButton.focus(); } } handled = true; } else if (keynum == 38) { // Arrow Up buttons = this._getVisibleButtons(); targetIndex = buttons.indexOf(target); prevButton = buttons[targetIndex - 1]; if (prevButton && prevButton.className == 'expand') { // skip expand button prevButton = buttons[targetIndex - 2]; } if (!prevButton) { // move to last button prevButton = buttons[buttons.length - 1]; } if (prevButton) { prevButton.focus(); } handled = true; } else if (keynum == 39) { // Arrow Right buttons = this._getVisibleButtons(); targetIndex = buttons.indexOf(target); nextButton = buttons[targetIndex + 1]; if (nextButton && nextButton.className == 'expand') { nextButton.focus(); } handled = true; } else if (keynum == 40) { // Arrow Down buttons = this._getVisibleButtons(); targetIndex = buttons.indexOf(target); nextButton = buttons[targetIndex + 1]; if (nextButton && nextButton.className == 'expand') { // skip expand button nextButton = buttons[targetIndex + 2]; } if (!nextButton) { // move to first button nextButton = buttons[0]; } if (nextButton) { nextButton.focus(); handled = true; } handled = true; } // TODO: arrow left and right if (handled) { util.stopPropagation(event); util.preventDefault(event); } }; /** * Test if an element is a child of a parent element. * @param {Element} child * @param {Element} parent * @return {boolean} isChild */ ContextMenu.prototype._isChildOf = function (child, parent) { var e = child.parentNode; while (e) { if (e == parent) { return true; } e = e.parentNode; } return false; }; /** * @constructor History * Store action history, enables undo and redo * @param {JSONEditor} editor */ function History (editor) { this.editor = editor; this.clear(); // map with all supported actions this.actions = { 'editField': { 'undo': function (params) { params.node.updateField(params.oldValue); }, 'redo': function (params) { params.node.updateField(params.newValue); } }, 'editValue': { 'undo': function (params) { params.node.updateValue(params.oldValue); }, 'redo': function (params) { params.node.updateValue(params.newValue); } }, 'appendNode': { 'undo': function (params) { params.parent.removeChild(params.node); }, 'redo': function (params) { params.parent.appendChild(params.node); } }, 'insertBeforeNode': { 'undo': function (params) { params.parent.removeChild(params.node); }, 'redo': function (params) { params.parent.insertBefore(params.node, params.beforeNode); } }, 'insertAfterNode': { 'undo': function (params) { params.parent.removeChild(params.node); }, 'redo': function (params) { params.parent.insertAfter(params.node, params.afterNode); } }, 'removeNode': { 'undo': function (params) { var parent = params.parent; var beforeNode = parent.childs[params.index] || parent.append; parent.insertBefore(params.node, beforeNode); }, 'redo': function (params) { params.parent.removeChild(params.node); } }, 'duplicateNode': { 'undo': function (params) { params.parent.removeChild(params.clone); }, 'redo': function (params) { params.parent.insertAfter(params.clone, params.node); } }, 'changeType': { 'undo': function (params) { params.node.changeType(params.oldType); }, 'redo': function (params) { params.node.changeType(params.newType); } }, 'moveNode': { 'undo': function (params) { params.startParent.moveTo(params.node, params.startIndex); }, 'redo': function (params) { params.endParent.moveTo(params.node, params.endIndex); } }, 'sort': { 'undo': function (params) { var node = params.node; node.hideChilds(); node.sort = params.oldSort; node.childs = params.oldChilds; node.showChilds(); }, 'redo': function (params) { var node = params.node; node.hideChilds(); node.sort = params.newSort; node.childs = params.newChilds; node.showChilds(); } } // TODO: restore the original caret position and selection with each undo // TODO: implement history for actions "expand", "collapse", "scroll", "setDocument" }; } /** * The method onChange is executed when the History is changed, and can * be overloaded. */ History.prototype.onChange = function () {}; /** * Add a new action to the history * @param {String} action The executed action. Available actions: "editField", * "editValue", "changeType", "appendNode", * "removeNode", "duplicateNode", "moveNode" * @param {Object} params Object containing parameters describing the change. * The parameters in params depend on the action (for * example for "editValue" the Node, old value, and new * value are provided). params contains all information * needed to undo or redo the action. */ History.prototype.add = function (action, params) { this.index++; this.history[this.index] = { 'action': action, 'params': params, 'timestamp': new Date() }; // remove redo actions which are invalid now if (this.index < this.history.length - 1) { this.history.splice(this.index + 1, this.history.length - this.index - 1); } // fire onchange event this.onChange(); }; /** * Clear history */ History.prototype.clear = function () { this.history = []; this.index = -1; // fire onchange event this.onChange(); }; /** * Check if there is an action available for undo * @return {Boolean} canUndo */ History.prototype.canUndo = function () { return (this.index >= 0); }; /** * Check if there is an action available for redo * @return {Boolean} canRedo */ History.prototype.canRedo = function () { return (this.index < this.history.length - 1); }; /** * Undo the last action */ History.prototype.undo = function () { if (this.canUndo()) { var obj = this.history[this.index]; if (obj) { var action = this.actions[obj.action]; if (action && action.undo) { action.undo(obj.params); if (obj.params.oldSelection) { this.editor.setSelection(obj.params.oldSelection); } } else { util.log('Error: unknown action "' + obj.action + '"'); } } this.index--; // fire onchange event this.onChange(); } }; /** * Redo the last action */ History.prototype.redo = function () { if (this.canRedo()) { this.index++; var obj = this.history[this.index]; if (obj) { var action = this.actions[obj.action]; if (action && action.redo) { action.redo(obj.params); if (obj.params.newSelection) { this.editor.setSelection(obj.params.newSelection); } } else { util.log('Error: unknown action "' + obj.action + '"'); } } // fire onchange event this.onChange(); } }; /** * create a mode box to be used in the editor menu's * @param {JSONEditor} editor * @param {String[]} modes Available modes: 'code', 'form', 'text', 'tree', 'view' * @param {String} current Available modes: 'code', 'form', 'text', 'tree', 'view' * @returns {HTMLElement} box */ function createModeBox(editor, modes, current) { /** * Switch the mode of the editor * @param {String} mode */ function switchMode(mode) { // switch mode editor.setMode(mode); // restore focus on mode box var modeBox = editor.dom && editor.dom.modeBox; if (modeBox) { modeBox.focus(); } } // available modes var availableModes = { code: { 'text': 'Code', 'title': 'Switch to code highlighter', 'click': function () { switchMode('code') } }, form: { 'text': 'Form', 'title': 'Switch to form editor', 'click': function () { switchMode('form'); } }, text: { 'text': 'Text', 'title': 'Switch to plain text editor', 'click': function () { switchMode('text'); } }, tree: { 'text': 'Tree', 'title': 'Switch to tree editor', 'click': function () { switchMode('tree'); } }, view: { 'text': 'View', 'title': 'Switch to tree view', 'click': function () { switchMode('view'); } } }; // list the selected modes var items = []; for (var i = 0; i < modes.length; i++) { var mode = modes[i]; var item = availableModes[mode]; if (!item) { throw new Error('Unknown mode "' + mode + '"'); } item.className = 'type-modes' + ((current == mode) ? ' selected' : ''); items.push(item); } // retrieve the title of current mode var currentMode = availableModes[current]; if (!currentMode) { throw new Error('Unknown mode "' + current + '"'); } var currentTitle = currentMode.text; // create the html element var box = document.createElement('button'); box.className = 'modes separator'; box.innerHTML = currentTitle + ' ▾'; box.title = 'Switch editor mode'; box.onclick = function () { var menu = new ContextMenu(items); menu.show(box); }; return box; } /** * @constructor SearchBox * Create a search box in given HTML container * @param {JSONEditor} editor The JSON Editor to attach to * @param {Element} container HTML container element of where to * create the search box */ function SearchBox (editor, container) { var searchBox = this; this.editor = editor; this.timeout = undefined; this.delay = 200; // ms this.lastText = undefined; this.dom = {}; this.dom.container = container; var table = document.createElement('table'); this.dom.table = table; table.className = 'search'; container.appendChild(table); var tbody = document.createElement('tbody'); this.dom.tbody = tbody; table.appendChild(tbody); var tr = document.createElement('tr'); tbody.appendChild(tr); var td = document.createElement('td'); tr.appendChild(td); var results = document.createElement('div'); this.dom.results = results; results.className = 'results'; td.appendChild(results); td = document.createElement('td'); tr.appendChild(td); var divInput = document.createElement('div'); this.dom.input = divInput; divInput.className = 'frame'; divInput.title = 'Search fields and values'; td.appendChild(divInput); // table to contain the text input and search button var tableInput = document.createElement('table'); divInput.appendChild(tableInput); var tbodySearch = document.createElement('tbody'); tableInput.appendChild(tbodySearch); tr = document.createElement('tr'); tbodySearch.appendChild(tr); var refreshSearch = document.createElement('button'); refreshSearch.className = 'refresh'; td = document.createElement('td'); td.appendChild(refreshSearch); tr.appendChild(td); var search = document.createElement('input'); this.dom.search = search; search.oninput = function (event) { searchBox._onDelayedSearch(event); }; search.onchange = function (event) { // For IE 8 searchBox._onSearch(event); }; search.onkeydown = function (event) { searchBox._onKeyDown(event); }; search.onkeyup = function (event) { searchBox._onKeyUp(event); }; refreshSearch.onclick = function (event) { search.select(); }; // TODO: ESC in FF restores the last input, is a FF bug, https://bugzilla.mozilla.org/show_bug.cgi?id=598819 td = document.createElement('td'); td.appendChild(search); tr.appendChild(td); var searchNext = document.createElement('button'); searchNext.title = 'Next result (Enter)'; searchNext.className = 'next'; searchNext.onclick = function () { searchBox.next(); }; td = document.createElement('td'); td.appendChild(searchNext); tr.appendChild(td); var searchPrevious = document.createElement('button'); searchPrevious.title = 'Previous result (Shift+Enter)'; searchPrevious.className = 'previous'; searchPrevious.onclick = function () { searchBox.previous(); }; td = document.createElement('td'); td.appendChild(searchPrevious); tr.appendChild(td); } /** * Go to the next search result * @param {boolean} [focus] If true, focus will be set to the next result * focus is false by default. */ SearchBox.prototype.next = function(focus) { if (this.results != undefined) { var index = (this.resultIndex != undefined) ? this.resultIndex + 1 : 0; if (index > this.results.length - 1) { index = 0; } this._setActiveResult(index, focus); } }; /** * Go to the prevous search result * @param {boolean} [focus] If true, focus will be set to the next result * focus is false by default. */ SearchBox.prototype.previous = function(focus) { if (this.results != undefined) { var max = this.results.length - 1; var index = (this.resultIndex != undefined) ? this.resultIndex - 1 : max; if (index < 0) { index = max; } this._setActiveResult(index, focus); } }; /** * Set new value for the current active result * @param {Number} index * @param {boolean} [focus] If true, focus will be set to the next result. * focus is false by default. * @private */ SearchBox.prototype._setActiveResult = function(index, focus) { // de-activate current active result if (this.activeResult) { var prevNode = this.activeResult.node; var prevElem = this.activeResult.elem; if (prevElem == 'field') { delete prevNode.searchFieldActive; } else { delete prevNode.searchValueActive; } prevNode.updateDom(); } if (!this.results || !this.results[index]) { // out of range, set to undefined this.resultIndex = undefined; this.activeResult = undefined; return; } this.resultIndex = index; // set new node active var node = this.results[this.resultIndex].node; var elem = this.results[this.resultIndex].elem; if (elem == 'field') { node.searchFieldActive = true; } else { node.searchValueActive = true; } this.activeResult = this.results[this.resultIndex]; node.updateDom(); // TODO: not so nice that the focus is only set after the animation is finished node.scrollTo(function () { if (focus) { node.focus(elem); } }); }; /** * Cancel any running onDelayedSearch. * @private */ SearchBox.prototype._clearDelay = function() { if (this.timeout != undefined) { clearTimeout(this.timeout); delete this.timeout; } }; /** * Start a timer to execute a search after a short delay. * Used for reducing the number of searches while typing. * @param {Event} event * @private */ SearchBox.prototype._onDelayedSearch = function (event) { // execute the search after a short delay (reduces the number of // search actions while typing in the search text box) this._clearDelay(); var searchBox = this; this.timeout = setTimeout(function (event) { searchBox._onSearch(event); }, this.delay); }; /** * Handle onSearch event * @param {Event} event * @param {boolean} [forceSearch] If true, search will be executed again even * when the search text is not changed. * Default is false. * @private */ SearchBox.prototype._onSearch = function (event, forceSearch) { this._clearDelay(); var value = this.dom.search.value; var text = (value.length > 0) ? value : undefined; if (text != this.lastText || forceSearch) { // only search again when changed this.lastText = text; this.results = this.editor.search(text); this._setActiveResult(undefined); // display search results if (text != undefined) { var resultCount = this.results.length; switch (resultCount) { case 0: this.dom.results.innerHTML = 'no results'; break; case 1: this.dom.results.innerHTML = '1 result'; break; default: this.dom.results.innerHTML = resultCount + ' results'; break; } } else { this.dom.results.innerHTML = ''; } } }; /** * Handle onKeyDown event in the input box * @param {Event} event * @private */ SearchBox.prototype._onKeyDown = function (event) { event = event || window.event; var keynum = event.which || event.keyCode; if (keynum == 27) { // ESC this.dom.search.value = ''; // clear search this._onSearch(event); util.preventDefault(event); util.stopPropagation(event); } else if (keynum == 13) { // Enter if (event.ctrlKey) { // force to search again this._onSearch(event, true); } else if (event.shiftKey) { // move to the previous search result this.previous(); } else { // move to the next search result this.next(); } util.preventDefault(event); util.stopPropagation(event); } }; /** * Handle onKeyUp event in the input box * @param {Event} event * @private */ SearchBox.prototype._onKeyUp = function (event) { event = event || window.event; var keynum = event.which || event.keyCode; if (keynum != 27 && keynum != 13) { // !show and !Enter this._onDelayedSearch(event); // For IE 8 } }; /** * The highlighter can highlight/unhighlight a node, and * animate the visibility of a context menu. * @constructor Highlighter */ function Highlighter () { this.locked = false; } /** * Hightlight given node and its childs * @param {Node} node */ Highlighter.prototype.highlight = function (node) { if (this.locked) { return; } if (this.node != node) { // unhighlight current node if (this.node) { this.node.setHighlight(false); } // highlight new node this.node = node; this.node.setHighlight(true); } // cancel any current timeout this._cancelUnhighlight(); }; /** * Unhighlight currently highlighted node. * Will be done after a delay */ Highlighter.prototype.unhighlight = function () { if (this.locked) { return; } var me = this; if (this.node) { this._cancelUnhighlight(); // do the unhighlighting after a small delay, to prevent re-highlighting // the same node when moving from the drag-icon to the contextmenu-icon // or vice versa. this.unhighlightTimer = setTimeout(function () { me.node.setHighlight(false); me.node = undefined; me.unhighlightTimer = undefined; }, 0); } }; /** * Cancel an unhighlight action (if before the timeout of the unhighlight action) * @private */ Highlighter.prototype._cancelUnhighlight = function () { if (this.unhighlightTimer) { clearTimeout(this.unhighlightTimer); this.unhighlightTimer = undefined; } }; /** * Lock highlighting or unhighlighting nodes. * methods highlight and unhighlight do not work while locked. */ Highlighter.prototype.lock = function () { this.locked = true; }; /** * Unlock highlighting or unhighlighting nodes */ Highlighter.prototype.unlock = function () { this.locked = false; }; // create namespace util = {}; // http://soledadpenades.com/2007/05/17/arrayindexof-in-internet-explorer/ if(!Array.prototype.indexOf) { Array.prototype.indexOf = function(obj){ for(var i = 0; i < this.length; i++){ if(this[i] == obj){ return i; } } return -1; } } // https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Array/forEach if (!Array.prototype.forEach) { Array.prototype.forEach = function(fn, scope) { for(var i = 0, len = this.length; i < len; ++i) { fn.call(scope || this, this[i], i, this); } } } // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/isArray if(!Array.isArray) { Array.isArray = function (vArg) { return Object.prototype.toString.call(vArg) === "[object Array]"; }; } /** * Parse JSON using the parser built-in in the browser. * On exception, the jsonString is validated and a detailed error is thrown. * @param {String} jsonString */ util.parse = function parse(jsonString) { try { return JSON.parse(jsonString); } catch (err) { // try to throw a more detailed error message using validate util.validate(jsonString); throw err; } }; /** * Validate a string containing a JSON object * This method uses JSONLint to validate the String. If JSONLint is not * available, the built-in JSON parser of the browser is used. * @param {String} jsonString String with an (invalid) JSON object * @throws Error */ util.validate = function validate(jsonString) { if (typeof(jsonlint) != 'undefined') { jsonlint.parse(jsonString); } else { JSON.parse(jsonString); } }; /** * Extend object a with the properties of object b * @param {Object} a * @param {Object} b * @return {Object} a */ util.extend = function extend(a, b) { for (var prop in b) { if (b.hasOwnProperty(prop)) { a[prop] = b[prop]; } } return a; }; /** * Remove all properties from object a * @param {Object} a * @return {Object} a */ util.clear = function clear (a) { for (var prop in a) { if (a.hasOwnProperty(prop)) { delete a[prop]; } } return a; }; /** * Output text to the console, if console is available * @param {...*} args */ util.log = function log (args) { if (console && typeof console.log === 'function') { console.log.apply(console, arguments); } }; /** * Get the type of an object * @param {*} object * @return {String} type */ util.type = function type (object) { if (object === null) { return 'null'; } if (object === undefined) { return 'undefined'; } if ((object instanceof Number) || (typeof object === 'number')) { return 'number'; } if ((object instanceof String) || (typeof object === 'string')) { return 'string'; } if ((object instanceof Boolean) || (typeof object === 'boolean')) { return 'boolean'; } if ((object instanceof RegExp) || (typeof object === 'regexp')) { return 'regexp'; } if (Array.isArray(object)) { return 'array'; } return 'object'; }; /** * Test whether a text contains a url (matches when a string starts * with 'http://*' or 'https://*' and has no whitespace characters) * @param {String} text */ var isUrlRegex = /^https?:\/\/\S+$/; util.isUrl = function isUrl (text) { return (typeof text == 'string' || text instanceof String) && isUrlRegex.test(text); }; /** * Retrieve the absolute left value of a DOM element * @param {Element} elem A dom element, for example a div * @return {Number} left The absolute left position of this element * in the browser page. */ util.getAbsoluteLeft = function getAbsoluteLeft(elem) { var left = elem.offsetLeft; var body = document.body; var e = elem.offsetParent; while (e != null && elem != body) { left += e.offsetLeft; left -= e.scrollLeft; e = e.offsetParent; } return left; }; /** * Retrieve the absolute top value of a DOM element * @param {Element} elem A dom element, for example a div * @return {Number} top The absolute top position of this element * in the browser page. */ util.getAbsoluteTop = function getAbsoluteTop(elem) { var top = elem.offsetTop; var body = document.body; var e = elem.offsetParent; while (e != null && e != body) { top += e.offsetTop; top -= e.scrollTop; e = e.offsetParent; } return top; }; /** * Get the absolute, vertical mouse position from an event. * @param {Event} event * @return {Number} mouseY */ util.getMouseY = function getMouseY(event) { var mouseY; if ('pageY' in event) { mouseY = event.pageY; } else { // for IE8 and older mouseY = (event.clientY + document.documentElement.scrollTop); } return mouseY; }; /** * Get the absolute, horizontal mouse position from an event. * @param {Event} event * @return {Number} mouseX */ util.getMouseX = function getMouseX(event) { var mouseX; if ('pageX' in event) { mouseX = event.pageX; } else { // for IE8 and older mouseX = (event.clientX + document.documentElement.scrollLeft); } return mouseX; }; /** * Get the window height * @return {Number} windowHeight */ util.getWindowHeight = function getWindowHeight() { if ('innerHeight' in window) { return window.innerHeight; } else { // for IE8 and older return Math.max(document.body.clientHeight, document.documentElement.clientHeight); } }; /** * add a className to the given elements style * @param {Element} elem * @param {String} className */ util.addClassName = function addClassName(elem, className) { var classes = elem.className.split(' '); if (classes.indexOf(className) == -1) { classes.push(className); // add the class to the array elem.className = classes.join(' '); } }; /** * add a className to the given elements style * @param {Element} elem * @param {String} className */ util.removeClassName = function removeClassName(elem, className) { var classes = elem.className.split(' '); var index = classes.indexOf(className); if (index != -1) { classes.splice(index, 1); // remove the class from the array elem.className = classes.join(' '); } }; /** * Strip the formatting from the contents of a div * the formatting from the div itself is not stripped, only from its childs. * @param {Element} divElement */ util.stripFormatting = function stripFormatting(divElement) { var childs = divElement.childNodes; for (var i = 0, iMax = childs.length; i < iMax; i++) { var child = childs[i]; // remove the style if (child.style) { // TODO: test if child.attributes does contain style child.removeAttribute('style'); } // remove all attributes var attributes = child.attributes; if (attributes) { for (var j = attributes.length - 1; j >= 0; j--) { var attribute = attributes[j]; if (attribute.specified == true) { child.removeAttribute(attribute.name); } } } // recursively strip childs util.stripFormatting(child); } }; /** * Set focus to the end of an editable div * code from Nico Burns * http://stackoverflow.com/users/140293/nico-burns * http://stackoverflow.com/questions/1125292/how-to-move-cursor-to-end-of-contenteditable-entity * @param {Element} contentEditableElement A content editable div */ util.setEndOfContentEditable = function setEndOfContentEditable(contentEditableElement) { var range, selection; if(document.createRange) {//Firefox, Chrome, Opera, Safari, IE 9+ range = document.createRange();//Create a range (a range is a like the selection but invisible) range.selectNodeContents(contentEditableElement);//Select the entire contents of the element with the range range.collapse(false);//collapse the range to the end point. false means collapse to end rather than the start selection = window.getSelection();//get the selection object (allows you to change selection) selection.removeAllRanges();//remove any selections already made selection.addRange(range);//make the range you have just created the visible selection } else if(document.selection) {//IE 8 and lower range = document.body.createTextRange();//Create a range (a range is a like the selection but invisible) range.moveToElementText(contentEditableElement);//Select the entire contents of the element with the range range.collapse(false);//collapse the range to the end point. false means collapse to end rather than the start range.select();//Select the range (make it the visible selection } }; /** * Select all text of a content editable div. * http://stackoverflow.com/a/3806004/1262753 * @param {Element} contentEditableElement A content editable div */ util.selectContentEditable = function selectContentEditable(contentEditableElement) { if (!contentEditableElement || contentEditableElement.nodeName != 'DIV') { return; } var sel, range; if (window.getSelection && document.createRange) { range = document.createRange(); range.selectNodeContents(contentEditableElement); sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); } else if (document.body.createTextRange) { range = document.body.createTextRange(); range.moveToElementText(contentEditableElement); range.select(); } }; /** * Get text selection * http://stackoverflow.com/questions/4687808/contenteditable-selected-text-save-and-restore * @return {Range | TextRange | null} range */ util.getSelection = function getSelection() { if (window.getSelection) { var sel = window.getSelection(); if (sel.getRangeAt && sel.rangeCount) { return sel.getRangeAt(0); } } else if (document.selection && document.selection.createRange) { return document.selection.createRange(); } return null; }; /** * Set text selection * http://stackoverflow.com/questions/4687808/contenteditable-selected-text-save-and-restore * @param {Range | TextRange | null} range */ util.setSelection = function setSelection(range) { if (range) { if (window.getSelection) { var sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); } else if (document.selection && range.select) { range.select(); } } }; /** * Get selected text range * @return {Object} params object containing parameters: * {Number} startOffset * {Number} endOffset * {Element} container HTML element holding the * selected text element * Returns null if no text selection is found */ util.getSelectionOffset = function getSelectionOffset() { var range = util.getSelection(); if (range && 'startOffset' in range && 'endOffset' in range && range.startContainer && (range.startContainer == range.endContainer)) { return { startOffset: range.startOffset, endOffset: range.endOffset, container: range.startContainer.parentNode }; } else { // TODO: implement getSelectionOffset for IE8 } return null; }; /** * Set selected text range in given element * @param {Object} params An object containing: * {Element} container * {Number} startOffset * {Number} endOffset */ util.setSelectionOffset = function setSelectionOffset(params) { if (document.createRange && window.getSelection) { var selection = window.getSelection(); if(selection) { var range = document.createRange(); // TODO: do not suppose that the first child of the container is a textnode, // but recursively find the textnodes range.setStart(params.container.firstChild, params.startOffset); range.setEnd(params.container.firstChild, params.endOffset); util.setSelection(range); } } else { // TODO: implement setSelectionOffset for IE8 } }; /** * Get the inner text of an HTML element (for example a div element) * @param {Element} element * @param {Object} [buffer] * @return {String} innerText */ util.getInnerText = function getInnerText(element, buffer) { var first = (buffer == undefined); if (first) { buffer = { 'text': '', 'flush': function () { var text = this.text; this.text = ''; return text; }, 'set': function (text) { this.text = text; } }; } // text node if (element.nodeValue) { return buffer.flush() + element.nodeValue; } // divs or other HTML elements if (element.hasChildNodes()) { var childNodes = element.childNodes; var innerText = ''; for (var i = 0, iMax = childNodes.length; i < iMax; i++) { var child = childNodes[i]; if (child.nodeName == 'DIV' || child.nodeName == 'P') { var prevChild = childNodes[i - 1]; var prevName = prevChild ? prevChild.nodeName : undefined; if (prevName && prevName != 'DIV' && prevName != 'P' && prevName != 'BR') { innerText += '\n'; buffer.flush(); } innerText += util.getInnerText(child, buffer); buffer.set('\n'); } else if (child.nodeName == 'BR') { innerText += buffer.flush(); buffer.set('\n'); } else { innerText += util.getInnerText(child, buffer); } } return innerText; } else { if (element.nodeName == 'P' && util.getInternetExplorerVersion() != -1) { // On Internet Explorer, a

with hasChildNodes()==false is // rendered with a new line. Note that a

with // hasChildNodes()==true is rendered without a new line // Other browsers always ensure there is a
inside the

, // and if not, the

does not render a new line return buffer.flush(); } } // br or unknown return ''; }; /** * Returns the version of Internet Explorer or a -1 * (indicating the use of another browser). * Source: http://msdn.microsoft.com/en-us/library/ms537509(v=vs.85).aspx * @return {Number} Internet Explorer version, or -1 in case of an other browser */ util.getInternetExplorerVersion = function getInternetExplorerVersion() { if (_ieVersion == -1) { var rv = -1; // Return value assumes failure. if (navigator.appName == 'Microsoft Internet Explorer') { var ua = navigator.userAgent; var re = new RegExp("MSIE ([0-9]{1,}[\.0-9]{0,})"); if (re.exec(ua) != null) { rv = parseFloat( RegExp.$1 ); } } _ieVersion = rv; } return _ieVersion; }; /** * cached internet explorer version * @type {Number} * @private */ var _ieVersion = -1; /** * Add and event listener. Works for all browsers * @param {Element} element An html element * @param {string} action The action, for example "click", * without the prefix "on" * @param {function} listener The callback function to be executed * @param {boolean} [useCapture] false by default * @return {function} the created event listener */ util.addEventListener = function addEventListener(element, action, listener, useCapture) { if (element.addEventListener) { if (useCapture === undefined) useCapture = false; if (action === "mousewheel" && navigator.userAgent.indexOf("Firefox") >= 0) { action = "DOMMouseScroll"; // For Firefox } element.addEventListener(action, listener, useCapture); return listener; } else { // IE browsers var f = function () { return listener.call(element, window.event); }; element.attachEvent("on" + action, f); return f; } }; /** * Remove an event listener from an element * @param {Element} element An html dom element * @param {string} action The name of the event, for example "mousedown" * @param {function} listener The listener function * @param {boolean} [useCapture] false by default */ util.removeEventListener = function removeEventListener(element, action, listener, useCapture) { if (element.removeEventListener) { // non-IE browsers if (useCapture === undefined) useCapture = false; if (action === "mousewheel" && navigator.userAgent.indexOf("Firefox") >= 0) { action = "DOMMouseScroll"; // For Firefox } element.removeEventListener(action, listener, useCapture); } else { // IE browsers element.detachEvent("on" + action, listener); } }; /** * Stop event propagation * @param {Event} event */ util.stopPropagation = function stopPropagation(event) { if (!event) { event = window.event; } if (event.stopPropagation) { event.stopPropagation(); // non-IE browsers } else { event.cancelBubble = true; // IE browsers } }; /** * Cancels the event if it is cancelable, without stopping further propagation of the event. * @param {Event} event */ util.preventDefault = function preventDefault(event) { if (!event) { event = window.event; } if (event.preventDefault) { event.preventDefault(); // non-IE browsers } else { event.returnValue = false; // IE browsers } }; // module exports var jsoneditor = { 'JSONEditor': JSONEditor, 'JSONFormatter': function () { throw new Error('JSONFormatter is deprecated. ' + 'Use JSONEditor with mode "text" or "code" instead'); }, 'util': util }; /** * load jsoneditor.css */ var loadCss = function () { // find the script named 'jsoneditor.js' or 'jsoneditor-min.js' or // 'jsoneditor.min.js', and use its path to find the css file to be // loaded. var scripts = document.getElementsByTagName('script'); for (var s = 0; s < scripts.length; s++) { var src = scripts[s].src; if (/(^|\/)jsoneditor([-\.]min)?.js$/.test(src)) { var jsFile = src.split('?')[0]; var cssFile = jsFile.substring(0, jsFile.length - 2) + 'css'; // load css file var link = document.createElement('link'); link.type = 'text/css'; link.rel = 'stylesheet'; link.href = cssFile; document.getElementsByTagName('head')[0].appendChild(link); break; } } }; /** * CommonJS module exports */ if (typeof(module) != 'undefined' && typeof(exports) != 'undefined') { loadCss(); module.exports = exports = jsoneditor; } /** * AMD module exports */ if (typeof(require) != 'undefined' && typeof(define) != 'undefined') { loadCss(); define(function () { return jsoneditor; }); } else { // attach the module to the window, load as a regular javascript file window['jsoneditor'] = jsoneditor; } })();