diff --git a/src/jsoneditor/components/JSONNode.js b/src/jsoneditor/components/JSONNode.js index 63868e9..025aed0 100644 --- a/src/jsoneditor/components/JSONNode.js +++ b/src/jsoneditor/components/JSONNode.js @@ -6,22 +6,46 @@ import FloatingMenu from './menu/FloatingMenu' import { escapeHTML, unescapeHTML } from '../utils/stringUtils' import { getInnerText, insideRect } from '../utils/domUtils' import { stringConvert, valueType, isUrl } from '../utils/typeUtils' -import { compileJSONPointer, META, SELECTED, SELECTED_END, SELECTED_AFTER, SELECTED_BEFORE } from '../eson' +import { + compileJSONPointer, + META, + SELECTED, SELECTED_START, SELECTED_END, SELECTED_AFTER, SELECTED_BEFORE, SELECTED_FIRST, SELECTED_LAST +} from '../eson' -// TODO: rename SELECTED, SELECTED_END, etc to AREA_*? It's used for both selection and hovering -const SELECTED_CLASS_NAMES = { - [SELECTED]: ' jsoneditor-selected', - [SELECTED_END]: ' jsoneditor-selected jsoneditor-selected-end', - [SELECTED_AFTER]: ' jsoneditor-selected jsoneditor-selected-insert-area', - [SELECTED_BEFORE]: ' jsoneditor-selected jsoneditor-selected-insert-area', -} +const MENU_ITEMS_OBJECT = [ + {type: 'sort'}, + {type: 'duplicate'}, + {type: 'cut'}, + {type: 'copy'}, + {type: 'paste'}, + {type: 'remove'} +] -const HOVERED_CLASS_NAMES = { - [SELECTED]: ' jsoneditor-hover', - [SELECTED_END]: ' jsoneditor-hover jsoneditor-hover-end', - [SELECTED_AFTER]: ' jsoneditor-hover jsoneditor-hover-insert-area', - [SELECTED_BEFORE]: ' jsoneditor-hover jsoneditor-hover-insert-area', -} +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' @@ -46,7 +70,7 @@ export default class JSONNode extends PureComponent { state = { menu: null, // can contain object {anchor, root} appendMenu: null, // can contain object {anchor, root} - hover: false + hover: null } componentWillUnmount () { @@ -104,17 +128,7 @@ export default class JSONNode extends PureComponent { } } - const floatingMenu = (meta.selected === SELECTED_END) - ? this.renderFloatingMenu([ - {type: 'sort'}, - {type: 'duplicate'}, - {type: 'cut'}, - {type: 'copy'}, - {type: 'paste'}, - {type: 'remove'} - ]) - : null - + const floatingMenu = this.renderFloatingMenu(MENU_ITEMS_OBJECT, meta.selected) const insertArea = this.renderInsertBeforeArea() return h('div', { @@ -159,17 +173,7 @@ export default class JSONNode extends PureComponent { } } - const floatingMenu = (meta.selected === SELECTED_END) - ? this.renderFloatingMenu([ - {type: 'sort'}, - {type: 'duplicate'}, - {type: 'cut'}, - {type: 'copy'}, - {type: 'paste'}, - {type: 'remove'} - ]) - : null - + const floatingMenu = this.renderFloatingMenu(MENU_ITEMS_ARRAY, meta.selected) const insertArea = this.renderInsertBeforeArea() return h('div', { @@ -194,16 +198,7 @@ export default class JSONNode extends PureComponent { this.renderError(meta.error) ]) - const floatingMenu = (meta.selected === SELECTED_END) - ? this.renderFloatingMenu([ - // {text: 'String', onClick: this.props.emit('changeType', {type: 'checkbox', checked: false}}), - {type: 'duplicate'}, - {type: 'cut'}, - {type: 'copy'}, - {type: 'paste'}, - {type: 'remove'} - ]) - : null + const floatingMenu = this.renderFloatingMenu(MENU_ITEMS_VALUE, meta.selected) const insertArea = this.renderInsertBeforeArea() @@ -216,14 +211,9 @@ export default class JSONNode extends PureComponent { } renderInsertBeforeArea () { - const floatingMenu = (this.props.value[META].selected === SELECTED_BEFORE) - ? this.renderFloatingMenu([ - {type: 'insertStructure'}, - {type: 'insertValue'}, - {type: 'insertObject'}, - {type: 'insertArray'}, - {type: 'paste'}, - ]) + const floatingMenu = ((this.props.value[META].selected & SELECTED_BEFORE) !== 0) + ? this.renderFloatingMenu(MENU_ITEMS_INSERT_BEFORE, + SELECTED + SELECTED_END + SELECTED_FIRST) : null return h('div', { @@ -356,9 +346,23 @@ export default class JSONNode extends PureComponent { } getContainerClassName (selected, hover) { - return 'jsoneditor-node-container' + - (hover ? (HOVERED_CLASS_NAMES[hover]) : '') + - (selected ? (SELECTED_CLASS_NAMES[selected]) : '') + let classNames = ['jsoneditor-node-container'] + + if ((selected & SELECTED) !== 0) { classNames.push('jsoneditor-selected') } + if ((selected & SELECTED_START) !== 0) { classNames.push('jsoneditor-selected-start') } + if ((selected & SELECTED_END) !== 0) { classNames.push('jsoneditor-selected-end') } + if ((selected & SELECTED_FIRST) !== 0) { classNames.push('jsoneditor-selected-first') } + if ((selected & SELECTED_LAST) !== 0) { classNames.push('jsoneditor-selected-last') } + if ((selected & SELECTED_BEFORE) !== 0) { classNames.push('jsoneditor-selected-insert-area-before') } + if ((selected & SELECTED_AFTER) !== 0) { classNames.push('jsoneditor-selected-insert-area-after') } + + if ((hover & SELECTED) !== 0) { classNames.push('jsoneditor-hover') } + if ((hover & SELECTED_START) !== 0) { classNames.push('jsoneditor-hover-start') } + if ((hover & SELECTED_END) !== 0) { classNames.push('jsoneditor-hover-end') } + if ((hover & SELECTED_BEFORE) !== 0) { classNames.push('jsoneditor-hover-insert-area-before') } + if ((hover & SELECTED_AFTER) !== 0) { classNames.push('jsoneditor-hover-insert-area-after') } + + return classNames.join(' ') } /** @@ -473,12 +477,20 @@ export default class JSONNode extends PureComponent { ) } - renderFloatingMenu (items) { + renderFloatingMenu (items, selected) { + if ((selected & SELECTED_END) === 0) { + return null + } + + const isLastOfMultiple = ((selected & SELECTED_LAST) !== 0) && + ((selected & SELECTED_FIRST) === 0) + return h(FloatingMenu, { key: 'floating-menu', path: this.props.value[META].path, emit: this.props.emit, - items + items, + position: isLastOfMultiple ? 'bottom' : 'top' }) } @@ -487,7 +499,7 @@ export default class JSONNode extends PureComponent { event.stopPropagation() const hover = (event.target.className.indexOf('jsoneditor-insert-area') !== -1) - ? SELECTED_AFTER + ? (SELECTED + SELECTED_AFTER) : SELECTED if (hoveredNode && hoveredNode !== this) { @@ -505,7 +517,7 @@ export default class JSONNode extends PureComponent { handleMouseLeave = (event) => { event.stopPropagation() // FIXME: this gives issues when the hovered node doesn't exist anymore. check whether mounted? - hoveredNode.setState({hover: false}) + hoveredNode.setState({hover: null}) this.setState({hover: null}) } diff --git a/src/jsoneditor/components/jsoneditor.css b/src/jsoneditor/components/jsoneditor.css index b627dec..f0cf041 100644 --- a/src/jsoneditor/components/jsoneditor.css +++ b/src/jsoneditor/components/jsoneditor.css @@ -529,35 +529,35 @@ div.jsoneditor-node-container { background-color: #ffed99; } div.jsoneditor-node-container.jsoneditor-selected.jsoneditor-hover { background-color: #ffdb80; } - div.jsoneditor-node-container.jsoneditor-selected.jsoneditor-hover.jsoneditor-hover-insert-area { + div.jsoneditor-node-container.jsoneditor-selected.jsoneditor-hover.jsoneditor-hover-insert-area-after { background-color: #ffed99; } - div.jsoneditor-node-container.jsoneditor-selected.jsoneditor-hover.jsoneditor-hover-insert-area > div.jsoneditor-insert-area { + div.jsoneditor-node-container.jsoneditor-selected.jsoneditor-hover.jsoneditor-hover-insert-area-after > div.jsoneditor-insert-area { border: 1px dashed gray; background-color: #f2f2f2; } - div.jsoneditor-node-container.jsoneditor-selected.jsoneditor-hover.jsoneditor-hover-insert-area.jsoneditor-selected-insert-area > div.jsoneditor-insert-area { + div.jsoneditor-node-container.jsoneditor-selected.jsoneditor-hover.jsoneditor-hover-insert-area-after.jsoneditor-selected-insert-area-before > div.jsoneditor-insert-area { border: 1px dashed #f4af41; background-color: #ffdb80; } - div.jsoneditor-node-container.jsoneditor-selected.jsoneditor-selected-insert-area { + div.jsoneditor-node-container.jsoneditor-selected.jsoneditor-selected-insert-area-before { background-color: inherit; } - div.jsoneditor-node-container.jsoneditor-selected.jsoneditor-selected-insert-area.jsoneditor-hover { + div.jsoneditor-node-container.jsoneditor-selected.jsoneditor-selected-insert-area-before.jsoneditor-hover { background-color: #f2f2f2; } - div.jsoneditor-node-container.jsoneditor-selected.jsoneditor-selected-insert-area.jsoneditor-hover.jsoneditor-hover-insert-area { + div.jsoneditor-node-container.jsoneditor-selected.jsoneditor-selected-insert-area-before.jsoneditor-hover.jsoneditor-hover-insert-area-after { background-color: inherit; } - div.jsoneditor-node-container.jsoneditor-selected.jsoneditor-selected-insert-area > div.jsoneditor-insert-area { + div.jsoneditor-node-container.jsoneditor-selected.jsoneditor-selected-insert-area-before > div.jsoneditor-insert-area { border: 1px dashed #f4af41; background: #ffed99; } div.jsoneditor-node-container.jsoneditor-selected div.jsoneditor-hover { background-color: #ffdb80; } - div.jsoneditor-node-container.jsoneditor-selected div.jsoneditor-hover.jsoneditor-hover-insert-area { + div.jsoneditor-node-container.jsoneditor-selected div.jsoneditor-hover.jsoneditor-hover-insert-area-after { background-color: inherit; } - div.jsoneditor-node-container.jsoneditor-selected div.jsoneditor-hover.jsoneditor-hover-insert-area > div.jsoneditor-insert-area { + div.jsoneditor-node-container.jsoneditor-selected div.jsoneditor-hover.jsoneditor-hover-insert-area-after > div.jsoneditor-insert-area { border: 1px dashed #f4af41; background: #ffdb80; } div.jsoneditor-node-container.jsoneditor-hover { background-color: #f2f2f2; } - div.jsoneditor-node-container.jsoneditor-hover.jsoneditor-hover-insert-area { + div.jsoneditor-node-container.jsoneditor-hover.jsoneditor-hover-insert-area-after { background-color: inherit; } - div.jsoneditor-node-container.jsoneditor-hover.jsoneditor-hover-insert-area > div.jsoneditor-insert-area { + div.jsoneditor-node-container.jsoneditor-hover.jsoneditor-hover-insert-area-after > div.jsoneditor-insert-area { border: 1px dashed gray; background-color: #f2f2f2; } div.jsoneditor-node-container div.jsoneditor-insert-area { @@ -587,8 +587,17 @@ div.jsoneditor-node-container { width: 0; height: 0; border-top: solid 10px #4d4d4d; + border-bottom: none; border-left: solid 10px transparent; border-right: solid 10px transparent; } + div.jsoneditor-node-container div.jsoneditor-floating-menu.jsoneditor-floating-menu-bottom { + bottom: auto; + top: 100%; } + div.jsoneditor-node-container div.jsoneditor-floating-menu.jsoneditor-floating-menu-bottom:after { + top: -10px; + margin-left: -10px; + border-top: none; + border-bottom: solid 10px #4d4d4d; } div.jsoneditor-node-container div.jsoneditor-floating-menu button.jsoneditor-floating-menu-item { color: #fff; background: #4d4d4d; diff --git a/src/jsoneditor/components/jsoneditor.scss b/src/jsoneditor/components/jsoneditor.scss index e96969b..dc61096 100644 --- a/src/jsoneditor/components/jsoneditor.scss +++ b/src/jsoneditor/components/jsoneditor.scss @@ -575,7 +575,7 @@ div.jsoneditor-node-container { &.jsoneditor-hover { background-color: $hoverAndSelectedColor; - &.jsoneditor-hover-insert-area { + &.jsoneditor-hover-insert-area-after { background-color: $selectedColor; > div.jsoneditor-insert-area { @@ -583,7 +583,7 @@ div.jsoneditor-node-container { background-color: $hoverColor; } - &.jsoneditor-selected-insert-area { + &.jsoneditor-selected-insert-area-before { > div.jsoneditor-insert-area { border: 1px dashed #f4af41; background-color: $hoverAndSelectedColor; @@ -592,13 +592,13 @@ div.jsoneditor-node-container { } } - &.jsoneditor-selected-insert-area { + &.jsoneditor-selected-insert-area-before { background-color: inherit; &.jsoneditor-hover { background-color: $hoverColor; - &.jsoneditor-hover-insert-area { + &.jsoneditor-hover-insert-area-after { background-color: inherit; } } @@ -613,7 +613,7 @@ div.jsoneditor-node-container { div.jsoneditor-hover { background-color: $hoverAndSelectedColor; - &.jsoneditor-hover-insert-area { + &.jsoneditor-hover-insert-area-after { background-color: inherit; > div.jsoneditor-insert-area { @@ -627,7 +627,7 @@ div.jsoneditor-node-container { &.jsoneditor-hover { background-color: $hoverColor; - &.jsoneditor-hover-insert-area { + &.jsoneditor-hover-insert-area-after { background-color: inherit; > div.jsoneditor-insert-area { @@ -662,16 +662,29 @@ div.jsoneditor-node-container { box-shadow: 0 2px 6px 0 rgba(0,0,0,.24); &:after { - content:''; - position: absolute; - top: 100%; - left: 35px; + content: ''; + position: absolute; + top: 100%; + left: 35px; + margin-left: -10px; + width: 0; + height: 0; + border-top: solid 10px $floating-menu-background; + border-bottom: none; + border-left: solid 10px transparent; + border-right: solid 10px transparent; + } + + &.jsoneditor-floating-menu-bottom { + bottom: auto; + top: 100%; + + &:after { + top: -10px; margin-left: -10px; - width: 0; - height: 0; - border-top: solid 10px $floating-menu-background; - border-left: solid 10px transparent; - border-right: solid 10px transparent; + border-top: none; + border-bottom: solid 10px $floating-menu-background; + } } button.jsoneditor-floating-menu-item { diff --git a/src/jsoneditor/components/menu/FloatingMenu.js b/src/jsoneditor/components/menu/FloatingMenu.js index e8d47bf..3091b5b 100644 --- a/src/jsoneditor/components/menu/FloatingMenu.js +++ b/src/jsoneditor/components/menu/FloatingMenu.js @@ -2,6 +2,7 @@ import { createElement as h, PureComponent } from 'react' import PropTypes from 'prop-types' const MENU_CLASS_NAME = 'jsoneditor-floating-menu' +const MENU_CLASS_NAME_BOTTOM = 'jsoneditor-floating-menu-bottom' const MENU_ITEM_CLASS_NAME = 'jsoneditor-floating-menu-item' // Array: Sort | Map | Filter | Duplicate | Cut | Copy | Paste | Remove @@ -134,7 +135,10 @@ export default class FloatingMenu extends PureComponent { type: PropTypes.string.isRequired }) ]).isRequired - ).isRequired + ).isRequired, + path: PropTypes.arrayOf(PropTypes.string).isRequired, + emit: PropTypes.func.isRequired, + position: PropTypes.string // 'top' or 'bottom' } render () { @@ -150,7 +154,8 @@ export default class FloatingMenu extends PureComponent { }) return h('div', { - className: MENU_CLASS_NAME, + className: MENU_CLASS_NAME + + (this.props.position === 'bottom' ? (' ' + MENU_CLASS_NAME_BOTTOM) : ''), onMouseDown: this.handleTouchStart, onTouchStart: this.handleTouchStart, }, items) diff --git a/src/jsoneditor/eson.js b/src/jsoneditor/eson.js index db3c34d..f272882 100644 --- a/src/jsoneditor/eson.js +++ b/src/jsoneditor/eson.js @@ -13,9 +13,12 @@ import initial from 'lodash/initial' import last from 'lodash/last' export const SELECTED = 1 -export const SELECTED_END = 2 -export const SELECTED_BEFORE = 3 -export const SELECTED_AFTER = 4 +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_AFTER = 64 export const META = Symbol('meta') @@ -349,7 +352,7 @@ function setSearchStatus (eson, esonPointer, searchStatus) { /** * Merge selection status into the eson object, cleanup previous selection * @param {ESON} eson - * @param {Selection} [selection] + * @param {Selection | null} selection * @return {ESON} Returns updated eson object */ export function applySelection (eson, selection) { @@ -357,11 +360,13 @@ export function applySelection (eson, selection) { return cleanupMetaData(eson, 'selected') } else if (selection.before) { - const updatedEson = setIn(eson, selection.before.concat([META, 'selected']), SELECTED_BEFORE) + const updatedEson = setIn(eson, selection.before.concat([META, 'selected']), + SELECTED + SELECTED_BEFORE) return cleanupMetaData(updatedEson, 'selected', [selection.before]) } else if (selection.after) { - const updatedEson = setIn(eson, selection.after.concat([META, 'selected']), SELECTED_AFTER) + const updatedEson = setIn(eson, selection.after.concat([META, 'selected']), + SELECTED + SELECTED_AFTER) return cleanupMetaData(updatedEson, 'selected', [selection.after]) } else { // selection.start and selection.end @@ -379,15 +384,21 @@ export function applySelection (eson, selection) { const startIndex = root[META].props.indexOf(start) const endIndex = root[META].props.indexOf(end) - const minIndex = Math.min(startIndex, endIndex) - const maxIndex = Math.max(startIndex, endIndex) + 1 // include max index itself + const firstIndex = Math.min(startIndex, endIndex) + const lastIndex = Math.max(startIndex, endIndex) + 1 // include max index itself + const firstProp = root[META].props[firstIndex] + const lastProp = root[META].props[lastIndex - 1] - const selectedProps = root[META].props.slice(minIndex, maxIndex) + const selectedProps = root[META].props.slice(firstIndex, lastIndex) selectedPaths = selectedProps.map(prop => rootPath.concat(prop)) let updatedObj = cloneWithSymbols(root) selectedProps.forEach(prop => { - updatedObj[prop] = setIn(updatedObj[prop], [META, 'selected'], - prop === end ? SELECTED_END : SELECTED) + const selected = SELECTED + + (prop === start ? SELECTED_START : 0) + + (prop === end ? SELECTED_END : 0) + + (prop === firstProp ? SELECTED_FIRST : 0) + + (prop === lastProp ? SELECTED_LAST : 0) + updatedObj[prop] = setIn(updatedObj[prop], [META, 'selected'], selected) }) return updatedObj @@ -396,17 +407,21 @@ export function applySelection (eson, selection) { const startIndex = parseInt(start, 10) const endIndex = parseInt(end, 10) - const minIndex = Math.min(startIndex, endIndex) - const maxIndex = Math.max(startIndex, endIndex) + 1 // include max index itself + const firstIndex = Math.min(startIndex, endIndex) + const lastIndex = Math.max(startIndex, endIndex) + 1 // include max index itself - const selectedIndices = range(minIndex, maxIndex) + const selectedIndices = range(firstIndex, lastIndex) selectedPaths = selectedIndices.map(index => rootPath.concat(String(index))) let updatedArr = root.slice() updatedArr = cloneWithSymbols(root) selectedIndices.forEach(index => { - updatedArr[index] = setIn(updatedArr[index], [META, 'selected'], - index === endIndex ? SELECTED_END : SELECTED) + const selected = SELECTED + + (index === start ? SELECTED_START : 0) + + (index === end ? SELECTED_END : 0) + + (index === firstIndex ? SELECTED_FIRST : 0) + + (index === lastIndex ? SELECTED_LAST : 0) + updatedArr[index] = setIn(updatedArr[index], [META, 'selected'], selected) }) return updatedArr