Skip to content
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
14 changes: 13 additions & 1 deletion src/components/rightSidePanel/shared.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
91 changes: 68 additions & 23 deletions src/components/rightSidePanel/shared.ts
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we extract the search logic to a service or composable? To make it re-usable, testable, deduplicate, and potentially implement lifecycle hooks if they become necessary?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This search logic was reused in the entire right side panel. They use the same data structure and search widgets. I don't know the situation in other places.

Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -17,10 +19,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<T extends { widget: IBaseWidget }[]>(
list: T,
Expand All @@ -29,27 +39,48 @@ export function searchWidgets<T extends { widget: IBaseWidget }[]>(
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<WidgetSearchItem> = {
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,
Expand All @@ -58,12 +89,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<NodeSearchItem> = {
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 {
Expand Down