From dd989f9a64ccb7dab8b7bee4d19381fbc0a54dce Mon Sep 17 00:00:00 2001 From: jos Date: Wed, 8 Nov 2017 13:13:01 +0100 Subject: [PATCH] Cut/Copy/Paste selection starting to work (WIP) --- src/actions.js | 123 ++++++++++++++++++++++- src/components/TreeMode.js | 148 ++++++++++++---------------- src/components/menu/FloatingMenu.js | 50 ++++++---- src/eson.js | 82 +++++++-------- src/patchEson.js | 5 +- 5 files changed, 262 insertions(+), 146 deletions(-) diff --git a/src/actions.js b/src/actions.js index 409779b..4c46a48 100644 --- a/src/actions.js +++ b/src/actions.js @@ -1,4 +1,9 @@ -import { compileJSONPointer, toEsonPath, esonToJson, findNextProp } from './eson' +import last from 'lodash/last' +import initial from 'lodash/initial' +import { + compileJSONPointer, toEsonPath, esonToJson, findNextProp, + pathsFromSelection, findRootPath, findSelectionIndices +} from './eson' import { findUniqueName } from './utils/stringUtils' import { getIn } from './utils/immutabilityHelpers' import { isObject, stringConvert } from './utils/typeUtils' @@ -168,6 +173,108 @@ export function insert (data, path, type) { } } +/** + * Create a JSONPatch for an insert action. + * + * This function needs the current data in order to be able to determine + * a unique property name for the inserted node in case of duplicating + * and object property + * + * @param {ESON} data + * @param {Path} path + * @param {Array.<{name?: string, value: JSONType, type?: ESONType}>} values + * @return {Array} + */ +export function insertBefore (data, path, values) { // TODO: find a better name and define datastructure for values + const parentPath = initial(path) + const esonPath = toEsonPath(data, parentPath) + const parent = getIn(data, esonPath) + + if (parent.type === 'Array') { + const startIndex = parseInt(last(path)) + return values.map((entry, offset) => ({ + op: 'add', + path: compileJSONPointer(parentPath.concat(startIndex + offset)), + value: entry.value, + jsoneditor: { + type: entry.type + } + })) + } + else { // object.type === 'Object' + const before = last(path) + return values.map(entry => { + const newProp = findUniqueName(entry.name, parent.props.map(p => p.name)) + return { + op: 'add', + path: compileJSONPointer(parentPath.concat(newProp)), + value: entry.value, + jsoneditor: { + type: entry.type, + before + } + } + }) + } +} + +/** + * Create a JSONPatch for an insert action. + * + * This function needs the current data in order to be able to determine + * a unique property name for the inserted node in case of duplicating + * and object property + * + * @param {ESON} data + * @param {Selection} selection + * @param {Array.<{name?: string, value: JSONType, type?: ESONType}>} values + * @return {Array} + */ +export function replace (data, selection, values) { // TODO: find a better name and define datastructure for values + + const rootPath = findRootPath(selection) + const start = selection.start.path[rootPath.length] + const end = selection.end.path[rootPath.length] + console.log('rootPath', rootPath, start, end) + const root = getIn(data, toEsonPath(data, rootPath)) + const { minIndex, maxIndex } = findSelectionIndices(root, start, end) + console.log('selection', minIndex, maxIndex) + + if (root.type === 'Array') { + const removeActions = removeAll(pathsFromSelection(data, selection)) + const insertActions = values.map((entry, offset) => ({ + op: 'add', + path: compileJSONPointer(rootPath.concat(minIndex + offset)), + value: entry.value, + jsoneditor: { + type: entry.type + } + })) + + return removeActions.concat(insertActions) + } + else { // object.type === 'Object' + const nextProp = root.props && root.props[maxIndex] + const before = nextProp ? nextProp.name : null + + const removeActions = removeAll(pathsFromSelection(data, selection)) + const insertActions = values.map(entry => { + const newProp = findUniqueName(entry.name, root.props.map(p => p.name)) + return { + op: 'add', + path: compileJSONPointer(rootPath.concat(newProp)), + value: entry.value, + jsoneditor: { + type: entry.type, + before + } + } + }) + + return removeActions.concat(insertActions) + } +} + /** * Create a JSONPatch for an append action. * @@ -214,6 +321,7 @@ export function append (data, parentPath, type) { /** * Create a JSONPatch for a remove action * @param {Path} path + * @return {ESONPatch} */ export function remove (path) { return [{ @@ -222,6 +330,19 @@ export function remove (path) { }] } +/** + * Create a JSONPatch for a multiple remove action + * @param {Path[]} paths + * @return {ESONPatch} + */ +export function removeAll (paths) { + return paths.map(path => ({ + op: 'remove', + path: compileJSONPointer(path) + })) +} +// TODO: test removeAll + /** * Create a JSONPatch to order the items of an array or the properties of an object in ascending * or descending order diff --git a/src/components/TreeMode.js b/src/components/TreeMode.js index d1f9e96..d301c45 100644 --- a/src/components/TreeMode.js +++ b/src/components/TreeMode.js @@ -22,8 +22,8 @@ import { } from '../eson' import { patchEson } from '../patchEson' import { - duplicate, insert, append, remove, - changeType, changeValue, changeProperty, sort + duplicate, insert, insertBefore, append, remove, removeAll, replace, + createEntry, changeType, changeValue, changeProperty, sort } from '../actions' import JSONNode from './JSONNode' import JSONNodeView from './JSONNodeView' @@ -90,14 +90,15 @@ export default class TreeMode extends Component { onChangeValue: this.handleChangeValue, onChangeType: this.handleChangeType, onInsert: this.handleInsert, + onInsertStructure: this.handleInsertStructure, onAppend: this.handleAppend, onDuplicate: this.handleDuplicate, onRemove: this.handleRemove, onSort: this.handleSort, - onCut: this.handleMenuCut, - onCopy: this.handleMenuCopy, - onPaste: this.handleMenuPaste, + onCut: this.handleCut, + onCopy: this.handleCopy, + onPaste: this.handlePaste, onExpand: this.handleExpand, @@ -117,7 +118,7 @@ export default class TreeMode extends Component { end: null, // ESONPointer }, - clipboard: null // array entries {prop: string, value: JSON} + clipboard: null // array entries {name: string, value: JSONType} } } @@ -129,6 +130,15 @@ export default class TreeMode extends Component { this.applyProps(nextProps, this.props) } + // TODO: use or cleanup + // componentDidMount () { + // document.addEventListener('keydown', this.handleKeyDown) + // } + // + // componentWillUnmount () { + // document.removeEventListener('keydown', this.handleKeyDown) + // } + // TODO: create some sort of watcher structure for these props? Is there a React pattern for that? applyProps (nextProps, currentProps) { // Apply text @@ -340,12 +350,21 @@ export default class TreeMode extends Component { } handleInsert = (path, type) => { - this.handlePatch(insert(this.state.data, path, type)) + this.handlePatch(insert(this.state.data, path, createEntry(type), type)) + + this.setState({ selection : null }) // TODO: select the inserted entry // apply focus to new node this.focusToNext(path) } + handleInsertStructure = (path) => { + // TODO: implement handleInsertStructure + console.log('handleInsertStructure', path) + alert('not yet implemented...') + + } + handleAppend = (parentPath, type) => { this.handlePatch(append(this.state.data, parentPath, type)) @@ -361,15 +380,24 @@ export default class TreeMode extends Component { } handleRemove = (path) => { - // apply focus to next sibling element if existing, else to the previous element - const fromElement = findNode(this.refs.contents, path) - const success = moveDownSibling(fromElement, 'property') - if (!success) { - moveUp(fromElement, 'property') - } + if (path) { + // apply focus to next sibling element if existing, else to the previous element + const fromElement = findNode(this.refs.contents, path) + const success = moveDownSibling(fromElement, 'property') + if (!success) { + moveUp(fromElement, 'property') + } - this.setState({ selection : null }) - this.handlePatch(remove(path)) + this.setState({ selection : null }) + this.handlePatch(remove(path)) + } + else if (this.state.selection) { + // remove selection + // TODO: select next property? (same as when removing a path?) + const paths = pathsFromSelection(this.state.data, this.state.selection) + this.setState({ selection: null }) + this.handlePatch(removeAll(paths)) + } } moveUp = (event) => { @@ -403,54 +431,32 @@ export default class TreeMode extends Component { } handleKeyDownCut = (event) => { - const { selection } = this.state - if (selection) { + if (this.state.selection) { event.preventDefault() } - this.handleCut(selection) + this.handleCut() } handleKeyDownCopy = (event) => { - const { selection } = this.state - if (selection) { + if (this.state.selection) { event.preventDefault() } - this.handleCopy(selection) + this.handleCopy() } handleKeyDownPaste = (event) => { - const { clipboard, selection } = this.state + const { clipboard, data } = this.state + if (clipboard && clipboard.length > 0) { event.preventDefault() - if (selection) { - this.handlePaste(clipboard, selection, null) - } - else { - // no selection -> paste after current path - const path = this.findDataPathFromElement(event.target) - this.handlePaste(clipboard, null, path) - } + + const path = this.findDataPathFromElement(event.target) + this.handlePatch(insertBefore(data, path, clipboard)) } } - handleMenuCut = (path) => { - const selection = { start: { path }, end: { path }} - this.handleCut(selection) - } - - handleMenuCopy = (path) => { - const selection = { start: { path }, end: { path }} - this.handleCopy(selection) - } - - handleMenuPaste = (path) => { - const { clipboard } = this.state - if (clipboard && clipboard.length > 0) { - this.handlePaste(clipboard, null, path) - } - } - - handleCut = (selection: ESONSelection) => { + handleCut = () => { + const selection = this.state.selection if (selection && selection.start && selection.end) { const data = this.state.data const paths = pathsFromSelection(data, selection) @@ -469,7 +475,8 @@ export default class TreeMode extends Component { } } - handleCopy = (selection: ESONSelection) => { + handleCopy = () => { + const selection = this.state.selection if (selection && selection.start && selection.end) { const data = this.state.data const paths = pathsFromSelection(data, selection) @@ -483,42 +490,12 @@ export default class TreeMode extends Component { } } - handlePaste = (clipboard, selection: ESONSelection, path: JSONPath) => { - const { data } = this.state + handlePaste = () => { + const { data, selection, clipboard } = this.state - if (clipboard && clipboard.length > 0) { + if (selection && clipboard && clipboard.length > 0) { // FIXME: handle pasting in an empty object or array - - if (path && path.length > 0) { - const parentPath = initial(path) - const parent = getIn(data, toEsonPath(data, parentPath)) - const isObject = parent.type === 'Object' - - if (parent.type === 'Object') { - const existingProps = parent.props.map(p => p.name) - const prop = last(path) - const patch = clipboard.map(entry => ({ - op: 'add', - path: compileJSONPointer(parentPath.concat(findUniqueName(entry.name, existingProps))), - value: entry.value, - jsoneditor: { before: prop } - })) - - this.handlePatch(patch) - } - else { // parent.type === 'Array' - const patch = clipboard.map(entry => ({ - op: 'add', - path: compileJSONPointer(path), - value: entry.value - })) - - this.handlePatch(patch) - } - } - else if (selection){ - console.log('TODO: replace selection') - } + this.handlePatch(replace(data, selection, clipboard)) } } @@ -673,7 +650,10 @@ export default class TreeMode extends Component { handleTouchStart = (event) => { const pointer = this.findESONPointerFromElement(event.target) - if (pointer) { + const clickedOnEmptySpace = (event.target.nodeName === 'DIV') && + (event.target.contentEditable !== 'true') + + if (clickedOnEmptySpace && pointer) { this.setState({ selection: {start: pointer, end: pointer}}) } else { diff --git a/src/components/menu/FloatingMenu.js b/src/components/menu/FloatingMenu.js index ae95b59..dad8869 100644 --- a/src/components/menu/FloatingMenu.js +++ b/src/components/menu/FloatingMenu.js @@ -6,13 +6,13 @@ import { keyComboFromEvent } from '../../utils/keyBindings' const MENU_CLASS_NAME = 'jsoneditor-floating-menu' const MENU_ITEM_CLASS_NAME = 'jsoneditor-floating-menu-item' -// Array: Sort | Map | Filter | Duplicate | Cut | Copy | Remove +// Array: Sort | Map | Filter | Duplicate | Cut | Copy | Paste | Remove // advanced sort (asc, desc, nested fields, custom comparator) // sort, map, filter, open a popup covering the editor (not the whole page) // (or if it's small, can be a dropdown) -// Object: Sort | Duplicate | Cut | Copy | Remove +// Object: Sort | Duplicate | Cut | Copy | Paste | Remove // simple sort (asc/desc) -// Value: [x] String | Duplicate | Cut | Copy | Remove +// Value: [x] String | Duplicate | Cut | Copy | Paste | Remove // String is a checkmark // Between: Insert Structure | Insert Value | Insert Object | Insert Array | Paste // inserting (value selected): [field] [value] @@ -81,52 +81,55 @@ const CREATE_TYPE = { remove: (path, events) => h('button', { key: 'remove', className: MENU_ITEM_CLASS_NAME, - onClick: () => events.onRemove(path), + onClick: () => events.onRemove(null), // do not pass path: we want to remove selection title: 'Remove' }, 'Remove'), insertStructure: (path, events) => h('button', { key: 'insertStructure', className: MENU_ITEM_CLASS_NAME, - // onClick: () => events.onRemove(path), + onClick: () => events.onInsertStructure(path), title: 'Insert a new object with the same data structure as the item above' }, 'Insert structure'), insertValue: (path, events) => h('button', { key: 'insertValue', className: MENU_ITEM_CLASS_NAME, - // onClick: () => events.onRemove(path), + onClick: () => events.onInsert(path, 'value'), title: 'Insert value' }, 'Insert value'), insertObject: (path, events) => h('button', { key: 'insertObject', className: MENU_ITEM_CLASS_NAME, - // onClick: () => events.onRemove(path), + onClick: () => events.onInsert(path, 'Object'), title: 'Insert Object' }, 'Insert Object'), insertArray: (path, events) => h('button', { key: 'insertArray', className: MENU_ITEM_CLASS_NAME, - // onClick: () => events.onRemove(path), + onClick: () => events.onInsert(path, 'Array'), title: 'Insert Array' }, 'Insert Array'), } export default class FloatingMenu extends PureComponent { - componentDidMount () { - setTimeout(() => { - const firstButton = this.refs.root && this.refs.root.querySelector('button') - if (firstButton) { - firstButton.focus() - } - }) - } + // TODO: use or cleanup + // componentDidMount () { + // setTimeout(() => { + // const firstButton = this.refs.root && this.refs.root.querySelector('button') + // // TODO: find a better way to ensure the JSONEditor has focus so the quickkeys work + // // console.log(document.activeElement) + // if (firstButton && document.activeElement === document.body) { + // firstButton.focus() + // } + // }) + // } render () { - return h('div', {ref: 'root', className: MENU_CLASS_NAME}, this.props.items.map(item => { + const items = this.props.items.map(item => { const type = typeof item === 'string' ? item : item.type const createType = CREATE_TYPE[type] if (createType) { @@ -135,6 +138,17 @@ export default class FloatingMenu extends PureComponent { else { throw new Error('Unknown type of menu item for floating menu: ' + JSON.stringify(item)) } - })) + }) + + return h('div', { + // ref: 'root', + className: MENU_CLASS_NAME, + onMouseDown: this.handleTouchStart, + onTouchStart: this.handleTouchStart, + }, items) + } + + handleTouchStart = (event) => { + event.stopPropagation() } } diff --git a/src/eson.js b/src/eson.js index 9e03cbf..cf00220 100644 --- a/src/eson.js +++ b/src/eson.js @@ -363,33 +363,23 @@ export function applySelection (eson: ESON, selection: ESONSelection) { } // find the parent node shared by both start and end of the selection - const rootPath = findSharedPath(selection.start.path, selection.end.path) + const rootPath = findRootPath(selection) const rootEsonPath = toEsonPath(eson, rootPath) - if (rootPath.length === selection.start.path.length || rootPath.length === selection.end.path.length) { - // select a single node - const selectionType = (selection.start.area === 'after') ? SELECTED_AFTER : SELECTED_END -console.log('selectionType', selectionType, selection) + return updateIn(eson, rootEsonPath, (root) => { + const start = selection.start.path[rootPath.length] + const end = selection.end.path[rootPath.length] + const { minIndex, maxIndex } = findSelectionIndices(root, start, end) + + const childsKey = (root.type === 'Object') ? 'props' : 'items' // property name of the array with props/items + const childsBefore = root[childsKey].slice(0, minIndex) + const childsUpdated = root[childsKey].slice(minIndex, maxIndex) + .map((child, index) => setIn(child, ['value', 'selected'], index === 0 ? SELECTED_END : SELECTED)) + const childsAfter = root[childsKey].slice(maxIndex) // FIXME: actually mark the end index as SELECTED_END, currently we select the first index - return setIn(eson, rootEsonPath.concat(['selected']), selectionType) - } - else { - // select multiple childs of an object or array - return updateIn(eson, rootEsonPath, (root) => { - const start = selection.start.path[rootPath.length] - const end = selection.end.path[rootPath.length] - const { minIndex, maxIndex } = findSelectionIndices(root, start, end) - const childsKey = (root.type === 'Object') ? 'props' : 'items' // property name of the array with props/items - const childsBefore = root[childsKey].slice(0, minIndex) - const childsUpdated = root[childsKey].slice(minIndex, maxIndex) - .map((child, index) => setIn(child, ['value', 'selected'], index === 0 ? SELECTED_END : SELECTED)) - const childsAfter = root[childsKey].slice(maxIndex) - // FIXME: actually mark the end index as SELECTED_END, currently we select the first index - - return setIn(root, [childsKey], childsBefore.concat(childsUpdated, childsAfter)) - }) - } + return setIn(root, [childsKey], childsBefore.concat(childsUpdated, childsAfter)) + }) } /** @@ -413,26 +403,19 @@ export function findSelectionIndices (root: ESON, start: string, end: string) : */ export function pathsFromSelection (eson: ESON, selection: ESONSelection): JSONPath[] { // find the parent node shared by both start and end of the selection - const rootPath = findSharedPath(selection.start.path, selection.end.path) + const rootPath = findRootPath(selection) const rootEsonPath = toEsonPath(eson, rootPath) - if (rootPath.length === selection.start.path.length || rootPath.length === selection.end.path.length) { - // select a single node - return [ rootPath ] - } - else { - // select multiple childs of an object or array - const root = getIn(eson, rootEsonPath) - const start = selection.start.path[rootPath.length] - const end = selection.end.path[rootPath.length] - const { minIndex, maxIndex } = findSelectionIndices(root, start, end) + const root = getIn(eson, rootEsonPath) + const start = selection.start.path[rootPath.length] + const end = selection.end.path[rootPath.length] + const { minIndex, maxIndex } = findSelectionIndices(root, start, end) - if (root.type === 'Object') { - return times(maxIndex - minIndex, i => rootPath.concat(root.props[i + minIndex].name)) - } - else { // root.type === 'Array' - return times(maxIndex - minIndex, i => rootPath.concat(String(i + minIndex))) - } + if (root.type === 'Object') { + return times(maxIndex - minIndex, i => rootPath.concat(root.props[i + minIndex].name)) + } + else { // root.type === 'Array' + return times(maxIndex - minIndex, i => rootPath.concat(String(i + minIndex))) } } @@ -448,10 +431,29 @@ export function contentsFromPaths (data: ESON, paths: JSONPath[]) { return { name: getIn(data, initial(esonPath).concat('name')) || String(esonPath[esonPath.length - 2]), value: esonToJson(getIn(data, esonPath)) + // FIXME: also store the type and expanded state } }) } +/** + * Find the root path of a selection: the parent node shared by both start + * and end of the selection + * @param {Selection} selection + * @return {JSONPath} + */ +export function findRootPath(selection) { + const sharedPath = findSharedPath(selection.start.path, selection.end.path) + + if (sharedPath.length === selection.start.path.length && + sharedPath.length === selection.end.path.length) { + // there is just one node selected, return it's parent + return initial(sharedPath) + } + else { + return sharedPath + } +} /** * Find the common path of two paths. diff --git a/src/patchEson.js b/src/patchEson.js index d15f1c3..9f2724d 100644 --- a/src/patchEson.js +++ b/src/patchEson.js @@ -1,13 +1,12 @@ import isEqual from 'lodash/isEqual' import initial from 'lodash/initial' -import type { ESON, Path, ESONPatch, ESONPatchOptions, ESONPatchResult, ESONSelection } from './types' +import type { ESON, Path, ESONPatch } from './types' import { setIn, updateIn, getIn, deleteIn, insertAt } from './utils/immutabilityHelpers' import { jsonToEson, esonToJson, toEsonPath, parseJSONPointer, compileJSONPointer, - expandAll, pathExists, resolvePathIndex, findPropertyIndex, findNextProp, getId, - pathsFromSelection + expandAll, pathExists, resolvePathIndex, findPropertyIndex, findNextProp, getId } from './eson' /**