Cut/copy/paste (WIP)

This commit is contained in:
Jos de Jong 2020-07-05 14:18:34 +02:00
parent 823b445e94
commit fa2a23a83d
8 changed files with 355 additions and 38 deletions

View File

@ -1,5 +1,6 @@
<script>
import { tick, beforeUpdate, afterUpdate } from 'svelte'
import { insertAfter, insertBefore, removeAll, replace } from './actions.js'
import {
DEFAULT_LIMIT,
STATE_EXPANDED,
@ -14,6 +15,7 @@
import { expandSelection } from './selection.js'
import { singleton } from './singleton.js'
import {
deleteIn,
existsIn,
getIn,
setIn,
@ -26,6 +28,7 @@
import { isEqual, isNumber, initial, last, cloneDeep } from 'lodash-es'
import jump from './assets/jump.js/src/jump.js'
import { syncState } from './utils/syncState.js'
import { isObject } from './utils/typeUtils.js'
let divContents
@ -41,21 +44,12 @@
let selection = null
let selectionMap = {}
// $: {
// selectionMap = {}
// if (selection != null) {
// selection.forEach(path => {
// selectionMap[compileJSONPointer(path)] = true
// })
// }
// }
export let onChangeJson = () => {}
let clipboard = null
$: canCut = selection != null
$: canCopy = selection != null
$: canPaste = clipboard != null
$: canCut = selection != null && selection.paths != null
$: canCopy = selection != null && selection.paths != null
$: canPaste = clipboard != null && selection != null
$: state = syncState(doc, state, [], (path) => path.length < 1)
@ -134,20 +128,60 @@
}
}
function selectionToClipboard (selection) {
if (!selection || !selection.paths) {
return null
}
return selection.paths.map(path => {
return {
key: String(last(path)),
value: cloneDeep(getIn(doc, path))
}
})
}
function handleCut() {
// FIXME: implement
if (selection && selection.paths) {
clipboard = selectionToClipboard(selection)
const operations = removeAll(selection.paths)
handlePatch(operations)
console.log('cut', { selection, clipboard })
}
}
function handleCopy() {
if (selection) {
clipboard = selection.map(path => cloneDeep(getIn(doc, path)))
console.log('copied to clipboard', clipboard)
if (selection && selection.paths) {
clipboard = selectionToClipboard(selection)
console.log('copy', { clipboard })
}
}
function handlePaste() {
if (clipboard) {
console.log('focus', singleton.focus)
if (selection && clipboard) {
console.log('paste', { clipboard, selection })
if (selection.beforePath) {
const operations = insertBefore(doc, selection.beforePath, clipboard)
console.log('patch', operations)
handlePatch(operations)
// FIXME: must adjust STATE_PROPS of the object where we inserted the clipboard
} else if (selection.afterPath) {
const operations = insertAfter(doc, selection.afterPath, clipboard)
console.log('patch', operations)
handlePatch(operations)
// FIXME: must adjust STATE_PROPS of the object where we inserted the clipboard
} else if (selection.paths) {
const operations = replace(doc, selection.paths, clipboard)
console.log('patch', operations)
handlePatch(operations)
// FIXME: must adjust STATE_PROPS of the object where we inserted the clipboard
}
}
}
@ -313,7 +347,8 @@
})
selection = {
paths: pathsMap
paths,
pathsMap
}
} else {
console.error('Unknown type of selection', newSelection)
@ -383,6 +418,19 @@
handleRedo()
}
}
if (combo === 'Ctrl+X' || combo === 'Command+X') {
event.preventDefault()
handleCut()
}
if (combo === 'Ctrl+C' || combo === 'Command+C') {
event.preventDefault()
handleCopy()
}
if (combo === 'Ctrl+V' || combo === 'Command+V') {
event.preventDefault()
handlePaste()
}
}
</script>

View File

