diff --git a/src/components/rightSidePanel/shared.test.ts b/src/components/rightSidePanel/shared.test.ts index 02be8e293..37a8f3aeb 100644 --- a/src/components/rightSidePanel/shared.test.ts +++ b/src/components/rightSidePanel/shared.test.ts @@ -38,10 +38,22 @@ describe('searchWidgets', () => { expect(searchWidgets(widgets, 'width')).toHaveLength(1) expect(searchWidgets(widgets, 'slider')).toHaveLength(1) - expect(searchWidgets(widgets, 'high')).toHaveLength(1) expect(searchWidgets(widgets, 'image')).toHaveLength(1) }) + it('should support fuzzy matching (e.g., "high" matches both "height" and value "high")', () => { + const widgets = [ + createWidget('width', 'number', '100', 'Size Control'), + createWidget('height', 'slider', '200', 'Image Height'), + createWidget('quality', 'text', 'high', 'Quality') + ] + + const results = searchWidgets(widgets, 'high') + expect(results).toHaveLength(2) + expect(results.some((r) => r.widget.name === 'height')).toBe(true) + expect(results.some((r) => r.widget.name === 'quality')).toBe(true) + }) + it('should handle multiple search words', () => { const widgets = [ createWidget('width', 'number', '100', 'Image Width'), diff --git a/src/components/rightSidePanel/shared.ts b/src/components/rightSidePanel/shared.ts index b2b9f0cdf..0d70d6d34 100644 --- a/src/components/rightSidePanel/shared.ts +++ b/src/components/rightSidePanel/shared.ts @@ -1,9 +1,11 @@ import type { InjectionKey, MaybeRefOrGetter } from 'vue' import { computed, toValue } from 'vue' +import Fuse from 'fuse.js' +import type { IFuseOptions } from 'fuse.js' import type { Positionable } from '@/lib/litegraph/src/interfaces' import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup' -import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' +import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode' import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil' import { useSettingStore } from '@/platform/settings/settingStore' @@ -18,10 +20,18 @@ export type NodeWidgetsListList = Array<{ widgets: NodeWidgetsList }> +interface WidgetSearchItem { + index: number + searchableLabel: string + searchableName: string + searchableType: string + searchableValue: string +} + /** - * Searches widgets in a list and returns search results. + * Searches widgets in a list using fuzzy search and returns search results. + * Uses Fuse.js for better matching with typo tolerance and relevance ranking. * Filters by name, localized label, type, and user-input value. - * Performs basic tokenization of the query string. */ export function searchWidgets( list: T, @@ -30,27 +40,48 @@ export function searchWidgets( if (query.trim() === '') { return list } - const words = query.trim().toLowerCase().split(' ') - return list.filter(({ widget }) => { - const label = widget.label?.toLowerCase() - const name = widget.name.toLowerCase() - const type = widget.type.toLowerCase() - const value = widget.value?.toString().toLowerCase() - return words.every( - (word) => - name.includes(word) || - label?.includes(word) || - type?.includes(word) || - value?.includes(word) - ) - }) as T + + const searchableList: WidgetSearchItem[] = list.map((item, index) => { + const searchableItem = { + index, + searchableLabel: item.widget.label?.toLowerCase() || '', + searchableName: item.widget.name.toLowerCase(), + searchableType: item.widget.type.toLowerCase(), + searchableValue: item.widget.value?.toString().toLowerCase() || '' + } + return searchableItem + }) + + const fuseOptions: IFuseOptions = { + keys: [ + { name: 'searchableName', weight: 0.4 }, + { name: 'searchableLabel', weight: 0.3 }, + { name: 'searchableValue', weight: 0.3 }, + { name: 'searchableType', weight: 0.2 } + ], + threshold: 0.3 + } + + const fuse = new Fuse(searchableList, fuseOptions) + const results = fuse.search(query.trim()) + + const matchedItems = new Set( + results.map((result) => list[result.item.index]!) + ) + + return list.filter((item) => matchedItems.has(item)) as T +} + +type NodeSearchItem = { + nodeId: NodeId + searchableTitle: string } /** - * Searches widgets and nodes in a list and returns search results. + * Searches widgets and nodes in a list using fuzzy search and returns search results. + * Uses Fuse.js for node title matching with typo tolerance and relevance ranking. * First checks if the node title matches the query (if so, keeps entire node). * Otherwise, filters widgets using searchWidgets. - * Performs basic tokenization of the query string. */ export function searchWidgetsAndNodes( list: NodeWidgetsListList, @@ -59,12 +90,26 @@ export function searchWidgetsAndNodes( if (query.trim() === '') { return list } - const words = query.trim().toLowerCase().split(' ') + + const searchableList: NodeSearchItem[] = list.map((item) => ({ + nodeId: item.node.id, + searchableTitle: (item.node.getTitle() ?? '').toLowerCase() + })) + + const fuseOptions: IFuseOptions = { + keys: [{ name: 'searchableTitle', weight: 1.0 }], + threshold: 0.3 + } + + const fuse = new Fuse(searchableList, fuseOptions) + const nodeMatches = fuse.search(query.trim()) + const matchedNodeIds = new Set( + nodeMatches.map((result) => result.item.nodeId) + ) + return list .map((item) => { - const { node } = item - const title = node.getTitle().toLowerCase() - if (words.every((word) => title.includes(word))) { + if (matchedNodeIds.has(item.node.id)) { return { ...item, keep: true } } return {