diff --git a/src/jsoneditor/actions.js b/src/jsoneditor/actions.js index b883cb2..1d5b344 100644 --- a/src/jsoneditor/actions.js +++ b/src/jsoneditor/actions.js @@ -2,9 +2,9 @@ import last from 'lodash/last' import initial from 'lodash/initial' import isEmpty from 'lodash/isEmpty' import first from 'lodash/first' -import { findRootPath } from './eson' +import { findRootPath, toJSON } from './eson' import { getIn } from './utils/immutabilityHelpers' -import { findUniqueName } from './utils/stringUtils' +import { duplicateInText, findUniqueName } from './utils/stringUtils' import { isObject, stringConvert } from './utils/typeUtils' import { compareAsc, compareDesc } from './utils/arrayUtils' import { compileJSONPointer, parseJSONPointer } from './jsonPointer' @@ -80,37 +80,63 @@ export function changeType (json, path, type) { * @return {Array} */ export function duplicate (json, selection) { - // console.log('duplicate', path) - if (isEmpty(selection.multi)) { - return [] - } + if (!isEmpty(selection.multi)) { + const rootPath = findRootPath(selection) + const root = getIn(json, rootPath) + const paths = selection.multi.map(parseJSONPointer) - const rootPath = findRootPath(selection) - const root = getIn(json, rootPath) - const paths = selection.multi.map(parseJSONPointer) + if (Array.isArray(root)) { + const lastPath = last(paths) + const offset = lastPath ? (parseInt(last(lastPath), 10) + 1) : 0 - if (Array.isArray(root)) { - const lastPath = last(paths) - const offset = lastPath ? (parseInt(last(lastPath), 10) + 1) : 0 - - return paths.map((path, index) => ({ - op: 'copy', - from: compileJSONPointer(path), - path: compileJSONPointer(rootPath.concat(index + offset)) - })) - } - else { // 'object' - return paths.map(path => { - const prop = last(path) - const newProp = findUniqueName(prop, root) - - return { + return paths.map((path, index) => ({ op: 'copy', from: compileJSONPointer(path), - path: compileJSONPointer(rootPath.concat(newProp)) - } - }) + path: compileJSONPointer(rootPath.concat(index + offset)) + })) + } + else { // 'object' + return paths.map(path => { + const prop = last(path) + const newProp = findUniqueName(prop, root) + + return { + op: 'copy', + from: compileJSONPointer(path), + path: compileJSONPointer(rootPath.concat(newProp)) + } + }) + } } + + if (selection.type === 'caret') { + if (selection.anchorOffset === selection.focusOffset) { + // no text selected -> duplicate the current node + return duplicate(json, { + type: 'multi', + multi: [selection.path] + }) + } + else { + // no text selected -> duplicate selected text + if (selection.input === 'property') { + const path = parseJSONPointer(selection.path) + const parentPath = initial(path) + const oldProperty = last(path) + const newProperty = duplicateInText(oldProperty, selection.anchorOffset, selection.focusOffset) + + return changeProperty(json, parentPath, oldProperty, newProperty) + } + else { // selection.input === 'value' + const oldValue = String(toJSON(getIn(json, parseJSONPointer(selection.path)))) + const newValue = duplicateInText(oldValue, selection.anchorOffset, selection.focusOffset) + + return changeValue(json, parseJSONPointer(selection.path), newValue) + } + } + } + + return [] } /** @@ -467,7 +493,7 @@ export function convertType (value, type) { /** * Extract the patched nodes and create a selection * @param {JSONPatchDocument} operations - * @return {Selection} + * @return {Selection | null} */ export function getSelectionFromPatch (operations) { const paths = operations @@ -483,5 +509,5 @@ export function getSelectionFromPatch (operations) { // TODO: after a remove, select after? - return { type: 'none' } + return null } diff --git a/src/jsoneditor/components/JSONNode.js b/src/jsoneditor/components/JSONNode.js index bf00592..40eb612 100644 --- a/src/jsoneditor/components/JSONNode.js +++ b/src/jsoneditor/components/JSONNode.js @@ -330,6 +330,7 @@ export default class JSONNode extends PureComponent { h('div', { key: 'property', className: 'jsoneditor-property' + emptyClassName + searchClassName, + 'data-input': 'property', contentEditable: 'true', suppressContentEditableWarning: true, spellCheck: 'false', @@ -386,6 +387,7 @@ export default class JSONNode extends PureComponent { key: 'value', className: JSONNode.getValueClass(type, itsAnUrl, isEmpty) + JSONNode.getSearchResultClass(searchResult), + 'data-input': 'value', contentEditable: 'true', suppressContentEditableWarning: true, spellCheck: 'false', diff --git a/src/jsoneditor/components/TreeMode.js b/src/jsoneditor/components/TreeMode.js index e059aed..b9ad3b0 100644 --- a/src/jsoneditor/components/TreeMode.js +++ b/src/jsoneditor/components/TreeMode.js @@ -51,6 +51,7 @@ import { KEY_BINDINGS } from '../constants' import { immutableJSONPatch } from '../immutableJSONPatch' import { applyErrors, + applySearch, applySelection, contentsFromPaths, expand, @@ -61,13 +62,12 @@ import { immutableESONPatch, nextSearchResult, previousSearchResult, - applySearch, SELECTION, syncEson } from '../eson' import TreeModeMenu from './menu/TreeModeMenu' import Search from './menu/Search' -import { findParentWithAttribute, toArray } from '../utils/domUtils' +import { findParentWithAttribute, hasAttribute, hasParent, toArray } from '../utils/domUtils' const AJV_OPTIONS = { allErrors: true, @@ -103,7 +103,7 @@ export default class TreeMode extends PureComponent { 'cut': this.handleKeyDownCut, 'copy': this.handleKeyDownCopy, 'paste': this.handleKeyDownPaste, - 'duplicate': this.handleKeyDownDuplicate, + 'duplicate': this.handleDuplicate, 'remove': this.handleKeyDownRemove, 'undo': this.handleUndo, 'redo': this.handleRedo, @@ -164,14 +164,19 @@ export default class TreeMode extends PureComponent { this.applyProps(nextProps, this.props) } - // TODO: use or cleanup - // componentDidMount () { - // document.addEventListener('keydown', this.handleKeyDown) - // } - // - // componentWillUnmount () { - // document.removeEventListener('keydown', this.handleKeyDown) - // } + componentDidMount () { + // TODO: use or cleanup + // document.addEventListener('keydown', this.handleKeyDown) + + document.addEventListener('selectionchange', this.handleSelectionChange) + } + + componentWillUnmount () { + // TODO: use or cleanup + // document.removeEventListener('keydown', this.handleKeyDown) + + document.removeEventListener('selectionchange', this.handleSelectionChange) + } // TODO: create some sort of watcher structure for these props? Is there a React pattern for that? applyProps (nextProps, currentProps) { @@ -362,6 +367,42 @@ export default class TreeMode extends PureComponent { } } + handleSelectionChange = () => { + // FIXME: selection change isn't supported by Safari. Create a fallback + + const selection = this.getCaretSelection() + + if (!isEqual(selection, this.state.selection)) { + this.setState({selection}) + console.log('selection', JSON.stringify(selection)) // TODO: cleanup logging + } + } + + getCaretSelection = () => { + const documentSelection = window.getSelection() + + if (documentSelection.anchorNode && + documentSelection.anchorNode === documentSelection.focusNode && + hasAttribute(documentSelection.anchorNode.parentNode, 'data-input')) { + + const path = this.findDataPathFromElement(documentSelection.anchorNode) + + if (hasParent(documentSelection.anchorNode, this.refs.contents) && path) { + return { + type: 'caret', + path: compileJSONPointer(path), + input: documentSelection.anchorNode.parentNode.getAttribute + ? documentSelection.anchorNode.parentNode.getAttribute('data-input') + : null, + anchorOffset: documentSelection.anchorOffset, + focusOffset: documentSelection.focusOffset, + } + } + } + + return null + } + handleChangeValue = ({path, value}) => { this.handlePatch(changeValue(this.state.eson, path, value)) } @@ -480,19 +521,7 @@ export default class TreeMode extends PureComponent { if (clipboard && clipboard.length > 0) { event.preventDefault() - const path = this.findDataPathFromElement(event.target) - this.handlePatch(insertBefore(eson, path, clipboard)) - } - } - - handleKeyDownDuplicate = (event) => { - const path = this.findDataPathFromElement(event.target) - if (path) { - const selection = { type: 'multi', multi: [path] } - this.handlePatch(duplicate(this.state.eson, selection)) - - // apply focus to the duplicated node - this.focusToNext(path) + this.handlePaste() } } @@ -561,6 +590,9 @@ export default class TreeMode extends PureComponent { else if (selection.type === 'before-childs') { this.handlePatch(insertInside(eson, parseJSONPointer(selection.beforeChildsOf), clipboard), true) } + else if (selection.type === 'caret') { + this.handlePatch(insertBefore(eson, parseJSONPointer(selection.path), clipboard), true) + } else { throw new Error(`Cannot paste at current selection ${JSON.stringify(selection)}`) } @@ -588,11 +620,13 @@ export default class TreeMode extends PureComponent { } else if (selection.after) { this.handlePatch(insertAfter(eson, parseJSONPointer(selection.after), clipboard), true) - } else if (selection.beforeChildsOf) { this.handlePatch(insertInside(eson, parseJSONPointer(selection.beforeChildsOf), clipboard), true) } + else if (selection.type === 'caret') { + this.handlePatch(insertBefore(eson, parseJSONPointer(selection.path), clipboard), true) + } else { throw new Error(`Cannot insert at current selection ${JSON.stringify(selection)}`) } @@ -844,19 +878,32 @@ export default class TreeMode extends PureComponent { return } - this.selectionStartPointer = this.findSelectionPointerFromEvent(event.target, event.clientY) + // don't start selecting nodes when selecting text inside the editable div of a property or value + if (!hasAttribute(event.target, 'data-input')) { + this.selectionStartPointer = this.findSelectionPointerFromEvent(event.target, event.clientY) - this.setState({ selection: null }) + this.setState({ selection: null }) + console.log('selection', JSON.stringify(null)) // TODO: cleanup logging + } + else { + this.selectionStartPointer = null + } } handlePan = (event) => { + if (!this.selectionStartPointer) { + return + } + this.selectionEndPointer = this.findSelectionPointerFromEvent(event.target, event.center.y) const selection = this.findSelectionFromPointers(this.selectionStartPointer, this.selectionEndPointer) - if (!isEqual(selection, this.state.selection)) { - this.setState({ selection }) - console.log('selection', JSON.stringify(selection)) // TODO: cleanup logging + if (isEqual(selection, this.state.selection)) { + return } + + this.setState({ selection }) + console.log('selection', JSON.stringify(selection)) // TODO: cleanup logging } handlePanEnd = (event) => { @@ -908,7 +955,7 @@ export default class TreeMode extends PureComponent { /** * @param {SelectionPointer} start * @param {SelectionPointer} end - * @return {Selection} + * @return {Selection | null} */ findSelectionFromPointers (start, end) { if (start && end) { @@ -971,10 +1018,6 @@ export default class TreeMode extends PureComponent { } } } - - return { - type: 'none' - } } /** @@ -1106,7 +1149,7 @@ export default class TreeMode extends PureComponent { const selectionBefore = this.state.selection const selectionAfter = selectChangedContents ? getSelectionFromPatch(operations) - : { type: 'none' } + : null const jsonResult = immutableJSONPatch(this.state.json, operations) const esonResult = immutableESONPatch(this.state.eson, operations) diff --git a/src/jsoneditor/components/menu/TreeModeMenu.js b/src/jsoneditor/components/menu/TreeModeMenu.js index cca60a9..0b11b28 100644 --- a/src/jsoneditor/components/menu/TreeModeMenu.js +++ b/src/jsoneditor/components/menu/TreeModeMenu.js @@ -61,8 +61,9 @@ export default class TreeModeMenu extends PureComponent { let items = [] const { selection, clipboard } = this.props - const hasCursor = selection && selection.type !== 'none' + const hasCursor = !!selection const hasSelectedContent = selection ? !isEmpty(selection.multi) : false + const hasCaret = selection && selection.type === 'caret' const hasClipboard = clipboard ? (clipboard.length > 0) : false // mode @@ -127,7 +128,7 @@ export default class TreeModeMenu extends PureComponent { key: 'duplicate', className: 'jsoneditor-duplicate', title: 'Duplicate current selection', - disabled: !hasSelectedContent, + disabled: !(hasSelectedContent || hasCaret), onClick: this.props.onDuplicate }, h('i', {className: 'fa fa-clone'})), h('button', { diff --git a/src/jsoneditor/types.js b/src/jsoneditor/types.js index c36d786..4eebbea 100644 --- a/src/jsoneditor/types.js +++ b/src/jsoneditor/types.js @@ -29,12 +29,27 @@ */ /** - * @typedef {{ - * type: 'multi' | 'after' | 'before-childs', 'none' - * after? string - * multi?: string[] - * beforeChildsOf?: string - * }} Selection + * @typedef { + * { + * type: 'multi', + * multi: string[] + * } | + * { + * type: 'after', + * after: string + * } | + * { + * type: 'before-childs', + * beforeChildsOf: string + * } | + * { + * type: 'caret', + * path: string, + * input: 'property' | 'value', + * anchorOffset: number, + * focusOffset: number + * } + * } Selection */ /** diff --git a/src/jsoneditor/utils/domUtils.js b/src/jsoneditor/utils/domUtils.js index 65ff05c..da1f8da 100644 --- a/src/jsoneditor/utils/domUtils.js +++ b/src/jsoneditor/utils/domUtils.js @@ -69,22 +69,6 @@ export function getInnerText (element, buffer) { return '' } -/** - * Get text selection - * http://stackoverflow.com/questions/4687808/contenteditable-selected-text-save-and-restore - * @return {Range | TextRange | null} range - */ -export function getSelection() { - if (window.getSelection) { - const sel = window.getSelection() - if (sel.getRangeAt && sel.rangeCount) { - return sel.getRangeAt(0) - } - } - - return null -} - /** * Select all text of a content editable div. * http://stackoverflow.com/a/3806004/1262753 @@ -151,10 +135,30 @@ export function findParentWithClassName (element, className) { return null } +/** + * Check whether a given element has given parent as parent somewhere in the tree up + * @param {Element} element + * @param {Element} parent + * @return {boolean} + */ +export function hasParent(element, parent) { + let e = element + + while (e) { + if (e === parent) { + return true + } + + e = e.parentNode + } + + return false +} + /** * Test whether a HTML element contains a specific className * @param {Element} element - * @param {boolean} className + * @param {string} className * @return {boolean} */ export function hasClassName (element, className) { @@ -163,6 +167,18 @@ export function hasClassName (element, className) { : false } +/** + * Test whether an element has a certain attribute + * @param {Element} element + * @param {string} attribute + * @returns {boolean} + */ +export function hasAttribute (element, attribute) { + return element && element.hasAttribute + ? element.hasAttribute(attribute) + : false +} + /** * Test whether the child rect fits completely inside the parent rect. * @param {ClientRect} parent diff --git a/src/jsoneditor/utils/stringUtils.js b/src/jsoneditor/utils/stringUtils.js index c8f5476..fd9a4d3 100644 --- a/src/jsoneditor/utils/stringUtils.js +++ b/src/jsoneditor/utils/stringUtils.js @@ -127,3 +127,20 @@ export function toCapital(text) { export function compareStrings (a, b) { return (a < b) ? -1 : (a > b) ? 1 : 0 } + + +/** + * Duplicate a piece of text + * @param {string} text + * @param {number} anchorOffset + * @param {number} focusOffset + * @return {string} + */ +export function duplicateInText(text, anchorOffset, focusOffset) { + const startOffset = Math.min(anchorOffset, focusOffset) + const endOffset = Math.max(anchorOffset, focusOffset) + + return text.slice(0, endOffset) + + text.slice(startOffset, endOffset) + // the duplicated piece of the text + text.slice(endOffset) +} diff --git a/src/jsoneditor/utils/stringUtils.test.js b/src/jsoneditor/utils/stringUtils.test.js index 4ef0b05..6d975f2 100644 --- a/src/jsoneditor/utils/stringUtils.test.js +++ b/src/jsoneditor/utils/stringUtils.test.js @@ -1,4 +1,4 @@ -import { escapeHTML, unescapeHTML, findUniqueName, toCapital, compareStrings } from './stringUtils' +import { compareStrings, duplicateInText, escapeHTML, findUniqueName, toCapital, unescapeHTML } from './stringUtils' test('escapeHTML', () => { expect(escapeHTML(' hello ')).toEqual('\u00A0\u00A0 hello \u00A0') @@ -40,3 +40,8 @@ test('compareStrings', () => { const array = ['b', 'c', 'a'] expect(array.sort(compareStrings)).toEqual(['a', 'b', 'c']) }) + +test('duplicateInText', () => { + expect(duplicateInText('abcdef', 2, 4)).toEqual('abcdcdef') + expect(duplicateInText('abcdef', 4, 2)).toEqual('abcdcdef') +})