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 { h, Component } from 'preact'
import ActionMenu from './menu/ActionMenu' import ActionButton from './menu/ActionButton'
import AppendActionMenu from './menu/AppendActionMenu' import AppendActionButton from './menu/AppendActionButton'
import { escapeHTML, unescapeHTML } from './utils/stringUtils' import { escapeHTML, unescapeHTML } from './utils/stringUtils'
import { getInnerText } from './utils/domUtils' import { getInnerText } from './utils/domUtils'
import { stringConvert, valueType, isUrl } from './utils/typeUtils' import { stringConvert, valueType, isUrl } from './utils/typeUtils'
@ -34,6 +34,7 @@ export default class JSONNode extends Component {
constructor (props) { constructor (props) {
super(props) super(props)
// TODO: remove state
this.state = { this.state = {
menu: null, // context menu menu: null, // context menu
appendMenu: null, // append context menu (used in placeholder of empty object/array) appendMenu: null, // append context menu (used in placeholder of empty object/array)
@ -281,53 +282,19 @@ export default class JSONNode extends Component {
} }
renderActionMenuButton () { renderActionMenuButton () {
const className = 'jsoneditor-button jsoneditor-contextmenu' + return h(ActionButton, {
(this.state.menu ? ' jsoneditor-visible' : '')
return h('div', {class: 'jsoneditor-button-container'}, [
this.renderActionMenu(),
h('button', {class: className, onClick: this.handleContextMenu})
])
}
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(), path: this.getPath(),
type: this.props.data.type, type: this.props.data.type,
events: this.props.events events: this.props.events
}) })
} }
else {
return null
}
}
renderAppendContextMenu () { renderAppendContextMenuButton () {
if (this.state.appendMenu) { return h(AppendActionButton, {
return h(AppendActionMenu, {
anchor: this.state.menu.anchor,
root: this.state.menu.root,
path: this.getPath(), path: this.getPath(),
events: this.props.events events: this.props.events
}) })
} }
else {
return null
}
}
shouldComponentUpdate(nextProps, nextState) { shouldComponentUpdate(nextProps, nextState) {
let prop let prop
@ -500,7 +467,7 @@ export default class JSONNode extends Component {
* @param event * @param event
* @return {*} * @return {*}
*/ */
// TODO: move to TreeMode? // TODO: cleanup
static findRootElement (event) { static findRootElement (event) {
function isEditorElement (elem) { function isEditorElement (elem) {
// FIXME: this is a bit tricky. can we use a special attribute or something? // 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) { render (props, state) {
// TODO: make mode tree dynamic // 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(), this.renderMenu(),
h('div', {class: 'jsoneditor-contents jsoneditor-tree-contents', onClick: this.handleHideMenus}, [ 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; background-position: -2px -74px;
} }
button.jsoneditor-button.jsoneditor-contextmenu { button.jsoneditor-button.jsoneditor-actionmenu {
background-position: -50px -74px; background-position: -50px -74px;
} }
button.jsoneditor-button.jsoneditor-contextmenu:hover, button.jsoneditor-button.jsoneditor-actionmenu:hover,
button.jsoneditor-button.jsoneditor-contextmenu:focus, button.jsoneditor-button.jsoneditor-actionmenu:focus,
button.jsoneditor-button.jsoneditor-contextmenu.jsoneditor-visible { button.jsoneditor-button.jsoneditor-actionmenu.jsoneditor-visible {
background-position: -50px -50px; background-position: -50px -50px;
} }
/******************************* Context Menu *********************************/ /******************************* Action Menu **********************************/
div.jsoneditor-contextmenu { div.jsoneditor-actionmenu {
position: absolute; position: absolute;
box-sizing: border-box; box-sizing: border-box;
z-index: 99999; z-index: 99999;
@ -301,7 +301,7 @@ 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 { div.jsoneditor-actionmenu.jsoneditor-actionmenu-top {
top: auto; top: auto;
bottom: 20px; bottom: 20px;
} }
@ -521,6 +521,7 @@ div.jsoneditor-modes {
height: auto; height: auto;
padding: 2px 6px; padding: 2px 6px;
border-radius: 0; border-radius: 0;
opacity: 1;
&:hover { &:hover {
border: none; 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 { export default class ActionMenu extends Component {
/** /**
* @param {{anchor, root, path, type, events}} props * @param {{open, anchor, root, path, type, events, onRequestClose}} props
* @param state * @param state
* @return {JSX.Element} * @return {JSX.Element}
*/ */
@ -34,8 +34,7 @@ export default class ActionMenu extends Component {
// TODO: implement a hook to adjust the action menu // TODO: implement a hook to adjust the action menu
return h(Menu, { return h(Menu, {
anchor: props.anchor, ...props,
root: props.root,
items 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 // TODO: implement a hook to adjust the action menu
return h(Menu, { return h(Menu, {
anchor: props.anchor, ...props,
root: props.root,
items items
}) })
} }

View File

@ -1,4 +1,5 @@
import { h, Component } from 'preact' import { h, Component } from 'preact'
import { findParentNode } from '../utils/domUtils'
export let CONTEXT_MENU_HEIGHT = 240 export let CONTEXT_MENU_HEIGHT = 240
@ -6,6 +7,23 @@ export default class Menu extends Component {
constructor(props) { constructor(props) {
super(props) super(props)
this.state = {
expanded: null, // menu index of expanded menu item
expanding: null, // menu index of expanding menu item
collapsing: null // menu index of collapsing menu item
}
}
/**
* @param {{open: boolean, items: Array, anchor, root, onRequestClose: function}} props
* @param state
* @return {*}
*/
render (props, state) {
if (!props.open) {
return null
}
// determine orientation // determine orientation
const anchorRect = this.props.anchor.getBoundingClientRect() const anchorRect = this.props.anchor.getBoundingClientRect()
const rootRect = this.props.root.getBoundingClientRect() const rootRect = this.props.root.getBoundingClientRect()
@ -14,31 +32,16 @@ export default class Menu extends Component {
? 'top' ? 'top'
: 'bottom' : '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
}
}
/**
* @param {{items: Array}} props
* @param state
* @return {*}
*/
render (props, state) {
if (!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
const className = 'jsoneditor-contextmenu ' + const className = 'jsoneditor-actionmenu ' +
((this.state.orientation === 'top') ? 'jsoneditor-contextmenu-top' : 'jsoneditor-contextmenu-bottom') ((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) props.items.map(this.renderMenuItem)
) )
} }
@ -49,9 +52,15 @@ export default class Menu extends Component {
} }
if (item.click && item.submenu) { 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 // two buttons: direct click and a small button to expand the submenu
return h('div', {class: 'jsoneditor-menu-item'}, [ 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-icon'}),
h('span', {class: 'jsoneditor-text'}, item.text) h('span', {class: 'jsoneditor-text'}, item.text)
]), ]),
@ -73,9 +82,15 @@ export default class Menu extends Component {
]) ])
} }
else { else {
// FIXME: don't create functions in the render function
const onClick = (event) => {
item.click()
this.props.onRequestClose()
}
// just a button (no submenu) // just a button (no 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: item.click }, [ h('button', {class: 'jsoneditor-menu-button ' + item.className, title: item.title, onClick }, [
h('span', {class: 'jsoneditor-icon'}), h('span', {class: 'jsoneditor-icon'}),
h('span', {class: 'jsoneditor-text'}, item.text) h('span', {class: 'jsoneditor-text'}, item.text)
]), ]),
@ -92,8 +107,14 @@ export default class Menu extends Component {
const collapsing = this.state.collapsing === index const collapsing = this.state.collapsing === index
const contents = submenu.map(item => { 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'}, [ 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-icon'}),
h('span', {class: 'jsoneditor-text'}, item.text) h('span', {class: 'jsoneditor-text'}, item.text)
]), ]),
@ -128,4 +149,49 @@ export default class Menu extends Component {
}, 300) }, 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 { h, Component } from 'preact'
import { toCapital } from '../utils/stringUtils' import { toCapital } from '../utils/stringUtils'
import { findParentNode } from '../utils/domUtils'
export default class ModeMenu extends Component { export default class ModeMenu extends Component {
/** /**
* @param {{open, modes, mode, onMode, onError}} props * @param {{open, modes, mode, onMode, onRequestClose, onError}} props
* @param {Obect} state * @param {Object} state
* @return {JSX.Element} * @return {JSX.Element}
*/ */
render (props, state) { render (props, state) {
@ -17,7 +18,7 @@ export default class ModeMenu extends Component {
onClick: () => { onClick: () => {
try { try {
props.onMode(mode) props.onMode(mode)
this.setState({ open: false }) props.onRequestClose()
} }
catch (err) { catch (err) {
props.onError(err) props.onError(err)
@ -27,8 +28,8 @@ export default class ModeMenu extends Component {
}) })
return h('div', { return h('div', {
class: 'jsoneditor-contextmenu jsoneditor-modemenu', class: 'jsoneditor-actionmenu jsoneditor-modemenu',
'isnodemenu': 'true', nodemenu: 'true',
}, items) }, items)
} }
else { else {
@ -63,7 +64,7 @@ export default class ModeMenu extends Component {
// the menu will immediately result in requestClose event as well // the menu will immediately result in requestClose event as well
setTimeout(() => { setTimeout(() => {
this.handleRequestClose = (event) => { this.handleRequestClose = (event) => {
if (!ModeMenu.inNodeMenu(event.target)) { if (!findParentNode(event.target, 'data-menu', 'true')) {
this.props.onRequestClose() 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 handleRequestClose = null
} }

View File

@ -69,6 +69,31 @@ export function getInnerText (element, buffer) {
return '' 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 * Returns the version of Internet Explorer or a -1
* (indicating the use of another browser). * (indicating the use of another browser).