Implement Duplicate
This commit is contained in:
parent
401a6e19fd
commit
9ac6ca95c4
|
@ -1,7 +1,7 @@
|
||||||
<script>
|
<script>
|
||||||
import { tick } from 'svelte'
|
import { tick } from 'svelte'
|
||||||
import {
|
import {
|
||||||
append,
|
append, duplicate,
|
||||||
insertBefore,
|
insertBefore,
|
||||||
removeAll,
|
removeAll,
|
||||||
replace
|
replace
|
||||||
|
@ -15,10 +15,14 @@
|
||||||
} from './constants.js'
|
} from './constants.js'
|
||||||
import SearchBox from './SearchBox.svelte'
|
import SearchBox from './SearchBox.svelte'
|
||||||
import Icon from 'svelte-awesome'
|
import Icon from 'svelte-awesome'
|
||||||
import { faCut, faCopy, faPaste, faSearch, faUndo, faRedo } from '@fortawesome/free-solid-svg-icons'
|
import { faCut, faClone, faCopy, faPaste, faSearch, faUndo, faRedo } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { createHistory } from './history.js'
|
import { createHistory } from './history.js'
|
||||||
import JSONNode from './JSONNode.svelte'
|
import JSONNode from './JSONNode.svelte'
|
||||||
import { expandSelection } from './selection.js'
|
import {
|
||||||
|
createPathsMap,
|
||||||
|
createSelectionFromOperations,
|
||||||
|
expandSelection
|
||||||
|
} from './selection.js'
|
||||||
import { isContentEditableDiv } from './utils/domUtils.js'
|
import { isContentEditableDiv } from './utils/domUtils.js'
|
||||||
import {
|
import {
|
||||||
existsIn,
|
existsIn,
|
||||||
|
@ -45,9 +49,8 @@
|
||||||
export let onChangeJson = () => {}
|
export let onChangeJson = () => {}
|
||||||
|
|
||||||
let clipboard = null
|
let clipboard = null
|
||||||
$: canCut = selection != null && selection.paths != null
|
$: hasSelectionContents = selection != null && selection.paths != null
|
||||||
$: canCopy = selection != null && selection.paths != null
|
$: hasClipboardContents = clipboard != null && selection != null
|
||||||
$: canPaste = clipboard != null && selection != null
|
|
||||||
|
|
||||||
$: state = syncState(doc, state, [], (path) => path.length < 1)
|
$: state = syncState(doc, state, [], (path) => path.length < 1)
|
||||||
|
|
||||||
|
@ -168,12 +171,12 @@
|
||||||
const props = getIn(state, parentPath.concat(STATE_PROPS))
|
const props = getIn(state, parentPath.concat(STATE_PROPS))
|
||||||
const nextKeys = getNextKeys(props, parentPath, beforeKey, true)
|
const nextKeys = getNextKeys(props, parentPath, beforeKey, true)
|
||||||
const operations = insertBefore(doc, selection.beforePath, clipboard, nextKeys)
|
const operations = insertBefore(doc, selection.beforePath, clipboard, nextKeys)
|
||||||
const newSelection = createNewSelection(operations)
|
const newSelection = createSelectionFromOperations(operations)
|
||||||
|
|
||||||
handlePatch(operations, newSelection)
|
handlePatch(operations, newSelection)
|
||||||
} else if (selection.appendPath) {
|
} else if (selection.appendPath) {
|
||||||
const operations = append(doc, selection.appendPath, clipboard)
|
const operations = append(doc, selection.appendPath, clipboard)
|
||||||
const newSelection = createNewSelection(operations)
|
const newSelection = createSelectionFromOperations(operations)
|
||||||
|
|
||||||
handlePatch(operations, newSelection)
|
handlePatch(operations, newSelection)
|
||||||
} else if (selection.paths) {
|
} else if (selection.paths) {
|
||||||
|
@ -183,13 +186,31 @@
|
||||||
const props = getIn(state, parentPath.concat(STATE_PROPS))
|
const props = getIn(state, parentPath.concat(STATE_PROPS))
|
||||||
const nextKeys = getNextKeys(props, parentPath, beforeKey, true)
|
const nextKeys = getNextKeys(props, parentPath, beforeKey, true)
|
||||||
const operations = replace(doc, selection.paths, clipboard, nextKeys)
|
const operations = replace(doc, selection.paths, clipboard, nextKeys)
|
||||||
const newSelection = createNewSelection(operations)
|
const newSelection = createSelectionFromOperations(operations)
|
||||||
|
|
||||||
handlePatch(operations, newSelection)
|
handlePatch(operations, newSelection)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDuplicate() {
|
||||||
|
if (selection && selection.paths) {
|
||||||
|
console.log('duplicate', { selection })
|
||||||
|
|
||||||
|
const lastPath = last(selection.paths) // FIXME: here we assume selection.paths is sorted correctly, that's a dangerous assumption
|
||||||
|
const parentPath = initial(lastPath)
|
||||||
|
const beforeKey = last(lastPath)
|
||||||
|
const props = getIn(state, parentPath.concat(STATE_PROPS))
|
||||||
|
const nextKeys = getNextKeys(props, parentPath, beforeKey, false)
|
||||||
|
|
||||||
|
const operations = duplicate(doc, selection.paths, nextKeys)
|
||||||
|
const newSelection = createSelectionFromOperations(operations)
|
||||||
|
|
||||||
|
console.log('newSelection', newSelection)
|
||||||
|
handlePatch(operations, newSelection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: cleanup
|
// TODO: cleanup
|
||||||
$: console.log('doc', doc)
|
$: console.log('doc', doc)
|
||||||
$: console.log('state', state)
|
$: console.log('state', state)
|
||||||
|
@ -370,16 +391,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPathsMap (paths) {
|
|
||||||
const pathsMap = {}
|
|
||||||
|
|
||||||
paths.forEach(path => {
|
|
||||||
pathsMap[compileJSONPointer(path)] = true
|
|
||||||
})
|
|
||||||
|
|
||||||
return pathsMap
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expand all nodes on given path
|
* Expand all nodes on given path
|
||||||
* @param {Path} path
|
* @param {Path} path
|
||||||
|
@ -417,6 +428,10 @@
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
handlePaste()
|
handlePaste()
|
||||||
}
|
}
|
||||||
|
if (combo === 'Ctrl+D' || combo === 'Command+D') {
|
||||||
|
event.preventDefault()
|
||||||
|
handleDuplicate()
|
||||||
|
}
|
||||||
if (combo === 'Escape') {
|
if (combo === 'Escape') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
selection = null
|
selection = null
|
||||||
|
@ -467,7 +482,7 @@
|
||||||
<button
|
<button
|
||||||
class="button cut"
|
class="button cut"
|
||||||
on:click={handleCut}
|
on:click={handleCut}
|
||||||
disabled={!canCut}
|
disabled={!hasSelectionContents}
|
||||||
title="Cut (Ctrl+X)"
|
title="Cut (Ctrl+X)"
|
||||||
>
|
>
|
||||||
<Icon data={faCut} />
|
<Icon data={faCut} />
|
||||||
|
@ -475,7 +490,7 @@
|
||||||
<button
|
<button
|
||||||
class="button copy"
|
class="button copy"
|
||||||
on:click={handleCopy}
|
on:click={handleCopy}
|
||||||
disabled={!canCopy}
|
disabled={!hasSelectionContents}
|
||||||
title="Copy (Ctrl+C)"
|
title="Copy (Ctrl+C)"
|
||||||
>
|
>
|
||||||
<Icon data={faCopy} />
|
<Icon data={faCopy} />
|
||||||
|
@ -483,7 +498,7 @@
|
||||||
<button
|
<button
|
||||||
class="button paste"
|
class="button paste"
|
||||||
on:click={handlePaste}
|
on:click={handlePaste}
|
||||||
disabled={!canPaste}
|
disabled={!hasClipboardContents}
|
||||||
title="Paste (Ctrl+V)"
|
title="Paste (Ctrl+V)"
|
||||||
>
|
>
|
||||||
<Icon data={faPaste} />
|
<Icon data={faPaste} />
|
||||||
|
@ -491,6 +506,17 @@
|
||||||
|
|
||||||
<div class="separator"></div>
|
<div class="separator"></div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="button duplicate"
|
||||||
|
on:click={handleDuplicate}
|
||||||
|
disabled={!hasSelectionContents}
|
||||||
|
title="Duplicate (Ctrl+D)"
|
||||||
|
>
|
||||||
|
<Icon data={faClone} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="separator"></div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="button search"
|
class="button search"
|
||||||
on:click={handleToggleSearch}
|
on:click={handleToggleSearch}
|
||||||
|
|
|
@ -16,7 +16,7 @@ import { findUniqueName } from './utils/stringUtils'
|
||||||
* @param {string[]} nextKeys A list with all keys *after* the renamed key,
|
* @param {string[]} nextKeys A list with all keys *after* the renamed key,
|
||||||
* these keys will be moved down, so the renamed
|
* these keys will be moved down, so the renamed
|
||||||
* key will maintain it's position above these keys
|
* key will maintain it's position above these keys
|
||||||
* @return {Array}
|
* @return {JSONPatchDocument}
|
||||||
*/
|
*/
|
||||||
export function insertBefore (json, path, values, nextKeys) { // TODO: find a better name and define datastructure for values
|
export function insertBefore (json, path, values, nextKeys) { // TODO: find a better name and define datastructure for values
|
||||||
const parentPath = initial(path)
|
const parentPath = initial(path)
|
||||||
|
@ -59,7 +59,7 @@ export function insertBefore (json, path, values, nextKeys) { // TODO: find a b
|
||||||
* @param {JSON} json
|
* @param {JSON} json
|
||||||
* @param {Path} path
|
* @param {Path} path
|
||||||
* @param {Array.<{key?: string, value: JSON}>} values
|
* @param {Array.<{key?: string, value: JSON}>} values
|
||||||
* @return {Array}
|
* @return {JSONPatchDocument}
|
||||||
*/
|
*/
|
||||||
export function append (json, path, values) { // TODO: find a better name and define datastructure for values
|
export function append (json, path, values) { // TODO: find a better name and define datastructure for values
|
||||||
const parent = getIn(json, path)
|
const parent = getIn(json, path)
|
||||||
|
@ -94,7 +94,7 @@ export function append (json, path, values) { // TODO: find a better name and d
|
||||||
* @param {string[]} nextKeys A list with all keys *after* the renamed key,
|
* @param {string[]} nextKeys A list with all keys *after* the renamed key,
|
||||||
* these keys will be moved down, so the renamed
|
* these keys will be moved down, so the renamed
|
||||||
* key will maintain it's position above these keys
|
* key will maintain it's position above these keys
|
||||||
* @returns {Array}
|
* @returns {JSONPatchDocument}
|
||||||
*/
|
*/
|
||||||
export function rename(parentPath, oldKey, newKey, nextKeys) {
|
export function rename(parentPath, oldKey, newKey, nextKeys) {
|
||||||
return [
|
return [
|
||||||
|
@ -123,7 +123,7 @@ export function rename(parentPath, oldKey, newKey, nextKeys) {
|
||||||
* @param {string[]} nextKeys A list with all keys *after* the renamed key,
|
* @param {string[]} nextKeys A list with all keys *after* the renamed key,
|
||||||
* these keys will be moved down, so the renamed
|
* these keys will be moved down, so the renamed
|
||||||
* key will maintain it's position above these keys
|
* key will maintain it's position above these keys
|
||||||
* @return {Array}
|
* @return {JSONPatchDocument}
|
||||||
*/
|
*/
|
||||||
export function replace (json, paths, values, nextKeys) { // TODO: find a better name and define datastructure for values
|
export function replace (json, paths, values, nextKeys) { // TODO: find a better name and define datastructure for values
|
||||||
const firstPath = first(paths)
|
const firstPath = first(paths)
|
||||||
|
@ -173,6 +173,62 @@ export function replace (json, paths, values, nextKeys) { // TODO: find a bette
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a JSONPatch for a duplicate action.
|
||||||
|
*
|
||||||
|
* This function needs the current data in order to be able to determine
|
||||||
|
* a unique property name for the duplicated node in case of duplicating
|
||||||
|
* and object property
|
||||||
|
*
|
||||||
|
* @param {JSON} json
|
||||||
|
* @param {Path[]} paths
|
||||||
|
* @param {string[]} nextKeys A list with all keys *after* the renamed key,
|
||||||
|
* these keys will be moved down, so the renamed
|
||||||
|
* key will maintain it's position above these keys
|
||||||
|
* @return {JSONPatchDocument}
|
||||||
|
*/
|
||||||
|
export function duplicate (json, paths, nextKeys) {
|
||||||
|
const firstPath = first(paths)
|
||||||
|
const parentPath = initial(firstPath)
|
||||||
|
const parent = getIn(json, parentPath)
|
||||||
|
|
||||||
|
if (Array.isArray(parent)) {
|
||||||
|
const lastPath = last(paths)
|
||||||
|
const offset = lastPath ? (parseInt(last(lastPath), 10) + 1) : 0
|
||||||
|
|
||||||
|
return [
|
||||||
|
// copy operations
|
||||||
|
...paths.map((path, index) => ({
|
||||||
|
op: 'copy',
|
||||||
|
from: compileJSONPointer(path),
|
||||||
|
path: compileJSONPointer(parentPath.concat(index + offset))
|
||||||
|
})),
|
||||||
|
|
||||||
|
// move down operations
|
||||||
|
// move all lower down keys so the renamed key will maintain it's position
|
||||||
|
...nextKeys.map(key => moveDown(parentPath, key))
|
||||||
|
]
|
||||||
|
} else { // 'object'
|
||||||
|
return [
|
||||||
|
// copy operations
|
||||||
|
...paths.map(path => {
|
||||||
|
const prop = last(path)
|
||||||
|
const newProp = findUniqueName(prop, parent)
|
||||||
|
|
||||||
|
return {
|
||||||
|
op: 'copy',
|
||||||
|
from: compileJSONPointer(path),
|
||||||
|
path: compileJSONPointer(parentPath.concat(newProp))
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// move down operations
|
||||||
|
// move all lower down keys so the renamed key will maintain it's position
|
||||||
|
...nextKeys.map(key => moveDown(parentPath, key))
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a JSONPatch for a remove operation
|
* Create a JSONPatch for a remove operation
|
||||||
* @param {Path} path
|
* @param {Path} path
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { first, initial, isEmpty, isEqual, last } from 'lodash-es'
|
import { first, initial, isEmpty, isEqual, last } from 'lodash-es'
|
||||||
import { STATE_PROPS } from './constants.js'
|
import { STATE_PROPS } from './constants.js'
|
||||||
import { getIn } from './utils/immutabilityHelpers.js'
|
import { getIn } from './utils/immutabilityHelpers.js'
|
||||||
|
import { compileJSONPointer, parseJSONPointer } from './utils/jsonPointer.js'
|
||||||
import { isObject } from './utils/typeUtils.js'
|
import { isObject } from './utils/typeUtils.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -64,6 +65,35 @@ export function expandSelection (doc, state, anchorPath, focusPath) {
|
||||||
throw new Error('Failed to create selection')
|
throw new Error('Failed to create selection')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {JSONPatchDocument} operations
|
||||||
|
* @returns {MultiSelection}
|
||||||
|
*/
|
||||||
|
export function createSelectionFromOperations (operations) {
|
||||||
|
const paths = operations
|
||||||
|
.filter(operation => operation.op === 'add' || operation.op === 'copy')
|
||||||
|
.map(operation => parseJSONPointer(operation.path))
|
||||||
|
|
||||||
|
return {
|
||||||
|
paths,
|
||||||
|
pathsMap: createPathsMap(paths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Path[]} paths
|
||||||
|
* @returns {Object}
|
||||||
|
*/
|
||||||
|
export function createPathsMap (paths) {
|
||||||
|
const pathsMap = {}
|
||||||
|
|
||||||
|
paths.forEach(path => {
|
||||||
|
pathsMap[compileJSONPointer(path)] = true
|
||||||
|
})
|
||||||
|
|
||||||
|
return pathsMap
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the common path of two paths.
|
* Find the common path of two paths.
|
||||||
* For example findCommonRoot(['arr', '1', 'name'], ['arr', '1', 'address', 'contact']) returns ['arr', '1']
|
* For example findCommonRoot(['arr', '1', 'name'], ['arr', '1', 'address', 'contact']) returns ['arr', '1']
|
||||||
|
|
|
@ -9,9 +9,12 @@ export function findUniqueName (name, existingProps) {
|
||||||
let validName = name
|
let validName = name
|
||||||
let i = 1
|
let i = 1
|
||||||
|
|
||||||
|
// remove any " (copy)" or " (copy 2)" suffix from the name
|
||||||
|
const nameWithoutCopySuffix = name.replace(/ \(copy( \d+)?\)$/, '')
|
||||||
|
|
||||||
while (validName in existingProps) {
|
while (validName in existingProps) {
|
||||||
const copy = 'copy' + (i > 1 ? (' ' + i) : '')
|
const copy = 'copy' + (i > 1 ? (' ' + i) : '')
|
||||||
validName = `${name} (${copy})`
|
validName = `${nameWithoutCopySuffix} (${copy})`
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,10 @@ describe('stringUtils', () => {
|
||||||
assert.deepStrictEqual(findUniqueName('other', {'a': true, 'b': true, 'c': true}), 'other')
|
assert.deepStrictEqual(findUniqueName('other', {'a': true, 'b': true, 'c': true}), 'other')
|
||||||
assert.deepStrictEqual(findUniqueName('b', {'a': true, 'b': true, 'c': true}), 'b (copy)')
|
assert.deepStrictEqual(findUniqueName('b', {'a': true, 'b': true, 'c': true}), 'b (copy)')
|
||||||
assert.deepStrictEqual(findUniqueName('b', {'a': true, 'b': true, 'c': true, 'b (copy)': true}), 'b (copy 2)')
|
assert.deepStrictEqual(findUniqueName('b', {'a': true, 'b': true, 'c': true, 'b (copy)': true}), 'b (copy 2)')
|
||||||
|
assert.deepStrictEqual(findUniqueName('b (copy)', {'a': true, 'b': true, 'c': true, 'b (copy)': true}), 'b (copy 2)')
|
||||||
assert.deepStrictEqual(findUniqueName('b', {'a': true, 'b': true, 'c': true, 'b (copy)': true, 'b (copy 2)': true}), 'b (copy 3)')
|
assert.deepStrictEqual(findUniqueName('b', {'a': true, 'b': true, 'c': true, 'b (copy)': true, 'b (copy 2)': true}), 'b (copy 3)')
|
||||||
|
assert.deepStrictEqual(findUniqueName('b (copy)', {'a': true, 'b': true, 'c': true, 'b (copy)': true, 'b (copy 2)': true}), 'b (copy 3)')
|
||||||
|
assert.deepStrictEqual(findUniqueName('b (copy 2)', {'a': true, 'b': true, 'c': true, 'b (copy)': true, 'b (copy 2)': true}), 'b (copy 3)')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('toCapital', () => {
|
it('toCapital', () => {
|
||||||
|
|
|
@ -76,7 +76,7 @@ export function patchProps (state, operations) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (operation.op === 'add') {
|
if (operation.op === 'add' || operation.op === 'copy') {
|
||||||
const pathTo = parseJSONPointer(operation.path)
|
const pathTo = parseJSONPointer(operation.path)
|
||||||
const parentPath = initial(pathTo)
|
const parentPath = initial(pathTo)
|
||||||
const key = last(pathTo)
|
const key = last(pathTo)
|
||||||
|
|
Loading…
Reference in New Issue