New searchbox with fuzzy search (WIP) (#83)

* Disable default searchbox

Add headlessui and vue

Add vite's vue plugin

Vue app helloworld

Format vue

Add vue shim

minimal working searchbox

Add primevue dark mode

Use primevue

nit

Add fuse fuzzy search

Add tailwindcss / center searchbox

Fix style

Add node source chip

desc text wrapping

Add placeholder

inputbox filter support wip

Revert some filter designs

Add filter modal

Drop down show all nodes

Change modal font

Add filtered search

nit

Complete on focus

Auto fill filterOption

Fix dropdown

Fix z-index

Fix search bug

Properly remove chip

Adjust node source detection

Resolve merge conflict

nit

* Refactor

* Use badge to display filter type

* nit

* Trigger on canvas event

* nit

* Auto add data type filter when link released

* nit

* Auto focus when shown

* Focus on add/remvoe filter

* close dialog when escape pressed

* Add node at fixed location

* nit

* Update litegraph

* nit

* Change theme

* Increase search limit

* Add node on event location

* Clear filter when dialog closed

* Enable/Disable new search bx

* Improve app loading

* Fix copy node

* Update test expectations

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Chenlei Hu
2024-07-10 19:46:35 -04:00
committed by GitHub
parent 1cb707ea61
commit a28ac0c0fa
57 changed files with 1802 additions and 76 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -12,19 +12,15 @@
font-family: 'Roboto Mono', 'Noto Color Emoji';
}
</style> -->
<script type="module" src="/src/main.ts"></script>
<script type="module">
console.log("ComfyUI Front-end version:", __COMFYUI_FRONTEND_VERSION__);
import { app } from "./src/scripts/app";
(async () => {
await app.setup();
window.app = app;
window.graph = app.graph;
})();
</script>
<link rel="stylesheet" type="text/css" href="/user.css" />
<link rel="stylesheet" type="text/css" href="/materialdesignicons.min.css" />
</head>
<body class="litegraph">
<div id="vue-app"></div>
<div id="comfy-user-selection" class="comfy-user-selection" style="display: none;">
<main class="comfy-user-selection-inner">
<h1>ComfyUI</h1>

