Floating menu starting to work (WIP)

This commit is contained in:
jos 2017-11-03 14:23:04 +01:00
parent a2f7f61389
commit 8425579718
8 changed files with 316 additions and 148 deletions

View File

@ -8,10 +8,25 @@ 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, SELECTED, SELECTED_END } from '../eson'
import { compileJSONPointer, SELECTED, SELECTED_END, SELECTED_AFTER, SELECTED_BEFORE } from '../eson'
import type { ESONObjectProperty, ESON, SearchResultStatus, Path } from '../types'
// 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-after',
[SELECTED_BEFORE]: ' jsoneditor-selected jsoneditor-selected-before',
}
const HOVERED_CLASS_NAMES = {
[SELECTED]: ' jsoneditor-hover',
[SELECTED_END]: ' jsoneditor-hover jsoneditor-hover-end',
[SELECTED_AFTER]: ' jsoneditor-hover jsoneditor-hover-after',
[SELECTED_BEFORE]: ' jsoneditor-hover jsoneditor-hover-before',
}
export default class JSONNode extends PureComponent {
static URL_TITLE = 'Ctrl+Click or Ctrl+Enter to open url'
@ -21,6 +36,12 @@ export default class JSONNode extends PureComponent {
hover: false
}
componentWillUnmount () {
if (hoveredNode === this) {
hoveredNode = null
}
}
render () {
const { props } = this
@ -38,9 +59,8 @@ export default class JSONNode extends PureComponent {
renderJSONObject ({prop, index, data, options, events}) {
const childCount = data.props.length
const node = h('div', {
'data-path': compileJSONPointer(this.props.path),
onKeyDown: this.handleKeyDown,
key: 'node',
onKeyDown: this.handleKeyDown,
className: 'jsoneditor-node jsoneditor-object'
}, [
this.renderExpandButton(),
@ -55,30 +75,26 @@ export default class JSONNode extends PureComponent {
let childs
if (data.expanded) {
if (data.props.length > 0) {
const props = data.props.map(prop => {
return h('li', { key: prop.id, className: JSONNode.selectedClassName(prop.value.selected) },
h(this.constructor, {
const props = data.props.map(prop => h(this.constructor, {
key: prop.id,
path: this.props.path.concat(prop.name),
prop,
data: prop.value,
options,
events
})
)
})
}))
childs = h('ul', {key: 'childs', className: 'jsoneditor-list'}, props)
childs = h('div', {key: 'childs', className: 'jsoneditor-list'}, props)
}
else {
childs = h('ul', {key: 'childs', className: 'jsoneditor-list'},
h('li', {},
childs = h('div', {key: 'childs', className: 'jsoneditor-list'},
this.renderAppend('(empty object)')
)
)
}
}
const floatingMenu = this.renderFloatingMenu([
const floatingMenu = (data.selected === SELECTED_END)
? this.renderFloatingMenu([
{type: 'sort'},
{type: 'duplicate'},
{type: 'cut'},
@ -86,29 +102,22 @@ export default class JSONNode extends PureComponent {
{type: 'paste'},
{type: 'remove'}
])
: null
return h('div', {
className: 'jsoneditor-node-container ' + (this.state.hover ? ' jsoneditor-node-hover': ''),
'data-path': compileJSONPointer(this.props.path),
className: this.getContainerClassName(data.selected, this.state.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?)
renderJSONArray ({prop, index, data, options, events}) {
const childCount = data.items.length
const node = h('div', {
'data-path': compileJSONPointer(this.props.path),
onKeyDown: this.handleKeyDown,
key: 'node',
onKeyDown: this.handleKeyDown,
className: 'jsoneditor-node jsoneditor-array'
}, [
this.renderExpandButton(),
@ -123,29 +132,26 @@ export default class JSONNode extends PureComponent {
let childs
if (data.expanded) {
if (data.items.length > 0) {
const items = data.items.map((item, index) => {
return h('li', { key : item.id, className: JSONNode.selectedClassName(prop.value.selected)},
h(this.constructor, {
const items = data.items.map((item, index) => h(this.constructor, {
key : item.id,
path: this.props.path.concat(String(index)),
index,
data: item.value,
options,
events
})
)
})
childs = h('ul', {key: 'childs', className: 'jsoneditor-list'}, items)
}))
childs = h('div', {key: 'childs', className: 'jsoneditor-list'}, items)
}
else {
childs = h('ul', {key: 'childs', className: 'jsoneditor-list'},
h('li', {},
childs = h('div', {key: 'childs', className: 'jsoneditor-list'},
this.renderAppend('(empty array)')
)
)
}
}
const floatingMenu = this.renderFloatingMenu([
const floatingMenu = (data.selected === SELECTED_END)
? this.renderFloatingMenu([
{type: 'sort'},
{type: 'duplicate'},
{type: 'cut'},
@ -153,9 +159,11 @@ export default class JSONNode extends PureComponent {
{type: 'paste'},
{type: 'remove'}
])
: null
return h('div', {
className: 'jsoneditor-node-container ' + (this.state.hover ? ' jsoneditor-node-hover': ''),
'data-path': compileJSONPointer(this.props.path),
className: this.getContainerClassName(data.selected, this.state.hover),
onMouseOver: this.handleMouseOver,
onMouseLeave: this.handleMouseLeave
}, [node, floatingMenu, childs])
@ -163,8 +171,7 @@ export default class JSONNode extends PureComponent {
renderJSONValue ({prop, index, data, options}) {
const node = h('div', {
key: 'value',
'data-path': compileJSONPointer(this.props.path),
key: 'node',
onKeyDown: this.handleKeyDown,
className: 'jsoneditor-node'
}, [
@ -178,7 +185,8 @@ export default class JSONNode extends PureComponent {
this.renderError(data.error)
])
const floatingMenu = this.renderFloatingMenu([
const floatingMenu = (data.selected === SELECTED_END)
? this.renderFloatingMenu([
// {text: 'String', onClick: this.props.events.onChangeType, type: 'checkbox', checked: false},
{type: 'duplicate'},
{type: 'cut'},
@ -186,12 +194,34 @@ export default class JSONNode extends PureComponent {
{type: 'paste'},
{type: 'remove'}
])
: null
const insertArea = this.renderInsertArea()
return h('div', {
className: 'jsoneditor-node-container ' + (this.state.hover ? ' jsoneditor-node-hover': ''),
'data-path': compileJSONPointer(this.props.path),
className: this.getContainerClassName(data.selected, this.state.hover),
onMouseOver: this.handleMouseOver,
onMouseLeave: this.handleMouseLeave
}, [node, floatingMenu])
}, [node, floatingMenu, insertArea])
}
renderInsertArea () {
const floatingMenu = (this.props.data.selected === SELECTED_AFTER)
? this.renderFloatingMenu([
{type: 'insertStructure'},
{type: 'insertValue'},
{type: 'insertObject'},
{type: 'insertArray'},
{type: 'paste'},
])
: null
return h('div', {
key: 'menu',
className: 'jsoneditor-insert-area',
'data-area': 'after'
}, [floatingMenu])
}
/**
@ -311,6 +341,12 @@ export default class JSONNode extends PureComponent {
}
}
getContainerClassName (selected, hover) {
return 'jsoneditor-node-container' +
(hover ? (HOVERED_CLASS_NAMES[hover]) : '') +
(selected ? (SELECTED_CLASS_NAMES[selected]) : '')
}
/**
* Find the best position for the popover: right, above, below, or left
* from the warning icon.
@ -473,7 +509,7 @@ export default class JSONNode extends PureComponent {
renderFloatingMenu (items) {
return h(FloatingMenu, {
key: 'menu',
key: 'floating-menu',
path: this.props.path,
events: this.props.events,
items
@ -495,23 +531,31 @@ export default class JSONNode extends PureComponent {
}
handleMouseOver = (event) => {
if (event.buttons === 0) { // no mouse button down, no dragging
event.stopPropagation()
if (hoveredNode !== this) {
const hover = (event.target.className.indexOf('jsoneditor-insert-area') !== -1)
? SELECTED_AFTER
: SELECTED
if (hoveredNode) {
// FIXME: this may give issues when the hovered node doesn't exist anymore. check whether mounted
hoveredNode.setState({hover: false})
if (hoveredNode && hoveredNode !== this) {
// FIXME: this gives issues when the hovered node doesn't exist anymore. check whether mounted?
hoveredNode.setState({hover: null})
}
this.setState({hover: true})
if (hover !== this.state.hover) {
this.setState({hover})
hoveredNode = this
}
}
}
handleMouseLeave = (event) => {
event.stopPropagation()
this.setState({hover: false})
// FIXME: this gives issues when the hovered node doesn't exist anymore. check whether mounted?
hoveredNode.setState({hover: false})
this.setState({hover: null})
}
handleOpenActionMenu = (event) => {

View File

@ -37,7 +37,7 @@ import {
import { createFindKeyBinding } from '../utils/keyBindings'
import { KEY_BINDINGS } from '../constants'
import type { ESON, ESONPatch, JSONPath, ESONSelection } from '../types'
import type { ESON, ESONPatch, JSONPath, ESONSelection, ESONPointer } from '../types'
const AJV_OPTIONS = {
allErrors: true,
@ -101,6 +101,8 @@ export default class TreeMode extends Component {
onExpand: this.handleExpand,
onSelect: this.handleSelect,
// TODO: now we're passing not just events but also other methods. reorganize this or rename 'state.events'
findKeyBinding: this.handleFindKeyBinding
},
@ -205,12 +207,14 @@ export default class TreeMode extends Component {
h(Hammer, {
id: this.id,
direction: 'DIRECTION_VERTICAL',
onTap: this.handleTap,
onPanStart: this.handlePanStart,
onPan: this.handlePan,
onPanEnd: this.handlePanEnd
},
h('ul', {className: 'jsoneditor-list jsoneditor-root' + (data.selected ? ' jsoneditor-selected' : '')},
h('div', {
onMouseDown: this.handleTouchStart,
onTouchStart: this.handleTouchStart,
className: 'jsoneditor-list jsoneditor-root' +
(data.selected ? ' jsoneditor-selected' : '')},
h(Node, {
data,
events: state.events,
@ -364,6 +368,7 @@ export default class TreeMode extends Component {
moveUp(fromElement, 'property')
}
this.setState({ selection : null })
this.handlePatch(remove(path))
}
@ -535,6 +540,11 @@ export default class TreeMode extends Component {
this.handlePatch(sort(this.state.data, path, order))
}
handleSelect = (selection: ESONSelection) => {
console.log('handleSelect', selection)
this.setState({ selection })
}
handleExpand = (path, expanded, recurse) => {
if (recurse) {
const esonPath = toEsonPath(this.state.data, path)
@ -661,22 +671,13 @@ export default class TreeMode extends Component {
this.emitOnChange (actions, result.revert, result.data)
}
handleTap = (event) => {
const path = this.findDataPathFromElement(event.target.firstChild)
if (this.state.selection) {
this.setState({ selection: {start: {path}, end: {path}}})
handleTouchStart = (event) => {
const pointer = this.findESONPointerFromElement(event.target)
if (pointer) {
this.setState({ selection: {start: pointer, end: pointer}})
}
}
handlePanStart = (event) => {
const path = this.findDataPathFromElement(event.target.firstChild)
if (path) {
this.setState({
selection: {
start: {path},
end: {path}
}
})
else {
this.setState({ selection: null })
}
}
@ -713,6 +714,13 @@ export default class TreeMode extends Component {
return attr ? parseJSONPointer(attr.replace(/\/-$/, '')) : null
}
findESONPointerFromElement (element: Element) : ESONPointer {
const path = this.findDataPathFromElement(element)
const area = element && element.getAttribute && element.getAttribute('data-area') || null
return { path, area }
}
/**
* Scroll the window vertically to the node with given path
* @param {Path} path

View File

@ -84,11 +84,49 @@ const CREATE_TYPE = {
onClick: () => events.onRemove(path),
title: 'Remove'
}, 'Remove'),
insertStructure: (path, events) => h('button', {
key: 'insertStructure',
className: MENU_ITEM_CLASS_NAME,
// onClick: () => events.onRemove(path),
title: 'Insert a new object with the same data structure as the item above'
}, 'Insert structure'),
insertValue: (path, events) => h('button', {
key: 'insertValue',
className: MENU_ITEM_CLASS_NAME,
// onClick: () => events.onRemove(path),
title: 'Insert value'
}, 'Insert value'),
insertObject: (path, events) => h('button', {
key: 'insertObject',
className: MENU_ITEM_CLASS_NAME,
// onClick: () => events.onRemove(path),
title: 'Insert Object'
}, 'Insert Object'),
insertArray: (path, events) => h('button', {
key: 'insertArray',
className: MENU_ITEM_CLASS_NAME,
// onClick: () => events.onRemove(path),
title: 'Insert Array'
}, 'Insert Array'),
}
export default class FloatingMenu extends PureComponent {
componentDidMount () {
setTimeout(() => {
const firstButton = this.refs.root && this.refs.root.querySelector('button')
if (firstButton) {
firstButton.focus()
}
})
}
render () {
return h('div', {className: MENU_CLASS_NAME}, this.props.items.map(item => {
return h('div', {ref: 'root', className: MENU_CLASS_NAME}, this.props.items.map(item => {
const type = typeof item === 'string' ? item : item.type
const createType = CREATE_TYPE[type]
if (createType) {

View File

@ -10,7 +10,7 @@ let lastInputName = null
// TODO: create a constants file with the CSS names that are used in domSelector
const SEARCH_TEXT_CLASS_NAME = 'jsoneditor-search-text'
const SEARCH_COMPONENT_CLASS_NAME = 'jsoneditor-search'
const NODE_CONTAINER_CLASS_NAME = 'jsoneditor-node'
const NODE_CONTAINER_CLASS_NAME = 'jsoneditor-node-container'
const CONTENTS_CONTAINER_CLASS_NAME = 'jsoneditor-tree-contents'
const PROPERTY_CLASS_NAME = 'jsoneditor-property'
const VALUE_CLASS_NAME = 'jsoneditor-value'

View File

@ -22,6 +22,8 @@ type RecurseCallback = (value: ESON, path: Path, root: ESON) => ESON
export const SELECTED = 1
export const SELECTED_END = 2
export const SELECTED_BEFORE = 3
export const SELECTED_AFTER = 4
/**
* Expand function which will expand all nodes
@ -263,7 +265,7 @@ export function search (eson: ESON, text: string): ESONPointer[] {
const parentPath = initial(path)
const parent = getIn(eson, toEsonPath(eson, parentPath))
if (parent.type === 'Object') {
results.push({path, field: 'property'})
results.push({path, area: 'property'})
}
}
}
@ -271,7 +273,7 @@ export function search (eson: ESON, text: string): ESONPointer[] {
// check value
if (value.type === 'value') {
if (containsCaseInsensitive(value.value, text)) {
results.push({path, field: 'value'})
results.push({path, area: 'value'})
}
}
})
@ -335,13 +337,13 @@ export function applySearchResults (eson: ESON, searchResults: ESONPointer[], ac
let updatedEson = eson
searchResults.forEach(function (searchResult) {
if (searchResult.field === 'value') {
if (searchResult.area === 'value') {
const esonPath = toEsonPath(updatedEson, searchResult.path).concat('searchResult')
const value = isEqual(searchResult, activeSearchResult) ? 'active' : 'normal'
updatedEson = setIn(updatedEson, esonPath, value)
}
if (searchResult.field === 'property') {
if (searchResult.area === 'property') {
const esonPath = toEsonPath(updatedEson, searchResult.path)
const propertyPath = initial(esonPath).concat('searchResult')
const value = isEqual(searchResult, activeSearchResult) ? 'active' : 'normal'
@ -366,8 +368,10 @@ 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']), SELECTED_END)
const selectionType = (selection.start.area === 'after') ? SELECTED_AFTER : SELECTED_END
console.log('selectionType', selectionType, selection)
// FIXME: actually mark the end index as SELECTED_END, currently we select the first index
return setIn(eson, rootEsonPath.concat(['selected']), selectionType)
}
else {
// select multiple childs of an object or array

View File

@ -9,7 +9,8 @@
@floating-menu-color: #fff;
// @selectedColor: #e5e5e5;
@selectedColor: #ffed99;
@hoverColor: rgba(10, 10, 10, 0.05);
@hoverColor: #f2f2f2;
@hoverAndSelectedColor: #F2E191;
.jsoneditor {
border: 1px solid @theme-color;
@ -119,16 +120,17 @@
flex: 0 0 auto;
}
ul.jsoneditor-list {
div.jsoneditor-list {
list-style-type: none;
padding-left: 20px;
margin: 0;
font-size: 0;
}
/* no left padding for the root ul element */
.jsoneditor-contents > ul.jsoneditor-list {
/* no left padding for the root div element */
.jsoneditor-contents > div.jsoneditor-list {
padding-left: 2px;
padding-bottom: 24px;
}
.jsoneditor-property,
@ -261,10 +263,6 @@ div.jsoneditor-value.jsoneditor-empty::after {
content: 'value';
}
.jsoneditor-selected {
background-color: @selectedColor;
}
.jsoneditor-highlight {
background-color: yellow;
}
@ -567,8 +565,90 @@ div.jsoneditor-node-container {
position: relative;
transition: background-color 100ms ease-in;
// TODO: can the hover/select css be simplified?
&.jsoneditor-selected {
background-color: @selectedColor;
&.jsoneditor-hover {
background-color: @hoverAndSelectedColor;
&.jsoneditor-hover-after {
background-color: @selectedColor;
div.jsoneditor-insert-area {
border: 1px dashed gray;
background-color: @hoverColor;
}
&.jsoneditor-selected-after {
div.jsoneditor-insert-area {
border: 1px dashed #f4af41;
background-color: @hoverAndSelectedColor;
}
}
}
}
&.jsoneditor-selected-after {
background-color: inherit;
&.jsoneditor-hover {
background-color: @hoverColor;
&.jsoneditor-hover-after {
background-color: inherit;
}
}
div.jsoneditor-insert-area {
border: 1px dashed #f4af41;
background: @selectedColor;
}
}
// hovering nested elements
.jsoneditor-hover {
background-color: @hoverAndSelectedColor;
&.jsoneditor-hover-after {
background-color: inherit;
div.jsoneditor-insert-area {
border: 1px dashed #f4af41;
background: @hoverAndSelectedColor;
}
}
}
}
&.jsoneditor-hover {
background-color: @hoverColor;
&.jsoneditor-hover-after {
background-color: inherit;
div.jsoneditor-insert-area {
border: 1px dashed gray;
background-color: @hoverColor;
}
}
}
div.jsoneditor-insert-area {
@height: 8px;
position: absolute;
width: 100%;
height: @height;
left: 0;
bottom: -@height/2;
border: 1px transparent;
box-sizing: border-box;
z-index: 1; // must be on top of next node, it overlaps a bit
}
div.jsoneditor-floating-menu {
display: none;
position: absolute;
bottom: 100%;
right: 0;
@ -599,7 +679,9 @@ div.jsoneditor-node-container {
border-right: 1px solid lighten(@floating-menu-background, 10%);
padding: 10px;
cursor: pointer;
outline: none;
&:focus,
&:hover {
background: lighten(@floating-menu-background, 10%);
}
@ -616,14 +698,6 @@ div.jsoneditor-node-container {
}
}
}
&.jsoneditor-node-hover {
background-color: @hoverColor;
}
}
.jsoneditor-selected-end > .jsoneditor-node-container > div.jsoneditor-floating-menu {
display: inherit;
}
/******************************* **********************************/

View File

@ -33,7 +33,7 @@ export type JSONArrayType = JSONType[]
/********************** TYPES FOR THE ESON OBJECT MODEL *************************/
export type SearchResultStatus = 'normal' | 'active'
export type ESONPointerField = 'value' | 'property'
export type ESONPointerArea = 'value' | 'property' | 'before' | 'after'
export type ESONObjectProperty = {
id: number,
@ -78,7 +78,7 @@ export type ESONPath = string[]
export type ESONPointer = {
path: JSONPath, // TODO: change path to an ESONPath?
field?: ESONPointerField
area?: ESONPointerArea
}
export type ESONSelection = {

View File

@ -194,12 +194,12 @@ test('search', t => {
// printJSON(searchResults)
t.deepEqual(searchResults, [
{path: ['obj', 'arr', '2', 'last'], field: 'property'},
{path: ['str'], field: 'value'},
{path: ['nill'], field: 'property'},
{path: ['nill'], field: 'value'},
{path: ['bool'], field: 'property'},
{path: ['bool'], field: 'value'}
{path: ['obj', 'arr', '2', 'last'], area: 'property'},
{path: ['str'], area: 'value'},
{path: ['nill'], area: 'property'},
{path: ['nill'], area: 'value'},
{path: ['bool'], area: 'property'},
{path: ['bool'], area: 'value'}
])
const activeSearchResult = searchResults[0]
@ -219,30 +219,30 @@ test('search', t => {
test('nextSearchResult', t => {
const searchResults = [
{path: ['obj', 'arr', '2', 'last'], field: 'property'},
{path: ['str'], field: 'value'},
{path: ['nill'], field: 'property'},
{path: ['nill'], field: 'value'},
{path: ['bool'], field: 'property'},
{path: ['bool'], field: 'value'}
{path: ['obj', 'arr', '2', 'last'], area: 'property'},
{path: ['str'], area: 'value'},
{path: ['nill'], area: 'property'},
{path: ['nill'], area: 'value'},
{path: ['bool'], area: 'property'},
{path: ['bool'], area: 'value'}
]
t.deepEqual(nextSearchResult(searchResults,
{path: ['nill'], field: 'property'}),
{path: ['nill'], field: 'value'})
{path: ['nill'], area: 'property'}),
{path: ['nill'], area: 'value'})
// wrap around
t.deepEqual(nextSearchResult(searchResults,
{path: ['bool'], field: 'value'}),
{path: ['obj', 'arr', '2', 'last'], field: 'property'})
{path: ['bool'], area: 'value'}),
{path: ['obj', 'arr', '2', 'last'], area: 'property'})
// return first when current is not found
t.deepEqual(nextSearchResult(searchResults,
{path: ['non', 'existing'], field: 'value'}),
{path: ['obj', 'arr', '2', 'last'], field: 'property'})
{path: ['non', 'existing'], area: 'value'}),
{path: ['obj', 'arr', '2', 'last'], area: 'property'})
// return null when searchResults are empty
t.deepEqual(nextSearchResult([], {path: ['non', 'existing'], field: 'value'}), null)
t.deepEqual(nextSearchResult([], {path: ['non', 'existing'], area: 'value'}), null)
})
test('previousSearchResult', t => {