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 { findUniqueName } from './utils/stringUtils'
import { getIn } from './utils/immutabilityHelpers' import { getIn } from './utils/immutabilityHelpers'
import { isObject, stringConvert } from './utils/typeUtils' 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. * Create a JSONPatch for an append action.
* *
@ -214,6 +321,7 @@ export function append (data, parentPath, type) {
/** /**
* Create a JSONPatch for a remove action * Create a JSONPatch for a remove action
* @param {Path} path * @param {Path} path
* @return {ESONPatch}
*/ */
export function remove (path) { export function remove (path) {
return [{ 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 * Create a JSONPatch to order the items of an array or the properties of an object in ascending
* or descending order * or descending order

View File

@ -22,8 +22,8 @@ import {
} from '../eson' } from '../eson'
import { patchEson } from '../patchEson' import { patchEson } from '../patchEson'
import { import {
duplicate, insert, append, remove, duplicate, insert, insertBefore, append, remove, removeAll, replace,
changeType, changeValue, changeProperty, sort createEntry, changeType, changeValue, changeProperty, sort
} from '../actions' } from '../actions'
import JSONNode from './JSONNode' import JSONNode from './JSONNode'
import JSONNodeView from './JSONNodeView' import JSONNodeView from './JSONNodeView'
@ -90,14 +90,15 @@ export default class TreeMode extends Component {
onChangeValue: this.handleChangeValue, onChangeValue: this.handleChangeValue,
onChangeType: this.handleChangeType, onChangeType: this.handleChangeType,
onInsert: this.handleInsert, onInsert: this.handleInsert,
onInsertStructure: this.handleInsertStructure,
onAppend: this.handleAppend, onAppend: this.handleAppend,
onDuplicate: this.handleDuplicate, onDuplicate: this.handleDuplicate,
onRemove: this.handleRemove, onRemove: this.handleRemove,
onSort: this.handleSort, onSort: this.handleSort,
onCut: this.handleMenuCut, onCut: this.handleCut,
onCopy: this.handleMenuCopy, onCopy: this.handleCopy,
onPaste: this.handleMenuPaste, onPaste: this.handlePaste,
onExpand: this.handleExpand, onExpand: this.handleExpand,
@ -117,7 +118,7 @@ export default class TreeMode extends Component {
end: null, // ESONPointer 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) 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? // TODO: create some sort of watcher structure for these props? Is there a React pattern for that?
applyProps (nextProps, currentProps) { applyProps (nextProps, currentProps) {
// Apply text // Apply text
@ -340,12 +350,21 @@ export default class TreeMode extends Component {
} }
handleInsert = (path, type) => { 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 // apply focus to new node
this.focusToNext(path) this.focusToNext(path)
} }
handleInsertStructure = (path) => {
// TODO: implement handleInsertStructure
console.log('handleInsertStructure', path)
alert('not yet implemented...')
}
handleAppend = (parentPath, type) => { handleAppend = (parentPath, type) => {
this.handlePatch(append(this.state.data, parentPath, type)) this.handlePatch(append(this.state.data, parentPath, type))
@ -361,15 +380,24 @@ export default class TreeMode extends Component {
} }
handleRemove = (path) => { handleRemove = (path) => {
// apply focus to next sibling element if existing, else to the previous element if (path) {
const fromElement = findNode(this.refs.contents, path) // apply focus to next sibling element if existing, else to the previous element
const success = moveDownSibling(fromElement, 'property') const fromElement = findNode(this.refs.contents, path)
if (!success) { const success = moveDownSibling(fromElement, 'property')
moveUp(fromElement, 'property') if (!success) {
} moveUp(fromElement, 'property')
}
this.setState({ selection : null }) this.setState({ selection : null })
this.handlePatch(remove(path)) 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) => { moveUp = (event) => {
@ -403,54 +431,32 @@ export default class TreeMode extends Component {
} }
handleKeyDownCut = (event) => { handleKeyDownCut = (event) => {
const { selection } = this.state if (this.state.selection) {
if (selection) {
event.preventDefault() event.preventDefault()
} }
this.handleCut(selection) this.handleCut()
} }
handleKeyDownCopy = (event) => { handleKeyDownCopy = (event) => {
const { selection } = this.state if (this.state.selection) {
if (selection) {
event.preventDefault() event.preventDefault()
} }
this.handleCopy(selection) this.handleCopy()
} }
handleKeyDownPaste = (event) => { handleKeyDownPaste = (event) => {
const { clipboard, selection } = this.state const { clipboard, data } = this.state
if (clipboard && clipboard.length > 0) { if (clipboard && clipboard.length > 0) {
event.preventDefault() event.preventDefault()
if (selection) {
this.handlePaste(clipboard, selection, null) const path = this.findDataPathFromElement(event.target)
} this.handlePatch(insertBefore(data, path, clipboard))
else {
// no selection -> paste after current path
const path = this.findDataPathFromElement(event.target)
this.handlePaste(clipboard, null, path)
}
} }
} }
handleMenuCut = (path) => { handleCut = () => {
const selection = { start: { path }, end: { path }} const selection = this.state.selection
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) => {
if (selection && selection.start && selection.end) { if (selection && selection.start && selection.end) {
const data = this.state.data const data = this.state.data
const paths = pathsFromSelection(data, selection) 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) { if (selection && selection.start && selection.end) {
const data = this.state.data const data = this.state.data
const paths = pathsFromSelection(data, selection) const paths = pathsFromSelection(data, selection)
@ -483,42 +490,12 @@ export default class TreeMode extends Component {
} }
} }
handlePaste = (clipboard, selection: ESONSelection, path: JSONPath) => { handlePaste = () => {
const { data } = this.state 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 // FIXME: handle pasting in an empty object or array
this.handlePatch(replace(data, selection, clipboard))
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')
}
} }
} }
@ -673,7 +650,10 @@ export default class TreeMode extends Component {
handleTouchStart = (event) => { handleTouchStart = (event) => {
const pointer = this.findESONPointerFromElement(event.target) 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}}) this.setState({ selection: {start: pointer, end: pointer}})
} }
else { else {

View File

@ -6,13 +6,13 @@ import { keyComboFromEvent } from '../../utils/keyBindings'
const MENU_CLASS_NAME = 'jsoneditor-floating-menu' const MENU_CLASS_NAME = 'jsoneditor-floating-menu'
const MENU_ITEM_CLASS_NAME = 'jsoneditor-floating-menu-item' 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) // advanced sort (asc, desc, nested fields, custom comparator)
// sort, map, filter, open a popup covering the editor (not the whole page) // sort, map, filter, open a popup covering the editor (not the whole page)
// (or if it's small, can be a dropdown) // (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) // simple sort (asc/desc)
// Value: [x] String | Duplicate | Cut | Copy | Remove // Value: [x] String | Duplicate | Cut | Copy | Paste | Remove
// String is a checkmark // String is a checkmark
// Between: Insert Structure | Insert Value | Insert Object | Insert Array | Paste // Between: Insert Structure | Insert Value | Insert Object | Insert Array | Paste
// inserting (value selected): [field] [value] // inserting (value selected): [field] [value]
@ -81,52 +81,55 @@ const CREATE_TYPE = {
remove: (path, events) => h('button', { remove: (path, events) => h('button', {
key: 'remove', key: 'remove',
className: MENU_ITEM_CLASS_NAME, className: MENU_ITEM_CLASS_NAME,
onClick: () => events.onRemove(path), onClick: () => events.onRemove(null), // do not pass path: we want to remove selection
title: 'Remove' title: 'Remove'
}, 'Remove'), }, 'Remove'),
insertStructure: (path, events) => h('button', { insertStructure: (path, events) => h('button', {
key: 'insertStructure', key: 'insertStructure',
className: MENU_ITEM_CLASS_NAME, 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' title: 'Insert a new object with the same data structure as the item above'
}, 'Insert structure'), }, 'Insert structure'),
insertValue: (path, events) => h('button', { insertValue: (path, events) => h('button', {
key: 'insertValue', key: 'insertValue',
className: MENU_ITEM_CLASS_NAME, className: MENU_ITEM_CLASS_NAME,
// onClick: () => events.onRemove(path), onClick: () => events.onInsert(path, 'value'),
title: 'Insert value' title: 'Insert value'
}, 'Insert value'), }, 'Insert value'),
insertObject: (path, events) => h('button', { insertObject: (path, events) => h('button', {
key: 'insertObject', key: 'insertObject',
className: MENU_ITEM_CLASS_NAME, className: MENU_ITEM_CLASS_NAME,
// onClick: () => events.onRemove(path), onClick: () => events.onInsert(path, 'Object'),
title: 'Insert Object' title: 'Insert Object'
}, 'Insert Object'), }, 'Insert Object'),
insertArray: (path, events) => h('button', { insertArray: (path, events) => h('button', {
key: 'insertArray', key: 'insertArray',
className: MENU_ITEM_CLASS_NAME, className: MENU_ITEM_CLASS_NAME,
// onClick: () => events.onRemove(path), onClick: () => events.onInsert(path, 'Array'),
title: 'Insert Array' title: 'Insert Array'
}, 'Insert Array'), }, 'Insert Array'),
} }
export default class FloatingMenu extends PureComponent { export default class FloatingMenu extends PureComponent {
componentDidMount () { // TODO: use or cleanup
setTimeout(() => { // componentDidMount () {
const firstButton = this.refs.root && this.refs.root.querySelector('button') // setTimeout(() => {
if (firstButton) { // const firstButton = this.refs.root && this.refs.root.querySelector('button')
firstButton.focus() // // 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 () { 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 type = typeof item === 'string' ? item : item.type
const createType = CREATE_TYPE[type] const createType = CREATE_TYPE[type]
if (createType) { if (createType) {
@ -135,6 +138,17 @@ export default class FloatingMenu extends PureComponent {
else { else {
throw new Error('Unknown type of menu item for floating menu: ' + JSON.stringify(item)) 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 // 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) const rootEsonPath = toEsonPath(eson, rootPath)
if (rootPath.length === selection.start.path.length || rootPath.length === selection.end.path.length) { return updateIn(eson, rootEsonPath, (root) => {
// select a single node const start = selection.start.path[rootPath.length]
const selectionType = (selection.start.area === 'after') ? SELECTED_AFTER : SELECTED_END const end = selection.end.path[rootPath.length]
console.log('selectionType', selectionType, selection) 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 // 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 return setIn(root, [childsKey], childsBefore.concat(childsUpdated, childsAfter))
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))
})
}
} }
/** /**
@ -413,26 +403,19 @@ export function findSelectionIndices (root: ESON, start: string, end: string) :
*/ */
export function pathsFromSelection (eson: ESON, selection: ESONSelection): JSONPath[] { export function pathsFromSelection (eson: ESON, selection: ESONSelection): JSONPath[] {
// find the parent node shared by both start and end of the selection // 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) const rootEsonPath = toEsonPath(eson, rootPath)
if (rootPath.length === selection.start.path.length || rootPath.length === selection.end.path.length) { const root = getIn(eson, rootEsonPath)
// select a single node const start = selection.start.path[rootPath.length]
return [ rootPath ] const end = selection.end.path[rootPath.length]
} const { minIndex, maxIndex } = findSelectionIndices(root, start, end)
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)
if (root.type === 'Object') { if (root.type === 'Object') {
return times(maxIndex - minIndex, i => rootPath.concat(root.props[i + minIndex].name)) return times(maxIndex - minIndex, i => rootPath.concat(root.props[i + minIndex].name))
} }
else { // root.type === 'Array' else { // root.type === 'Array'
return times(maxIndex - minIndex, i => rootPath.concat(String(i + minIndex))) return times(maxIndex - minIndex, i => rootPath.concat(String(i + minIndex)))
}
} }
} }
@ -448,10 +431,29 @@ export function contentsFromPaths (data: ESON, paths: JSONPath[]) {
return { return {
name: getIn(data, initial(esonPath).concat('name')) || String(esonPath[esonPath.length - 2]), name: getIn(data, initial(esonPath).concat('name')) || String(esonPath[esonPath.length - 2]),
value: esonToJson(getIn(data, esonPath)) 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. * Find the common path of two paths.

View File

@ -1,13 +1,12 @@
import isEqual from 'lodash/isEqual' import isEqual from 'lodash/isEqual'
import initial from 'lodash/initial' 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 { setIn, updateIn, getIn, deleteIn, insertAt } from './utils/immutabilityHelpers'
import { import {
jsonToEson, esonToJson, toEsonPath, jsonToEson, esonToJson, toEsonPath,
parseJSONPointer, compileJSONPointer, parseJSONPointer, compileJSONPointer,
expandAll, pathExists, resolvePathIndex, findPropertyIndex, findNextProp, getId, expandAll, pathExists, resolvePathIndex, findPropertyIndex, findNextProp, getId
pathsFromSelection
} from './eson' } from './eson'
/** /**