mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-26 17:30:07 +00:00
Organize searchbox files (#315)
This commit is contained in:
178
src/components/searchbox/NodeSearchBox.vue
Normal file
178
src/components/searchbox/NodeSearchBox.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<div class="comfy-vue-node-search-container">
|
||||
<div class="comfy-vue-node-preview-container">
|
||||
<NodePreview
|
||||
:nodeDef="hoveredSuggestion"
|
||||
:key="hoveredSuggestion?.name || ''"
|
||||
v-if="hoveredSuggestion"
|
||||
/>
|
||||
</div>
|
||||
<NodeSearchFilter @addFilter="onAddFilter" />
|
||||
<AutoCompletePlus
|
||||
:model-value="props.filters"
|
||||
class="comfy-vue-node-search-box"
|
||||
scrollHeight="40vh"
|
||||
:placeholder="placeholder"
|
||||
:input-id="inputId"
|
||||
append-to="self"
|
||||
:suggestions="suggestions"
|
||||
:min-length="0"
|
||||
@complete="search($event.query)"
|
||||
@option-select="emit('addNode', $event.value)"
|
||||
@focused-option-changed="setHoverSuggestion($event)"
|
||||
complete-on-focus
|
||||
auto-option-focus
|
||||
force-selection
|
||||
multiple
|
||||
>
|
||||
<template v-slot:option="{ option }">
|
||||
<div class="option-container">
|
||||
<div class="option-display-name">
|
||||
{{ option.display_name }}
|
||||
<NodeSourceChip
|
||||
v-if="option.python_module !== undefined"
|
||||
:python_module="option.python_module"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="option.description" class="option-description">
|
||||
{{ option.description }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- FilterAndValue -->
|
||||
<template v-slot:chip="{ value }">
|
||||
<Chip removable @remove="onRemoveFilter($event, value)">
|
||||
<Badge size="small" :class="value[0].invokeSequence + '-badge'">
|
||||
{{ value[0].invokeSequence.toUpperCase() }}
|
||||
</Badge>
|
||||
{{ value[1] }}
|
||||
</Chip>
|
||||
</template>
|
||||
</AutoCompletePlus>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import AutoCompletePlus from '@/components/primevueOverride/AutoCompletePlus.vue'
|
||||
import Chip from 'primevue/chip'
|
||||
import Badge from 'primevue/badge'
|
||||
import NodeSearchFilter from '@/components/searchbox/NodeSearchFilter.vue'
|
||||
import NodeSourceChip from '@/components/node/NodeSourceChip.vue'
|
||||
import { type FilterAndValue } from '@/services/nodeSearchService'
|
||||
import NodePreview from '@/components/node/NodePreview.vue'
|
||||
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
const props = defineProps({
|
||||
filters: {
|
||||
type: Array<FilterAndValue>
|
||||
},
|
||||
searchLimit: {
|
||||
type: Number,
|
||||
default: 64
|
||||
}
|
||||
})
|
||||
|
||||
const inputId = `comfy-vue-node-search-box-input-${Math.random()}`
|
||||
const suggestions = ref<ComfyNodeDefImpl[]>([])
|
||||
const hoveredSuggestion = ref<ComfyNodeDefImpl | null>(null)
|
||||
const placeholder = computed(() => {
|
||||
return props.filters.length === 0 ? 'Search for nodes' : ''
|
||||
})
|
||||
|
||||
const search = (query: string) => {
|
||||
suggestions.value = useNodeDefStore().nodeSearchService.searchNode(
|
||||
query,
|
||||
props.filters,
|
||||
{
|
||||
limit: props.searchLimit
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const emit = defineEmits(['addFilter', 'removeFilter', 'addNode'])
|
||||
|
||||
const reFocusInput = () => {
|
||||
const inputElement = document.getElementById(inputId) as HTMLInputElement
|
||||
if (inputElement) {
|
||||
inputElement.blur()
|
||||
inputElement.focus()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(reFocusInput)
|
||||
const onAddFilter = (filterAndValue: FilterAndValue) => {
|
||||
emit('addFilter', filterAndValue)
|
||||
reFocusInput()
|
||||
}
|
||||
const onRemoveFilter = (event: Event, filterAndValue: FilterAndValue) => {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
emit('removeFilter', filterAndValue)
|
||||
reFocusInput()
|
||||
}
|
||||
const setHoverSuggestion = (index: number) => {
|
||||
if (index === -1) {
|
||||
hoveredSuggestion.value = null
|
||||
return
|
||||
}
|
||||
const value = suggestions.value[index]
|
||||
hoveredSuggestion.value = value
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.comfy-vue-node-search-container {
|
||||
@apply flex justify-center items-center w-full min-w-96;
|
||||
}
|
||||
|
||||
.comfy-vue-node-search-container * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.comfy-vue-node-preview-container {
|
||||
position: absolute;
|
||||
left: -350px;
|
||||
top: 50px;
|
||||
}
|
||||
|
||||
.comfy-vue-node-search-box {
|
||||
@apply z-10 flex-grow;
|
||||
}
|
||||
|
||||
.option-container {
|
||||
@apply flex flex-col px-4 py-2 cursor-pointer overflow-hidden w-full;
|
||||
}
|
||||
|
||||
.option-container:hover .option-description {
|
||||
@apply overflow-visible;
|
||||
/* Allows text to wrap */
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.option-display-name {
|
||||
@apply font-semibold;
|
||||
}
|
||||
|
||||
.option-description {
|
||||
@apply text-sm text-gray-400 overflow-hidden text-ellipsis;
|
||||
/* Keeps the text on a single line by default */
|
||||
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;
|
||||
}
|
||||
</style>
|
||||
145
src/components/searchbox/NodeSearchBoxPopover.vue
Normal file
145
src/components/searchbox/NodeSearchBoxPopover.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<div>
|
||||
<Dialog
|
||||
v-model:visible="visible"
|
||||
pt:root="invisible-dialog-root"
|
||||
pt:mask="node-search-box-dialog-mask"
|
||||
modal
|
||||
:dismissable-mask="dismissable"
|
||||
@hide="clearFilters"
|
||||
>
|
||||
<template #container>
|
||||
<NodeSearchBox
|
||||
:filters="nodeFilters"
|
||||
@add-filter="addFilter"
|
||||
@remove-filter="removeFilter"
|
||||
@add-node="addNode"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { app } from '@/scripts/app'
|
||||
import { onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import NodeSearchBox from './NodeSearchBox.vue'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import { LiteGraphCanvasEvent, ConnectingLink } from '@comfyorg/litegraph'
|
||||
import { FilterAndValue } from '@/services/nodeSearchService'
|
||||
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { ConnectingLinkImpl } from '@/types/litegraphTypes'
|
||||
|
||||
interface LiteGraphPointerEvent extends Event {
|
||||
canvasX: number
|
||||
canvasY: number
|
||||
}
|
||||
|
||||
const visible = ref(false)
|
||||
const dismissable = ref(true)
|
||||
const triggerEvent = ref<LiteGraphCanvasEvent | null>(null)
|
||||
const getNewNodeLocation = (): [number, number] => {
|
||||
if (triggerEvent.value === null) {
|
||||
return [100, 100]
|
||||
}
|
||||
|
||||
const originalEvent = triggerEvent.value.detail
|
||||
.originalEvent as LiteGraphPointerEvent
|
||||
return [originalEvent.canvasX, originalEvent.canvasY]
|
||||
}
|
||||
const nodeFilters = reactive([])
|
||||
const addFilter = (filter: FilterAndValue) => {
|
||||
nodeFilters.push(filter)
|
||||
}
|
||||
const removeFilter = (filter: FilterAndValue) => {
|
||||
const index = nodeFilters.findIndex((f) => f === filter)
|
||||
if (index !== -1) {
|
||||
nodeFilters.splice(index, 1)
|
||||
}
|
||||
}
|
||||
const clearFilters = () => {
|
||||
nodeFilters.splice(0, nodeFilters.length)
|
||||
}
|
||||
const closeDialog = () => {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
const addNode = (nodeDef: ComfyNodeDefImpl) => {
|
||||
const node = app.addNodeOnGraph(nodeDef, { pos: getNewNodeLocation() })
|
||||
|
||||
const eventDetail = triggerEvent.value.detail
|
||||
if (eventDetail.subType === 'empty-release') {
|
||||
eventDetail.linkReleaseContext.links.forEach((link: ConnectingLink) => {
|
||||
ConnectingLinkImpl.createFromPlainObject(link).connectTo(node)
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: This is not robust timing-wise.
|
||||
// PrimeVue complains about the dialog being closed before the event selecting
|
||||
// item is fully processed.
|
||||
window.setTimeout(() => {
|
||||
closeDialog()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const canvasEventHandler = (e: LiteGraphCanvasEvent) => {
|
||||
const shiftPressed = (e.detail.originalEvent as KeyboardEvent).shiftKey
|
||||
// Ignore empty releases unless shift is pressed
|
||||
// Empty release without shift is trigger right click menu
|
||||
if (e.detail.subType === 'empty-release' && !shiftPressed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (e.detail.subType === 'empty-release') {
|
||||
const context = e.detail.linkReleaseContext
|
||||
if (context.links.length === 0) {
|
||||
console.warn('Empty release with no links! This should never happen')
|
||||
return
|
||||
}
|
||||
const firstLink = ConnectingLinkImpl.createFromPlainObject(context.links[0])
|
||||
const filter = useNodeDefStore().nodeSearchService.getFilterById(
|
||||
firstLink.releaseSlotType
|
||||
)
|
||||
const dataType = firstLink.type
|
||||
addFilter([filter, dataType])
|
||||
}
|
||||
triggerEvent.value = e
|
||||
visible.value = true
|
||||
// Prevent the dialog from being dismissed immediately
|
||||
dismissable.value = false
|
||||
setTimeout(() => {
|
||||
dismissable.value = true
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const handleEscapeKeyPress = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeDialog()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('litegraph:canvas', canvasEventHandler)
|
||||
document.addEventListener('keydown', handleEscapeKeyPress)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('litegraph:canvas', canvasEventHandler)
|
||||
document.removeEventListener('keydown', handleEscapeKeyPress)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.invisible-dialog-root {
|
||||
width: 30%;
|
||||
min-width: 24rem;
|
||||
max-width: 48rem;
|
||||
border: 0 !important;
|
||||
background-color: transparent !important;
|
||||
margin-top: 25vh;
|
||||
}
|
||||
|
||||
.node-search-box-dialog-mask {
|
||||
align-items: flex-start !important;
|
||||
}
|
||||
</style>
|
||||
97
src/components/searchbox/NodeSearchFilter.vue
Normal file
97
src/components/searchbox/NodeSearchFilter.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<Button
|
||||
icon="pi pi-filter"
|
||||
severity="secondary"
|
||||
class="_filter-button"
|
||||
@click="showModal"
|
||||
/>
|
||||
<Dialog v-model:visible="visible" class="_dialog">
|
||||
<template #header>
|
||||
<h3>Add node filter condition</h3>
|
||||
</template>
|
||||
<div class="_dialog-body">
|
||||
<SelectButton
|
||||
v-model="selectedFilter"
|
||||
:options="filters"
|
||||
:allowEmpty="false"
|
||||
optionLabel="name"
|
||||
@change="updateSelectedFilterValue"
|
||||
/>
|
||||
<AutoComplete
|
||||
v-model="selectedFilterValue"
|
||||
:suggestions="filterValues"
|
||||
: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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { NodeFilter, type FilterAndValue } from '@/services/nodeSearchService'
|
||||
import Button from 'primevue/button'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import AutoComplete from 'primevue/autocomplete'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
const visible = ref<boolean>(false)
|
||||
const filters = ref<NodeFilter[]>([])
|
||||
const selectedFilter = ref<NodeFilter>()
|
||||
const filterValues = ref<string[]>([])
|
||||
const selectedFilterValue = ref<string>('')
|
||||
|
||||
onMounted(() => {
|
||||
const nodeSearchService = useNodeDefStore().nodeSearchService
|
||||
filters.value = nodeSearchService.nodeFilters
|
||||
selectedFilter.value = nodeSearchService.nodeFilters[0]
|
||||
})
|
||||
|
||||
const emit = defineEmits(['addFilter'])
|
||||
|
||||
const updateSelectedFilterValue = () => {
|
||||
updateFilterValues('')
|
||||
if (filterValues.value.includes(selectedFilterValue.value)) {
|
||||
return
|
||||
}
|
||||
selectedFilterValue.value = filterValues.value[0]
|
||||
}
|
||||
|
||||
const updateFilterValues = (query: string) => {
|
||||
filterValues.value = selectedFilter.value.fuseSearch.search(query)
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
visible.value = false
|
||||
emit('addFilter', [
|
||||
selectedFilter.value,
|
||||
selectedFilterValue.value
|
||||
] as FilterAndValue)
|
||||
}
|
||||
|
||||
const showModal = () => {
|
||||
updateSelectedFilterValue()
|
||||
visible.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
._filter-button {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
._dialog {
|
||||
@apply min-w-96;
|
||||
}
|
||||
|
||||
._dialog-body {
|
||||
@apply flex flex-col space-y-2;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user