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 { createElement as h, Component } from 'react'
import ActionButton from './menu/ActionButton' import ActionMenu from './menu/ActionMenu'
import AppendActionButton from './menu/AppendActionButton'
import { escapeHTML, unescapeHTML } from '../utils/stringUtils' 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 { stringConvert, valueType, isUrl } from '../utils/typeUtils'
import { compileJSONPointer } from '../jsonData' import { compileJSONPointer } from '../jsonData'
import type { PropertyData, JSONData, SearchResultStatus } from '../types' 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 { export default class JSONNode extends Component {
static URL_TITLE = 'Ctrl+Click or Ctrl+Enter to open url' static URL_TITLE = 'Ctrl+Click or Ctrl+Enter to open url'
state = { state = {
menu: null, // context menu menu: null, // can contain object {anchor, root}
appendMenu: null, // append context menu (used in placeholder of empty object/array) appendMenu: null, // can contain object {anchor, root}
} }
render () { render () {
@ -48,6 +41,7 @@ export default class JSONNode extends Component {
className: 'jsoneditor-node jsoneditor-object' className: 'jsoneditor-node jsoneditor-object'
}, [ }, [
this.renderExpandButton(), this.renderExpandButton(),
this.renderActionMenu('update', this.state.menu, this.handleCloseActionMenu),
this.renderActionMenuButton(), this.renderActionMenuButton(),
this.renderProperty(prop, index, data, options), this.renderProperty(prop, index, data, options),
this.renderReadonly(`{${childCount}}`, `Array containing ${childCount} items`), this.renderReadonly(`{${childCount}}`, `Array containing ${childCount} items`),
@ -93,6 +87,7 @@ export default class JSONNode extends Component {
className: 'jsoneditor-node jsoneditor-array' className: 'jsoneditor-node jsoneditor-array'
}, [ }, [
this.renderExpandButton(), this.renderExpandButton(),
this.renderActionMenu('update', this.state.menu, this.handleCloseActionMenu),
this.renderActionMenuButton(), this.renderActionMenuButton(),
this.renderProperty(prop, index, data, options), this.renderProperty(prop, index, data, options),
this.renderReadonly(`[${childCount}]`, `Array containing ${childCount} items`), this.renderReadonly(`[${childCount}]`, `Array containing ${childCount} items`),
@ -134,6 +129,7 @@ export default class JSONNode extends Component {
className: 'jsoneditor-node' className: 'jsoneditor-node'
}, [ }, [
this.renderPlaceholder(), this.renderPlaceholder(),
this.renderActionMenu('update', this.state.menu, this.handleCloseActionMenu),
this.renderActionMenuButton(), this.renderActionMenuButton(),
this.renderProperty(prop, index, data, options), this.renderProperty(prop, index, data, options),
this.renderSeparator(), this.renderSeparator(),
@ -154,7 +150,8 @@ export default class JSONNode extends Component {
onKeyDown: this.handleKeyDownAppend onKeyDown: this.handleKeyDownAppend
}, [ }, [
this.renderPlaceholder(), this.renderPlaceholder(),
this.renderAppendMenuButton(), this.renderActionMenu('append', this.state.appendMenu, this.handleCloseAppendActionMenu),
this.renderAppendActionMenuButton(),
this.renderReadonly(text) this.renderReadonly(text)
]) ])
} }
@ -167,6 +164,7 @@ export default class JSONNode extends Component {
return h('div', {key: 'readonly', className: 'jsoneditor-readonly', title}, text) 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}) { renderProperty (prop: ?PropertyData, index: ?number, data: JSONData, options: {escapeUnicode: boolean, isPropertyEditable: (path: string) => boolean}) {
const isIndex = typeof index === 'number' const isIndex = typeof index === 'number'
@ -368,23 +366,89 @@ export default class JSONNode extends Component {
) )
} }
renderActionMenuButton () { // TODO: simplify code for the two action menus
return h(ActionButton, { renderActionMenu (menuType, menuState, onClose) {
key: 'action', if (!menuState) {
return null
}
return h(ActionMenu, {
key: 'menu',
path: this.props.path, path: this.props.path,
events: this.props.events,
type: this.props.data.type, type: this.props.data.type,
events: this.props.events
menuType,
open: true,
anchor: menuState.anchor,
root: menuState.root,
onRequestClose: onClose
}) })
} }
renderAppendMenuButton () { renderActionMenuButton () {
return h(AppendActionButton, { const className = 'jsoneditor-button jsoneditor-actionmenu' +
key: 'append', ((this.state.open) ? ' jsoneditor-visible' : '')
path: this.props.path,
events: this.props.events 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) { shouldComponentUpdate (nextProps, nextState) {
let prop let prop
@ -456,6 +520,18 @@ export default class JSONNode extends Component {
event.preventDefault() event.preventDefault()
this.props.events.onRemove(this.props.path) 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 */ /** @private */
@ -466,6 +542,11 @@ export default class JSONNode extends Component {
event.preventDefault() event.preventDefault()
this.props.events.onAppend(this.props.path, 'value') this.props.events.onAppend(this.props.path, 'value')
} }
if (keyBinding === 'actionMenu') {
event.preventDefault()
this.handleOpenAppendActionMenu(event)
}
} }
/** @private */ /** @private */
@ -485,20 +566,6 @@ export default class JSONNode extends Component {
this.props.events.onExpand(this.props.path, expanded, recurse) 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 * When this JSONNode holds an URL as value, open this URL in a new browser tab
* @param event * @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 MAX_HISTORY_ITEMS = 1000 // maximum number of undo/redo items to be kept in memory
const SEARCH_DEBOUNCE = 300 // milliseconds 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 { export default class TreeMode extends Component {
id: number id: number
state: Object state: Object
@ -49,30 +71,6 @@ export default class TreeMode extends Component {
this.id = Math.round(Math.random() * 1e5) // TODO: create a uuid here? 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 = { this.state = {
data, data,
@ -100,7 +98,7 @@ export default class TreeMode extends Component {
active: null // active search result 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', key: 'contents',
ref: 'contents', ref: 'contents',
className: 'jsoneditor-contents jsoneditor-tree-contents', className: 'jsoneditor-contents jsoneditor-tree-contents',
onClick: this.handleHideMenus, id: this.id id: this.id
}, },
h('ul', {className: 'jsoneditor-list jsoneditor-root'}, h('ul', {className: 'jsoneditor-list jsoneditor-root'},
h(Node, { h(Node, {
@ -322,11 +320,6 @@ export default class TreeMode extends Component {
} }
} }
/** @private */
handleHideMenus = () => {
JSONNode.hideActionMenu()
}
/** @private */ /** @private */
handleChangeValue = (path, value) => { handleChangeValue = (path, value) => {
this.handlePatch(changeValue(this.state.data, 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 { import {
createChangeType, createSort, createChangeType, createSort,
createSeparator, createSeparator,
createInsert, createDuplicate, createRemove createInsert, createAppend, createDuplicate, createRemove
} from './entries' } from './items'
export default class ActionMenu extends Component { export default class ActionMenu extends Component {
/** /**
* @param {{open, anchor, root, path, type, events, onRequestClose}} props * props = {open, anchor, root, path, type, menuType, events, onRequestClose}
* @param state
* @return {JSX.Element}
*/ */
render () { 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 let items = [] // array with menu items
@ -33,11 +42,12 @@ export default class ActionMenu extends Component {
items.push(createRemove(props.path, props.events.onRemove)) items.push(createRemove(props.path, props.events.onRemove))
} }
// TODO: implement a hook to adjust the action menu return items
}
return h(Menu, { createAppendMenuItems () {
...props, return [
items 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 let CONTEXT_MENU_HEIGHT = 240
export default class Menu extends Component { export default class Menu extends Component {
/**
* @param {{open: boolean, items: Array, anchor, root, onRequestClose: function}} props
*/
constructor(props) { constructor(props) {
super(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 () { render () {
if (!this.props.open) { if (!this.props.open) {
return null return null
@ -159,7 +158,8 @@ export default class Menu extends Component {
} }
componentWillUnmount () { componentWillUnmount () {
this.removeRequestCloseListener() // remove on next tick, since a listener can be created on next tick too
setTimeout(() => this.removeRequestCloseListener())
} }
updateRequestCloseListener () { updateRequestCloseListener () {
@ -172,18 +172,14 @@ export default class Menu extends Component {
} }
addRequestCloseListener () { addRequestCloseListener () {
if (!this.handleRequestClose) { // Attach event listener on next tick, else the current click to open
// Attach event listener on next tick, else the current click to open // the menu will immediately result in requestClose event as well
// the menu will immediately result in requestClose event as well setTimeout(() => {
setTimeout(() => { if (this.props.open && !this.handleRequestClose) {
this.handleRequestClose = (event) => { this.handleRequestClose = (event) => this.props.onRequestClose()
if (!findParentNode(event.target, 'data-menu', 'true')) {
this.props.onRequestClose()
}
}
window.addEventListener('click', this.handleRequestClose) window.addEventListener('click', this.handleRequestClose)
}, 0) }
} })
} }
removeRequestCloseListener () { removeRequestCloseListener () {

View File

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

View File

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