UNPKG

jsoneditor

Version:

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

1,797 lines (1,583 loc) 136 kB
'use strict' import naturalSort from 'javascript-natural-sort' import { createAbsoluteAnchor } from './createAbsoluteAnchor' import { ContextMenu } from './ContextMenu' import { appendNodeFactory } from './appendNodeFactory' import { showMoreNodeFactory } from './showMoreNodeFactory' import { showSortModal } from './showSortModal' import { showTransformModal } from './showTransformModal' import { addClassName, addEventListener, debounce, escapeUnicodeChars, findUniqueName, getAbsoluteLeft, getAbsoluteTop, getInnerText, getType, isTimestamp, isUrl, isValidColor, makeFieldTooltip, parse, parsePath, parseString, removeAllClassNames, removeClassName, removeEventListener, selectContentEditable, setEndOfContentEditable, stripFormatting, textDiff } from './util' import { translate } from './i18n' import { DEFAULT_MODAL_ANCHOR } from './constants' /** * @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'. */ export class Node { constructor (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 = debounce(this._onChangeValue.bind(this), Node.prototype.DEBOUNCE_INTERVAL) this._debouncedOnChangeField = debounce(this._onChangeField.bind(this), Node.prototype.DEBOUNCE_INTERVAL) // starting value for visible children this.visibleChilds = this.getMaxVisibleChilds() } getMaxVisibleChilds () { 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 */ _updateEditability () { 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')) { const getValue = this.getValue.bind(this) const editable = this.editor.options.onEditable({ field: this.field, get value () { return getValue() }, path: this.getPath() }) if (typeof editable === 'boolean') { this.editable.field = editable this.editable.value = editable } else if (typeof editable === 'object' && editable !== null) { if (typeof editable.field === 'boolean') this.editable.field = editable.field if (typeof editable.value === 'boolean') this.editable.value = editable.value } else { console.error( 'Invalid return value for function onEditable.', 'Actual value:', editable, '.', 'Either a boolean or object { field: boolean, value: boolean } expected.') this.editable.field = false this.editable.value = false } } } } /** * 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. */ getPath () { let node = this const path = [] while (node) { const 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 */ getInternalPath () { let node = this const internalPath = [] while (node) { if (node.parent) { internalPath.unshift(node.getIndex()) } node = node.parent } return internalPath } /** * Get node serializable name * @returns {String|Number} */ getName () { 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 */ findNodeByPath (path) { if (!path) { return } if (path.length === 0) { return this } if (path.length && this.childs && this.childs.length) { for (let 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. */ findNodeByInternalPath (internalPath) { if (!internalPath) { return undefined } let node = this for (let i = 0; i < internalPath.length && node; i++) { const 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} */ serialize () { 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 */ findNode (jsonPath) { const path = parsePath(jsonPath) let node = this while (node && path.length > 0) { const 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(child => 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>} */ findParents () { const parents = [] let 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. */ setError (error, child) { this.error = error this.errorChild = child if (this.dom && this.dom.tr) { this.updateError() } } /** * Render the error */ updateError () { const error = this.fieldError || this.valueError || this.error let tdError = this.dom.tdError if (error && this.dom && this.dom.tr) { addClassName(this.dom.tr, 'jsoneditor-validation-error') if (!tdError) { tdError = document.createElement('td') this.dom.tdError = tdError this.dom.tdValue.parentNode.appendChild(tdError) } const button = document.createElement('button') button.type = 'button' button.className = 'jsoneditor-button jsoneditor-schema-error' const destroy = () => { if (this.dom.popupAnchor) { this.dom.popupAnchor.destroy() // this will trigger the onDestroy callback } } const onDestroy = () => { delete this.dom.popupAnchor } const createPopup = (destroyOnMouseOut) => { const frame = this.editor.frame this.dom.popupAnchor = createAbsoluteAnchor(button, this.editor.getPopupAnchor(), onDestroy, destroyOnMouseOut) const popupWidth = 200 // must correspond to what's configured in the CSS const buttonRect = button.getBoundingClientRect() const frameRect = frame.getBoundingClientRect() const position = (frameRect.width - buttonRect.x > (popupWidth / 2 + 20)) ? 'jsoneditor-above' : 'jsoneditor-left' const popover = document.createElement('div') popover.className = 'jsoneditor-popover ' + position popover.appendChild(document.createTextNode(error.message)) this.dom.popupAnchor.appendChild(popover) } button.onmouseover = () => { if (!this.dom.popupAnchor) { createPopup(true) } } button.onfocus = () => { destroy() createPopup(false) } button.onblur = () => { destroy() } // when clicking the error icon, expand all nodes towards the invalid // child node, and set focus to the child node const child = this.errorChild if (child) { button.onclick = function showInvalidNode () { child.findParents().forEach(parent => { parent.expand(false) }) child.scrollTo(() => { child.focus() }) } } // apply the error message to the node while (tdError.firstChild) { tdError.removeChild(tdError.firstChild) } tdError.appendChild(button) } else { if (this.dom.tr) { 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 */ getIndex () { if (this.parent) { const index = this.parent.childs.indexOf(this) return index !== -1 ? index : null } else { return -1 } } /** * Set parent node * @param {Node} parent */ setParent (parent) { this.parent = parent } /** * Set field * @param {String} field * @param {boolean} [fieldEditable] */ setField (field, fieldEditable) { this.field = field this.previousField = field this.fieldEditable = (fieldEditable === true) } /** * Get field * @return {String} */ getField () { 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' */ setValue (value, type) { let childValue, child let i, j const updateDom = false const 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 }) const visible = i < this.getMaxVisibleChilds() this.appendChild(child, visible, updateDom) } } } // 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], updateDom) } } 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 (!hasOwnProperty(value, this.childs[j].field)) { this.removeChild(this.childs[j], updateDom) } } i = 0 for (const childField in value) { if (hasOwnProperty(value, childField)) { childValue = value[childField] if (childValue !== undefined && !(childValue instanceof Function)) { const child = this.findChildByProperty(childField) if (child) { // reuse existing child, keep its state child.setField(childField, true) child.setValue(childValue) } else { // create a new child, append to the end const newChild = new Node(this.editor, { field: childField, value: childValue }) const visible = i < this.getMaxVisibleChilds() this.appendChild(newChild, visible, updateDom) } } i++ } } this.value = '' // sort object keys during initialization. Must not trigger an onChange action if (this.editor.options.sortObjectKeys === true) { const triggerAction = false this.sort([], 'asc', triggerAction) } } 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 */ setInternalValue (internalValue) { let childValue, child, visible let i, j const notUpdateDom = false const 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 */ recreateDom () { if (this.dom && this.dom.tr && this.dom.tr.parentNode) { const domAnchor = this._detachFromDom() this.clearDom() this._attachToDom(domAnchor) } else { this.clearDom() } } /** * Get value. Value is a JSON structure * @return {*} value */ getValue () { if (this.type === 'array') { const arr = [] this.childs.forEach(child => { arr.push(child.getValue()) }) return arr } else if (this.type === 'object') { const obj = {} this.childs.forEach(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 */ getInternalValue () { if (this.type === 'array') { return { type: this.type, childs: this.childs.map(child => child.getInternalValue()) } } else if (this.type === 'object') { return { type: this.type, childs: this.childs.map(child => ({ 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 */ getLevel () { return (this.parent ? this.parent.getLevel() + 1 : 0) } /** * Get jsonpath of the current node * @return {Node[]} Returns an array with nodes */ getNodePath () { const 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 */ clone () { const 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 const cloneChilds = [] this.childs.forEach(child => { const 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 */ expand (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(child => { child.expand(recurse) }) } // update the css classes of table row, and fire onClassName etc this.updateDom({ recurse: false }) } /** * Collapse this node and optionally its childs. * @param {boolean} [recurse] Optional recursion, true by default. When * true, all childs will be collapsed recursively */ collapse (recurse) { if (!this.childs) { return } this.hideChilds() // collapse childs in case of recurse if (recurse !== false) { this.childs.forEach(child => { child.collapse(recurse) }) } // make this node collapsed if (this.dom.expand) { this.dom.expand.className = 'jsoneditor-button jsoneditor-collapsed' } this.expanded = false // update the css classes of table row, and fire onClassName etc this.updateDom({ recurse: false }) } /** * Recursively show all childs when they are expanded */ showChilds () { const childs = this.childs if (!childs) { return } if (!this.expanded) { return } const tr = this.dom.tr let nextTr const table = tr ? tr.parentNode : undefined if (table) { // show row with append button const append = this.getAppendDom() if (!append.parentNode) { nextTr = tr.nextSibling if (nextTr) { table.insertBefore(append, nextTr) } else { table.appendChild(append) } } // show childs const iMax = Math.min(this.childs.length, this.visibleChilds) nextTr = this._getNextTr() for (let i = 0; i < iMax; i++) { const child = this.childs[i] if (!child.getDom().parentNode) { table.insertBefore(child.getDom(), nextTr) } child.showChilds() } // show "show more childs" if limited const showMore = this.getShowMoreDom() nextTr = this._getNextTr() if (!showMore.parentNode) { table.insertBefore(showMore, nextTr) } this.showMore.updateDom() // to update the counter } } _getNextTr () { 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] */ hide (options) { const tr = this.dom.tr const table = tr ? tr.parentNode : undefined if (table) { table.removeChild(tr) } if (this.dom.popupAnchor) { this.dom.popupAnchor.destroy() } this.hideChilds(options) } /** * Recursively hide all childs * @param {{resetVisibleChilds: boolean}} [options] */ hideChilds (options) { const childs = this.childs if (!childs) { return } if (!this.expanded) { return } // hide append row const append = this.getAppendDom() if (append.parentNode) { append.parentNode.removeChild(append) } // hide childs this.childs.forEach(child => { child.hide() }) // hide "show more" row const 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 */ _updateCssClassName () { if (this.dom.field && this.editor && this.editor.options && typeof this.editor.options.onClassName === 'function' && this.dom.tree) { removeAllClassNames(this.dom.tree) const getValue = this.getValue.bind(this) const addClasses = this.editor.options.onClassName({ path: this.getPath(), field: this.field, get value () { return getValue() } }) || '' addClassName(this.dom.tree, 'jsoneditor-values ' + addClasses) } } recursivelyUpdateCssClassesOnNodes () { this._updateCssClassName() if (Array.isArray(this.childs)) { for (let 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 */ expandTo () { let 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) */ appendChild (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 const newTr = node.getDom() const nextTr = this._getNextTr() const 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 * @param {boolean} [updateDom] If true (default), the DOM of both parent * node and appended node will be updated * (child count, indexes) */ moveBefore (node, beforeNode, updateDom) { if (this._hasChilds()) { // create a temporary row, to prevent the scroll position from jumping // when removing the node const tbody = (this.dom.tr) ? this.dom.tr.parentNode : undefined let trTemp if (tbody) { 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) { const lastVisibleNode = this.childs[this.visibleChilds - 1] this.insertBefore(node, lastVisibleNode, updateDom) } else { const visible = true this.appendChild(node, visible, updateDom) } } else { this.insertBefore(node, beforeNode, updateDom) } if (tbody && trTemp) { 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 * @param {boolean} [updateDom] If true (default), the DOM of both parent * node and appended node will be updated * (child count, indexes) */ insertBefore (node, beforeNode, updateDom) { 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 const 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 const newTr = node.getDom() const nextTr = beforeNode.getDom() const table = nextTr ? nextTr.parentNode : undefined if (nextTr && table) { table.insertBefore(newTr, nextTr) } node.showChilds() this.showChilds() } if (updateDom !== false) { 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 */ insertAfter (node, afterNode) { if (this._hasChilds()) { const index = this.childs.indexOf(afterNode) const 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 */ search (text, results) { if (!Array.isArray(results)) { results = [] } let index const 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) { const 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(child => { child.search(text, results) }) } } else { // string, auto if (this.value !== undefined && results.length <= this.MAX_SEARCH_RESULTS) { const 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] */ scrollTo (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 */ expandPathToNode () { let node = this const recurse = false while (node && node.parent) { // expand visible childs of the parent if needed const 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 } } /** * 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) */ focus (elementName) { Node.focusElement = elementName if (this.dom.tr && this.dom.tr.parentNode) { const 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() selectContentEditable(dom.field) } else if (dom.value && !this._hasChilds()) { dom.value.focus() selectContentEditable(dom.value) } else { dom.menu.focus() } break case 'field': if (dom.field && this.fieldEditable) { dom.field.focus() selectContentEditable(dom.field) } else if (dom.value && !this._hasChilds()) { dom.value.focus() 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() selectContentEditable(dom.value) } else if (dom.field && this.fieldEditable) { dom.field.focus() selectContentEditable(dom.field) } else if (this._hasChilds()) { dom.expand.focus() } else { dom.menu.focus() } break } } } /** * Check if given node is a child. The method will check recursively to find * this node. * @param {Node} node * @return {boolean} containsNode */ containsNode (node) { if (this === node) { return true } const childs = this.childs if (childs) { // TODO: use the js5 Array.some() here? for (let 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 */ removeChild (node, updateDom) { if (this.childs) { const 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 const 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 */ _remove (node) { this.removeChild(node) } /** * Change the type of the value of this Node * @param {String} newType */ changeType (newType) { const 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 const 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(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((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 = parseString(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 */ deepEqual (json) { let 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 } // we reckon with the order of the properties too. const props = Object.keys(json) if (this.childs.length !== props.length) { return false } for (i = 0; i < props.length; i++) { const child = this.childs[i] if (child.field !== props[i] || !child.deepEqual(json[child.field])) { return false } } } else { if (this.value !== json) { return false } } return true } /** * Retrieve value from DOM * @private */ _getDomValue () { this._clearValueError() if (this.dom.value && this.type !== 'array' && this.type !== 'object') { this.valueInnerText = getInnerText(this.dom.value) if (this.valueInnerText === '' && this.dom.value.innerHTML !== '') { // When clearing the contents, often a <br/> remains, messing up the // styling of the empty text box. Therefore we remove the <br/> this.dom.value.textContent = '' } } if (this.valueInnerText !== undefined) { try { // retrieve the value let value if (this.type === 'string') { value = this._unescapeHTML(this.valueInnerText) } else { const str = this._unescapeHTML(this.valueInnerText) value = parseString(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 */ _setValueError (message) { this.valueError = { message } this.updateError() } _clearValueError () { if (this.valueError) { this.valueError = null this.updateError() } } /** * Show a local error in case of invalid or duplicate field * @param {string} message * @private */ _setFieldError (message) { this.fieldError = { message } this.updateError() } _clearFieldError () { if (this.fieldError) { this.fieldError = null this.updateError() } } /** * Handle a changed value * @private */ _onChangeValue () { // get current selection, then override the range such that we can select // the added/removed text on undo/redo const oldSelection = this.editor.getDomSelection() if (oldSelection.range) { const undoDiff = textDiff(String(this.value), String(this.previousValue)) oldSelection.range.startOffset = undoDiff.start oldSelection.range.endOffset = undoDiff.end } const newSelection = this.editor.getDomSelection() if (newSelection.range) { const redoDiff = 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, newSelection }) this.previousValue = this.value } /** * Handle a changed field * @private */ _onChangeField () { // get current selection, then override the range such that we can select // the added/removed text on undo/redo const oldSelection = this.editor.getDomSelection() const previous = this.previousField || '' if (oldSelection.range) { const undoDiff = textDiff(this.field, previous) oldSelection.range.startOffset = undoDiff.start oldSelection.range.endOffset = undoDiff.end } const newSelection = this.editor.getDomSelection() if (newSelection.range) { const redoDiff = 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, 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 */ _updateDomValue () { const domValue = this.dom.value if (domValue) { const classNames = ['jsoneditor-value'] // set text color depending on value type const value = this.value const valueType = (this.type === 'auto') ? getType(value) : this.type const valueIsUrl = valueType === 'string' && isUrl(value) classNames.push('jsoneditor-' + valueType) if (valueIsUrl) { classNames.push('jsoneditor-url') } // visual styling when empty const 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 (valueType === 'array' || valueType === 'object') { const count = this.childs ? this.childs.length : 0 domValue.title = this.type + ' containing ' + count + ' items' } else if (valueIsUrl && this.editable.value) { domValue.title = translate('openUrl') } else { domValue.title = '' } // show checkbox when the value is a boolean if (valueType === '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 const defaultOption = document.createElement('option') defaultOption.value = '' defaultOption.textContent = '--' this.dom.select.appendChild(defaultOption) // Iterate all enum values and add them as options this._updateEnumOptions() 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) } // Select the matching value this.dom.select.value = (this.enum.indexOf(this.value) !== -1) ? this.value : '' // default // If the enum is inside a composite type display // both the simple input and the dropdown field if (this.schema && ( !hasOwnProperty(this.schema, 'oneOf') && !hasOwnProperty(this.schema, 'anyOf') && !hasOwnProperty(this.schema, 'allOf')) ) { this.valueFieldHTML = this.dom.tdValue.innerHTML this.dom.tdValue.style.visibility = 'hidden' this.dom.tdValue.textContent = '' } else { delete this.valueFieldHTML } } else { // cleanup select box when displayed, and attach the editable div instead 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 this.dom.tdValue.appendChild(this.dom.value) } } // show color picker when value is a color if (this.editor.options.colorPicker && typeof value === 'string' && 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) }