From 8cec2df3c7b16b22e618d5376e775541a3986b63 Mon Sep 17 00:00:00 2001 From: Jos de Jong Date: Wed, 14 Oct 2020 17:52:51 +0200 Subject: [PATCH] Implement flexible expansion of parts of a large array --- src/components/treemode/CollapsedItems.scss | 57 +++++++++ src/components/treemode/CollapsedItems.svelte | 41 +++++++ src/components/treemode/JSONNode.scss | 16 --- src/components/treemode/JSONNode.svelte | 67 ++++++----- src/components/treemode/TreeMode.svelte | 19 ++- src/constants.js | 4 +- src/logic/documentState.js | 61 ++++++---- src/logic/documentState.test.js | 8 +- src/logic/expandItemsSections.js | 94 +++++++++++++++ src/logic/expandItemsSections.test.js | 113 ++++++++++++++++++ src/styles.scss | 3 + src/types.js | 6 +- 12 files changed, 406 insertions(+), 83 deletions(-) create mode 100644 src/components/treemode/CollapsedItems.scss create mode 100644 src/components/treemode/CollapsedItems.svelte create mode 100644 src/logic/expandItemsSections.js create mode 100644 src/logic/expandItemsSections.test.js diff --git a/src/components/treemode/CollapsedItems.scss b/src/components/treemode/CollapsedItems.scss new file mode 100644 index 0000000..70564c4 --- /dev/null +++ b/src/components/treemode/CollapsedItems.scss @@ -0,0 +1,57 @@ +@import '../../styles.scss'; + +$color: $gray; +$background-color: $background-gray; + +div.collapsed-items { + font-family: $font-family; + font-size: $font-size; + color: $color; + + // https://sharkcoder.com/visual/borders + $size: 8px; + padding: $padding / 2; + border: $size solid transparent; + border-width: $size 0; + background-color: $background-color; + background-color: hsla(0, 0%, 0%, 0); + background-image: + linear-gradient($background-color, $background-color), + linear-gradient(to bottom right, transparent 50.5%, $background-color 50.5%), + linear-gradient(to bottom left, transparent 50.5%, $background-color 50.5%), + linear-gradient(to top right, transparent 50.5%, $background-color 50.5%), + linear-gradient(to top left, transparent 50.5%, $background-color 50.5%); + background-repeat: repeat, repeat-x, repeat-x, repeat-x, repeat-x; + background-position: 0 0, $size 0, $size 0, $size 100%,$size 100%; + background-size: auto auto, 2*$size 2*$size, 2*$size 2*$size, 2*$size 2*$size, 2*$size 2*$size; + background-clip: padding-box, border-box, border-box, border-box, border-box; + background-origin: padding-box, border-box, border-box, border-box, border-box; + + display: flex; + + div.text, + button.expand-items { + margin: 0 $padding / 2; + } + + div.text { + display: inline; + } + + button.expand-items { + font-family: $font-family; + font-size: $font-size; + color: $color; + + background: none; + border: none; + padding: 0; + text-decoration: underline; + cursor: pointer; + + &:hover, + &:focus { + color: $red; + } + } +} diff --git a/src/components/treemode/CollapsedItems.svelte b/src/components/treemode/CollapsedItems.svelte new file mode 100644 index 0000000..a455367 --- /dev/null +++ b/src/components/treemode/CollapsedItems.svelte @@ -0,0 +1,41 @@ + + +
+
+
Items {startIndex}-{endIndex}
{#each expandItemsSections as expandItemsSection + } + {/each} +
+
+ + diff --git a/src/components/treemode/JSONNode.scss b/src/components/treemode/JSONNode.scss index 7d72d00..647cda0 100644 --- a/src/components/treemode/JSONNode.scss +++ b/src/components/treemode/JSONNode.scss @@ -208,22 +208,6 @@ } } -div.limit { - button { - background: none; - border: none; - padding: 0; - color: $black; - text-decoration: underline; - cursor: pointer; - - &:hover, - &:focus { - color: $red; - } - } -} - div.empty { &:not(:focus) { outline: 1px dotted lightgray; diff --git a/src/components/treemode/JSONNode.svelte b/src/components/treemode/JSONNode.svelte index c4f2693..96057c0 100644 --- a/src/components/treemode/JSONNode.svelte +++ b/src/components/treemode/JSONNode.svelte @@ -6,12 +6,11 @@ import { singleton } from './singleton.js' import { DEBOUNCE_DELAY, - DEFAULT_LIMIT, STATE_EXPANDED, - STATE_LIMIT, STATE_PROPS, STATE_SEARCH_PROPERTY, STATE_SEARCH_VALUE, + STATE_VISIBLE_SECTIONS, INDENTATION_WIDTH, VALIDATION_ERROR } from '../../constants.js' @@ -29,6 +28,7 @@ import { isUrl, stringConvert, valueType } from '../../utils/typeUtils' import { compileJSONPointer } from '../../utils/jsonPointer' import { getNextKeys } from '../../logic/documentState.js' + import CollapsedItems from './CollapsedItems.svelte' export let key = undefined // only applicable for object properties export let value @@ -39,12 +39,15 @@ export let onPatch export let onUpdateKey export let onExpand - export let onLimit export let onSelect + + /** @type {function (path: Path, section: Section)} */ + export let onExpandSection + export let selection $: expanded = state && state[STATE_EXPANDED] - $: limit = state && state[STATE_LIMIT] + $: visibleSections = state && state[STATE_VISIBLE_SECTIONS] $: props = state && state[STATE_PROPS] $: validationError = validationErrors && validationErrors[VALIDATION_ERROR] @@ -56,6 +59,7 @@ $: type = valueType (value) + $: limit = visibleSections && visibleSections[0].end // FIXME: make dynamic $: limited = type === 'array' && value.length > limit $: items = type === 'array' @@ -211,14 +215,6 @@ } } - function handleShowAll () { - onLimit(path, Infinity) - } - - function handleShowMore () { - onLimit(path, (Math.round(limit / DEFAULT_LIMIT) + 1) * DEFAULT_LIMIT) - } - function handleMouseDown (event) { // unselect existing selection on mouse down if any if (selection) { @@ -400,21 +396,32 @@ {#if expanded}
- {#each items as item, index (index)} - + {#each visibleSections as visibleSection, sectionIndex (sectionIndex)} + {#each value.slice(visibleSection.start, Math.min(visibleSection.end, value.length)) as item, itemIndex (itemIndex)} + + {/each} + {#if visibleSection.end < value.length} + + {/if} {/each}
- {#if limited} +
diff --git a/src/constants.js b/src/constants.js index a831582..a203403 100644 --- a/src/constants.js +++ b/src/constants.js @@ -1,6 +1,7 @@ export const STATE_EXPANDED = Symbol('expanded') export const STATE_LIMIT = Symbol('limit') +export const STATE_VISIBLE_SECTIONS = Symbol('visible sections') export const STATE_PROPS = Symbol('props') export const STATE_SEARCH_PROPERTY = Symbol('search:property') export const STATE_SEARCH_VALUE = Symbol('search:value') @@ -10,7 +11,8 @@ export const SCROLL_DURATION = 300 // ms export const DEBOUNCE_DELAY = 300 export const SEARCH_PROGRESS_THROTTLE = 300 // ms export const MAX_SEARCH_RESULTS = 1000 -export const DEFAULT_LIMIT = 100 +export const ARRAY_SECTION_SIZE = 100 +export const DEFAULT_VISIBLE_SECTIONS = [{ start: 0, end: ARRAY_SECTION_SIZE }] export const MAX_PREVIEW_CHARACTERS = 20e3 // characters export const INDENTATION_WIDTH = 18 // pixels IMPORTANT: keep in sync with sass constant $indentation-width diff --git a/src/logic/documentState.js b/src/logic/documentState.js index f4cbb33..cc5485f 100644 --- a/src/logic/documentState.js +++ b/src/logic/documentState.js @@ -1,13 +1,14 @@ -import { initial, isEqual, isNumber, last, uniqueId } from 'lodash-es' +import { initial, isEqual, isNumber, last, merge, uniqueId } from 'lodash-es' import { - DEFAULT_LIMIT, + DEFAULT_VISIBLE_SECTIONS, STATE_EXPANDED, - STATE_LIMIT, - STATE_PROPS + STATE_PROPS, + STATE_VISIBLE_SECTIONS } from '../constants.js' -import { deleteIn, getIn, insertAt, setIn } from '../utils/immutabilityHelpers.js' +import { deleteIn, getIn, insertAt, setIn, updateIn } from '../utils/immutabilityHelpers.js' import { parseJSONPointer } from '../utils/jsonPointer.js' import { isObject, isObjectOrArray } from '../utils/typeUtils.js' +import { mergeSections, inVisibleSection, previousRoundNumber, nextRoundNumber } from './expandItemsSections.js' /** * Sync a state object with the doc it belongs to: update props, limit, and expanded state @@ -52,19 +53,21 @@ export function syncState (doc, state = undefined, path, expand, forceRefresh = ? state[STATE_EXPANDED] : expand(path) - // note that we reset the limit when the state is not expanded - updatedState[STATE_LIMIT] = (state && updatedState[STATE_EXPANDED]) - ? state[STATE_LIMIT] - : DEFAULT_LIMIT + // note that we reset the visible items when the state is not expanded + updatedState[STATE_VISIBLE_SECTIONS] = (state && updatedState[STATE_EXPANDED]) + ? state[STATE_VISIBLE_SECTIONS] + : DEFAULT_VISIBLE_SECTIONS if (updatedState[STATE_EXPANDED]) { - for (let i = 0; i < Math.min(doc.length, updatedState[STATE_LIMIT]); i++) { - const childDocument = doc[i] - if (isObjectOrArray(childDocument)) { - const childState = state && state[i] - updatedState[i] = syncState(childDocument, childState, path.concat(i), expand, forceRefresh) + updatedState[STATE_VISIBLE_SECTIONS].forEach(({ start, end }) => { + for (let i = start; i < Math.min(doc.length, end); i++) { + const childDocument = doc[i] + if (isObjectOrArray(childDocument)) { + const childState = state && state[i] + updatedState[i] = syncState(childDocument, childState, path.concat(i), expand, forceRefresh) + } } - } + }) } return updatedState @@ -89,13 +92,17 @@ export function expandPath (state, path) { // FIXME: setIn has to create object first updatedState = setIn(updatedState, partialPath.concat(STATE_EXPANDED), true, true) - // if needed, enlarge the limit such that the search result becomes visible + // if needed, enlarge the expanded sections such that the search result becomes visible in the array const key = path[i] if (isNumber(key)) { - const limit = getIn(updatedState, partialPath.concat(STATE_LIMIT)) || DEFAULT_LIMIT - if (key > limit) { - const newLimit = Math.ceil(key / DEFAULT_LIMIT) * DEFAULT_LIMIT - updatedState = setIn(updatedState, partialPath.concat(STATE_LIMIT), newLimit, true) + const sectionsPath = partialPath.concat(STATE_VISIBLE_SECTIONS) + const sections = getIn(updatedState, sectionsPath) || DEFAULT_VISIBLE_SECTIONS + if (!inVisibleSection(sections, key)) { + const start = previousRoundNumber(key) + const end = nextRoundNumber(start) + const newSection = { start, end } + const updatedSections = mergeSections(sections.concat(newSection)) + updatedState = setIn(updatedState, sectionsPath, updatedSections) } } } @@ -103,6 +110,20 @@ export function expandPath (state, path) { return updatedState } +/** + * Expand a section of items in an array + * @param {JSON} state + * @param {Path} path + * @param {Section} section + * @return {JSON} returns the updated state + */ +// TODO: write unit test +export function expandSection (state, path, section) { + return updateIn(state, path.concat(STATE_VISIBLE_SECTIONS), (sections = DEFAULT_VISIBLE_SECTIONS) => { + return mergeSections(sections.concat(section)) + }) +} + export function updateProps (value, prevProps) { if (!isObject(value)) { return undefined diff --git a/src/logic/documentState.test.js b/src/logic/documentState.test.js index 1b30331..f677cfe 100644 --- a/src/logic/documentState.test.js +++ b/src/logic/documentState.test.js @@ -1,9 +1,9 @@ import assert from 'assert' import { - DEFAULT_LIMIT, + DEFAULT_VISIBLE_SECTIONS, STATE_EXPANDED, - STATE_LIMIT, - STATE_PROPS + STATE_PROPS, + STATE_VISIBLE_SECTIONS } from '../constants.js' import { syncState, updateProps } from './documentState.js' @@ -30,7 +30,7 @@ describe('documentState', () => { ] expectedState.array = [] expectedState.array[STATE_EXPANDED] = true - expectedState.array[STATE_LIMIT] = DEFAULT_LIMIT + expectedState.array[STATE_VISIBLE_SECTIONS] = DEFAULT_VISIBLE_SECTIONS expectedState.array[2] = {} expectedState.array[2][STATE_EXPANDED] = false expectedState.array[2][STATE_PROPS] = [ diff --git a/src/logic/expandItemsSections.js b/src/logic/expandItemsSections.js new file mode 100644 index 0000000..e100af4 --- /dev/null +++ b/src/logic/expandItemsSections.js @@ -0,0 +1,94 @@ +import { sortBy } from 'lodash-es' +import { ARRAY_SECTION_SIZE } from '../constants.js' + +/** + * Create sections that can be expanded. + * Used to display a button like "Show items 100-200" + * + * @param {number} startIndex + * @param {number} endIndex + * @return {Section[]} + */ +export function getExpandItemsSections (startIndex, endIndex) { + // expand the start of the section + const section1 = { + start: startIndex, + end: Math.min(nextRoundNumber(startIndex), endIndex) + } + + // expand the middle of the section + const start2 = Math.max(previousRoundNumber((startIndex + endIndex) / 2), startIndex) + const section2 = { + start: start2, + end: Math.min(nextRoundNumber(start2), endIndex) + } + + // expand the end of the section + const section3 = { + start: Math.max(previousRoundNumber(endIndex), startIndex), + end: endIndex + } + + const sections = [ + section1 + ] + + const showSection2 = section2.start >= section1.end && section2.end <= section3.end + if (showSection2) { + sections.push(section2) + } + + const showSection3 = section3.start >= (showSection2 ? section2.end : section1.end) + if (showSection3) { + sections.push(section3) + } + + return sections +} + +/** + * Sort and merge a list with sections + * @param {Section[]} sections + * @return {Section[]} + */ +export function mergeSections (sections) { + const sortedSections = sortBy(sections, section => section.start) + + const mergedSections = [ + sortedSections[0] + ] + + for (let sortedIndex = 0; sortedIndex < sortedSections.length; sortedIndex++) { + const mergedIndex = mergedSections.length - 1 + const previous = mergedSections[mergedIndex] + const current = sortedSections[sortedIndex] + + if (current.start <= previous.end) { + // there is overlap -> replace the previous item + mergedSections[mergedIndex] = { + start: Math.min(previous.start, current.start), + end: Math.max(previous.end, current.end) + } + } else { + // no overlap, just add the item + mergedSections.push(current) + } + } + + return mergedSections +} + +// TODO: write unit test +export function inVisibleSection (sections, index) { + return sections.some(section => { + return index >= section.start && index < section.end + }) +} + +export function nextRoundNumber (index) { + return Math.floor((index + ARRAY_SECTION_SIZE) / ARRAY_SECTION_SIZE) * ARRAY_SECTION_SIZE +} + +export function previousRoundNumber (index) { + return Math.ceil((index - ARRAY_SECTION_SIZE) / ARRAY_SECTION_SIZE) * ARRAY_SECTION_SIZE +} diff --git a/src/logic/expandItemsSections.test.js b/src/logic/expandItemsSections.test.js new file mode 100644 index 0000000..4fbac7b --- /dev/null +++ b/src/logic/expandItemsSections.test.js @@ -0,0 +1,113 @@ +import assert from 'assert' +import { + mergeSections, + getExpandItemsSections, + nextRoundNumber, + previousRoundNumber +} from './expandItemsSections.js' + +describe('expandItemsSections', () => { + it('should find the next round number', () => { + assert.strictEqual(nextRoundNumber(5), 100) + assert.strictEqual(nextRoundNumber(99), 100) + assert.strictEqual(nextRoundNumber(100), 200) + }) + + it('should find the previous round number', () => { + assert.strictEqual(previousRoundNumber(100), 0) + assert.strictEqual(previousRoundNumber(199), 100) + assert.strictEqual(previousRoundNumber(200), 100) + assert.strictEqual(previousRoundNumber(101), 100) + assert.strictEqual(previousRoundNumber(500), 400) + }) + + it('should calculate expandable sections (start, middle, end)', () => { + assert.deepStrictEqual(getExpandItemsSections(0, 1000), [ + { start: 0, end: 100 }, + { start: 400, end: 500 }, + { start: 900, end: 1000 } + ]) + + assert.deepStrictEqual(getExpandItemsSections(30, 510), [ + { start: 30, end: 100 }, + { start: 200, end: 300 }, + { start: 500, end: 510 } + ]) + + assert.deepStrictEqual(getExpandItemsSections(30, 250), [ + { start: 30, end: 100 }, + { start: 100, end: 200 }, + { start: 200, end: 250 } + ]) + + assert.deepStrictEqual(getExpandItemsSections(30, 200), [ + { start: 30, end: 100 }, + { start: 100, end: 200 } + ]) + + assert.deepStrictEqual(getExpandItemsSections(30, 170), [ + { start: 30, end: 100 }, + { start: 100, end: 170 } + ]) + + assert.deepStrictEqual(getExpandItemsSections(30, 100), [ + { start: 30, end: 100 } + ]) + + assert.deepStrictEqual(getExpandItemsSections(30, 70), [ + { start: 30, end: 70 } + ]) + }) + + it('should apply expanding a new piece of selection', () => { + // merge + assert.deepStrictEqual(mergeSections([ + { start: 0, end: 100 }, + { start: 100, end: 200 } + ]), [ + { start: 0, end: 200 } + ]) + + // sort correctly + assert.deepStrictEqual(mergeSections([ + { start: 0, end: 100 }, + { start: 400, end: 500 }, + { start: 200, end: 300 } + ]), [ + { start: 0, end: 100 }, + { start: 200, end: 300 }, + { start: 400, end: 500 } + ]) + + // merge partial overlapping + assert.deepStrictEqual(mergeSections([ + { start: 0, end: 30 }, + { start: 20, end: 100 } + ]), [ + { start: 0, end: 100 } + ]) + + // merge full overlapping + assert.deepStrictEqual(mergeSections([ + { start: 100, end: 200 }, + { start: 0, end: 300 } + ]), [ + { start: 0, end: 300 } + ]) + assert.deepStrictEqual(mergeSections([ + { start: 0, end: 300 }, + { start: 100, end: 200 } + ]), [ + { start: 0, end: 300 } + ]) + + // merge overlapping with two + assert.deepStrictEqual(mergeSections([ + { start: 0, end: 100 }, + { start: 200, end: 300 }, + { start: 100, end: 200 } + ]), [ + { start: 0, end: 300 } + ]) + }) +}) diff --git a/src/styles.scss b/src/styles.scss index d508367..33f36f5 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -30,6 +30,9 @@ $hovered-background: #f0f0f0; $background-gray: #f5f5f5; $border-gray: #d8dbdf; +$lightblue: lightblue; +$darkblue: darkblue; + $line-height: 18px; $indentation-width: 18px; // IMPORTANT: keep in sync with js constant INDENTATION_WIDTH $menu-button-size: 32px; diff --git a/src/types.js b/src/types.js index 0aa0e31..6a13802 100644 --- a/src/types.js +++ b/src/types.js @@ -86,4 +86,8 @@ /** * @typedef {{path: Path, message: string, isChildError?: boolean}} ValidationError - */ \ No newline at end of file + */ + +/** + * @typedef {{start: number, end: number}} Section + */