diff --git a/gulpfile.js b/gulpfile.js index eec15e1..d99713b 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -119,9 +119,10 @@ gulp.task('zip', shell.task([ ])); // The watch task (to automatically rebuild when the source code changes) -// Does only generate jsoneditor.js and jsoneditor.css, not the minified versions -gulp.task('watch', ['bundle', 'bundle-css'], function () { - gulp.watch(['src/**/*.js'], ['bundle', 'bundle-css']); +// Does only generate jsoneditor.js and jsoneditor.css, and copy the image +// Does NOT minify the code +gulp.task('watch', ['bundle', 'bundle-css', 'copy-img'], function () { + gulp.watch(['src/**/*'], ['bundle', 'bundle-css', 'copy-img']); }); // The default task (called when you run `gulp`) diff --git a/package.json b/package.json index 3d1792d..612a34f 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "test": "mocha test" }, "dependencies": { + "ajv": "3.2.0", "brace": "0.7.0" }, "devDependencies": { diff --git a/src/css/img/jsoneditor-icons.svg b/src/css/img/jsoneditor-icons.svg index bb1341d..cbbd444 100644 --- a/src/css/img/jsoneditor-icons.svg +++ b/src/css/img/jsoneditor-icons.svg @@ -42,13 +42,13 @@ inkscape:window-height="1028" id="namedview4144" showgrid="true" - inkscape:zoom="4" - inkscape:cx="41.516298" - inkscape:cy="105.31073" + inkscape:zoom="8" + inkscape:cx="193.21238" + inkscape:cy="59.527316" inkscape:window-x="0" inkscape:window-y="0" inkscape:window-maximized="1" - inkscape:current-layer="g4394" + inkscape:current-layer="svg4136" showguides="false" borderlayer="false" inkscape:showpageshadow="true" @@ -60,447 +60,440 @@ - Layer 1 + id="g4394"> + + + + + + style="stroke:none" + id="g4299"> + style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0" + id="svg_1-1" + height="1.9999986" + width="9.9999924" + y="10.999998" + x="7.0000048" /> + style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0" + id="svg_1-1-1" + height="9.9999838" + width="1.9999955" + y="7.0000114" + x="11.000005" /> + + + style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0" + id="svg_1-1-0" + height="1.9999986" + width="9.9999924" + y="10.999998" + x="7.0000048" /> + style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0" + id="svg_1-1-1-9" + height="9.9999838" + width="1.9999955" + y="7.0000114" + x="11.000005" /> + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:0" + x="198" + y="7.0000005" + width="11.999995" + height="1.9999946" + id="rect4374" /> - - - - - - - - - - - - - - - - - - - - - - - - + x="198" + y="14.999996" + width="3.9999928" + height="1.9999995" + id="rect4376" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/css/jsoneditor.css b/src/css/jsoneditor.css index 4d41bfd..ef16b13 100644 --- a/src/css/jsoneditor.css +++ b/src/css/jsoneditor.css @@ -271,3 +271,137 @@ div.jsoneditor textarea { font-size: 10pt; color: #1A1A1A; } + + + + + +/* popover */ +.jsoneditor-schema-error { + cursor: default; + display: inline-block; + font-family: arial, sans-serif; + height: 24px; + line-height: 24px; + position: relative; + text-align: center; + width: 24px; +} + +div.jsoneditor-tree .jsoneditor-schema-error { + width: 24px; + height: 24px; + padding: 0; + margin: 0 4px 0 0; + background: url('img/jsoneditor-icons.svg') -168px -48px; +} + +.jsoneditor-schema-error .jsoneditor-popover { + background-color: #4c4c4c; + border-radius: 3px; + box-shadow: 0 0 5px rgba(0,0,0,0.4); + color: #fff; + display: none; + padding: 7px 10px; + position: absolute; + width: 200px; + z-index: 4; +} + +.jsoneditor-schema-error .jsoneditor-popover.jsoneditor-above { + bottom: 32px; + left: -98px; +} + +.jsoneditor-schema-error .jsoneditor-popover.jsoneditor-below { + top: 32px; + left: -98px; +} + +.jsoneditor-schema-error .jsoneditor-popover.jsoneditor-left { + top: -7px; + right: 32px; +} + +.jsoneditor-schema-error .jsoneditor-popover.jsoneditor-right { + top: -7px; + left: 32px; +} + +.jsoneditor-schema-error .jsoneditor-popover:before { + border-right: 7px solid transparent; + border-left: 7px solid transparent; + content: ''; + display: block; + left: 50%; + margin-left: -7px; + position: absolute; +} + +.jsoneditor-schema-error .jsoneditor-popover.jsoneditor-above:before { + border-top: 7px solid #4c4c4c; + bottom: -7px; +} + +.jsoneditor-schema-error .jsoneditor-popover.jsoneditor-below:before { + border-bottom: 7px solid #4c4c4c; + top: -7px; +} + +.jsoneditor-schema-error .jsoneditor-popover.jsoneditor-left:before { + border-left: 7px solid #4c4c4c; + border-top: 7px solid transparent; + border-bottom: 7px solid transparent; + content: ''; + top: 19px; + right: -14px; + left: inherit; + margin-left: inherit; + margin-top: -7px; + position: absolute; +} + +.jsoneditor-schema-error .jsoneditor-popover.jsoneditor-right:before { + border-right: 7px solid #4c4c4c; + border-top: 7px solid transparent; + border-bottom: 7px solid transparent; + content: ''; + top: 19px; + left: -14px; + margin-left: inherit; + margin-top: -7px; + position: absolute; +} + +.jsoneditor-schema-error:hover .jsoneditor-popover, +.jsoneditor-schema-error:focus .jsoneditor-popover { + display: block; + -webkit-animation: fade-in .3s linear 1, move-up .3s linear 1; + -moz-animation: fade-in .3s linear 1, move-up .3s linear 1; + -ms-animation: fade-in .3s linear 1, move-up .3s linear 1; +} + +@-webkit-keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} +@-moz-keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} +@-ms-keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} +/*@-webkit-keyframes move-up {*/ + /*from { bottom: 24px; }*/ + /*to { bottom: 32px; }*/ +/*}*/ +/*@-moz-keyframes move-up {*/ + /*from { bottom: 24px; }*/ + /*to { bottom: 32px; }*/ +/*}*/ +/*@-ms-keyframes move-up {*/ + /*from { bottom: 24px; }*/ + /*to { bottom: 32px; }*/ +/*}*/ diff --git a/src/js/JSONEditor.js b/src/js/JSONEditor.js index 6d3650d..b0fe00a 100644 --- a/src/js/JSONEditor.js +++ b/src/js/JSONEditor.js @@ -66,7 +66,7 @@ function JSONEditor (container, options, json) { // validate options if (options) { var VALID_OPTIONS = [ - 'ace', + 'ace', 'schema', 'onChange', 'onEditable', 'onError', 'onModeChange', 'escapeUnicode', 'history', 'mode', 'modes', 'name', 'indentation', 'theme' ]; diff --git a/src/js/Node.js b/src/js/Node.js index 2490ba1..6bf8db2 100644 --- a/src/js/Node.js +++ b/src/js/Node.js @@ -14,7 +14,7 @@ var util = require('./util'); * 'object', or 'string'. */ function Node (editor, params) { - /** @type {TreeEditor} */ + /** @type {./treemode} */ this.editor = editor; this.dom = {}; this.expanded = false; @@ -80,6 +80,93 @@ Node.prototype.getFieldsPath = function () { return path; }; +/** + * Find a Node from a JSON path like '.items[3].name' + * @param {string} jsonPath + * @return {Node | null} Returns the Node when found, returns null if not found + */ +Node.prototype.findNode = function (jsonPath) { + var path = util.parsePath(jsonPath); + var node = this; + while (node && path.length > 0) { + var prop = path.shift(); + if (typeof prop === 'number') { + if (node.type !== 'array') { + throw new Error('Cannot get child node at index ' + prop + ': node is no array'); + } + node = node.childs[prop]; + } + else { // string + if (node.type !== 'object') { + throw new Error('Cannot get child node ' + prop + ': node is no object'); + } + node = node.childs.filter(function (child) { + return child.field === prop; + })[0]; + } + } + + return node; +}; + +/** + * + * @param {{dataPath: string, keyword: string, message: string, params: Object, schemaPath: string} | null} error + */ +Node.prototype.setError = function (error) { + // ensure the dom exists + this.getDom(); + + this.error = error; + var tdError = this.dom.tdError; + if (error) { + if (!tdError) { + tdError = document.createElement('td'); + this.dom.tdError = tdError; + this.dom.tdValue.parentNode.appendChild(tdError); + } + + var popover = document.createElement('div'); + popover.className = 'jsoneditor-popover jsoneditor-right'; + popover.appendChild(document.createTextNode(error.message)); + + var button = document.createElement('button'); + button.className = 'jsoneditor-schema-error'; + button.appendChild(popover); + + var editor = this.editor; + button.onmouseover = button.onclick = button.onfocus = function () { + var directions = ['right', 'above', 'below', 'left']; + for (var i = 0; i < directions.length; i++) { + var direction = directions[i]; + popover.className = 'jsoneditor-popover jsoneditor-' + direction; + + var contentRect = editor.content.getBoundingClientRect(); + var popoverRect = popover.getBoundingClientRect(); + var fit = util.insideRect(contentRect, popoverRect); + + if (fit) { + break; + } + } + }; + + while (tdError.firstChild) { + tdError.removeChild(tdError.firstChild); + } + tdError.appendChild(button); + + // loop over all parents of this node, and set an error "This object contains childs with errors" + + } + else { + if (tdError) { + this.dom.tdError.parentNode.removeChild(this.dom.tdError); + delete this.dom.tdError; + } + } +}; + /** * Get the index of this node: the index in the list of childs where this * node is part of @@ -1601,7 +1688,7 @@ Node.prototype.updateDom = function (options) { domTree.style.marginLeft = this.getLevel() * 24 + 'px'; } - // update field + // apply field to DOM var domField = this.dom.field; if (domField) { if (this.fieldEditable) { @@ -1631,7 +1718,7 @@ Node.prototype.updateDom = function (options) { domField.innerHTML = this._escapeHTML(field); } - // update value + // apply value to DOM var domValue = this.dom.value; if (domValue) { var count = this.childs ? this.childs.length : 0; diff --git a/src/js/treemode.js b/src/js/treemode.js index d413c58..930b492 100644 --- a/src/js/treemode.js +++ b/src/js/treemode.js @@ -1,3 +1,4 @@ +var Ajv = require('ajv/dist/ajv.bundle.js'); var Highlighter = require('./Highlighter'); var History = require('./History'); var SearchBox = require('./SearchBox'); @@ -6,6 +7,9 @@ var Node = require('./Node'); var modeswitcher = require('./modeswitcher'); var util = require('./util'); +// create an instance of ajv for JSON validation +var ajv = Ajv({ allErrors: true }); + // create a mixin with the functions for tree mode var treemode = {}; @@ -26,6 +30,7 @@ var treemode = {}; * {boolean} escapeUnicode If true, unicode * characters are escaped. * false by default. + * {Object} schema A JSON Schema for validation * @private */ treemode.create = function (container, options) { @@ -39,6 +44,7 @@ treemode.create = function (container, options) { this.multiselection = { nodes: [] }; + this.errorNodes = []; this._setOptions(options); @@ -70,7 +76,8 @@ treemode._setOptions = function (options) { search: true, history: true, mode: 'tree', - name: undefined // field name of root node + name: undefined, // field name of root node + schema: null }; // copy all options @@ -81,6 +88,9 @@ treemode._setOptions = function (options) { } } } + + // compile a JSON schema validator if a JSON schema is provided + this._validate = this.options.schema ? ajv.compile(this.options.schema) : null; }; // node currently being edited @@ -113,12 +123,17 @@ treemode.set = function (json, name) { // replace the root node var params = { - 'field': this.options.name, - 'value': json + field: this.options.name, + value: json }; var node = new Node(this, params); this._setRoot(node); + // validate JSON schema + if (this.options.schema) { + this.validate(); + } + // expand var recurse = false; this.node.expand(recurse); @@ -308,6 +323,21 @@ treemode._onAction = function (action, params) { 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 () { + // validate JSON schema + if (this.options.schema) { + this.validate(); + } + // trigger the onChange callback if (this.options.onChange) { try { @@ -319,6 +349,44 @@ treemode._onAction = function (action, params) { } }; +/** + * Validate current JSON object against the configured JSON schema + * Throws an exception when no JSON schema is configured + */ +treemode.validate = function () { + if (!this._validate) { + throw new Error('Cannot validate: no JSON schema configured'); + } + + //console.time('validate'); // TODO: clean up time measurement + var valid = this._validate(this.node.getValue()); + //console.timeEnd('validate'); + + // clear all current errors + this.errorNodes.forEach(function (node) { + node.setError(null); + }); + + // apply all new errors + var root = this.node; + if (!valid) { + this.errorNodes = this._validate.errors + .map(function (error) { + var node = root.findNode(error.dataPath); + if (node) { + node.setError(error); + } + return node; + }) + .filter(function (node) { + return node != null + }); + } + else { + this.errorNodes = []; + } +}; + /** * Start autoscrolling when given mouse position is above the top of the * editor contents, or below the bottom. @@ -604,10 +672,8 @@ treemode._onUndo = function () { // undo last action this.history.undo(); - // trigger change callback - if (this.options.onChange) { - this.options.onChange(); - } + // fire change event + this._onChange(); } }; @@ -620,10 +686,8 @@ treemode._onRedo = function () { // redo last action this.history.redo(); - // trigger change callback - if (this.options.onChange) { - this.options.onChange(); - } + // fire change event + this._onChange(); } }; diff --git a/src/js/util.js b/src/js/util.js index ff1ad19..3254bfd 100644 --- a/src/js/util.js +++ b/src/js/util.js @@ -626,3 +626,53 @@ exports.removeEventListener = function removeEventListener(element, action, list element.detachEvent("on" + action, listener); } }; + +/** + * Parse a JSON path like '.items[3].name' into an array + * @param {string} jsonPath + * @return {Array} + */ +exports.parsePath = function parsePath(jsonPath) { + var prop, remainder; + + if (jsonPath.length === 0) { + return []; + } + + // find a match like '.prop' + var match = jsonPath.match(/^\.(\w+)/); + if (match) { + prop = match[1]; + remainder = jsonPath.substr(prop.length + 1); + } + else if (jsonPath[0] === '[') { + // find a match like + var end = jsonPath.indexOf(']'); + if (end === -1) { + throw new SyntaxError('Character ] expected in path'); + } + if (end === 1) { + throw new SyntaxError('Index expected after ['); + } + + prop = JSON.parse(jsonPath.substring(1, end)); + remainder = jsonPath.substr(end + 1); + } + else { + throw new SyntaxError('Failed to parse path'); + } + + return [prop].concat(parsePath(remainder)) +}; + +/** + * Test whether the child rect fits completely inside the parent rect. + * @param {ClientRect} parent + * @param {ClientRect} child + */ +exports.insideRect = function (parent, child) { + return child.left >= parent.left + && child.right <= parent.right + && child.top >= parent.top + && child.bottom <= parent.bottom; +}; \ No newline at end of file diff --git a/test/test_schema.html b/test/test_schema.html new file mode 100644 index 0000000..cfa80af --- /dev/null +++ b/test/test_schema.html @@ -0,0 +1,93 @@ + + + + + + + + + + + + +

+ Switch editor mode using the mode box. + Note that the mode can be changed programmatically as well using the method + editor.setMode(mode), try it in the console of your browser. +

+ +
+ + + +