Implemented actions for ContextMenu (not all fully working yet)

This commit is contained in:
jos 2016-07-30 14:40:58 +02:00
parent c3c836fa89
commit 667d3f32aa
7 changed files with 383 additions and 109 deletions

View File

@ -45,7 +45,7 @@ export default class JSONNode extends Component {
}
}
renderJSONObject ({data, index, options, events}) {
renderJSONObject ({data, path, index, options, events}) {
const childCount = data.childs.length
const contents = [
h('div', {class: 'jsoneditor-node jsoneditor-object'}, [
@ -60,6 +60,7 @@ export default class JSONNode extends Component {
if (data.expanded) {
const childs = data.childs.map(child => {
return h(JSONNode, {
path: path + '/' + child.prop,
data: child,
options,
events
@ -72,7 +73,7 @@ export default class JSONNode extends Component {
return h('li', {}, contents)
}
renderJSONArray ({data, index, options, events}) {
renderJSONArray ({data, path, index, options, events}) {
const childCount = data.childs.length
const contents = [
h('div', {class: 'jsoneditor-node jsoneditor-array'}, [
@ -87,6 +88,7 @@ export default class JSONNode extends Component {
if (data.expanded) {
const childs = data.childs.map((child, index) => {
return h(JSONNode, {
path: path + '/' + index,
data: child,
index,
options,
@ -172,8 +174,10 @@ export default class JSONNode extends Component {
}
renderContextMenu ({anchor, root}) {
const hasParent = this.props.data.path !== ''
const path = this.props.path
const hasParent = path !== ''
const type = this.props.data.type
const events = this.props.events
const items = [] // array with menu items
items.push({
@ -185,35 +189,25 @@ export default class JSONNode extends Component {
text: 'Value',
className: 'jsoneditor-type-value' + (type == 'value' ? ' jsoneditor-selected' : ''),
title: TYPE_TITLES.value,
click: function () {
alert('value') // TODO
}
click: () => events.onChangeType(path, 'value')
},
{
text: 'Array',
className: 'jsoneditor-type-array' + (type == 'array' ? ' jsoneditor-selected' : ''),
title: TYPE_TITLES.array,
click: function () {
alert('array') // TODO
}
click: () => events.onChangeType(path, 'array')
},
{
text: 'Object',
className: 'jsoneditor-type-object' + (type == 'object' ? ' jsoneditor-selected' : ''),
title: TYPE_TITLES.object,
click: function () {
//node._onChangeType('object');
alert('object') // TODO
}
click: () => events.onChangeType(path, 'object')
},
{
text: 'String',
className: 'jsoneditor-type-string' + (type == 'string' ? ' jsoneditor-selected' : ''),
title: TYPE_TITLES.string,
click: function () {
// node._onChangeType('string');
alert('string') // TODO
}
click: () => events.onChangeType(path, 'string')
}
]
});
@ -224,28 +218,19 @@ export default class JSONNode extends Component {
text: 'Sort',
title: 'Sort the childs of this ' + TYPE_TITLES.type,
className: 'jsoneditor-sort-' + direction,
click: function () {
// node.sort(direction);
alert('sort') // TODO
},
click: () => events.onSort(path),
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
}
click: () => events.onSort(path, 'asc')
},
{
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
}
click: () => events.onSort(path, 'desc')
}
]
});
@ -265,46 +250,31 @@ export default class JSONNode extends Component {
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
},
click: () => events.onInsert(path, '', ''),
submenu: [
{
text: 'Value',
className: 'jsoneditor-type-value',
title: TYPE_TITLES.value,
click: function () {
// node._onInsertBefore('', '', 'value');
alert('insert value') // TODO
}
click: () => events.onInsert(path, '', '')
},
{
text: 'Array',
className: 'jsoneditor-type-array',
title: TYPE_TITLES.array,
click: function () {
// node._onInsertBefore('', []);
alert('insert array') // TODO
}
click: () => events.onInsert(path, '', [])
},
{
text: 'Object',
className: 'jsoneditor-type-object',
title: TYPE_TITLES.object,
click: function () {
// node._onInsertBefore('', {});
alert('insert object') // TODO
}
click: () => events.onInsert(path, '', {})
},
{
text: 'String',
className: 'jsoneditor-type-string',
title: TYPE_TITLES.string,
click: function () {
// node._onInsertBefore('', '', 'string');
alert('insert string') // TODO
}
click: () => events.onInsert(path, '', '', 'string')
}
]
});
@ -315,10 +285,7 @@ export default class JSONNode extends Component {
text: 'Duplicate',
title: 'Duplicate this item (Ctrl+D)',
className: 'jsoneditor-duplicate',
click: function () {
// Node.onDuplicate(node);
alert('duplicate') // TODO
}
click: () => events.onDuplicate(path)
});
// create remove button
@ -326,10 +293,7 @@ export default class JSONNode extends Component {
text: 'Remove',
title: 'Remove this item (Ctrl+Del)',
className: 'jsoneditor-remove',
click: function () {
//Node.onRemove(node);
alert('remove') // TODO
}
click: () => events.onRemove(path)
});
}
}
@ -357,8 +321,8 @@ export default class JSONNode extends Component {
const newProp = unescapeHTML(getInnerText(event.target))
// 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)
const index = this.props.path.lastIndexOf('/')
const path = this.props.path.substr(0, index)
this.props.events.onChangeProperty(path, oldProp, newProp)
}
@ -366,7 +330,7 @@ export default class JSONNode extends Component {
handleChangeValue (event) {
const value = JSONNode._getValueFromEvent(event)
this.props.events.onChangeValue(this.props.data.path, value)
this.props.events.onChangeValue(this.props.path, value)
}
handleClickValue (event) {
@ -382,7 +346,7 @@ export default class JSONNode extends Component {
}
handleExpand (event) {
this.props.events.onExpand(this.props.data.path, !this.props.data.expanded)
this.props.events.onExpand(this.props.path, !this.props.data.expanded)
}
handleContextMenu (event) {
@ -393,7 +357,7 @@ export default class JSONNode extends Component {
}
else {
this.props.events.showContextMenu({
path: this.props.data.path,
path: this.props.path,
anchor: event.target,
root: JSONNode._findRootElement(event)
})

View File

@ -1,8 +1,10 @@
import { h, Component } from 'preact'
import * as pointer from 'json-pointer'
import { setIn, getIn } from './utils/objectUtils'
import { setIn, updateIn, getIn, deleteIn, cloneDeep } from './utils/objectUtils'
import { compareAsc, compareDesc } from './utils/arrayUtils'
import { isObject } from './utils/typeUtils'
import bindMethods from './utils/bindMethods'
import JSONNode from './JSONNode'
export default class Main extends Component {
@ -10,11 +12,7 @@ export default class Main extends Component {
super(props)
// TODO: create a function bindMethods(this)
this.handleChangeProperty = this.handleChangeProperty.bind(this)
this.handleChangeValue = this.handleChangeValue.bind(this)
this.handleExpand = this.handleExpand.bind(this)
this.handleShowContextMenu = this.handleShowContextMenu.bind(this)
this.handleHideContextMenu = this.handleHideContextMenu.bind(this)
bindMethods(this)
this.state = {
options: Object.assign({
@ -32,7 +30,14 @@ export default class Main extends Component {
events: {
onChangeProperty: this.handleChangeProperty,
onChangeValue: this.handleChangeValue,
onChangeType: this.handleChangeType,
onInsert: this.handleInsert,
onDuplicate: this.handleDuplicate,
onRemove: this.handleRemove,
onSort: this.handleSort,
onExpand: this.handleExpand,
showContextMenu: this.handleShowContextMenu,
hideContextMenu: this.handleHideContextMenu
},
@ -47,7 +52,12 @@ export default class Main extends Component {
render() {
return h('div', {class: 'jsoneditor', onClick: this.handleHideContextMenu}, [
h('ul', {class: 'jsoneditor-list'}, [
h(JSONNode, this.state)
h(JSONNode, {
data: this.state.data,
events: this.state.events,
options: this.state.options,
path: ''
})
])
])
}
@ -55,36 +65,137 @@ export default class Main extends Component {
handleChangeValue (path, value) {
console.log('handleChangeValue', path, value)
const modelPath = Main._pathToModelPath(this.state.data, Main._parsePath(path)).concat('value')
this.setState({
data: setIn(this.state.data, modelPath, value)
})
this._setIn(path, ['value'], value)
}
handleChangeProperty (path, oldProp, newProp) {
console.log('handleChangeProperty', path, oldProp, newProp)
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 index = this._findIndex(path, oldProp)
const newPath = path + '/' + pointer.escape(newProp)
let data = this.state.data
data = setIn(data, modelPath.concat(['childs', index, 'path']), newPath)
data = setIn(data, modelPath.concat(['childs', index, 'prop']), newProp)
this._setIn(path, ['childs', index, 'path'], newPath)
this._setIn(path, ['childs', index, 'prop'], newProp)
}
this.setState({ data })
handleChangeType (path, type) {
console.log('handleChangeType', path, type)
this._setIn(path, ['type'], type)
}
handleInsert (path, prop, value, type) {
console.log('handleInsert', path, prop, value, type)
this.handleHideContextMenu() // TODO: should be handled by the contextmenu itself
// TODO: this method is quite complicated. Can we simplify it?
const parsedPath = pointer.parse(path)
const afterProp = parsedPath[parsedPath.length - 1]
const parentPath = parsedPath.slice(0, parsedPath.length - 1)
.map(entry => '/' + pointer.escape(entry)).join('')
const parent = this._getIn(parentPath)
const index = parent.type === 'array'
? parseInt(afterProp)
: this._findIndex(parentPath, afterProp)
this._updateIn(parentPath, ['childs'], function (childs) {
const updated = childs.slice(0)
const type = isObject(value) ? 'object' : Array.isArray(value) ? 'array' : (type || 'value')
const newEntry = {
expanded: true,
type,
prop,
value,
childs: []
}
updated.splice(index + 1, 0, newEntry)
return updated
})
}
handleDuplicate (path) {
console.log('handleDuplicate', path)
this.handleHideContextMenu() // TODO: should be handled by the contextmenu itself
// TODO: this method is quite complicated. Can we simplify it?
const parsedPath = pointer.parse(path)
const prop = parsedPath[parsedPath.length - 1]
const parentPath = parsedPath.slice(0, parsedPath.length - 1)
.map(entry => '/' + pointer.escape(entry)).join('')
const parent = this._getIn(parentPath)
const index = parent.type === 'array'
? parseInt(prop)
: this._findIndex(parentPath, prop)
this._updateIn(parentPath, ['childs'], function (childs) {
const updated = childs.slice(0)
const original = childs[index]
const duplicate = cloneDeep(original)
updated.splice(index + 1, 0, duplicate)
return updated
})
}
handleRemove (path) {
console.log('handleRemove', path)
this.handleHideContextMenu() // TODO: should be handled by the contextmenu itself
this._deleteIn(path)
}
/**
*
* @param path
* @param {'asc' | 'desc' | null} [order=null] If not provided, will toggle current ordering
*/
handleSort (path, order = null) {
console.log('handleSort', path, order)
this.handleHideContextMenu() // TODO: should be handled by the contextmenu itself
const comparators = {
asc: compareAsc,
desc: compareDesc
}
let _order
if (order === 'asc' || order === 'desc') {
_order = order
}
else {
// toggle previous order
const current = this._getIn(path, ['order'])
_order = current !== 'asc' ? 'asc' : 'desc'
this._setIn(path, ['order'], _order)
}
this._updateIn(path, ['childs'], function (childs) {
const ordered = childs.slice(0)
const compare = comparators[_order] || comparators['asc']
ordered.sort((a, b) => compare(a.value, b.value))
return ordered
})
}
handleExpand(path, expand) {
const modelPath = Main._pathToModelPath(this.state.data, Main._parsePath(path))
console.log('handleExpand', path, expand)
console.log('handleExpand', path, modelPath)
this.setState({
data: setIn(this.state.data, modelPath.concat('expanded'), expand)
})
this._setIn(path, ['expanded'], expand)
}
/**
@ -97,29 +208,62 @@ export default class Main extends Component {
handleShowContextMenu({path, anchor, root}) {
let data = this.state.data
// TODO: remove this cached this.state.contextMenuPath and do a brute-force sweep over the data instead?
// hide previous context menu (if any)
if (this.state.contextMenuPath !== null) {
const modelPath = Main._pathToModelPath(this.state.data, Main._parsePath(this.state.contextMenuPath))
data = setIn(data, modelPath.concat('contextMenu'), false)
this._setIn(this.state.contextMenuPath, ['contextMenu'], null)
}
// show new menu
if (typeof path === 'string') {
const modelPath = Main._pathToModelPath(this.state.data, Main._parsePath(path))
data = setIn(data, modelPath.concat('contextMenu'), {anchor, root})
this._setIn(path, ['contextMenu'], {anchor, root})
}
this.setState({
contextMenuPath: typeof path === 'string' ? path : null, // store path of current menu, just to easily find it next time
data
contextMenuPath: typeof path === 'string' ? path : null // store path of current menu, just to easily find it next time
})
}
handleHideContextMenu (event) {
handleHideContextMenu () {
// 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.handleShowContextMenu({})
}
_getIn (path, modelProps = []) {
const modelPath = Main._pathToModelPath(this.state.data, Main._parsePath(path))
return getIn(this.state.data, modelPath.concat(modelProps))
}
_setIn (path, modelProps = [], value) {
const modelPath = Main._pathToModelPath(this.state.data, Main._parsePath(path))
this.setState({
data: setIn(this.state.data, modelPath.concat(modelProps), value)
})
}
_updateIn (path, modelProps = [], callback) {
const modelPath = Main._pathToModelPath(this.state.data, Main._parsePath(path))
this.setState({
data: updateIn(this.state.data, modelPath.concat(modelProps), callback)
})
}
_deleteIn (path, modelProps = []) {
const modelPath = Main._pathToModelPath(this.state.data, Main._parsePath(path))
this.setState({
data: deleteIn(this.state.data, modelPath.concat(modelProps))
})
}
_findIndex(path, prop) {
const object = this._getIn(path)
return object.childs.findIndex(child => child.prop === prop)
}
// TODO: comment
get () {
return Main._modelToJson(this.state.data)
@ -198,7 +342,6 @@ export default class Main extends Component {
return {
type: 'array',
expanded: expand(path),
path,
prop,
childs: value.map((child, index) => Main._jsonToModel(path + '/' + index, null, child, expand))
}
@ -207,7 +350,6 @@ export default class Main extends Component {
return {
type: 'object',
expanded: expand(path),
path,
prop,
childs: Object.keys(value).map(prop => {
return Main._jsonToModel(path + '/' + pointer.escape(prop), prop, value[prop], expand)
@ -217,7 +359,6 @@ export default class Main extends Component {
else {
return {
type: 'value',
path,
prop,
value
}

View File

@ -35,9 +35,9 @@
// set json
document.getElementById('setJSON').onclick = function () {
// console.time('set')
console.time('set')
editor.set(largeJSON);
// console.timeEnd('set')
console.timeEnd('set')
};
// get json

View File

@ -4,7 +4,6 @@
* type: string,
* expanded: boolean?,
* menu: boolean?,
* path: string,
* prop: string?,
* value: *?,
* childs: Model[]?

View File

@ -5,4 +5,32 @@
*/
export function last (array) {
return array[array.length - 1]
}
}
/**
* Comparator to sort an array in ascending order
*
* Usage:
* [4,2,5].sort(compareAsc) // [2,4,5]
*
* @param a
* @param b
* @return {number}
*/
export function compareAsc (a, b) {
return a > b ? 1 : a < b ? -1 : 0
}
/**
* Comparator to sort an array in ascending order
*
* Usage:
* [4,2,5].sort(compareDesc) // [5,4,2]
*
* @param a
* @param b
* @return {number}
*/
export function compareDesc (a, b) {
return a > b ? -1 : a < b ? 1 : 0
}

28
src/utils/bindMethods.js Normal file
View File

@ -0,0 +1,28 @@
/**
* Helper function to bind all methods of a class instance to the instance
*
* Usage:
*
* import bindMethods from './bindMethods'
*
* class MyClass {
* constructor () {
* bindMethods(this)
* }
*
* myMethod () {
* // ...
* }
* }
*
* @param {Object} instance Instance of an ES6 class or prototype
*/
export default function bindMethods (instance) {
const prototype = Object.getPrototypeOf(instance)
Object.getOwnPropertyNames(prototype).forEach(name => {
if (typeof instance[name] === 'function') {
instance[name] = instance[name].bind(instance);
}
})
}

View File

@ -1,5 +1,13 @@
import { isObject } from './typeUtils'
// TODO: unit test getIn
// inspiration:
//
// https://www.npmjs.com/package/seamless-immutable
// https://www.npmjs.com/package/ih
// https://www.npmjs.com/package/mutatis
// TODO: unit test clone
/**
* Flat clone the properties of an object or array
@ -25,6 +33,34 @@ export function clone (value) {
}
}
// TODO: test cloneDeep
/**
* Deep clone the properties of an object or array
* @param {Object | Array} value
* @return {Object | Array} Returns a deep clone of the object or Array
*/
export function cloneDeep (value) {
if (Array.isArray(value)) {
return value.map(cloneDeep)
}
else if (isObject(value)) {
const cloned = {}
Object.keys(value).forEach(key => {
cloned[key] = cloneDeep(value[key])
})
return cloned
}
else {
// a primitive value
return value
}
}
// TODO: unit test getIn
/**
* helper function to get a nested property in an object or array
*
@ -67,15 +103,93 @@ export function setIn (object, path, value) {
return value
}
// TODO: change array into object and vice versa when key is a number/string
const key = path[0]
const child = (Array.isArray(object[key]) || isObject(object[key]))
? object[key]
: (typeof path[1] === 'number' ? [] : {})
const updated = clone(object)
let updated
if (typeof key === 'string' && !isObject(object)) {
updated = {} // change into an object
}
else if (typeof key === 'number' && !Array.isArray(object)) {
updated = [] // change into an array
}
else {
updated = clone(object)
}
updated[key] = setIn(child, path.slice(1), value)
updated[key] = setIn(updated[key], path.slice(1), value)
return updated
}
// TODO: unit test updateIn
/**
* helper function to replace a nested property in an object with a new value
* without mutating the object itself.
*
* @param {Object | Array} object
* @param {Array.<string | number>} path
* @param {function} callback
* @return {Object | Array} Returns a new, updated object or array
*/
export function updateIn (object, path, callback) {
if (path.length === 0) {
return callback(object)
}
const key = path[0]
let updated
if (typeof key === 'string' && !isObject(object)) {
updated = {} // change into an object
}
else if (typeof key === 'number' && !Array.isArray(object)) {
updated = [] // change into an array
}
else {
updated = clone(object)
}
updated[key] = updateIn(updated[key], path.slice(1), callback)
return updated
}
// TODO: unit test deleteIn
/**
* helper function to delete a nested property in an object
* without mutating the object itself.
*
* @param {Object | Array} object
* @param {Array.<string | number>} path
* @return {Object | Array} Returns a new, updated object or array
*/
export function deleteIn (object, path) {
if (path.length === 0) {
return object
}
if (path.length === 1) {
const key = path[0]
const updated = clone(object)
if (Array.isArray(updated)) {
updated.splice(key, 1)
}
else {
delete updated[key]
}
return updated
}
const key = path[0]
const child = object[key]
if (Array.isArray(child) || isObject(child)) {
const updated = clone(object)
updated[key] = deleteIn(child, path.slice(1))
return updated
}
else {
// child property doesn't exist. just do nothing
return object
}
}