UNPKG

jsoneditor

Version:

A web-based tool to view, edit, format, and validate JSON

1,882 lines (1,649 loc) 127 kB
'use strict'; var jmespath = require('jmespath'); var naturalSort = require('javascript-natural-sort'); var createAbsoluteAnchor = require('./createAbsoluteAnchor').createAbsoluteAnchor; var ContextMenu = require('./ContextMenu'); var appendNodeFactory = require('./appendNodeFactory'); var showMoreNodeFactory = require('./showMoreNodeFactory'); var showSortModal = require('./showSortModal'); var showTransformModal = require('./showTransformModal'); var util = require('./util'); var translate = require('./i18n').translate; var DEFAULT_MODAL_ANCHOR = document.body; // TODO: this constant is defined twice var YEAR_2000 = 946684800000; /** * @constructor Node * Create a new Node * @param {./treemode} 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 {./treemode} */ this.editor = editor; this.dom = {}; this.expanded = false; if(params && (params instanceof Object)) { this.setField(params.field, params.fieldEditable); if ('value' in params) { this.setValue(params.value, params.type); } if ('internalValue' in params) { this.setInternalValue(params.internalValue); } } else { this.setField(''); this.setValue(null); } this._debouncedOnChangeValue = util.debounce(this._onChangeValue.bind(this), Node.prototype.DEBOUNCE_INTERVAL); this._debouncedOnChangeField = util.debounce(this._onChangeField.bind(this), Node.prototype.DEBOUNCE_INTERVAL); // starting value for visible children this.visibleChilds = this.getMaxVisibleChilds(); } // debounce interval for keyboard input in milliseconds Node.prototype.DEBOUNCE_INTERVAL = 150; // search will stop iterating as soon as the max is reached Node.prototype.MAX_SEARCH_RESULTS = 999; // default number of child nodes to display var DEFAULT_MAX_VISIBLE_CHILDS = 100; Node.prototype.getMaxVisibleChilds = function() { return (this.editor && this.editor.options && this.editor.options.maxVisibleChilds) ? this.editor.options.maxVisibleChilds : DEFAULT_MAX_VISIBLE_CHILDS; } /** * Determine whether the field and/or value of this node are editable * @private */ Node.prototype._updateEditability = function () { this.editable = { field: true, value: true }; if (this.editor) { this.editable.field = this.editor.options.mode === 'tree'; this.editable.value = this.editor.options.mode !== 'view'; if ((this.editor.options.mode === 'tree' || this.editor.options.mode === 'form') && (typeof this.editor.options.onEditable === 'function')) { var editable = this.editor.options.onEditable({ field: this.field, value: this.value, path: this.getPath() }); if (typeof editable === 'boolean') { this.editable.field = editable; this.editable.value = editable; } else { if (typeof editable.field === 'boolean') this.editable.field = editable.field; if (typeof editable.value === 'boolean') this.editable.value = editable.value; } } } }; /** * Get the path of this node * @return {{string|number}[]} Array containing the path to this node. * Element is a number if is the index of an array, a string otherwise. */ Node.prototype.getPath = function () { var node = this; var path = []; while (node) { var field = node.getName(); if (field !== undefined) { path.unshift(field); } node = node.parent; } return path; }; /** * Get the internal path of this node, a list with the child indexes. * @return {String[]} Array containing the internal path to this node */ Node.prototype.getInternalPath = function () { var node = this; var internalPath = []; while (node) { if (node.parent) { internalPath.unshift(node.getIndex()); } node = node.parent; } return internalPath; }; /** * Get node serializable name * @returns {String|Number} */ Node.prototype.getName = function () { return !this.parent ? undefined // do not add an (optional) field name of the root node : (this.parent.type != 'array') ? this.field : this.index; }; /** * Find child node by serializable path * @param {Array<String>} path */ Node.prototype.findNodeByPath = function (path) { if (!path) { return; } if (path.length == 0) { return this; } if (path.length && this.childs && this.childs.length) { for (var i=0; i < this.childs.length; ++i) { if (('' + path[0]) === ('' + this.childs[i].getName())) { return this.childs[i].findNodeByPath(path.slice(1)); } } } }; /** * Find child node by an internal path: the indexes of the childs nodes * @param {Array<String>} internalPath * @return {Node | undefined} Returns the node if the path exists. * Returns undefined otherwise. */ Node.prototype.findNodeByInternalPath = function (internalPath) { if (!internalPath) { return undefined; } var node = this; for (var i = 0; i < internalPath.length && node; i++) { var childIndex = internalPath[i]; node = node.childs[childIndex]; } return node; }; /** * @typedef {{value: String|Object|Number|Boolean, path: Array.<String|Number>}} SerializableNode * * Returns serializable representation for the node * @return {SerializableNode} */ Node.prototype.serialize = function () { return { value: this.getValue(), path: this.getPath() }; }; /** * Find a Node from a JSON path like '.items[3].name' * @param {string} jsonPath * @return {Node | null} Returns the Node when found, returns null if not found */ Node.prototype.findNode = function (jsonPath) { var path = util.parsePath(jsonPath); var node = this; while (node && path.length > 0) { var prop = path.shift(); if (typeof prop === 'number') { if (node.type !== 'array') { throw new Error('Cannot get child node at index ' + prop + ': node is no array'); } node = node.childs[prop]; } else { // string if (node.type !== 'object') { throw new Error('Cannot get child node ' + prop + ': node is no object'); } node = node.childs.filter(function (child) { return child.field === prop; })[0]; } } return node; }; /** * Find all parents of this node. The parents are ordered from root node towards * the original node. * @return {Array.<Node>} */ Node.prototype.findParents = function () { var parents = []; var parent = this.parent; while (parent) { parents.unshift(parent); parent = parent.parent; } return parents; }; /** * * @param {{dataPath: string, keyword: string, message: string, params: Object, schemaPath: string} | null} error * @param {Node} [child] When this is the error of a parent node, pointing * to an invalid child node, the child node itself * can be provided. If provided, clicking the error * icon will set focus to the invalid child node. */ Node.prototype.setError = function (error, child) { this.error = error; this.errorChild = child; if (this.dom && this.dom.tr) { this.updateError(); } }; /** * Render the error */ Node.prototype.updateError = function() { var error = this.fieldError || this.valueError || this.error; var tdError = this.dom.tdError; if (error && this.dom && this.dom.tr) { util.addClassName(this.dom.tr, 'jsoneditor-validation-error'); if (!tdError) { tdError = document.createElement('td'); this.dom.tdError = tdError; this.dom.tdValue.parentNode.appendChild(tdError); } var popover = document.createElement('div'); popover.className = 'jsoneditor-popover jsoneditor-right'; popover.appendChild(document.createTextNode(error.message)); var button = document.createElement('button'); button.type = 'button'; button.className = 'jsoneditor-button jsoneditor-schema-error'; button.appendChild(popover); // update the direction of the popover button.onmouseover = button.onfocus = function updateDirection() { var directions = ['right', 'above', 'below', 'left']; for (var i = 0; i < directions.length; i++) { var direction = directions[i]; popover.className = 'jsoneditor-popover jsoneditor-' + direction; var contentRect = this.editor.content.getBoundingClientRect(); var popoverRect = popover.getBoundingClientRect(); var margin = 20; // account for a scroll bar var fit = util.insideRect(contentRect, popoverRect, margin); if (fit) { break; } } }.bind(this); // when clicking the error icon, expand all nodes towards the invalid // child node, and set focus to the child node var child = this.errorChild; if (child) { button.onclick = function showInvalidNode() { child.findParents().forEach(function (parent) { parent.expand(false); }); child.scrollTo(function () { child.focus(); }); }; } // apply the error message to the node while (tdError.firstChild) { tdError.removeChild(tdError.firstChild); } tdError.appendChild(button); } else { if (this.dom.tr) { util.removeClassName(this.dom.tr, 'jsoneditor-validation-error'); } if (tdError) { this.dom.tdError.parentNode.removeChild(this.dom.tdError); delete this.dom.tdError; } } }; /** * Get the index of this node: the index in the list of childs where this * node is part of * @return {number | null} Returns the index, or null if this is the root node */ Node.prototype.getIndex = function () { if (this.parent) { var index = this.parent.childs.indexOf(this); return index !== -1 ? index : null; } else { return -1; } }; /** * 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.previousField = 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, visible; var i, j; var notUpdateDom = false; var previousChilds = this.childs; 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 if (!this.childs) { this.childs = []; } for (i = 0; i < value.length; i++) { childValue = value[i]; if (childValue !== undefined && !(childValue instanceof Function)) { if (i < this.childs.length) { // reuse existing child, keep its state child = this.childs[i]; child.fieldEditable = false; child.index = i; child.setValue(childValue); } else { // create a new child child = new Node(this.editor, { value: childValue }); visible = i < this.getMaxVisibleChilds(); this.appendChild(child, visible, notUpdateDom); } } } // cleanup redundant childs // we loop backward to prevent issues with shifting index numbers for (j = this.childs.length; j >= value.length; j--) { this.removeChild(this.childs[j], notUpdateDom); } } else if (this.type === 'object') { // object if (!this.childs) { this.childs = []; } // cleanup redundant childs // we loop backward to prevent issues with shifting index numbers for (j = this.childs.length - 1; j >= 0; j--) { if (!value.hasOwnProperty(this.childs[j].field)) { this.removeChild(this.childs[j], notUpdateDom); } } i = 0; for (var childField in value) { if (value.hasOwnProperty(childField)) { childValue = value[childField]; if (childValue !== undefined && !(childValue instanceof Function)) { child = this.findChildByProperty(childField); if (child) { // reuse existing child, keep its state child.setField(childField, true); child.setValue(childValue); } else { // create a new child child = new Node(this.editor, { field: childField, value: childValue }); visible = i < this.getMaxVisibleChilds(); this.appendChild(child, visible, notUpdateDom); } } i++; } } this.value = ''; // sort object keys if (this.editor.options.sortObjectKeys === true) { this.sort([], 'asc'); } } else { // value this.hideChilds(); delete this.append; delete this.showMore; delete this.expanded; delete this.childs; this.value = value; } // recreate the DOM if switching from an object/array to auto/string or vice versa // needed to recreated the expand button for example if (Array.isArray(previousChilds) !== Array.isArray(this.childs)) { this.recreateDom(); } this.updateDom({'updateIndexes': true}); this.previousValue = this.value; // used only to check for changes in DOM vs JS model }; /** * Set internal value * @param {*} internalValue Internal value structure keeping type, * order and duplicates in objects */ Node.prototype.setInternalValue = function(internalValue) { var childValue, child, visible; var i, j; var notUpdateDom = false; var previousChilds = this.childs; this.type = internalValue.type; if (internalValue.type === 'array') { // array if (!this.childs) { this.childs = []; } for (i = 0; i < internalValue.childs.length; i++) { childValue = internalValue.childs[i]; if (childValue !== undefined && !(childValue instanceof Function)) { if (i < this.childs.length) { // reuse existing child, keep its state child = this.childs[i]; child.fieldEditable = false; child.index = i; child.setInternalValue(childValue); } else { // create a new child child = new Node(this.editor, { internalValue: childValue }); visible = i < this.getMaxVisibleChilds(); this.appendChild(child, visible, notUpdateDom); } } } // cleanup redundant childs // we loop backward to prevent issues with shifting index numbers for (j = this.childs.length; j >= internalValue.childs.length; j--) { this.removeChild(this.childs[j], notUpdateDom); } } else if (internalValue.type === 'object') { // object if (!this.childs) { this.childs = []; } for (i = 0; i < internalValue.childs.length; i++) { childValue = internalValue.childs[i]; if (childValue !== undefined && !(childValue instanceof Function)) { if (i < this.childs.length) { // reuse existing child, keep its state child = this.childs[i]; delete child.index; child.setField(childValue.field, true); child.setInternalValue(childValue.value); } else { // create a new child child = new Node(this.editor, { field: childValue.field, internalValue: childValue.value }); visible = i < this.getMaxVisibleChilds(); this.appendChild(child, visible, notUpdateDom); } } } // cleanup redundant childs // we loop backward to prevent issues with shifting index numbers for (j = this.childs.length; j >= internalValue.childs.length; j--) { this.removeChild(this.childs[j], notUpdateDom); } } else { // value this.hideChilds(); delete this.append; delete this.showMore; delete this.expanded; delete this.childs; this.value = internalValue.value; } // recreate the DOM if switching from an object/array to auto/string or vice versa // needed to recreated the expand button for example if (Array.isArray(previousChilds) !== Array.isArray(this.childs)) { this.recreateDom(); } this.updateDom({'updateIndexes': true}); this.previousValue = this.value; // used only to check for changes in DOM vs JS model }; /** * Remove the DOM of this node and it's childs and recreate it again */ Node.prototype.recreateDom = function() { if (this.dom && this.dom.tr && this.dom.tr.parentNode) { var domAnchor = this._detachFromDom(); this.clearDom(); this._attachToDom(domAnchor); } else { this.clearDom(); } }; /** * Get value. Value is a JSON structure * @return {*} value */ Node.prototype.getValue = function() { 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 internal value, a structure which maintains ordering and duplicates in objects * @return {*} value */ Node.prototype.getInternalValue = function() { if (this.type === 'array') { return { type: this.type, childs: this.childs.map (function (child) { return child.getInternalValue(); }) }; } else if (this.type === 'object') { return { type: this.type, childs: this.childs.map(function (child) { return { field: child.getField(), value: child.getInternalValue() } }) }; } else { if (this.value === undefined) { this._getDomValue(); } return { type: this.type, value: this.value }; } }; /** * Get the nesting level of this node * @return {Number} level */ Node.prototype.getLevel = function() { return (this.parent ? this.parent.getLevel() + 1 : 0); }; /** * Get jsonpath of the current node * @return {Node[]} Returns an array with nodes */ Node.prototype.getNodePath = function () { var path = this.parent ? this.parent.getNodePath() : []; path.push(this); return path; }; /** * 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.previousField = this.previousField; clone.value = this.value; clone.valueInnerText = this.valueInnerText; clone.previousValue = this.previousValue; clone.expanded = this.expanded; clone.visibleChilds = this.visibleChilds; 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 = 'jsoneditor-button jsoneditor-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 = 'jsoneditor-button jsoneditor-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.getAppendDom(); if (!append.parentNode) { var nextTr = tr.nextSibling; if (nextTr) { table.insertBefore(append, nextTr); } else { table.appendChild(append); } } // show childs var iMax = Math.min(this.childs.length, this.visibleChilds); var nextTr = this._getNextTr(); for (var i = 0; i < iMax; i++) { var child = this.childs[i]; if (!child.getDom().parentNode) { table.insertBefore(child.getDom(), nextTr); } child.showChilds(); } // show "show more childs" if limited var showMore = this.getShowMoreDom(); var nextTr = this._getNextTr(); if (!showMore.parentNode) { table.insertBefore(showMore, nextTr); } this.showMore.updateDom(); // to update the counter } }; Node.prototype._getNextTr = function() { if (this.showMore && this.showMore.getDom().parentNode) { return this.showMore.getDom(); } if (this.append && this.append.getDom().parentNode) { return this.append.getDom(); } }; /** * Hide the node with all its childs * @param {{resetVisibleChilds: boolean}} [options] */ Node.prototype.hide = function(options) { var tr = this.dom.tr; var table = tr ? tr.parentNode : undefined; if (table) { table.removeChild(tr); } this.hideChilds(options); }; /** * Recursively hide all childs * @param {{resetVisibleChilds: boolean}} [options] */ Node.prototype.hideChilds = function(options) { var childs = this.childs; if (!childs) { return; } if (!this.expanded) { return; } // hide append row var append = this.getAppendDom(); if (append.parentNode) { append.parentNode.removeChild(append); } // hide childs this.childs.forEach(function (child) { child.hide(); }); // hide "show more" row var showMore = this.getShowMoreDom(); if (showMore.parentNode) { showMore.parentNode.removeChild(showMore); } // reset max visible childs if (!options || options.resetVisibleChilds) { this.visibleChilds = this.getMaxVisibleChilds(); } }; /** * set custom css classes on a node */ Node.prototype._updateCssClassName = function () { if(this.dom.field && this.editor && this.editor.options && typeof this.editor.options.onClassName ==='function' && this.dom.tree){ util.removeAllClassNames(this.dom.tree); var addClasses = this.editor.options.onClassName({ path: this.getPath(), field: this.field, value: this.value }) || ""; util.addClassName(this.dom.tree, "jsoneditor-values " + addClasses); } }; Node.prototype.recursivelyUpdateCssClassesOnNodes = function () { this._updateCssClassName(); if (Array.isArray(this.childs)) { for (var i = 0; i < this.childs.length; i++) { this.childs[i].recursivelyUpdateCssClassesOnNodes(); } } } /** * Goes through the path from the node to the root and ensures that it is expanded */ Node.prototype.expandTo = function() { var currentNode = this.parent; while (currentNode) { if (!currentNode.expanded) { currentNode.expand(); } currentNode = currentNode.parent; } }; /** * Add a new child to the node. * Only applicable when Node value is of type array or object * @param {Node} node * @param {boolean} [visible] If true (default), the child will be rendered * @param {boolean} [updateDom] If true (default), the DOM of both parent * node and appended node will be updated * (child count, indexes) */ Node.prototype.appendChild = function(node, visible, updateDom) { 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; } if (this.type === 'object' && node.field == undefined) { // initialize field value if needed node.setField(''); } this.childs.push(node); if (this.expanded && visible !== false) { // insert into the DOM, before the appendRow var newTr = node.getDom(); var nextTr = this._getNextTr(); var table = nextTr ? nextTr.parentNode : undefined; if (nextTr && table) { table.insertBefore(newTr, nextTr); } node.showChilds(); this.visibleChilds++; } if (updateDom !== false) { 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 || !beforeNode) { // the this.childs.length + 1 is to reckon with the node that we're about to add if (this.childs.length + 1 > this.visibleChilds) { var lastVisibleNode = this.childs[this.visibleChilds - 1]; this.insertBefore(node, lastVisibleNode); } else { this.appendChild(node); } } else { this.insertBefore(node, beforeNode); } if (tbody) { tbody.removeChild(trTemp); } } }; /** * 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()) { this.visibleChilds++; // initialize field value if needed if (this.type === 'object' && node.field == undefined) { node.setField(''); } 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.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 * Searches are case insensitive. * @param {String} text * @param {Node[]} [results] Array where search results will be added * used to count and limit the results whilst iterating * @return {Node[]} results Array with nodes containing the search text */ Node.prototype.search = function(text, results) { if (!Array.isArray(results)) { 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 && results.length <= this.MAX_SEARCH_RESULTS) { 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) { this.childs.forEach(function (child) { child.search(text, results); }); } } else { // string, auto if (this.value !== undefined && results.length <= this.MAX_SEARCH_RESULTS) { 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) { this.expandPathToNode(); if (this.dom.tr && this.dom.tr.parentNode) { this.editor.scrollTo(this.dom.tr.offsetTop, callback); } }; /** * if the node is not visible, expand its parents */ Node.prototype.expandPathToNode = function () { var node = this; var recurse = false; while (node && node.parent) { // expand visible childs of the parent if needed var index = node.parent.type === 'array' ? node.index : node.parent.childs.indexOf(node); while (node.parent.visibleChilds < index + 1) { node.parent.visibleChilds += this.getMaxVisibleChilds(); } // expand the parent itself node.parent.expand(recurse); node = node.parent; } }; // 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.select) { // enum select box dom.select.focus(); } else 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); }; /** * 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; }; /** * 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; * @param {boolean} [updateDom] If true (default), the DOM of the parent * node will be updated (like child count) * @return {Node | undefined} node The removed node on success, * else undefined */ Node.prototype.removeChild = function(node, updateDom) { if (this.childs) { var index = this.childs.indexOf(node); if (index !== -1) { if (index < this.visibleChilds && this.expanded) { this.visibleChilds--; } node.hide(); // delete old search results delete node.searchField; delete node.searchValue; var removedNode = this.childs.splice(index, 1)[0]; removedNode.parent = null; if (updateDom !== false) { 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 fire 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 domAnchor = this._detachFromDom(); // delete the old DOM 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) { 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; } this._attachToDom(domAnchor); } 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}); }; /** * Test whether the JSON contents of this node are deep equal to provided JSON object. * @param {*} json */ Node.prototype.deepEqual = function (json) { var i; if (this.type === 'array') { if (!Array.isArray(json)) { return false; } if (this.childs.length !== json.length) { return false; } for (i = 0; i < this.childs.length; i++) { if (!this.childs[i].deepEqual(json[i])) { return false; } } } else if (this.type === 'object') { if (typeof json !== 'object' || !json) { return false; } // TODO: for better efficiency, we could create a property `isDuplicate` on all of the childs // and keep that up to date. This should make deepEqual about 20% faster. var props = {}; var propCount = 0; for (i = 0; i < this.childs.length; i++) { var child = this.childs[i]; if (!props[child.field]) { // We can have childs with duplicate field names. // We take the first, and ignore the others. props[child.field] = true; propCount++; if (!(child.field in json)) { return false; } if (!child.deepEqual(json[child.field])) { return false; } } } if (propCount !== Object.keys(json).length) { return false; } } else { if (this.value !== json) { return false; } } return true; }; /** * Retrieve value from DOM * @private */ Node.prototype._getDomValue = function() { this._clearValueError(); 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) { this.value = value; this._debouncedOnChangeValue(); } } catch (err) { // keep the previous value this._setValueError(translate('cannotParseValueError')); } } }; /** * Show a local error in case of invalid value * @param {string} message * @private */ Node.prototype._setValueError = function (message) { this.valueError = { message: message }; this.updateError(); } Node.prototype._clearValueError = function () { if (this.valueError) { this.valueError = null; this.updateError(); } } /** * Show a local error in case of invalid or duplicate field * @param {string} message * @private */ Node.prototype._setFieldError = function (message) { this.fieldError = { message: message }; this.updateError(); } Node.prototype._clearFieldError = function () { if (this.fieldError) { this.fieldError = null; this.updateError(); } } /** * Handle a changed value * @private */ Node.prototype._onChangeValue = function () { // get current selection, then override the range such that we can select // the added/removed text on undo/redo var oldSelection = this.editor.getDomSelection(); if (oldSelection.range) { var undoDiff = util.textDiff(String(this.value), String(this.previousValue)); oldSelection.range.startOffset = undoDiff.start; oldSelection.range.endOffset = undoDiff.end; } var newSelection = this.editor.getDomSelection(); if (newSelection.range) { var redoDiff = util.textDiff(String(this.previousValue), String(this.value)); newSelection.range.startOffset = redoDiff.start; newSelection.range.endOffset = redoDiff.end; } this.editor._onAction('editValue', { path: this.getInternalPath(), oldValue: this.previousValue, newValue: this.value, oldSelection: oldSelection, newSelection: newSelection }); this.previousValue = this.value; }; /** * Handle a changed field * @private */ Node.prototype._onChangeField = function () { // get current selection, then override the range such that we can select // the added/removed text on undo/redo var oldSelection = this.editor.getDomSelection(); var previous = this.previousField || ''; if (oldSelection.range) { var undoDiff = util.textDiff(this.field, previous); oldSelection.range.startOffset = undoDiff.start; oldSelection.range.endOffset = undoDiff.end; } var newSelection = this.editor.getDomSelection(); if (newSelection.range) { var redoDiff = util.textDiff(previous, this.field); newSelection.range.startOffset = redoDiff.start; newSelection.range.endOffset = redoDiff.end; } this.editor._onAction('editField', { parentPath: this.parent.getInternalPath(), index: this.getIndex(), oldValue: this.previousField, newValue: this.field, oldSelection: oldSelection, newSelection: newSelection }); this.previousField = this.field; }; /** * 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) { var classNames = ['jsoneditor-value']; // set text color depending on value type var value = this.value; var type = (this.type == 'auto') ? util.type(value) : this.type; var isUrl = type == 'string' && util.isUrl(value); classNames.push('jsoneditor-' + type); if (isUrl) { classNames.push('jsoneditor-url'); } // visual styling when empty var isEmpty = (String(this.value) == '' && this.type != 'array' && this.type != 'object'); if (isEmpty) { classNames.push('jsoneditor-empty'); } // highlight when there is a search result if (this.searchValueActive) { classNames.push('jsoneditor-highlight-active'); } if (this.searchValue) { classNames.push('jsoneditor-highlight'); } domValue.className = classNames.join(' '); // update title if (type == 'array' || type == 'object') { var count = this.childs ? this.childs.length : 0; domValue.title = this.type + ' containing ' + count + ' items'; } else if (isUrl && this.editable.value) { domValue.title = translate('openUrl'); } else { domValue.title = ''; } // show checkbox when the value is a boolean if (type === 'boolean' && this.editable.value) { if (!this.dom.checkbox) { this.dom.checkbox = document.createElement('input'); this.dom.checkbox.type = 'checkbox'; this.dom.tdCheckbox = document.createElement('td'); this.dom.tdCheckbox.className = 'jsoneditor-tree'; this.dom.tdCheckbox.appendChild(this.dom.checkbox); this.dom.tdValue.parentNode.insertBefore(this.dom.tdCheckbox, this.dom.tdValue); } this.dom.checkbox.checked = this.value; } else { // cleanup checkbox when displayed if (this.dom.tdCheckbox) { this.dom.tdCheckbox.parentNode.removeChild(this.dom.tdCheckbox); delete this.dom.tdCheckbox; delete this.dom.checkbox; } } // create select box when this node has an enum object if (this.enum && this.editable.value) { if (!this.dom.select) { this.dom.select = document.createElement('select'); this.id = this.field + "_" + new Date().getUTCMilliseconds(); this.dom.select.id = this.id; this.dom.select.name = this.dom.select.id; //Create the default empty option this.dom.select.option = document.createElement('option'); this.dom.select.option.value = ''; this.dom.select.option.innerHTML = '--'; this.dom.select.appendChild(this.dom.select.option); //Iterate all enum values and add them as options for(var i = 0; i < this.enum.length; i++) { this.dom.select.option = document.createElement('option'); this.dom.select.option.value = this.enum[i]; this.dom.select.option.innerHTML = this.enum[i]; if(this.dom.select.option.value == this.value){ this.dom.select.option.selected = true; } this.dom.select.appendChild(this.dom.select.option); } this.dom.tdSelect = document.createElement('td'); this.dom.tdSelect.className = 'jsoneditor-tree'; this.dom.tdSelect.appendChild(this.dom.select); this.dom.tdValue.parentNode.insertBefore(this.dom.tdSelect, this.dom.tdValue); } // If the enum is inside a composite type display // both the simple input and the dropdown field if(this.schema && ( !this.schema.hasOwnProperty("oneOf") && !this.schema.hasOwnProperty("anyOf") && !this.schema.hasOwnProperty("allOf")) ) { this.valueFieldHTML = this.dom.tdValue.innerHTML; this.dom.tdValue.style.visibility = 'hidden'; this.dom.tdValue.innerHTML = ''; } else { delete this.valueFieldHTML; } } else { // cleanup select box when displayed if (this.dom.tdSelect) { this.dom.tdSelect.parentNode.removeChild(this.dom.tdSelect); delete this.dom.tdSelect; delete this.dom.select; this.dom.tdValue.innerHTML = this.valueFieldHTML; this.dom.tdValue.style.visibility = ''; delete this.valueFieldHTML; } } // show color picker when value is a color if (this.editable.value && this.editor.options.colorPicker && typeof value === 'string' && util.isValidColor(value)) { if (!this.dom.color) { this.dom.color = document.createElement('div'); this.dom.color.className = 'jsoneditor-color'; this.dom.tdColor = document.createElement('td'); this.dom.tdColor.className = 'jsoneditor-tree'; this.dom.tdColor.appendChild(this.dom.color); this.dom.tdValue.parentNode.insertBefore(this.dom.tdColor, this.dom.tdValue); // this is a bit hacky, overriding the text color like this. find a nicer solution this.dom.value.style.color = '#1A1A1A'; } // update the color background this.dom.color.style.backgroundColor = value; } else { // cleanup color picker when displayed this._deleteDomColor(); } // show date tag when value is a timestamp in milliseconds if (this.editor.options.timestampTag && typeof value === 'number' && value > YEAR_2000 && !isNaN(new Date(value).valueOf())) { if (!this.dom.date) { this.dom.date = document.createElement('div'); this.dom.date.className = 'jsoneditor-date' this.dom.value.parentNode.appendChild(this.dom.date); } this.dom.date.innerHTML = new Date(value).toISOString(); this.dom.date.title = new Date(value).toString(); } else { // cleanup date tag if (this.dom.date) { this.dom.date.parentNode.removeChild(this.dom.date); delete this.dom.date; } } // strip formatting from the contents of the editable div util.stripFormatting(domValue); this._updateDomDefault(); } }; Node.prototype._deleteDomColor = function () { if (this.dom.color) { this.dom.tdColor.parentNode.removeChild(this.dom.tdColor); delete this.dom.tdColor; delete this.dom.color; this.dom.value.style.color = ''; } } /** * 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) { var tooltip = util.makeFieldTooltip(this.schema, this.editor.options.language); if (tooltip) { domField.title = tooltip; } // make backgound color lightgray when empty var isEmpty = (String(this.field) == '' && this.parent.type !=