diff --git a/src/components/JSONNode.js b/src/components/JSONNode.js index cdf970c..2da2b04 100644 --- a/src/components/JSONNode.js +++ b/src/components/JSONNode.js @@ -149,6 +149,7 @@ export default class JSONNode extends Component { */ renderAppend (text) { return h('div', { + name: compileJSONPointer(this.props.path) + '/#', className: 'jsoneditor-node', onKeyDown: this.handleKeyDownAppend }, [ @@ -506,52 +507,6 @@ export default class JSONNode extends Component { this.props.events.onExpand(this.props.path, expanded, recurse) } - /** @private */ - handleContextMenu = (event) => { - event.stopPropagation() - - if (this.state.menu) { - // hide context menu - JSONNode.hideActionMenu() - } - else { - // hide any currently visible context menu - JSONNode.hideActionMenu() - - // show context menu - this.setState({ - menu: { - anchor: event.target, - root: JSONNode.findRootElement(event) - } - }) - activeContextMenu = this - } - } - - /** @private */ - handleAppendContextMenu = (event) => { - event.stopPropagation() - - if (this.state.appendMenu) { - // hide append context menu - JSONNode.hideActionMenu() - } - else { - // hide any currently visible context menu - JSONNode.hideActionMenu() - - // show append context menu - this.setState({ - appendMenu: { - anchor: event.target, - root: JSONNode.findRootElement(event) - } - }) - activeContextMenu = this - } - } - /** * Singleton function to hide the currently visible context menu if any. * @protected diff --git a/src/components/TreeMode.js b/src/components/TreeMode.js index c1ffc3f..672f953 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 { moveUp, moveDown, moveLeft, moveRight } from './util/domSelector' import { keyComboFromEvent } from '../utils/keyBindings' import type { JSONData, JSONPatch } from '../types' @@ -51,7 +52,23 @@ export default class TreeMode extends Component { 'duplicate': ['Ctrl+D', 'Command+D'], 'insert': ['Ctrl+Insert', 'Command+Insert'], 'remove': ['Ctrl+Delete', 'Command+Delete'], - 'openUrl': ['Ctrl+4', 'Ctrl+Enter', 'Command+Enter'] + '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 = { @@ -147,9 +164,7 @@ 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))) - }, + 'onKeyDown': this.handleKeyDown, 'data-jsoneditor': 'true' }, [ this.renderMenu(searchResults ? searchResults.length : null), @@ -281,6 +296,30 @@ export default class TreeMode extends Component { return [] } + handleKeyDown = (event) => { + const keyBinding = this.findKeyBinding(event) + + if (keyBinding === 'up') { + event.preventDefault() + moveUp(event.target) + } + + if (keyBinding === 'down') { + event.preventDefault() + moveDown(event.target) + } + + if (keyBinding === 'left') { + event.preventDefault() + moveLeft(event.target) + } + + if (keyBinding === 'right') { + event.preventDefault() + moveRight(event.target) + } + } + /** @private */ handleHideMenus = () => { JSONNode.hideActionMenu() diff --git a/src/components/util/domSelector.js b/src/components/util/domSelector.js new file mode 100644 index 0000000..953b8ed --- /dev/null +++ b/src/components/util/domSelector.js @@ -0,0 +1,220 @@ +import { selectContentEditable } from '../../utils/domUtils' + +// singleton +let lastInputName = null + +/** + * Move the selection to the input field above current selected input + * Heavily relies on classNames of the JSONEditor DOM + * @param {Element} fromElement + */ +export function moveUp (fromElement) { + const prev = findPreviousNode(fromElement) + if (prev) { + if (!lastInputName) { + lastInputName = getInputName(fromElement) + } + + const container = findContainer(fromElement) + setSelection(container, prev.getAttribute('name'), lastInputName) + } +} + +/** + * Move the selection to the input field below current selected input + * Heavily relies on classNames of the JSONEditor DOM + * @param {Element} fromElement + */ +export function moveDown (fromElement) { + const prev = findNextNode(fromElement) + if (prev) { + if (!lastInputName) { + lastInputName = getInputName(fromElement) + } + + const container = findContainer(fromElement) + setSelection(container, prev.getAttribute('name'), lastInputName) + } +} + +/** + * Move the selection to the input field left from current selected input + * Heavily relies on classNames of the JSONEditor DOM + * @param {Element} fromElement + */ +export function moveLeft (fromElement) { + const container = findContainer(fromElement) + const node = findNode(fromElement, 'jsoneditor-node') + const inputName = getInputName(fromElement) + lastInputName = findInput(node, inputName, 'left') + setSelection(container, node.getAttribute('name'), lastInputName) +} + +/** + * Move the selection to the input field right from current selected input + * Heavily relies on classNames of the JSONEditor DOM + * @param {Element} fromElement + */ +export function moveRight (fromElement) { + const container = findContainer(fromElement) + const node = findNode(fromElement, 'jsoneditor-node') + const inputName = getInputName(fromElement) + lastInputName = findInput(node, inputName, 'right') + setSelection(container, node.getAttribute('name'), lastInputName) +} + +/** + * Set selection to a specific node and input field + * @param {Element} container + * @param {JSONPointer} path + * @param {string} inputName + */ +export function setSelection (container, path, inputName) { + const node = container.querySelector(`div[name="${path}"]`) + if (node) { + const closestInputName = findInput(node, inputName, 'closest') + const element = findInputName(node, closestInputName) + if (element) { + element.focus() + if (element.nodeName === 'DIV') { + selectContentEditable(element) + } + } + } +} + +function findContainer (element) { + return findNode (element, 'jsoneditor-tree-contents') +} + +/** + * Find the base element of a node from one of it's childs + * @param {Element} element + * @param {string} className + * @return {Element} Returns the base element of the node + */ +function findNode (element, className) { + let e = element + do { + if (e && e.className.includes(className)) { + return e + } + + e = e.parentNode + } + while (e) + + return null +} + +function findPreviousNode (element) { + const container = findContainer(element) + const node = findNode(element, 'jsoneditor-node') + + // TODO: implement a faster way to find the previous node, by walking the DOM tree back, instead of a slow find all query + const all = Array.from(container.querySelectorAll('div.jsoneditor-node')) + const index = all.indexOf(node) + + return all[index - 1] +} + +function findNextNode (element) { + const container = findContainer(element) + const node = findNode(element, 'jsoneditor-node') + + // TODO: implement a faster way to find the previous node, by walking the DOM tree, instead of a slow find all query + const all = Array.from(container.querySelectorAll('div.jsoneditor-node')) + const index = all.indexOf(node) + + return all[index + 1] +} + +/** + * Get the input name of an element + * @param {Element} element + * @return {'property' | 'value' | 'action' | 'expand' | null} + */ +function getInputName (element) { + if (element.className.includes('jsoneditor-property')) { + return 'property' + } + + if (element.className.includes('jsoneditor-value')) { + return 'value' + } + + if (element.className.includes('jsoneditor-actionmenu')) { + return 'action' + } + + if (element.className.includes('jsoneditor-expanded') || + element.className.includes('jsoneditor-collapsed')) { + return 'expand' + } + + return null +} + +function findInputName (node, name) { + if (node) { + if (name === 'property') { + const div = node.querySelector('.jsoneditor-property') + return (div && div.contentEditable === 'true') ? div : null + } + + if (name === 'value') { + const div = node.querySelector('.jsoneditor-value') + return (div && div.contentEditable === 'true') ? div : null + } + + if (name === 'action') { + return node.querySelector('.jsoneditor-actionmenu') + } + + if (name === 'expand') { + return node.querySelector('.jsoneditor-expanded') || node.querySelector('.jsoneditor-collapsed') + } + } + + return null +} + +/** + * find the closest input that actually exists in this node + * @param {Element} node + * @param {string} inputName + * @param {'closest' | 'left' | 'right'} [rule] + * @return {Element} + */ +function findInput (node, inputName, rule = 'closest') { + const inputNames = INPUT_NAME_RULES[rule][inputName] + if (inputNames) { + return inputNames.find(name => { + return findInputName(node, name) + }) + } + + return null +} + +const INPUT_NAME_RULES = { + closest: { + 'property': ['property', 'value', 'action', 'expand'], + 'value': ['value', 'property', 'action', 'expand'], + 'action': ['action', 'expand', 'property', 'value'], + 'expand': ['expand', 'action', 'property', 'value'], + }, + left: { + 'property': ['action', 'expand'], + 'value': ['property', 'action', 'expand'], + 'action': ['expand'], + 'expand': [], + }, + right: { + 'property': ['value'], + 'value': [], + 'action': ['property', 'value'], + 'expand': ['action', 'property', 'value'], + } +} + diff --git a/src/utils/domUtils.js b/src/utils/domUtils.js index daf3fa9..be8e9ac 100644 --- a/src/utils/domUtils.js +++ b/src/utils/domUtils.js @@ -70,6 +70,24 @@ export function getInnerText (element, buffer) { } +/** + * Select all text of a content editable div. + * http://stackoverflow.com/a/3806004/1262753 + * @param {Element} contentEditableElement A content editable div + */ +export function selectContentEditable(contentEditableElement) { + if (!contentEditableElement || contentEditableElement.nodeName !== 'DIV') { + return + } + + if (window.getSelection && document.createRange) { + const range = document.createRange(); + range.selectNodeContents(contentEditableElement) + const sel = window.getSelection() + sel.removeAllRanges() + sel.addRange(range) + } +} /** * Find the parent node of an element which has an attribute with given value.