From 1127c15ea28cf2bf8239540e95f05b50c9521850 Mon Sep 17 00:00:00 2001 From: jos Date: Wed, 10 Jan 2018 22:11:23 +0100 Subject: [PATCH] All floating menus working now --- src/jsoneditor/actions.js | 68 ++++++++++ src/jsoneditor/components/JSONNode.js | 118 +++++++++++------- src/jsoneditor/components/TreeMode.js | 50 ++++++-- src/jsoneditor/components/jsoneditor.css | 6 +- src/jsoneditor/components/jsoneditor.scss | 12 +- .../components/menu/FloatingMenu.js | 50 ++++++-- src/jsoneditor/eson.js | 20 +-- 7 files changed, 241 insertions(+), 83 deletions(-) diff --git a/src/jsoneditor/actions.js b/src/jsoneditor/actions.js index c66d78c..812970b 100644 --- a/src/jsoneditor/actions.js +++ b/src/jsoneditor/actions.js @@ -173,6 +173,74 @@ export function insertBefore (eson, path, values) { // TODO: find a better name } } +/** + * Create a JSONPatch for an insert action. + * + * This function needs the current data in order to be able to determine + * a unique property name for the inserted node in case of duplicating + * and object property + * + * @param {ESON} eson + * @param {Path} path + * @param {Array.<{name?: string, value: JSON, type?: ESONType}>} values + * @return {Array} + */ +export function insertAfter (eson, path, values) { // TODO: find a better name and define datastructure for values + const parentPath = initial(path) + const parent = getIn(eson, parentPath) + + if (parent[META].type === 'Array') { + const startIndex = parseInt(last(path), 10) + return values.map((entry, offset) => ({ + op: 'add', + path: compileJSONPointer(parentPath.concat(startIndex + 1 + offset)), // +1 to insert after + value: entry.value, + meta: { + type: entry.type + } + })) + } + else { // parent[META].type === 'Object' + const prop = last(path) + const propIndex = parent[META].props.indexOf(prop) + const before = parent[META].props[propIndex + 1] + return values.map(entry => { + const newProp = findUniqueName(entry.name, parent[META].props) + return { + op: 'add', + path: compileJSONPointer(parentPath.concat(newProp)), + value: entry.value, + meta: { + type: entry.type, + before + } + } + }) + } +} + +/** + * Insert values at the start of an Object or Array + * @param {ESON} eson + * @param {Path} parentPath + * @param {Array.<{name?: string, value: JSON, type?: ESONType}>} values + * @return {Array} + */ +export function insertInside (eson, parentPath, values) { + const parent = getIn(eson, parentPath) + + if (parent[META].type === 'Array') { + return insertBefore(eson, parentPath.concat('0'), values) + } + else if (parent[META].type === 'Object') { + const firstProp = parent[META].props[0] || null + return insertBefore(eson, parentPath.concat(firstProp), values) + } + else { + throw new Error('Cannot insert in a value, only in an Object or Array') + } +} + /** * Create a JSONPatch for an insert action. * diff --git a/src/jsoneditor/components/JSONNode.js b/src/jsoneditor/components/JSONNode.js index 61a65c5..4422328 100644 --- a/src/jsoneditor/components/JSONNode.js +++ b/src/jsoneditor/components/JSONNode.js @@ -9,45 +9,10 @@ import { stringConvert, valueType, isUrl } from '../utils/typeUtils' import { compileJSONPointer, META, - SELECTED, SELECTED_START, SELECTED_END, SELECTED_AFTER, SELECTED_BEFORE, + SELECTED, SELECTED_START, SELECTED_END, SELECTED_AFTER, SELECTED_INSIDE, SELECTED_FIRST, SELECTED_LAST } from '../eson' -const MENU_ITEMS_OBJECT = [ - {type: 'sort'}, - {type: 'duplicate'}, - {type: 'cut'}, - {type: 'copy'}, - {type: 'paste'}, - {type: 'remove'} -] - -const MENU_ITEMS_ARRAY = [ - {type: 'sort'}, - {type: 'duplicate'}, - {type: 'cut'}, - {type: 'copy'}, - {type: 'paste'}, - {type: 'remove'} -] - -const MENU_ITEMS_VALUE = [ - // {text: 'String', onClick: this.props.emit('changeType', {type: 'checkbox', checked: false}}), - {type: 'duplicate'}, - {type: 'cut'}, - {type: 'copy'}, - {type: 'paste'}, - {type: 'remove'} -] - -const MENU_ITEMS_INSERT_BEFORE = [ - {type: 'insertStructure'}, - {type: 'insertValue'}, - {type: 'insertObject'}, - {type: 'insertArray'}, - {type: 'paste'}, -] - export default class JSONNode extends PureComponent { static URL_TITLE = 'Ctrl+Click or Ctrl+Enter to open url' @@ -139,7 +104,7 @@ export default class JSONNode extends PureComponent { } } - const floatingMenu = this.renderFloatingMenu(MENU_ITEMS_OBJECT, meta.selected) + const floatingMenu = this.renderFloatingMenu('Object', meta.selected) const nodeEnd = meta.expanded ? h('div', {key: 'node-end', className: 'jsoneditor-node-end', 'data-area': 'empty'}, [ this.renderDelimiter('}', 'jsoneditor-delimiter-end'), @@ -201,7 +166,7 @@ export default class JSONNode extends PureComponent { } } - const floatingMenu = this.renderFloatingMenu(MENU_ITEMS_ARRAY, meta.selected) + const floatingMenu = this.renderFloatingMenu('Array', meta.selected) const nodeEnd = meta.expanded ? h('div', {key: 'node-end', className: 'jsoneditor-node-end', 'data-area': 'empty'}, [ this.renderDelimiter(']', 'jsoneditor-delimiter-end'), @@ -233,7 +198,7 @@ export default class JSONNode extends PureComponent { this.renderError(meta.error) ]) - const floatingMenu = this.renderFloatingMenu(MENU_ITEMS_VALUE, meta.selected) + const floatingMenu = this.renderFloatingMenu('value', meta.selected) // const insertArea = this.renderInsertBeforeArea() @@ -251,7 +216,7 @@ export default class JSONNode extends PureComponent { key: 'insert', className: 'jsoneditor-insert jsoneditor-insert-before', title: 'Insert a new item or paste clipboard', - 'data-area': 'before' + 'data-area': 'inside' }) } @@ -276,7 +241,7 @@ export default class JSONNode extends PureComponent { className: 'jsoneditor-node', onKeyDown: this.handleKeyDownAppend }, [ - this.renderPlaceholder('before'), + this.renderPlaceholder('inside'), this.renderReadonly(text) ]) } @@ -289,7 +254,7 @@ export default class JSONNode extends PureComponent { }) } - renderReadonly (text, title = null, dataArea = 'before') { + renderReadonly (text, title = null, dataArea = 'inside') { return h('div', { key: 'readonly', 'data-area': dataArea, @@ -419,7 +384,7 @@ export default class JSONNode extends PureComponent { getContainerClassName (selected, hover) { let classNames = ['jsoneditor-node-container'] - if ((selected & SELECTED_BEFORE) !== 0) { + if ((selected & SELECTED_INSIDE) !== 0) { classNames.push('jsoneditor-selected-insert-before') } else if ((selected & SELECTED_AFTER) !== 0) { @@ -433,7 +398,7 @@ export default class JSONNode extends PureComponent { if ((selected & SELECTED_LAST) !== 0) { classNames.push('jsoneditor-selected-last') } } - if ((hover & SELECTED_BEFORE) !== 0) { + if ((hover & SELECTED_INSIDE) !== 0) { classNames.push('jsoneditor-hover-insert-before') } else if ((hover & SELECTED_AFTER) !== 0) { @@ -560,8 +525,10 @@ export default class JSONNode extends PureComponent { ) } - renderFloatingMenu (items, selected) { - if ((selected & SELECTED_END) === 0) { + renderFloatingMenu (type, selected) { + if (((selected & SELECTED_END) === 0) && + ((selected & SELECTED_INSIDE) === 0) && + ((selected & SELECTED_AFTER) === 0)) { return null } @@ -572,11 +539,68 @@ export default class JSONNode extends PureComponent { key: 'floating-menu', path: this.props.value[META].path, emit: this.props.emit, - items, + items: this.getFloatingMenuItems(type, selected), position: isLastOfMultiple ? 'bottom' : 'top' }) } + getFloatingMenuItems (type, selected) { + if ((selected & SELECTED_AFTER) !== 0) { + return [ + {type: 'insertStructureAfter'}, + {type: 'insertValueAfter'}, + {type: 'insertObjectAfter'}, + {type: 'insertArrayAfter'}, + {type: 'paste'}, + ] + } + + if ((selected & SELECTED_INSIDE) !== 0) { + return [ + {type: 'insertStructureInside'}, + {type: 'insertValueInside'}, + {type: 'insertObjectInside'}, + {type: 'insertArrayInside'}, + {type: 'paste'}, + ] + } + + if (type === 'Object') { + return [ + {type: 'sort'}, + {type: 'duplicate'}, + {type: 'cut'}, + {type: 'copy'}, + {type: 'paste'}, + {type: 'remove'} + ] + } + + if (type === 'Array') { + return [ + {type: 'sort'}, + {type: 'duplicate'}, + {type: 'cut'}, + {type: 'copy'}, + {type: 'paste'}, + {type: 'remove'} + ] + } + + if (type === 'value') { + return [ + // {text: 'String', onClick: this.props.emit('changeType', {type: 'checkbox', checked: false}}), + {type: 'duplicate'}, + {type: 'cut'}, + {type: 'copy'}, + {type: 'paste'}, + {type: 'remove'} + ] + } + + throw new Error(`Cannot create FloatingMenu items for type: ${type}, selected: ${selected})`) + } + handleMouseOver = (event) => { if (event.buttons === 0) { // no mouse button down, no dragging event.stopPropagation() diff --git a/src/jsoneditor/components/TreeMode.js b/src/jsoneditor/components/TreeMode.js index 86e8215..615c7f6 100644 --- a/src/jsoneditor/components/TreeMode.js +++ b/src/jsoneditor/components/TreeMode.js @@ -21,7 +21,7 @@ import { } from '../eson' import { patchEson } from '../patchEson' import { - duplicate, insertBefore, append, remove, removeAll, replace, + duplicate, insertBefore, insertAfter, insertInside, append, remove, removeAll, replace, createEntry, changeType, changeValue, changeProperty, sort } from '../actions' import JSONNode from './JSONNode' @@ -86,7 +86,8 @@ export default class TreeMode extends PureComponent { this.emitter.on('changeProperty', this.handleChangeProperty) this.emitter.on('changeValue', this.handleChangeValue) this.emitter.on('changeType', this.handleChangeType) - this.emitter.on('insert', this.handleInsert) + this.emitter.on('insertAfter', this.handleInsertAfter) + this.emitter.on('insertInside', this.handleInsertInside) this.emitter.on('insertStructure', this.handleInsertStructure) this.emitter.on('append', this.handleAppend) this.emitter.on('duplicate', this.handleDuplicate) @@ -98,7 +99,6 @@ export default class TreeMode extends PureComponent { this.emitter.on('expand', this.handleExpand) this.emitter.on('select', this.handleSelect) - this.state = { json, eson, @@ -355,17 +355,30 @@ export default class TreeMode extends PureComponent { this.handlePatch(changeType(this.state.eson, path, type)) } - handleInsert = ({path, type}) => { - this.handlePatch(insertBefore(this.state.eson, path, [{ + handleInsertInside = ({path, type}) => { + this.handlePatch(insertInside(this.state.eson, path, [{ type, name: '', - value: createEntry(type) + value: createEntry(type) // TODO: give objects and arrays an expanded state }])) this.setState({ selection : null }) // TODO: select the inserted entry // apply focus to new node - this.focusToPrevious(path) + this.focusToNext(path) + } + + handleInsertAfter = ({path, type}) => { + this.handlePatch(insertAfter(this.state.eson, path, [{ + type, + name: '', + value: createEntry(type) // TODO: give objects and arrays an expanded state + }])) + + this.setState({ selection : null }) // TODO: select the inserted entry + + // apply focus to new node + this.focusToNext(path) } handleInsertStructure = ({path}) => { @@ -519,7 +532,20 @@ export default class TreeMode extends PureComponent { if (selection && clipboard && clipboard.length > 0) { this.setState({ selection: null }) - this.handlePatch(replace(eson, selection, clipboard)) + + if (selection.start && selection.end) { + this.handlePatch(replace(eson, selection, clipboard)) + } + else if (selection.after) { + this.handlePatch(insertAfter(eson, selection.after, clipboard)) + } + else if (selection.inside) { + this.handlePatch(insertInside(eson, selection.inside, clipboard)) + } + else { + throw new Error(`Cannot paste at current selection ${JSON.stringify(selection)}`) + } + // TODO: select the pasted contents } } @@ -713,14 +739,14 @@ export default class TreeMode extends PureComponent { // TODO: cleanup // console.log('handlePan', { - // start: selection.start || selection.before || selection.after || selection.empty || selection.emptyBefore, + // start: selection.start || selection.inside || selection.after || selection.empty || selection.emptyBefore, // end: path // }) // FIXME: when selection.empty, start should be set to the next node this.setState({ selection: { - start: selection.start || selection.before || selection.after || selection.empty || selection.emptyBefore, + start: selection.start || selection.inside || selection.after || selection.empty || selection.emptyBefore, end: path } }) @@ -775,8 +801,8 @@ export default class TreeMode extends PureComponent { if (pointer.area === 'after') { return {after: pointer.path} } - else if (pointer.area === 'before') { - return {before: pointer.path} + else if (pointer.area === 'inside') { + return {inside: pointer.path} } else if (pointer.area === 'empty') { return {empty: pointer.path} diff --git a/src/jsoneditor/components/jsoneditor.css b/src/jsoneditor/components/jsoneditor.css index 866cef0..c7839dc 100644 --- a/src/jsoneditor/components/jsoneditor.css +++ b/src/jsoneditor/components/jsoneditor.css @@ -174,7 +174,8 @@ .jsoneditor-node-end .jsoneditor-button-container:hover { visibility: visible; } -.jsoneditor-root > .jsoneditor-node-container > .jsoneditor-node-end > .jsoneditor-button-container { +.jsoneditor-root > .jsoneditor-node-container > .jsoneditor-node-end > .jsoneditor-button-container, +.jsoneditor-root > .jsoneditor-node-container > .jsoneditor-node-end > div.jsoneditor-insert { display: none; } .jsoneditor-node > div { @@ -416,6 +417,9 @@ div.jsoneditor-node-container { div.jsoneditor-node-container.jsoneditor-selected-insert-before > .jsoneditor-node-end > .jsoneditor-insert-before { background-image: url("img/jsoneditor-icons.svg"); background-position: -2px -2px !important; } + div.jsoneditor-node-container.jsoneditor-selected-insert-before > .jsoneditor-list { + border-top: 1px dashed #c0c0c0; + margin-top: -1px; } div.jsoneditor-node-container.jsoneditor-hover > .jsoneditor-node > .jsoneditor-button-container, div.jsoneditor-node-container.jsoneditor-hover > .jsoneditor-node > .jsoneditor-button-placeholder { background-color: #d3d3d3; } diff --git a/src/jsoneditor/components/jsoneditor.scss b/src/jsoneditor/components/jsoneditor.scss index 5aeadd9..3bedeed 100644 --- a/src/jsoneditor/components/jsoneditor.scss +++ b/src/jsoneditor/components/jsoneditor.scss @@ -136,8 +136,11 @@ $insert-area-height: 6px; } } -.jsoneditor-root > .jsoneditor-node-container > .jsoneditor-node-end > .jsoneditor-button-container { - display: none; +.jsoneditor-root > .jsoneditor-node-container > .jsoneditor-node-end { + > .jsoneditor-button-container, + > div.jsoneditor-insert { + display: none; + } } .jsoneditor-node > div { @@ -702,6 +705,11 @@ div.jsoneditor-node-container { background-image: url('img/jsoneditor-icons.svg'); background-position: -2px -2px !important; } + + > .jsoneditor-list { + border-top: 1px dashed $light-gray; + margin-top: -1px; + } } &.jsoneditor-hover { diff --git a/src/jsoneditor/components/menu/FloatingMenu.js b/src/jsoneditor/components/menu/FloatingMenu.js index 3091b5b..e4d6be7 100644 --- a/src/jsoneditor/components/menu/FloatingMenu.js +++ b/src/jsoneditor/components/menu/FloatingMenu.js @@ -84,31 +84,59 @@ const CREATE_TYPE = { title: 'Remove' }, 'Remove'), - insertStructure: (path, emit) => h('button', { + insertStructureAfter: (path, emit) => h('button', { key: 'insertStructure', className: MENU_ITEM_CLASS_NAME, - onClick: () => emit('insertStructure', {path}), + onClick: () => emit('insertStructureAfter', {path}), title: 'Insert a new object with the same data structure as the item above' }, 'Insert structure'), - insertValue: (path, emit) => h('button', { - key: 'insertValue', + insertValueAfter: (path, emit) => h('button', { + key: 'insertValueAfter', className: MENU_ITEM_CLASS_NAME, - onClick: () => emit('insert', {path, type: 'value'}), + onClick: () => emit('insertAfter', {path, type: 'value'}), title: 'Insert value' }, 'Insert value'), - insertObject: (path, emit) => h('button', { - key: 'insertObject', + insertObjectAfter: (path, emit) => h('button', { + key: 'insertObjectAfter', className: MENU_ITEM_CLASS_NAME, - onClick: () => emit('insert', {path, type: 'Object'}), + onClick: () => emit('insertAfter', {path, type: 'Object'}), title: 'Insert Object' }, 'Insert Object'), - insertArray: (path, emit) => h('button', { - key: 'insertArray', + insertArrayAfter: (path, emit) => h('button', { + key: 'insertArrayAfter', className: MENU_ITEM_CLASS_NAME, - onClick: () => emit('insert', {path, type: 'Array'}), + onClick: () => emit('insertAfter', {path, type: 'Array'}), + title: 'Insert Array' + }, 'Insert Array'), + + insertStructureInside: (path, emit) => h('button', { + key: 'insertStructureInside', + className: MENU_ITEM_CLASS_NAME, + onClick: () => emit('insertStructureInside', {path}), + title: 'Insert a new object with the same data structure as the item above' + }, 'Insert structure'), + + insertValueInside: (path, emit) => h('button', { + key: 'insertValueInside', + className: MENU_ITEM_CLASS_NAME, + onClick: () => emit('insertInside', {path, type: 'value'}), + title: 'Insert value' + }, 'Insert value'), + + insertObjectInside: (path, emit) => h('button', { + key: 'insertObjectInside', + className: MENU_ITEM_CLASS_NAME, + onClick: () => emit('insertInside', {path, type: 'Object'}), + title: 'Insert Object' + }, 'Insert Object'), + + insertArrayInside: (path, emit) => h('button', { + key: 'insertArrayInside', + className: MENU_ITEM_CLASS_NAME, + onClick: () => emit('insertInside', {path, type: 'Array'}), title: 'Insert Array' }, 'Insert Array'), diff --git a/src/jsoneditor/eson.js b/src/jsoneditor/eson.js index bc484c6..b3d3ad4 100644 --- a/src/jsoneditor/eson.js +++ b/src/jsoneditor/eson.js @@ -17,7 +17,7 @@ export const SELECTED_START = 2 export const SELECTED_END = 4 export const SELECTED_FIRST = 8 export const SELECTED_LAST = 16 -export const SELECTED_BEFORE = 32 +export const SELECTED_INSIDE = 32 export const SELECTED_AFTER = 64 export const SELECTED_EMPTY = 128 export const SELECTED_EMPTY_BEFORE = 256 @@ -361,10 +361,10 @@ export function applySelection (eson, selection) { if (!selection) { return cleanupMetaData(eson, 'selected') } - else if (selection.before) { - const updatedEson = setIn(eson, selection.before.concat([META, 'selected']), - SELECTED_BEFORE) - return cleanupMetaData(updatedEson, 'selected', [selection.before]) + else if (selection.inside) { + const updatedEson = setIn(eson, selection.inside.concat([META, 'selected']), + SELECTED_INSIDE) + return cleanupMetaData(updatedEson, 'selected', [selection.inside]) } else if (selection.after) { const updatedEson = setIn(eson, selection.after.concat([META, 'selected']), @@ -455,8 +455,8 @@ export function applySelection (eson, selection) { * @return {{minIndex: number, maxIndex: number}} */ export function findSelectionIndices (root, rootPath, selection) { - const start = (selection.after || selection.before || selection.start)[rootPath.length] - const end = (selection.after || selection.before || selection.end)[rootPath.length] + const start = (selection.after || selection.inside || selection.start)[rootPath.length] + const end = (selection.after || selection.inside || selection.end)[rootPath.length] // if no object we assume it's an Array const startIndex = root[META].type === 'Object' ? root[META].props.indexOf(start) : parseInt(start, 10) @@ -464,7 +464,7 @@ export function findSelectionIndices (root, rootPath, selection) { const minIndex = Math.min(startIndex, endIndex) const maxIndex = Math.max(startIndex, endIndex) + - ((selection.after || selection.before) ? 0 : 1) // include max index itself + ((selection.after || selection.inside) ? 0 : 1) // include max index itself return { minIndex, maxIndex } } @@ -562,8 +562,8 @@ export function applyEsonState(data, state) { * @return {Path} */ export function findRootPath(selection) { - if (selection.before) { - return initial(selection.before) + if (selection.inside) { + return initial(selection.inside) } else if (selection.after) { return initial(selection.after)