UNPKG

jsoneditor

Version:

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

1,769 lines (1,564 loc) 53 kB
'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