diff --git a/src/lib/components/__snapshots__/JSONEditor.test.ts.snap b/src/lib/components/__snapshots__/JSONEditor.test.ts.snap index c5ec1c7c..a13a7022 100644 --- a/src/lib/components/__snapshots__/JSONEditor.test.ts.snap +++ b/src/lib/components/__snapshots__/JSONEditor.test.ts.snap @@ -98,6 +98,33 @@ exports[`JSONEditor > render table mode 1`] = ` + + + + + + + + {/if} +
+
+
+ {#if searching} + + {:else} + + {/if} +
+ +
+ {activeIndex !== -1 && activeIndex < resultCount + ? `${activeIndex + 1}/` + : ''}{formattedResultCount} +
+ + + +
+ {#if showReplace && !readOnly} +
+ + + +
+ {/if} +
+ + +{/if} + + diff --git a/src/lib/components/modes/tablemode/JSONValue.svelte b/src/lib/components/modes/tablemode/JSONValue.svelte index b24404c1..775a1565 100644 --- a/src/lib/components/modes/tablemode/JSONValue.svelte +++ b/src/lib/components/modes/tablemode/JSONValue.svelte @@ -54,7 +54,7 @@
diff --git a/src/lib/components/modes/tablemode/TableMode.scss b/src/lib/components/modes/tablemode/TableMode.scss index e3645e5a..444b9384 100644 --- a/src/lib/components/modes/tablemode/TableMode.scss +++ b/src/lib/components/modes/tablemode/TableMode.scss @@ -17,6 +17,16 @@ border-top: $main-border; } + .jse-search-box-container { + position: relative; + height: 0; + top: calc($line-height + 2 * $padding); + margin-right: calc($padding + 20px); + margin-left: $padding; + text-align: right; + z-index: 3; // must be above the ContextMenuButton + } + .jse-hidden-input-label { position: fixed; right: 0; @@ -40,6 +50,7 @@ display: flex; overflow: auto; overflow-anchor: none; // important to prevent Chrome from adjusting the scrollTop based on changing contents (causing an infinite loop) + scrollbar-gutter: stable; border-left: $main-border; border-right: $main-border; @@ -107,10 +118,6 @@ background: $selection-background-color; } - :global(div) { - display: inline-flex; - } - // FIXME: this is ugly. give JSONValue an extra class instead? :global(div.jse-value) { overflow-wrap: normal; @@ -120,6 +127,7 @@ } .jse-context-menu-anchor { + display: inline-flex; position: relative; vertical-align: top; } diff --git a/src/lib/components/modes/tablemode/TableMode.svelte b/src/lib/components/modes/tablemode/TableMode.svelte index c50e0137..a931ff00 100644 --- a/src/lib/components/modes/tablemode/TableMode.svelte +++ b/src/lib/components/modes/tablemode/TableMode.svelte @@ -7,8 +7,8 @@ AfterPatchCallback, Content, ContentErrors, + ContextMenuItem, DocumentState, - ExtendedSearchResultItem, HistoryItem, JSONEditorContext, JSONEditorSelection, @@ -28,6 +28,7 @@ OnTransformModal, ParseError, PastedJson, + SearchResult, SortedColumn, TransformModalOptions, ValidationError, @@ -36,13 +37,15 @@ } from '$lib/types' import { Mode, SortDirection, ValidationSeverity } from '$lib/types.js' import TableMenu from './menu/TableMenu.svelte' - import type { JSONPatchDocument, JSONPath } from 'immutable-json-patch' import { compileJSONPointer, + compileJSONPointerProp, existsIn, getIn, immutableJSONPatch, - isJSONArray + isJSONArray, + type JSONPatchDocument, + type JSONPath } from 'immutable-json-patch' import { isTextContent, @@ -74,7 +77,6 @@ findParentWithNodeName, getDataPathFromTarget, getWindow, - isChildOfNodeName, isEditableDivRef } from '$lib/utils/domUtils.js' import { createDebug } from '$lib/utils/debug.js' @@ -117,6 +119,7 @@ CONTEXT_MENU_HEIGHT, CONTEXT_MENU_WIDTH, SCROLL_DURATION, + SEARCH_BOX_HEIGHT, SIMPLE_MODAL_OPTIONS } from '$lib/constants.js' import { noop } from '$lib/utils/noop.js' @@ -137,13 +140,15 @@ import { resizeObserver } from '$lib/actions/resizeObserver.js' import CopyPasteModal from '../../../components/modals/CopyPasteModal.svelte' import ContextMenuPointer from '../../../components/controls/contextmenu/ContextMenuPointer.svelte' + import SearchBox from '../../controls/SearchBox.svelte' import TableModeWelcome from './TableModeWelcome.svelte' import JSONPreview from '../../controls/JSONPreview.svelte' import RefreshColumnHeader from './RefreshColumnHeader.svelte' import type { Context } from 'svelte-simple-modal' - import type { ContextMenuItem } from '$lib/types' import createTableContextMenuItems from './contextmenu/createTableContextMenuItems' import ContextMenu from '../../controls/contextmenu/ContextMenu.svelte' + import { filterValueSearchResults } from '$lib/logic/search.js' + import { filterPointerOrUndefined } from 'svelte-jsoneditor/utils/jsonPointer' const debug = createDebug('jsoneditor:TableMode') const { open } = getContext('simple-modal') @@ -215,6 +220,42 @@ let pastedJson: PastedJson + let searchResult: SearchResult | undefined + let showSearch = false + let showReplace = false + + $: applySearchBoxSpacing(showSearch) + + function applySearchBoxSpacing(showSearch: boolean) { + if (!refContents) { + return + } + + const offset = showSearch ? SEARCH_BOX_HEIGHT : -SEARCH_BOX_HEIGHT + refContents.scrollTo({ + top: (refContents.scrollTop += offset), + left: refContents.scrollLeft + }) + } + + function handleSearch(result: SearchResult | undefined) { + searchResult = result + } + + async function handleFocusSearch(path: JSONPath) { + documentState = { + ...documentState, + selection: null // navigation path of current selection would be confusing + } + await scrollTo(path) + } + + function handleCloseSearch() { + showSearch = false + showReplace = false + focus() + } + $: applyExternalContent(externalContent) $: applyExternalSelection(externalSelection) @@ -224,7 +265,8 @@ ? maintainColumnOrder(getColumns(json, flattenColumns, maxSampleCount), columns) : [] - $: containsValidArray = json && !isEmpty(columns) + let containsValidArray: boolean + $: containsValidArray = !!(json && !isEmpty(columns)) $: showRefreshButton = Array.isArray(json) && json.length > maxSampleCount // modalOpen is true when one of the modals is open. @@ -244,7 +286,8 @@ viewPortHeight, json, itemHeightsCache, // warning: itemHeightsCache is mutated and is not responsive itself - defaultItemHeight + defaultItemHeight, + showSearch ? SEARCH_BOX_HEIGHT : 0 ) $: refreshScrollTop(json) @@ -314,7 +357,6 @@ let documentState = createDocumentState() let textIsRepaired = false - const searchResultItems: ExtendedSearchResultItem[] | undefined = undefined // TODO: implement support for search and replace function onSortByHeader(newSortedColumn: SortedColumn) { if (readOnly) { @@ -326,7 +368,7 @@ const rootPath: JSONPath = [] const direction = newSortedColumn.sortDirection === SortDirection.desc ? -1 : 1 const operations = sortJson(json, rootPath, newSortedColumn.path, direction) - handlePatch(operations, (patchedJson, patchedState) => { + handlePatch(operations, (_, patchedState) => { return { state: { ...patchedState, @@ -624,14 +666,12 @@ } }) - const patchResult = { + return { json, previousJson, undo, redo: operations } - - return patchResult } function handlePatch( @@ -728,8 +768,8 @@ event.preventDefault() } - // for example when clicking on the empty area in the main menu - if (!isChildOfNodeName(target, 'BUTTON') && !target.isContentEditable) { + // for example when clicking on the empty area in the main menu or on an InlineValue + if (!target.isContentEditable) { focus() } } @@ -788,8 +828,9 @@ * Expand the path when needed. */ export function scrollTo(path: JSONPath, scrollToWhenVisible = true): Promise { + const searchBoxHeight = showSearch ? SEARCH_BOX_HEIGHT : 0 const top = calculateAbsolutePosition(path, columns, itemHeightsCache, defaultItemHeight) - const roughDistance = top - scrollTop + const roughDistance = top - scrollTop + searchBoxHeight + defaultItemHeight const elem = findElement(path) debug('scrollTo', { path, top, scrollTop, elem }) @@ -807,10 +848,7 @@ } } - const offset = -(viewPortRect.height / 4) - - // FIXME: scroll horizontally when needed - // FIXME: scroll to the exact element (rough distance can be inexact) + const offset = -Math.max(searchBoxHeight + 2 * defaultItemHeight, viewPortRect.height / 4) if (elem) { return new Promise((resolve) => { @@ -832,22 +870,11 @@ offset, duration: SCROLL_DURATION, callback: async () => { + // ensure the element is rendered now that it is scrolled into view await tick() - const newTop = calculateAbsolutePosition( - path, - columns, - itemHeightsCache, - defaultItemHeight - ) - - if (newTop !== top) { - await scrollTo(path, scrollToWhenVisible) - } else { - // TODO: improve horizontal scrolling: animate and integrate with the vertical scrolling (jump) - scrollToHorizontal(path) - } - + // TODO: improve horizontal scrolling: animate and integrate with the vertical scrolling (jump) + scrollToHorizontal(path) resolve() } }) @@ -910,7 +937,13 @@ * Note that the path can only be found when the node is expanded. */ export function findElement(path: JSONPath): Element | null { - return refContents ? refContents.querySelector(`td[data-path="${encodeDataPath(path)}"]`) : null + const column = columns.find((c) => pathStartsWith(path.slice(1), c)) + + const resolvedPath = column ? path.slice(0, 1).concat(column) : path + + return refContents + ? refContents.querySelector(`td[data-path="${encodeDataPath(resolvedPath)}"]`) + : null } function openContextMenu({ @@ -1085,7 +1118,7 @@ value: updatedValue } ], - (patchedJson, patchedState) => { + (_, patchedState) => { return { state: setEnforceString(patchedState, pointer, enforceString) } @@ -1355,14 +1388,12 @@ if (combo === 'Ctrl+F') { event.preventDefault() - // openFind(false) - // TODO: implement find + openFind(false) } if (combo === 'Ctrl+H') { event.preventDefault() - // openFind(true) - // TODO: implement find and replace + openFind(true) } if (combo === 'Ctrl+Z') { @@ -1516,7 +1547,7 @@ onSort: ({ operations, itemPath, direction }) => { debug('onSort', operations, rootPath, itemPath, direction) - handlePatch(operations, (patchedJson, patchedState) => { + handlePatch(operations, (_, patchedState) => { return { state: { ...patchedState, @@ -1631,6 +1662,19 @@ }) } + function openFind(findAndReplace: boolean): void { + debug('openFind', { findAndReplace }) + + showSearch = false + showReplace = false + + tick().then(() => { + // trick to make sure the focus goes to the search box + showSearch = true + showReplace = findAndReplace + }) + } + function handleUndo() { if (readOnly) { return @@ -1736,8 +1780,9 @@ > {#if mainMenuBar} {#if containsValidArray} +
+ +
{#key rowIndex} {#if isObjectOrArray(value)} + {@const searchResultItemsByCell = searchResultItemsByRow + ? filterPointerOrUndefined(searchResultItemsByRow, pointer) + : undefined} + {@const containsActiveSearchResult = searchResultItemsByCell + ? Object.values(searchResultItemsByCell).some((items) => + items.some((item) => item.active) + ) + : false} + {:else} + {@const searchResultItemsByCell = searchResult?.itemsMap + ? filterValueSearchResults(searchResult?.itemsMap, pointer) + : undefined} + {/if}{#if !readOnly && isSelected && !isEditingSelection(documentState.selection)}
diff --git a/src/lib/components/modes/tablemode/contextmenu/createTableContextMenuItems.ts b/src/lib/components/modes/tablemode/contextmenu/createTableContextMenuItems.ts index afa8fb7b..beb44d23 100644 --- a/src/lib/components/modes/tablemode/contextmenu/createTableContextMenuItems.ts +++ b/src/lib/components/modes/tablemode/contextmenu/createTableContextMenuItems.ts @@ -10,7 +10,7 @@ import { faSquare, faTrashCan } from '@fortawesome/free-solid-svg-icons' -import { isKeySelection, isMultiSelection, isValueSelection } from 'svelte-jsoneditor' +import { isKeySelection, isMultiSelection, isValueSelection } from '$lib/logic/selection' import { compileJSONPointer, getIn } from 'immutable-json-patch' import { getFocusPath, singleItemSelected } from '$lib/logic/selection' import { isObjectOrArray } from '$lib/utils/typeUtils' diff --git a/src/lib/components/modes/tablemode/menu/TableMenu.svelte b/src/lib/components/modes/tablemode/menu/TableMenu.svelte index 38f16c85..5d697e6b 100644 --- a/src/lib/components/modes/tablemode/menu/TableMenu.svelte +++ b/src/lib/components/modes/tablemode/menu/TableMenu.svelte @@ -7,14 +7,16 @@ faEllipsisV, faFilter, faRedo, + faSearch, faSortAmountDownAlt, faUndo } from '@fortawesome/free-solid-svg-icons' import type { HistoryState } from '$lib/logic/history' import { CONTEXT_MENU_EXPLANATION } from '$lib/constants.js' - export let json: unknown | undefined + export let containsValidArray: boolean export let readOnly: boolean + export let showSearch = false export let historyState: HistoryState export let onSort: () => void export let onTransform: () => void @@ -23,6 +25,10 @@ export let onRedo: () => void export let onRenderMenu: OnRenderMenuInternal + function handleToggleSearch() { + showSearch = !showSearch + } + let defaultItems: MenuItem[] $: defaultItems = !readOnly ? [ @@ -32,7 +38,7 @@ title: 'Sort', className: 'jse-sort', onClick: onSort, - disabled: readOnly || json === undefined + disabled: readOnly || !containsValidArray }, { type: 'button', @@ -40,7 +46,15 @@ title: 'Transform contents (filter, sort, project)', className: 'jse-transform', onClick: onTransform, - disabled: readOnly || json === undefined + disabled: readOnly || !containsValidArray + }, + { + type: 'button', + icon: faSearch, + title: 'Search (Ctrl+F)', + className: 'jse-search', + onClick: handleToggleSearch, + disabled: !containsValidArray }, { type: 'button', diff --git a/src/lib/components/modes/tablemode/tag/InlineValue.scss b/src/lib/components/modes/tablemode/tag/InlineValue.scss index e092983b..59bfc007 100644 --- a/src/lib/components/modes/tablemode/tag/InlineValue.scss +++ b/src/lib/components/modes/tablemode/tag/InlineValue.scss @@ -17,4 +17,14 @@ &.jse-selected { background: $selection-background-color; } + + &.jse-highlight { + background-color: $search-match-color; + outline: $search-match-outline; + + &.jse-active { + background-color: $search-match-active-color; + outline: $search-match-active-outline; + } + } } diff --git a/src/lib/components/modes/tablemode/tag/InlineValue.svelte b/src/lib/components/modes/tablemode/tag/InlineValue.svelte index c19c7b1f..e38a5f39 100644 --- a/src/lib/components/modes/tablemode/tag/InlineValue.svelte +++ b/src/lib/components/modes/tablemode/tag/InlineValue.svelte @@ -10,6 +10,8 @@ export let value: unknown export let parser: JSONParser export let isSelected: boolean + export let containsSearchResult: boolean + export let containsActiveSearchResult: boolean export let onEdit: (path: JSONPath) => void @@ -17,6 +19,8 @@ type="button" class="jse-inline-value" class:jse-selected={isSelected} + class:jse-highlight={containsSearchResult} + class:jse-active={containsActiveSearchResult} on:dblclick={() => onEdit(path)} > {truncate(parser.stringify(value) ?? '', MAX_INLINE_OBJECT_CHARS)} diff --git a/src/lib/components/modes/treemode/TreeMode.scss b/src/lib/components/modes/treemode/TreeMode.scss index 07eaa8cd..07ad7d59 100644 --- a/src/lib/components/modes/treemode/TreeMode.scss +++ b/src/lib/components/modes/treemode/TreeMode.scss @@ -27,6 +27,10 @@ } } + &.no-main-menu { + border-top: $main-border; + } + .jse-search-box-container { position: relative; height: 0; @@ -37,24 +41,19 @@ z-index: 3; // must be above the ContextMenuButton } - &.no-main-menu { - border-top: $main-border; - } - .jse-contents { - border-left: $main-border; - border-right: $main-border; - - &:last-child { - border-bottom: $main-border; - } - flex: 1; overflow: auto; position: relative; padding: $contents-padding; display: flex; flex-direction: column; + border-left: $main-border; + border-right: $main-border; + + &:last-child { + border-bottom: $main-border; + } .jse-loading-space { flex: 1; diff --git a/src/lib/components/modes/treemode/TreeMode.svelte b/src/lib/components/modes/treemode/TreeMode.svelte index adf8fb81..3adf7677 100644 --- a/src/lib/components/modes/treemode/TreeMode.svelte +++ b/src/lib/components/modes/treemode/TreeMode.svelte @@ -7,15 +7,14 @@ import type { JSONPatchDocument, JSONPath } from 'immutable-json-patch' import { compileJSONPointer, existsIn, getIn, immutableJSONPatch } from 'immutable-json-patch' import { jsonrepair } from 'jsonrepair' - import { initial, isEmpty, isEqual, noop, throttle, uniqueId } from 'lodash-es' + import { initial, isEmpty, isEqual, noop, uniqueId } from 'lodash-es' import { getContext, onDestroy, onMount, tick } from 'svelte' import { createJump } from '$lib/assets/jump.js/src/jump.js' import { CONTEXT_MENU_HEIGHT, CONTEXT_MENU_WIDTH, - MAX_SEARCH_RESULTS, SCROLL_DURATION, - SEARCH_UPDATE_THROTTLE, + SEARCH_BOX_HEIGHT, SIMPLE_MODAL_OPTIONS } from '$lib/constants.js' import { @@ -35,14 +34,6 @@ } from '$lib/logic/documentState.js' import { createHistory } from '$lib/logic/history.js' import { duplicate, extract, revertJSONPatchWithMoveOperations } from '$lib/logic/operations.js' - import { - createSearchAndReplaceAllOperations, - createSearchAndReplaceOperations, - search, - searchNext, - searchPrevious, - updateSearchResult - } from '$lib/logic/search.js' import { canConvert, createAfterSelection, @@ -61,6 +52,7 @@ getSelectionPaths, getSelectionRight, getSelectionUp, + hasSelectionContents, isAfterSelection, isEditingSelection, isInsideSelection, @@ -72,7 +64,6 @@ isValueSelection, removeEditModeFromSelection, selectAll, - hasSelectionContents, updateSelectionInDocumentState } from '$lib/logic/selection.js' import { mapValidationErrors, validateJSON } from '$lib/logic/validation.js' @@ -106,13 +97,14 @@ import TreeMenu from './menu/TreeMenu.svelte' import Welcome from './Welcome.svelte' import NavigationBar from '../../controls/navigationBar/NavigationBar.svelte' - import SearchBox from './menu/SearchBox.svelte' + import SearchBox from '../../controls/SearchBox.svelte' import type { AbsolutePopupContext, AbsolutePopupOptions, AfterPatchCallback, Content, ContentErrors, + ContextMenuItem, ConvertType, DocumentState, HistoryItem, @@ -161,7 +153,6 @@ } from '$lib/logic/actions.js' import JSONPreview from '../../controls/JSONPreview.svelte' import type { Context } from 'svelte-simple-modal' - import type { ContextMenuItem } from '$lib/types' import ContextMenu from '../../controls/contextmenu/ContextMenu.svelte' import createTreeContextMenuItems from './contextmenu/createTreeContextMenuItems' @@ -260,7 +251,6 @@ let documentStateInitialized = false let documentState = createDocumentState() - let searchResult: SearchResult | undefined let normalization: ValueNormalization $: normalization = createNormalizationFunctions({ @@ -272,126 +262,49 @@ let pastedJson: PastedJson + let searchResult: SearchResult | undefined let showSearch = false let showReplace = false - let searching = false - let searchText = '' - - async function handleSearchText(text: string) { - debug('search text updated', text) - searchText = text - await tick() // await for the search results to be updated - await focusActiveSearchResult() - } - async function handleNextSearchResult() { - searchResult = searchResult ? searchNext(searchResult) : undefined + $: applySearchBoxSpacing(showSearch) - await focusActiveSearchResult() - } - - async function handlePreviousSearchResult() { - searchResult = searchResult ? searchPrevious(searchResult) : undefined - - await focusActiveSearchResult() - } - - async function handleReplace(text: string, replacementText: string) { - const activeItem = searchResult?.activeItem - debug('handleReplace', { replacementText, activeItem }) - - if (!activeItem || json === undefined) { + function applySearchBoxSpacing(showSearch: boolean) { + if (!refContents) { return } - const { operations, newSelection } = createSearchAndReplaceOperations( - json, - documentState, - replacementText, - activeItem, - parser - ) - - handlePatch(operations, (patchedJson, patchedState) => ({ - state: { ...patchedState, selection: newSelection } - })) - - await tick() - - await focusActiveSearchResult() + if (showSearch) { + const padding = parseInt(getComputedStyle(refContents).padding) ?? 0 + refContents.style.overflowAnchor = 'none' + refContents.style.paddingTop = padding + SEARCH_BOX_HEIGHT + 'px' + refContents.scrollTop += SEARCH_BOX_HEIGHT + refContents.style.overflowAnchor = '' + } else { + refContents.style.overflowAnchor = 'none' + refContents.style.paddingTop = '' + refContents.scrollTop -= SEARCH_BOX_HEIGHT + refContents.style.overflowAnchor = '' + } } - async function handleReplaceAll(text: string, replacementText: string) { - debug('handleReplaceAll', { text, replacementText }) - - const { operations, newSelection } = createSearchAndReplaceAllOperations( - json, - documentState, - text, - replacementText, - parser - ) - - handlePatch(operations, (patchedJson, patchedState) => ({ - state: { ...patchedState, selection: newSelection } - })) - - await tick() + function handleSearch(result: SearchResult | undefined) { + searchResult = result + } - await focusActiveSearchResult() + async function handleFocusSearch(path: JSONPath) { + documentState = { + ...expandPath(json, documentState, path), + selection: null // navigation path of current selection would be confusing + } + await scrollTo(path) } - function clearSearchResult() { + function handleCloseSearch() { showSearch = false showReplace = false - handleSearchText('') focus() } - async function focusActiveSearchResult() { - const activeItem = searchResult?.activeItem - - debug('focusActiveSearchResult', searchResult) - - if (activeItem && json !== undefined) { - const path = activeItem.path - documentState = { - ...expandPath(json, documentState, path), - selection: null // navigation path of current selection would be confusing - } - await tick() - await scrollTo(path) - } - } - - // we pass searchText and json as argument to trigger search when these variables change, - // via $: applySearchThrottled(searchText, json) - function applySearch(searchText: string, json: unknown) { - if (searchText === '') { - debug('clearing search result') - - if (searchResult !== undefined) { - searchResult = undefined - } - - return - } - - searching = true - - // setTimeout is to wait until the search icon has been rendered - setTimeout(() => { - debug('searching...', searchText) - - // console.time('search') // TODO: cleanup - const newResultItems = search(searchText, json, MAX_SEARCH_RESULTS) - searchResult = updateSearchResult(json, newResultItems, searchResult) - // console.timeEnd('search') // TODO: cleanup - - searching = false - }) - } - function handleSelectValidationError(error: ValidationError) { debug('select validation error', error) @@ -425,9 +338,6 @@ $: applyExternalSelection(externalSelection) - const applySearchThrottled = throttle(applySearch, SEARCH_UPDATE_THROTTLE) - $: applySearchThrottled(searchText, json) - let textIsRepaired = false let validationErrors: ValidationError[] = [] @@ -2159,18 +2069,17 @@ {:else}
diff --git a/src/lib/components/modes/treemode/menu/SearchBox.svelte b/src/lib/components/modes/treemode/menu/SearchBox.svelte deleted file mode 100644 index e6ad303d..00000000 --- a/src/lib/components/modes/treemode/menu/SearchBox.svelte +++ /dev/null @@ -1,214 +0,0 @@ - - - - -{#if show} - -{/if} - - diff --git a/src/lib/constants.ts b/src/lib/constants.ts index e913f453..a6d3673f 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -4,7 +4,6 @@ import { SortDirection } from './types.js' export const SCROLL_DURATION = 300 // ms export const DEBOUNCE_DELAY = 300 // ms export const TEXT_MODE_ONCHANGE_DELAY = 300 // ms -export const SEARCH_UPDATE_THROTTLE = 300 // ms export const AUTO_SCROLL_INTERVAL = 50 // ms export const AUTO_SCROLL_SPEED_SLOW = 200 // pixels per second export const AUTO_SCROLL_SPEED_NORMAL = 400 // pixels per second @@ -67,6 +66,7 @@ export const JSON_STATUS_INVALID = 'invalid' // TODO: can we dynamically calculate the size? export const CONTEXT_MENU_HEIGHT = (40 + 2) * 8 // px export const CONTEXT_MENU_WIDTH = 260 // px +export const SEARCH_BOX_HEIGHT = 100 // px for search and replace export const SORT_DIRECTION_NAMES = { [SortDirection.asc]: 'ascending', diff --git a/src/lib/logic/search.test.ts b/src/lib/logic/search.test.ts index c683d9bf..38ba8837 100644 --- a/src/lib/logic/search.test.ts +++ b/src/lib/logic/search.test.ts @@ -11,7 +11,7 @@ import { search, splitValue } from './search.js' -import type { ExtendedSearchResultItem, SearchResultItem } from '$lib/types.js' +import type { ExtendedSearchResultItem, SearchOptions, SearchResultItem } from '$lib/types.js' import { SearchField } from '$lib/types.js' import { createKeySelection, createValueSelection } from './selection.js' @@ -137,20 +137,86 @@ describe('search', () => { assert.deepStrictEqual(resultsAll.length, count) const maxResults = 4 - const results = search('42', json, maxResults) + const results = search('42', json, { maxResults }) assert.deepStrictEqual(results.length, maxResults) }) + describe('search using columns', () => { + const json = [ + { id: 1, name: 'John', address: { city: 'Rotterdam' } }, + { id: 2, name: 'Sarah', address: { city: 'Amsterdam' } } + ] + + const json2 = ['John', 'Sarah'] + + function searchPaths(searchText: string, json: unknown, options?: SearchOptions): JSONPath[] { + return search(searchText, json, options).map((result) => result.path) + } + + test('should search in column names when not using columns', () => { + assert.deepStrictEqual(searchPaths('name', json), [ + ['0', 'name'], + ['1', 'name'] + ]) + assert.deepStrictEqual(searchPaths('address', json), [ + ['0', 'address'], + ['1', 'address'] + ]) + assert.deepStrictEqual(searchPaths('city', json), [ + ['0', 'address', 'city'], + ['1', 'address', 'city'] + ]) + assert.deepStrictEqual(searchPaths('john', json), [['0', 'name']]) + assert.deepStrictEqual(searchPaths('rotterdam', json), [['0', 'address', 'city']]) + }) + + test('should not search in column names when using nested columns', () => { + const columns = [['id'], ['name'], ['address']] + assert.deepStrictEqual(searchPaths('name', json, { columns }), []) + assert.deepStrictEqual(searchPaths('address', json, { columns }), []) + assert.deepStrictEqual(searchPaths('city', json, { columns }), [ + ['0', 'address', 'city'], + ['1', 'address', 'city'] + ]) + assert.deepStrictEqual(searchPaths('john', json, { columns }), [['0', 'name']]) + assert.deepStrictEqual(searchPaths('rotterdam', json, { columns }), [ + ['0', 'address', 'city'] + ]) + }) + + test('should not search in column names when using flattened columns', () => { + const columns = [['id'], ['name'], ['address', 'city']] + assert.deepStrictEqual(searchPaths('name', json, { columns }), []) + assert.deepStrictEqual(searchPaths('address', json, { columns }), []) + assert.deepStrictEqual(searchPaths('city', json, { columns }), []) + assert.deepStrictEqual(searchPaths('john', json, { columns }), [['0', 'name']]) + assert.deepStrictEqual(searchPaths('rotterdam', json, { columns }), [ + ['0', 'address', 'city'] + ]) + }) + + test('should search in a flat array without columns', () => { + assert.deepStrictEqual(searchPaths('foo', json2), []) + assert.deepStrictEqual(searchPaths('john', json2), [['0']]) + }) + + test('should search in a flat array with columns', () => { + const columns = [[]] + assert.deepStrictEqual(searchPaths('foo', json2, { columns }), []) + assert.deepStrictEqual(searchPaths('john', json2, { columns }), [['0']]) + }) + }) + test('should limit search results to the provided max in case of multiple matches in a single field', () => { const maxResults = 4 assert.deepStrictEqual( - search('ha', { greeting: 'ha ha ha ha ha ha' }, maxResults).length, + search('ha', { greeting: 'ha ha ha ha ha ha' }, { maxResults }).length, maxResults ) assert.deepStrictEqual( - search('ha', { 'ha ha ha ha ha ha': 'ha ha ha ha ha ha' }, maxResults).length, + search('ha', { 'ha ha ha ha ha ha': 'ha ha ha ha ha ha' }, { maxResults }).length, maxResults ) }) diff --git a/src/lib/logic/search.ts b/src/lib/logic/search.ts index a81878ac..662042c4 100644 --- a/src/lib/logic/search.ts +++ b/src/lib/logic/search.ts @@ -16,6 +16,7 @@ import type { JSONParser, JSONPointerMap, JSONSelection, + SearchOptions, SearchResult, SearchResultItem } from '$lib/types' @@ -108,15 +109,20 @@ export function searchPrevious(searchResult: SearchResult): SearchResult { export function search( searchText: string, json: unknown, - maxResults = Infinity + options: SearchOptions = {} ): SearchResultItem[] { + const searchTextLowerCase = searchText.toLowerCase() + const maxResults = options?.maxResults ?? Infinity + const columns = options?.columns const results: SearchResultItem[] = [] const path: JSONPath = [] // we reuse the same Array recursively, this is *much* faster than creating a new path every time function onMatch(match: SearchResultItem) { - if (results.length < maxResults) { - results.push(match) + if (results.length >= maxResults) { + return } + + results.push(match) } function searchRecursive(searchTextLowerCase: string, value: unknown) { @@ -166,12 +172,47 @@ export function search( } } - if (typeof searchText === 'string' && searchText !== '') { - const searchTextLowerCase = searchText.toLowerCase() + if (searchText === '') { + return [] + } else if (columns) { + if (!Array.isArray(json)) { + throw new Error('json must be an Array when option columns is defined') + } + + for (let i = 0; i < json.length; i++) { + path[0] = String(i) + + const item = json[i] + + for (let c = 0; c < columns.length; c++) { + const column = columns[c] + + if (column.length === 1) { + path[1] = column[0] + } else { + for (let p = 0; p < column.length; p++) { + path[p + 1] = column[p] + } + } + while (path.length > column.length + 1) { + path.pop() + } + + const value = getIn(item, column) + + searchRecursive(searchTextLowerCase, value) + } + + if (results.length >= maxResults) { + break + } + } + + return results + } else { searchRecursive(searchTextLowerCase, json) + return results } - - return results } /** @@ -302,7 +343,7 @@ export function createSearchAndReplaceAllOperations( parser: JSONParser ): { newSelection: JSONSelection | null; operations: JSONPatchDocument } { // TODO: to improve performance, we could reuse existing search results (except when hitting a maxResult limit) - const searchResultItems = search(searchText, json, Infinity /* maxResults */) + const searchResultItems = search(searchText, json, { maxResults: Infinity }) interface Match { path: JSONPath diff --git a/src/lib/logic/table.ts b/src/lib/logic/table.ts index 158dfd77..c27135ca 100644 --- a/src/lib/logic/table.ts +++ b/src/lib/logic/table.ts @@ -159,6 +159,7 @@ export function calculateVisibleSection( json: unknown | undefined, itemHeights: Record, defaultItemHeight: number, + searchBoxOffset: number, margin = 80 ): VisibleSection { const itemCount = isJSONArray(json) ? json.length : 0 @@ -169,7 +170,7 @@ export function calculateVisibleSection( const getItemHeight = (index: number) => itemHeights[index] || defaultItemHeight let startIndex = 0 - let startHeight = 0 + let startHeight = searchBoxOffset while (startHeight < viewPortTop && startIndex < itemCount) { startHeight += getItemHeight(startIndex) startIndex++ diff --git a/src/lib/types.ts b/src/lib/types.ts index 21234bcc..fcc5c9ef 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -337,6 +337,11 @@ export enum SearchField { value = 'value' } +export interface SearchOptions { + maxResults?: number + columns?: JSONPath[] +} + export interface SearchResultItem { path: JSONPath field: SearchField diff --git a/src/routes/development/+page.svelte b/src/routes/development/+page.svelte index 0473c134..83fd5d5d 100644 --- a/src/routes/development/+page.svelte +++ b/src/routes/development/+page.svelte @@ -647,7 +647,7 @@