diff --git a/src/JSONNode.js b/src/JSONNode.js index 06586e9..6400298 100644 --- a/src/JSONNode.js +++ b/src/JSONNode.js @@ -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? diff --git a/src/TreeMode.js b/src/TreeMode.js index 35e2254..78f3907 100644 --- a/src/TreeMode.js +++ b/src/TreeMode.js @@ -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}, [ diff --git a/src/jsoneditor.less b/src/jsoneditor.less index 28d735b..774413c 100644 --- a/src/jsoneditor.less +++ b/src/jsoneditor.less @@ -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; diff --git a/src/menu/ActionButton.js b/src/menu/ActionButton.js new file mode 100644 index 0000000..a81a1e2 --- /dev/null +++ b/src/menu/ActionButton.js @@ -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}) + } +} diff --git a/src/menu/ActionMenu.js b/src/menu/ActionMenu.js index e945a7f..58a54b3 100644 --- a/src/menu/ActionMenu.js +++ b/src/menu/ActionMenu.js @@ -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 }) } diff --git a/src/menu/AppendActionButton.js b/src/menu/AppendActionButton.js new file mode 100644 index 0000000..7d2a80d --- /dev/null +++ b/src/menu/AppendActionButton.js @@ -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}) + } +} diff --git a/src/menu/AppendActionMenu.js b/src/menu/AppendActionMenu.js index 91ffed3..6acbe6c 100644 --- a/src/menu/AppendActionMenu.js +++ b/src/menu/AppendActionMenu.js @@ -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 }) } diff --git a/src/menu/Menu.js b/src/menu/Menu.js index 65e28d2..534e874 100644 --- a/src/menu/Menu.js +++ b/src/menu/Menu.js @@ -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 } diff --git a/src/menu/ModeMenu.js b/src/menu/ModeMenu.js index 39e5598..1134eaa 100644 --- a/src/menu/ModeMenu.js +++ b/src/menu/ModeMenu.js @@ -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 } \ No newline at end of file diff --git a/src/utils/domUtils.js b/src/utils/domUtils.js index 218121e..dcc3549 100644 --- a/src/utils/domUtils.js +++ b/src/utils/domUtils.js @@ -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).