@ -1,5 +1,5 @@
<script>
import { debounce, initial, isEqual } from 'lodash-es'
import { debounce, initial, isEqual, last } from 'lodash-es'
import {
DEBOUNCE_DELAY,
DEFAULT_LIMIT,
@ -194,10 +194,8 @@
function handleMouseDown (event) {
// unselect existing selection on mouse down if any
if (singleton.selectionAnchor != null && singleton.selectionFocus != null) {
singleton.selectionAnchor = null
singleton.selectionFocus = null
onSelect(null, null)
if (selection) {
onSelect(null)
}
// check if the mouse down is not happening in the key or value input fields
@ -266,13 +264,15 @@
event.stopPropagation()
onSelect({
afterPath: path
afterPath: type === 'array'
? path.concat(items.length)
: path.concat(last(props).key)
})
}
// FIXME: this is not efficient. Create a nested object with the selection and pass that
$: selected = (selection && selection.paths)
? selection.paths[compileJSONPointer(path)] === true
$: selected = (selection && selection.pathsMap)
? selection.pathsMap[compileJSONPointer(path)] === true
: false
$: selectedBefore = (selection && selection.beforePath)
@ -280,7 +280,7 @@
: false
$: selectedAfter = (selection && selection.afterPath)
? isEqual(selection.afterPath, path)
? isEqual(initial(selection.afterPath), path)
: false
$: indentationStyle = getIndentationStyle(path.length)

View File

@ -47,6 +47,7 @@ $search-size: 24px;
margin: 0;
flex: 1;
width: 100px;
outline: none;
}
.search-count {

188
src/actions.js Normal file
View File

@ -0,0 +1,188 @@
import first from 'lodash/first'
import initial from 'lodash/initial'
import last from 'lodash/last'
import { getIn } from './utils/immutabilityHelpers'
import { compileJSONPointer } from './utils/jsonPointer'
import { findUniqueName } from './utils/stringUtils'
/**
* 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 {JSON} json
* @param {Path} path
* @param {Array.<{key?: string, value: JSON}>} values
* @return {Array}
*/
export function insertBefore (json, path, values) { // TODO: find a better name and define datastructure for values
const parentPath = initial(path)
const parent = getIn(json, parentPath)
if (Array.isArray(parent)) {
const startIndex = parseInt(last(path), 10)
return values.map((entry, offset) => ({
op: 'add',
path: compileJSONPointer(parentPath.concat(startIndex + offset)),
value: entry.value
}))
}
else { // 'object'
const addActions = values.map(entry => {
const newProp = findUniqueName(entry.key, parent)
return {
op: 'add',
path: compileJSONPointer(parentPath.concat(newProp)),
value: entry.value
}
})
// we move all lower down properties to the same parent again,
// to force them to move under the inserted properties instead of the
// new properties appearing at the bottom of the object
const keys = Object.keys(parent)
const beforeKey = last(path)
const beforeIndex = keys.indexOf(beforeKey)
const keysToMove = (beforeIndex !== -1)
? keys.slice(beforeIndex)
: []
const moveActions = keysToMove.map(key => {
const movePath = compileJSONPointer(parentPath.concat(key))
return {
op: 'move',
from: movePath,
path: movePath
}
})
return addActions.concat(moveActions)
}
}
/**
* 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 {JSON} json
* @param {Path} path
* @param {Array.<{key?: string, value: JSON}>} values
* @return {Array}
*/
export function insertAfter (json, path, values) { // TODO: find a better name and define datastructure for values
// TODO: refactor. path should be parent path
const parentPath = initial(path)
const parent = getIn(json, parentPath)
if (Array.isArray(parent)) {
const startIndex = parseInt(last(path), 10)
return values.map((entry, offset) => ({
op: 'add',
path: compileJSONPointer(parentPath.concat(startIndex + 1 + offset)), // +1 to insert after
value: entry.value
}))
}
else { // 'object'
return values.map(entry => {
const newProp = findUniqueName(entry.key, parent)
return {
op: 'add',
path: compileJSONPointer(parentPath.concat(newProp)),
value: entry.value
}
})
}
}
/**
* 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 {JSON} json
* @param {Path[]} paths
* @param {Array.<{key?: string, value: JSON}>} values
* @return {Array}
*/
export function replace (json, paths, values) { // TODO: find a better name and define datastructure for values
const firstPath = first(paths)
const parentPath = initial(firstPath)
const parent = getIn(json, parentPath)
if (Array.isArray(parent)) {
const firstPath = first(paths)
const offset = firstPath ? parseInt(last(firstPath), 10) : 0
const removeActions = removeAll(paths)
const insertActions = values.map((entry, index) => ({
op: 'add',
path: compileJSONPointer(parentPath.concat(index + offset)),
value: entry.value
}))
return removeActions.concat(insertActions)
}
else { // parent is Object
const removeActions = removeAll(paths)
const insertActions = values.map(entry => {
const newProp = findUniqueName(entry.key, parent)
return {
op: 'add',
path: compileJSONPointer(parentPath.concat(newProp)),
value: entry.value
}
})
// we move all lower down properties to the same parent again,
// to force them to move under the inserted properties instead of the
// new properties appearing at the bottom of the object
const keys = Object.keys(parent)
const beforeKey = last(firstPath)
const beforeIndex = keys.indexOf(beforeKey)
const keysToMove = (beforeIndex !== -1)
? keys.slice(beforeIndex)
: []
const moveActions = keysToMove.map(key => {
const movePath = compileJSONPointer(parentPath.concat(key))
return {
op: 'move',
from: movePath,
path: movePath
}
})
return removeActions.concat(insertActions, moveActions)
}
}
/**
* Create a JSONPatch for a remove action
* @param {Path} path
* @return {JSONPatchDocument}
*/
export function remove (path) {
return [{
op: 'remove',
path: compileJSONPointer(path)
}]
}
/**
* Create a JSONPatch for a multiple remove action
* @param {Path[]} paths
* @return {JSONPatchDocument}
*/
export function removeAll (paths) {
return paths
.map(path => ({
op: 'remove',
path: compileJSONPointer(path)
}))
.reverse() // reverse is needed for arrays: delete the last index first
}

View File

@ -1,9 +0,0 @@
function createClipboard () {
const contents = undefined
return {
isEmpty: () => {},
cut: function () {}
}
}

View File

@ -50,5 +50,5 @@
*/
/**
* @typedef {{paths: Path[]} | {beforePath: Path}} Selection
* @typedef {{paths: Path[], pathsMap: Object<string, boolean>}} | {beforePath: Path} | {afterPath: Path}} Selection
*/

62
src/utils/arrayUtils.js Normal file
View File

@ -0,0 +1,62 @@
/**
* Comparator to sort an array in ascending order
*
* Usage:
* [4,2,5].sort(compareAsc) // [2,4,5]
*
* @param a
* @param b
* @return {number}
*/
export function compareAsc (a, b) {
return a > b ? 1 : a < b ? -1 : 0
}
/**
* Comparator to sort an array in ascending order
*
* Usage:
* [4,2,5].sort(compareDesc) // [5,4,2]
*
* @param a
* @param b
* @return {number}
*/
export function compareDesc (a, b) {
return a > b ? -1 : a < b ? 1 : 0
}
/**
* Test whether all items of an array are strictly equal
* @param {Array} a
* @param {Array} b
*/
export function strictShallowEqual (a, b) {
if (a.length !== b.length) {
return false
}
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
return false
}
}
return true
}
export function compareArrays(a, b) {
const minLength = Math.min(a.length, b.length)
for (let i = 0; i < minLength; i++) {
if (a[i] < b[i]) {
return -1
}
if (a[i] > b[i]) {
return 1
}
}
return a.length - b.length
}

View File

@ -0,0 +1,27 @@
import { compareArrays } from './arrayUtils'
test('compareArrays', () => {
expect(compareArrays([], [])).toEqual(0)
expect(compareArrays(['a'], ['a'])).toEqual(0)
expect(compareArrays(['a'], ['b'])).toEqual(-1)
expect(compareArrays(['b'], ['a'])).toEqual(1)
expect(compareArrays(['a'], ['a', 'b'])).toEqual(-1)
expect(compareArrays(['a', 'b'], ['a'])).toEqual(1)
expect(compareArrays(['a', 'b'], ['a', 'b'])).toEqual(0)
const arrays = [
['b', 'a'],
['a'],
[],
['b', 'c'],
['b'],
]
expect(arrays.sort(compareArrays)).toEqual([
[],
['a'],
['b'],
['b', 'a'],
['b', 'c']
])
})