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