Implement selecting one or multiple nodes by dragging
This commit is contained in:
parent
20a067508d
commit
9a23799d5f
|
@ -64,8 +64,7 @@
|
|||
flex: 1;
|
||||
overflow: auto;
|
||||
|
||||
.bottom {
|
||||
height: $input-padding;
|
||||
}
|
||||
//display: flex;
|
||||
//flex-direction: column;
|
||||
}
|
||||
}
|
|
@ -11,6 +11,7 @@
|
|||
import { faSearch, faUndo, faRedo } from '@fortawesome/free-solid-svg-icons'
|
||||
import { createHistory } from './history.js'
|
||||
import Node from './JSONNode.svelte'
|
||||
import { expandSelection } from './selection.js'
|
||||
import {
|
||||
existsIn,
|
||||
getIn,
|
||||
|
@ -36,6 +37,19 @@
|
|||
|
||||
export let doc = {}
|
||||
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 = () => {}
|
||||
|
||||
|
@ -257,6 +271,14 @@
|
|||
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
|
||||
* @param {Path} path
|
||||
|
@ -373,8 +395,9 @@
|
|||
onPatch={handlePatch}
|
||||
onExpand={handleExpand}
|
||||
onLimit={handleLimit}
|
||||
onSelect={handleSelect}
|
||||
selectionMap={selectionMap}
|
||||
/>
|
||||
<div class='bottom'></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -5,6 +5,18 @@
|
|||
font-size: $font-size;
|
||||
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 {
|
||||
position: relative;
|
||||
}
|
||||
|
@ -27,6 +39,7 @@
|
|||
}
|
||||
|
||||
.footer {
|
||||
display: inline-block;
|
||||
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 {
|
||||
|
||||
&.string {
|
||||
|
@ -125,8 +134,6 @@
|
|||
}
|
||||
|
||||
div.limit {
|
||||
margin-left: $indentation-width;
|
||||
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
|
|
|
@ -1,18 +1,21 @@
|
|||
<script>
|
||||
import { debounce, initial } from 'lodash-es'
|
||||
import { debounce, initial, isEqual } from 'lodash-es'
|
||||
import {
|
||||
DEBOUNCE_DELAY, DEFAULT_LIMIT,
|
||||
STATE_EXPANDED, STATE_LIMIT, STATE_PROPS,
|
||||
DEBOUNCE_DELAY,
|
||||
DEFAULT_LIMIT,
|
||||
STATE_EXPANDED,
|
||||
STATE_LIMIT,
|
||||
STATE_PROPS,
|
||||
STATE_SEARCH_PROPERTY,
|
||||
STATE_SEARCH_VALUE
|
||||
STATE_SEARCH_VALUE,
|
||||
INDENTATION_WIDTH
|
||||
} from './constants.js'
|
||||
import { singleton } from './singleton.js'
|
||||
import { getPlainText, setPlainText } from './utils/domUtils.js'
|
||||
import Icon from 'svelte-awesome'
|
||||
import { faCaretDown, faCaretRight } from '@fortawesome/free-solid-svg-icons'
|
||||
import classnames from 'classnames'
|
||||
import { findUniqueName } from './utils/stringUtils.js'
|
||||
import { isUrl, stringConvert, valueType } from './utils/typeUtils'
|
||||
import { updateProps } from './utils/updateProps.js'
|
||||
import { compileJSONPointer } from './utils/jsonPointer'
|
||||
|
||||
export let key = undefined // only applicable for object properties
|
||||
|
@ -23,6 +26,8 @@
|
|||
export let onPatch
|
||||
export let onExpand
|
||||
export let onLimit
|
||||
export let onSelect
|
||||
export let selectionMap
|
||||
|
||||
$: expanded = state && state[STATE_EXPANDED]
|
||||
$: limit = state && state[STATE_LIMIT]
|
||||
|
@ -67,6 +72,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
function getIndentationStyle(level) {
|
||||
return `margin-left: ${level * INDENTATION_WIDTH}px`
|
||||
}
|
||||
|
||||
function getValueClass (value, searchResult) {
|
||||
const type = valueType (value)
|
||||
|
||||
|
@ -182,11 +191,73 @@
|
|||
function handleShowMore () {
|
||||
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>
|
||||
|
||||
<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'}
|
||||
<div class='header'>
|
||||
<div class='header' style={getIndentationStyle(path.length)}>
|
||||
<button
|
||||
class='expand'
|
||||
on:click={toggleExpand}
|
||||
|
@ -230,20 +301,22 @@
|
|||
onPatch={onPatch}
|
||||
onExpand={onExpand}
|
||||
onLimit={onLimit}
|
||||
onSelect={onSelect}
|
||||
selectionMap={selectionMap}
|
||||
/>
|
||||
{/each}
|
||||
{#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>)
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="footer" style={getIndentationStyle(path.length)}>
|
||||
<span class="delimiter">]</span>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if type === 'object'}
|
||||
<div class='header'>
|
||||
<div class='header' style={getIndentationStyle(path.length)}>
|
||||
<button
|
||||
class='expand'
|
||||
on:click={toggleExpand}
|
||||
|
@ -287,15 +360,17 @@
|
|||
onPatch={onPatch}
|
||||
onExpand={onExpand}
|
||||
onLimit={onLimit}
|
||||
onSelect={onSelect}
|
||||
selectionMap={selectionMap}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="footer" style={getIndentationStyle(path.length)}>
|
||||
<span class="delimiter">}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="contents">
|
||||
<div class="contents" style={getIndentationStyle(path.length)}>
|
||||
{#if typeof key === 'string'}
|
||||
<div
|
||||
class={keyClass}
|
||||
|
|
|
@ -8,3 +8,5 @@ export const STATE_SEARCH_VALUE = Symbol('search:value')
|
|||
export const SCROLL_DURATION = 300 // ms
|
||||
export const DEBOUNCE_DELAY = 300
|
||||
export const DEFAULT_LIMIT = 100
|
||||
|
||||
export const INDENTATION_WIDTH = 18 // pixels IMPORTANT: keep in sync with sass constant $indentation-width
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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']
|
||||
])
|
||||
})
|
||||
})
|
|
@ -0,0 +1,5 @@
|
|||
export const singleton = {
|
||||
mousedown: false,
|
||||
selectionAnchor: null, // Path
|
||||
selectionFocus: null // Path
|
||||
}
|
|
@ -24,12 +24,14 @@ $error-border-color: #ffd700;
|
|||
$gray: #9d9d9d;
|
||||
$gray-icon: $gray;
|
||||
$light-gray: #c0c0c0;
|
||||
$selection-background: #e0e0e0;
|
||||
|
||||
$line-height: 18px;
|
||||
$indentation-width: 18px;
|
||||
$indentation-width: 18px; // IMPORTANT: keep in sync with js constant INDENTATION_WIDTH
|
||||
$menu-button-size: 32px;
|
||||
$input-padding: 5px;
|
||||
$search-box-offset: 10px;
|
||||
$border-radius: 3px;
|
||||
|
||||
$menu-padding: 5px;
|
||||
$bottom-height: 5px;
|
||||
|
|
|
@ -24,9 +24,9 @@ describe('syncState', () => {
|
|||
const expectedState = {}
|
||||
expectedState[STATE_EXPANDED] = true
|
||||
expectedState[STATE_PROPS] = [
|
||||
{ 'id': '1', 'key': 'array' },
|
||||
{ 'id': '2', 'key': 'object' },
|
||||
{ 'id': '3', 'key': 'value' }
|
||||
{ 'id': state[STATE_PROPS][0].id, 'key': 'array' },
|
||||
{ 'id': state[STATE_PROPS][1].id, 'key': 'object' },
|
||||
{ 'id': state[STATE_PROPS][2].id, 'key': 'value' }
|
||||
]
|
||||
expectedState.array = []
|
||||
expectedState.array[STATE_EXPANDED] = true
|
||||
|
@ -34,13 +34,13 @@ describe('syncState', () => {
|
|||
expectedState.array[2] = {}
|
||||
expectedState.array[2][STATE_EXPANDED] = false
|
||||
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[STATE_EXPANDED] = true
|
||||
expectedState.object[STATE_PROPS] = [
|
||||
{ 'id': '5', 'key': 'a' },
|
||||
{ 'id': '6', 'key': 'b' }
|
||||
{ 'id': state.object[STATE_PROPS][0].id, 'key': 'a' },
|
||||
{ 'id': state.object[STATE_PROPS][1].id, 'key': 'b' }
|
||||
]
|
||||
|
||||
assert.deepStrictEqual(state, expectedState)
|
||||
|
|
Loading…
Reference in New Issue