From 4e0aa5659c55e059b0b0a0f09f06ce11dff479b8 Mon Sep 17 00:00:00 2001 From: jos Date: Tue, 12 Jul 2016 15:43:42 +0200 Subject: [PATCH] Apply changes in fields --- public/jsoneditor.css | 10 ++-- src/JSONNode.js | 69 ++++++++++++++++++-------- src/Main.js | 26 +++++++++- src/utils/escapeHTML.js | 42 ++++++++++++++++ src/utils/getIn.js | 32 ++++++++++++ src/utils/getInnerText.js | 101 ++++++++++++++++++++++++++++++++++++++ src/utils/parseJSON.js | 35 +++++++++++++ src/utils/unescapeHTML.js | 56 +++++++++++++++++++++ 8 files changed, 344 insertions(+), 27 deletions(-) create mode 100644 src/utils/escapeHTML.js create mode 100644 src/utils/getIn.js create mode 100644 src/utils/getInnerText.js create mode 100644 src/utils/parseJSON.js create mode 100644 src/utils/unescapeHTML.js diff --git a/public/jsoneditor.css b/public/jsoneditor.css index 0775762..90a2b28 100644 --- a/public/jsoneditor.css +++ b/public/jsoneditor.css @@ -24,17 +24,17 @@ ul.jsoneditor-list { margin: 0; } -.jsoneditor-key, +.jsoneditor-field, .jsoneditor-value { - min-width: 32px; + min-width: 24px; border: 1px solid transparent; border-radius: 2px; word-break: break-word; } -.jsoneditor-key:focus, +.jsoneditor-field:focus, .jsoneditor-value:focus, -.jsoneditor-key:hover, +.jsoneditor-field:hover, .jsoneditor-value:hover { background-color: #FFFFAB; border-color: #ff0; @@ -44,6 +44,6 @@ ul.jsoneditor-list { color: gray; } -.jsoneditor-info { +.jsoneditor-readonly { color: gray; } \ No newline at end of file diff --git a/src/JSONNode.js b/src/JSONNode.js index 3bc8a7c..59b68c3 100644 --- a/src/JSONNode.js +++ b/src/JSONNode.js @@ -1,11 +1,15 @@ import { h, Component } from 'preact' import isObject from './utils/isObject' +import escapeHTML from './utils/escapeHTML' +import unescapeHTML from './utils/unescapeHTML' +import getInnerText from './utils/getInnerText' export default class JSONNode extends Component { constructor (props) { super(props) - this.onValueInput = this.onValueInput.bind(this) + this.onBlurField = this.onBlurField.bind(this) + this.onBlurValue = this.onBlurValue.bind(this) } render (props) { @@ -20,47 +24,51 @@ export default class JSONNode extends Component { } } - renderObject ({field, value, onChangeValue}) { + renderObject ({parent, field, value, onChangeValue, onChangeField}) { //console.log('JSONObject', field,value) + const hasParent = parent !== null return h('li', {class: 'jsoneditor-object'}, [ h('div', {class: 'jsoneditor-node'}, [ - h('div', {class: 'jsoneditor-field', contentEditable: true}, field), + h('div', {class: 'jsoneditor-field', contentEditable: hasParent, onBlur: this.onBlurField}, hasParent ? field : 'object'), h('div', {class: 'jsoneditor-separator'}, ':'), - h('div', {class: 'jsoneditor-info'}, '{' + Object.keys(value).length + '}') + h('div', {class: 'jsoneditor-readonly', contentEditable: false}, '{' + Object.keys(value).length + '}') ]), h('ul', {class: 'jsoneditor-list'}, - Object.keys(value).map(f => h(JSONNode, {parent: this, field: f, value: value[f], onChangeValue}))) + Object + .keys(value) + .map(f => h(JSONNode, {parent: this, field: f, value: value[f], onChangeValue, onChangeField}))) ]) } - renderArray ({field, value, onChangeValue}) { + renderArray ({parent, field, value, onChangeValue, onChangeField}) { + const hasParent = parent !== null + return h('li', {}, [ h('div', {class: 'jsoneditor-node jsoneditor-array'}, [ - h('div', {class: 'jsoneditor-field', contentEditable: true}, field), + h('div', {class: 'jsoneditor-field', contentEditable: hasParent, onBlur: this.onBlurField}, hasParent ? field : 'array'), h('div', {class: 'jsoneditor-separator'}, ':'), - h('div', {class: 'jsoneditor-info'}, '{' + value.length + '}') + h('div', {class: 'jsoneditor-readonly', contentEditable: false}, '{' + value.length + '}') ]), h('ul', {class: 'jsoneditor-list'}, - value.map((v, f) => h(JSONNode, {parent: this, field: f, value: v, onChangeValue}))) + value + .map((v, i) => h(JSONNode, {parent: this, index: i, value: v, onChangeValue, onChangeField}))) ]) } - renderValue ({field, value}) { + renderValue ({parent, index, field, value}) { + const hasParent = parent !== null //console.log('JSONValue', field, value) return h('li', {}, [ h('div', {class: 'jsoneditor-node'}, [ - h('div', {class: 'jsoneditor-field', contentEditable: true}, field), + index !== undefined + ? h('div', {class: 'jsoneditor-readonly', contentEditable: false}, index) + : h('div', {class: 'jsoneditor-field', contentEditable: hasParent, onBlur: this.onBlurField}, hasParent ? field : 'value'), h('div', {class: 'jsoneditor-separator'}, ':'), - h('div', { - class: 'jsoneditor-value', - contentEditable: true, - // 'data-path': JSON.stringify(this.getPath()) - onInput: this.onValueInput - }, value) + h('div', {class: 'jsoneditor-value', contentEditable: true, onBlur: this.onBlurValue}, escapeHTML(value)) ]) ]) } @@ -69,10 +77,21 @@ export default class JSONNode extends Component { return nextProps.field !== this.props.field || nextProps.value !== this.props.value } - onValueInput (event) { + onBlurField (event) { + const path = this.props.parent.getPath() + const newField = getInnerText(event.target) + const oldField = this.props.field + if (newField !== oldField) { + this.props.onChangeField(path, newField, oldField) + } + } + + onBlurValue (event) { const path = this.getPath() - const value = event.target.innerHTML - this.props.onChangeValue(path, value) + const value = unescapeHTML(getInnerText(event.target)) + if (value !== this.props.value) { + this.props.onChangeValue(path, value) + } } getPath () { @@ -80,7 +99,7 @@ export default class JSONNode extends Component { let node = this while (node) { - path.unshift(node.props.field) + path.unshift(node.props.field || node.props.index) node = node.props.parent } @@ -89,4 +108,12 @@ export default class JSONNode extends Component { return path } + + getRoot () { + let node = this + while (node && node.props.parent) { + node = node.props.parent + } + return node + } } diff --git a/src/Main.js b/src/Main.js index 6d7deb8..0983885 100644 --- a/src/Main.js +++ b/src/Main.js @@ -1,5 +1,7 @@ import { h, Component } from 'preact' import setIn from './utils/setIn' +import getIn from './utils/getIn' +import clone from './utils/clone' import JSONNode from './JSONNode' export default class Main extends Component { @@ -11,12 +13,19 @@ export default class Main extends Component { } this.onChangeValue = this.onChangeValue.bind(this) + this.onChangeField = this.onChangeField.bind(this) } render(props, state) { return h('div', {class: 'jsoneditor', onInput: this.onInput}, [ h('ul', {class: 'jsoneditor-list'}, [ - h(JSONNode, {parent: null, field: null, value: state.json, onChangeValue: this.onChangeValue}) + h(JSONNode, { + parent: null, + field: null, + value: state.json, + onChangeField: this.onChangeField, + onChangeValue: this.onChangeValue + }) ]) ]) } @@ -28,6 +37,21 @@ export default class Main extends Component { }) } + onChangeField (path, newField, oldField) { + console.log('onChangeField', path, newField, oldField) + + const value = clone(getIn(this.state.json, path)) + + console.log('value', value) + + value[newField] = value[oldField] + delete value[oldField] + + this.setState({ + json: setIn(this.state.json, path, value) + }) + } + get () { return this.state.json } diff --git a/src/utils/escapeHTML.js b/src/utils/escapeHTML.js new file mode 100644 index 0000000..04f5d92 --- /dev/null +++ b/src/utils/escapeHTML.js @@ -0,0 +1,42 @@ +/** + * escape a text, such that it can be displayed safely in an HTML element + * @param {String} text + * @param {boolean} [escapeUnicode=false] + * @return {String} escapedText + */ +export default function escapeHTML (text, escapeUnicode = false) { + if (typeof text !== 'string') { + return String(text) + } + else { + var htmlEscaped = String(text) + .replace(/&/g, '&') // must be replaced first! + .replace(//g, '>') + .replace(/ /g, '  ') // replace double space with an nbsp and space + .replace(/^ /, ' ') // space at start + .replace(/ $/, ' '); // space at end + + var json = JSON.stringify(htmlEscaped) + var html = json.substring(1, json.length - 1) + if (escapeUnicode === true) { + html = escapeUnicodeChars(html) + } + return html + } +} + +/** + * Escape unicode characters. + * For example input '\u2661' (length 1) will output '\\u2661' (length 5). + * @param {string} text + * @return {string} + */ +function escapeUnicodeChars (text) { + // see https://www.wikiwand.com/en/UTF-16 + // note: we leave surrogate pairs as two individual chars, + // as JSON doesn't interpret them as a single unicode char. + return text.replace(/[\u007F-\uFFFF]/g, function(c) { + return '\\u'+('0000' + c.charCodeAt(0).toString(16)).slice(-4) + }) +} \ No newline at end of file diff --git a/src/utils/getIn.js b/src/utils/getIn.js new file mode 100644 index 0000000..6006606 --- /dev/null +++ b/src/utils/getIn.js @@ -0,0 +1,32 @@ +import isObject from './isObject' + +// TODO: unit test getIn + +/** + * helper function to get a nested property in an object or array + * + * @param {Object | Array} object + * @param {Array.} path + * @return {* | undefined} Returns the field when found, or undefined when the + * path doesn't exist + */ +export default function getIn (object, path) { + let value = object + let i = 0 + + while(i < path.length) { + if (Array.isArray(value) || isObject(value)) { + value = value[path[i]] + } + else { + value = undefined + } + + i++ + } + + return value +} + + +window.getIn = getIn // TODO: cleanup diff --git a/src/utils/getInnerText.js b/src/utils/getInnerText.js new file mode 100644 index 0000000..8377a1a --- /dev/null +++ b/src/utils/getInnerText.js @@ -0,0 +1,101 @@ +/** + * Get the inner text of an HTML element (for example a div element) + * @param {Element} element + * @param {Object} [buffer] + * @return {String} innerText + */ +export default function getInnerText (element, buffer) { + var first = (buffer == undefined) + if (first) { + buffer = { + 'text': '', + 'flush': function () { + var text = this.text + this.text = '' + return text + }, + 'set': function (text) { + this.text = text + } + } + } + + // text node + if (element.nodeValue) { + return buffer.flush() + element.nodeValue + } + + // divs or other HTML elements + if (element.hasChildNodes()) { + var childNodes = element.childNodes + var innerText = '' + + for (var i = 0, iMax = childNodes.length; i < iMax; i++) { + var child = childNodes[i] + + if (child.nodeName == 'DIV' || child.nodeName == 'P') { + var prevChild = childNodes[i - 1] + var prevName = prevChild ? prevChild.nodeName : undefined + if (prevName && prevName != 'DIV' && prevName != 'P' && prevName != 'BR') { + innerText += '\n' + buffer.flush() + } + innerText += getInnerText(child, buffer) + buffer.set('\n') + } + else if (child.nodeName == 'BR') { + innerText += buffer.flush() + buffer.set('\n') + } + else { + innerText += getInnerText(child, buffer) + } + } + + return innerText + } + else { + if (element.nodeName == 'P' && getInternetExplorerVersion() != -1) { + // On Internet Explorer, a

with hasChildNodes()==false is + // rendered with a new line. Note that a

with + // hasChildNodes()==true is rendered without a new line + // Other browsers always ensure there is a
inside the

, + // and if not, the

does not render a new line + return buffer.flush() + } + } + + // br or unknown + return '' +} + +/** + * Returns the version of Internet Explorer or a -1 + * (indicating the use of another browser). + * Source: http://msdn.microsoft.com/en-us/library/ms537509(v=vs.85).aspx + * @return {Number} Internet Explorer version, or -1 in case of an other browser + */ +export function getInternetExplorerVersion() { + if (_ieVersion == -1) { + var rv = -1 // Return value assumes failure. + if (navigator.appName == 'Microsoft Internet Explorer') + { + var ua = navigator.userAgent + var re = new RegExp("MSIE ([0-9]{1,}[\.0-9]{0,})") + if (re.exec(ua) != null) { + rv = parseFloat( RegExp.$1 ) + } + } + + _ieVersion = rv + } + + return _ieVersion +} + +/** + * cached internet explorer version + * @type {Number} + * @private + */ +var _ieVersion = -1 diff --git a/src/utils/parseJSON.js b/src/utils/parseJSON.js new file mode 100644 index 0000000..0d79c93 --- /dev/null +++ b/src/utils/parseJSON.js @@ -0,0 +1,35 @@ +/** + * Parse JSON using the parser built-in in the browser. + * On exception, the jsonString is validated and a detailed error is thrown. + * @param {String} jsonString + * @return {JSON} json + */ +export default function parseJSON(jsonString) { + try { + return JSON.parse(jsonString) + } + catch (err) { + // try to throw a more detailed error message using validate + exports.validate(jsonString) + + // rethrow the original error + throw err + } +} + + +/** + * Validate a string containing a JSON object + * This method uses JSONLint to validate the String. If JSONLint is not + * available, the built-in JSON parser of the browser is used. + * @param {String} jsonString String with an (invalid) JSON object + * @throws Error + */ +export function validate(jsonString) { + if (typeof(window.jsonlint) !== 'undefined') { + window.jsonlint.parse(jsonString) + } + else { + JSON.parse(jsonString) + } +} diff --git a/src/utils/unescapeHTML.js b/src/utils/unescapeHTML.js new file mode 100644 index 0000000..2b3a2b7 --- /dev/null +++ b/src/utils/unescapeHTML.js @@ -0,0 +1,56 @@ +import parseJSON from './parseJSON' +/** + * unescape a string. + * @param {String} escapedText + * @return {String} text + */ +export default function unescapeHTML (escapedText) { + var json = '"' + escapeJSON(escapedText) + '"' + var htmlEscaped = parseJSON(json) + + return htmlEscaped + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/ |\u00A0/g, ' ') + .replace(/&/g, '&') // must be replaced last +} + +/** + * escape a text to make it a valid JSON string. The method will: + * - replace unescaped double quotes with '\"' + * - replace unescaped backslash with '\\' + * - replace returns with '\n' + * @param {String} text + * @return {String} escapedText + * @private + */ +export function escapeJSON (text) { + // TODO: replace with some smart regex (only when a new solution is faster!) + var escaped = '' + var i = 0 + while (i < text.length) { + var c = text.charAt(i) + if (c == '\n') { + escaped += '\\n' + } + else if (c == '\\') { + escaped += c + i++ + + c = text.charAt(i) + if (c === '' || '"\\/bfnrtu'.indexOf(c) == -1) { + escaped += '\\' // no valid escape character + } + escaped += c + } + else if (c == '"') { + escaped += '\\"' + } + else { + escaped += c + } + i++ + } + + return escaped +} \ No newline at end of file