diff --git a/src/components/JSONNode.js b/src/components/JSONNode.js index b8adabc..a44d6ef 100644 --- a/src/components/JSONNode.js +++ b/src/components/JSONNode.js @@ -8,7 +8,7 @@ import FloatingMenu from './menu/FloatingMenu' import { escapeHTML, unescapeHTML } from '../utils/stringUtils' import { getInnerText, insideRect, findParentWithAttribute } from '../utils/domUtils' import { stringConvert, valueType, isUrl } from '../utils/typeUtils' -import { compileJSONPointer } from '../eson' +import { compileJSONPointer, SELECTED, SELECTED_END } from '../eson' import type { ESONObjectProperty, ESON, SearchResultStatus, Path } from '../types' @@ -18,6 +18,7 @@ export default class JSONNode extends PureComponent { state = { menu: null, // can contain object {anchor, root} appendMenu: null, // can contain object {anchor, root} + hover: false } render () { @@ -45,16 +46,9 @@ export default class JSONNode extends PureComponent { this.renderExpandButton(), // this.renderActionMenu('update', this.state.menu, this.handleCloseActionMenu), // this.renderActionMenuButton(), - this.renderFloatingMenu([ - {type: 'sort'}, - {type: 'duplicate'}, - {type: 'cut'}, - {type: 'copy'}, - {type: 'paste'}, - {type: 'remove'} - ]), this.renderProperty(prop, index, data, options), this.renderReadonly(`{${childCount}}`, `Array containing ${childCount} items`), + // this.renderFloatingMenuButton(), this.renderError(data.error) ]) @@ -62,7 +56,7 @@ export default class JSONNode extends PureComponent { if (data.expanded) { if (data.props.length > 0) { const props = data.props.map(prop => { - return h('li', { key: prop.id, className: (prop.value.selected ? ' jsoneditor-selected' : '') }, + return h('li', { key: prop.id, className: JSONNode.selectedClassName(prop.value.selected) }, h(this.constructor, { path: this.props.path.concat(prop.name), prop, @@ -84,7 +78,28 @@ export default class JSONNode extends PureComponent { } } - return h('div', {}, [node, childs]) + const floatingMenu = this.renderFloatingMenu([ + {type: 'sort'}, + {type: 'duplicate'}, + {type: 'cut'}, + {type: 'copy'}, + {type: 'paste'}, + {type: 'remove'} + ]) + + return h('div', { + className: 'jsoneditor-node-container ' + (this.state.hover ? ' jsoneditor-node-hover': ''), + onMouseOver: this.handleMouseOver, + onMouseLeave: this.handleMouseLeave + }, [node, floatingMenu, childs]) + } + + static selectedClassName(selected: number) { + return (selected === SELECTED) + ? ' jsoneditor-selected' + : (selected === SELECTED_END) + ? 'jsoneditor-selected jsoneditor-selected-end' + : '' } // TODO: extract a function renderChilds shared by both renderJSONObject and renderJSONArray (rename .props and .items to .childs?) @@ -99,16 +114,9 @@ export default class JSONNode extends PureComponent { this.renderExpandButton(), // this.renderActionMenu('update', this.state.menu, this.handleCloseActionMenu), // this.renderActionMenuButton(), - this.renderFloatingMenu([ - {type: 'sort'}, - {type: 'duplicate'}, - {type: 'cut'}, - {type: 'copy'}, - {type: 'paste'}, - {type: 'remove'} - ]), this.renderProperty(prop, index, data, options), this.renderReadonly(`[${childCount}]`, `Array containing ${childCount} items`), + // this.renderFloatingMenuButton(), this.renderError(data.error) ]) @@ -116,7 +124,7 @@ export default class JSONNode extends PureComponent { if (data.expanded) { if (data.items.length > 0) { const items = data.items.map((item, index) => { - return h('li', { key : item.id, className: (item.value.selected ? ' jsoneditor-selected' : '')}, + return h('li', { key : item.id, className: JSONNode.selectedClassName(prop.value.selected)}, h(this.constructor, { path: this.props.path.concat(String(index)), index, @@ -137,11 +145,25 @@ export default class JSONNode extends PureComponent { } } - return h('div', {}, [node, childs]) + const floatingMenu = this.renderFloatingMenu([ + {type: 'sort'}, + {type: 'duplicate'}, + {type: 'cut'}, + {type: 'copy'}, + {type: 'paste'}, + {type: 'remove'} + ]) + + return h('div', { + className: 'jsoneditor-node-container ' + (this.state.hover ? ' jsoneditor-node-hover': ''), + onMouseOver: this.handleMouseOver, + onMouseLeave: this.handleMouseLeave + }, [node, floatingMenu, childs]) } renderJSONValue ({prop, index, data, options}) { - return h('div', { + const node = h('div', { + key: 'value', 'data-path': compileJSONPointer(this.props.path), onKeyDown: this.handleKeyDown, className: 'jsoneditor-node' @@ -149,19 +171,27 @@ export default class JSONNode extends PureComponent { this.renderPlaceholder(), // this.renderActionMenu('update', this.state.menu, this.handleCloseActionMenu), // this.renderActionMenuButton(), - this.renderFloatingMenu([ - // {text: 'String', onClick: this.props.events.onChangeType, type: 'checkbox', checked: false}, - {type: 'duplicate'}, - {type: 'cut'}, - {type: 'copy'}, - {type: 'paste'}, - {type: 'remove'} - ]), this.renderProperty(prop, index, data, options), this.renderSeparator(), this.renderValue(data.value, data.searchResult, options), + // this.renderFloatingMenuButton(), this.renderError(data.error) ]) + + const floatingMenu = this.renderFloatingMenu([ + // {text: 'String', onClick: this.props.events.onChangeType, type: 'checkbox', checked: false}, + {type: 'duplicate'}, + {type: 'cut'}, + {type: 'copy'}, + {type: 'paste'}, + {type: 'remove'} + ]) + + return h('div', { + className: 'jsoneditor-node-container ' + (this.state.hover ? ' jsoneditor-node-hover': ''), + onMouseOver: this.handleMouseOver, + onMouseLeave: this.handleMouseLeave + }, [node, floatingMenu]) } /** @@ -427,6 +457,20 @@ export default class JSONNode extends PureComponent { ]) } + // TODO: cleanup + renderFloatingMenuButton () { + const className = 'jsoneditor-button jsoneditor-floatingmenu' + + ((this.state.open) ? ' jsoneditor-visible' : '') + + return h('div', {className: 'jsoneditor-button-container', key: 'action'}, [ + h('button', { + key: 'button', + className, + onClick: this.handleOpenActionMenu + }) + ]) + } + renderFloatingMenu (items) { return h(FloatingMenu, { key: 'menu', @@ -450,6 +494,26 @@ export default class JSONNode extends PureComponent { ]) } + handleMouseOver = (event) => { + event.stopPropagation() + + if (hoveredNode !== this) { + + if (hoveredNode) { + // FIXME: this may give issues when the hovered node doesn't exist anymore. check whether mounted + hoveredNode.setState({hover: false}) + } + + this.setState({hover: true}) + hoveredNode = this + } + } + + handleMouseLeave = (event) => { + event.stopPropagation() + this.setState({hover: false}) + } + handleOpenActionMenu = (event) => { // TODO: don't use refs, find root purely via DOM? const root = findParentWithAttribute(this.refs.actionMenuButton, 'data-jsoneditor', 'true') @@ -612,3 +676,6 @@ export default class JSONNode extends PureComponent { : stringConvert(stringValue) } } + +// singleton holding the node that's currently being hovered +let hoveredNode = null diff --git a/src/components/TreeMode.js b/src/components/TreeMode.js index 147a630..857dcfb 100644 --- a/src/components/TreeMode.js +++ b/src/components/TreeMode.js @@ -662,8 +662,9 @@ export default class TreeMode extends Component { } handleTap = (event) => { + const path = this.findDataPathFromElement(event.target.firstChild) if (this.state.selection) { - this.setState({ selection: null }) + this.setState({ selection: {start: {path}, end: {path}}}) } } diff --git a/src/components/menu/FloatingMenu.js b/src/components/menu/FloatingMenu.js index f6c731a..e271e05 100644 --- a/src/components/menu/FloatingMenu.js +++ b/src/components/menu/FloatingMenu.js @@ -3,7 +3,6 @@ import { createElement as h, PureComponent } from 'react' import { keyComboFromEvent } from '../../utils/keyBindings' -const MENU_CONTAINER_CLASS_NAME = 'jsoneditor-floating-menu-container' const MENU_CLASS_NAME = 'jsoneditor-floating-menu' const MENU_ITEM_CLASS_NAME = 'jsoneditor-floating-menu-item' @@ -89,17 +88,15 @@ const CREATE_TYPE = { export default class FloatingMenu extends PureComponent { render () { - return h('div', {className: MENU_CONTAINER_CLASS_NAME}, - h('div', {className: MENU_CLASS_NAME}, this.props.items.map(item => { - const type = typeof item === 'string' ? item : item.type - const createType = CREATE_TYPE[type] - if (createType) { - return createType(this.props.path, this.props.events) - } - else { - throw new Error('Unknown type of menu item for floating menu: ' + JSON.stringify(item)) - } - }) - )) + return h('div', {className: MENU_CLASS_NAME}, this.props.items.map(item => { + const type = typeof item === 'string' ? item : item.type + const createType = CREATE_TYPE[type] + if (createType) { + return createType(this.props.path, this.props.events) + } + else { + throw new Error('Unknown type of menu item for floating menu: ' + JSON.stringify(item)) + } + })) } } diff --git a/src/eson.js b/src/eson.js index ee463ef..8e51cde 100644 --- a/src/eson.js +++ b/src/eson.js @@ -20,6 +20,9 @@ import type { type RecurseCallback = (value: ESON, path: Path, root: ESON) => ESON +export const SELECTED = 1 +export const SELECTED_END = 2 + /** * Expand function which will expand all nodes * @param {Path} path @@ -363,7 +366,8 @@ export function applySelection (eson: ESON, selection: ESONSelection) { if (rootPath.length === selection.start.path.length || rootPath.length === selection.end.path.length) { // select a single node - return setIn(eson, rootEsonPath.concat(['selected']), true) + return setIn(eson, rootEsonPath.concat(['selected']), SELECTED_END) + // FIXME: actually mark the end index as SELECTED_END, currently we select the first index } else { // select multiple childs of an object or array @@ -375,8 +379,9 @@ export function applySelection (eson: ESON, selection: ESONSelection) { const childsKey = (root.type === 'Object') ? 'props' : 'items' // property name of the array with props/items const childsBefore = root[childsKey].slice(0, minIndex) const childsUpdated = root[childsKey].slice(minIndex, maxIndex) - .map(child => setIn(child, ['value', 'selected'], true)) + .map((child, index) => setIn(child, ['value', 'selected'], index === 0 ? SELECTED_END : SELECTED)) const childsAfter = root[childsKey].slice(maxIndex) + // FIXME: actually mark the end index as SELECTED_END, currently we select the first index return setIn(root, [childsKey], childsBefore.concat(childsUpdated, childsAfter)) }) diff --git a/src/jsoneditor.less b/src/jsoneditor.less index 582916c..a4e4c3c 100644 --- a/src/jsoneditor.less +++ b/src/jsoneditor.less @@ -7,7 +7,9 @@ @theme-color: #3883fa; @floating-menu-background: #4d4d4d; @floating-menu-color: #fff; -@selectedColor: #e5e5e5; +// @selectedColor: #e5e5e5; +@selectedColor: #ffed99; +@hoverColor: rgba(10, 10, 10, 0.05); .jsoneditor { border: 1px solid @theme-color; @@ -561,70 +563,68 @@ button.jsoneditor-type-Array.jsoneditor-selected span.jsoneditor-icon { /******************************* Floatting Menu **********************************/ -div.jsoneditor-node { +div.jsoneditor-node-container { + position: relative; + transition: background-color 100ms ease-in; - div.jsoneditor-floating-menu-container { + div.jsoneditor-floating-menu { display: none; position: absolute; bottom: 100%; - left: 50%; + right: 0; z-index: 999; - div.jsoneditor-floating-menu { - margin: 10px; - white-space: nowrap; - border-radius: 5px; - box-shadow: 0 2px 6px 0 rgba(0,0,0,.24); + margin: 10px; + white-space: nowrap; + border-radius: 5px; + box-shadow: 0 2px 6px 0 rgba(0,0,0,.24); - &:after { - content:''; - position: absolute; - top: 100%; - left: 10%; - margin-left: -10px; - margin-top: -10px; - width: 0; - height: 0; - border-top: solid 10px @floating-menu-background; - border-left: solid 10px transparent; - border-right: solid 10px transparent; + &:after { + content:''; + position: absolute; + top: 100%; + left: 35px; + margin-left: -10px; + width: 0; + height: 0; + border-top: solid 10px @floating-menu-background; + border-left: solid 10px transparent; + border-right: solid 10px transparent; + } + + button.jsoneditor-floating-menu-item { + color: @floating-menu-color; + background: @floating-menu-background; + border: none; + border-right: 1px solid lighten(@floating-menu-background, 10%); + padding: 10px; + cursor: pointer; + + &:hover { + background: lighten(@floating-menu-background, 10%); } - button.jsoneditor-floating-menu-item { - color: @floating-menu-color; - background: @floating-menu-background; - border: none; - border-right: 1px solid lighten(@floating-menu-background, 10%); - padding: 10px; - cursor: pointer; + &:first-child { + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; + } - &:hover { - background: lighten(@floating-menu-background, 10%); - } - - &:first-child { - border-top-left-radius: 5px; - border-bottom-left-radius: 5px; - } - - &:last-child { - border-top-right-radius: 5px; - border-bottom-right-radius: 5px; - border-right: none; - } + &:last-child { + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; + border-right: none; } } } - &:hover { - background-color: @selectedColor; - - div.jsoneditor-floating-menu-container { - display: inherit; - } + &.jsoneditor-node-hover { + background-color: @hoverColor; } } +.jsoneditor-selected-end > .jsoneditor-node-container > div.jsoneditor-floating-menu { + display: inherit; +} /******************************* **********************************/ div.jsoneditor-modes { diff --git a/test/eson.test.js b/test/eson.test.js index 6e4e31b..31a20be 100644 --- a/test/eson.test.js +++ b/test/eson.test.js @@ -5,7 +5,8 @@ import { jsonToEson, esonToJson, toEsonPath, toJsonPath, pathExists, transform, traverse, parseJSONPointer, compileJSONPointer, expand, addErrors, search, applySearchResults, nextSearchResult, previousSearchResult, - applySelection, pathsFromSelection + applySelection, pathsFromSelection, + SELECTED, SELECTED_END } from '../src/eson' const JSON1 = loadJSON('./resources/json1.json') @@ -281,9 +282,9 @@ test('selection (object)', t => { const actual = applySelection(ESON1, selection) let expected = ESON1 - expected = setIn(expected, toEsonPath(ESON1, ['obj']).concat(['selected']), true) - expected = setIn(expected, toEsonPath(ESON1, ['str']).concat(['selected']), true) - expected = setIn(expected, toEsonPath(ESON1, ['nill']).concat(['selected']), true) + expected = setIn(expected, toEsonPath(ESON1, ['obj']).concat(['selected']), SELECTED_END) + expected = setIn(expected, toEsonPath(ESON1, ['str']).concat(['selected']), SELECTED) + expected = setIn(expected, toEsonPath(ESON1, ['nill']).concat(['selected']), SELECTED) t.deepEqual(actual, expected) }) @@ -296,9 +297,10 @@ test('selection (array)', t => { const actual = applySelection(ESON1, selection) + // FIXME: SELECTE_END should be selection.start, not the first let expected = ESON1 - expected = setIn(expected, toEsonPath(ESON1, ['obj', 'arr', '0']).concat(['selected']), true) - expected = setIn(expected, toEsonPath(ESON1, ['obj', 'arr', '1']).concat(['selected']), true) + expected = setIn(expected, toEsonPath(ESON1, ['obj', 'arr', '0']).concat(['selected']), SELECTED_END) + expected = setIn(expected, toEsonPath(ESON1, ['obj', 'arr', '1']).concat(['selected']), SELECTED) t.deepEqual(actual, expected) }) @@ -310,7 +312,7 @@ test('selection (value)', t => { } const actual = applySelection(ESON1, selection) - const expected = setIn(ESON1, toEsonPath(ESON1, ['obj', 'arr', '2', 'first']).concat(['selected']), true) + const expected = setIn(ESON1, toEsonPath(ESON1, ['obj', 'arr', '2', 'first']).concat(['selected']), SELECTED_END) t.deepEqual(actual, expected) }) @@ -321,7 +323,7 @@ test('selection (node)', t => { } const actual = applySelection(ESON1, selection) - const expected = setIn(ESON1, toEsonPath(ESON1, ['obj', 'arr']).concat(['selected']), true) + const expected = setIn(ESON1, toEsonPath(ESON1, ['obj', 'arr']).concat(['selected']), SELECTED_END) t.deepEqual(actual, expected) })