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>
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
@@ -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
17
package.json
@@ -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
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
66
src/App.vue
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
157
src/components/NodeSearchBox.vue
Normal 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>
|
||||
115
src/components/NodeSearchBoxPopover.vue
Normal 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>
|
||||
101
src/components/NodeSearchFilter.vue
Normal 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>
|
||||
29
src/components/NodeSourceChip.vue
Normal 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>
|
||||
@@ -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
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
168
src/services/nodeSearchService.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -219,6 +219,7 @@ const zComfyNodeDef = z.object({
|
||||
description: z.string(),
|
||||
category: z.string(),
|
||||
output_node: z.boolean(),
|
||||
python_module: z.string(),
|
||||
});
|
||||
|
||||
// `/object_info`
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,12 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
},
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"src/**/*.vue",
|
||||
"src/types/**/*.d.ts",
|
||||
"tests-ui/**/*"
|
||||
],
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
});
|
||||