Implement selecting one or multiple nodes by dragging

This commit is contained in:
josdejong 2020-06-24 14:20:25 +02:00
parent 20a067508d
commit 9a23799d5f
10 changed files with 294 additions and 30 deletions

View File

@ -64,8 +64,7 @@
flex: 1; flex: 1;
overflow: auto; overflow: auto;
.bottom { //display: flex;
height: $input-padding; //flex-direction: column;
}
} }
} }

View File

@ -11,6 +11,7 @@
import { faSearch, faUndo, faRedo } from '@fortawesome/free-solid-svg-icons' import { faSearch, faUndo, faRedo } from '@fortawesome/free-solid-svg-icons'
import { createHistory } from './history.js' import { createHistory } from './history.js'
import Node from './JSONNode.svelte' import Node from './JSONNode.svelte'
import { expandSelection } from './selection.js'
import { import {
existsIn, existsIn,
getIn, getIn,
@ -36,6 +37,19 @@
export let doc = {} export let doc = {}
let state = undefined let state = undefined
let selection = null
let selectionMap = {}
$: {
selectionMap = {}
if (selection != null) {
selection.forEach(path => {
selectionMap[compileJSONPointer(path)] = true
})
}
}
$: console.log('selectionMap', selectionMap)
export let onChangeJson = () => {} export let onChangeJson = () => {}
@ -257,6 +271,14 @@
state = setIn(state, path.concat(STATE_LIMIT), limit) state = setIn(state, path.concat(STATE_LIMIT), limit)
} }
function handleSelect (anchorPath, focusPath) {
if (anchorPath && focusPath) {
selection = expandSelection(doc, state, anchorPath, focusPath)
} else {
selection = null
}
}
/** /**
* Expand all nodes on given path * Expand all nodes on given path
* @param {Path} path * @param {Path} path
@ -373,8 +395,9 @@
onPatch={handlePatch} onPatch={handlePatch}
onExpand={handleExpand} onExpand={handleExpand}
onLimit={handleLimit} onLimit={handleLimit}
onSelect={handleSelect}
selectionMap={selectionMap}
/> />
<div class='bottom'></div>
</div> </div>
</div> </div>

View File

@ -5,6 +5,18 @@
font-size: $font-size; font-size: $font-size;
color: $black; color: $black;
&.root {
min-height: 100%;
padding-bottom: $input-padding;
box-sizing: border-box;
}
&.selected .header,
&.selected .contents,
&.selected .footer {
background-color: $selection-background;
}
.header { .header {
position: relative; position: relative;
} }
@ -27,6 +39,7 @@
} }
.footer { .footer {
display: inline-block;
padding-left: $line-height + $input-padding; // must be the same as the width of the expand button padding-left: $line-height + $input-padding; // must be the same as the width of the expand button
} }
} }
@ -86,10 +99,6 @@
} }
} }
.items,
.props {
padding-left: $indentation-width;
}
.value { .value {
&.string { &.string {
@ -125,8 +134,6 @@
} }
div.limit { div.limit {
margin-left: $indentation-width;
button { button {
background: none; background: none;
border: none; border: none;

View File

@ -1,18 +1,21 @@
<script> <script>
import { debounce, initial } from 'lodash-es' import { debounce, initial, isEqual } from 'lodash-es'
import { import {
DEBOUNCE_DELAY, DEFAULT_LIMIT, DEBOUNCE_DELAY,
STATE_EXPANDED, STATE_LIMIT, STATE_PROPS, DEFAULT_LIMIT,
STATE_EXPANDED,
STATE_LIMIT,
STATE_PROPS,
STATE_SEARCH_PROPERTY, STATE_SEARCH_PROPERTY,
STATE_SEARCH_VALUE STATE_SEARCH_VALUE,
INDENTATION_WIDTH
} from './constants.js' } from './constants.js'
import { singleton } from './singleton.js'
import { getPlainText, setPlainText } from './utils/domUtils.js' import { getPlainText, setPlainText } from './utils/domUtils.js'
import Icon from 'svelte-awesome' import Icon from 'svelte-awesome'
import { faCaretDown, faCaretRight } from '@fortawesome/free-solid-svg-icons' import { faCaretDown, faCaretRight } from '@fortawesome/free-solid-svg-icons'
import classnames from 'classnames' import classnames from 'classnames'
import { findUniqueName } from './utils/stringUtils.js'
import { isUrl, stringConvert, valueType } from './utils/typeUtils' import { isUrl, stringConvert, valueType } from './utils/typeUtils'
import { updateProps } from './utils/updateProps.js'
import { compileJSONPointer } from './utils/jsonPointer' import { compileJSONPointer } from './utils/jsonPointer'
export let key = undefined // only applicable for object properties export let key = undefined // only applicable for object properties
@ -23,6 +26,8 @@
export let onPatch export let onPatch
export let onExpand export let onExpand
export let onLimit export let onLimit
export let onSelect
export let selectionMap
$: expanded = state && state[STATE_EXPANDED] $: expanded = state && state[STATE_EXPANDED]
$: limit = state && state[STATE_LIMIT] $: limit = state && state[STATE_LIMIT]
@ -67,6 +72,10 @@
} }
} }
function getIndentationStyle(level) {
return `margin-left: ${level * INDENTATION_WIDTH}px`
}
function getValueClass (value, searchResult) { function getValueClass (value, searchResult) {
const type = valueType (value) const type = valueType (value)
@ -182,11 +191,73 @@
function handleShowMore () { function handleShowMore () {
onLimit(path, (Math.round(limit / DEFAULT_LIMIT) + 1) * DEFAULT_LIMIT) onLimit(path, (Math.round(limit / DEFAULT_LIMIT) + 1) * DEFAULT_LIMIT)
} }
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)
}
// check if the mouse down is not happening in the key or value input fields
if (event.target.contentEditable !== 'true') {
// initialize dragging a selection
singleton.mousedown = true
singleton.selectionAnchor = path
singleton.selectionFocus = null
event.stopPropagation()
}
// we attache the mouse up event listener to the global document,
// so we will not miss if the mouse up is happening outside of the editor
document.addEventListener('mouseup', handleMouseUp)
}
function handleMouseMove (event) {
if (singleton.mousedown) {
event.preventDefault()
event.stopPropagation()
if (singleton.selectionFocus == null) {
// First move event, no selection yet.
// Clear the default selection of the browser
if (window.getSelection) {
window.getSelection().empty()
}
}
if (!isEqual(path, singleton.selectionFocus)) {
singleton.selectionFocus = path
onSelect(singleton.selectionAnchor, singleton.selectionFocus)
}
}
}
function handleMouseUp (event) {
if (singleton.mousedown) {
event.preventDefault()
event.stopPropagation()
singleton.mousedown = false
}
document.removeEventListener('mouseup', handleMouseUp)
}
// FIXME: this is not efficient. Create a nested object with the selection and pass that
$: selected = selectionMap[compileJSONPointer(path)] === true
</script> </script>
<div class='json-node'> <div
class='json-node'
class:root={path.length === 0}
class:selected={selected}
on:mousedown={handleMouseDown}
on:mousemove={handleMouseMove}
>
{#if type === 'array'} {#if type === 'array'}
<div class='header'> <div class='header' style={getIndentationStyle(path.length)}>
<button <button
class='expand' class='expand'
on:click={toggleExpand} on:click={toggleExpand}
@ -230,20 +301,22 @@
onPatch={onPatch} onPatch={onPatch}
onExpand={onExpand} onExpand={onExpand}
onLimit={onLimit} onLimit={onLimit}
onSelect={onSelect}
selectionMap={selectionMap}
/> />
{/each} {/each}
{#if limited} {#if limited}
<div class='limit'> <div class="limit" style={getIndentationStyle(path.length + 2)}>
(showing {limit} of {value.length} items <button on:click={handleShowMore}>show more</button> <button on:click={handleShowAll}>show all</button>) (showing {limit} of {value.length} items <button on:click={handleShowMore}>show more</button> <button on:click={handleShowAll}>show all</button>)
</div> </div>
{/if} {/if}
</div> </div>
<div class="footer"> <div class="footer" style={getIndentationStyle(path.length)}>
<span class="delimiter">]</span> <span class="delimiter">]</span>
</div> </div>
{/if} {/if}
{:else if type === 'object'} {:else if type === 'object'}
<div class='header'> <div class='header' style={getIndentationStyle(path.length)}>
<button <button
class='expand' class='expand'
on:click={toggleExpand} on:click={toggleExpand}
@ -287,15 +360,17 @@
onPatch={onPatch} onPatch={onPatch}
onExpand={onExpand} onExpand={onExpand}
onLimit={onLimit} onLimit={onLimit}
onSelect={onSelect}
selectionMap={selectionMap}
/> />
{/each} {/each}
</div> </div>
<div class="footer"> <div class="footer" style={getIndentationStyle(path.length)}>
<span class="delimiter">}</span> <span class="delimiter">}</span>
</div> </div>
{/if} {/if}
{:else} {:else}
<div class="contents"> <div class="contents" style={getIndentationStyle(path.length)}>
{#if typeof key === 'string'} {#if typeof key === 'string'}
<div <div
class={keyClass} class={keyClass}

View File

@ -8,3 +8,5 @@ export const STATE_SEARCH_VALUE = Symbol('search:value')
export const SCROLL_DURATION = 300 // ms export const SCROLL_DURATION = 300 // ms
export const DEBOUNCE_DELAY = 300 export const DEBOUNCE_DELAY = 300
export const DEFAULT_LIMIT = 100 export const DEFAULT_LIMIT = 100
export const INDENTATION_WIDTH = 18 // pixels IMPORTANT: keep in sync with sass constant $indentation-width

83
src/selection.js Normal file
View File

@ -0,0 +1,83 @@
import { isEqual } from 'lodash-es'
import { STATE_PROPS } from './constants.js'
import { getIn } from './utils/immutabilityHelpers.js'
import { isObject } from './utils/typeUtils.js'
/**
* Expand a selection start and end into an array containing all paths
* between (and including) start and end
*
* @param {JSON} doc
* @param {JSON} state
* @param {Path} anchorPath
* @param {Path} focusPath
* @return {Path[]} selection
*/
export function expandSelection (doc, state, anchorPath, focusPath) {
if (isEqual(anchorPath, focusPath)) {
// just a single node
return [ anchorPath ]
} else {
// multiple nodes
let sharedPath = findSharedPath(anchorPath, focusPath)
if (anchorPath.length === sharedPath.length || focusPath.length === sharedPath.length) {
// a parent and a child, like ['arr', 1] and ['arr']
return [ sharedPath ]
}
const anchorProp = anchorPath[sharedPath.length]
const focusProp = focusPath[sharedPath.length]
const value = getIn(doc, sharedPath)
if (isObject(value)) {
const props = getIn(state, sharedPath.concat(STATE_PROPS))
const anchorIndex = props.findIndex(prop => prop.key === anchorProp)
const focusIndex = props.findIndex(prop => prop.key === focusProp)
if (anchorIndex !== -1 && focusIndex !== -1) {
const startIndex = Math.min(anchorIndex, focusIndex)
const endIndex = Math.max(anchorIndex, focusIndex)
const paths = []
for (let i = startIndex; i <= endIndex; i++) {
paths.push(sharedPath.concat(props[i].key))
}
return paths
}
}
if (Array.isArray(value)) {
const startIndex = Math.min(anchorProp, focusProp)
const endIndex = Math.max(anchorProp, focusProp)
const paths = []
for (let i = startIndex; i <= endIndex; i++) {
paths.push(sharedPath.concat(i))
}
return paths
}
}
// should never happen
return []
}
/**
* Find the common path of two paths.
* For example findCommonRoot(['arr', '1', 'name'], ['arr', '1', 'address', 'contact']) returns ['arr', '1']
* @param {Path} path1
* @param {Path} path2
* @return {Path}
*/
// TODO: write unit tests for findSharedPath
export function findSharedPath (path1, path2) {
let i = 0;
while (i < path1.length && path1[i] === path2[i]) {
i++;
}
return path1.slice(0, i)
}

