'use strict'; var naturalSort = require('javascript-natural-sort'); var ContextMenu = require('./ContextMenu'); var appendNodeFactory = require('./appendNodeFactory'); var util = require('./util'); /** * @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); this.setValue(params.value, params.type); } 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); } // debounce interval for keyboard input in milliseconds Node.prototype.DEBOUNCE_INTERVAL = 150; /** * 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[]} Array containing the path to this node */ Node.prototype.getPath = function () { var node = this; var path = []; while (node) { var field = !node.parent ? undefined // do not add an (optional) field name of the root node : (node.parent.type != 'array') ? node.field : node.index; if (field !== undefined) { path.unshift(field); } node = node.parent; } return path; }; /** * 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.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) { // ensure the dom exists this.getDom(); this.error = error; var tdError = this.dom.tdError; if (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.className = '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 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 (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} Returns the index, or -1 if this is the root node */ Node.prototype.getIndex = function () { return this.parent ? this.parent.childs.indexOf(this) : -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; // 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 = ''; // sort object keys if (this.editor.options.sortObjectKeys === true) { this.sort('asc'); } } else { // value this.childs = undefined; this.value = value; } this.previousValue = this.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); }; /** * Get path of the root node till 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.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 = '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-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); }; /** * 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]; removedNode.parent = null; 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 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) { this.value = value; this._debouncedOnChangeValue(); } } catch (err) { this.value = undefined; // TODO: sent an action with the new, invalid value? if (silent !== true) { throw err; } } } }; /** * 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.getSelection(); 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.getSelection(); 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', { node: this, 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.getSelection(); if (oldSelection.range) { var undoDiff = util.textDiff(this.field, this.previousField); oldSelection.range.startOffset = undoDiff.start; oldSelection.range.endOffset = undoDiff.end; } var newSelection = this.editor.getSelection(); if (newSelection.range) { var redoDiff = util.textDiff(this.previousField, this.field); newSelection.range.startOffset = redoDiff.start; newSelection.range.endOffset = redoDiff.end; } this.editor._onAction('editField', { node: this, 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 = 'Ctrl+Click or Ctrl+Enter to open url in new window'; } 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; } } if (this.enum && this.editable.value) { // create select box when this node has an enum object 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; } } // 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, 'jsoneditor-empty'); } else { util.removeClassName(domField, 'jsoneditor-empty'); } // highlight when there is a search result if (this.searchFieldActive) { util.addClassName(domField, 'jsoneditor-highlight-active'); } else { util.removeClassName(domField, 'jsoneditor-highlight-active'); } if (this.searchField) { util.addClassName(domField, 'jsoneditor-highlight'); } else { util.removeClassName(domField, 'jsoneditor-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) { this.field = field; this._debouncedOnChangeField(); } } catch (err) { this.field = undefined; // TODO: sent an action here, with the new, invalid value? if (silent !== true) { throw err; } } } }; /** * Validate this node and all it's childs * @return {Array.<{node: Node, error: {message: string}}>} Returns a list with duplicates */ Node.prototype.validate = function () { var errors = []; // find duplicate keys if (this.type === 'object') { var keys = {}; var duplicateKeys = []; for (var i = 0; i < this.childs.length; i++) { var child = this.childs[i]; if (keys[child.field]) { duplicateKeys.push(child.field); } keys[child.field] = true; } if (duplicateKeys.length > 0) { errors = this.childs .filter(function (node) { return duplicateKeys.indexOf(node.field) !== -1; }) .map(function (node) { return { node: node, error: { message: 'duplicate key "' + node.field + '"' } } }); } } // recurse over the childs if (this.childs) { for (var i = 0; i < this.childs.length; i++) { var e = this.childs[i].validate(); if (e.length > 0) { errors = errors.concat(e); } } } return errors; }; /** * 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; } this._updateEditability(); // create row dom.tr = document.createElement('tr'); dom.tr.node = this; if (this.editor.options.mode === 'tree') { // note: we take here the global setting var tdDrag = document.createElement('td'); if (this.editable.field) { // create draggable area if (this.parent) { var domDrag = document.createElement('button'); dom.drag = domDrag; domDrag.className = 'jsoneditor-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 = 'jsoneditor-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 {Node[] | Node} nodes * @param {Event} event */ Node.onDragStart = function (nodes, event) { if (!Array.isArray(nodes)) { return Node.onDragStart([nodes], event); } if (nodes.length === 0) { return; } var firstNode = nodes[0]; var lastNode = nodes[nodes.length - 1]; var draggedNode = Node.getNodeFromTarget(event.target); var beforeNode = lastNode._nextSibling(); var editor = firstNode.editor; // in case of multiple selected nodes, offsetY prevents the selection from // jumping when you start dragging one of the lower down nodes in the selection var offsetY = util.getAbsoluteTop(draggedNode.dom.tr) - util.getAbsoluteTop(firstNode.dom.tr); if (!editor.mousemove) { editor.mousemove = util.addEventListener(window, 'mousemove', function (event) { Node.onDrag(nodes, event); }); } if (!editor.mouseup) { editor.mouseup = util.addEventListener(window, 'mouseup',function (event ) { Node.onDragEnd(nodes, event); }); } editor.highlighter.lock(); editor.drag = { oldCursor: document.body.style.cursor, oldSelection: editor.getSelection(), oldBeforeNode: beforeNode, mouseX: event.pageX, offsetY: offsetY, level: firstNode.getLevel() }; document.body.style.cursor = 'move'; event.preventDefault(); }; /** * Drag event, fired when moving the mouse while dragging a Node * @param {Node[] | Node} nodes * @param {Event} event */ Node.onDrag = function (nodes, event) { if (!Array.isArray(nodes)) { return Node.onDrag([nodes], event); } if (nodes.length === 0) { return; } // TODO: this method has grown too large. Split it in a number of methods var editor = nodes[0].editor; var mouseY = event.pageY - editor.drag.offsetY; var mouseX = event.pageX; 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 var firstNode = nodes[0]; trThis = firstNode.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 == firstNode) { 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) { nodes.forEach(function (node) { nodePrev.parent.moveBefore(node, nodePrev); }); moved = true; } } else { // move down var lastNode = nodes[nodes.length - 1]; trLast = (lastNode.expanded && lastNode.append) ? lastNode.append.getDom() : lastNode.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 == nodes.length && nodeNext.parent.childs[nodes.length - 1] == lastNode) { // We are about to remove the last child of this parent, // which will make the parents appendNode visible. topThis += 27; // TODO: dangerous to suppose the height of the appendNode a constant of 27 px. } } trNext = trNext.nextSibling; } while (trNext && mouseY > topThis + heightNext); if (nodeNext && nodeNext.parent) { // calculate the desired level var diffX = (mouseX - editor.drag.mouseX); var diffLevel = Math.round(diffX / 24 / 2); var level = editor.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); var isDraggedNode = nodes.some(function (node) { return node === nodePrev || nodePrev._isChildOf(node); }); if (isDraggedNode) { // neglect the dragged nodes themselves and their childs } else if (nodePrev instanceof AppendNode) { var childs = nodePrev.parent.childs; if (childs.length != nodes.length || childs[nodes.length - 1] != lastNode) { // 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) { nodes.forEach(function (node) { nodeNext.parent.moveBefore(node, nodeNext); }); moved = true; } } } } if (moved) { // update the dragging parameters when moved editor.drag.mouseX = mouseX; editor.drag.level = firstNode.getLevel(); } // auto scroll when hovering around the top of the editor editor.startAutoScroll(mouseY); event.preventDefault(); }; /** * Drag event, fired on mouseup after having dragged a node * @param {Node[] | Node} nodes * @param {Event} event */ Node.onDragEnd = function (nodes, event) { if (!Array.isArray(nodes)) { return Node.onDrag([nodes], event); } if (nodes.length === 0) { return; } var firstNode = nodes[0]; var editor = firstNode.editor; var parent = firstNode.parent; var firstIndex = parent.childs.indexOf(firstNode); var beforeNode = parent.childs[firstIndex + nodes.length] || parent.append; // set focus to the context menu button of the first node if (nodes[0]) { nodes[0].dom.menu.focus(); } var params = { nodes: nodes, oldSelection: editor.drag.oldSelection, newSelection: editor.getSelection(), oldBeforeNode: editor.drag.oldBeforeNode, newBeforeNode: beforeNode }; if (params.oldBeforeNode != params.newBeforeNode) { // only register this action if the node is actually moved to another place editor._onAction('moveNodes', params); } document.body.style.cursor = editor.drag.oldCursor; editor.highlighter.unlock(); nodes.forEach(function (node) { if (event.target !== node.dom.drag && event.target !== node.dom.menu) { editor.highlighter.unhighlight(); } }); delete editor.drag; if (editor.mousemove) { util.removeEventListener(window, 'mousemove', editor.mousemove); delete editor.mousemove; } if (editor.mouseup) { util.removeEventListener(window, 'mouseup', editor.mouseup); delete editor.mouseup; } // Stop any running auto scroll editor.stopAutoScroll(); event.preventDefault(); }; /** * 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) { if (highlight) { util.addClassName(this.dom.tr, 'jsoneditor-highlight'); } else { util.removeClassName(this.dom.tr, 'jsoneditor-highlight'); } if (this.append) { this.append.setHighlight(highlight); } if (this.childs) { this.childs.forEach(function (child) { child.setHighlight(highlight); }); } } }; /** * Select or deselect a node * @param {boolean} selected * @param {boolean} [isFirst] */ Node.prototype.setSelected = function (selected, isFirst) { this.selected = selected; if (this.dom.tr) { if (selected) { util.addClassName(this.dom.tr, 'jsoneditor-selected'); } else { util.removeClassName(this.dom.tr, 'jsoneditor-selected'); } if (isFirst) { util.addClassName(this.dom.tr, 'jsoneditor-first'); } else { util.removeClassName(this.dom.tr, 'jsoneditor-first'); } if (this.append) { this.append.setSelected(selected); } if (this.childs) { this.childs.forEach(function (child) { child.setSelected(selected); }); } } }; /** * 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'; } // apply field to DOM var domField = this.dom.field; if (domField) { if (this.fieldEditable) { // parent is an object domField.contentEditable = this.editable.field; domField.spellcheck = false; domField.className = 'jsoneditor-field'; } else { // parent is an array this is the root node domField.className = 'jsoneditor-readonly'; } var fieldText; if (this.index != undefined) { fieldText = this.index; } else if (this.field != undefined) { fieldText = this.field; } else if (this._hasChilds()) { fieldText = this.type; } else { fieldText = ''; } domField.innerHTML = this._escapeHTML(fieldText); this._updateSchema(); } // apply value to DOM var domValue = this.dom.value; if (domValue) { var count = this.childs ? this.childs.length : 0; if (this.type == 'array') { domValue.innerHTML = '[' + count + ']'; util.addClassName(this.dom.tr, 'jsoneditor-expandable'); } else if (this.type == 'object') { domValue.innerHTML = '{' + count + '}'; util.addClassName(this.dom.tr, 'jsoneditor-expandable'); } else { domValue.innerHTML = this._escapeHTML(this.value); util.removeClassName(this.dom.tr, 'jsoneditor-expandable'); } } // 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(); } }; /** * Locate the JSON schema of the node and check for any enum type * @private */ Node.prototype._updateSchema = function () { //Locating the schema of the node and checking for any enum type if(this.editor && this.editor.options) { // find the part of the json schema matching this nodes path this.schema = Node._findSchema(this.editor.options.schema, this.getPath()); if (this.schema) { this.enum = Node._findEnum(this.schema); } else { delete this.enum; } } }; /** * find an enum definition in a JSON schema, as property `enum` or inside * one of the schemas composites (`oneOf`, `anyOf`, `allOf`) * @param {Object} schema * @return {Array | null} Returns the enum when found, null otherwise. * @private */ Node._findEnum = function (schema) { if (schema.enum) { return schema.enum; } var composite = schema.oneOf || schema.anyOf || schema.allOf; if (composite) { var match = composite.filter(function (entry) {return entry.enum}); if (match.length > 0) { return match[0].enum; } } return null }; /** * Return the part of a JSON schema matching given path. * @param {Object} schema * @param {Array.} path * @return {Object | null} * @private */ Node._findSchema = function (schema, path) { var childSchema = schema; for (var i = 0; i < path.length && childSchema; i++) { var key = path[i]; if (typeof key === 'string' && childSchema.properties) { childSchema = childSchema.properties[key] || null } else if (typeof key === 'number' && childSchema.items) { childSchema = childSchema.items } } return childSchema }; /** * 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.innerHTML = '[...]'; } else if (this.type == 'object') { domValue = document.createElement('div'); domValue.innerHTML = '{...}'; } else { if (!this.editable.value && util.isUrl(this.value)) { // create a link in case of read-only editor and value containing an url domValue = document.createElement('a'); domValue.href = this.value; domValue.target = '_blank'; domValue.innerHTML = this._escapeHTML(this.value); } else { // create an editable or read-only div domValue = document.createElement('div'); domValue.contentEditable = this.editable.value; domValue.spellcheck = false; 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 ? 'jsoneditor-expanded' : 'jsoneditor-collapsed'; expand.title = 'Click to expand/collapse this field (Ctrl+E). \n' + 'Ctrl+Click to expand/collapse including all childs.'; } else { expand.className = 'jsoneditor-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 = 'jsoneditor-values'; domTree.appendChild(tbody); var tr = document.createElement('tr'); tbody.appendChild(tr); // create expand button var tdExpand = document.createElement('td'); tdExpand.className = 'jsoneditor-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 = 'jsoneditor-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 = 'jsoneditor-tree'; tr.appendChild(tdSeparator); if (this.type != 'object' && this.type != 'array') { tdSeparator.appendChild(document.createTextNode(':')); tdSeparator.className = 'jsoneditor-separator'; } dom.tdSeparator = tdSeparator; // create the value var tdValue = document.createElement('td'); tdValue.className = 'jsoneditor-tree'; tr.appendChild(tdValue); dom.value = this._createDomValue(); tdValue.appendChild(dom.value); dom.tdValue = tdValue; return domTree; }; /** * Handle an event. The event is caught 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, 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(); } } // context menu events if (type == 'click' && target == dom.menu) { var highlighter = node.editor.highlighter; highlighter.highlight(node); highlighter.lock(); util.addClassName(dom.menu, 'jsoneditor-selected'); this.showContextMenu(dom.menu, function () { util.removeClassName(dom.menu, 'jsoneditor-selected'); highlighter.unlock(); highlighter.unhighlight(); }); } // expand events if (type == 'click') { if (target == dom.expand || ((node.editor.options.mode === 'view' || node.editor.options.mode === 'form') && target.nodeName === 'DIV')) { if (expandable) { var recurse = event.ctrlKey; // with ctrl-key, expand/collapse all this._onExpand(recurse); } } } // swap the value of a boolean when the checkbox displayed left is clicked if (type == 'change' && target == dom.checkbox) { this.dom.value.innerHTML = !this.value; this._getDomValue(); } // update the value of the node based on the selected option if (type == 'change' && target == dom.select) { this.dom.value.innerHTML = dom.select.value; this._getDomValue(); this._updateDomValue(); } // value events var domValue = dom.value; if (target == domValue) { //noinspection FallthroughInSwitchStatementJS switch (type) { case 'blur': case 'change': this._getDomValue(true); this._updateDomValue(); if (this.value) { domValue.innerHTML = this._escapeHTML(this.value); } break; case 'input': //this._debouncedGetDomValue(true); // TODO this._getDomValue(true); this._updateDomValue(); break; case 'keydown': case 'mousedown': // TODO: cleanup this.editor.selection = this.editor.getSelection(); break; case 'click': if (event.ctrlKey || !this.editable.value) { if (util.isUrl(this.value)) { window.open(this.value, '_blank'); } } break; case 'keyup': //this._debouncedGetDomValue(true); // TODO 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 'blur': case 'change': this._getDomField(true); this._updateDomField(); if (this.field) { domField.innerHTML = this._escapeHTML(this.field); } break; case 'input': this._getDomField(true); this._updateSchema(); this._updateDomField(); this._updateDomValue(); 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 && type == 'click' && !event.hasMoved) { var left = (event.offsetX != undefined) ? (event.offsetX < (this.getLevel() + 1) * 24) : (event.pageX < 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 && !this.enum) { util.setEndOfContentEditable(domValue); domValue.focus(); } } } if (((target == dom.tdExpand && !expandable) || target == dom.tdField || target == dom.tdSeparator) && (type == 'click' && !event.hasMoved)) { if (domField) { util.setEndOfContentEditable(domField); domField.focus(); } } 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; var editable = this.editor.options.mode === 'tree'; var oldSelection; var oldBeforeNode; var nodes; var multiselection; var selectedNodes = this.editor.multiselection.nodes.length > 0 ? this.editor.multiselection.nodes : [this]; var firstNode = selectedNodes[0]; var lastNode = selectedNodes[selectedNodes.length - 1]; // console.log(ctrlKey, keynum, event.charCode); // TODO: cleanup if (keynum == 13) { // Enter if (target == this.dom.value) { if (!this.editable.value || 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 && editable) { // Ctrl+D Node.onDuplicate(selectedNodes); 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 && editable) { // M if (ctrlKey) { // Ctrl+M this.showContextMenu(target); handled = true; } } else if (keynum == 46 && editable) { // Del if (ctrlKey) { // Ctrl+Del Node.onRemove(selectedNodes); handled = true; } } else if (keynum == 45 && editable) { // 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 endNode = this._lastNode(); if (endNode) { endNode.focus(Node.focusElement || this._getElementName(target)); } handled = true; } } else if (keynum == 36) { // Home if (altKey) { // Alt+Home // find the first node var homeNode = this._firstNode(); if (homeNode) { homeNode.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 && editable) { // Alt + Shift + Arrow left if (lastNode.expanded) { var appendDom = lastNode.getAppend(); nextDom = appendDom ? appendDom.nextSibling : undefined; } else { var dom = lastNode.getDom(); nextDom = dom.nextSibling; } if (nextDom) { nextNode = Node.getNodeFromTarget(nextDom); nextDom2 = nextDom.nextSibling; nextNode2 = Node.getNodeFromTarget(nextDom2); if (nextNode && nextNode instanceof AppendNode && !(lastNode.parent.childs.length == 1) && nextNode2 && nextNode2.parent) { oldSelection = this.editor.getSelection(); oldBeforeNode = lastNode._nextSibling(); selectedNodes.forEach(function (node) { nextNode2.parent.moveBefore(node, nextNode2); }); this.focus(Node.focusElement || this._getElementName(target)); this.editor._onAction('moveNodes', { nodes: selectedNodes, oldBeforeNode: oldBeforeNode, newBeforeNode: nextNode2, oldSelection: oldSelection, newSelection: this.editor.getSelection() }); } } } } else if (keynum == 38) { // Arrow Up if (altKey && !shiftKey) { // Alt + Arrow Up // find the previous node prevNode = this._previousNode(); if (prevNode) { this.editor.deselect(true); prevNode.focus(Node.focusElement || this._getElementName(target)); } handled = true; } else if (!altKey && ctrlKey && shiftKey && editable) { // Ctrl + Shift + Arrow Up // select multiple nodes prevNode = this._previousNode(); if (prevNode) { multiselection = this.editor.multiselection; multiselection.start = multiselection.start || this; multiselection.end = prevNode; nodes = this.editor._findTopLevelNodes(multiselection.start, multiselection.end); this.editor.select(nodes); prevNode.focus('field'); // select field as we know this always exists } handled = true; } else if (altKey && shiftKey && editable) { // Alt + Shift + Arrow Up // find the previous node prevNode = firstNode._previousNode(); if (prevNode && prevNode.parent) { oldSelection = this.editor.getSelection(); oldBeforeNode = lastNode._nextSibling(); selectedNodes.forEach(function (node) { prevNode.parent.moveBefore(node, prevNode); }); this.focus(Node.focusElement || this._getElementName(target)); this.editor._onAction('moveNodes', { nodes: selectedNodes, oldBeforeNode: oldBeforeNode, newBeforeNode: prevNode, oldSelection: oldSelection, newSelection: this.editor.getSelection() }); } 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 && editable) { // Alt + Shift + Arrow Right dom = firstNode.getDom(); var prevDom = dom.previousSibling; if (prevDom) { prevNode = Node.getNodeFromTarget(prevDom); if (prevNode && prevNode.parent && (prevNode instanceof AppendNode) && !prevNode.isVisible()) { oldSelection = this.editor.getSelection(); oldBeforeNode = lastNode._nextSibling(); selectedNodes.forEach(function (node) { prevNode.parent.moveBefore(node, prevNode); }); this.focus(Node.focusElement || this._getElementName(target)); this.editor._onAction('moveNodes', { nodes: selectedNodes, oldBeforeNode: oldBeforeNode, newBeforeNode: prevNode, oldSelection: oldSelection, newSelection: this.editor.getSelection() }); } } } } else if (keynum == 40) { // Arrow Down if (altKey && !shiftKey) { // Alt + Arrow Down // find the next node nextNode = this._nextNode(); if (nextNode) { this.editor.deselect(true); nextNode.focus(Node.focusElement || this._getElementName(target)); } handled = true; } else if (!altKey && ctrlKey && shiftKey && editable) { // Ctrl + Shift + Arrow Down // select multiple nodes nextNode = this._nextNode(); if (nextNode) { multiselection = this.editor.multiselection; multiselection.start = multiselection.start || this; multiselection.end = nextNode; nodes = this.editor._findTopLevelNodes(multiselection.start, multiselection.end); this.editor.select(nodes); nextNode.focus('field'); // select field as we know this always exists } handled = true; } else if (altKey && shiftKey && editable) { // Alt + Shift + Arrow Down // find the 2nd next node and move before that one if (lastNode.expanded) { nextNode = lastNode.append ? lastNode.append._nextNode() : undefined; } else { nextNode = lastNode._nextNode(); } var nextNode2 = nextNode && (nextNode._nextNode() || nextNode.parent.append); if (nextNode2 && nextNode2.parent) { oldSelection = this.editor.getSelection(); oldBeforeNode = lastNode._nextSibling(); selectedNodes.forEach(function (node) { nextNode2.parent.moveBefore(node, nextNode2); }); this.focus(Node.focusElement || this._getElementName(target)); this.editor._onAction('moveNodes', { nodes: selectedNodes, oldBeforeNode: oldBeforeNode, newBeforeNode: nextNode2, oldSelection: oldSelection, newSelection: this.editor.getSelection() }); } handled = true; } } if (handled) { event.preventDefault(); event.stopPropagation(); } }; /** * 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 nodes * @param {Node[] | Node} nodes */ Node.onRemove = function(nodes) { if (!Array.isArray(nodes)) { return Node.onRemove([nodes]); } if (nodes && nodes.length > 0) { var firstNode = nodes[0]; var parent = firstNode.parent; var editor = firstNode.editor; var firstIndex = firstNode.getIndex(); editor.highlighter.unhighlight(); // adjust the focus var oldSelection = editor.getSelection(); Node.blurNodes(nodes); var newSelection = editor.getSelection(); // remove the nodes nodes.forEach(function (node) { node.parent._remove(node); }); // store history action editor._onAction('removeNodes', { nodes: nodes.slice(0), // store a copy of the array! parent: parent, index: firstIndex, oldSelection: oldSelection, newSelection: newSelection }); } }; /** * Duplicate nodes * duplicated nodes will be added right after the original nodes * @param {Node[] | Node} nodes */ Node.onDuplicate = function(nodes) { if (!Array.isArray(nodes)) { return Node.onDuplicate([nodes]); } if (nodes && nodes.length > 0) { var lastNode = nodes[nodes.length - 1]; var parent = lastNode.parent; var editor = lastNode.editor; editor.deselect(editor.multiselection.nodes); // duplicate the nodes var oldSelection = editor.getSelection(); var afterNode = lastNode; var clones = nodes.map(function (node) { var clone = node.clone(); parent.insertAfter(clone, afterNode); afterNode = clone; return clone; }); // set selection to the duplicated nodes if (nodes.length === 1) { clones[0].focus(); } else { editor.select(clones); } var newSelection = editor.getSelection(); editor._onAction('duplicateNodes', { afterNode: lastNode, nodes: clones, parent: 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('insertBeforeNodes', { nodes: [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('insertAfterNodes', { nodes: [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('appendNodes', { nodes: [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 child's 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.sort = function (direction) { if (!this._hasChilds()) { return; } var order = (direction == 'desc') ? -1 : 1; var prop = (this.type == 'array') ? 'value': 'field'; this.hideChilds(); var oldChilds = this.childs; var oldSortOrder = this.sortOrder; // 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) { return order * naturalSort(a[prop], b[prop]); }); this.sortOrder = (order == 1) ? 'asc' : 'desc'; this.editor._onAction('sort', { node: this, oldChilds: oldChilds, oldSort: oldSortOrder, newChilds: this.childs, newSort: this.sortOrder }); 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; }; /** * Remove the focus of given nodes, and move the focus to the (a) node before, * (b) the node after, or (c) the parent node. * @param {Array. | Node} nodes */ Node.blurNodes = function (nodes) { if (!Array.isArray(nodes)) { Node.blurNodes([nodes]); return; } var firstNode = nodes[0]; var parent = firstNode.parent; var firstIndex = firstNode.getIndex(); if (parent.childs[firstIndex + nodes.length]) { parent.childs[firstIndex + nodes.length].focus(); } else if (parent.childs[firstIndex - 1]) { parent.childs[firstIndex - 1].focus(); } else { parent.focus(); } }; /** * Get the next sibling of current node * @return {Node} nextSibling * @private */ Node.prototype._nextSibling = function () { var index = this.parent.childs.indexOf(this); return this.parent.childs[index + 1] || this.parent.append; }; /** * 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 attach the context menu to * as sibling. * @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 = []; if (this.editable.value) { items.push({ text: 'Type', title: 'Change the type of this field', className: 'jsoneditor-type-' + this.type, submenu: [ { text: 'Auto', className: 'jsoneditor-type-auto' + (this.type == 'auto' ? ' jsoneditor-selected' : ''), title: titles.auto, click: function () { node._onChangeType('auto'); } }, { text: 'Array', className: 'jsoneditor-type-array' + (this.type == 'array' ? ' jsoneditor-selected' : ''), title: titles.array, click: function () { node._onChangeType('array'); } }, { text: 'Object', className: 'jsoneditor-type-object' + (this.type == 'object' ? ' jsoneditor-selected' : ''), title: titles.object, click: function () { node._onChangeType('object'); } }, { text: 'String', className: 'jsoneditor-type-string' + (this.type == 'string' ? ' jsoneditor-selected' : ''), title: titles.string, click: function () { node._onChangeType('string'); } } ] }); } if (this._hasChilds()) { var direction = ((this.sortOrder == 'asc') ? 'desc': 'asc'); items.push({ text: 'Sort', title: 'Sort the childs of this ' + this.type, className: 'jsoneditor-sort-' + direction, click: function () { node.sort(direction); }, submenu: [ { text: 'Ascending', className: 'jsoneditor-sort-asc', title: 'Sort the childs of this ' + this.type + ' in ascending order', click: function () { node.sort('asc'); } }, { text: 'Descending', className: 'jsoneditor-sort-desc', title: 'Sort the childs of this ' + this.type +' in descending order', click: function () { node.sort('desc'); } } ] }); } if (this.parent && this.parent._hasChilds()) { if (items.length) { // 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: 'jsoneditor-append', click: function () { node._onAppend('', '', 'auto'); }, submenu: [ { text: 'Auto', className: 'jsoneditor-type-auto', title: titles.auto, click: function () { node._onAppend('', '', 'auto'); } }, { text: 'Array', className: 'jsoneditor-type-array', title: titles.array, click: function () { node._onAppend('', []); } }, { text: 'Object', className: 'jsoneditor-type-object', title: titles.object, click: function () { node._onAppend('', {}); } }, { text: 'String', className: 'jsoneditor-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: 'jsoneditor-insert', click: function () { node._onInsertBefore('', '', 'auto'); }, submenu: [ { text: 'Auto', className: 'jsoneditor-type-auto', title: titles.auto, click: function () { node._onInsertBefore('', '', 'auto'); } }, { text: 'Array', className: 'jsoneditor-type-array', title: titles.array, click: function () { node._onInsertBefore('', []); } }, { text: 'Object', className: 'jsoneditor-type-object', title: titles.object, click: function () { node._onInsertBefore('', {}); } }, { text: 'String', className: 'jsoneditor-type-string', title: titles.string, click: function () { node._onInsertBefore('', '', 'string'); } } ] }); if (this.editable.field) { // create duplicate button items.push({ text: 'Duplicate', title: 'Duplicate this field (Ctrl+D)', className: 'jsoneditor-duplicate', click: function () { Node.onDuplicate(node); } }); // create remove button items.push({ text: 'Remove', title: 'Remove this field (Ctrl+Del)', className: 'jsoneditor-remove', click: function () { Node.onRemove(node); } }); } } var menu = new ContextMenu(items, {close: onClose}); menu.show(anchor, this.editor.content); }; /** * 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) { if (typeof text !== 'string') { return String(text); } else { var htmlEscaped = String(text) .replace(/&/g, '&') // must be replaced first! .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); var html = json.substring(1, json.length - 1); if (this.editor.options.escapeUnicode === true) { html = util.escapeUnicodeChars(html); } return html; } }; /** * 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(/ |\u00A0/g, ' ') .replace(/&/g, '&'); // must be replaced last }; /** * 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; while (i < text.length) { var c = text.charAt(i); if (c == '\n') { escaped += '\\n'; } else if (c == '\\') { escaped += c; i++; c = text.charAt(i); if (c === '' || '"\\/bfnrtu'.indexOf(c) == -1) { escaped += '\\'; // no valid escape character } escaped += c; } else if (c == '"') { escaped += '\\"'; } else { escaped += c; } i++; } return escaped; }; // TODO: find a nicer solution to resolve this circular dependency between Node and AppendNode var AppendNode = appendNodeFactory(Node); module.exports = Node;