Implement Duplicate

This commit is contained in:
Jos de Jong 2020-07-22 11:55:11 +02:00
parent 401a6e19fd
commit 9ac6ca95c4
6 changed files with 146 additions and 28 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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', () => {

View File

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