68
src/selection.test.js Normal file
View File

@ -0,0 +1,68 @@
import assert from 'assert'
import { expandSelection } from './selection.js'
import { syncState } from './utils/syncState.js'
describe ('selection', () => {
const doc = {
"obj": {
"arr": [1,2, {"first":3,"last":4}]
},
"str": "hello world",
"nill": null,
"bool": false
}
const state = syncState(doc, undefined, [], () => true)
it('should expand a selection (object)', () => {
const start = ['obj', 'arr', '2', 'last']
const end = ['nill']
const actual = expandSelection(doc, state, start, end)
assert.deepStrictEqual(actual, [
['obj'],
['str'],
['nill']
])
})
it('should expand a selection (array)', () => {
const start = ['obj', 'arr', 1]
const end = ['obj', 'arr', 0] // note the "wrong" order of start and end
const actual = expandSelection(doc, state, start, end)
assert.deepStrictEqual(actual, [
['obj', 'arr', 0],
['obj', 'arr', 1]
])
})
it('should expand a selection (array) (2)', () => {
const start = ['obj', 'arr', 1] // child
const end = ['obj', 'arr'] // parent
const actual = expandSelection(doc, state, start, end)
assert.deepStrictEqual(actual, [
['obj', 'arr']
])
})
it('should expand a selection (value)', () => {
const start = ['obj', 'arr', 2, 'first']
const end = ['obj', 'arr', 2, 'first']
const actual = expandSelection(doc, state, start, end)
assert.deepStrictEqual(actual, [
['obj', 'arr', 2, 'first']
])
})
it('should expand a selection (value)', () => {
const start = ['obj', 'arr']
const end = ['obj', 'arr']
const actual = expandSelection(doc, state, start, end)
assert.deepStrictEqual(actual, [
['obj', 'arr']
])
})
})

