jsoneditor
Version:
A web-based tool to view, edit, format, and validate JSON
1,797 lines (1,583 loc) • 136 kB
JavaScript
'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)
}