diff --git a/src/components/JSONNode.js b/src/components/JSONNode.js index f642497..cdf970c 100644 --- a/src/components/JSONNode.js +++ b/src/components/JSONNode.js @@ -41,7 +41,12 @@ export default class JSONNode extends Component { renderJSONObject ({prop, index, data, options, events}) { const childCount = data.props.length - const node = h('div', {name: compileJSONPointer(this.props.path), key: 'node', className: 'jsoneditor-node jsoneditor-object'}, [ + const node = h('div', { + name: compileJSONPointer(this.props.path), + onKeyDown: this.handleKeyDown, + key: 'node', + className: 'jsoneditor-node jsoneditor-object' + }, [ this.renderExpandButton(), this.renderActionMenuButton(), this.renderProperty(prop, index, data, options), @@ -81,7 +86,12 @@ export default class JSONNode extends Component { // TODO: extract a function renderChilds shared by both renderJSONObject and renderJSONArray (rename .props and .items to .childs?) renderJSONArray ({prop, index, data, options, events}) { const childCount = data.items.length - const node = h('div', {name: compileJSONPointer(this.props.path), key: 'node', className: 'jsoneditor-node jsoneditor-array'}, [ + const node = h('div', { + name: compileJSONPointer(this.props.path), + onKeyDown: this.handleKeyDown, + key: 'node', + className: 'jsoneditor-node jsoneditor-array' + }, [ this.renderExpandButton(), this.renderActionMenuButton(), this.renderProperty(prop, index, data, options), @@ -118,7 +128,11 @@ export default class JSONNode extends Component { } renderJSONValue ({prop, index, data, options}) { - return h('div', {name: compileJSONPointer(this.props.path), className: 'jsoneditor-node'}, [ + return h('div', { + name: compileJSONPointer(this.props.path), + onKeyDown: this.handleKeyDown, + className: 'jsoneditor-node' + }, [ this.renderPlaceholder(), this.renderActionMenuButton(), this.renderProperty(prop, index, data, options), @@ -134,7 +148,10 @@ export default class JSONNode extends Component { * @return {*} */ renderAppend (text) { - return h('div', {className: 'jsoneditor-node'}, [ + return h('div', { + className: 'jsoneditor-node', + onKeyDown: this.handleKeyDownAppend + }, [ this.renderPlaceholder(), this.renderAppendMenuButton(), this.renderReadonly(text) @@ -149,7 +166,7 @@ export default class JSONNode extends Component { return h('div', {key: 'readonly', className: 'jsoneditor-readonly', title}, text) } - renderProperty (prop: ?PropertyData, index: ?number, data: JSONData, options: {escapeUnicode: boolean, isPropertyEditable: (Path) => boolean}) { + renderProperty (prop: ?PropertyData, index: ?number, data: JSONData, options: {escapeUnicode: boolean, isPropertyEditable: (path: string) => boolean}) { const isIndex = typeof index === 'number' if (!prop && !isIndex) { @@ -442,9 +459,41 @@ export default class JSONNode extends Component { } } + /** @private */ + handleKeyDown = (event) => { + const keyBinding = this.props.events.findKeyBinding(event) + + if (keyBinding === 'duplicate') { + event.preventDefault() + this.props.events.onDuplicate(this.props.path) + } + + if (keyBinding === 'insert') { + event.preventDefault() + this.props.events.onInsert(this.props.path, 'value') + } + + if (keyBinding === 'remove') { + event.preventDefault() + this.props.events.onRemove(this.props.path) + } + } + + /** @private */ + handleKeyDownAppend = (event) => { + const keyBinding = this.props.events.findKeyBinding(event) + + if (keyBinding === 'insert') { + event.preventDefault() + this.props.events.onAppend(this.props.path, 'value') + } + } + /** @private */ handleKeyDownValue = (event) => { - if (event.ctrlKey && event.which === 13) { // Ctrl+Enter + const keyBinding = this.props.events.findKeyBinding(event) + + if (keyBinding === 'openUrl') { this.openLinkIfUrl(event) } } diff --git a/src/components/TreeMode.js b/src/components/TreeMode.js index 46344c5..c1ffc3f 100644 --- a/src/components/TreeMode.js +++ b/src/components/TreeMode.js @@ -22,6 +22,7 @@ import JSONNodeView from './JSONNodeView' import JSONNodeForm from './JSONNodeForm' import ModeButton from './menu/ModeButton' import Search from './menu/Search' +import { keyComboFromEvent } from '../utils/keyBindings' import type { JSONData, JSONPatch } from '../types' @@ -45,6 +46,14 @@ 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'], + 'openUrl': ['Ctrl+4', 'Ctrl+Enter', 'Command+Enter'] + } + this.state = { data, @@ -61,13 +70,18 @@ export default class TreeMode extends Component { onRemove: this.handleRemove, onSort: this.handleSort, - onExpand: this.handleExpand + onExpand: this.handleExpand, + + // TODO: now we're passing not just events but also other methods. reorganize this or rename 'state.events' + findKeyBinding: this.findKeyBinding }, search: { text: '', active: null // active search result - } + }, + + keyCombos: this.bindingsByCombos (keyBindings) } } @@ -133,6 +147,9 @@ export default class TreeMode extends Component { return h('div', { className: `jsoneditor jsoneditor-mode-${props.mode}`, + 'onKeyDown': (event) => { + // console.log('keydown', keyComboFromEvent(event), this.findKeyBinding(keyComboFromEvent(event))) + }, 'data-jsoneditor': 'true' }, [ this.renderMenu(searchResults ? searchResults.length : null), @@ -226,6 +243,27 @@ export default class TreeMode extends Component { return h('div', {key: 'menu', className: 'jsoneditor-menu'}, items) } + /** + * Turn a map with key bindings by name into a map by combo + * @param {Object.} keyBindings + * @return {Object.} Returns keyCombos + */ + bindingsByCombos (keyBindings) { + const keyCombos = {} + + Object.keys(keyBindings).forEach ((name) => { + keyBindings[name].forEach(combo => keyCombos[combo.toUpperCase()] = name) + }) + + return keyCombos + } + + findKeyBinding = (event) => { + const keyCombo = keyComboFromEvent(event) + + return this.state.keyCombos[keyCombo.toUpperCase()] || null + } + /** * Validate the JSON against the configured JSON schema * Returns an array with the errors when not valid, returns an empty array @@ -266,11 +304,13 @@ export default class TreeMode extends Component { /** @private */ handleInsert = (path, type) => { this.handlePatch(insert(this.state.data, path, type)) + // FIXME: apply focus to new field } /** @private */ handleAppend = (parentPath, type) => { this.handlePatch(append(this.state.data, parentPath, type)) + // FIXME: apply focus to new field } /** @private */ @@ -281,6 +321,7 @@ export default class TreeMode extends Component { /** @private */ handleRemove = (path) => { this.handlePatch(remove(path)) + // FIXME: apply focus next/prev field } /** @private */ diff --git a/src/utils/keyBindings.js b/src/utils/keyBindings.js new file mode 100644 index 0000000..f3d9b23 --- /dev/null +++ b/src/utils/keyBindings.js @@ -0,0 +1,146 @@ +// inspiration: https://github.com/andrepolischuk/keycomb + +/** + * Get a named key from a key code. + * For example: + * keyFromCode(65) returns 'A' + * keyFromCode(13) returns 'Enter' + * @param {string} code + * @return {string} + */ +export function nameFromKeyCode(code) { + return codes[code] || '' +} + +/** + * Get the active key combination from a keyboard event. + * For example returns "Ctrl+Shift+Up" or "Ctrl+A" + * @param {KeyboardEvent} event + * @return {string} + */ +export function keyComboFromEvent (event) { + let combi = [] + + if (event.ctrlKey) { combi.push('Ctrl') } + if (event.metaKey) { combi.push('Command') } + if (event.altKey) { combi.push(isMac ? 'Option' : 'Alt') } + if (event.shiftKey) { combi.push('Shift') } + + const keyName = nameFromKeyCode(event.which) + if (!metaCodes[keyName]) { // prevent output like 'Ctrl+Ctrl' + combi.push(keyName) + } + + return combi.join('+') +} + +const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0 + +const metaCodes = { + 'Ctrl': true, + 'Command': true, + 'Alt': true, + 'Option': true, + 'Shift': true +} + +const codes = { + '8': 'Backspace', + '9': 'Tab', + '13': 'Enter', + '16': 'Shift', + '17': 'Ctrl', + '18': 'Alt', + '19': 'Pause_Break', + '20': 'Caps_Lock', + '27': 'Escape', + '33': 'Page_Up', + '34': 'Page_Down', + '35': 'End', + '36': 'Home', + '37': 'Left', + '38': 'Up', + '39': 'Right', + '40': 'Down', + '45': 'Insert', + '46': 'Delete', + '48': '0', + '49': '1', + '50': '2', + '51': '3', + '52': '4', + '53': '5', + '54': '6', + '55': '7', + '56': '8', + '57': '9', + '65': 'A', + '66': 'B', + '67': 'C', + '68': 'D', + '69': 'E', + '70': 'F', + '71': 'G', + '72': 'H', + '73': 'I', + '74': 'J', + '75': 'K', + '76': 'L', + '77': 'M', + '78': 'N', + '79': 'O', + '80': 'P', + '81': 'Q', + '82': 'R', + '83': 'S', + '84': 'T', + '85': 'U', + '86': 'V', + '87': 'W', + '88': 'X', + '89': 'Y', + '90': 'Z', + '91': 'Left_Window_Key', + '92': 'Right_Window_Key', + '93': 'Select_Key', + '96': 'Numpad_0', + '97': 'Numpad_1', + '98': 'Numpad_2', + '99': 'Numpad_3', + '100': 'Numpad_4', + '101': 'Numpad_5', + '102': 'Numpad_6', + '103': 'Numpad_7', + '104': 'Numpad_8', + '105': 'Numpad_9', + '106': 'Numpad_*', + '107': 'Numpad_+', + '109': 'Numpad_-', + '110': 'Numpad_.', + '111': 'Numpad_/', + '112': 'F1', + '113': 'F2', + '114': 'F3', + '115': 'F4', + '116': 'F5', + '117': 'F6', + '118': 'F7', + '119': 'F8', + '120': 'F9', + '121': 'F10', + '122': 'F11', + '123': 'F12', + '144': 'Num_Lock', + '145': 'Scroll_Lock', + '186': ';', + '187': '=', + '188': ',', + '189': '-', + '190': '.', + '191': '/', + '192': '`', + '219': '[', + '220': '\\', + '221': ']', + '222': '\'' +}