Node library search filters (#636)

* Add search filters to node library

* Fix

* Dont close on add

* Fix wildcard

---------

Co-authored-by: Chenlei Hu <chenlei.hu@mail.utoronto.ca>
This commit is contained in:
pythongosssss
2024-08-28 02:17:34 +01:00
committed by GitHub
parent fef9395a2c
commit 968f417061
6 changed files with 233 additions and 96 deletions

View File

@@ -1,20 +1,45 @@
<template> <template>
<IconField :class="props.class"> <div :class="props.class">
<InputIcon :class="props.icon" /> <IconField>
<InputText <InputIcon :class="props.icon" />
class="search-box-input" <InputText
@input="handleInput" class="search-box-input"
:modelValue="props.modelValue" :class="{ ['with-filter']: props.filterIcon }"
:placeholder="props.placeholder" @input="handleInput"
/> :modelValue="props.modelValue"
</IconField> :placeholder="props.placeholder"
/>
<Button
v-if="props.filterIcon"
class="p-inputicon"
:icon="props.filterIcon"
text
severity="contrast"
@click="$emit('showFilter', $event)"
/>
</IconField>
<div class="search-filters" v-if="filters">
<SearchFilterChip
v-for="filter in filters"
:key="filter.id"
:text="filter.text"
:badge="filter.badge"
:badge-class="filter.badgeClass"
@remove="$emit('removeFilter', filter)"
/>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts" generic="TFilter extends SearchFilter">
import type { SearchFilter } from './SearchFilterChip.vue'
import { debounce } from 'lodash' import { debounce } from 'lodash'
import IconField from 'primevue/iconfield' import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon' import InputIcon from 'primevue/inputicon'
import InputText from 'primevue/inputtext' import InputText from 'primevue/inputtext'
import Button from 'primevue/button'
import SearchFilterChip from './SearchFilterChip.vue'
import { toRefs } from 'vue'
interface Props { interface Props {
class?: string class?: string
@@ -22,6 +47,8 @@ interface Props {
placeholder?: string placeholder?: string
icon?: string icon?: string
debounceTime?: number debounceTime?: number
filterIcon?: string
filters?: TFilter[]
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -30,10 +57,17 @@ const props = withDefaults(defineProps<Props>(), {
debounceTime: 300 debounceTime: 300
}) })
const emit = defineEmits(['update:modelValue', 'search']) const { filters } = toRefs(props)
const emit = defineEmits([
'update:modelValue',
'search',
'showFilter',
'removeFilter'
])
const emitSearch = debounce((value: string) => { const emitSearch = debounce((value: string) => {
emit('search', value) emit('search', value, props.filters)
}, props.debounceTime) }, props.debounceTime)
const handleInput = (event: Event) => { const handleInput = (event: Event) => {
@@ -46,5 +80,20 @@ const handleInput = (event: Event) => {
<style scoped> <style scoped>
.search-box-input { .search-box-input {
width: 100%; width: 100%;
padding-left: 36px;
}
.search-box-input.with-filter {
padding-right: 36px;
}
.p-button.p-inputicon {
padding: 0;
width: auto;
border: none !important;
}
.search-filters {
@apply pt-2 flex flex-wrap gap-2;
} }
</style> </style>

View File

@@ -0,0 +1,41 @@
<template>
<Chip removable @remove="$emit('remove', $event)">
<Badge size="small" :class="badgeClass">
{{ badge }}
</Badge>
{{ text }}
</Chip>
</template>
<script setup lang="ts">
import Chip from 'primevue/chip'
import Badge from 'primevue/badge'
export interface SearchFilter {
text: string
badge: string
badgeClass: string
id: string | number
}
defineProps<Omit<SearchFilter, 'id'>>()
defineEmits(['remove'])
</script>
<style scoped>
:deep(.i-badge) {
@apply bg-green-500 text-white;
}
:deep(.o-badge) {
@apply bg-red-500 text-white;
}
:deep(.c-badge) {
@apply bg-blue-500 text-white;
}
:deep(.s-badge) {
@apply bg-yellow-500;
}
</style>

View File

@@ -7,7 +7,22 @@
v-if="hoveredSuggestion" v-if="hoveredSuggestion"
/> />
</div> </div>
<NodeSearchFilter @addFilter="onAddFilter" />
<Button
icon="pi pi-filter"
severity="secondary"
class="_filter-button"
@click="nodeSearchFilterVisible = true"
/>
<Dialog v-model:visible="nodeSearchFilterVisible" class="_dialog">
<template #header>
<h3>Add node filter condition</h3>
</template>
<div class="_dialog-body">
<NodeSearchFilter @addFilter="onAddFilter"></NodeSearchFilter>
</div>
</Dialog>
<AutoCompletePlus <AutoCompletePlus
:model-value="props.filters" :model-value="props.filters"
class="comfy-vue-node-search-box" class="comfy-vue-node-search-box"
@@ -56,12 +71,12 @@
</template> </template>
<!-- FilterAndValue --> <!-- FilterAndValue -->
<template v-slot:chip="{ value }"> <template v-slot:chip="{ value }">
<Chip removable @remove="onRemoveFilter($event, value)"> <SearchFilterChip
<Badge size="small" :class="value[0].invokeSequence + '-badge'"> @remove="onRemoveFilter($event, value)"
{{ value[0].invokeSequence.toUpperCase() }} :text="value[1]"
</Badge> :badge="value[0].invokeSequence.toUpperCase()"
{{ value[1] }} :badge-class="value[0].invokeSequence + '-badge'"
</Chip> />
</template> </template>
</AutoCompletePlus> </AutoCompletePlus>
</div> </div>
@@ -70,9 +85,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import AutoCompletePlus from '@/components/primevueOverride/AutoCompletePlus.vue' import AutoCompletePlus from '@/components/primevueOverride/AutoCompletePlus.vue'
import Chip from 'primevue/chip'
import Badge from 'primevue/badge'
import Tag from 'primevue/tag' import Tag from 'primevue/tag'
import Dialog from 'primevue/dialog'
import Button from 'primevue/button'
import NodeSearchFilter from '@/components/searchbox/NodeSearchFilter.vue' import NodeSearchFilter from '@/components/searchbox/NodeSearchFilter.vue'
import NodeSourceChip from '@/components/node/NodeSourceChip.vue' import NodeSourceChip from '@/components/node/NodeSourceChip.vue'
import { type FilterAndValue } from '@/services/nodeSearchService' import { type FilterAndValue } from '@/services/nodeSearchService'
@@ -80,6 +95,7 @@ import NodePreview from '@/components/node/NodePreview.vue'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore' import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore' import { useSettingStore } from '@/stores/settingStore'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import SearchFilterChip from '../common/SearchFilterChip.vue'
const settingStore = useSettingStore() const settingStore = useSettingStore()
const { t } = useI18n() const { t } = useI18n()
@@ -101,6 +117,7 @@ const props = defineProps({
} }
}) })
const nodeSearchFilterVisible = ref(false)
const inputId = `comfy-vue-node-search-box-input-${Math.random()}` const inputId = `comfy-vue-node-search-box-input-${Math.random()}`
const suggestions = ref<ComfyNodeDefImpl[]>([]) const suggestions = ref<ComfyNodeDefImpl[]>([])
const hoveredSuggestion = ref<ComfyNodeDefImpl | null>(null) const hoveredSuggestion = ref<ComfyNodeDefImpl | null>(null)
@@ -136,6 +153,7 @@ const reFocusInput = () => {
onMounted(reFocusInput) onMounted(reFocusInput)
const onAddFilter = (filterAndValue: FilterAndValue) => { const onAddFilter = (filterAndValue: FilterAndValue) => {
nodeSearchFilterVisible.value = false
emit('addFilter', filterAndValue) emit('addFilter', filterAndValue)
reFocusInput() reFocusInput()
} }
@@ -188,22 +206,6 @@ const setHoverSuggestion = (index: number) => {
white-space: nowrap; white-space: nowrap;
} }
.i-badge {
@apply bg-green-500 text-white;
}
.o-badge {
@apply bg-red-500 text-white;
}
.c-badge {
@apply bg-blue-500 text-white;
}
.s-badge {
@apply bg-yellow-500;
}
:deep(.highlight) { :deep(.highlight) {
background-color: var(--p-primary-color); background-color: var(--p-primary-color);
color: var(--p-primary-contrast-color); color: var(--p-primary-contrast-color);
@@ -212,4 +214,12 @@ const setHoverSuggestion = (index: number) => {
padding: 0rem 0.125rem; padding: 0rem 0.125rem;
margin: -0.125rem 0.125rem; margin: -0.125rem 0.125rem;
} }
._filter-button {
z-index: 10;
}
._dialog {
@apply min-w-96;
}
</style> </style>

View File

@@ -1,48 +1,35 @@
<template> <template>
<Button <div class="_content">
icon="pi pi-filter" <SelectButton
severity="secondary" v-model="selectedFilter"
class="_filter-button" :options="filters"
@click="showModal" :allowEmpty="false"
/> optionLabel="name"
<Dialog v-model:visible="visible" class="_dialog"> @change="updateSelectedFilterValue"
<template #header> />
<h3>Add node filter condition</h3> <AutoComplete
</template> v-model="selectedFilterValue"
<div class="_dialog-body"> :suggestions="filterValues"
<SelectButton :min-length="0"
v-model="selectedFilter" @complete="(event) => updateFilterValues(event.query)"
:options="filters" completeOnFocus
:allowEmpty="false" forceSelection
optionLabel="name" dropdown
@change="updateSelectedFilterValue" ></AutoComplete>
/> </div>
<AutoComplete <div class="_footer">
v-model="selectedFilterValue" <Button type="button" label="Add" @click="submit"></Button>
:suggestions="filterValues" </div>
:min-length="0"
@complete="(event) => updateFilterValues(event.query)"
completeOnFocus
forceSelection
dropdown
></AutoComplete>
</div>
<template #footer>
<Button type="button" label="Add" @click="submit"></Button>
</template>
</Dialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { NodeFilter, type FilterAndValue } from '@/services/nodeSearchService' import { NodeFilter, type FilterAndValue } from '@/services/nodeSearchService'
import Button from 'primevue/button' import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import SelectButton from 'primevue/selectbutton' import SelectButton from 'primevue/selectbutton'
import AutoComplete from 'primevue/autocomplete' import AutoComplete from 'primevue/autocomplete'
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useNodeDefStore } from '@/stores/nodeDefStore' import { useNodeDefStore } from '@/stores/nodeDefStore'
const visible = ref<boolean>(false)
const filters = ref<NodeFilter[]>([]) const filters = ref<NodeFilter[]>([])
const selectedFilter = ref<NodeFilter>() const selectedFilter = ref<NodeFilter>()
const filterValues = ref<string[]>([]) const filterValues = ref<string[]>([])
@@ -69,29 +56,21 @@ const updateFilterValues = (query: string) => {
} }
const submit = () => { const submit = () => {
visible.value = false
emit('addFilter', [ emit('addFilter', [
selectedFilter.value, selectedFilter.value,
selectedFilterValue.value selectedFilterValue.value
] as FilterAndValue) ] as FilterAndValue)
} }
const showModal = () => { onMounted(updateSelectedFilterValue)
updateSelectedFilterValue()
visible.value = true
}
</script> </script>
<style scoped> <style scoped>
._filter-button { ._content {
z-index: 10;
}
._dialog {
@apply min-w-96;
}
._dialog-body {
@apply flex flex-col space-y-2; @apply flex flex-col space-y-2;
} }
._footer {
@apply flex flex-col pt-4 items-end;
}
</style> </style>

