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.
+