Compare commits

...

1 Commits

Author SHA1 Message Date
Yourz
853782e395 feat: highlight search matches in node library sidebar
Use Fuse.js native includeMatches to get exact character ranges for
search matches, then render highlighted labels in the tree view.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019d2553-559f-730f-a880-1645bdb99bdd
2026-03-26 00:24:55 +08:00
5 changed files with 177 additions and 17 deletions

View File

@@ -20,9 +20,12 @@
@dragend="handleDragEnd"
>
<i class="icon-[comfy--node] size-4 shrink-0 text-muted-foreground" />
<span class="text-foreground min-w-0 flex-1 truncate text-sm">
<span
class="text-foreground min-w-0 flex-1 truncate text-sm [&_.highlight]:rounded-sm [&_.highlight]:bg-primary-background [&_.highlight]:px-0.5 [&_.highlight]:font-bold [&_.highlight]:text-white"
>
<slot name="node" :node="item.value">
{{ item.value.label }}
<span v-if="highlightedLabel" v-html="highlightedLabel" />
<template v-else>{{ item.value.label }}</template>
</slot>
</span>
<button
@@ -90,13 +93,16 @@
<script setup lang="ts">
import type { FlattenedItem } from 'reka-ui'
import { TreeItem } from 'reka-ui'
import { computed, inject } from 'vue'
import { computed, inject, ref } from 'vue'
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
import { useNodePreviewAndDrag } from '@/composables/node/useNodePreviewAndDrag'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
import {
InjectKeyContextMenuNode,
InjectKeySearchHighlights
} from '@/types/treeExplorerTypes'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { cn } from '@/utils/tailwindUtil'
@@ -119,9 +125,18 @@ const emit = defineEmits<{
}>()
const contextMenuNode = inject(InjectKeyContextMenuNode)
const searchHighlights = inject(
InjectKeySearchHighlights,
ref(new Map<string, string>())
)
const nodeBookmarkStore = useNodeBookmarkStore()
const nodeDef = computed(() => item.value.data)
const highlightedLabel = computed(() => {
const name = nodeDef.value?.name
if (!name) return ''
return searchHighlights.value.get(name) ?? ''
})
const isBookmarked = computed(() => {
if (!nodeDef.value) return false

View File

@@ -164,7 +164,7 @@ import {
DropdownMenuRoot,
DropdownMenuTrigger
} from 'reka-ui'
import { computed, nextTick, onMounted, ref, watchEffect } from 'vue'
import { computed, nextTick, onMounted, provide, ref, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import {
@@ -200,6 +200,7 @@ import type {
RenderedTreeExplorerNode,
TreeNode
} from '@/types/treeExplorerTypes'
import { InjectKeySearchHighlights } from '@/types/treeExplorerTypes'
import AllNodesPanel from './nodeLibrary/AllNodesPanel.vue'
import BlueprintsPanel from './nodeLibrary/BlueprintsPanel.vue'
@@ -261,18 +262,74 @@ const expandedKeys = usePerTabState(selectedTab, expandedKeysByTab)
const nodeDefStore = useNodeDefStore()
const { startDrag } = useNodeDragToCanvas()
const filteredNodeDefs = computed(() => {
if (searchQuery.value.length === 0) {
return []
function escapeHtml(s: string): string {
return s
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}
function buildHighlightHtml(
text: string,
indices: ReadonlyArray<readonly [number, number]>
): string {
if (indices.length === 0) return escapeHtml(text)
const sorted = [...indices].sort((a, b) => a[0] - b[0])
let result = ''
let lastIndex = 0
for (const [start, end] of sorted) {
if (start > lastIndex) {
result += escapeHtml(text.slice(lastIndex, start))
}
result += `<span class="highlight">${escapeHtml(text.slice(start, end + 1))}</span>`
lastIndex = end + 1
}
return nodeDefStore.nodeSearchService.searchNode(
searchQuery.value,
[],
{ limit: 64 },
{ matchWildcards: false }
)
if (lastIndex < text.length) {
result += escapeHtml(text.slice(lastIndex))
}
return result
}
const searchResult = computed(() => {
if (searchQuery.value.length === 0) {
return {
items: [] as ComfyNodeDefImpl[],
highlights: new Map<string, string>()
}
}
const { items, matchesByNode } =
nodeDefStore.nodeSearchService.searchNodeWithMatches(
searchQuery.value,
[],
{ limit: 64 },
{ matchWildcards: false }
)
const highlights = new Map<string, string>()
for (const [nodeName, matches] of matchesByNode) {
const displayNameMatch = matches.find((m) => m.key === 'display_name')
if (displayNameMatch) {
const node = items.find((n) => n.name === nodeName)
if (node) {
highlights.set(
nodeName,
buildHighlightHtml(node.display_name, displayNameMatch.indices)
)
}
}
}
return { items, highlights }
})
const filteredNodeDefs = computed(() => searchResult.value.items)
const searchHighlights = computed(() => searchResult.value.highlights)
provide(InjectKeySearchHighlights, searchHighlights)
const activeNodes = computed(() =>
filteredNodeDefs.value.length > 0
? filteredNodeDefs.value

View File

@@ -1,9 +1,14 @@
import type { FuseSearchOptions } from 'fuse.js'
import type { FuseResultMatch, FuseSearchOptions } from 'fuse.js'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
import { FuseFilter, FuseSearch } from '@/utils/fuseUtil'
interface NodeSearchResult {
items: ComfyNodeDefImpl[]
matchesByNode: Map<string, readonly FuseResultMatch[]>
}
export class NodeSearchService {
public readonly nodeFuseSearch: FuseSearch<ComfyNodeDefImpl>
public readonly inputTypeFilter: FuseFilter<ComfyNodeDefImpl, string>
@@ -99,6 +104,43 @@ export class NodeSearchService {
return options?.limit ? results.slice(0, options.limit) : results
}
public searchNodeWithMatches(
query: string,
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[] = [],
options?: FuseSearchOptions,
extraOptions: { matchWildcards?: boolean } = {}
): NodeSearchResult {
const { matchWildcards = true } = extraOptions
const wildcard = matchWildcards ? '*' : undefined
const matchedResults = this.nodeFuseSearch.searchWithMatches(query)
const matchesByNode = new Map<string, readonly FuseResultMatch[]>()
for (const r of matchedResults) {
matchesByNode.set(r.item.name, r.matches)
}
const matchedNodes = matchedResults.map((r) => r.item)
const results = matchedNodes.filter((node) =>
filters.every(({ filterDef, value }) => filterDef.matches(node, value))
)
if (matchWildcards) {
const alreadyValid = new Set(results.map((r) => r.name))
results.push(
...matchedNodes
.filter((node) => !alreadyValid.has(node.name))
.filter((node) =>
filters.every(({ filterDef, value }) =>
filterDef.matches(node, value, { wildcard })
)
)
)
}
const items = options?.limit ? results.slice(0, options.limit) : results
return { items, matchesByNode }
}
get nodeFilters(): FuseFilter<ComfyNodeDefImpl, string>[] {
return [
this.inputTypeFilter,

View File

@@ -99,3 +99,6 @@ export const InjectKeyExpandedKeys: InjectionKey<
export const InjectKeyContextMenuNode: InjectionKey<
Ref<RenderedTreeExplorerNode<ComfyNodeDefImpl> | null>
> = Symbol()
export const InjectKeySearchHighlights: InjectionKey<Ref<Map<string, string>>> =
Symbol()

View File

@@ -1,8 +1,19 @@
import type { FuseOptionKey, FuseSearchOptions, IFuseOptions } from 'fuse.js'
import type {
FuseOptionKey,
FuseResult,
FuseResultMatch,
FuseSearchOptions,
IFuseOptions
} from 'fuse.js'
import Fuse from 'fuse.js'
export type SearchAuxScore = number[]
interface FuseSearchResultWithMatches<T> {
item: T
matches: readonly FuseResultMatch[]
}
export interface FuseFilterWithValue<T, O = string> {
filterDef: FuseFilter<T, O>
value: O
@@ -109,7 +120,7 @@ export class FuseSearch<T> {
createIndex && this.keys.length
? Fuse.createIndex(this.keys, data)
: undefined
this.fuse = new Fuse(data, fuseOptions, index)
this.fuse = new Fuse(data, { ...fuseOptions, includeMatches: true }, index)
}
public search(query: string, options?: FuseSearchOptions): T[] {
@@ -135,6 +146,38 @@ export class FuseSearch<T> {
return aux.map((x) => x.item)
}
public searchWithMatches(
query: string,
options?: FuseSearchOptions
): FuseSearchResultWithMatches<T>[] {
if (!query) {
return this.data.map((item) => ({ item, matches: [] }))
}
const fuseResult: FuseResult<T>[] = this.fuse.search(query, options)
const toResult = (r: FuseResult<T>): FuseSearchResultWithMatches<T> => ({
item: r.item,
matches: r.matches ?? []
})
if (!this.advancedScoring) {
return fuseResult.map(toResult)
}
return fuseResult
.map((r) => ({
result: toResult(r),
scores: this.calcAuxScores(
query.toLocaleLowerCase(),
r.item,
r.score ?? 0
)
}))
.sort((a, b) => this.compareAux(a.scores, b.scores))
.map((x) => x.result)
}
public calcAuxScores(query: string, entry: T, score: number): SearchAuxScore {
let values: string[] = []
if (typeof entry === 'string') {