View File

@@ -23,8 +23,17 @@
class="node-lib-search-box" class="node-lib-search-box"
v-model:modelValue="searchQuery" v-model:modelValue="searchQuery"
@search="handleSearch" @search="handleSearch"
@show-filter="($event) => searchFilter.toggle($event)"
@remove-filter="onRemoveFilter"
:placeholder="$t('searchNodes') + '...'" :placeholder="$t('searchNodes') + '...'"
filter-icon="pi pi-filter"
:filters
/> />
<Popover ref="searchFilter" class="node-lib-filter-popup">
<NodeSearchFilter @addFilter="onAddFilter" />
</Popover>
<Tree <Tree
class="node-lib-tree" class="node-lib-tree"
v-model:expandedKeys="expandedKeys" v-model:expandedKeys="expandedKeys"
@@ -102,8 +111,9 @@ import {
ComfyNodeDefImpl, ComfyNodeDefImpl,
useNodeDefStore useNodeDefStore
} from '@/stores/nodeDefStore' } from '@/stores/nodeDefStore'
import { computed, ref, nextTick } from 'vue' import { computed, ref, nextTick, Ref } from 'vue'
import type { TreeNode } from 'primevue/treenode' import type { TreeNode } from 'primevue/treenode'
import Popover from 'primevue/popover'
import NodeTreeLeaf from './nodeLibrary/NodeTreeLeaf.vue' import NodeTreeLeaf from './nodeLibrary/NodeTreeLeaf.vue'
import NodeTreeFolder from './nodeLibrary/NodeTreeFolder.vue' import NodeTreeFolder from './nodeLibrary/NodeTreeFolder.vue'
import Tree from 'primevue/tree' import Tree from 'primevue/tree'
@@ -116,17 +126,20 @@ import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue
import { useSettingStore } from '@/stores/settingStore' import { useSettingStore } from '@/stores/settingStore'
import { app } from '@/scripts/app' import { app } from '@/scripts/app'
import { findNodeByKey, sortedTree } from '@/utils/treeUtil' import { findNodeByKey, sortedTree } from '@/utils/treeUtil'
import _ from 'lodash'
import { useTreeExpansion } from '@/hooks/treeHooks' import { useTreeExpansion } from '@/hooks/treeHooks'
import type { MenuItem } from 'primevue/menuitem' import type { MenuItem } from 'primevue/menuitem'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useToast } from 'primevue/usetoast' import { useToast } from 'primevue/usetoast'
import NodeSearchFilter from '@/components/searchbox/NodeSearchFilter.vue'
import { FilterAndValue } from '@/services/nodeSearchService'
import { SearchFilter } from '@/components/common/SearchFilterChip.vue'
const { t } = useI18n() const { t } = useI18n()
const toast = useToast() const toast = useToast()
const nodeDefStore = useNodeDefStore() const nodeDefStore = useNodeDefStore()
const { expandedKeys, expandNode, toggleNodeOnEvent } = useTreeExpansion() const { expandedKeys, expandNode, toggleNodeOnEvent } = useTreeExpansion()
const searchFilter = ref(null)
const alphabeticalSort = ref(false) const alphabeticalSort = ref(false)
const hoveredComfyNodeName = ref<string | null>(null) const hoveredComfyNodeName = ref<string | null>(null)
const hoveredComfyNode = computed<ComfyNodeDefImpl | null>(() => { const hoveredComfyNode = computed<ComfyNodeDefImpl | null>(() => {
@@ -240,16 +253,26 @@ const insertNode = (nodeDef: ComfyNodeDefImpl) => {
} }
const filteredRoot = ref<TreeNode | null>(null) const filteredRoot = ref<TreeNode | null>(null)
const filters: Ref<Array<SearchFilter & { filter: FilterAndValue<string> }>> =
ref([])
const handleSearch = (query: string) => { const handleSearch = (query: string) => {
if (query.length < 3) { if (query.length < 3 && !filters.value.length) {
filteredRoot.value = null filteredRoot.value = null
expandedKeys.value = {} expandedKeys.value = {}
return return
} }
const matchedNodes = nodeDefStore.nodeSearchService.searchNode(query, [], { const f = filters.value.map((f) => f.filter as FilterAndValue<string>)
limit: 64 const matchedNodes = nodeDefStore.nodeSearchService.searchNode(
}) query,
f,
{
limit: 64
},
{
matchWildcards: false
}
)
filteredRoot.value = buildNodeDefTree(matchedNodes) filteredRoot.value = buildNodeDefTree(matchedNodes)
expandNode(filteredRoot.value) expandNode(filteredRoot.value)
@@ -353,6 +376,26 @@ const updateCustomization = (icon: string, color: string) => {
) )
} }
} }
const onAddFilter = (filterAndValue: FilterAndValue) => {
filters.value.push({
filter: filterAndValue,
badge: filterAndValue[0].invokeSequence.toUpperCase(),
badgeClass: filterAndValue[0].invokeSequence + '-badge',
text: filterAndValue[1],
id: +new Date()
})
handleSearch(searchQuery.value)
}
const onRemoveFilter = (filterAndValue) => {
const index = filters.value.findIndex((f) => f === filterAndValue)
if (index !== -1) {
filters.value.splice(index, 1)
}
handleSearch(searchQuery.value)
}
</script> </script>
<style> <style>
@@ -362,6 +405,10 @@ const updateCustomization = (icon: string, color: string) => {
margin-left: var(--p-tree-node-gap); margin-left: var(--p-tree-node-gap);
flex-grow: 1; flex-grow: 1;
} }
.node-lib-filter-popup {
margin-left: -13px;
}
</style> </style>
<style scoped> <style scoped>

