Cut/Copy/Paste selection starting to work (WIP)

This commit is contained in:
jos 2017-11-08 13:13:01 +01:00
parent 8425579718
commit dd989f9a64
5 changed files with 262 additions and 146 deletions

View File

@ -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

View File

@ -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 {

View File

@ -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()
}
}

View File

@ -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.

View File

@ -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'
/**