jsoneditor
Version:
A web-based tool to view, edit, format, and validate JSON
1,769 lines (1,564 loc) • 53 kB
JavaScript
'use strict'
import { autocomplete } from './autocomplete'
import { ContextMenu } from './ContextMenu'
import { FocusTracker } from './FocusTracker'
import { Highlighter } from './Highlighter'
import { setLanguage, setLanguages, translate } from './i18n'
import { createQuery, executeQuery } from './jmespathQuery'
import { ModeSwitcher } from './ModeSwitcher'
import { Node } from './Node'
import { NodeHistory } from './NodeHistory'
import { SearchBox } from './SearchBox'
import { TreePath } from './TreePath'
import {
addClassName,
addEventListener,
debounce,
getAbsoluteTop,
getSelectionOffset,
getWindow,
hasParentNode,
improveSchemaError,
isPromise,
isValidationErrorChanged,
isValidValidationError,
parse,
removeClassName,
removeEventListener,
selectContentEditable,
setSelectionOffset,
tryJsonRepair
} from './util'
import VanillaPicker from './vanilla-picker'
// create a mixin with the functions for tree mode
const treemode = {}
/**
* Create a tree editor
* @param {Element} container Container element
* @param {Object} [options] Object with options. See docs for details.
* @private
*/
treemode.create = function (container, options) {
if (!container) {
throw new Error('No container element provided.')
}
this.container = container
this.dom = {}
this.highlighter = new Highlighter()
this.selection = undefined // will hold the last input selection
this.multiselection = {
nodes: []
}
this.validateSchema = null // will be set in .setSchema(schema)
this.validationSequence = 0
this.errorNodes = []
this.lastSchemaErrors = undefined
this.node = null
this.focusTarget = null
this._setOptions(options)
if (options.autocomplete) { this.autocomplete = autocomplete(options.autocomplete) }
if (this.options.history && this.options.mode !== 'view') {
this.history = new NodeHistory(this)
}
this._createFrame()
this._createTable()
}
/**
* Destroy the editor. Clean up DOM, event listeners, and web workers.
*/
treemode.destroy = function () {
if (this.frame && this.container && this.frame.parentNode === this.container) {
this.container.removeChild(this.frame)
this.frame = null
}
this.container = null
this.dom = null
this.clear()
this.node = null
this.focusTarget = null
this.selection = null
this.multiselection = null
this.errorNodes = null
this.validateSchema = null
this._debouncedValidate = null
if (this.history) {
this.history.destroy()
this.history = null
}
if (this.searchBox) {
this.searchBox.destroy()
this.searchBox = null
}
if (this.modeSwitcher) {
this.modeSwitcher.destroy()
this.modeSwitcher = null
}
// Removing the FocusTracker set to track the editor's focus event
this.frameFocusTracker.destroy()
}
/**
* Initialize and set default options
* @param {Object} [options] See description in constructor
* @private
*/
treemode._setOptions = function (options) {
this.options = {
search: true,
history: true,
mode: 'tree',
name: undefined, // field name of root node
schema: null,
schemaRefs: null,
autocomplete: null,
navigationBar: true,
mainMenuBar: true,
limitDragging: false,
onSelectionChange: null,
colorPicker: true,
onColorPicker: function (parent, color, onChange) {
if (VanillaPicker) {
// we'll render the color picker on top
// when there is not enough space below, and there is enough space above
const pickerHeight = 300 // estimated height of the color picker
const top = parent.getBoundingClientRect().top
const windowHeight = getWindow(parent).innerHeight
const showOnTop = ((windowHeight - top) < pickerHeight && top > pickerHeight)
new VanillaPicker({
parent,
color,
popup: showOnTop ? 'top' : 'bottom',
onDone: function (color) {
const alpha = color.rgba[3]
const hex = (alpha === 1)
? color.hex.substr(0, 7) // return #RRGGBB
: color.hex // return #RRGGBBAA
onChange(hex)
}
}).show()
} else {
console.warn('Cannot open color picker: the `vanilla-picker` library is not included in the bundle. ' +
'Either use the full bundle or implement your own color picker using `onColorPicker`.')
}
},
timestampTag: true,
timestampFormat: null,
createQuery,
executeQuery,
onEvent: null,
enableSort: true,
enableTransform: true
}
// copy all options
if (options) {
Object.keys(options).forEach(prop => {
this.options[prop] = options[prop]
})
// default limitDragging to true when a JSON schema is defined
if (options.limitDragging == null && options.schema != null) {
this.options.limitDragging = true
}
}
// compile a JSON schema validator if a JSON schema is provided
this.setSchema(this.options.schema, this.options.schemaRefs)
// create a debounced validate function
this._debouncedValidate = debounce(this._validateAndCatch.bind(this), this.DEBOUNCE_INTERVAL)
if (options.onSelectionChange) {
this.onSelectionChange(options.onSelectionChange)
}
setLanguages(this.options.languages)
setLanguage(this.options.language)
}
/**
* Set new JSON object in editor.
* Resets the state of the editor (expanded nodes, search, selection).
*
* @param {*} json
*/
treemode.set = function (json) {
// verify if json is valid JSON, ignore when a function
if (json instanceof Function || (json === undefined)) {
this.clear()
} else {
this.content.removeChild(this.table) // Take the table offline
// replace the root node
const params = {
field: this.options.name,
value: json
}
const node = new Node(this, params)
this._setRoot(node)
// validate JSON schema (if configured)
this._validateAndCatch()
// expand
const recurse = false
this.node.expand(recurse)
this.content.appendChild(this.table) // Put the table online again
}
// TODO: maintain history, store last state and previous document
if (this.history) {
this.history.clear()
}
// clear search
if (this.searchBox) {
this.searchBox.clear()
}
}
/**
* Update JSON object in editor.
* Maintains the state of the editor (expanded nodes, search, selection).
*
* @param {*} json
*/
treemode.update = function (json) {
// don't update if there are no changes
if (this.node.deepEqual(json)) {
return
}
const selection = this.getSelection()
// apply the changed json
this.onChangeDisabled = true // don't fire an onChange event
this.node.update(json)
this.onChangeDisabled = false
// validate JSON schema
this._validateAndCatch()
// update search result if any
if (this.searchBox && !this.searchBox.isEmpty()) {
this.searchBox.forceSearch()
}
// update selection if any
if (selection && selection.start && selection.end) {
// only keep/update the selection if both start and end node still exists,
// else we clear the selection
const startNode = this.node.findNodeByPath(selection.start.path)
const endNode = this.node.findNodeByPath(selection.end.path)
if (startNode && endNode) {
this.setSelection(selection.start, selection.end)
} else {
this.setSelection({}, {}) // clear selection
}
} else {
this.setSelection({}, {}) // clear selection
}
}
/**
* Get JSON object from editor
* @return {Object | undefined} json
*/
treemode.get = function () {
// TODO: resolve pending debounced input changes if any, but do not resolve invalid inputs
if (this.node) {
return this.node.getValue()
} else {
return undefined
}
}
/**
* Get the text contents of the editor
* @return {String} jsonText
*/
treemode.getText = function () {
return JSON.stringify(this.get())
}
/**
* Set the text contents of the editor.
* Resets the state of the editor (expanded nodes, search, selection).
* @param {String} jsonText
*/
treemode.setText = function (jsonText) {
try {
this.set(parse(jsonText)) // this can throw an error
} catch (err) {
// try to repair json, replace JavaScript notation with JSON notation
const repairedJsonText = tryJsonRepair(jsonText)
// try to parse again
this.set(parse(repairedJsonText)) // this can throw an error
}
}
/**
* Update the text contents of the editor.
* Maintains the state of the editor (expanded nodes, search, selection).
* @param {String} jsonText
*/
treemode.updateText = function (jsonText) {
try {
this.update(parse(jsonText)) // this can throw an error
} catch (err) {
// try to repair json, replace JavaScript notation with JSON notation
const repairJsonText = tryJsonRepair(jsonText)
// try to parse again
this.update(parse(repairJsonText)) // this can throw an error
}
}
/**
* Set a field name for the root node.
* @param {String | undefined} name
*/
treemode.setName = function (name) {
this.options.name = name
if (this.node) {
this.node.updateField(this.options.name)
}
}
/**
* Get the field name for the root node.
* @return {String | undefined} name
*/
treemode.getName = function () {
return this.options.name
}
/**
* Set focus to the editor. Focus will be set to:
* - the first editable field or value, or else
* - to the expand button of the root node, or else
* - to the context menu button of the root node, or else
* - to the first button in the top menu
*/
treemode.focus = function () {
let input = this.scrollableContent.querySelector('[contenteditable=true]')
if (input) {
input.focus()
} else if (this.node.dom.expand) {
this.node.dom.expand.focus()
} else if (this.node.dom.menu) {
this.node.dom.menu.focus()
} else {
// focus to the first button in the menu
input = this.frame.querySelector('button')
if (input) {
input.focus()
}
}
}
/**
* Remove the root node from the editor
*/
treemode.clear = function () {
if (this.node) {
this.node.hide()
delete this.node
}
if (this.treePath) {
this.treePath.reset()
}
}
/**
* Set the root node for the json editor
* @param {Node} node
* @private
*/
treemode._setRoot = function (node) {
this.clear()
this.node = node
node.setParent(null)
node.setField(this.getName(), false)
delete node.index
// append to the dom
this.tbody.appendChild(node.getDom())
}
/**
* Search text in all nodes
* The nodes will be expanded when the text is found one of its childs,
* else it will be collapsed. Searches are case insensitive.
* @param {String} text
* @return {Object[]} results Array with nodes containing the search results
* The result objects contains fields:
* - {Node} node,
* - {String} elem the dom element name where
* the result is found ('field' or
* 'value')
*/
treemode.search = function (text) {
let results
if (this.node) {
this.content.removeChild(this.table) // Take the table offline
results = this.node.search(text)
this.content.appendChild(this.table) // Put the table online again
} else {
results = []
}
return results
}
/**
* Expand all nodes
*/
treemode.expandAll = function () {
if (this.node) {
this.content.removeChild(this.table) // Take the table offline
this.node.expand()
this.content.appendChild(this.table) // Put the table online again
}
}
/**
* Collapse all nodes
*/
treemode.collapseAll = function () {
if (this.node) {
this.content.removeChild(this.table) // Take the table offline
this.node.collapse()
this.content.appendChild(this.table) // Put the table online again
}
}
/**
* Expand/collapse a given JSON node.
* @param {Object} [options] Available parameters:
* {Array<String>} [path] Path for the node to expand/collapse.
* {Boolean} [isExpand] Whether to expand the node (else collapse).
* {Boolean} [recursive] Whether to expand/collapse child nodes recursively.
*/
treemode.expand = function (options) {
if (!options) return
const node = this.node ? this.node.findNodeByPath(options.path) : null
if (!node) return
if (options.isExpand) {
node.expand(options.recursive)
} else {
node.collapse(options.recursive)
}
}
/**
* The method onChange is called whenever a field or value is changed, created,
* deleted, duplicated, etc.
* @param {String} action Change action. Available values: "editField",
* "editValue", "changeType", "appendNode",
* "removeNode", "duplicateNode", "moveNode", "expand",
* "collapse".
* @param {Object} params Object containing parameters describing the change.
* The parameters in params depend on the action (for
* example for "editValue" the Node, old value, and new
* value are provided). params contains all information
* needed to undo or redo the action.
* @private
*/
treemode._onAction = function (action, params) {
// add an action to the history
if (this.history) {
this.history.add(action, params)
}
this._onChange()
}
/**
* Handle a change:
* - Validate JSON schema
* - Send a callback to the onChange listener if provided
* @private
*/
treemode._onChange = function () {
if (this.onChangeDisabled) {
return
}
// selection can be changed after undo/redo
this.selection = this.getDomSelection()
// validate JSON schema (if configured)
this._debouncedValidate()
if (this.treePath) {
const selectedNode = (this.node && this.selection)
? this.node.findNodeByInternalPath(this.selection.path)
: this.multiselection
? this.multiselection.nodes[0]
: undefined
if (selectedNode) {
this._updateTreePath(selectedNode.getNodePath())
} else {
this.treePath.reset()
}
}
// trigger the onChange callback
if (this.options.onChange) {
try {
this.options.onChange()
} catch (err) {
console.error('Error in onChange callback: ', err)
}
}
// trigger the onChangeJSON callback
if (this.options.onChangeJSON) {
try {
this.options.onChangeJSON(this.get())
} catch (err) {
console.error('Error in onChangeJSON callback: ', err)
}
}
// trigger the onChangeText callback
if (this.options.onChangeText) {
try {
this.options.onChangeText(this.getText())
} catch (err) {
console.error('Error in onChangeText callback: ', err)
}
}
// trigger the onClassName callback
if (this.options.onClassName) {
this.node.recursivelyUpdateCssClassesOnNodes()
}
// trigger the onNodeName callback
if (this.options.onNodeName && this.node.childs) {
try {
this.node.recursivelyUpdateNodeName()
} catch (err) {
console.error('Error in onNodeName callback: ', err)
}
}
}
/**
* Validate current JSON object against the configured JSON schema
* Throws an exception when no JSON schema is configured
*/
treemode.validate = function () {
const root = this.node
if (!root) { // TODO: this should be redundant but is needed on mode switch
return
}
const json = root.getValue()
// execute JSON schema validation
let schemaErrors = []
if (this.validateSchema) {
const valid = this.validateSchema(json)
if (!valid) {
// apply all new errors
schemaErrors = this.validateSchema.errors
.map(error => improveSchemaError(error))
.map(function findNode (error) {
return {
node: root.findNode(error.dataPath),
error,
type: 'validation'
}
})
.filter(function hasNode (entry) {
return entry.node != null
})
}
}
// execute custom validation and after than merge and render all errors
try {
this.validationSequence++
const me = this
const seq = this.validationSequence
return this._validateCustom(json)
.then(customValidationErrors => {
// only apply when there was no other validation started whilst resolving async results
if (seq === me.validationSequence) {
const errorNodes = [].concat(schemaErrors, customValidationErrors || [])
me._renderValidationErrors(errorNodes)
if (
typeof this.options.onValidationError === 'function' &&
isValidationErrorChanged(errorNodes, this.lastSchemaErrors)
) {
this.options.onValidationError.call(this, errorNodes)
}
this.lastSchemaErrors = errorNodes
}
return this.lastSchemaErrors
})
} catch (err) {
return Promise.reject(err)
}
}
treemode._validateAndCatch = function () {
this.validate().catch(err => {
console.error('Error running validation:', err)
})
}
treemode._renderValidationErrors = function (errorNodes) {
// clear all current errors
if (this.errorNodes) {
this.errorNodes.forEach(node => {
node.setError(null)
})
}
// render the new errors
const parentPairs = errorNodes
.reduce((all, entry) => entry.node
.findParents()
.filter(parent => !all.some(pair => pair[0] === parent))
.map(parent => [parent, entry.node])
.concat(all), [])
this.errorNodes = parentPairs
.map(pair => ({
node: pair[0],
child: pair[1],
error: {
message: pair[0].type === 'object'
? translate('containsInvalidProperties') // object
: translate('containsInvalidItems') // array
}
}))
.concat(errorNodes)
.map(function setError (entry) {
entry.node.setError(entry.error, entry.child)
return entry.node
})
}
/**
* Execute custom validation if configured.
*
* Returns a promise resolving with the custom errors (or nothing).
*/
treemode._validateCustom = function (json) {
try {
if (this.options.onValidate) {
const root = this.node
const customValidateResults = this.options.onValidate(json)
const resultPromise = isPromise(customValidateResults)
? customValidateResults
: Promise.resolve(customValidateResults)
return resultPromise.then(customValidationPathErrors => {
if (Array.isArray(customValidationPathErrors)) {
return customValidationPathErrors
.filter(error => {
const valid = isValidValidationError(error)
if (!valid) {
console.warn('Ignoring a custom validation error with invalid structure. ' +
'Expected structure: {path: [...], message: "..."}. ' +
'Actual error:', error)
}
return valid
})
.map(error => {
let node
try {
node = (error && error.path) ? root.findNodeByPath(error.path) : null
} catch (err) {
// stay silent here, we throw a generic warning if no node is found
}
if (!node) {
console.warn('Ignoring validation error: node not found. Path:', error.path, 'Error:', error)
}
return {
node,
error,
type: 'customValidation'
}
})
.filter(entry => entry && entry.node && entry.error && entry.error.message)
} else {
return null
}
})
}
} catch (err) {
return Promise.reject(err)
}
return Promise.resolve(null)
}
/**
* Refresh the rendered contents
*/
treemode.refresh = function () {
if (this.node) {
this.node.updateDom({ recurse: true })
}
}
/**
* Start autoscrolling when given mouse position is above the top of the
* editor contents, or below the bottom.
* @param {Number} mouseY Absolute mouse position in pixels
*/
treemode.startAutoScroll = function (mouseY) {
const me = this
const content = this.scrollableContent
const top = getAbsoluteTop(content)
const height = content.clientHeight
const bottom = top + height
const margin = 24
const interval = 50 // ms
if ((mouseY < top + margin) && content.scrollTop > 0) {
this.autoScrollStep = ((top + margin) - mouseY) / 3
} else if (mouseY > bottom - margin &&
height + content.scrollTop < content.scrollHeight) {
this.autoScrollStep = ((bottom - margin) - mouseY) / 3
} else {
this.autoScrollStep = undefined
}
if (this.autoScrollStep) {
if (!this.autoScrollTimer) {
this.autoScrollTimer = setInterval(() => {
if (me.autoScrollStep) {
content.scrollTop -= me.autoScrollStep
} else {
me.stopAutoScroll()
}
}, interval)
}
} else {
this.stopAutoScroll()
}
}
/**
* Stop auto scrolling. Only applicable when scrolling
*/
treemode.stopAutoScroll = function () {
if (this.autoScrollTimer) {
clearTimeout(this.autoScrollTimer)
delete this.autoScrollTimer
}
if (this.autoScrollStep) {
delete this.autoScrollStep
}
}
/**
* Set the focus to an element in the editor, set text selection, and
* set scroll position.
* @param {Object} selection An object containing fields:
* {Element | undefined} dom The dom element
* which has focus
* {Range | TextRange} range A text selection
* {Node[]} nodes Nodes in case of multi selection
* {Number} scrollTop Scroll position
*/
treemode.setDomSelection = function (selection) {
if (!selection) {
return
}
if ('scrollTop' in selection && this.scrollableContent) {
// TODO: animated scroll
this.scrollableContent.scrollTop = selection.scrollTop
}
if (selection.paths) {
// multi-select
const me = this
const nodes = selection.paths.map(path => me.node.findNodeByInternalPath(path))
this.select(nodes)
} else {
// find the actual DOM element where to apply the focus
const node = selection.path
? this.node.findNodeByInternalPath(selection.path)
: null
const container = (node && selection.domName)
? node.dom[selection.domName]
: null
if (selection.range && container) {
const range = Object.assign({}, selection.range, { container })
setSelectionOffset(range)
} else if (node) { // just a fallback
node.focus()
}
}
}
/**
* Get the current focus
* @return {Object} selection An object containing fields:
* {Element | undefined} dom The dom element
* which has focus
* {Range | TextRange} range A text selection
* {Node[]} nodes Nodes in case of multi selection
* {Number} scrollTop Scroll position
*/
treemode.getDomSelection = function () {
// find the node and field name of the current target,
// so we can store the current selection in a serializable
// way (internal node path and domName)
const node = Node.getNodeFromTarget(this.focusTarget)
const focusTarget = this.focusTarget
const domName = node
? Object.keys(node.dom).find(domName => node.dom[domName] === focusTarget)
: null
let range = getSelectionOffset()
if (range && range.container.nodeName !== 'DIV') { // filter on (editable) divs)
range = null
}
if (range && range.container !== focusTarget) {
range = null
}
if (range) {
// we cannot rely on the current instance of the container,
// we need to store the internal node path and field and
// find the actual DOM field when applying the selection
delete range.container
}
return {
path: node ? node.getInternalPath() : null,
domName,
range,
paths: this.multiselection.length > 0
? this.multiselection.nodes.map(node => node.getInternalPath())
: null,
scrollTop: this.scrollableContent ? this.scrollableContent.scrollTop : 0
}
}
/**
* Adjust the scroll position such that given top position is shown at 1/4
* of the window height.
* @param {Number} top
* @param {function(boolean)} [animateCallback] Callback, executed when animation is
* finished. The callback returns true
* when animation is finished, or false
* when not.
*/
treemode.scrollTo = function (top, animateCallback) {
const content = this.scrollableContent
if (content) {
const editor = this
// cancel any running animation
if (editor.animateTimeout) {
clearTimeout(editor.animateTimeout)
delete editor.animateTimeout
}
if (editor.animateCallback) {
editor.animateCallback(false)
delete editor.animateCallback
}
// calculate final scroll position
const height = content.clientHeight
const bottom = content.scrollHeight - height
const finalScrollTop = Math.min(Math.max(top - height / 4, 0), bottom)
// animate towards the new scroll position
const animate = () => {
const scrollTop = content.scrollTop
const diff = (finalScrollTop - scrollTop)
if (Math.abs(diff) > 3) {
content.scrollTop += diff / 3
editor.animateCallback = animateCallback
editor.animateTimeout = setTimeout(animate, 50)
} else {
// finished
if (animateCallback) {
animateCallback(true)
}
content.scrollTop = finalScrollTop
delete editor.animateTimeout
delete editor.animateCallback
}
}
animate()
} else {
if (animateCallback) {
animateCallback(false)
}
}
}
/**
* Create main frame
* @private
*/
treemode._createFrame = function () {
// create the frame
this.frame = document.createElement('div')
this.frame.className = 'jsoneditor jsoneditor-mode-' + this.options.mode
// this.frame.setAttribute("tabindex","0");
this.container.appendChild(this.frame)
this.contentOuter = document.createElement('div')
this.contentOuter.className = 'jsoneditor-outer'
// create one global event listener to handle all events from all nodes
const editor = this
function onEvent (event) {
// when switching to mode "code" or "text" via the menu, some events
// are still fired whilst the _onEvent methods is already removed.
if (editor._onEvent) {
editor._onEvent(event)
}
}
// setting the FocusTracker on 'this.frame' to track the editor's focus event
const focusTrackerConfig = {
target: this.frame,
onFocus: this.options.onFocus || null,
onBlur: this.options.onBlur || null
}
this.frameFocusTracker = new FocusTracker(focusTrackerConfig)
this.frame.onclick = event => {
const target = event.target// || event.srcElement;
onEvent(event)
// prevent default submit action of buttons when editor is located
// inside a form
if (target.nodeName === 'BUTTON') {
event.preventDefault()
}
}
this.frame.oninput = onEvent
this.frame.onchange = onEvent
this.frame.onkeydown = onEvent
this.frame.onkeyup = onEvent
this.frame.oncut = onEvent
this.frame.onpaste = onEvent
this.frame.onmousedown = onEvent
this.frame.onmouseup = onEvent
this.frame.onmouseover = onEvent
this.frame.onmouseout = onEvent
// Note: focus and blur events do not propagate, therefore they defined
// using an eventListener with useCapture=true
// see http://www.quirksmode.org/blog/archives/2008/04/delegating_the.html
addEventListener(this.frame, 'focus', onEvent, true)
addEventListener(this.frame, 'blur', onEvent, true)
this.frame.onfocusin = onEvent // for IE
this.frame.onfocusout = onEvent // for IE
if (this.options.mainMenuBar) {
addClassName(this.contentOuter, 'has-main-menu-bar')
// create menu
this.menu = document.createElement('div')
this.menu.className = 'jsoneditor-menu'
this.frame.appendChild(this.menu)
// create expand all button
const expandAll = document.createElement('button')
expandAll.type = 'button'
expandAll.className = 'jsoneditor-expand-all'
expandAll.title = translate('expandAll')
expandAll.onclick = () => {
editor.expandAll()
if (typeof this.options.onExpand === 'function') {
this.options.onExpand({
path: [],
isExpand: true,
recursive: true
})
}
}
this.menu.appendChild(expandAll)
// create collapse all button
const collapseAll = document.createElement('button')
collapseAll.type = 'button'
collapseAll.title = translate('collapseAll')
collapseAll.className = 'jsoneditor-collapse-all'
collapseAll.onclick = () => {
editor.collapseAll()
if (typeof this.options.onExpand === 'function') {
this.options.onExpand({
path: [],
isExpand: false,
recursive: true
})
}
}
this.menu.appendChild(collapseAll)
// create sort button
if (this.options.enableSort) {
const sort = document.createElement('button')
sort.type = 'button'
sort.className = 'jsoneditor-sort'
sort.title = translate('sortTitleShort')
sort.onclick = () => {
editor.node.showSortModal()
}
this.menu.appendChild(sort)
}
// create transform button
if (this.options.enableTransform) {
const transform = document.createElement('button')
transform.type = 'button'
transform.title = translate('transformTitleShort')
transform.className = 'jsoneditor-transform'
transform.onclick = () => {
editor.node.showTransformModal()
}
this.menu.appendChild(transform)
}
// create undo/redo buttons
if (this.history) {
// create undo button
const undo = document.createElement('button')
undo.type = 'button'
undo.className = 'jsoneditor-undo jsoneditor-separator'
undo.title = translate('undo')
undo.onclick = () => {
editor._onUndo()
}
this.menu.appendChild(undo)
this.dom.undo = undo
// create redo button
const redo = document.createElement('button')
redo.type = 'button'
redo.className = 'jsoneditor-redo'
redo.title = translate('redo')
redo.onclick = () => {
editor._onRedo()
}
this.menu.appendChild(redo)
this.dom.redo = redo
// register handler for onchange of history
this.history.onChange = () => {
undo.disabled = !editor.history.canUndo()
redo.disabled = !editor.history.canRedo()
}
this.history.onChange()
}
// create mode box
if (this.options && this.options.modes && this.options.modes.length) {
const me = this
this.modeSwitcher = new ModeSwitcher(this.menu, this.options.modes, this.options.mode, function onSwitch (mode) {
// switch mode and restore focus
try {
me.setMode(mode)
me.modeSwitcher.focus()
} catch (err) {
me._onError(err)
}
})
}
// create search box
if (this.options.search) {
this.searchBox = new SearchBox(this, this.menu)
}
}
if (this.options.navigationBar) {
// create second menu row for treepath
this.navBar = document.createElement('div')
this.navBar.className = 'jsoneditor-navigation-bar nav-bar-empty'
this.frame.appendChild(this.navBar)
this.treePath = new TreePath(this.navBar, this.getPopupAnchor())
this.treePath.onSectionSelected(this._onTreePathSectionSelected.bind(this))
this.treePath.onContextMenuItemSelected(this._onTreePathMenuItemSelected.bind(this))
}
}
/**
* Perform an undo action
* @private
*/
treemode._onUndo = function () {
if (this.history) {
// undo last action
this.history.undo()
// fire change event
this._onChange()
}
}
/**
* Perform a redo action
* @private
*/
treemode._onRedo = function () {
if (this.history) {
// redo last action
this.history.redo()
// fire change event
this._onChange()
}
}
/**
* Event handler
* @param event
* @private
*/
treemode._onEvent = function (event) {
// don't process events when coming from the color picker
if (Node.targetIsColorPicker(event.target)) {
return
}
const node = Node.getNodeFromTarget(event.target)
if (event.type === 'keydown') {
this._onKeyDown(event)
}
if (node && event.type === 'focus') {
this.focusTarget = event.target
if (this.options.autocomplete && this.options.autocomplete.trigger === 'focus') {
this._showAutoComplete(event.target)
}
}
if (event.type === 'mousedown') {
this._startDragDistance(event)
}
if (event.type === 'mousemove' || event.type === 'mouseup' || event.type === 'click') {
this._updateDragDistance(event)
}
if (node && this.options && this.options.navigationBar && node && (event.type === 'keydown' || event.type === 'mousedown')) {
// apply on next tick, right after the new key press is applied
const me = this
setTimeout(() => {
me._updateTreePath(node.getNodePath())
})
}
if (node && node.selected) {
if (event.type === 'click') {
if (event.target === node.dom.menu) {
this.showContextMenu(event.target)
// stop propagation (else we will open the context menu of a single node)
return
}
// deselect a multi selection
if (!event.hasMoved) {
this.deselect()
}
}
if (event.type === 'mousedown') {
// drag multiple nodes
Node.onDragStart(this.multiselection.nodes, event)
}
} else {
// filter mouse events in the contents part of the editor (not the main menu)
if (event.type === 'mousedown' && hasParentNode(event.target, this.content)) {
this.deselect()
if (node && event.target === node.dom.drag) {
// drag a singe node
Node.onDragStart(node, event)
} else if (!node || (event.target !== node.dom.field && event.target !== node.dom.value && event.target !== node.dom.select)) {
// select multiple nodes
this._onMultiSelectStart(event)
}
}
}
if (node) {
node.onEvent(event)
}
}
/**
* Update TreePath components
* @param {Array<Node>} pathNodes list of nodes in path from root to selection
* @private
*/
treemode._updateTreePath = function (pathNodes) {
if (pathNodes && pathNodes.length) {
removeClassName(this.navBar, 'nav-bar-empty')
const pathObjs = []
pathNodes.forEach(node => {
const pathObj = {
name: getName(node),
node,
children: []
}
if (node.childs && node.childs.length) {
node.childs.forEach(childNode => {
pathObj.children.push({
name: getName(childNode),
node: childNode
})
})
}
pathObjs.push(pathObj)
})
this.treePath.setPath(pathObjs)
} else {
addClassName(this.navBar, 'nav-bar-empty')
}
function getName (node) {
return node.parent
? ((node.parent.type === 'array') ? node.index : node.field)
: (node.field || node.type)
}
}
/**
* Callback for tree path section selection - focus the selected node in the tree
* @param {Object} pathObj path object that was represents the selected section node
* @private
*/
treemode._onTreePathSectionSelected = pathObj => {
if (pathObj && pathObj.node) {
pathObj.node.expandTo()
pathObj.node.focus()
}
}
/**
* Callback for tree path menu item selection - rebuild the path accrding to the new selection and focus the selected node in the tree
* @param {Object} pathObj path object that was represents the parent section node
* @param {String} selection selected section child
* @private
*/
treemode._onTreePathMenuItemSelected = function (pathObj, selection) {
if (pathObj && pathObj.children.length) {
const selectionObj = pathObj.children.find(obj => obj.name === selection)
if (selectionObj && selectionObj.node) {
this._updateTreePath(selectionObj.node.getNodePath())
selectionObj.node.expandTo()
selectionObj.node.focus()
}
}
}
treemode._startDragDistance = function (event) {
this.dragDistanceEvent = {
initialTarget: event.target,
initialPageX: event.pageX,
initialPageY: event.pageY,
dragDistance: 0,
hasMoved: false
}
}
treemode._updateDragDistance = function (event) {
if (!this.dragDistanceEvent) {
this._startDragDistance(event)
}
const diffX = event.pageX - this.dragDistanceEvent.initialPageX
const diffY = event.pageY - this.dragDistanceEvent.initialPageY
this.dragDistanceEvent.dragDistance = Math.sqrt(diffX * diffX + diffY * diffY)
this.dragDistanceEvent.hasMoved =
this.dragDistanceEvent.hasMoved || this.dragDistanceEvent.dragDistance > 10
event.dragDistance = this.dragDistanceEvent.dragDistance
event.hasMoved = this.dragDistanceEvent.hasMoved
return event.dragDistance
}
/**
* Start multi selection of nodes by dragging the mouse
* @param {MouseEvent} event
* @private
*/
treemode._onMultiSelectStart = function (event) {
const node = Node.getNodeFromTarget(event.target)
if (this.options.mode !== 'tree' || this.options.onEditable !== undefined) {
// dragging not allowed in modes 'view' and 'form'
// TODO: allow multiselection of items when option onEditable is specified
return
}
this.multiselection = {
start: node || null,
end: null,
nodes: []
}
this._startDragDistance(event)
const editor = this
if (!this.mousemove) {
this.mousemove = addEventListener(event.view, 'mousemove', event => {
editor._onMultiSelect(event)
})
}
if (!this.mouseup) {
this.mouseup = addEventListener(event.view, 'mouseup', event => {
editor._onMultiSelectEnd(event)
})
}
event.preventDefault()
}
/**
* Multiselect nodes by dragging
* @param {MouseEvent} event
* @private
*/
treemode._onMultiSelect = function (event) {
event.preventDefault()
this._updateDragDistance(event)
if (!event.hasMoved) {
return
}
const node = Node.getNodeFromTarget(event.target)
if (node) {
if (this.multiselection.start == null) {
this.multiselection.start = node
}
this.multiselection.end = node
}
// deselect previous selection
this.deselect()
// find the selected nodes in the range from first to last
const start = this.multiselection.start
const end = this.multiselection.end || this.multiselection.start
if (start && end) {
// find the top level childs, all having the same parent
this.multiselection.nodes = this._findTopLevelNodes(start, end)
if (this.multiselection.nodes && this.multiselection.nodes.length) {
const firstNode = this.multiselection.nodes[0]
if (this.multiselection.start === firstNode || this.multiselection.start.isDescendantOf(firstNode)) {
this.multiselection.direction = 'down'
} else {
this.multiselection.direction = 'up'
}
}
this.select(this.multiselection.nodes)
}
}
/**
* End of multiselect nodes by dragging
* @param {MouseEvent} event
* @private
*/
treemode._onMultiSelectEnd = function (event) {
// set focus to the context menu button of the first node
const firstNode = this.multiselection.nodes[0]
if (firstNode && firstNode.dom.menu) {
firstNode.dom.menu.focus()
}
this.multiselection.start = null
this.multiselection.end = null
// cleanup global event listeners
if (this.mousemove) {
removeEventListener(event.view, 'mousemove', this.mousemove)
delete this.mousemove
}
if (this.mouseup) {
removeEventListener(event.view, 'mouseup', this.mouseup)
delete this.mouseup
}
}
/**
* deselect currently selected nodes
* @param {boolean} [clearStartAndEnd=false] If true, the `start` and `end`
* state is cleared too.
*/
treemode.deselect = function (clearStartAndEnd) {
const selectionChanged = !!this.multiselection.nodes.length
this.multiselection.nodes.forEach(node => {
node.setSelected(false)
})
this.multiselection.nodes = []
if (clearStartAndEnd) {
this.multiselection.start = null
this.multiselection.end = null
}
if (selectionChanged) {
if (this._selectionChangedHandler) {
this._selectionChangedHandler()
}
}
}
/**
* select nodes
* @param {Node[] | Node} nodes
*/
treemode.select = function (nodes) {
if (!Array.isArray(nodes)) {
return this.select([nodes])
}
if (nodes) {
this.deselect()
this.multiselection.nodes = nodes.slice(0)
const first = nodes[0]
nodes.forEach(node => {
node.expandPathToNode()
node.setSelected(true, node === first)
})
if (this._selectionChangedHandler) {
const selection = this.getSelection()
this._selectionChangedHandler(selection.start, selection.end)
}
}
}
/**
* From two arbitrary selected nodes, find their shared parent node.
* From that parent node, select the two child nodes in the brances going to
* nodes `start` and `end`, and select all childs in between.
* @param {Node} start
* @param {Node} end
* @return {Array.<Node>} Returns an ordered list with child nodes
* @private
*/
treemode._findTopLevelNodes = (start, end) => {
const startPath = start.getNodePath()
const endPath = end.getNodePath()
let i = 0
while (i < startPath.length && startPath[i] === endPath[i]) {
i++
}
let root = startPath[i - 1]
let startChild = startPath[i]
let endChild = endPath[i]
if (!startChild || !endChild) {
if (root.parent) {
// startChild is a parent of endChild or vice versa
startChild = root
endChild = root
root = root.parent
} else {
// we have selected the root node (which doesn't have a parent)
startChild = root.childs[0]
endChild = root.childs[root.childs.length - 1]
}
}
if (root && startChild && endChild) {
const startIndex = root.childs.indexOf(startChild)
const endIndex = root.childs.indexOf(endChild)
const firstIndex = Math.min(startIndex, endIndex)
const lastIndex = Math.max(startIndex, endIndex)
return root.childs.slice(firstIndex, lastIndex + 1)
} else {
return []
}
}
/**
* Show autocomplete menu
* @param {HTMLElement} element
* @private
*/
treemode._showAutoComplete = function (element) {
const node = Node.getNodeFromTarget(element)
let jsonElementType = ''
if (element.className.indexOf('jsoneditor-value') >= 0) jsonElementType = 'value'
if (element.className.indexOf('jsoneditor-field') >= 0) jsonElementType = 'field'
if (jsonElementType === '') {
// Unknown element field. Could be a button or something else
return
}
const self = this
setTimeout(() => {
if (node && (self.options.autocomplete.trigger === 'focus' || element.innerText.length > 0)) {
const result = self.options.autocomplete.getOptions(element.innerText, node.getPath(), jsonElementType, node.editor)
if (result === null) {
self.autocomplete.hideDropDown()
} else if (typeof result.then === 'function') {
// probably a promise
result
.then(obj => {
if (obj === null) {
self.autocomplete.hideDropDown()
} else if (obj.options) {
self.autocomplete.show(element, obj.startFrom, obj.options)
} else {
self.autocomplete.show(element, 0, obj)
}
})
.catch(err => {
console.error(err)
})
} else {
// definitely not a promise
if (result.options) {
self.autocomplete.show(element, result.startFrom, result.options)
} else {
self.autocomplete.show(element, 0, result)
}
}
} else { self.autocomplete.hideDropDown() }
}, 50)
}
/**
* Event handler for keydown. Handles shortcut keys
* @param {Event} event
* @private
*/
treemode._onKeyDown = function (event) {
const keynum = event.which || event.keyCode
const altKey = event.altKey
const ctrlKey = event.ctrlKey
const metaKey = event.metaKey
const shiftKey = event.shiftKey
let handled = false
const currentTarget = this.focusTarget
if (keynum === 9) { // Tab or Shift+Tab
const me = this
setTimeout(() => {
/*
- Checking for change in focusTarget
- Without the check,
pressing tab after reaching the final DOM element in the editor will
set the focus back to it than passing focus outside the editor
*/
if (me.focusTarget !== currentTarget) {
// select all text when moving focus to an editable div
selectContentEditable(me.focusTarget)
}
}, 0)
}
if (this.searchBox) {
if (ctrlKey && keynum === 70) { // Ctrl+F
this.searchBox.dom.search.focus()
this.searchBox.dom.search.select()
handled = true
} else if (keynum === 114 || (ctrlKey && keynum === 71)) { // F3 or Ctrl+G
const focus = true
if (!shiftKey) {
// select next search result (F3 or Ctrl+G)
this.searchBox.next(focus)
} else {
// select previous search result (Shift+F3 or Ctrl+Shift+G)
this.searchBox.previous(focus)
}
handled = true
}
}
if (this.history) {
if (ctrlKey && !shiftKey && keynum === 90) { // Ctrl+Z
// undo
this._onUndo()
handled = true
} else if (ctrlKey && shiftKey && keynum === 90) { // Ctrl+Shift+Z
// redo
this._onRedo()
handled = true
}
}
if ((this.options.autocomplete) && (!handled)) {
if (!ctrlKey && !altKey && !metaKey && (event.key.length === 1 || keynum === 8 || keynum === 46)) {
handled = false
// Activate autocomplete
this._showAutoComplete(event.target)
}
}
if (handled) {
event.preventDefault()
event.stopPropagation()
}
}
/**
* Create main table
* @private
*/
treemode._createTable = function () {
if (this.options.navigationBar) {
addClassName(this.contentOuter, 'has-nav-bar')
}
this.scrollableContent = document.createElement('div')
this.scrollableContent.className = 'jsoneditor-tree'
this.contentOuter.appendChild(this.scrollableContent)
// the jsoneditor-tree-inner div with bottom padding is here to
// keep space for the action menu dropdown. It's created as a
// separate div instead of using scrollableContent to work around
// and issue in the Chrome browser showing scrollable contents outside of the div
// see https://github.com/josdejong/jsoneditor/issues/557
this.content = document.createElement('div')
this.content.className = 'jsoneditor-tree-inner'
this.scrollableContent.appendChild(this.content)
this.table = document.createElement('table')
this.table.className = 'jsoneditor-tree'
this.content.appendChild(this.table)
// create colgroup where the first two columns don't have a fixed
// width, and the edit columns do have a fixed width
let col
this.colgroupContent = document.createElement('colgroup')
if (this.options.mode === 'tree') {
col = document.createElement('col')
col.width = '24px'
this.colgroupContent.appendChild(col)
}
col = document.createElement('col')
col.width = '24px'
this.colgroupContent.appendChild(col)
col = document.createElement('col')
this.colgroupContent.appendChild(col)
this.table.appendChild(this.colgroupContent)
this.tbody = document.createElement('tbody')
this.table.appendChild(this.tbody)
this.frame.appendChild(this.contentOuter)
}
/**
* Show a contextmenu for this node.
* Used for multiselection
* @param {HTMLElement} anchor Anchor element to attach the context menu to.
* @param {function} [onClose] Callback method called when the context menu
* is being closed.
*/
treemode.showContextMenu = function (anchor, onClose) {
let items = []
const selectedNodes = this.multiselection.nodes.slice()
// create duplicate button
items.push({
text: translate('duplicateText'),
title: translate('duplicateTitle'),
className: 'jsoneditor-duplicate',
click: function () {
Node.onDuplicate(selectedNodes)
}
})
// create remove button
items.push({
text: translate('remove'),
title: translate('removeTitle'),
className: 'jsoneditor-remove',
click: function () {
Node.onRemove(selectedNodes)
}
})
if (this.options.onCreateMenu) {
const paths = selectedNodes.map(node => node.getPath())
items = this.options.onCreateMenu(items, {
type: 'multiple',
path: paths[0],
paths
})
}
const menu = new ContextMenu(items, { close: onClose })
menu.show(anchor, this.getPopupAnchor())
}
treemode.getPopupAnchor = function () {
return this.options.popupAnchor || this.frame
}
/**
* Get current selected nodes
* @return {{start:SerializableNode, end: SerializableNode}}
*/
treemode.getSelection = function () {
const selection = {
start: null,
end: null
}
if (this.multiselection.nodes && this.multiselection.nodes.length) {
if (this.multiselection.nodes.length) {
const selection1 = this.multiselection.nodes[0]
const selection2 = this.multiselection.nodes[this.multiselection.nodes.length - 1]
if (this.multiselection.direction === 'down') {
selection.start = selection1.serialize()
selection.end = selection2.serialize()
} else {
selection.start = selection2.s