UNPKG

jsoneditor

Version:

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

707 lines (598 loc) 18.2 kB
'use strict' import { jsonrepair } from 'jsonrepair' import { DEFAULT_MODAL_ANCHOR, MAX_PREVIEW_CHARACTERS, PREVIEW_HISTORY_LIMIT, SIZE_LARGE } from './constants' import { ErrorTable } from './ErrorTable' import { FocusTracker } from './FocusTracker' import { History } from './History' import { setLanguage, setLanguages, translate } from './i18n' import { createQuery, executeQuery } from './jmespathQuery' import { ModeSwitcher } from './ModeSwitcher' import { showSortModal } from './showSortModal' import { showTransformModal } from './showTransformModal' import { textModeMixins } from './textmode' import { addClassName, debounce, escapeUnicodeChars, formatSize, isObject, limitCharacters, parse, removeClassName, sort, sortObjectKeys } from './util' const textmode = textModeMixins[0].mixin // create a mixin with the functions for text mode const previewmode = {} /** * Create a JSON document preview, suitable for processing of large documents * @param {Element} container * @param {Object} [options] Object with options. See docs for details. * @private */ previewmode.create = function (container, options = {}) { if (typeof options.statusBar === 'undefined') { options.statusBar = true } // setting default for previewmode options.mainMenuBar = options.mainMenuBar !== false options.enableSort = options.enableSort !== false options.enableTransform = options.enableTransform !== false options.createQuery = options.createQuery || createQuery options.executeQuery = options.executeQuery || executeQuery this.options = options // indentation if (typeof options.indentation === 'number') { this.indentation = Number(options.indentation) } else { this.indentation = 2 // number of spaces } // language setLanguages(this.options.languages) setLanguage(this.options.language) // determine mode this.mode = 'preview' const me = this this.container = container this.dom = {} this.json = undefined this.text = '' // TODO: JSON Schema support // create a debounced validate function this._debouncedValidate = debounce(this.validate.bind(this), this.DEBOUNCE_INTERVAL) this.width = container.clientWidth this.height = container.clientHeight this.frame = document.createElement('div') this.frame.className = 'jsoneditor jsoneditor-mode-preview' this.frame.onclick = event => { // prevent default submit action when the editor is located inside a form event.preventDefault() } // 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.content = document.createElement('div') this.content.className = 'jsoneditor-outer' this.dom.busy = document.createElement('div') this.dom.busy.className = 'jsoneditor-busy' this.dom.busyContent = document.createElement('span') this.dom.busyContent.textContent = 'busy...' this.dom.busy.appendChild(this.dom.busyContent) this.content.appendChild(this.dom.busy) this.dom.previewContent = document.createElement('pre') this.dom.previewContent.className = 'jsoneditor-preview' this.dom.previewText = document.createTextNode('') this.dom.previewContent.appendChild(this.dom.previewText) this.content.appendChild(this.dom.previewContent) if (this.options.mainMenuBar) { addClassName(this.content, 'has-main-menu-bar') // create menu this.menu = document.createElement('div') this.menu.className = 'jsoneditor-menu' this.frame.appendChild(this.menu) // create format button const buttonFormat = document.createElement('button') buttonFormat.type = 'button' buttonFormat.className = 'jsoneditor-format' buttonFormat.title = translate('formatTitle') this.menu.appendChild(buttonFormat) buttonFormat.onclick = function handleFormat () { me.executeWithBusyMessage(() => { try { me.format() } catch (err) { me._onError(err) } }, 'formatting...') } // create compact button const buttonCompact = document.createElement('button') buttonCompact.type = 'button' buttonCompact.className = 'jsoneditor-compact' buttonCompact.title = translate('compactTitle') this.menu.appendChild(buttonCompact) buttonCompact.onclick = function handleCompact () { me.executeWithBusyMessage(() => { try { me.compact() } catch (err) { me._onError(err) } }, 'compacting...') } // 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 = () => { me._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 = () => { me._showTransformModal() } this.dom.transform = transform this.menu.appendChild(transform) } // create repair button const buttonRepair = document.createElement('button') buttonRepair.type = 'button' buttonRepair.className = 'jsoneditor-repair' buttonRepair.title = translate('repairTitle') this.menu.appendChild(buttonRepair) buttonRepair.onclick = () => { if (me.json === undefined) { // only repair if we don't have valid JSON me.executeWithBusyMessage(() => { try { me.repair() } catch (err) { me._onError(err) } }, 'repairing...') } } // create history and undo/redo buttons if (this.options.history !== false) { // default option value is true const onHistoryChange = () => { me.dom.undo.disabled = !me.history.canUndo() me.dom.redo.disabled = !me.history.canRedo() } const calculateItemSize = item => // times two to account for the json object item.text.length * 2 this.history = new History(onHistoryChange, calculateItemSize, PREVIEW_HISTORY_LIMIT) // create undo button const undo = document.createElement('button') undo.type = 'button' undo.className = 'jsoneditor-undo jsoneditor-separator' undo.title = translate('undo') undo.onclick = () => { const action = me.history.undo() if (action) { me._applyHistory(action) } } 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 = () => { const action = me.history.redo() if (action) { me._applyHistory(action) } } this.menu.appendChild(redo) this.dom.redo = redo // force enabling/disabling the undo/redo button this.history.onChange() } // create mode box if (this.options && this.options.modes && this.options.modes.length) { 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) } }) } } const errorTableVisible = Array.isArray(this.options.showErrorTable) ? this.options.showErrorTable.includes(this.mode) : this.options.showErrorTable === true this.errorTable = new ErrorTable({ errorTableVisible, onToggleVisibility: function () { me.validate() }, onFocusLine: null, onChangeHeight: function (height) { // TODO: change CSS to using flex box, remove setting height using JavaScript const statusBarHeight = me.dom.statusBar ? me.dom.statusBar.clientHeight : 0 const totalHeight = height + statusBarHeight + 1 me.content.style.marginBottom = (-totalHeight) + 'px' me.content.style.paddingBottom = totalHeight + 'px' } }) this.frame.appendChild(this.content) this.frame.appendChild(this.errorTable.getErrorTable()) this.container.appendChild(this.frame) if (options.statusBar) { addClassName(this.content, 'has-status-bar') const statusBar = document.createElement('div') this.dom.statusBar = statusBar statusBar.className = 'jsoneditor-statusbar' this.frame.appendChild(statusBar) this.dom.fileSizeInfo = document.createElement('span') this.dom.fileSizeInfo.className = 'jsoneditor-size-info' this.dom.fileSizeInfo.innerText = '' statusBar.appendChild(this.dom.fileSizeInfo) this.dom.arrayInfo = document.createElement('span') this.dom.arrayInfo.className = 'jsoneditor-size-info' this.dom.arrayInfo.innerText = '' statusBar.appendChild(this.dom.arrayInfo) statusBar.appendChild(this.errorTable.getErrorCounter()) statusBar.appendChild(this.errorTable.getWarningIcon()) statusBar.appendChild(this.errorTable.getErrorIcon()) } this._renderPreview() this.setSchema(this.options.schema, this.options.schemaRefs) } previewmode._renderPreview = function () { const text = this.getText() this.dom.previewText.nodeValue = limitCharacters(text, MAX_PREVIEW_CHARACTERS) if (this.dom.fileSizeInfo) { this.dom.fileSizeInfo.innerText = 'Size: ' + formatSize(text.length) } if (this.dom.arrayInfo) { if (Array.isArray(this.json)) { this.dom.arrayInfo.innerText = ('Array: ' + this.json.length + ' items') } else { this.dom.arrayInfo.innerText = '' } } } /** * Handle a change: * - Validate JSON schema * - Send a callback to the onChange listener if provided * @private */ previewmode._onChange = function () { // validate JSON schema (if configured) this._debouncedValidate() // 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) } } } /** * Open a sort modal * @private */ previewmode._showSortModal = function () { const me = this function onSort (json, sortedBy) { if (Array.isArray(json)) { const sortedArray = sort(json, sortedBy.path, sortedBy.direction) me.sortedBy = sortedBy me._setAndFireOnChange(sortedArray) } if (isObject(json)) { const sortedObject = sortObjectKeys(json, sortedBy.direction) me.sortedBy = sortedBy me._setAndFireOnChange(sortedObject) } } this.executeWithBusyMessage(() => { const container = me.options.modalAnchor || DEFAULT_MODAL_ANCHOR const json = me.get() me._renderPreview() // update array count showSortModal(container, json, sortedBy => { me.executeWithBusyMessage(() => { onSort(json, sortedBy) }, 'sorting...') }, me.sortedBy) }, 'parsing...') } /** * Open a transform modal * @private */ previewmode._showTransformModal = function () { this.executeWithBusyMessage(() => { const { createQuery, executeQuery, modalAnchor, queryDescription } = this.options const json = this.get() this._renderPreview() // update array count showTransformModal({ container: modalAnchor || DEFAULT_MODAL_ANCHOR, json, queryDescription, // can be undefined createQuery, executeQuery, onTransform: query => { this.executeWithBusyMessage(() => { const updatedJson = executeQuery(json, query) this._setAndFireOnChange(updatedJson) }, 'transforming...') } }) }, 'parsing...') } /** * Destroy the editor. Clean up DOM, event listeners, and web workers. */ previewmode.destroy = function () { if (this.frame && this.container && this.frame.parentNode === this.container) { this.container.removeChild(this.frame) } if (this.modeSwitcher) { this.modeSwitcher.destroy() this.modeSwitcher = null } this._debouncedValidate = null if (this.history) { this.history.clear() this.history = null } // Removing the FocusTracker set to track the editor's focus event this.frameFocusTracker.destroy() } /** * Compact the code in the text editor */ previewmode.compact = function () { const json = this.get() const text = JSON.stringify(json) // we know that in this case the json is still the same, so we pass json too this._setTextAndFireOnChange(text, json) } /** * Format the code in the text editor */ previewmode.format = function () { const json = this.get() const text = JSON.stringify(json, null, this.indentation) // we know that in this case the json is still the same, so we pass json too this._setTextAndFireOnChange(text, json) } /** * Repair the code in the text editor */ previewmode.repair = function () { const text = this.getText() try { const repairedText = jsonrepair(text) this._setTextAndFireOnChange(repairedText) } catch (err) { // repair was not successful, do nothing } } /** * Set focus to the editor */ previewmode.focus = function () { // we don't really have a place to focus, // let's focus on the transform button this.dom.transform.focus() } /** * Set json data in the editor * @param {*} json */ previewmode.set = function (json) { if (this.history) { this.history.clear() } this._set(json) } /** * Update data. Same as calling `set` in text/code mode. * @param {*} json */ previewmode.update = function (json) { this._set(json) } /** * Set json data * @param {*} json */ previewmode._set = function (json) { this.text = undefined this.json = json this._renderPreview() this._pushHistory() // validate JSON schema this._debouncedValidate() } previewmode._setAndFireOnChange = function (json) { this._set(json) this._onChange() } /** * Get json data * @return {*} json */ previewmode.get = function () { if (this.json === undefined) { const text = this.getText() this.json = parse(text) // this can throw an error } return this.json } /** * Get the text contents of the editor * @return {String} jsonText */ previewmode.getText = function () { if (this.text === undefined) { this.text = JSON.stringify(this.json, null, this.indentation) if (this.options.escapeUnicode === true) { this.text = escapeUnicodeChars(this.text) } } return this.text } /** * Set the text contents of the editor * @param {String} jsonText */ previewmode.setText = function (jsonText) { if (this.history) { this.history.clear() } this._setText(jsonText) } /** * Update the text contents * @param {string} jsonText */ previewmode.updateText = function (jsonText) { // don't update if there are no changes if (this.getText() === jsonText) { return } this._setText(jsonText) } /** * Set the text contents of the editor * @param {string} jsonText * @param {*} [json] Optional JSON instance of the text * @private */ previewmode._setText = function (jsonText, json) { if (this.options.escapeUnicode === true) { this.text = escapeUnicodeChars(jsonText) } else { this.text = jsonText } this.json = json this._renderPreview() if (this.json === undefined) { const me = this this.executeWithBusyMessage(() => { try { // force parsing the json now, else it will be done in validate without feedback me.json = me.get() me._renderPreview() me._pushHistory() } catch (err) { // no need to throw an error, validation will show an error } }, 'parsing...') } else { this._pushHistory() } this._debouncedValidate() } /** * Set text and fire onChange callback * @param {string} jsonText * @param {*} [json] Optional JSON instance of the text * @private */ previewmode._setTextAndFireOnChange = function (jsonText, json) { this._setText(jsonText, json) this._onChange() } /** * Apply history to the current state * @param {{json?: JSON, text?: string}} action * @private */ previewmode._applyHistory = function (action) { this.json = action.json this.text = action.text this._renderPreview() this._debouncedValidate() } /** * Push the current state to history * @private */ previewmode._pushHistory = function () { if (!this.history) { return } const action = { text: this.text, json: this.json } this.history.add(action) } /** * Execute a heavy, blocking action. * Before starting the action, show a message on screen like "parsing..." * @param {function} fn * @param {string} message */ previewmode.executeWithBusyMessage = function (fn, message) { const size = this.getText().length if (size > SIZE_LARGE) { const me = this addClassName(me.frame, 'busy') me.dom.busyContent.innerText = message setTimeout(() => { fn() removeClassName(me.frame, 'busy') me.dom.busyContent.innerText = '' }, 100) } else { fn() } } // TODO: refactor into composable functions instead of this shaky mixin-like structure previewmode.validate = textmode.validate previewmode._renderErrors = textmode._renderErrors // define modes export const previewModeMixins = [ { mode: 'preview', mixin: previewmode, data: 'json' } ]