Show floating menu on selection

This commit is contained in:
jos 2017-10-14 21:57:59 +02:00
parent cea4e2c101
commit a2f7f61389
6 changed files with 174 additions and 102 deletions

View File

@ -8,7 +8,7 @@ import FloatingMenu from './menu/FloatingMenu'
import { escapeHTML, unescapeHTML } from '../utils/stringUtils' import { escapeHTML, unescapeHTML } from '../utils/stringUtils'
import { getInnerText, insideRect, findParentWithAttribute } from '../utils/domUtils' import { getInnerText, insideRect, findParentWithAttribute } from '../utils/domUtils'
import { stringConvert, valueType, isUrl } from '../utils/typeUtils' 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' import type { ESONObjectProperty, ESON, SearchResultStatus, Path } from '../types'
@ -18,6 +18,7 @@ export default class JSONNode extends PureComponent {
state = { state = {
menu: null, // can contain object {anchor, root} menu: null, // can contain object {anchor, root}
appendMenu: null, // can contain object {anchor, root} appendMenu: null, // can contain object {anchor, root}
hover: false
} }
render () { render () {
@ -45,16 +46,9 @@ export default class JSONNode extends PureComponent {
this.renderExpandButton(), this.renderExpandButton(),
// this.renderActionMenu('update', this.state.menu, this.handleCloseActionMenu), // this.renderActionMenu('update', this.state.menu, this.handleCloseActionMenu),
// this.renderActionMenuButton(), // this.renderActionMenuButton(),
this.renderFloatingMenu([
{type: 'sort'},
{type: 'duplicate'},
{type: 'cut'},
{type: 'copy'},
{type: 'paste'},
{type: 'remove'}
]),
this.renderProperty(prop, index, data, options), this.renderProperty(prop, index, data, options),
this.renderReadonly(`{${childCount}}`, `Array containing ${childCount} items`), this.renderReadonly(`{${childCount}}`, `Array containing ${childCount} items`),
// this.renderFloatingMenuButton(),
this.renderError(data.error) this.renderError(data.error)
]) ])
@ -62,7 +56,7 @@ export default class JSONNode extends PureComponent {
if (data.expanded) { if (data.expanded) {
if (data.props.length > 0) { if (data.props.length > 0) {
const props = data.props.map(prop => { 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, { h(this.constructor, {
path: this.props.path.concat(prop.name), path: this.props.path.concat(prop.name),
prop, 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?) // 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.renderExpandButton(),
// this.renderActionMenu('update', this.state.menu, this.handleCloseActionMenu), // this.renderActionMenu('update', this.state.menu, this.handleCloseActionMenu),
// this.renderActionMenuButton(), // this.renderActionMenuButton(),
this.renderFloatingMenu([
{type: 'sort'},
{type: 'duplicate'},
{type: 'cut'},
{type: 'copy'},
{type: 'paste'},
{type: 'remove'}
]),
this.renderProperty(prop, index, data, options), this.renderProperty(prop, index, data, options),
this.renderReadonly(`[${childCount}]`, `Array containing ${childCount} items`), this.renderReadonly(`[${childCount}]`, `Array containing ${childCount} items`),
// this.renderFloatingMenuButton(),
this.renderError(data.error) this.renderError(data.error)
]) ])
@ -116,7 +124,7 @@ export default class JSONNode extends PureComponent {
if (data.expanded) { if (data.expanded) {
if (data.items.length > 0) { if (data.items.length > 0) {
const items = data.items.map((item, index) => { 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, { h(this.constructor, {
path: this.props.path.concat(String(index)), path: this.props.path.concat(String(index)),
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}) { renderJSONValue ({prop, index, data, options}) {
return h('div', { const node = h('div', {
key: 'value',
'data-path': compileJSONPointer(this.props.path), 'data-path': compileJSONPointer(this.props.path),
onKeyDown: this.handleKeyDown, onKeyDown: this.handleKeyDown,
className: 'jsoneditor-node' className: 'jsoneditor-node'
@ -149,19 +171,27 @@ export default class JSONNode extends PureComponent {
this.renderPlaceholder(), this.renderPlaceholder(),
// this.renderActionMenu('update', this.state.menu, this.handleCloseActionMenu), // this.renderActionMenu('update', this.state.menu, this.handleCloseActionMenu),
// this.renderActionMenuButton(), // 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.renderProperty(prop, index, data, options),
this.renderSeparator(), this.renderSeparator(),
this.renderValue(data.value, data.searchResult, options), this.renderValue(data.value, data.searchResult, options),
// this.renderFloatingMenuButton(),
this.renderError(data.error) 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) { renderFloatingMenu (items) {
return h(FloatingMenu, { return h(FloatingMenu, {
key: 'menu', 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) => { handleOpenActionMenu = (event) => {
// TODO: don't use refs, find root purely via DOM? // TODO: don't use refs, find root purely via DOM?
const root = findParentWithAttribute(this.refs.actionMenuButton, 'data-jsoneditor', 'true') const root = findParentWithAttribute(this.refs.actionMenuButton, 'data-jsoneditor', 'true')
@ -612,3 +676,6 @@ export default class JSONNode extends PureComponent {
: stringConvert(stringValue) : stringConvert(stringValue)
} }
} }
// singleton holding the node that's currently being hovered
let hoveredNode = null

View File

@ -662,8 +662,9 @@ export default class TreeMode extends Component {
} }
handleTap = (event) => { handleTap = (event) => {
const path = this.findDataPathFromElement(event.target.firstChild)
if (this.state.selection) { if (this.state.selection) {
this.setState({ selection: null }) this.setState({ selection: {start: {path}, end: {path}}})
} }
} }

View File

@ -3,7 +3,6 @@
import { createElement as h, PureComponent } from 'react' import { createElement as h, PureComponent } from 'react'
import { keyComboFromEvent } from '../../utils/keyBindings' import { keyComboFromEvent } from '../../utils/keyBindings'
const MENU_CONTAINER_CLASS_NAME = 'jsoneditor-floating-menu-container'
const MENU_CLASS_NAME = 'jsoneditor-floating-menu' const MENU_CLASS_NAME = 'jsoneditor-floating-menu'
const MENU_ITEM_CLASS_NAME = 'jsoneditor-floating-menu-item' const MENU_ITEM_CLASS_NAME = 'jsoneditor-floating-menu-item'
@ -89,17 +88,15 @@ const CREATE_TYPE = {
export default class FloatingMenu extends PureComponent { export default class FloatingMenu extends PureComponent {
render () { render () {
return h('div', {className: MENU_CONTAINER_CLASS_NAME}, return h('div', {className: MENU_CLASS_NAME}, this.props.items.map(item => {
h('div', {className: MENU_CLASS_NAME}, this.props.items.map(item => { const type = typeof item === 'string' ? item : item.type
const type = typeof item === 'string' ? item : item.type const createType = CREATE_TYPE[type]
const createType = CREATE_TYPE[type] if (createType) {
if (createType) { return createType(this.props.path, this.props.events)
return createType(this.props.path, this.props.events) }
} else {
else { throw new Error('Unknown type of menu item for floating menu: ' + JSON.stringify(item))
throw new Error('Unknown type of menu item for floating menu: ' + JSON.stringify(item)) }
} }))
})
))
} }
} }

View File

@ -20,6 +20,9 @@ import type {
type RecurseCallback = (value: ESON, path: Path, root: ESON) => ESON 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 * Expand function which will expand all nodes
* @param {Path} path * @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) { if (rootPath.length === selection.start.path.length || rootPath.length === selection.end.path.length) {
// select a single node // 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 { else {
// select multiple childs of an object or array // 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 childsKey = (root.type === 'Object') ? 'props' : 'items' // property name of the array with props/items
const childsBefore = root[childsKey].slice(0, minIndex) const childsBefore = root[childsKey].slice(0, minIndex)
const childsUpdated = root[childsKey].slice(minIndex, maxIndex) 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) 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)) return setIn(root, [childsKey], childsBefore.concat(childsUpdated, childsAfter))
}) })

View File

@ -7,7 +7,9 @@
@theme-color: #3883fa; @theme-color: #3883fa;
@floating-menu-background: #4d4d4d; @floating-menu-background: #4d4d4d;
@floating-menu-color: #fff; @floating-menu-color: #fff;
@selectedColor: #e5e5e5; // @selectedColor: #e5e5e5;
@selectedColor: #ffed99;
@hoverColor: rgba(10, 10, 10, 0.05);
.jsoneditor { .jsoneditor {
border: 1px solid @theme-color; border: 1px solid @theme-color;
@ -561,70 +563,68 @@ button.jsoneditor-type-Array.jsoneditor-selected span.jsoneditor-icon {
/******************************* Floatting Menu **********************************/ /******************************* 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; display: none;
position: absolute; position: absolute;
bottom: 100%; bottom: 100%;
left: 50%; right: 0;
z-index: 999; z-index: 999;
div.jsoneditor-floating-menu { margin: 10px;
margin: 10px; white-space: nowrap;
white-space: nowrap; border-radius: 5px;
border-radius: 5px; box-shadow: 0 2px 6px 0 rgba(0,0,0,.24);
box-shadow: 0 2px 6px 0 rgba(0,0,0,.24);
&:after { &:after {
content:''; content:'';
position: absolute; position: absolute;
top: 100%; top: 100%;
left: 10%; left: 35px;
margin-left: -10px; margin-left: -10px;
margin-top: -10px; width: 0;
width: 0; height: 0;
height: 0; border-top: solid 10px @floating-menu-background;
border-top: solid 10px @floating-menu-background; border-left: solid 10px transparent;
border-left: solid 10px transparent; border-right: 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 { &:first-child {
color: @floating-menu-color; border-top-left-radius: 5px;
background: @floating-menu-background; border-bottom-left-radius: 5px;
border: none; }
border-right: 1px solid lighten(@floating-menu-background, 10%);
padding: 10px;
cursor: pointer;
&:hover { &:last-child {
background: lighten(@floating-menu-background, 10%); border-top-right-radius: 5px;
} border-bottom-right-radius: 5px;
border-right: none;
&: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;
}
} }
} }
} }
&:hover { &.jsoneditor-node-hover {
background-color: @selectedColor; background-color: @hoverColor;
div.jsoneditor-floating-menu-container {
display: inherit;
}
} }
} }
.jsoneditor-selected-end > .jsoneditor-node-container > div.jsoneditor-floating-menu {
display: inherit;
}
/******************************* **********************************/ /******************************* **********************************/
div.jsoneditor-modes { div.jsoneditor-modes {

View File

@ -5,7 +5,8 @@ import {
jsonToEson, esonToJson, toEsonPath, toJsonPath, pathExists, transform, traverse, jsonToEson, esonToJson, toEsonPath, toJsonPath, pathExists, transform, traverse,
parseJSONPointer, compileJSONPointer, parseJSONPointer, compileJSONPointer,
expand, addErrors, search, applySearchResults, nextSearchResult, previousSearchResult, expand, addErrors, search, applySearchResults, nextSearchResult, previousSearchResult,
applySelection, pathsFromSelection applySelection, pathsFromSelection,
SELECTED, SELECTED_END
} from '../src/eson' } from '../src/eson'
const JSON1 = loadJSON('./resources/json1.json') const JSON1 = loadJSON('./resources/json1.json')
@ -281,9 +282,9 @@ test('selection (object)', t => {
const actual = applySelection(ESON1, selection) const actual = applySelection(ESON1, selection)
let expected = ESON1 let expected = ESON1
expected = setIn(expected, toEsonPath(ESON1, ['obj']).concat(['selected']), true) expected = setIn(expected, toEsonPath(ESON1, ['obj']).concat(['selected']), SELECTED_END)
expected = setIn(expected, toEsonPath(ESON1, ['str']).concat(['selected']), true) expected = setIn(expected, toEsonPath(ESON1, ['str']).concat(['selected']), SELECTED)
expected = setIn(expected, toEsonPath(ESON1, ['nill']).concat(['selected']), true) expected = setIn(expected, toEsonPath(ESON1, ['nill']).concat(['selected']), SELECTED)
t.deepEqual(actual, expected) t.deepEqual(actual, expected)
}) })
@ -296,9 +297,10 @@ test('selection (array)', t => {
const actual = applySelection(ESON1, selection) const actual = applySelection(ESON1, selection)
// FIXME: SELECTE_END should be selection.start, not the first
let expected = ESON1 let expected = ESON1
expected = setIn(expected, toEsonPath(ESON1, ['obj', 'arr', '0']).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']), true) expected = setIn(expected, toEsonPath(ESON1, ['obj', 'arr', '1']).concat(['selected']), SELECTED)
t.deepEqual(actual, expected) t.deepEqual(actual, expected)
}) })
@ -310,7 +312,7 @@ test('selection (value)', t => {
} }
const actual = applySelection(ESON1, selection) 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) t.deepEqual(actual, expected)
}) })
@ -321,7 +323,7 @@ test('selection (node)', t => {
} }
const actual = applySelection(ESON1, selection) 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) t.deepEqual(actual, expected)
}) })