diff --git a/src/ContextMenu.js b/src/ContextMenu.js index ae9e4d7..76b1702 100644 --- a/src/ContextMenu.js +++ b/src/ContextMenu.js @@ -3,9 +3,127 @@ import { h, Component } from 'preact' export default class ContextMenu extends Component { constructor(props) { super(props) + + this.state = { + expanded: null, // menu index of expanded menu item + expanding: null, // menu index of expanding menu item + collapsing: null // menu index of collapsing menu item + } + + this.onUpdate = [] // handlers to be executed after component update + + this.renderMenuItem = this.renderMenuItem.bind(this) } render () { - return h('div', {class: 'jsoneditor-contextmenu'}, 'context menu...') + + // TODO: create a non-visible button to set the focus to the menu + // TODO: implement (customizable) quick keys + + // TODO: render the context menu on top when there is no space below the node + + return h('div', {class: 'jsoneditor-contextmenu'}, + this.props.items.map(this.renderMenuItem) + ) + } + + renderMenuItem (item, index) { + if (item.type === 'separator') { + return h('div', {class: 'jsoneditor-menu-separator'}) + } + + if (item.click && item.submenu) { + // two buttons: direct click and a small button to expand the submenu + return h('div', {class: 'jsoneditor-menu-item'}, [ + h('button', {class: 'jsoneditor-menu-button jsoneditor-menu-default ' + item.className, title: item.title, onClick: item.click }, [ + h('span', {class: 'jsoneditor-icon'}), + h('span', {class: 'jsoneditor-text'}, item.text) + ]), + h('button', {class: 'jsoneditor-menu-button jsoneditor-menu-expand', onClick: this.createExpandHandler(index) }, [ + h('span', {class: 'jsoneditor-icon jsoneditor-icon-expand'}) + ]), + this.renderSubMenu(item.submenu, index) + ]) + } + else if (item.submenu) { + // button expands the submenu + + return h('div', {class: 'jsoneditor-menu-item'}, [ + h('button', {class: 'jsoneditor-menu-button ' + item.className, title: item.title, onClick: this.createExpandHandler(index) }, [ + h('span', {class: 'jsoneditor-icon'}), + h('span', {class: 'jsoneditor-text'}, item.text), + h('span', {class: 'jsoneditor-icon jsoneditor-icon-expand'}), + ]), + this.renderSubMenu(item.submenu, index) + ]) + } + else { + // just a button (no submenu) + return h('div', {class: 'jsoneditor-menu-item'}, [ + h('button', {class: 'jsoneditor-menu-button ' + item.className, title: item.title, onClick: item.click }, [ + h('span', {class: 'jsoneditor-icon'}), + h('span', {class: 'jsoneditor-text'}, item.text) + ]), + ]) + } + } + + /** + * @param {Array} submenu + * @param {number} index + */ + renderSubMenu (submenu, index) { + const expanding = this.state.expanding === index + const collapsing = this.state.collapsing === index + + const contents = submenu.map(item => { + return h('div', {class: 'jsoneditor-menu-item'}, [ + h('button', {class: 'jsoneditor-menu-button ' + item.className, title: item.title, onClick: item.click }, [ + h('span', {class: 'jsoneditor-icon'}), + h('span', {class: 'jsoneditor-text'}, item.text) + ]), + ]) + }) + + const className = 'jsoneditor-submenu ' + + (expanding ? ' jsoneditor-expanding' : '') + + (collapsing ? ' jsoneditor-collapsing' : '') + + return h('div', {class: className}, contents) + } + + createExpandHandler (index) { + return (event) => { + event.stopPropagation() + + const prev = this.state.expanded + + this.setState({ + expanded: (prev === index) ? null : index, + expanding: null, + collapsing: prev + }) + + this.onUpdate.push(() => { + this.setState({ + expanding: this.state.expanded + }) + }) + + // timeout after unit is collapsed + setTimeout(() => { + if (prev === this.state.collapsing) { + this.setState({ + collapsing: null + }) + } + }, 300) + } + } + + componentDidUpdate () { + this.onUpdate.forEach(handler => handler()) + this.onUpdate = [] + } } diff --git a/src/JSONNode.js b/src/JSONNode.js index 65bf19a..00def47 100644 --- a/src/JSONNode.js +++ b/src/JSONNode.js @@ -4,7 +4,21 @@ import ContextMenu from './ContextMenu' import { escapeHTML, unescapeHTML } from './utils/stringUtils' import { getInnerText } from './utils/domUtils' import {stringConvert, valueType, isUrl} from './utils/typeUtils' -import * as pointer from 'json-pointer' + +// TYPE_TITLES with explanation for the different types +const TYPE_TITLES = { + 'value': 'Item type "value". ' + + 'The item type is automatically determined from the value ' + + 'and can be a string, number, boolean, or null.', + 'object': 'Item type "object". ' + + 'An object contains an unordered set of key/value pairs.', + 'array': 'Item type "array". ' + + 'An array contains an ordered collection of values.', + 'string': 'Item type "string". ' + + 'Item type is not determined from the value, ' + + 'but always returned as string.' +}; + export default class JSONNode extends Component { constructor (props) { @@ -30,7 +44,7 @@ export default class JSONNode extends Component { } } - renderJSONObject ({data, index, options, events, menu}) { + renderJSONObject ({data, index, options, events}) { const childCount = data.childs.length const contents = [ h('div', {class: 'jsoneditor-node jsoneditor-object'}, [ @@ -47,8 +61,7 @@ export default class JSONNode extends Component { return h(JSONNode, { data: child, options, - events, - menu + events }) }) @@ -58,7 +71,7 @@ export default class JSONNode extends Component { return h('li', {}, contents) } - renderJSONArray ({data, index, options, events, menu}) { + renderJSONArray ({data, index, options, events}) { const childCount = data.childs.length const contents = [ h('div', {class: 'jsoneditor-node jsoneditor-array'}, [ @@ -76,8 +89,7 @@ export default class JSONNode extends Component { data: child, index, options, - events, - menu + events }) }) @@ -147,25 +159,187 @@ export default class JSONNode extends Component { } renderContextMenuButton () { - const visible = this.contextMenuVisible() - const childs = visible - ? [ h(ContextMenu) ] - : null + const visible = this.props.data.menu === true - const className = `jsoneditor-button jsoneditor-contextmenu` + (visible ? ' jsoneditor-visible' : '') + const className = 'jsoneditor-button jsoneditor-contextmenu' + (visible ? ' jsoneditor-visible' : '') return h('div', {class: 'jsoneditor-button-container'}, - h('button', {class: className, onClick: this.handleContextMenu}, childs) + visible ? this.renderContextMenu() : null, + h('button', {class: className, onClick: this.handleContextMenu}) ) } + renderContextMenu () { + const hasParent = this.props.data.path !== '' + const type = this.props.data.type + const items = [] // array with menu items + + items.push({ + text: 'Type', + title: 'Change the type of this field', + className: 'jsoneditor-type-' + type, + submenu: [ + { + text: 'Value', + className: 'jsoneditor-type-value' + (type == 'value' ? ' jsoneditor-selected' : ''), + title: TYPE_TITLES.value, + click: function () { + alert('value') // TODO + } + }, + { + text: 'Array', + className: 'jsoneditor-type-array' + (type == 'array' ? ' jsoneditor-selected' : ''), + title: TYPE_TITLES.array, + click: function () { + alert('array') // TODO + } + }, + { + text: 'Object', + className: 'jsoneditor-type-object' + (type == 'object' ? ' jsoneditor-selected' : ''), + title: TYPE_TITLES.object, + click: function () { + //node._onChangeType('object'); + alert('object') // TODO + } + }, + { + text: 'String', + className: 'jsoneditor-type-string' + (type == 'string' ? ' jsoneditor-selected' : ''), + title: TYPE_TITLES.string, + click: function () { + // node._onChangeType('string'); + alert('string') // TODO + } + } + ] + }); + + if (type === 'array' || type === 'object') { + var direction = ((this.sortOrder == 'asc') ? 'desc': 'asc'); + items.push({ + text: 'Sort', + title: 'Sort the childs of this ' + TYPE_TITLES.type, + className: 'jsoneditor-sort-' + direction, + click: function () { + // node.sort(direction); + alert('sort') // TODO + }, + submenu: [ + { + text: 'Ascending', + className: 'jsoneditor-sort-asc', + title: 'Sort the childs of this ' + TYPE_TITLES.type + ' in ascending order', + click: function () { + // node.sort('asc'); + alert('asc') // TODO + } + }, + { + text: 'Descending', + className: 'jsoneditor-sort-desc', + title: 'Sort the childs of this ' + TYPE_TITLES.type +' in descending order', + click: function () { + // node.sort('desc'); + alert('desc') // TODO + } + } + ] + }); + } + + if (hasParent) { + if (items.length) { + // create a separator + items.push({ + 'type': 'separator' + }); + } + + // create insert button + items.push({ + text: 'Insert', + title: 'Insert a new item with type \'value\' after this item (Ctrl+Ins)', + submenuTitle: 'Select the type of the item to be inserted', + className: 'jsoneditor-insert', + click: function () { + // node._onInsertBefore('', '', 'value'); + alert('insert') // TODO + }, + submenu: [ + { + text: 'Value', + className: 'jsoneditor-type-value', + title: TYPE_TITLES.value, + click: function () { + // node._onInsertBefore('', '', 'value'); + alert('insert value') // TODO + } + }, + { + text: 'Array', + className: 'jsoneditor-type-array', + title: TYPE_TITLES.array, + click: function () { + // node._onInsertBefore('', []); + alert('insert array') // TODO + } + }, + { + text: 'Object', + className: 'jsoneditor-type-object', + title: TYPE_TITLES.object, + click: function () { + // node._onInsertBefore('', {}); + alert('insert object') // TODO + } + }, + { + text: 'String', + className: 'jsoneditor-type-string', + title: TYPE_TITLES.string, + click: function () { + // node._onInsertBefore('', '', 'string'); + alert('insert string') // TODO + } + } + ] + }); + + if (this.props.path !== '') { + // create duplicate button + items.push({ + text: 'Duplicate', + title: 'Duplicate this item (Ctrl+D)', + className: 'jsoneditor-duplicate', + click: function () { + // Node.onDuplicate(node); + alert('duplicate') // TODO + } + }); + + // create remove button + items.push({ + text: 'Remove', + title: 'Remove this item (Ctrl+Del)', + className: 'jsoneditor-remove', + click: function () { + //Node.onRemove(node); + alert('remove') // TODO + } + }); + } + } + + // TODO: implement a hook to adjust the context menu + + return h(ContextMenu, {items}) + } + shouldComponentUpdate(nextProps, nextState) { return Object.keys(nextProps).some(prop => this.props[prop] !== nextProps[prop]) } - contextMenuVisible () { - return this.props.menu === this.props.data.path - } - static _rootName (data, options) { return typeof options.name === 'string' ? options.name @@ -177,7 +351,10 @@ export default class JSONNode extends Component { handleChangeProperty (event) { const oldProp = this.props.data.prop const newProp = unescapeHTML(getInnerText(event.target)) - const path = this.props.data.path.replace(/\/.+$/, '') // remove last entry + + // remove last entry from the path to get the path of the parent object + const index = this.props.data.path.lastIndexOf('/') + const path = this.props.data.path.substr(0, index) this.props.events.onChangeProperty(path, oldProp, newProp) } @@ -205,8 +382,10 @@ export default class JSONNode extends Component { } handleContextMenu (event) { + event.stopPropagation() // stop propagation, because else Main.js will hide the context menu again + // toggle visibility of the context menu - const path = this.contextMenuVisible() + const path = this.props.data.menu === true ? null : this.props.data.path diff --git a/src/Main.js b/src/Main.js index 4b37e54..addd8fa 100644 --- a/src/Main.js +++ b/src/Main.js @@ -12,7 +12,7 @@ export default class Main extends Component { this.state = { options: Object.assign({ name: null, - expand: Main.expand + expand: Main.expand // TODO: remove expand as option, should be passed as optional callback to editor.set }, props.options || {}), data: { @@ -23,28 +23,31 @@ export default class Main extends Component { }, events: { - onChangeProperty: this._onChangeProperty.bind(this), - onChangeValue: this._onChangeValue.bind(this), - onExpand: this._onExpand.bind(this), - onContextMenu: this._onContextMenu.bind(this) + onChangeProperty: this.handleChangeProperty.bind(this), + onChangeValue: this.handleChangeValue.bind(this), + onExpand: this.handleExpand.bind(this), + onContextMenu: this.handleContextMenu.bind(this) }, + /** @type {string | null} */ menu: null, // json pointer to the node having menu visible search: null } + + this.handleHideContextMenu = this.handleHideContextMenu.bind(this) } render() { - return h('div', {class: 'jsoneditor'}, [ + return h('div', {class: 'jsoneditor', onClick: this.handleHideContextMenu}, [ h('ul', {class: 'jsoneditor-list'}, [ h(JSONNode, this.state) ]) ]) } - _onChangeValue (path, value) { - console.log('_onChangeValue', path, value) + handleChangeValue (path, value) { + console.log('handleChangeValue', path, value) const modelPath = Main._pathToModelPath(this.state.data, Main._parsePath(path)).concat('value') @@ -53,30 +56,28 @@ export default class Main extends Component { }) } - _onChangeProperty (path, oldProp, newProp) { - console.log('_onChangeProperty', path, oldProp, newProp) + handleChangeProperty (path, oldProp, newProp) { + console.log('handleChangeProperty', path, oldProp, newProp) - const array = pointer.parse(path) - const parent = getIn(this.state.data, array) + const modelPath = Main._pathToModelPath(this.state.data, pointer.parse(path)) + const parent = getIn(this.state.data, modelPath) const index = parent.childs.findIndex(child => child.prop === oldProp) - const newPath = path + '/' + pointer.escape(newProp) - const modelPath = Main._pathToModelPath(this.state.data, array).concat(['childs', index]) let data = this.state.data - data = setIn(data, modelPath.concat('path'), newPath) - data = setIn(data, modelPath.concat('prop'), newProp) + data = setIn(data, modelPath.concat(['childs', index, 'path']), newPath) + data = setIn(data, modelPath.concat(['childs', index, 'prop']), newProp) this.setState({ data }) } - _onExpand(path, expand) { - const modelPath = Main._pathToModelPath(this.state.data, Main._parsePath(path)).concat('expanded') + handleExpand(path, expand) { + const modelPath = Main._pathToModelPath(this.state.data, Main._parsePath(path)) - console.log('_onExpand', path, modelPath) + console.log('handleExpand', path, modelPath) this.setState({ - data: setIn(this.state.data, modelPath, expand) + data: setIn(this.state.data, modelPath.concat('expanded'), expand) }) } @@ -85,12 +86,32 @@ export default class Main extends Component { * @param {string | null} path * @private */ - _onContextMenu(path) { + handleContextMenu(path) { + let data = this.state.data + + // hide previous context menu (if any) + if (this.state.menu !== null) { + const modelPath = Main._pathToModelPath(this.state.data, Main._parsePath(this.state.menu)) + data = setIn(data, modelPath.concat('menu'), false) + } + + // show new menu + if (path !== null) { + const modelPath = Main._pathToModelPath(this.state.data, Main._parsePath(path)) + data = setIn(data, modelPath.concat('menu'), true) + } + this.setState({ - menu: path || null + menu: path, // store path of current menu, just to easily find it next time + data }) } + handleHideContextMenu (event) { + // FIXME: find a different way to show/hide the context menu. create a single instance in the Main, pass a reference to it into the JSON nodes? + this.handleContextMenu(null) + } + // TODO: comment get () { return Main._modelToJson(this.state.data) @@ -187,7 +208,7 @@ export default class Main extends Component { } else { return { - type: 'auto', + type: 'value', path, prop, value @@ -215,7 +236,7 @@ export default class Main extends Component { return object } else { - // type 'auto' or 'string' + // type 'value' or 'string' return model.value } } diff --git a/src/jsoneditor.css b/src/jsoneditor.css index 20cbb3d..d7e86f7 100644 --- a/src/jsoneditor.css +++ b/src/jsoneditor.css @@ -7,7 +7,8 @@ } .jsoneditor-node { - /*border: 1px solid #555;*/ + position: relative; + font: 14px Arial; /* flexbox setup */ @@ -167,13 +168,212 @@ button.jsoneditor-button.jsoneditor-contextmenu.jsoneditor-visible { background-position: -50px -50px; } + + +/******************************* Context Menu ******************************/ + div.jsoneditor-contextmenu { position: absolute; + box-sizing: border-box; + z-index: 99999; top: 20px; - left: 0; - - z-index: 1; + left: 18px; /* 20px - 2px where 2px half the difference between 24x24 icons of the menu and the 20x20 icons of the editor */ background: white; - padding: 5px; - border: 1px solid gray; + + border: 1px solid #d3d3d3; + box-shadow: 2px 2px 12px rgba(128, 128, 128, 0.3); +} + +div.jsoneditor-menu-item { + line-height: 0; + font-size: 0; +} + +button.jsoneditor-menu-button { + width: 136px; + height: 24px; + padding: 0; + margin: 0; + line-height: 24px; + + background: transparent; + border: transparent; + display: inline-block; + box-sizing: border-box; + + cursor: pointer; + color: #4d4d4d; + + font-size: 10pt; + font-family: arial, sans-serif; + text-align: left; +} + +button.jsoneditor-menu-button:hover, +button.jsoneditor-menu-button:focus { + color: #1a1a1a; + background-color: #f5f5f5; + outline: none; +} + +button.jsoneditor-menu-button.jsoneditor-selected { + color: white; + background-color: #ee422e; +} + +button.jsoneditor-menu-default { + width: 104px; /* 136px - 32px */ +} + +button.jsoneditor-menu-expand { + width: 32px; + float: right; + border-left: 1px solid #e5e5e5; +} + +span.jsoneditor-icon { + float: left; + width: 24px; + height: 24px; + border: none; + padding: 0; + margin: 0; + background-image: url('img/jsoneditor-icons.svg'); +} + +span.jsoneditor-icon.jsoneditor-icon-expand { + float: right; + width: 24px; + margin: 0 4px; + + background-position: 0 -72px !important; + opacity: 0.4; +} + +div.jsoneditor-menu-item button.jsoneditor-menu-button:hover span.jsoneditor-icon-expand, +div.jsoneditor-menu-item button:focus span.jsoneditor-icon-expand { + opacity: 1; +} + +span.jsoneditor-text { + display: inline-block; + line-height: 24px; +} + +div.jsoneditor-menu-separator { + height: 0; + border-top: 1px solid #e5e5e5; + padding-top: 5px; + margin-top: 5px; +} + +button.jsoneditor-remove span.jsoneditor-icon { + background-position: -24px -24px; +} +button.jsoneditor-remove:hover span.jsoneditor-icon, +button.jsoneditor-remove:focus span.jsoneditor-icon { + background-position: -24px 0; +} + +button.jsoneditor-insert span.jsoneditor-icon { + background-position: 0 -24px; +} +button.jsoneditor-insert:hover span.jsoneditor-icon, +button.jsoneditor-insert:focus span.jsoneditor-icon { + background-position: 0 0; +} + +button.jsoneditor-duplicate span.jsoneditor-icon { + background-position: -48px -24px; +} +button.jsoneditor-duplicate:hover span.jsoneditor-icon, +button.jsoneditor-duplicate:focus span.jsoneditor-icon { + background-position: -48px 0; +} + +button.jsoneditor-sort-asc span.jsoneditor-icon { + background-position: -168px -24px; +} +button.jsoneditor-sort-asc:hover span.jsoneditor-icon, +button.jsoneditor-sort-asc:focus span.jsoneditor-icon { + background-position: -168px 0; +} + +button.jsoneditor-sort-desc span.jsoneditor-icon { + background-position: -192px -24px; +} +button.jsoneditor-sort-desc:hover span.jsoneditor-icon, +button.jsoneditor-sort-desc:focus span.jsoneditor-icon { + background-position: -192px 0; +} + +div.jsoneditor-submenu { + visibility: hidden; + max-height: 0; + + overflow: hidden; + + transition: max-height 0.3s ease-out; + + box-shadow: inset 0 10px 10px -10px rgba(128, 128, 128, 0.5), + inset 0 -10px 10px -10px rgba(128, 128, 128, 0.5) +} + +div.jsoneditor-submenu.jsoneditor-expanding { + visibility: visible; + max-height: 104px; /* 4 * 24px + 2 * 5px */ + /* FIXME: shouldn't rely on max-height equal to 4 items, should be flexible */ +} + +div.jsoneditor-submenu.jsoneditor-collapsing { + visibility: visible; + max-height: 0; +} + +div.jsoneditor-submenu button { + padding-left: 24px; +} + +div.jsoneditor-submenu div.jsoneditor-menu-item:first-child { + margin-top: 5px; +} + +div.jsoneditor-submenu div.jsoneditor-menu-item:last-child { + margin-bottom: 5px; +} + +button.jsoneditor-type-string span.jsoneditor-icon { + background-position: -144px -24px; +} +button.jsoneditor-type-string:hover span.jsoneditor-icon, +button.jsoneditor-type-string:focus span.jsoneditor-icon, +button.jsoneditor-type-string.jsoneditor-selected span.jsoneditor-icon { + background-position: -144px 0; +} + +button.jsoneditor-type-value span.jsoneditor-icon { + background-position: -120px -24px; +} +button.jsoneditor-type-value:hover span.jsoneditor-icon, +button.jsoneditor-type-value:focus span.jsoneditor-icon, +button.jsoneditor-type-value.jsoneditor-selected span.jsoneditor-icon { + background-position: -120px 0; +} + +button.jsoneditor-type-object span.jsoneditor-icon { + background-position: -72px -24px; +} +button.jsoneditor-type-object:hover span.jsoneditor-icon, +button.jsoneditor-type-object:focus span.jsoneditor-icon, +button.jsoneditor-type-object.jsoneditor-selected span.jsoneditor-icon { + background-position: -72px 0; +} + +button.jsoneditor-type-array span.jsoneditor-icon { + background-position: -96px -24px; +} +button.jsoneditor-type-array:hover span.jsoneditor-icon, +button.jsoneditor-type-array:focus span.jsoneditor-icon, +button.jsoneditor-type-array.jsoneditor-selected span.jsoneditor-icon { + background-position: -96px 0; } diff --git a/src/typedef.js b/src/typedef.js index 15dc9bd..f81b474 100644 --- a/src/typedef.js +++ b/src/typedef.js @@ -3,6 +3,7 @@ * @typedef {{ * type: string, * expanded: boolean?, + * menu: boolean?, * path: string, * prop: string?, * value: *?,