Cut/copy/paste/insert/duplicate/remove mostly working for selections (WIP)

This commit is contained in:
jos 2018-10-03 11:33:03 +02:00
parent f1ddc03c6d
commit ce37b88296
10 changed files with 275 additions and 406 deletions

View File

@ -39,7 +39,13 @@ const json = {
'string': 'Hello World',
'unicode': 'A unicode character: \u260E',
'url': 'http://jsoneditoronline.org',
'largeArray': [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20]
'largeArray': [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20],
'structureArray': [
{name: 'Joe', age: 24},
{name: 'Sarah', age: 28},
{name: 'Brett', age: 21},
{name: 'Emma', age: 31},
]
}
function expandAll (path) {

View File

@ -2,12 +2,12 @@ import last from 'lodash/last'
import initial from 'lodash/initial'
import isEmpty from 'lodash/isEmpty'
import first from 'lodash/first'
import { findRootPath, pathsFromSelection } from './eson'
import { findRootPath } from './eson'
import { getIn } from './utils/immutabilityHelpers'
import { findUniqueName } from './utils/stringUtils'
import { isObject, stringConvert } from './utils/typeUtils'
import { compareAsc, compareDesc } from './utils/arrayUtils'
import { compileJSONPointer } from './jsonPointer'
import { compileJSONPointer, parseJSONPointer } from './jsonPointer'
/**
* Create a JSONPatch to change the value of a property or item
@ -81,13 +81,13 @@ export function changeType (json, path, type) {
*/
export function duplicate (json, selection) {
// console.log('duplicate', path)
if (!selection.start || !selection.end) {
if (isEmpty(selection.multi)) {
return []
}
const rootPath = findRootPath(selection)
const root = getIn(json, rootPath)
const paths = pathsFromSelection(json, selection)
const paths = selection.multi.map(parseJSONPointer)
if (Array.isArray(root)) {
const lastPath = last(paths)
@ -224,9 +224,11 @@ export function insertInside (json, parentPath, values) {
export function replace (json, selection, values) { // TODO: find a better name and define datastructure for values
const rootPath = findRootPath(selection)
const root = getIn(json, rootPath)
const paths = selection.multi
? selection.multi.map(parseJSONPointer)
: []
if (Array.isArray(root)) {
const paths = pathsFromSelection(json, selection)
const firstPath = first(paths)
const offset = firstPath ? parseInt(last(firstPath), 10) : 0
@ -240,7 +242,7 @@ export function replace (json, selection, values) { // TODO: find a better name
return removeActions.concat(insertActions)
}
else { // root is Object
const removeActions = removeAll(pathsFromSelection(json, selection))
const removeActions = removeAll(paths)
const insertActions = values.map(entry => {
const newProp = findUniqueName(entry.name, root)
return {
@ -293,7 +295,7 @@ export function append (json, parentPath, type) {
/**
* Create a JSONPatch for a remove action
* @param {Path} path
* @return {ESONPatchDocument}
* @return {JSONPatchDocument}
*/
export function remove (path) {
return [{
@ -305,7 +307,7 @@ export function remove (path) {
/**
* Create a JSONPatch for a multiple remove action
* @param {Path[]} paths
* @return {ESONPatchDocument}
* @return {JSONPatchDocument}
*/
export function removeAll (paths) {
return paths

View File

@ -6,13 +6,20 @@ import naturalSort from 'javascript-natural-sort'
import { escapeHTML, unescapeHTML } from '../utils/stringUtils'
import { getInnerText, insideRect } from '../utils/domUtils'
import { stringConvert, valueType, isUrl } from '../utils/typeUtils'
import { isUrl, stringConvert, valueType } from '../utils/typeUtils'
import {
SELECTED, SELECTED_START, SELECTED_END, SELECTED_AFTER, SELECTED_INSIDE,
SELECTED_FIRST, SELECTED_LAST
ERROR,
EXPANDED,
ID,
SEARCH_PROPERTY,
SEARCH_VALUE,
SELECTED_AFTER, SELECTED_BEFORE_CHILDS,
SELECTED_INSIDE,
SELECTION,
TYPE,
VALUE
} from '../eson'
import { compileJSONPointer } from '../jsonPointer'
import { ERROR, EXPANDED, ID, SEARCH_PROPERTY, SEARCH_VALUE, SELECTION, TYPE, VALUE } from '../eson'
import fontawesome from '@fortawesome/fontawesome'
import faExclamationTriangle from '@fortawesome/fontawesome-free-solid/faExclamationTriangle'
@ -45,9 +52,6 @@ export default class JSONNode extends PureComponent {
super(props)
this.state = {
menu: null, // can contain object {anchor, root}
appendMenu: null, // can contain object {anchor, root}
hover: null,
path: null // initialized via getDerivedStateFromProps
}
}
@ -106,7 +110,8 @@ export default class JSONNode extends PureComponent {
this.renderDelimiter('}', 'jsoneditor-delimiter-end jsoneditor-delimiter-collapsed')
]
: null,
this.renderError(this.props.eson[ERROR])
this.renderError(this.props.eson[ERROR]),
this.renderBeforeChilds()
])
let childs
@ -155,7 +160,7 @@ export default class JSONNode extends PureComponent {
'data-path': compileJSONPointer(this.state.path),
'data-area': 'empty', // TODO: remove
'data-selection-area': this.props.eson[EXPANDED] ? 'before-childs' : 'after',
className: this.getContainerClassName(this.props.eson[SELECTION], this.state.hover),
className: this.getContainerClassName(this.props.eson[SELECTION]),
// onMouseOver: this.handleMouseOver,
// onMouseLeave: this.handleMouseLeave
}, [nodeStart, childs, nodeEnd])
@ -165,11 +170,11 @@ export default class JSONNode extends PureComponent {
// TODO: refactor renderJSONArray (too large/complex)
const count = this.props.eson.length
const nodeStart = h('div', {
key: 'node',
onKeyDown: this.handleKeyDown,
'data-selection-area': 'inside',
className: 'jsoneditor-node jsoneditor-array'
}, [
key: 'node',
onKeyDown: this.handleKeyDown,
'data-selection-area': 'inside',
className: 'jsoneditor-node jsoneditor-array'
}, [
this.renderExpandButton(),
this.renderProperty(),
this.renderSeparator(),
@ -181,7 +186,8 @@ export default class JSONNode extends PureComponent {
this.renderDelimiter(']', 'jsoneditor-delimiter-end jsoneditor-delimiter-collapsed'),
]
: null,
this.renderError(this.props.eson[ERROR])
this.renderError(this.props.eson[ERROR]),
this.renderBeforeChilds()
])
let childs
@ -230,7 +236,7 @@ export default class JSONNode extends PureComponent {
'data-path': compileJSONPointer(this.state.path),
'data-area': 'empty', // TODO: remove data-area
'data-selection-area': this.props.eson[EXPANDED] ? 'before-childs' : 'after',
className: this.getContainerClassName(this.props.eson[SELECTION], this.state.hover),
className: this.getContainerClassName(this.props.eson[SELECTION]),
// onMouseOver: this.handleMouseOver,
// onMouseLeave: this.handleMouseLeave
}, [nodeStart, childs, nodeEnd])
@ -256,7 +262,7 @@ export default class JSONNode extends PureComponent {
'data-path': compileJSONPointer(this.state.path),
'data-area': 'empty', // TODO: remove
'data-selection-area': 'after',
className: this.getContainerClassName(this.props.eson[SELECTION], this.state.hover),
className: this.getContainerClassName(this.props.eson[SELECTION]),
// onMouseOver: this.handleMouseOver,
// onMouseLeave: this.handleMouseLeave
}, [node])
@ -350,6 +356,14 @@ export default class JSONNode extends PureComponent {
}
}
renderBeforeChilds () {
return h('div', {
key: 'before-childs',
className: 'jsoneditor-before-childs',
'data-selection-area': 'before-childs'
})
}
renderSeparator() {
const isProp = typeof this.props.prop === 'string'
if (!isProp) {
@ -423,33 +437,23 @@ export default class JSONNode extends PureComponent {
}
}
getContainerClassName (selected, hover) {
let classNames = ['jsoneditor-node-container']
getContainerClassName (selected) {
let classNames = [
'jsoneditor-node-container',
// `jsoneditor-node-${this.props.eson[TYPE]}`
this.props.eson[EXPANDED] ? 'jsoneditor-node-expanded' : 'jsoneditor-node-collapsed'
]
if ((selected & SELECTED_INSIDE) !== 0) {
classNames.push('jsoneditor-selected-insert-before')
}
else if ((selected & SELECTED_AFTER) !== 0) {
classNames.push('jsoneditor-selected-insert-after')
}
else {
if ((selected & SELECTED) !== 0) { classNames.push('jsoneditor-selected') }
if ((selected & SELECTED_START) !== 0) { classNames.push('jsoneditor-selected-start') }
if ((selected & SELECTED_END) !== 0) { classNames.push('jsoneditor-selected-end') }
if ((selected & SELECTED_FIRST) !== 0) { classNames.push('jsoneditor-selected-first') }
if ((selected & SELECTED_LAST) !== 0) { classNames.push('jsoneditor-selected-last') }
if (selected === SELECTED_INSIDE) {
classNames.push('jsoneditor-selected')
}
if ((hover & SELECTED_INSIDE) !== 0) {
classNames.push('jsoneditor-hover-insert-before')
if (selected === SELECTED_AFTER) {
classNames.push('jsoneditor-selected-after')
}
else if ((hover & SELECTED_AFTER) !== 0) {
classNames.push('jsoneditor-hover-insert-after')
}
else {
if ((hover & SELECTED) !== 0) { classNames.push('jsoneditor-hover') }
if ((hover & SELECTED_START) !== 0) { classNames.push('jsoneditor-hover-start') }
if ((hover & SELECTED_END) !== 0) { classNames.push('jsoneditor-hover-end') }
if (selected === SELECTED_BEFORE_CHILDS) {
classNames.push('jsoneditor-selected-before-childs')
}
return classNames.join(' ')
@ -574,34 +578,6 @@ export default class JSONNode extends PureComponent {
)
}
handleMouseOver = (event) => {
if (event.buttons === 0) { // no mouse button down, no dragging
event.stopPropagation()
const hover = (event.target.className.indexOf('jsoneditor-insert-area') !== -1)
? (SELECTED + SELECTED_AFTER)
: SELECTED
if (hoveredNode && hoveredNode !== this) {
// FIXME: this gives issues when the hovered node doesn't exist anymore. check whether mounted?
hoveredNode.setState({hover: null})
}
if (hover !== this.state.hover) {
this.setState({hover})
hoveredNode = this
}
}
}
handleMouseLeave = (event) => {
event.stopPropagation()
// FIXME: this gives issues when the hovered node doesn't exist anymore. check whether mounted?
hoveredNode.setState({hover: null})
this.setState({hover: null})
}
/** @private */
handleChangeProperty = (event) => {
const parentPath = initial(this.state.path)

View File

@ -1,6 +1,9 @@
import { createElement as h, PureComponent } from 'react'
import mitt from 'mitt'
import cloneDeepWith from 'lodash/cloneDeepWith'
import isEmpty from 'lodash/isEmpty'
import isEqual from 'lodash/isEqual'
import first from 'lodash/first'
import reverse from 'lodash/reverse'
import initial from 'lodash/initial'
import pick from 'lodash/pick'
@ -8,7 +11,7 @@ import Hammer from 'react-hammerjs'
import jump from '../assets/jump.js/src/jump'
import Ajv from 'ajv'
import { existsIn, setIn, updateIn } from '../utils/immutabilityHelpers'
import { existsIn, getIn, setIn, updateIn } from '../utils/immutabilityHelpers'
import { parseJSON } from '../utils/jsonUtils'
import { enrichSchemaError } from '../utils/schemaUtils'
import { compileJSONPointer, parseJSONPointer } from '../jsonPointer'
@ -52,10 +55,10 @@ import {
expand,
EXPANDED,
expandPath,
findRootPath,
findSharedPath,
immutableESONPatch,
nextSearchResult,
pathsFromSelection,
previousSearchResult,
search,
SELECTION,
@ -273,7 +276,7 @@ export default class TreeMode extends PureComponent {
renderMenu () {
const hasCursor = true // FIXME: implement hasCursor
const hasSelection = !!this.state.selection
const hasSelection = this.state.selection ? this.state.selection.type !== 'none' : false
const hasClipboard = this.state.clipboard
? this.state.clipboard.length > 0
: false
@ -295,7 +298,7 @@ export default class TreeMode extends PureComponent {
canInsert: hasCursor,
canDuplicate: hasSelection,
canRemove: hasSelection,
onInsert: this.handleInsertBefore,
onInsert: this.handleInsert,
onDuplicate: this.handleDuplicate,
onRemove: this.handleRemove,
@ -428,10 +431,11 @@ export default class TreeMode extends PureComponent {
}
handleRemove = () => {
if (this.state.selection) {
if (this.state.selection && this.state.selection.multi) {
// remove selection
// TODO: select next property? (same as when removing a path?)
const paths = pathsFromSelection(this.state.eson, this.state.selection)
// TODO: inefficient: first parsing the paths, and removeAll stringifies them again
const paths = this.state.selection.multi.map(parseJSONPointer)
this.setState({ selection: null })
this.handlePatch(removeAll(paths))
}
@ -495,7 +499,7 @@ export default class TreeMode extends PureComponent {
handleKeyDownDuplicate = (event) => {
const path = this.findDataPathFromElement(event.target)
if (path) {
const selection = { start: path, end: path }
const selection = { type: 'multi', multi: [path] }
this.handlePatch(duplicate(this.state.eson, selection))
// apply focus to the duplicated node
@ -520,9 +524,9 @@ export default class TreeMode extends PureComponent {
handleCut = () => {
const selection = this.state.selection
if (selection && selection.start && selection.end) {
if (selection && selection.multi) {
const eson = this.state.eson
const paths = pathsFromSelection(eson, selection)
const paths = selection.multi.map(parseJSONPointer)
const clipboard = contentsFromPaths(eson, paths)
this.setState({ clipboard, selection: null })
@ -540,9 +544,9 @@ export default class TreeMode extends PureComponent {
handleCopy = () => {
const selection = this.state.selection
if (selection && selection.start && selection.end) {
if (selection && selection.multi) {
const eson = this.state.eson
const paths = pathsFromSelection(eson, selection)
const paths = selection.multi.map(parseJSONPointer)
const clipboard = contentsFromPaths(eson, paths)
this.setState({ clipboard })
@ -559,14 +563,14 @@ export default class TreeMode extends PureComponent {
if (selection && clipboard && clipboard.length > 0) {
this.setState({ selection: null })
if (selection.start && selection.end) {
if (selection.multi) {
this.handlePatch(replace(eson, selection, clipboard))
}
else if (selection.after) {
this.handlePatch(insertAfter(eson, selection.after, clipboard))
this.handlePatch(insertAfter(eson, parseJSONPointer(selection.after), clipboard))
}
else if (selection.inside) {
this.handlePatch(insertInside(eson, selection.inside, clipboard))
else if (selection.type === 'before-childs') {
this.handlePatch(insertInside(eson, parseJSONPointer(selection.beforeChildsOf), clipboard))
}
else {
throw new Error(`Cannot paste at current selection ${JSON.stringify(selection)}`)
@ -579,9 +583,71 @@ export default class TreeMode extends PureComponent {
}
}
handleInsertBefore = (insertType) => {
// FIXME: implement handleInsertBefore
console.error('Insert not yet implemented...', insertType)
handleInsert = (insertType) => {
const { eson, selection } = this.state
if (selection) {
this.setState({ selection: null })
const clipboard = [{
name: '',
value: this.createValue(insertType, selection)
}]
if (selection.multi) {
this.handlePatch(replace(eson, selection, clipboard))
}
else if (selection.after) {
this.handlePatch(insertAfter(eson, parseJSONPointer(selection.after), clipboard))
}
else if (selection.beforeChildsOf) {
this.handlePatch(insertInside(eson, parseJSONPointer(selection.beforeChildsOf), clipboard))
}
else {
throw new Error(`Cannot insert at current selection ${JSON.stringify(selection)}`)
}
// TODO: expand the inserted contents when array/object/structure
// TODO: select the inserted contents
}
}
/**
* Create an eson value
* @param insertType
* @param selection
* @returns {*}
*/
createValue = (insertType, selection) => {
if (insertType === 'array') {
return []
}
if (insertType === 'object') {
return {}
}
if (insertType === 'structure') {
const rootPath = findRootPath(selection)
const parent = getIn(this.state.json, rootPath)
if (Array.isArray(parent) && !isEmpty(parent)) {
const jsonExample = first(parent)
const structure = cloneDeepWith(jsonExample, (value) => {
return (Array.isArray(value) || typeof value === 'object')
? undefined // leave as is
: ''
})
console.log('structure', jsonExample, structure)
return structure
}
}
// value or unknown type
return ''
}
/**
@ -662,13 +728,15 @@ export default class TreeMode extends PureComponent {
}
toggleSearch = () => {
this.setState({
showSearch: !this.state.showSearch
})
if (this.state.showSearch) {
this.handleCloseSearch()
}
else {
this.setState({ showSearch: true })
}
}
handleSearch = (text) => {
// FIXME
// FIXME: also apply search when eson is changed
const { eson, searchResult } = search(this.state.eson, text)
if (searchResult.matches.length > 0) {
@ -786,47 +854,16 @@ export default class TreeMode extends PureComponent {
this.selectionStartPointer = this.findSelectionPointerFromEvent(event.target, event.clientY)
console.log('selectionPointer', this.selectionStartPointer)
const pointer = this.findJSONPointerFromElement(event.target)
const clickedOnEmptySpace = (event.target.nodeName === 'DIV') &&
(event.target.contentEditable !== 'true') &&
(event.target.className !== 'jsoneditor-tag') // FIXME: this is an ugly hack to prevent an object/array from being selected when expanding it by clicking the tag
// TODO: cleanup
// console.log('handleTouchStart', clickedOnEmptySpace && pointer, pointer && this.selectionFromJSONPointer(pointer))
if (clickedOnEmptySpace && pointer) {
this.setState({ selection: this.selectionFromJSONPointer(pointer)})
}
else {
this.setState({ selection: null })
}
this.setState({ selection: null })
}
handlePan = (event) => {
this.selectionEndPointer = this.findSelectionPointerFromEvent(event.target, event.center.y)
const selection2 = this.findSelectionFromPointers(this.selectionStartPointer, this.selectionEndPointer)
console.log('selection', JSON.stringify(selection2))
const selection = this.state.selection
const path = this.findDataPathFromElement(event.target.firstChild)
if (path && selection && !isEqual(path, selection.end)) {
// TODO: cleanup
// console.log('handlePan', {
// start: selection.start || selection.inside || selection.after || selection.empty || selection.emptyBefore,
// end: path
// })
// FIXME: when selection.empty, start should be set to the next node
this.setState({
selection: {
start: selection.start || selection.inside || selection.after || selection.empty || selection.emptyBefore,
end: path
}
})
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
}
}
@ -891,6 +928,7 @@ export default class TreeMode extends PureComponent {
/**
* @param {SelectionPointer} start
* @param {SelectionPointer} end
* @return {Selection}
*/
findSelectionFromPointers (start, end) {
if (start && end) {
@ -909,6 +947,10 @@ export default class TreeMode extends PureComponent {
return { type: 'after', after: compileJSONPointer(sharedPath) }
}
if (start.area === 'before-childs' && end.area === 'before-childs' && start.path === end.path) {
return { type: 'before-childs', beforeChildsOf: compileJSONPointer(sharedPath) }
}
if (start.path !== end.path || start.area !== end.area || start.area === 'inside' || end.area === 'inside') {
return { type: 'multi', multi: [ compileJSONPointer(sharedPath) ] }
}
@ -934,6 +976,7 @@ export default class TreeMode extends PureComponent {
if (firstIndex < lastIndex) {
return {
type: 'multi',
after: includeFirst ? undefined : first.path,
multi: childs
.slice(firstIndex, lastIndex)
.map(element => element.getAttribute('data-path'))
@ -943,7 +986,7 @@ export default class TreeMode extends PureComponent {
// selection starts after first node and ends before last node
return {
type: 'after',
path: first.path
after: first.path
}
}
}

View File

@ -294,52 +294,7 @@ div.jsoneditor-menu-panel-right {
div.jsoneditor-node-container {
position: relative;
transition: background-color 100ms ease-in;
// TODO: cleanup insert-area css?
div.jsoneditor-insert-area {
//background: gray;
position: absolute;
width: 100%;
height: $insert-area-height;
left: 0;
top: -$insert-area-height/2;
box-sizing: border-box;
z-index: 1; // must be on top of next node, it overlaps a bit
$top: -$line-height / 2 + 3px;
// FIXME: nice border color
//&:before {
// content: '';
// position: absolute;
// right: 60px;
// top: $top;
// width: 0;
// height: 0;
// border-top: $line-height / 2 solid transparent;
// border-right: $line-height / 2 solid red;
// border-bottom: $line-height / 2 solid transparent;
//}
&:after {
content: '';
position: absolute;
top: $top;
right: 0;
width: 60px;
height: $line-height;
background: inherit;
}
}
//&.jsoneditor-hover {
// > .jsoneditor-node > .jsoneditor-delimiter-start,
// > .jsoneditor-delimiter-end {
// color: $theme-color;
// font-weight: bold;
// }
//}
//transition: background-color 100ms ease-in;
&.jsoneditor-selected {
.jsoneditor-node {
@ -356,6 +311,35 @@ div.jsoneditor-node-container {
}
}
&.jsoneditor-selected-after {
&.jsoneditor-node-expanded {
.jsoneditor-node-end {
background-color: $selectedColor;
> .jsoneditor-delimiter-end {
background-color: white;
}
}
}
&.jsoneditor-node-collapsed {
background-color: $selectedColor;
> .jsoneditor-node {
background-color: white;
}
}
}
&.jsoneditor-selected-before-childs {
> .jsoneditor-node > .jsoneditor-before-childs {
background-color: $selectedColor;
width: 40px; // FIXME: should use full remaining width
}
}
&.jsoneditor-hover {
> .jsoneditor-node > .jsoneditor-button-container,
> .jsoneditor-node > .jsoneditor-button-placeholder {

View File

@ -14,6 +14,6 @@ $hoverColor: #d3d3d3;
$hoverAndSelectedColor: #ffdb80;
$warning-color: #FBB917;
$gray: #9d9d9d;
$gray-icon: #5e5e5e;
$gray-icon: $gray;
$light-gray: #c0c0c0;
$input-padding: 5px;

View File

@ -1,12 +1,10 @@
import { deleteIn, getIn, setIn, shallowCloneWithSymbols, transform, updateIn } from './utils/immutabilityHelpers'
import range from 'lodash/range'
import { deleteIn, getIn, setIn, transform } from './utils/immutabilityHelpers'
import { compileJSONPointer, parseJSONPointer } from './jsonPointer'
import first from 'lodash/first'
import last from 'lodash/last'
import initial from 'lodash/initial'
import isEmpty from 'lodash/isEmpty'
import isEqual from 'lodash/isEqual'
import naturalSort from 'javascript-natural-sort'
import times from 'lodash/times'
import { immutableJSONPatch } from './immutableJSONPatch'
import { compareArrays } from './utils/arrayUtils'
import { compareStrings } from './utils/stringUtils'
@ -27,8 +25,7 @@ export const SELECTED_FIRST = 8
export const SELECTED_LAST = 16
export const SELECTED_INSIDE = 32
export const SELECTED_AFTER = 64
export const SELECTED_EMPTY = 128
export const SELECTED_EMPTY_BEFORE = 256
export const SELECTED_BEFORE_CHILDS = 128
// TODO: comment
export function syncEson(json, eson) {
@ -355,87 +352,34 @@ function setSearchStatus (eson, esonPointer, searchStatus) {
* @return {ESON} Returns updated eson object
*/
export function applySelection (eson, selection) {
if (!selection) {
return cleanupMetaData(eson, 'selected')
if (selection && selection.type === 'after') {
const updatedEson = setIn(eson, parseJSONPointer(selection.after).concat([SELECTION]), SELECTED_AFTER)
return cleanupMetaData(updatedEson, SELECTION, [selection.after])
}
else if (selection.inside) {
const updatedEson = setIn(eson, selection.inside.concat([SELECTION]), SELECTED_INSIDE)
return cleanupMetaData(updatedEson, 'selected', [selection.inside])
if (selection && selection.type === 'before-childs') {
const updatedEson = setIn(eson, parseJSONPointer(selection.beforeChildsOf).concat([SELECTION]), SELECTED_BEFORE_CHILDS)
return cleanupMetaData(updatedEson, SELECTION, [selection.beforeChildsOf])
}
else if (selection.after) {
const updatedEson = setIn(eson, selection.after.concat([SELECTION]), SELECTED_AFTER)
return cleanupMetaData(updatedEson, 'selected', [selection.after])
if (selection && selection.type === 'multi') {
let updatedEson = eson
if (selection.after) {
updatedEson = setIn(updatedEson, parseJSONPointer(selection.after).concat([SELECTION]), SELECTED_AFTER)
}
for (const path of selection.multi) {
updatedEson = setIn(updatedEson, parseJSONPointer(path).concat([SELECTION]), SELECTED_INSIDE)
}
const ignorePaths = selection.after
? selection.multi.concat([selection.after])
: selection.multi
return cleanupMetaData(updatedEson, SELECTION, ignorePaths)
}
else if (selection.empty) {
const updatedEson = setIn(eson, selection.empty.concat([SELECTION]), SELECTED_EMPTY)
return cleanupMetaData(updatedEson, 'selected', [selection.empty])
}
else if (selection.emptyBefore) {
const updatedEson = setIn(eson, selection.emptyBefore.concat([SELECTION]), SELECTED_EMPTY_BEFORE)
return cleanupMetaData(updatedEson, 'selected', [selection.emptyBefore])
}
else { // selection.start and selection.end
// find the parent node shared by both start and end of the selection
const rootPath = findRootPath(selection)
let selectedPaths = null
const updatedEson = updateIn(eson, rootPath, (root) => {
const start = selection.start[rootPath.length]
const end = selection.end[rootPath.length]
// TODO: simplify the update function. Use pathsFromSelection ?
if (root[TYPE] === 'object') {
const props = Object.keys(root).sort(naturalSort) // TODO: create a util function getSortedProps
const startIndex = props.indexOf(start)
const endIndex = props.indexOf(end)
const firstIndex = Math.min(startIndex, endIndex)
const lastIndex = Math.max(startIndex, endIndex)
const firstProp = props[firstIndex]
const lastProp = props[lastIndex]
const selectedProps = props.slice(firstIndex, lastIndex + 1)// include max index itself
selectedPaths = selectedProps.map(prop => rootPath.concat(prop))
let updatedObj = shallowCloneWithSymbols(root)
selectedProps.forEach(prop => {
const selected = SELECTED +
(prop === start ? SELECTED_START : 0) +
(prop === end ? SELECTED_END : 0) +
(prop === firstProp ? SELECTED_FIRST : 0) +
(prop === lastProp ? SELECTED_LAST : 0)
updatedObj[prop] = setIn(updatedObj[prop], [SELECTION], selected)
})
return updatedObj
}
else { // root[TYPE] === 'array'
const startIndex = parseInt(start, 10)
const endIndex = parseInt(end, 10)
const firstIndex = Math.min(startIndex, endIndex)
const lastIndex = Math.max(startIndex, endIndex)
const selectedIndices = range(firstIndex, lastIndex + 1)// include max index itself
selectedPaths = selectedIndices.map(index => rootPath.concat(String(index)))
let updatedArr = root.slice()
updatedArr = shallowCloneWithSymbols(root)
selectedIndices.forEach(index => {
const selected = SELECTED +
(index === startIndex ? SELECTED_START : 0) +
(index === endIndex ? SELECTED_END : 0) +
(index === firstIndex ? SELECTED_FIRST : 0) +
(index === lastIndex ? SELECTED_LAST : 0)
updatedArr[index] = setIn(updatedArr[index], [SELECTION], selected)
})
return updatedArr
}
})
return cleanupMetaData(updatedEson, 'selected', selectedPaths)
}
return cleanupMetaData(eson, SELECTION)
}
/**
@ -462,30 +406,44 @@ export function contentsFromPaths (eson, paths) {
* @return {Path}
*/
export function findRootPath(selection) {
if (selection.inside) {
return initial(selection.inside)
}
else if (selection.after) {
return initial(selection.after)
}
else if (selection.empty) {
return initial(selection.empty)
}
else if (selection.emptyBefore) {
return initial(selection.emptyBefore)
}
else { // selection.start and selection.end
const sharedPath = findSharedPath(selection.start, selection.end)
if (selection.multi) {
const firstPath = parseJSONPointer(first(selection.multi))
if (sharedPath.length === selection.start.length ||
sharedPath.length === selection.end.length) {
// there is just one node selected, return it's parent
return initial(sharedPath)
}
else {
return sharedPath
}
return initial(firstPath)
}
if (selection.after) {
return initial(parseJSONPointer(selection.after))
}
// TODO: handle area === 'before-childs' and area === 'after-childs'
// TODO: cleanup
// if (selection.inside) {
// return initial(selection.inside)
// }
// else if (selection.after) {
// return initial(selection.after)
// }
// else if (selection.empty) {
// return initial(selection.empty)
// }
// else if (selection.emptyBefore) {
// return initial(selection.emptyBefore)
// }
// else { // selection.start and selection.end
// const sharedPath = findSharedPath(selection.start, selection.end)
//
// if (sharedPath.length === selection.start.length ||
// sharedPath.length === selection.end.length) {
// // there is just one node selected, return it's parent
// return initial(sharedPath)
// }
// else {
// return sharedPath
// }
// }
}
/**
@ -504,43 +462,6 @@ export function findSharedPath (path1, path2) {
return path1.slice(0, i)
}
/**
* Get the JSON paths from a selection, sorted from first to last
* @param {ESON} eson
* @param {Selection} selection
* @return {Path[]}
*/
// TODO: move pathsFromSelection to a separate file clipboard.js or selection.js?
export function pathsFromSelection (eson, selection) {
// find the parent node shared by both start and end of the selection
const rootPath = findRootPath(selection)
const root = getIn(eson, rootPath)
const start = (selection.after || selection.inside || selection.start)[rootPath.length]
const end = (selection.after || selection.inside || selection.end)[rootPath.length]
if (getType(root) === 'object') {
// TODO: create a util function getSortedProps, cache results?
const props = Object.keys(root).sort(naturalSort)
const startIndex = props.indexOf(start)
const endIndex = props.indexOf(end)
const minIndex = Math.min(startIndex, endIndex)
const maxIndex = Math.max(startIndex, endIndex) + ((selection.after || selection.inside) ? 0 : 1) // include max index itself
return times(maxIndex - minIndex, i => rootPath.concat(props[i + minIndex]))
}
else { // root[TYPE] === 'array'
const startIndex = parseInt(start, 10)
const endIndex = parseInt(end, 10)
const minIndex = Math.min(startIndex, endIndex)
const maxIndex = Math.max(startIndex, endIndex) + ((selection.after || selection.inside) ? 0 : 1) // include max index itself
return times(maxIndex - minIndex, i => rootPath.concat(String(i + minIndex)))
}
}
/**
* Apply a JSON patch document to an ESON object.
* - Applies meta information to added values
@ -557,7 +478,11 @@ export function immutableESONPatch (eson, operations) {
})
}
// TODO: comment
/**
* Get the JSON type of any input
* @param {*} any
* @returns {string} Returns 'array', 'object', 'value', or 'undefined'
*/
export function getType (any) {
if (any === undefined) {
return 'undefined'

View File

@ -346,6 +346,7 @@ test('previousSearchResult', () => {
expect(getIn(first.eson, ['obj', 'arr', '2', 'last', SEARCH_PROPERTY])).toEqual('normal')
})
// FIXME: test selection
test('selection (object)', () => {
const eson = syncEson({
"obj": {
@ -380,6 +381,7 @@ test('selection (object)', () => {
assertEqualEson(actual2, expected2)
})
// FIXME: test selection
test('selection (array)', () => {
const eson = syncEson({
"obj": {
@ -405,6 +407,7 @@ test('selection (array)', () => {
assertEqualEson(actual, expected)
})
// FIXME: test selection
test('selection (value)', () => {
const eson = syncEson({
"obj": {
@ -425,6 +428,7 @@ test('selection (value)', () => {
assertEqualEson(actual, expected)
})
// FIXME: test selection
test('selection (node)', () => {
const eson = syncEson({
"obj": {
@ -444,78 +448,3 @@ test('selection (node)', () => {
SELECTED + SELECTED_START + SELECTED_END + SELECTED_FIRST + SELECTED_LAST)
assertEqualEson(actual, expected)
})
test('pathsFromSelection (object)', () => {
const json = {
"bool": false,
"nill": null,
"obj": {
"arr": [1,2, {"first":3,"last":4}]
},
"str": "hello world",
}
const selection = {
start: ['obj', 'arr', '2', 'last'],
end: ['nill']
}
expect(pathsFromSelection(json, selection)).toEqual([
['nill'],
['obj']
])
})
test('pathsFromSelection (array)', () => {
const json = {
"obj": {
"arr": [1,2, {"first":3,"last":4}]
},
"str": "hello world",
"nill": null,
"bool": false
}
const selection = {
start: ['obj', 'arr', '1'],
end: ['obj', 'arr', '0'] // note the "backward" order of start and end
}
expect(pathsFromSelection(json, selection)).toEqual([
['obj', 'arr', '0'],
['obj', 'arr', '1']
])
})
test('pathsFromSelection (value)', () => {
const json = {
"obj": {
"arr": [1,2, {"first":3,"last":4}]
},
"str": "hello world",
"nill": null,
"bool": false
}
const selection = {
start: ['obj', 'arr', '2', 'first'],
end: ['obj', 'arr', '2', 'first']
}
expect(pathsFromSelection(json, selection)).toEqual([
['obj', 'arr', '2', 'first'],
])
})
test('pathsFromSelection (after)', () => {
const json = {
"obj": {
"arr": [1,2, {"first":3,"last":4}]
},
"str": "hello world",
"nill": null,
"bool": false
}
const selection = {
after: ['obj', 'arr', '2', 'first']
}
expect(pathsFromSelection(json, selection)).toEqual([])
})

View File

@ -30,10 +30,10 @@
/**
* @typedef {{
* start?: Path,
* end?: Path,
* before?: Path,
* after?: Path,
* type: 'multi' | 'after' | 'before-childs', 'none'
* after? string
* multi?: string[]
* beforeChildsOf?: string
* }} Selection
*/

View File

@ -1,4 +1,4 @@
import { ID } from '../eson'
import { EXPANDED, ID, SELECTION, TYPE } from '../eson'
import { deleteIn, transform } from './immutabilityHelpers'
export function createAssertEqualEson(expect) {
@ -17,6 +17,10 @@ export function createAssertEqualEson(expect) {
else {
expect(actual).toEqual(expected)
}
expect(actual[TYPE]).toEqual(expected[TYPE])
expect(actual[SELECTION]).toEqual(expected[SELECTION])
expect(actual[EXPANDED]).toEqual(expected[EXPANDED])
}
function stripIdSymbols (eson) {