Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(editor): improve nodes panel search #4399

Merged
merged 2 commits into from
Oct 24, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,10 @@ import ItemIterator from './ItemIterator.vue';
import NoResults from './NoResults.vue';
import SearchBar from './SearchBar.vue';
import { INodeCreateElement, INodeItemProps, ISubcategoryItemProps, ICategoriesWithNodes, ICategoryItemProps, INodeFilterType } from '@/Interface';
import { CORE_NODES_CATEGORY, WEBHOOK_NODE_TYPE, HTTP_REQUEST_NODE_TYPE, ALL_NODE_FILTER, TRIGGER_NODE_FILTER, REGULAR_NODE_FILTER, NODE_TYPE_COUNT_MAPPER } from '@/constants';
import { WEBHOOK_NODE_TYPE, HTTP_REQUEST_NODE_TYPE, ALL_NODE_FILTER, TRIGGER_NODE_FILTER, REGULAR_NODE_FILTER, NODE_TYPE_COUNT_MAPPER } from '@/constants';
import { matchesNodeType, matchesSelectType } from './helpers';
import { BaseTextKey } from '@/plugins/i18n';
import { sublimeSearch } from './sortUtils';

export default mixins(externalHooks, globalLinkActions).extend({
name: 'CategorizedItems',
Expand Down Expand Up @@ -175,22 +176,35 @@ export default mixins(externalHooks, globalLinkActions).extend({
searchFilter(): string {
return this.nodeFilter.toLowerCase().trim();
},
defaultLocale (): string {
return this.$store.getters.defaultLocale;
},
filteredNodeTypes(): INodeCreateElement[] {
const searchableNodes = this.subcategorizedNodes.length > 0 ? this.subcategorizedNodes : this.searchItems;
const filter = this.searchFilter;
const matchedCategorizedNodes = searchableNodes.filter((el: INodeCreateElement) => {
return filter && matchesSelectType(el, this.selectedType) && matchesNodeType(el, filter);
});
const searchableNodes = this.subcategorizedNodes.length > 0 ? this.subcategorizedNodes : this.searchItems;

let returnItems: INodeCreateElement[] = [];
if (this.defaultLocale !== 'en') {
returnItems = searchableNodes.filter((el: INodeCreateElement) => {
return filter && matchesSelectType(el, this.selectedType) && matchesNodeType(el, filter);
});
}
else {
const matchingNodes = searchableNodes.filter((el) => matchesSelectType(el, this.selectedType));
const matchedCategorizedNodes = sublimeSearch<INodeCreateElement>(filter, matchingNodes, [{key: 'properties.nodeType.displayName', weight: 2}, {key: 'properties.nodeType.codex.alias', weight: 1}]);
returnItems = matchedCategorizedNodes.map(({item}) => item);;
}


setTimeout(() => {
this.$externalHooks().run('nodeCreateList.filteredNodeTypesComputed', {
nodeFilter: this.nodeFilter,
result: matchedCategorizedNodes,
result: returnItems,
selectedType: this.selectedType,
});
}, 0);

return matchedCategorizedNodes;
return returnItems;
},
filteredAllNodeTypes(): INodeCreateElement[] {
if(this.filteredNodeTypes.length > 0) return [];
Expand Down
268 changes: 268 additions & 0 deletions packages/editor-ui/src/components/Node/NodeCreator/sortUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
// based on https://github.com/forrestthewoods/lib_fts/blob/master/code/fts_fuzzy_match.js

const SEQUENTIAL_BONUS = 30; // bonus for adjacent matches
const SEPARATOR_BONUS = 30; // bonus if match occurs after a separator
const CAMEL_BONUS = 30; // bonus if match is uppercase and prev is lower
const FIRST_LETTER_BONUS = 15; // bonus if the first letter is matched

const LEADING_LETTER_PENALTY = -15; // penalty applied for every letter in str before the first match
const MAX_LEADING_LETTER_PENALTY = -200; // maximum penalty for leading letters
const UNMATCHED_LETTER_PENALTY = -5;

/**
* Returns true if each character in pattern is found sequentially within target
* @param {*} pattern string
* @param {*} target string
*/
function fuzzyMatchSimple(pattern: string, target: string): boolean {
let patternIdx = 0;
let strIdx = 0;

while (patternIdx < pattern.length && strIdx < target.length) {
const patternChar = pattern.charAt(patternIdx).toLowerCase();
const targetChar = target.charAt(strIdx).toLowerCase();
if (patternChar === targetChar) {
patternIdx++;
}
++strIdx;
}

return pattern.length !== 0 && target.length !== 0 && patternIdx === pattern.length;
}

/**
* Does a fuzzy search to find pattern inside a string.
* @param {*} pattern string pattern to search for
* @param {*} target string string which is being searched
* @returns [boolean, number] a boolean which tells if pattern was
* found or not and a search score
*/
function fuzzyMatch(pattern: string, target: string): {matched: boolean, outScore: number} {
const recursionCount = 0;
const recursionLimit = 5;
const matches: number[] = [];
const maxMatches = 256;

return fuzzyMatchRecursive(
pattern,
target,
0 /* patternCurIndex */,
0 /* strCurrIndex */,
null /* srcMatces */,
matches,
maxMatches,
0 /* nextMatch */,
recursionCount,
recursionLimit,
);
}

function fuzzyMatchRecursive(
pattern: string,
target: string,
patternCurIndex: number,
targetCurrIndex: number,
targetMatches: null | number[],
matches: number[],
maxMatches: number,
nextMatch: number,
recursionCount: number,
recursionLimit: number,
): {matched: boolean, outScore: number} {
let outScore = 0;

// Return if recursion limit is reached.
if (++recursionCount >= recursionLimit) {
return {matched: false, outScore};
}

// Return if we reached ends of strings.
if (patternCurIndex === pattern.length || targetCurrIndex === target.length) {
return {matched: false, outScore};
}

// Recursion params
let recursiveMatch = false;
let bestRecursiveMatches: number[] = [];
let bestRecursiveScore = 0;

// Loop through pattern and str looking for a match.
let firstMatch = true;
while (patternCurIndex < pattern.length && targetCurrIndex < target.length) {
// Match found.
if (
pattern[patternCurIndex].toLowerCase() === target[targetCurrIndex].toLowerCase()
) {
if (nextMatch >= maxMatches) {
return {matched: false, outScore};
}

if (firstMatch && targetMatches) {
matches = [...targetMatches];
firstMatch = false;
}

const recursiveMatches: number[] = [];
const recursiveResult = fuzzyMatchRecursive(
pattern,
target,
patternCurIndex,
targetCurrIndex + 1,
matches,
recursiveMatches,
maxMatches,
nextMatch,
recursionCount,
recursionLimit,
);

const recursiveScore = recursiveResult.outScore;
if (recursiveResult.matched) {
// Pick best recursive score.
if (!recursiveMatch || recursiveScore > bestRecursiveScore) {
bestRecursiveMatches = [...recursiveMatches];
bestRecursiveScore = recursiveScore;
}
recursiveMatch = true;
}

matches[nextMatch++] = targetCurrIndex;
++patternCurIndex;
}
++targetCurrIndex;
}

const matched = patternCurIndex === pattern.length;

if (matched) {
outScore = 100;

// Apply leading letter penalty
let penalty = LEADING_LETTER_PENALTY * matches[0];
penalty =
penalty < MAX_LEADING_LETTER_PENALTY
? MAX_LEADING_LETTER_PENALTY
: penalty;
outScore += penalty;

//Apply unmatched penalty
const unmatched = target.length - nextMatch;
outScore += UNMATCHED_LETTER_PENALTY * unmatched;

// Apply ordering bonuses
for (let i = 0; i < nextMatch; i++) {
const currIdx = matches[i];

if (i > 0) {
const prevIdx = matches[i - 1];
if (currIdx === prevIdx + 1) {
outScore += SEQUENTIAL_BONUS;
}
}

// Check for bonuses based on neighbor character value.
if (currIdx > 0) {
// Camel case
const neighbor = target[currIdx - 1];
const curr = target[currIdx];
if (
neighbor !== neighbor.toUpperCase() &&
curr !== curr.toLowerCase()
) {
outScore += CAMEL_BONUS;
}
const isNeighbourSeparator = neighbor === "_" || neighbor === " ";
if (isNeighbourSeparator) {
outScore += SEPARATOR_BONUS;
}
} else {
// First letter
outScore += FIRST_LETTER_BONUS;
}
}

// Return best result
if (recursiveMatch && (!matched || bestRecursiveScore > outScore)) {
// Recursive score is better than "this"
matches = [...bestRecursiveMatches];
outScore = bestRecursiveScore;
return {matched: true, outScore};
} else if (matched) {
// "this" score is better than recursive
return {matched: true, outScore};
} else {
return {matched: false, outScore};
}
}
return {matched: false, outScore};
}

// prop = 'key'
// prop = 'key1.key2'
// prop = ['key1', 'key2']
function getValue<T extends object>(obj: T, prop: string): unknown {
if (obj.hasOwnProperty(prop)) {
return obj[prop as keyof T];
}

const segments = prop.split('.');
let result: any = obj; // tslint:disable-line:no-any
let i = 0;
while (result && i < segments.length) {
result = result[segments[i]];
i++;
}
return result;
}

export function sublimeSearch<T extends object>(filter: string, data: Readonly<T[]>, keys: Array<{key: string, weight: number}>): Array<{score: number, item: T}> {
const results = data.reduce((accu: Array<{score: number, item: T}>, item: T) => {
let values: Array<{value: string, weight: number}> = [];
keys.forEach(({key, weight}) => {
const value = getValue(item, key);
if (Array.isArray(value)) {
values = values.concat(value.map((v) => ({value: v, weight})));
}
else if (typeof value === 'string') {
values.push({
value,
weight,
});
}
});

// for each item, check every key and get maximum score
const itemMatch = values.reduce((accu: null | {matched: boolean, outScore: number}, {value, weight}: {value: string, weight: number}) => {
if (!fuzzyMatchSimple(filter, value)) {
return accu;
}

const match = fuzzyMatch(filter, value);
match.outScore *= weight;

const {matched, outScore} = match;
if (!accu && matched) {
return match;
}
if (matched && accu && outScore > accu.outScore) {
return match;
}
return accu;
}, null);

if (itemMatch) {
accu.push({
score: itemMatch.outScore,
item,
});
}

return accu;
}, []);

results.sort((a, b) => {
return b.score - a.score;
});

return results;
}