Files
jsonhero-web/app/utilities/search.ts
T
James Ritchie 0632e82a4b Squashed commit of the following:
commit 09bed34946
Author: James Ritchie <james@jamesritchie.co.uk>
Date:   Fri Apr 8 14:03:50 2022 +0100

    Fixed the layout of a search list item

commit 90126d43ad
Author: Eric Allam <eallam@icloud.com>
Date:   Fri Apr 1 17:25:13 2022 +0100

    Insanely complicated path search results highlighitng and truncating

commit a4bbd5521e
Author: James Ritchie <james@jamesritchie.co.uk>
Date:   Fri Apr 1 14:05:01 2022 +0100

    Improved the title editing in the header

commit 5814053968
Author: James Ritchie <james@jamesritchie.co.uk>
Date:   Fri Apr 1 13:38:33 2022 +0100

    Improved text highlight colour

commit 110de37242
Author: James Ritchie <james@jamesritchie.co.uk>
Date:   Fri Apr 1 13:36:57 2022 +0100

    Icon hover state now uses isHighlighted

commit fb6da943d9
Author: James Ritchie <james@jamesritchie.co.uk>
Date:   Fri Apr 1 13:31:11 2022 +0100

    Light mode styling

commit 2c378d5158
Author: James Ritchie <james@jamesritchie.co.uk>
Date:   Fri Apr 1 12:24:43 2022 +0100

    Spacing between search items no longer a hack

commit 85d90bb0ae
Author: Eric Allam <eallam@icloud.com>
Date:   Fri Apr 1 12:19:10 2022 +0100

    Added ellipsis to search matches if they are windowed

commit 91f3e70676
Author: Eric Allam <eallam@icloud.com>
Date:   Fri Apr 1 12:08:00 2022 +0100

    Fixed search item icons

commit e384c87c6f
Author: James Ritchie <james@jamesritchie.co.uk>
Date:   Fri Apr 1 11:52:58 2022 +0100

    Improved hover state

commit f4681edc07
Author: James Ritchie <james@jamesritchie.co.uk>
Date:   Fri Apr 1 11:47:33 2022 +0100

    Removed focus state on button

commit faa2d0663c
Author: Eric Allam <eallam@icloud.com>
Date:   Fri Apr 1 11:26:42 2022 +0100

    made the search palette a little bigger

commit df8bd521fe
Author: Eric Allam <eallam@icloud.com>
Date:   Fri Apr 1 11:26:14 2022 +0100

    Remove onOverlayClick prop

commit ecd046b3b2
Author: Eric Allam <eallam@icloud.com>
Date:   Fri Apr 1 11:18:46 2022 +0100

    Close search when the overlay is clicked

commit fba330b5d9
Author: Eric Allam <eallam@icloud.com>
Date:   Fri Apr 1 11:10:35 2022 +0100

    hitting esc when search input is focused and empty should close the search

commit fc2fe8ae6f
Author: Eric Allam <eallam@icloud.com>
Date:   Fri Apr 1 11:00:08 2022 +0100

    Highlight the window of the best search match in the search results

commit 45193ec92c
Author: James Ritchie <james@jamesritchie.co.uk>
Date:   Fri Apr 1 10:45:01 2022 +0100

    Now you can arrow down to the last item in the list

commit 0de5fef7e9
Author: James Ritchie <james@jamesritchie.co.uk>
Date:   Fri Apr 1 10:37:20 2022 +0100

    Added a hacky margin between items and remove the transition to make it feel snappier

commit 167b548343
Author: Eric Allam <eallam@icloud.com>
Date:   Fri Apr 1 09:30:33 2022 +0100

    WIP implementing search

commit 5655631f12
Author: James Ritchie <james@jamesritchie.co.uk>
Date:   Fri Mar 25 15:28:19 2022 +0000

    Build and styled search modal

commit e368db81c8
Author: Eric Allam <eallam@icloud.com>
Date:   Fri Mar 25 14:42:03 2022 +0000

    Implement JSON search through fuse.js + web worker + react hook