5
src/singleton.js Normal file
View File

@ -0,0 +1,5 @@
export const singleton = {
mousedown: false,
selectionAnchor: null, // Path
selectionFocus: null // Path
}

View File

@ -24,12 +24,14 @@ $error-border-color: #ffd700;
$gray: #9d9d9d; $gray: #9d9d9d;
$gray-icon: $gray; $gray-icon: $gray;
$light-gray: #c0c0c0; $light-gray: #c0c0c0;
$selection-background: #e0e0e0;
$line-height: 18px; $line-height: 18px;
$indentation-width: 18px; $indentation-width: 18px; // IMPORTANT: keep in sync with js constant INDENTATION_WIDTH
$menu-button-size: 32px; $menu-button-size: 32px;
$input-padding: 5px; $input-padding: 5px;
$search-box-offset: 10px; $search-box-offset: 10px;
$border-radius: 3px; $border-radius: 3px;
$menu-padding: 5px; $menu-padding: 5px;
$bottom-height: 5px;

View File

@ -24,9 +24,9 @@ describe('syncState', () => {
const expectedState = {} const expectedState = {}
expectedState[STATE_EXPANDED] = true expectedState[STATE_EXPANDED] = true
expectedState[STATE_PROPS] = [ expectedState[STATE_PROPS] = [
{ 'id': '1', 'key': 'array' }, { 'id': state[STATE_PROPS][0].id, 'key': 'array' },
{ 'id': '2', 'key': 'object' }, { 'id': state[STATE_PROPS][1].id, 'key': 'object' },
{ 'id': '3', 'key': 'value' } { 'id': state[STATE_PROPS][2].id, 'key': 'value' }
] ]
expectedState.array = [] expectedState.array = []
expectedState.array[STATE_EXPANDED] = true expectedState.array[STATE_EXPANDED] = true
@ -34,13 +34,13 @@ describe('syncState', () => {
expectedState.array[2] = {} expectedState.array[2] = {}
expectedState.array[2][STATE_EXPANDED] = false expectedState.array[2][STATE_EXPANDED] = false
expectedState.array[2][STATE_PROPS] = [ expectedState.array[2][STATE_PROPS] = [
{ 'id': '4', 'key': 'c' } // FIXME: props should not be created because node is not expande { 'id': state.array[2][STATE_PROPS][0].id, 'key': 'c' } // FIXME: props should not be created because node is not expanded
] ]
expectedState.object = {} expectedState.object = {}
expectedState.object[STATE_EXPANDED] = true expectedState.object[STATE_EXPANDED] = true
expectedState.object[STATE_PROPS] = [ expectedState.object[STATE_PROPS] = [
{ 'id': '5', 'key': 'a' }, { 'id': state.object[STATE_PROPS][0].id, 'key': 'a' },
{ 'id': '6', 'key': 'b' } { 'id': state.object[STATE_PROPS][1].id, 'key': 'b' }
] ]
assert.deepStrictEqual(state, expectedState) assert.deepStrictEqual(state, expectedState)