Refactored action menus, added quick keys Ctrl+M, Ctrl+E

This commit is contained in:
jos 2017-07-13 12:10:42 +02:00
parent bb8734707e
commit 94b7be90d3
9 changed files with 167 additions and 232 deletions

View File

@ -2,27 +2,20 @@
import { createElement as h, Component } from 'react'
import ActionButton from './menu/ActionButton'
import AppendActionButton from './menu/AppendActionButton'
import ActionMenu from './menu/ActionMenu'
import { escapeHTML, unescapeHTML } from '../utils/stringUtils'
import { getInnerText, insideRect } from '../utils/domUtils'
import { getInnerText, insideRect, findParentNode } from '../utils/domUtils'
import { stringConvert, valueType, isUrl } from '../utils/typeUtils'
import { compileJSONPointer } from '../jsonData'
import type { PropertyData, JSONData, SearchResultStatus } from '../types'
/**
* @type {JSONNode | null} activeContextMenu singleton holding the JSONNode having
* the active (visible) context menu
*/
let activeContextMenu = null
export default class JSONNode extends Component {
static URL_TITLE = 'Ctrl+Click or Ctrl+Enter to open url'
state = {
menu: null, // context menu
appendMenu: null, // append context menu (used in placeholder of empty object/array)
menu: null, // can contain object {anchor, root}
appendMenu: null, // can contain object {anchor, root}
}
render () {
@ -48,6 +41,7 @@ export default class JSONNode extends Component {
className: 'jsoneditor-node jsoneditor-object'
}, [
this.renderExpandButton(),
this.renderActionMenu('update', this.state.menu, this.handleCloseActionMenu),
this.renderActionMenuButton(),
this.renderProperty(prop, index, data, options),
this.renderReadonly(`{${childCount}}`, `Array containing ${childCount} items`),
@ -93,6 +87,7 @@ export default class JSONNode extends Component {
className: 'jsoneditor-node jsoneditor-array'
}, [
this.renderExpandButton(),
this.renderActionMenu('update', this.state.menu, this.handleCloseActionMenu),
this.renderActionMenuButton(),
this.renderProperty(prop, index, data, options),
this.renderReadonly(`[${childCount}]`, `Array containing ${childCount} items`),
@ -134,6 +129,7 @@ export default class JSONNode extends Component {
className: 'jsoneditor-node'
}, [
this.renderPlaceholder(),
this.renderActionMenu('update', this.state.menu, this.handleCloseActionMenu),
this.renderActionMenuButton(),
this.renderProperty(prop, index, data, options),
this.renderSeparator(),
@ -154,7 +150,8 @@ export default class JSONNode extends Component {
onKeyDown: this.handleKeyDownAppend
}, [
this.renderPlaceholder(),
this.renderAppendMenuButton(),
this.renderActionMenu('append', this.state.appendMenu, this.handleCloseAppendActionMenu),
this.renderAppendActionMenuButton(),
this.renderReadonly(text)
])
}
@ -167,6 +164,7 @@ export default class JSONNode extends Component {
return h('div', {key: 'readonly', className: 'jsoneditor-readonly', title}, text)
}
// TODO: simplify the method renderProperty
renderProperty (prop: ?PropertyData, index: ?number, data: JSONData, options: {escapeUnicode: boolean, isPropertyEditable: (path: string) => boolean}) {
const isIndex = typeof index === 'number'
@ -368,23 +366,89 @@ export default class JSONNode extends Component {
)
}
renderActionMenuButton () {
return h(ActionButton, {
key: 'action',
// TODO: simplify code for the two action menus
renderActionMenu (menuType, menuState, onClose) {
if (!menuState) {
return null
}
return h(ActionMenu, {
key: 'menu',
path: this.props.path,
events: this.props.events,
type: this.props.data.type,
events: this.props.events
menuType,
open: true,
anchor: menuState.anchor,
root: menuState.root,
onRequestClose: onClose
})
}
renderAppendMenuButton () {
return h(AppendActionButton, {
key: 'append',
path: this.props.path,
events: this.props.events
renderActionMenuButton () {
const className = 'jsoneditor-button jsoneditor-actionmenu' +
((this.state.open) ? ' jsoneditor-visible' : '')
return h('div', {className: 'jsoneditor-button-container', key: 'action'}, [
h('button', {
key: 'button',
ref: 'actionMenuButton',
className,
onClick: this.handleOpenActionMenu
})
])
}
renderAppendActionMenuButton () {
const className = 'jsoneditor-button jsoneditor-actionmenu' +
((this.state.appendOpen) ? ' jsoneditor-visible' : '')
return h('div', {className: 'jsoneditor-button-container', key: 'action'}, [
h('button', {
key: 'button',
ref: 'appendActionMenuButton',
className,
onClick: this.handleOpenAppendActionMenu
})
])
}
handleOpenActionMenu = (event) => {
// TODO: don't use refs, find root purely via DOM?
const root = findParentNode(this.refs.actionMenuButton, 'data-jsoneditor', 'true')
this.setState({
menu: {
open: true,
anchor: this.refs.actionMenuButton,
root
}
})
}
handleCloseActionMenu = () => {
this.setState({ menu: null })
}
handleOpenAppendActionMenu = (event) => {
// TODO: don't use refs, find root purely via DOM?
const root = findParentNode(this.refs.appendActionMenuButton, 'data-jsoneditor', 'true')
this.setState({
appendMenu: {
open: true,
anchor: this.refs.actionMenuButton,
root
}
})
}
handleCloseAppendActionMenu = () => {
this.setState({ appendMenu: null })
}
shouldComponentUpdate (nextProps, nextState) {
let prop
@ -456,6 +520,18 @@ export default class JSONNode extends Component {
event.preventDefault()
this.props.events.onRemove(this.props.path)
}
if (keyBinding === 'expand') {
event.preventDefault()
const recurse = false
const expanded = !this.props.data.expanded
this.props.events.onExpand(this.props.path, expanded, recurse)
}
if (keyBinding === 'actionMenu') {
event.preventDefault()
this.handleOpenActionMenu(event)
}
}
/** @private */
@ -466,6 +542,11 @@ export default class JSONNode extends Component {
event.preventDefault()
this.props.events.onAppend(this.props.path, 'value')
}
if (keyBinding === 'actionMenu') {
event.preventDefault()
this.handleOpenAppendActionMenu(event)
}
}
/** @private */
@ -485,20 +566,6 @@ export default class JSONNode extends Component {
this.props.events.onExpand(this.props.path, expanded, recurse)
}
/**
* Singleton function to hide the currently visible context menu if any.
* @protected
*/
static hideActionMenu () {
if (activeContextMenu) {
activeContextMenu.setState({
menu: null,
appendMenu: null
})
activeContextMenu = null
}
}
/**
* When this JSONNode holds an URL as value, open this URL in a new browser tab
* @param event

View File

@ -38,6 +38,28 @@ const AJV_OPTIONS = {
const MAX_HISTORY_ITEMS = 1000 // maximum number of undo/redo items to be kept in memory
const SEARCH_DEBOUNCE = 300 // milliseconds
// TODO: make key bindings configurable
const KEY_BINDINGS = {
'duplicate': ['Ctrl+D', 'Command+D'],
'insert': ['Ctrl+Insert', 'Command+Insert'],
'remove': ['Ctrl+Delete', 'Command+Delete'],
'expand': ['Ctrl+E', 'Command+E'],
'actionMenu':['Ctrl+M', 'Command+M'],
'up': ['Alt+Up', 'Option+Up'],
'down': ['Alt+Down', 'Option+Down'],
'left': ['Alt+Left', 'Option+Left'],
'right': ['Alt+Right', 'Option+Right'],
'openUrl': ['Ctrl+Enter', 'Command+Enter']
// TODO: implement all quick keys
// Ctrl+Shift+Arrow Up/Down Select multiple fields
// Shift+Alt+Arrows Move current field or selected fields up/down/left/right
// Ctrl+F Find
// F3, Ctrl+G Find next
// Shift+F3, Ctrl+Shift+G Find previous
// Ctrl+Z Undo last action
// Ctrl+Shift+Z Redo
}
export default class TreeMode extends Component {
id: number
state: Object
@ -49,30 +71,6 @@ export default class TreeMode extends Component {
this.id = Math.round(Math.random() * 1e5) // TODO: create a uuid here?
// TODO: make key bindings configurable
const keyBindings = {
'duplicate': ['Ctrl+D', 'Command+D'],
'insert': ['Ctrl+Insert', 'Command+Insert'],
'remove': ['Ctrl+Delete', 'Command+Delete'],
'up': ['Alt+Up', 'Option+Up'],
'down': ['Alt+Down', 'Option+Down'],
'left': ['Alt+Left', 'Option+Left'],
'right': ['Alt+Right', 'Option+Right'],
'openUrl': ['Ctrl+Enter', 'Command+Enter']
// TODO: implement all quick keys
// Ctrl+Shift+Arrow Up/Down Select multiple fields
// Shift+Alt+Arrows Move current field or selected fields up/down/left/right
// Ctrl+Ins Insert a new field with type auto
// Ctrl+Shift+Ins Append a new field with type auto
// Ctrl+E Expand or collapse field
// Ctrl+F Find
// F3, Ctrl+G Find next
// Shift+F3, Ctrl+Shift+G Find previous
// Ctrl+M Show actions menu
// Ctrl+Z Undo last action
// Ctrl+Shift+Z Redo
}
this.state = {
data,
@ -100,7 +98,7 @@ export default class TreeMode extends Component {
active: null // active search result
},
keyCombos: this.bindingsByCombos (keyBindings)
keyCombos: this.bindingsByCombos (KEY_BINDINGS)
}
}
@ -175,7 +173,7 @@ export default class TreeMode extends Component {
key: 'contents',
ref: 'contents',
className: 'jsoneditor-contents jsoneditor-tree-contents',
onClick: this.handleHideMenus, id: this.id
id: this.id
},
h('ul', {className: 'jsoneditor-list jsoneditor-root'},
h(Node, {
@ -322,11 +320,6 @@ export default class TreeMode extends Component {
}
}
/** @private */
handleHideMenus = () => {
JSONNode.hideActionMenu()
}
/** @private */
handleChangeValue = (path, value) => {
this.handlePatch(changeValue(this.state.data, path, value))

View File

@ -1,53 +0,0 @@
import { createElement as h, Component } from 'react'
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 () {
const { props, state} = this
const className = 'jsoneditor-button jsoneditor-actionmenu' +
(this.state.open ? ' jsoneditor-visible' : '')
return h('div', {className: 'jsoneditor-button-container'}, [
h(ActionMenu, {
key: 'menu',
...props, // path, type, events
...state, // open, anchor, root
onRequestClose: this.handleRequestClose
}),
h('button', {
key: 'button',
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

@ -3,17 +3,26 @@ import Menu from './Menu'
import {
createChangeType, createSort,
createSeparator,
createInsert, createDuplicate, createRemove
} from './entries'
createInsert, createAppend, createDuplicate, createRemove
} from './items'
export default class ActionMenu extends Component {
/**
* @param {{open, anchor, root, path, type, events, onRequestClose}} props
* @param state
* @return {JSX.Element}
* props = {open, anchor, root, path, type, menuType, events, onRequestClose}
*/
render () {
const { props, state} = this
const items = this.props.menuType === 'append' // update or append
? this.createAppendMenuItems()
: this.createActionMenuItems()
// TODO: implement a hook to adjust the action menu items
return h(Menu, { ...this.props, items })
}
createActionMenuItems () {
const props = this.props
let items = [] // array with menu items
@ -33,11 +42,12 @@ export default class ActionMenu extends Component {
items.push(createRemove(props.path, props.events.onRemove))
}
// TODO: implement a hook to adjust the action menu
return items
}
return h(Menu, {
...props,
items
})
createAppendMenuItems () {
return [
createAppend(this.props.path, this.props.events.onAppend)
]
}
}

View File

@ -1,53 +0,0 @@
import { createElement as h, Component } from 'react'
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 () {
const { props, state} = this
const className = 'jsoneditor-button jsoneditor-actionmenu' +
(this.state.open ? ' jsoneditor-visible' : '')
return h('div', {className: 'jsoneditor-button-container'}, [
h(AppendActionMenu, {
key: 'menu',
...props, // path, events
...state, // open, anchor, root
onRequestClose: this.handleRequestClose
}),
h('button', {
key: 'button',
className: 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

@ -1,25 +0,0 @@
import { createElement as h, Component } from 'react'
import Menu from './Menu'
import { createAppend } from './entries'
export default class AppendActionMenu extends Component {
/**
* @param {{anchor, root, path, events}} props
* @param state
* @return {JSX.Element}
*/
render () {
const { props, state} = this
const items = [
createAppend(props.path, props.events.onAppend)
]
// TODO: implement a hook to adjust the action menu
return h(Menu, {
...props,
items
})
}
}

View File

@ -4,6 +4,10 @@ import { findParentNode } from '../../utils/domUtils'
export let CONTEXT_MENU_HEIGHT = 240
export default class Menu extends Component {
/**
* @param {{open: boolean, items: Array, anchor, root, onRequestClose: function}} props
*/
constructor(props) {
super(props)
@ -14,11 +18,6 @@ export default class Menu extends Component {
}
}
/**
* @param {{open: boolean, items: Array, anchor, root, onRequestClose: function}} props
* @param state
* @return {*}
*/
render () {
if (!this.props.open) {
return null
@ -159,7 +158,8 @@ export default class Menu extends Component {
}
componentWillUnmount () {
this.removeRequestCloseListener()
// remove on next tick, since a listener can be created on next tick too
setTimeout(() => this.removeRequestCloseListener())
}
updateRequestCloseListener () {
@ -172,18 +172,14 @@ export default class Menu extends Component {
}
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()
}
}
// Attach event listener on next tick, else the current click to open
// the menu will immediately result in requestClose event as well
setTimeout(() => {
if (this.props.open && !this.handleRequestClose) {
this.handleRequestClose = (event) => this.props.onRequestClose()
window.addEventListener('click', this.handleRequestClose)
}, 0)
}
}
})
}
removeRequestCloseListener () {

View File

@ -22,25 +22,25 @@ export function createChangeType (path, type, onChangeType) {
submenu: [
{
text: 'Value',
className: 'jsoneditor-type-value' + (type == 'value' ? ' jsoneditor-selected' : ''),
className: 'jsoneditor-type-value' + (type === 'value' ? ' jsoneditor-selected' : ''),
title: TYPE_TITLES.value,
click: () => onChangeType(path, 'value')
},
{
text: 'Array',
className: 'jsoneditor-type-Array' + (type == 'Array' ? ' jsoneditor-selected' : ''),
className: 'jsoneditor-type-Array' + (type === 'Array' ? ' jsoneditor-selected' : ''),
title: TYPE_TITLES.array,
click: () => onChangeType(path, 'Array')
},
{
text: 'Object',
className: 'jsoneditor-type-Object' + (type == 'Object' ? ' jsoneditor-selected' : ''),
className: 'jsoneditor-type-Object' + (type === 'Object' ? ' jsoneditor-selected' : ''),
title: TYPE_TITLES.object,
click: () => onChangeType(path, 'Object')
},
{
text: 'String',
className: 'jsoneditor-type-string' + (type == 'string' ? ' jsoneditor-selected' : ''),
className: 'jsoneditor-type-string' + (type === 'string' ? ' jsoneditor-selected' : ''),
title: TYPE_TITLES.string,
click: () => onChangeType(path, 'string')
}
@ -49,7 +49,7 @@ export function createChangeType (path, type, onChangeType) {
}
export function createSort (path, order, onSort) {
var direction = ((order == 'asc') ? 'desc': 'asc')
const direction = ((order === 'asc') ? 'desc': 'asc')
return {
text: 'Sort',
title: 'Sort the childs of this ' + TYPE_TITLES.type,
@ -166,4 +166,4 @@ export function createSeparator () {
return {
'type': 'separator'
}
}
}

View File

@ -1,4 +1,4 @@
import { selectContentEditable, getSelection as getDOMSelection } from '../../utils/domUtils'
import { selectContentEditable } from '../../utils/domUtils'
// singleton
let lastInputName = null