1068
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,7 @@
"deploy": "node scripts/deploy.js",
"zipdist": "node scripts/zipdist.js",
"typecheck": "tsc --noEmit",
"format": "prettier --write 'src/**/*.{js,ts,tsx}'",
"format": "prettier --write 'src/**/*.{js,ts,tsx,vue}'",
"test": "npm run build && jest",
"test:generate:examples": "npx tsx tests-ui/extractExamples",
"test:generate": "npx tsx tests-ui/setup",
@@ -22,7 +22,9 @@
"@babel/preset-env": "^7.22.20",
"@playwright/test": "^1.44.1",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.6",
"@types/node": "^20.14.8",
"autoprefixer": "^10.4.19",
"babel-plugin-transform-import-meta": "^2.2.1",
"babel-plugin-transform-rename-import": "^2.3.0",
"chalk": "^5.3.0",
@@ -32,7 +34,9 @@
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"lint-staged": "^15.2.7",
"postcss": "^8.4.39",
"prettier": "^3.3.2",
"tailwindcss": "^3.4.4",
"ts-jest": "^29.1.4",
"ts-node": "^10.9.2",
"tsx": "^4.15.6",
@@ -42,13 +46,20 @@
"zip-dir": "^2.0.0"
},
"dependencies": {
"@comfyorg/litegraph": "^0.7.23",
"@comfyorg/litegraph": "^0.7.25",
"@primevue/themes": "^4.0.0-rc.2",
"@vitejs/plugin-vue": "^5.0.5",
"dotenv": "^16.4.5",
"fuse.js": "^7.0.0",
"lodash": "^4.17.21",
"primeicons": "^7.0.0",
"primevue": "^4.0.0-rc.2",
"vue": "^3.4.31",
"zod": "^3.23.8",
"zod-validation-error": "^3.3.0"
},
"lint-staged": {
"src/**/*.{js,ts,tsx}": [
"src/**/*.{js,ts,tsx,vue}": [
"prettier --write",
"git add"
]

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

66
src/App.vue Normal file
View File

@@ -0,0 +1,66 @@
<template>
<ProgressSpinner v-if="isLoading" class="spinner"></ProgressSpinner>
<div v-else>
<NodeSearchboxPopover v-if="nodeSearchEnabled" />
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, provide, ref } from "vue";
import NodeSearchboxPopover from "@/components/NodeSearchBoxPopover.vue";
import ProgressSpinner from "primevue/progressspinner";
import { api } from "@/scripts/api";
import { NodeSearchService } from "./services/nodeSearchService";
import { ColorPaletteLoadedEvent } from "./types/colorPalette";
import { LiteGraphNodeSearchSettingEvent } from "./scripts/ui";
const isLoading = ref(true);
const nodeSearchEnabled = ref(false);
const nodeSearchService = ref<NodeSearchService>();
const updateTheme = (e: ColorPaletteLoadedEvent) => {
const DARK_THEME_CLASS = "dark-theme";
const isDarkTheme = e.detail.id !== "light";
if (isDarkTheme) {
document.body.classList.add(DARK_THEME_CLASS);
} else {
document.body.classList.remove(DARK_THEME_CLASS);
}
};
const updateNodeSearchSetting = (e: LiteGraphNodeSearchSettingEvent) => {
nodeSearchEnabled.value = !e.detail;
};
onMounted(async () => {
const nodeDefs = Object.values(await api.getNodeDefs());
nodeSearchService.value = new NodeSearchService(nodeDefs);
isLoading.value = false;
document.addEventListener("comfy:setting:color-palette-loaded", updateTheme);
document.addEventListener(
"comfy:setting:litegraph-node-search",
updateNodeSearchSetting
);
});
onUnmounted(() => {
document.removeEventListener(
"comfy:setting:color-palette-loaded",
updateTheme
);
document.removeEventListener(
"comfy:setting:litegraph-node-search",
updateNodeSearchSetting
);
});
provide("nodeSearchService", nodeSearchService);
</script>
<style scoped>
.spinner {
@apply absolute inset-0 flex justify-center items-center h-screen;
}
</style>

View File

@@ -36,14 +36,15 @@ body {
height: 100vh;
margin: 0;
overflow: hidden;
background-color: var(--bg-color);
color: var(--fg-color);
grid-template-columns: auto 1fr auto;
grid-template-rows: auto auto 1fr auto;
background-color: var(--bg-color);
color: var(--fg-color);
min-height: -webkit-fill-available;
max-height: -webkit-fill-available;
min-width: -webkit-fill-available;
max-width: -webkit-fill-available;
font-family: Arial, sans-serif;
}
.comfyui-body-top {
@@ -149,7 +150,7 @@ body {
right: 0;
text-align: center;
z-index: 999;
width: 170px;
width: 190px;
display: flex;
flex-direction: column;
align-items: center;
@@ -634,3 +635,17 @@ dialog::backdrop {
audio.comfy-audio.empty-audio-widget {
display: none;
}
#vue-app {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
/* Set auto complete panel's width as it is not accessible within vue-root */
.p-autocomplete-overlay {
max-width: 25vw;
}

View File

@@ -0,0 +1,157 @@
<template>
<div class="comfy-vue-node-search-container">
<NodeSearchFilter @addFilter="onAddFilter" />
<AutoComplete
:model-value="props.filters"
class="comfy-vue-node-search-box"
scrollHeight="28rem"
:placeholder="placeholder"
:input-id="inputId"
append-to="self"
:suggestions="suggestions"
:min-length="0"
@complete="search($event.query)"
@option-select="emit('addNode', $event.value)"
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>
</AutoComplete>
</div>
</template>
<script setup lang="ts">
import { computed, inject, onMounted, Ref, ref } from "vue";
import AutoComplete from "primevue/autocomplete";
import Chip from "primevue/chip";
import Badge from "primevue/badge";
import NodeSearchFilter from "@/components/NodeSearchFilter.vue";
import NodeSourceChip from "@/components/NodeSourceChip.vue";
import { ComfyNodeDef } from "@/types/apiTypes";
import {
NodeSearchService,
type FilterAndValue,
} from "@/services/nodeSearchService";
const props = defineProps({
filters: {
type: Array<FilterAndValue>,
},
searchLimit: {
type: Number,
default: 64,
},
});
const nodeSearchService = (
inject("nodeSearchService") as Ref<NodeSearchService>
).value;
const inputId = `comfy-vue-node-search-box-input-${Math.random()}`;
const suggestions = ref<ComfyNodeDef[]>([]);
const placeholder = computed(() => {
return props.filters.length === 0 ? "Search for nodes" : "";
});
const search = (query: string) => {
suggestions.value = 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();
};
</script>
<style scoped>
.comfy-vue-node-search-container {
@apply flex justify-center items-center;
}
.comfy-vue-node-search-container * {
pointer-events: auto;
}
.comfy-vue-node-search-box {
@apply min-w-96 w-full z-10;
}
.option-container {
@apply flex flex-col px-4 py-2 cursor-pointer overflow-hidden;
}
.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>

View File

@@ -0,0 +1,115 @@
<template>
<div>
<Dialog v-model:visible="visible" pt:root:class="invisible-dialog-root">
<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 { inject, onMounted, onUnmounted, reactive, Ref, ref } from "vue";
import NodeSearchBox from "./NodeSearchBox.vue";
import Dialog from "primevue/dialog";
import { LiteGraph, LiteGraphCanvasEvent } from "@comfyorg/litegraph";
import {
FilterAndValue,
NodeSearchService,
} from "@/services/nodeSearchService";
import { ComfyNodeDef } from "@/types/apiTypes";
interface LiteGraphPointerEvent extends Event {
canvasX: number;
canvasY: number;
}
const visible = ref(false);
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 = () => {
clearFilters();
visible.value = false;
};
const addNode = (nodeDef: ComfyNodeDef) => {
closeDialog();
const node = LiteGraph.createNode(nodeDef.name, nodeDef.display_name, {});
if (node) {
node.pos = getNewNodeLocation();
app.graph.add(node);
}
};
const nodeSearchService = (
inject("nodeSearchService") as Ref<NodeSearchService>
).value;
const canvasEventHandler = (e: LiteGraphCanvasEvent) => {
const shiftPressed = (e.detail.originalEvent as KeyboardEvent).shiftKey;
if (e.detail.subType === "empty-release" && shiftPressed) {
const destIsInput = e.detail.linkReleaseContext.node_from !== undefined;
const filter = destIsInput
? nodeSearchService.getFilterById("input")
: nodeSearchService.getFilterById("output");
const value = destIsInput
? e.detail.linkReleaseContext.type_filter_in
: e.detail.linkReleaseContext.type_filter_out;
addFilter([filter, value]);
}
triggerEvent.value = e;
visible.value = true;
};
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;
}
</style>

View File

@@ -0,0 +1,101 @@
<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,
NodeSearchService,
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 { inject, ref, onMounted } from "vue";
const visible = ref<boolean>(false);
const nodeSearchService: NodeSearchService = inject("nodeSearchService").value;
const filters = ref<NodeFilter[]>([]);
const selectedFilter = ref<NodeFilter>();
const filterValues = ref<string[]>([]);
const selectedFilterValue = ref<string>("");
onMounted(() => {
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>

View File

@@ -0,0 +1,29 @@
<template>
<Chip :class="nodeSource.className">
{{ nodeSource.displayText }}
</Chip>
</template>
<script setup lang="ts">
import { getNodeSource } from "@/types/nodeSource";
import Chip from "primevue/chip";
import { computed } from "vue";
const props = defineProps({
python_module: {
type: String,
required: true,
},
});
const nodeSource = computed(() => getNodeSource(props.python_module));
</script>
<style scoped>
.comfy-core,
.comfy-custom-nodes,
.comfy-unknown {
font-size: small;
font-weight: lighter;
}
</style>

View File

@@ -1,6 +1,6 @@
import { app } from "../../scripts/app";
import { $el } from "../../scripts/ui";
import type { ColorPalettes } from "@/types/colorPalette";
import type { ColorPalettes, Palette } from "@/types/colorPalette";
import { LGraphCanvas, LiteGraph } from "@comfyorg/litegraph";
// Manage color palettes
@@ -626,7 +626,7 @@ app.registerExtension({
await loadColorPalette(getColorPalette());
};
const loadColorPalette = async (colorPalette) => {
const loadColorPalette = async (colorPalette: Palette) => {
colorPalette = await completeColorPalette(colorPalette);
if (colorPalette.colors) {
// Sets the colors of node slots and links
@@ -671,6 +671,12 @@ app.registerExtension({
}
app.canvas.draw(true, true);
}
document.dispatchEvent(
new CustomEvent("comfy:setting:color-palette-loaded", {
detail: colorPalette,
})
);
};
const getColorPalette = (colorPaletteId?) => {

28
src/main.ts Normal file
View File

@@ -0,0 +1,28 @@
import { createApp } from "vue";
import PrimeVue from "primevue/config";
import Aura from "@primevue/themes/aura";
import "primeicons/primeicons.css";
import App from "./App.vue";
import { app as comfyApp } from "@/scripts/app";
const app = createApp(App);
app
.use(PrimeVue, {
theme: {
preset: Aura,
options: {
prefix: "p",
cssLayer: false,
// This is a workaround for the issue with the dark mode selector
// https://github.com/primefaces/primevue/issues/5515
darkModeSelector: ".dark-theme, :root:has(.dark-theme)",
},
},
})
.mount("#vue-app");
comfyApp.setup().then(() => {
window["app"] = comfyApp;
window["graph"] = comfyApp.graph;
});

View File

@@ -1145,7 +1145,7 @@ export class ComfyApp {
// copy nodes and clear clipboard
if (
e.target instanceof Element &&
e.target.className === "litegraph" &&
e.target.classList.contains("litegraph") &&
this.canvas.selected_nodes
) {
this.canvas.copyToClipboard();
@@ -1858,6 +1858,7 @@ export class ComfyApp {
this.#addAfterConfigureHandler();
this.canvas = new LGraphCanvas(canvasEl, this.graph);
this.ui.settings.refreshSetting("Comfy.NodeSearchBoxImpl");
this.ctx = canvasEl.getContext("2d");
LiteGraph.release_link_on_empty_shows_menu = true;

View File

@@ -7,6 +7,8 @@ import { TaskItem } from "@/types/apiTypes";
export const ComfyDialog = _ComfyDialog;
export type LiteGraphNodeSearchSettingEvent = CustomEvent<boolean>;
type Position2D = {
x: number;
y: number;
@@ -420,6 +422,26 @@ export class ComfyUI {
defaultValue: 0,
});
this.settings.addSetting({
id: "Comfy.NodeSearchBoxImpl",
name: "Node Search box implementation",
type: "combo",
options: ["default", "litegraph (legacy)"],
defaultValue: "default",
onChange: (value?: string) => {
if (value === undefined) return;
if (!app.canvas) return;
const useLitegraphSearch = value === "litegraph (legacy)";
app.canvas.allow_searchbox = useLitegraphSearch;
document.dispatchEvent(
new CustomEvent("comfy:setting:litegraph-node-search", {
detail: useLitegraphSearch,
})
);
},
});
const fileInput = $el("input", {
id: "comfy-file-input",
type: "file",

View File

@@ -150,6 +150,12 @@ export class ComfySettingsDialog extends ComfyDialog<HTMLDialogElement> {
});
}
refreshSetting(id: string) {
const value = this.getSettingValue(id);
this.settingsLookup[id].onChange?.(value);
this.#dispatchChange(id, value);
}
addSetting(params: SettingParams) {
const {
id,

View File

@@ -0,0 +1,168 @@
import { ComfyNodeDef } from "@/types/apiTypes";
import { getNodeSource } from "@/types/nodeSource";
import Fuse, { IFuseOptions, FuseSearchOptions } from "fuse.js";
import _ from "lodash";
export class FuseSearch<T> {
private fuse: Fuse<T>;
public readonly data: T[];
constructor(
data: T[],
options?: IFuseOptions<T>,
createIndex: boolean = true
) {
this.data = data;
const index =
createIndex && options?.keys
? Fuse.createIndex(options.keys, data)
: undefined;
this.fuse = new Fuse(data, options, index);
}
public search(query: string, options?: FuseSearchOptions): T[] {
if (!query || query === "") {
return [...this.data];
}
return this.fuse.search(query, options).map((result) => result.item);
}
}
export type FilterAndValue<T = string> = [NodeFilter<T>, T];
export abstract class NodeFilter<FilterOptionT = string> {
public abstract readonly id: string;
public abstract readonly name: string;
public abstract readonly invokeSequence: string;
public abstract readonly longInvokeSequence: string;
public readonly fuseSearch: FuseSearch<FilterOptionT>;
constructor(nodeDefs: ComfyNodeDef[], options?: IFuseOptions<FilterOptionT>) {
this.fuseSearch = new FuseSearch(this.getAllNodeOptions(nodeDefs), options);
}
private getAllNodeOptions(nodeDefs: ComfyNodeDef[]): FilterOptionT[] {
return [
...new Set(
nodeDefs.reduce((acc, nodeDef) => {
return [...acc, ...this.getNodeOptions(nodeDef)];
}, [])
),
];
}
public abstract getNodeOptions(node: ComfyNodeDef): FilterOptionT[];
public matches(node: ComfyNodeDef, value: FilterOptionT): boolean {
return this.getNodeOptions(node).includes(value);
}
}
export class InputTypeFilter extends NodeFilter<string> {
public readonly id: string = "input";
public readonly name = "Input Type";
public readonly invokeSequence = "i";
public readonly longInvokeSequence = "input";
public override getNodeOptions(node: ComfyNodeDef): string[] {
const inputs = {
...(node.input.required || {}),
...(node.input.optional || {}),
};
return Object.values(inputs).map((input) => {
const [inputType, inputSpec] = input;
return typeof inputType === "string" ? inputType : "COMBO";
});
}
}
export class OutputTypeFilter extends NodeFilter<string> {
public readonly id: string = "output";
public readonly name = "Output Type";
public readonly invokeSequence = "o";
public readonly longInvokeSequence = "output";
public override getNodeOptions(node: ComfyNodeDef): string[] {
const outputs = node.output;
return outputs.map((output) => {
return typeof output === "string" ? output : output[0];
});
}
}
export class NodeSourceFilter extends NodeFilter<string> {
public readonly id: string = "source";
public readonly name = "Source";
public readonly invokeSequence = "s";
public readonly longInvokeSequence = "source";
public override getNodeOptions(node: ComfyNodeDef): string[] {
return [getNodeSource(node.python_module).displayText];
}
}
export class NodeCategoryFilter extends NodeFilter<string> {
public readonly id: string = "category";
public readonly name = "Category";
public readonly invokeSequence = "c";
public readonly longInvokeSequence = "category";
public override getNodeOptions(node: ComfyNodeDef): string[] {
return [node.category];
}
}
export class NodeSearchService {
public readonly nodeFuseSearch: FuseSearch<ComfyNodeDef>;
public readonly nodeFilters: NodeFilter<string>[];
constructor(data: ComfyNodeDef[]) {
this.nodeFuseSearch = new FuseSearch(data, {
keys: ["name", "display_name", "description"],
includeScore: true,
threshold: 0.6,
shouldSort: true,
});
const filterSearchOptions = {
includeScore: true,
threshold: 0.6,
shouldSort: true,
};
this.nodeFilters = [
new InputTypeFilter(data, filterSearchOptions),
new OutputTypeFilter(data, filterSearchOptions),
new NodeCategoryFilter(data, filterSearchOptions),
];
if (data[0].python_module !== undefined) {
this.nodeFilters.push(new NodeSourceFilter(data, filterSearchOptions));
}
}
public endsWithFilterStartSequence(query: string): boolean {
return query.endsWith(":");
}
public searchNode(
query: string,
filters: FilterAndValue<string>[] = [],
options?: FuseSearchOptions
): ComfyNodeDef[] {
const matchedNodes = this.nodeFuseSearch.search(query);
const results = matchedNodes.filter((node) => {
return _.every(filters, (filterAndValue) => {
const [filter, value] = filterAndValue;
return filter.matches(node, value);
});
});
return options?.limit ? results.slice(0, options.limit) : results;
}
public getFilterById(id: string): NodeFilter<string> | undefined {
return this.nodeFilters.find((filter) => filter.id === id);
}
}

View File

@@ -219,6 +219,7 @@ const zComfyNodeDef = z.object({
description: z.string(),
category: z.string(),
output_node: z.boolean(),
python_module: z.string(),
});
// `/object_info`

View File

@@ -93,3 +93,4 @@ const colorPalettesSchema = z.record(paletteSchema);
export type Colors = z.infer<typeof colorsSchema>;
export type Palette = z.infer<typeof paletteSchema>;
export type ColorPalettes = z.infer<typeof colorPalettesSchema>;
export type ColorPaletteLoadedEvent = CustomEvent<Palette>;

25
src/types/nodeSource.ts Normal file
View File

@@ -0,0 +1,25 @@
export type NodeSourceType = "core" | "custom_nodes";
export type NodeSource = {
type: NodeSourceType;
className: string;
displayText: string;
};
export const getNodeSource = (python_module: string): NodeSource => {
const modules = python_module.split(".");
if (["nodes", "comfy_extras"].includes(modules[0])) {
return {
type: "core",
className: "comfy-core",
displayText: "Comfy Core",
};
} else if (modules[0] === "custom_nodes") {
return {
type: "custom_nodes",
className: "comfy-custom-nodes",
displayText: modules[1],
};
} else {
throw new Error(`Unknown node source: ${python_module}`);
}
};

6
src/types/vue-shim.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
// vue-shim.d.ts
declare module "*.vue" {
import { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}

12
tailwind.config.js Normal file
View File

@@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

View File

@@ -28,6 +28,7 @@
},
"include": [
"src/**/*",
"src/**/*.vue",
"src/types/**/*.d.ts",
"tests-ui/**/*"
],

View File

@@ -1,4 +1,5 @@
import { defineConfig, Plugin } from 'vite';
import vue from '@vitejs/plugin-vue';
import path from 'path';
import dotenv from "dotenv";
dotenv.config();
@@ -98,6 +99,7 @@ export default defineConfig({
}
},
plugins: [
vue(),
comfyAPIPlugin(),
],
build: {
@@ -112,4 +114,9 @@ export default defineConfig({
define: {
'__COMFYUI_FRONTEND_VERSION__': JSON.stringify(process.env.npm_package_version),
},
resolve: {
alias: {
'@': '/src'
}
}
});