commit 8caa11ba1c
Author: Eric Allam <eallam@icloud.com>
Date:   Wed Mar 23 10:21:33 2022 +0000

    Start of the Command Palette using radix-ui Dialog

commit 904b64d781
Author: Eric Allam <eallam@icloud.com>
Date:   Wed Mar 23 09:58:52 2022 +0000

    Uncommented out the search bar field
2022-04-08 14:07:39 +01:00

578 lines
16 KiB
TypeScript

import { JSONValueType, inferType } from "@jsonhero/json-infer-types";
import { JSONHeroPath } from "@jsonhero/path";
import { Array, Dict } from "@swan-io/boxed";
import Fuse from "fuse.js";
import { groupBy, replace, uniq } from "lodash-es";
import { formatValue } from "./formatter";
export interface JsonSearchEntry {
path: string;
rawValue?: string;
formattedValue?: string;
}
export function createSearchIndex(
json: unknown
): [Fuse.FuseIndex<JsonSearchEntry>, Array<JsonSearchEntry>] {
const entries = createSearchEntries(json);
const index = Fuse.createIndex<JsonSearchEntry>(
["path", "rawValue", "formattedValue"],
entries
);
return [index, entries];
}
export function createSearchEntries(json: unknown): Array<JsonSearchEntry> {
return createSearchEntryChildren(inferType(json), new JSONHeroPath("$"));
}
function createSearchEntryChildren(
info: JSONValueType,
path: JSONHeroPath
): Array<JsonSearchEntry> {
if (info.name === "array" && info.value) {
return info.value.flatMap((value, index) => {
const childPath = path.child(index.toString());
const childInfo = inferType(value);
const children = createSearchEntryChildren(childInfo, childPath);
return [
{
path: childPath.toString(),
rawValue: getRawValue(childInfo),
formattedValue: formatValue(childInfo, { leafNodesOnly: true }),
},
...children,
];
});
}
if (info.name === "object" && info.value) {
return Object.entries(info.value).flatMap(([key, value]) => {
const childPath = path.child(key);
const childInfo = inferType(value);
const children = createSearchEntryChildren(childInfo, childPath);
return [
{
path: childPath.toString(),
rawValue: getRawValue(childInfo),
formattedValue: formatValue(childInfo, { leafNodesOnly: true }),
},
...children,
];
});
}
return [];
}
export function getRawValue(type: JSONValueType): string | undefined {
switch (type.name) {
case "string":
return type.value;
case "int":
return type.value.toString();
case "float":
return type.value.toString();
case "bool":
return type.value ? "true" : "false";
case "null":
return "null";
}
}
type StringSlice = {
isMatch: boolean;
slice: string;
};
// getStringSlices should scope to the largest match
// For example, if the windowSize is 56 and the stringValue is "This is a very long string and the largest matched range is outside of the window, so we should try and get only slices of the string that focus on the largest match"
// and the matches are [[10, 15], [80, 90], [100, 105]]
// then we should return slices:
// [
// { isMatch: false, slice: "…" }
// { isMatch: false, slice: "the string," },
// { isMatch: true, slice: ", we should" },
// { isMatch: false, slice: "d only retu" },
// { isMatch: true, slice: "urn sl" },
// { isMatch: false, slice: "lices that are within" },
// { isMatch: false, slice: "…" },
//
// If stringValue length is less than the windowSize, then we should return all the string slices of the string
export function getStringSlices(
stringValue: string,
matchingIndices: ReadonlyArray<[number, number]>,
windowSize: number
): Array<StringSlice> {
const slices: StringSlice[] = [];
const addSlice = (isMatch: boolean, slice: string) => {
if (slice.length > 0) {
slices.push({ isMatch, slice });
}
};
const addEllipsis = () => {
addSlice(false, "…");
};
const calculateWindow = (): { start: number; end: number } => {
if (stringValue.length <= windowSize) {
return { start: 0, end: stringValue.length };
}
const largestMatch = matchingIndices.reduce(
(largestMatch, match) => {
if (match[1] - match[0] > largestMatch[1] - largestMatch[0]) {
return match;
}
return largestMatch;
},
[0, 0]
);
const largestMatchLength = largestMatch[1] - largestMatch[0];
const start =
largestMatch[0] - Math.floor(windowSize / 2 - largestMatchLength / 2);
const end =
largestMatch[1] + Math.floor(windowSize / 2 - largestMatchLength / 2);
return {
start: Math.max(start, 0),
end: Math.min(end, stringValue.length),
};
};
const window = calculateWindow();
let currentIndex = window.start;
if (window.start > 0) {
addEllipsis();
}
for (const [start, end] of matchingIndices) {
if (start < window.start && end <= window.start) {
continue;
} else if (start >= window.end) {
continue;
} else if (start < window.start && end > window.start) {
addSlice(true, stringValue.slice(window.start, end + 1));
currentIndex = end + 1;
} else if (start >= window.start && end <= window.end) {
if (start > 0) {
addSlice(false, stringValue.slice(currentIndex, start));
}
addSlice(true, stringValue.slice(start, end + 1));
currentIndex = end + 1;
}
}
addSlice(false, stringValue.slice(currentIndex, window.end + 1).trimEnd());
if (window.end < stringValue.length - 1) {
addEllipsis();
}
return slices;
}
type EllispisSlice = {
type: "ellipsis";
};
type ComponentSlice = {
type: "component";
componentIndex: number;
slice: StringSlice;
};
type JoinSlice = {
type: "join";
};
export type PathSlice = EllispisSlice | ComponentSlice | JoinSlice;
// getComponentSlices returns slices that are either ellipsis or component
// and the component slices are the slices of the component string that, depending on the matchingIndices
//
// If the "weight" of the path is more than the window size, then we need to "hide" some of the component strings behind an ellipsis
// But the hidden components shouldn't be ones that are matched, sorted by the largest match first.
//
// The "weight" of the path is calculated by summing the length of the component strings + (8 * the number of components)
//
// Ellipsis is a special case where the weight is 2
//
// Example:
// path = records.0.users.9.addresses.0.street_address.street_name
// maxWeight = 60
// matchingIndices = [ [ 0, 1 ], [ 11, 14 ], [ 30, 35 ], [ 45, 50 ] ]
//
// Weight Calculation:
// records = 7
// . = 8
// 0 = 1
// . = 8
// users = 5
// . = 8
// 9 = 1
// . = 8
// addresses = 9
// . = 8
// 0 = 1
// . = 8
// street_address = 14
// . = 8
// street_name = 11
//
// Total Weight: 7 + 8 + 1 + 8 + 5 + 8 + 1 + 8 + 9 + 8 + 1 + 8 + 14 + 8 + 11 = 105
//
//
// To get below the maxWeight, we need to hide the components that are not the largest match
//
// Using the example from above, the result should be:
// records = 7
// . = 8
// … = 2
// . = 8
// street_address = 14
// . = 8
// street_name = 11
//
// Total Weight: 7 + 8 + 2 + 8 + 14 + 8 + 11 = 58
//
// So the result from getComponentSlices for the above example should be:
// [
// { type: "component", slice: { isMatch: true, slice: "re" } },
// { type: "component", slice: { isMatch: false, slice: "cords" } },
// { type: "join" },
// { type: "ellipsis" },
// { type: "join" },
// { type: "component", slice: { isMatch: true, slice: "street" } },
// { type: "component", slice: { isMatch: false, slice: "_address" } },
// { type: "join" },
// { type: "component", slice: { isMatch: true, slice: "street" } },
// { type: "component", slice: { isMatch: false, slice: "_name" } },
// ]
//
// Some rules:
// 1. We never have more than one ellipsis
// 2. We never hide the first component behind an ellipsis
// 3. We try to get the final weight to be as close to the maxWeight as possible
//
export function getComponentSlices(
path: string,
matchingIndices: ReadonlyArray<[number, number]>,
maxWeight: number
): Array<PathSlice> {
const calculateWeight = (pathSlices: PathSlice[]): number => {
let weight = 0;
for (const slice of pathSlices) {
weight += calculateSliceWeight(slice);
}
return weight;
};
const calculateSliceWeight = (slice: PathSlice): number => {
if (slice.type === "component") {
return slice.slice.slice.length;
} else if (slice.type === "ellipsis") {
return 2;
} else {
return 8;
}
};
const calculateLongestMatch = (componentSlices: ComponentSlice[]): number => {
return componentSlices.reduce(
(longestMatch, slice) =>
slice.slice.isMatch
? Math.max(longestMatch, slice.slice.slice.length)
: longestMatch,
0
);
};
const addEllipsisToSlices = (
slices: PathSlice[],
mostImportantComponentIndex: number
): PathSlice[] => {
// This should take an array of slices like this:
// [
// { type: "component", slice: { isMatch: true, slice: "re" } },
// { type: "component", slice: { isMatch: false, slice: "cords" } },
// { type: "join" },
// { type: "ellipsis" },
// { type: "join" },
// { type: "ellipsis" },
// { type: "ellipsis" },
// { type: "join" },
// { type: "component", slice: { isMatch: true, slice: "street" } },
// { type: "component", slice: { isMatch: false, slice: "_address" } },
// ]
//
//
// And should return this:
// [
// { type: "component", slice: { isMatch: true, slice: "re" } },
// { type: "component", slice: { isMatch: false, slice: "cords" } },
// { type: "join" },
// { type: "ellipsis" },
// { type: "join" },
// { type: "component", slice: { isMatch: true, slice: "street" } },
// { type: "component", slice: { isMatch: false, slice: "_address" } },
// ]
const combineAdjacentEllipsis = (toCombine: PathSlice[]): PathSlice[] => {
const combined: PathSlice[] = [];
let inEllipsis = false;
for (let i = 0; i < toCombine.length; i++) {
const slice = toCombine[i];
if (slice.type === "ellipsis") {
if (!inEllipsis) {
inEllipsis = true;
combined.push(slice);
}
}
if (slice.type === "join") {
if (!inEllipsis) {
combined.push(slice);
}
}
if (slice.type === "component") {
if (inEllipsis) {
combined.push({ type: "join" });
inEllipsis = false;
}
combined.push(slice);
}
}
return combined;
};
const replaceComponentIndexWithEllipsis = (
ellipsisComponentIndex: number
): PathSlice[] => {
const ellipsisSliceIndices = slices
.map((slice, index) => [slice, index] as [PathSlice, number])
.filter(
([slice, index]) =>
slice.type === "component" &&
slice.componentIndex === ellipsisComponentIndex
)
.map(([, index]) => index);
const newEllipsis = slices.map((slice, index) => {
if (ellipsisSliceIndices.includes(index)) {
return {
type: "ellipsis",
};
} else {
return slice;
}
}) as PathSlice[];
return combineAdjacentEllipsis(newEllipsis);
};
const componentSlices = Array.keepMap(slices, (slice) =>
slice.type === "component" ? slice : null
);
const componentIndexes = uniq(
componentSlices.map((slice) => slice.componentIndex)
);
const ellipsisIndex = slices.findIndex(
(slice) => slice.type === "ellipsis"
);
if (ellipsisIndex === -1) {
let ellipsisComponentIndex = 0;
// There are no ellipsis yet, so we need to figure out where to put the first one
if (mostImportantComponentIndex === 0) {
ellipsisComponentIndex = componentIndexes[componentIndexes.length - 2];
} else if (mostImportantComponentIndex === componentIndexes.length - 1) {
ellipsisComponentIndex = componentIndexes[1];
} else {
const halfWay = Math.floor(componentIndexes.length / 2);
if (mostImportantComponentIndex < halfWay) {
ellipsisComponentIndex = componentIndexes[halfWay + 1];
}
if (mostImportantComponentIndex > halfWay) {
ellipsisComponentIndex = componentIndexes[halfWay - 1];
}
if (mostImportantComponentIndex === halfWay) {
ellipsisComponentIndex = componentIndexes[1];
}
}
return replaceComponentIndexWithEllipsis(ellipsisComponentIndex);
} else {
// Add to the existing ellipsis
// Get nearest component index to the ellipsis, before and after
const nearestBefore = Array.keepMap(
slices.slice(0, ellipsisIndex).reverse(),
(slice) => (slice.type === "component" ? slice : null)
)[0];
const nearestAfter = Array.keepMap(
slices.slice(ellipsisIndex + 1),
(slice) => (slice.type === "component" ? slice : null)
)[0];
if (
nearestBefore.componentIndex !== 0 &&
nearestBefore.componentIndex !== mostImportantComponentIndex
) {
return replaceComponentIndexWithEllipsis(nearestBefore.componentIndex);
} else if (
nearestAfter.componentIndex !== 0 &&
nearestAfter.componentIndex !== mostImportantComponentIndex
) {
return replaceComponentIndexWithEllipsis(nearestAfter.componentIndex);
}
}
return slices;
};
let slices = createComponentSlices(path, matchingIndices);
let weight = calculateWeight(slices);
while (weight > maxWeight) {
const componentSlices = Array.keepMap(slices, (slice) =>
slice.type === "component" ? slice : null
);
const groupByComponentIndex = groupBy(
componentSlices,
(slice) => slice.componentIndex
);
const sortedByLongestMatch = Dict.entries(groupByComponentIndex).sort(
([, componentSlicesA], [, componentSlicesB]) => {
if (
calculateLongestMatch(componentSlicesA) >
calculateLongestMatch(componentSlicesB)
) {
return -1;
}
if (
calculateLongestMatch(componentSlicesB) >
calculateLongestMatch(componentSlicesA)
) {
return 1;
}
return 0;
}
);
const mostImportantComponentIndex = Number(sortedByLongestMatch[0][0]);
slices = addEllipsisToSlices(slices, mostImportantComponentIndex);
const newWeight = calculateWeight(slices);
// Just in case we can't shrink the weight any further
if (newWeight === weight) {
break;
}
weight = newWeight;
}
return slices;
}
export function createComponentSlices(
path: string,
matchingIndices: ReadonlyArray<[number, number]>
): Array<PathSlice> {
const slices: PathSlice[] = [];
const addComponent = (slice: StringSlice, componentIndex: number) => {
slices.push({ type: "component", componentIndex, slice });
};
const addJoin = () => {
slices.push({ type: "join" });
};
const addEllipsis = () => {
slices.push({ type: "ellipsis" });
};
const components = path.split(".");
let currentIndex = 0;
let currentComponentIndex = 0;
for (const component of components) {
if (currentComponentIndex !== 0) {
// Adds the "."
currentIndex += 1;
}
const endIndex = currentIndex + component.length;
// Example matchingIndices = [[0, 1], [6, 10], [12, 20]]
// currentIndex = 7
// endIndex = 7 + 6 = 13
const intersectingMatches = matchingIndices
.filter(
([start, end]) =>
(currentIndex >= start && currentIndex <= end) ||
(endIndex >= start && endIndex <= end) ||
(start >= currentIndex && end <= endIndex)
)
.map(
([start, end]) =>
[Math.max(start - currentIndex, 0), end - currentIndex] as [
number,
number
]
);
const stringSlices = getStringSlices(
component,
intersectingMatches,
component.length + 1
);
for (const stringSlice of stringSlices) {
addComponent(stringSlice, currentComponentIndex);
}
if (currentComponentIndex + 1 < components.length) {
addJoin();
}
currentComponentIndex += 1;
currentIndex = endIndex;
}
return slices;
}