Refactored ActionMenu and AppendActionMenu

This commit is contained in:
jos 2016-09-30 13:40:14 +02:00
parent 1bcc6382aa
commit 5fc5e302dc
10 changed files with 235 additions and 101 deletions

View File

@ -1,7 +1,7 @@
import { h, Component } from 'preact'
import ActionMenu from './menu/ActionMenu'
import AppendActionMenu from './menu/AppendActionMenu'
import ActionButton from './menu/ActionButton'
import AppendActionButton from './menu/AppendActionButton'
import { escapeHTML, unescapeHTML } from './utils/stringUtils'
import { getInnerText } from './utils/domUtils'
import { stringConvert, valueType, isUrl } from './utils/typeUtils'
@ -34,6 +34,7 @@ export default class JSONNode extends Component {
constructor (props) {
super(props)
// TODO: remove state
this.state = {
menu: null, // context menu
appendMenu: null, // append context menu (used in placeholder of empty object/array)
@ -281,52 +282,18 @@ export default class JSONNode extends Component {
}
renderActionMenuButton () {
const className = 'jsoneditor-button jsoneditor-contextmenu' +
(this.state.menu ? ' jsoneditor-visible' : '')
return h('div', {class: 'jsoneditor-button-container'}, [
this.renderActionMenu(),
h('button', {class: className, onClick: this.handleContextMenu})
])
return h(ActionButton, {
path: this.getPath(),
type: this.props.data.type,
events: this.props.events
})
}
renderAppendContextMenuButton () {
const className = 'jsoneditor-button jsoneditor-contextmenu' +
(this.state.appendMenu ? ' jsoneditor-visible' : '')
return h('div', {class: 'jsoneditor-button-container'}, [
this.renderAppendContextMenu(),
h('button', {class: className, onClick: this.handleAppendContextMenu})
])
}
renderActionMenu () {
if (this.state.menu) {
return h(ActionMenu, {
anchor: this.state.menu.anchor,
root: this.state.menu.root,
path: this.getPath(),
type: this.props.data.type,
events: this.props.events
})
}
else {
return null
}
}
renderAppendContextMenu () {
if (this.state.appendMenu) {
return h(AppendActionMenu, {
anchor: this.state.menu.anchor,
root: this.state.menu.root,
path: this.getPath(),
events: this.props.events
})
}
else {
return null
}
return h(AppendActionButton, {
path: this.getPath(),
events: this.props.events
})
}
shouldComponentUpdate(nextProps, nextState) {
@ -500,7 +467,7 @@ export default class JSONNode extends Component {
* @param event
* @return {*}
*/
// TODO: move to TreeMode?
// TODO: cleanup
static findRootElement (event) {
function isEditorElement (elem) {
// FIXME: this is a bit tricky. can we use a special attribute or something?

View File

@ -49,7 +49,10 @@ export default class TreeMode extends Component {
render (props, state) {
// TODO: make mode tree dynamic
return h('div', {class: 'jsoneditor jsoneditor-mode-tree'}, [
return h('div', {
class: 'jsoneditor jsoneditor-mode-tree',
'data-jsoneditor': 'true'
}, [
this.renderMenu(),
h('div', {class: 'jsoneditor-contents jsoneditor-tree-contents', onClick: this.handleHideMenus}, [

View File

@ -275,21 +275,21 @@ button.jsoneditor-button.jsoneditor-expanded {
background-position: -2px -74px;
}
button.jsoneditor-button.jsoneditor-contextmenu {
button.jsoneditor-button.jsoneditor-actionmenu {
background-position: -50px -74px;
}
button.jsoneditor-button.jsoneditor-contextmenu:hover,
button.jsoneditor-button.jsoneditor-contextmenu:focus,
button.jsoneditor-button.jsoneditor-contextmenu.jsoneditor-visible {
button.jsoneditor-button.jsoneditor-actionmenu:hover,
button.jsoneditor-button.jsoneditor-actionmenu:focus,
button.jsoneditor-button.jsoneditor-actionmenu.jsoneditor-visible {
background-position: -50px -50px;
}
/******************************* Context Menu *********************************/
/******************************* Action Menu **********************************/
div.jsoneditor-contextmenu {
div.jsoneditor-actionmenu {
position: absolute;
box-sizing: border-box;
z-index: 99999;
@ -301,7 +301,7 @@ div.jsoneditor-contextmenu {
box-shadow: 2px 2px 12px rgba(128, 128, 128, 0.3);
}
div.jsoneditor-contextmenu.jsoneditor-contextmenu-top {
div.jsoneditor-actionmenu.jsoneditor-actionmenu-top {
top: auto;
bottom: 20px;
}
@ -521,6 +521,7 @@ div.jsoneditor-modes {
height: auto;
padding: 2px 6px;
border-radius: 0;
opacity: 1;
&:hover {
border: none;

46
src/menu/ActionButton.js Normal file
View File

@ -0,0 +1,46 @@
import { h, Component } from 'preact'
import ActionMenu from './ActionMenu'
import { findParentNode } from '../utils/domUtils'
export default class ActionButton extends Component {
constructor (props) {
super (props)
this.state = {
open: false, // whether the menu is open or not
anchor: null,
root: null
}
}
/**
* @param {{path, type, events}} props
* @param state
* @return {*}
*/
render (props, state) {
const className = 'jsoneditor-button jsoneditor-actionmenu' +
(this.state.open ? ' jsoneditor-visible' : '')
return h('div', {class: 'jsoneditor-button-container'}, [
h(ActionMenu, {
...props, // path, type, events
...state, // open, anchor, root
onRequestClose: this.handleRequestClose
}),
h('button', {class: className, onClick: this.handleOpen})
])
}
handleOpen = (event) => {
this.setState({
open: true,
anchor: event.target,
root: findParentNode(event.target, 'data-jsoneditor', 'true')
})
}
handleRequestClose = () => {
this.setState({open: false})
}
}

View File

@ -8,7 +8,7 @@ import {
export default class ActionMenu extends Component {
/**
* @param {{anchor, root, path, type, events}} props
* @param {{open, anchor, root, path, type, events, onRequestClose}} props
* @param state
* @return {JSX.Element}
*/
@ -34,8 +34,7 @@ export default class ActionMenu extends Component {
// TODO: implement a hook to adjust the action menu
return h(Menu, {
anchor: props.anchor,
root: props.root,
...props,
items
})
}

View File

@ -0,0 +1,46 @@
import { h, Component } from 'preact'
import AppendActionMenu from './AppendActionMenu'
import { findParentNode } from '../utils/domUtils'
export default class AppendActionButton extends Component {
constructor (props) {
super (props)
this.state = {
open: false, // whether the menu is open or not
anchor: null,
root: null
}
}
/**
* @param {{path, events}} props
* @param state
* @return {*}
*/
render (props, state) {
const className = 'jsoneditor-button jsoneditor-actionmenu' +
(this.state.open ? ' jsoneditor-visible' : '')
return h('div', {class: 'jsoneditor-button-container'}, [
h(AppendActionMenu, {
...props, // path, events
...state, // open, anchor, root
onRequestClose: this.handleRequestClose
}),
h('button', {class: className, onClick: this.handleOpen})
])
}
handleOpen = (event) => {
this.setState({
open: true,
anchor: event.target,
root: findParentNode(event.target, 'data-jsoneditor', 'true')
})
}
handleRequestClose = () => {
this.setState({open: false})
}
}

View File

@ -16,8 +16,7 @@ export default class AppendActionMenu extends Component {
// TODO: implement a hook to adjust the action menu
return h(Menu, {
anchor: props.anchor,
root: props.root,
...props,
items
})
}

View File

@ -1,4 +1,5 @@
import { h, Component } from 'preact'
import { findParentNode } from '../utils/domUtils'
export let CONTEXT_MENU_HEIGHT = 240
@ -6,16 +7,7 @@ export default class Menu extends Component {
constructor(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 = {
orientation,
expanded: null, // menu index of expanded menu item
expanding: null, // menu index of expanding menu item
collapsing: null // menu index of collapsing menu item
@ -23,22 +15,33 @@ export default class Menu extends Component {
}
/**
* @param {{items: Array}} props
* @param {{open: boolean, items: Array, anchor, root, onRequestClose: function}} props
* @param state
* @return {*}
*/
render (props, state) {
if (!props.items) {
if (!props.open) {
return null
}
// 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'
// TODO: create a non-visible button to set the focus to the menu
// TODO: implement (customizable) quick keys
const className = 'jsoneditor-contextmenu ' +
((this.state.orientation === 'top') ? 'jsoneditor-contextmenu-top' : 'jsoneditor-contextmenu-bottom')
const className = 'jsoneditor-actionmenu ' +
((orientation === 'top') ? 'jsoneditor-actionmenu-top' : 'jsoneditor-actionmenu-bottom')
return h('div', {class: className},
return h('div', {
class: className,
'data-menu': 'true'
},
props.items.map(this.renderMenuItem)
)
}
@ -49,9 +52,15 @@ export default class Menu extends Component {
}
if (item.click && item.submenu) {
// FIXME: don't create functions in the render function
const onClick = (event) => {
item.click()
this.props.onRequestClose()
}
// two buttons: direct click and a small button to expand the submenu
return h('div', {class: 'jsoneditor-menu-item'}, [
h('button', {class: 'jsoneditor-menu-button jsoneditor-menu-default ' + item.className, title: item.title, onClick: item.click }, [
h('button', {class: 'jsoneditor-menu-button jsoneditor-menu-default ' + item.className, title: item.title, onClick }, [
h('span', {class: 'jsoneditor-icon'}),
h('span', {class: 'jsoneditor-text'}, item.text)
]),
@ -73,9 +82,15 @@ export default class Menu extends Component {
])
}
else {
// FIXME: don't create functions in the render function
const onClick = (event) => {
item.click()
this.props.onRequestClose()
}
// just a button (no submenu)
return h('div', {class: 'jsoneditor-menu-item'}, [
h('button', {class: 'jsoneditor-menu-button ' + item.className, title: item.title, onClick: item.click }, [
h('button', {class: 'jsoneditor-menu-button ' + item.className, title: item.title, onClick }, [
h('span', {class: 'jsoneditor-icon'}),
h('span', {class: 'jsoneditor-text'}, item.text)
]),
@ -92,8 +107,14 @@ export default class Menu extends Component {
const collapsing = this.state.collapsing === index
const contents = submenu.map(item => {
// FIXME: don't create functions in the render function
const onClick = () => {
item.click()
this.props.onRequestClose()
}
return h('div', {class: 'jsoneditor-menu-item'}, [
h('button', {class: 'jsoneditor-menu-button ' + item.className, title: item.title, onClick: item.click }, [
h('button', {class: 'jsoneditor-menu-button ' + item.className, title: item.title, onClick }, [
h('span', {class: 'jsoneditor-icon'}),
h('span', {class: 'jsoneditor-text'}, item.text)
]),
@ -128,4 +149,49 @@ export default class Menu extends Component {
}, 300)
}
}
componentDidMount () {
this.updateRequestCloseListener()
}
componentDidUpdate () {
this.updateRequestCloseListener()
}
componentWillUnmount () {
this.removeRequestCloseListener()
}
updateRequestCloseListener () {
if (this.props.open) {
this.addRequestCloseListener()
}
else {
this.removeRequestCloseListener()
}
}
addRequestCloseListener () {
if (!this.handleRequestClose) {
// Attach event listener on next tick, else the current click to open
// the menu will immediately result in requestClose event as well
setTimeout(() => {
this.handleRequestClose = (event) => {
if (!findParentNode(event.target, 'data-menu', 'true')) {
this.props.onRequestClose()
}
}
window.addEventListener('click', this.handleRequestClose)
}, 0)
}
}
removeRequestCloseListener () {
if (this.handleRequestClose) {
window.removeEventListener('click', this.handleRequestClose)
this.handleRequestClose = null
}
}
handleRequestClose = null
}

View File

@ -1,10 +1,11 @@
import { h, Component } from 'preact'
import { toCapital } from '../utils/stringUtils'
import { findParentNode } from '../utils/domUtils'
export default class ModeMenu extends Component {
/**
* @param {{open, modes, mode, onMode, onError}} props
* @param {Obect} state
* @param {{open, modes, mode, onMode, onRequestClose, onError}} props
* @param {Object} state
* @return {JSX.Element}
*/
render (props, state) {
@ -17,7 +18,7 @@ export default class ModeMenu extends Component {
onClick: () => {
try {
props.onMode(mode)
this.setState({ open: false })
props.onRequestClose()
}
catch (err) {
props.onError(err)
@ -27,8 +28,8 @@ export default class ModeMenu extends Component {
})
return h('div', {
class: 'jsoneditor-contextmenu jsoneditor-modemenu',
'isnodemenu': 'true',
class: 'jsoneditor-actionmenu jsoneditor-modemenu',
nodemenu: 'true',
}, items)
}
else {
@ -63,7 +64,7 @@ export default class ModeMenu extends Component {
// the menu will immediately result in requestClose event as well
setTimeout(() => {
this.handleRequestClose = (event) => {
if (!ModeMenu.inNodeMenu(event.target)) {
if (!findParentNode(event.target, 'data-menu', 'true')) {
this.props.onRequestClose()
}
}
@ -79,24 +80,5 @@ export default class ModeMenu extends Component {
}
}
/**
* Test whether any of the parent nodes of this element is the root of the
* NodeMenu (has an attribute isNodeMenu:true)
* @param elem
* @return {boolean}
*/
static inNodeMenu (elem) {
let parent = elem
while (parent && parent.getAttribute) {
if (parent.getAttribute('isnodemenu')) {
return true
}
parent = parent.parentNode
}
return false
}
handleRequestClose = null
}

View File

@ -69,6 +69,31 @@ export function getInnerText (element, buffer) {
return ''
}
/**
* Find the parent node of an element which has an attribute with given value.
* Can return the element itself too.
* @param {Element} elem
* @param {string} attr
* @param {string} value
* @return {Element | null} Returns the parent element when found,
* or null otherwise
*/
export function findParentNode (elem, attr, value) {
let parent = elem
while (parent && parent.getAttribute) {
if (parent.getAttribute(attr) == value) {
return parent
}
parent = parent.parentNode
}
return null
}
/**
* Returns the version of Internet Explorer or a -1
* (indicating the use of another browser).