Fixes in life cycle of ContextMenu. Adjust top/bottom orientation

This commit is contained in:
jos 2016-07-24 22:14:45 +02:00
parent a9f0fe07c1
commit c3c836fa89
6 changed files with 113 additions and 60 deletions

View File

@ -1,28 +1,41 @@
import { h, Component } from 'preact' import { h, render, Component } from 'preact'
export let CONTEXT_MENU_HEIGHT = 240
export default class ContextMenu extends Component { export default class ContextMenu extends Component {
constructor(props) { constructor(props) {
super(props) super(props)
// determine orientation
const anchorRect = this.props.anchor.getBoundingClientRect()
const rootRect = this.props.root.getBoundingClientRect()
const orientation = (rootRect.bottom - anchorRect.bottom < CONTEXT_MENU_HEIGHT &&
anchorRect.top - rootRect.top > CONTEXT_MENU_HEIGHT)
? 'top'
: 'bottom'
this.state = { this.state = {
orientation,
expanded: null, // menu index of expanded menu item expanded: null, // menu index of expanded menu item
expanding: null, // menu index of expanding menu item expanding: null, // menu index of expanding menu item
collapsing: null // menu index of collapsing menu item collapsing: null // menu index of collapsing menu item
} }
this.onUpdate = [] // handlers to be executed after component update
this.renderMenuItem = this.renderMenuItem.bind(this) this.renderMenuItem = this.renderMenuItem.bind(this)
} }
render () { render () {
if (!this.props.items) {
return null
}
// TODO: create a non-visible button to set the focus to the menu // TODO: create a non-visible button to set the focus to the menu
// TODO: implement (customizable) quick keys // TODO: implement (customizable) quick keys
// TODO: render the context menu on top when there is no space below the node const className = 'jsoneditor-contextmenu ' +
((this.state.orientation === 'top') ? 'jsoneditor-contextmenu-top' : 'jsoneditor-contextmenu-bottom')
return h('div', {class: 'jsoneditor-contextmenu'}, return h('div', {class: className},
this.props.items.map(this.renderMenuItem) this.props.items.map(this.renderMenuItem)
) )
} }
@ -47,7 +60,6 @@ export default class ContextMenu extends Component {
} }
else if (item.submenu) { else if (item.submenu) {
// button expands the submenu // button expands the submenu
return h('div', {class: 'jsoneditor-menu-item'}, [ return h('div', {class: 'jsoneditor-menu-item'}, [
h('button', {class: 'jsoneditor-menu-button ' + item.className, title: item.title, onClick: this.createExpandHandler(index) }, [ h('button', {class: 'jsoneditor-menu-button ' + item.className, title: item.title, onClick: this.createExpandHandler(index) }, [
h('span', {class: 'jsoneditor-icon'}), h('span', {class: 'jsoneditor-icon'}),
@ -73,7 +85,7 @@ export default class ContextMenu extends Component {
* @param {number} index * @param {number} index
*/ */
renderSubMenu (submenu, index) { renderSubMenu (submenu, index) {
const expanding = this.state.expanding === index const expanded = this.state.expanded === index
const collapsing = this.state.collapsing === index const collapsing = this.state.collapsing === index
const contents = submenu.map(item => { const contents = submenu.map(item => {
@ -86,7 +98,7 @@ export default class ContextMenu extends Component {
}) })
const className = 'jsoneditor-submenu ' + const className = 'jsoneditor-submenu ' +
(expanding ? ' jsoneditor-expanding' : '') + (expanded ? ' jsoneditor-expanded' : '') +
(collapsing ? ' jsoneditor-collapsing' : '') (collapsing ? ' jsoneditor-collapsing' : '')
return h('div', {class: className}, contents) return h('div', {class: className}, contents)
@ -100,16 +112,9 @@ export default class ContextMenu extends Component {
this.setState({ this.setState({
expanded: (prev === index) ? null : index, expanded: (prev === index) ? null : index,
expanding: null,
collapsing: prev collapsing: prev
}) })
this.onUpdate.push(() => {
this.setState({
expanding: this.state.expanded
})
})
// timeout after unit is collapsed // timeout after unit is collapsed
setTimeout(() => { setTimeout(() => {
if (prev === this.state.collapsing) { if (prev === this.state.collapsing) {
@ -120,10 +125,4 @@ export default class ContextMenu extends Component {
}, 300) }, 300)
} }
} }
componentDidUpdate () {
this.onUpdate.forEach(handler => handler())
this.onUpdate = []
}
} }

View File

@ -24,6 +24,7 @@ export default class JSONNode extends Component {
constructor (props) { constructor (props) {
super(props) super(props)
// TODO: create a function bindMethods(this)
this.handleChangeProperty = this.handleChangeProperty.bind(this) this.handleChangeProperty = this.handleChangeProperty.bind(this)
this.handleChangeValue = this.handleChangeValue.bind(this) this.handleChangeValue = this.handleChangeValue.bind(this)
this.handleClickValue = this.handleClickValue.bind(this) this.handleClickValue = this.handleClickValue.bind(this)
@ -159,16 +160,18 @@ export default class JSONNode extends Component {
} }
renderContextMenuButton () { renderContextMenuButton () {
const visible = this.props.data.menu === true const className = 'jsoneditor-button jsoneditor-contextmenu' +
(this.props.data.contextMenu ? ' jsoneditor-visible' : '')
const className = 'jsoneditor-button jsoneditor-contextmenu' + (visible ? ' jsoneditor-visible' : '')
return h('div', {class: 'jsoneditor-button-container'}, return h('div', {class: 'jsoneditor-button-container'},
visible ? this.renderContextMenu() : null, this.props.data.contextMenu
? this.renderContextMenu(this.props.data.contextMenu)
: null,
h('button', {class: className, onClick: this.handleContextMenu}) h('button', {class: className, onClick: this.handleContextMenu})
) )
} }
renderContextMenu () { renderContextMenu ({anchor, root}) {
const hasParent = this.props.data.path !== '' const hasParent = this.props.data.path !== ''
const type = this.props.data.type const type = this.props.data.type
const items = [] // array with menu items const items = [] // array with menu items
@ -333,10 +336,11 @@ export default class JSONNode extends Component {
// TODO: implement a hook to adjust the context menu // TODO: implement a hook to adjust the context menu
return h(ContextMenu, {items}) return h(ContextMenu, {anchor, root, items})
} }
shouldComponentUpdate(nextProps, nextState) { shouldComponentUpdate(nextProps, nextState) {
// WARNING: we suppose that JSONNode is stateless, we don't check changes in the state, only in props
return Object.keys(nextProps).some(prop => this.props[prop] !== nextProps[prop]) return Object.keys(nextProps).some(prop => this.props[prop] !== nextProps[prop])
} }
@ -360,20 +364,20 @@ export default class JSONNode extends Component {
} }
handleChangeValue (event) { handleChangeValue (event) {
const value = this._getValueFromEvent(event) const value = JSONNode._getValueFromEvent(event)
this.props.events.onChangeValue(this.props.data.path, value) this.props.events.onChangeValue(this.props.data.path, value)
} }
handleClickValue (event) { handleClickValue (event) {
if (event.ctrlKey && event.button === 0) { // Ctrl+Left click if (event.ctrlKey && event.button === 0) { // Ctrl+Left click
this._openLinkIfUrl(event) JSONNode._openLinkIfUrl(event)
} }
} }
handleKeyDownValue (event) { handleKeyDownValue (event) {
if (event.ctrlKey && event.which === 13) { // Ctrl+Enter if (event.ctrlKey && event.which === 13) { // Ctrl+Enter
this._openLinkIfUrl(event) JSONNode._openLinkIfUrl(event)
} }
} }
@ -384,16 +388,25 @@ export default class JSONNode extends Component {
handleContextMenu (event) { handleContextMenu (event) {
event.stopPropagation() // stop propagation, because else Main.js will hide the context menu again event.stopPropagation() // stop propagation, because else Main.js will hide the context menu again
// toggle visibility of the context menu if (this.props.data.contextMenu) {
const path = this.props.data.menu === true this.props.events.hideContextMenu()
? null }
: this.props.data.path else {
this.props.events.showContextMenu({
this.props.events.onContextMenu(path) path: this.props.data.path,
anchor: event.target,
root: JSONNode._findRootElement(event)
})
}
} }
_openLinkIfUrl (event) { /**
const value = this._getValueFromEvent(event) * When this JSONNode holds an URL as value, open this URL in a new browser tab
* @param event
* @private
*/
static _openLinkIfUrl (event) {
const value = JSONNode._getValueFromEvent(event)
if (isUrl(value)) { if (isUrl(value)) {
event.preventDefault() event.preventDefault()
@ -403,7 +416,33 @@ export default class JSONNode extends Component {
} }
} }
_getValueFromEvent (event) { static _getValueFromEvent (event) {
return stringConvert(unescapeHTML(getInnerText(event.target))) return stringConvert(unescapeHTML(getInnerText(event.target)))
} }
/**
* Find the root DOM element of the JSONEditor
* Search is done based on the CSS class 'jsoneditor'
* @param event
* @return {*}
* @private
*/
static _findRootElement (event) {
function isEditorElement (elem) {
return elem.className.split(' ').indexOf('jsoneditor') !== -1
}
let elem = event.target
while (elem) {
if (isEditorElement(elem)) {
return elem
}
elem = elem.parentNode
}
return null
}
} }

View File

@ -9,6 +9,13 @@ export default class Main extends Component {
constructor (props) { constructor (props) {
super(props) super(props)
// TODO: create a function bindMethods(this)
this.handleChangeProperty = this.handleChangeProperty.bind(this)
this.handleChangeValue = this.handleChangeValue.bind(this)
this.handleExpand = this.handleExpand.bind(this)
this.handleShowContextMenu = this.handleShowContextMenu.bind(this)
this.handleHideContextMenu = this.handleHideContextMenu.bind(this)
this.state = { this.state = {
options: Object.assign({ options: Object.assign({
name: null, name: null,
@ -23,19 +30,18 @@ export default class Main extends Component {
}, },
events: { events: {
onChangeProperty: this.handleChangeProperty.bind(this), onChangeProperty: this.handleChangeProperty,
onChangeValue: this.handleChangeValue.bind(this), onChangeValue: this.handleChangeValue,
onExpand: this.handleExpand.bind(this), onExpand: this.handleExpand,
onContextMenu: this.handleContextMenu.bind(this) showContextMenu: this.handleShowContextMenu,
hideContextMenu: this.handleHideContextMenu
}, },
/** @type {string | null} */ /** @type {string | null} */
menu: null, // json pointer to the node having menu visible contextMenuPath: null, // json pointer to the node having menu visible
search: null search: null
} }
this.handleHideContextMenu = this.handleHideContextMenu.bind(this)
} }
render() { render() {
@ -84,32 +90,34 @@ export default class Main extends Component {
/** /**
* Set ContextMenu to a json pointer, or hide the context menu by passing null * Set ContextMenu to a json pointer, or hide the context menu by passing null
* @param {string | null} path * @param {string | null} path
* @param {Element} anchor
* @param {Element} root
* @private * @private
*/ */
handleContextMenu(path) { handleShowContextMenu({path, anchor, root}) {
let data = this.state.data let data = this.state.data
// hide previous context menu (if any) // hide previous context menu (if any)
if (this.state.menu !== null) { if (this.state.contextMenuPath !== null) {
const modelPath = Main._pathToModelPath(this.state.data, Main._parsePath(this.state.menu)) const modelPath = Main._pathToModelPath(this.state.data, Main._parsePath(this.state.contextMenuPath))
data = setIn(data, modelPath.concat('menu'), false) data = setIn(data, modelPath.concat('contextMenu'), false)
} }
// show new menu // show new menu
if (path !== null) { if (typeof path === 'string') {
const modelPath = Main._pathToModelPath(this.state.data, Main._parsePath(path)) const modelPath = Main._pathToModelPath(this.state.data, Main._parsePath(path))
data = setIn(data, modelPath.concat('menu'), true) data = setIn(data, modelPath.concat('contextMenu'), {anchor, root})
} }
this.setState({ this.setState({
menu: path, // store path of current menu, just to easily find it next time contextMenuPath: typeof path === 'string' ? path : null, // store path of current menu, just to easily find it next time
data data
}) })
} }
handleHideContextMenu (event) { handleHideContextMenu (event) {
// FIXME: find a different way to show/hide the context menu. create a single instance in the Main, pass a reference to it into the JSON nodes? // FIXME: find a different way to show/hide the context menu. create a single instance in the Main, pass a reference to it into the JSON nodes?
this.handleContextMenu(null) this.handleShowContextMenu({})
} }
// TODO: comment // TODO: comment

View File

@ -17,7 +17,10 @@
// create the editor // create the editor
const container = document.getElementById('container'); const container = document.getElementById('container');
const options = { const options = {
name: 'myObject' name: 'myObject',
expand: function (path) {
return true
}
}; };
const editor = jsoneditor(container, options); const editor = jsoneditor(container, options);
const json = { const json = {

View File

@ -170,7 +170,7 @@ button.jsoneditor-button.jsoneditor-contextmenu.jsoneditor-visible {
/******************************* Context Menu ******************************/ /******************************* Context Menu *********************************/
div.jsoneditor-contextmenu { div.jsoneditor-contextmenu {
position: absolute; position: absolute;
@ -184,6 +184,11 @@ div.jsoneditor-contextmenu {
box-shadow: 2px 2px 12px rgba(128, 128, 128, 0.3); box-shadow: 2px 2px 12px rgba(128, 128, 128, 0.3);
} }
div.jsoneditor-contextmenu.jsoneditor-contextmenu-top {
top: auto;
bottom: 20px;
}
div.jsoneditor-menu-item { div.jsoneditor-menu-item {
line-height: 0; line-height: 0;
font-size: 0; font-size: 0;
@ -319,7 +324,7 @@ div.jsoneditor-submenu {
inset 0 -10px 10px -10px rgba(128, 128, 128, 0.5) inset 0 -10px 10px -10px rgba(128, 128, 128, 0.5)
} }
div.jsoneditor-submenu.jsoneditor-expanding { div.jsoneditor-submenu.jsoneditor-expanded {
visibility: visible; visibility: visible;
max-height: 104px; /* 4 * 24px + 2 * 5px */ max-height: 104px; /* 4 * 24px + 2 * 5px */
/* FIXME: shouldn't rely on max-height equal to 4 items, should be flexible */ /* FIXME: shouldn't rely on max-height equal to 4 items, should be flexible */

View File

@ -61,20 +61,19 @@ export function isUrl (text) {
* @private * @private
*/ */
export function stringConvert (str) { export function stringConvert (str) {
const lower = str.toLowerCase()
const num = Number(str) // will nicely fail with '123ab' const num = Number(str) // will nicely fail with '123ab'
const numFloat = parseFloat(str) // will nicely fail with ' ' const numFloat = parseFloat(str) // will nicely fail with ' '
if (str == '') { if (str == '') {
return '' return ''
} }
else if (lower == 'null') { else if (str == 'null') {
return null return null
} }
else if (lower == 'true') { else if (str == 'true') {
return true return true
} }
else if (lower == 'false') { else if (str == 'false') {
return false return false
} }
else if (!isNaN(num) && !isNaN(numFloat)) { else if (!isNaN(num) && !isNaN(numFloat)) {