View File

@@ -5,6 +5,10 @@ import _ from 'lodash'
type SearchAuxScore = [number, number, number, number] type SearchAuxScore = [number, number, number, number]
interface ExtraSearchOptions {
matchWildcards?: boolean
}
export class FuseSearch<T> { export class FuseSearch<T> {
private fuse: Fuse<T> private fuse: Fuse<T>
private readonly keys: string[] private readonly keys: string[]
@@ -145,13 +149,19 @@ export abstract class NodeFilter<FilterOptionT = string> {
public abstract getNodeOptions(node: ComfyNodeDefImpl): FilterOptionT[] public abstract getNodeOptions(node: ComfyNodeDefImpl): FilterOptionT[]
public matches(node: ComfyNodeDefImpl, value: FilterOptionT): boolean { public matches(
if (value === '*') { node: ComfyNodeDefImpl,
value: FilterOptionT,
extraOptions?: ExtraSearchOptions
): boolean {
const matchWildcards = extraOptions?.matchWildcards !== false
if (matchWildcards && value === '*') {
return true return true
} }
const options = this.getNodeOptions(node) const options = this.getNodeOptions(node)
return ( return (
options.includes(value) || _.some(options, (option) => option === '*') options.includes(value) ||
(matchWildcards && _.some(options, (option) => option === '*'))
) )
} }
} }
@@ -242,14 +252,15 @@ export class NodeSearchService {
public searchNode( public searchNode(
query: string, query: string,
filters: FilterAndValue<string>[] = [], filters: FilterAndValue<string>[] = [],
options?: FuseSearchOptions options?: FuseSearchOptions,
extraOptions?: ExtraSearchOptions
): ComfyNodeDefImpl[] { ): ComfyNodeDefImpl[] {
const matchedNodes = this.nodeFuseSearch.search(query) const matchedNodes = this.nodeFuseSearch.search(query)
const results = matchedNodes.filter((node) => { const results = matchedNodes.filter((node) => {
return _.every(filters, (filterAndValue) => { return _.every(filters, (filterAndValue) => {
const [filter, value] = filterAndValue const [filter, value] = filterAndValue
return filter.matches(node, value) return filter.matches(node, value, extraOptions)
}) })
}) })