mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-21 15:24:09 +00:00
Node preview on focus (#116)
* Basic preview * Adjust position * Fix node display * nit * handle combo default value * nit * Custom AutoComplete
This commit is contained in:
260
src/components/NodePreview.vue
Normal file
260
src/components/NodePreview.vue
Normal file
@@ -0,0 +1,260 @@
|
||||
<!-- Reference:
|
||||
https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c683087a3e168db/app/js/functions/sb_fn.js#L149
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div id="previewDiv">
|
||||
<div class="sb_table">
|
||||
<div class="node_header">
|
||||
<div class="sb_dot headdot"></div>
|
||||
{{ nodeDef.display_name }}
|
||||
</div>
|
||||
<div class="sb_preview_badge">PREVIEW</div>
|
||||
|
||||
<!-- Node slot I/O -->
|
||||
<div
|
||||
v-for="[slotInput, slotOutput] in _.zip(slotInputDefs, allOutputDefs)"
|
||||
class="sb_row slot_row"
|
||||
>
|
||||
<div class="sb_col">
|
||||
<div v-if="slotInput" :class="['sb_dot', slotInput.type]"></div>
|
||||
</div>
|
||||
<div class="sb_col">{{ slotInput ? slotInput.name : "" }}</div>
|
||||
<div class="sb_col middle-column"></div>
|
||||
<div class="sb_col sb_inherit">
|
||||
{{ slotOutput ? slotOutput.name : "" }}
|
||||
</div>
|
||||
<div class="sb_col">
|
||||
<div v-if="slotOutput" :class="['sb_dot', slotOutput.type]"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Node widget inputs -->
|
||||
<div v-for="widgetInput in widgetInputDefs" class="sb_row long_field">
|
||||
<div class="sb_col sb_arrow">◀</div>
|
||||
<div class="sb_col">{{ widgetInput.name }}</div>
|
||||
<div class="sb_col middle-column"></div>
|
||||
<div class="sb_col sb_inherit">{{ widgetInput.defaultValue }}</div>
|
||||
<div class="sb_col sb_arrow">▶</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sb_description" v-if="nodeDef.description">
|
||||
{{ nodeDef.description }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { app } from "@/scripts/app";
|
||||
import { type ComfyNodeDef } from "@/types/apiTypes";
|
||||
import _ from "lodash";
|
||||
import { PropType } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
nodeDef: {
|
||||
type: Object as PropType<ComfyNodeDef>,
|
||||
required: true,
|
||||
},
|
||||
// Make sure vue properly re-render the component when the nodeDef changes
|
||||
key: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const nodeDef = props.nodeDef as ComfyNodeDef;
|
||||
|
||||
// --------------------------------------------------
|
||||
// TODO: Move out to separate file
|
||||
interface IComfyNodeInputDef {
|
||||
name: string;
|
||||
type: string;
|
||||
widgetType: string | null;
|
||||
defaultValue: any;
|
||||
}
|
||||
|
||||
interface IComfyNodeOutputDef {
|
||||
name: string | null;
|
||||
type: string;
|
||||
isList: boolean;
|
||||
}
|
||||
|
||||
const allInputs = Object.assign(
|
||||
{},
|
||||
nodeDef.input.required || {},
|
||||
nodeDef.input.optional || {}
|
||||
);
|
||||
const allInputDefs: IComfyNodeInputDef[] = Object.entries(allInputs).map(
|
||||
([inputName, inputData]) => {
|
||||
return {
|
||||
name: inputName,
|
||||
type: inputData[0],
|
||||
widgetType: app.getWidgetType(inputData, inputName),
|
||||
defaultValue:
|
||||
inputData[1]?.default ||
|
||||
(inputData[0] instanceof Array ? inputData[0][0] : ""),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const allOutputDefs: IComfyNodeOutputDef[] = _.zip(
|
||||
nodeDef.output,
|
||||
nodeDef.output_name || [],
|
||||
nodeDef.output_is_list || []
|
||||
).map(([outputType, outputName, isList]) => {
|
||||
return {
|
||||
name: outputName,
|
||||
type: outputType instanceof Array ? "COMBO" : outputType,
|
||||
isList: isList,
|
||||
};
|
||||
});
|
||||
|
||||
const slotInputDefs = allInputDefs.filter((input) => !input.widgetType);
|
||||
const widgetInputDefs = allInputDefs.filter((input) => !!input.widgetType);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.slot_row {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
/* Original N-SideBar styles */
|
||||
.sb_dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: grey;
|
||||
}
|
||||
|
||||
.node_header {
|
||||
line-height: 1;
|
||||
padding: 8px 13px 7px;
|
||||
background: var(--comfy-input-bg);
|
||||
margin-bottom: 5px;
|
||||
font-size: 15px;
|
||||
text-wrap: nowrap;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.headdot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
float: inline-start;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.IMAGE {
|
||||
background-color: #64b5f6;
|
||||
}
|
||||
|
||||
.VAE {
|
||||
background-color: #ff6e6e;
|
||||
}
|
||||
|
||||
.LATENT {
|
||||
background-color: #ff9cf9;
|
||||
}
|
||||
|
||||
.MASK {
|
||||
background-color: #81c784;
|
||||
}
|
||||
|
||||
.CONDITIONING {
|
||||
background-color: #ffa931;
|
||||
}
|
||||
|
||||
.CLIP {
|
||||
background-color: #ffd500;
|
||||
}
|
||||
|
||||
.MODEL {
|
||||
background-color: #b39ddb;
|
||||
}
|
||||
|
||||
.CONTROL_NET {
|
||||
background-color: #a5d6a7;
|
||||
}
|
||||
|
||||
#previewDiv {
|
||||
background-color: var(--comfy-menu-bg);
|
||||
font-family: "Open Sans", sans-serif;
|
||||
font-size: small;
|
||||
color: var(--descrip-text);
|
||||
border: 1px solid var(--descrip-text);
|
||||
min-width: 300px;
|
||||
width: min-content;
|
||||
height: fit-content;
|
||||
z-index: 9999;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
#previewDiv .sb_description {
|
||||
margin: 10px;
|
||||
padding: 6px;
|
||||
background: var(--border-color);
|
||||
border-radius: 5px;
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.sb_table {
|
||||
display: grid;
|
||||
|
||||
grid-column-gap: 10px;
|
||||
/* Spazio tra le colonne */
|
||||
width: 100%;
|
||||
/* Imposta la larghezza della tabella al 100% del contenitore */
|
||||
}
|
||||
|
||||
.sb_row {
|
||||
display: grid;
|
||||
grid-template-columns: 10px 1fr 1fr 1fr 10px;
|
||||
grid-column-gap: 10px;
|
||||
align-items: center;
|
||||
padding-left: 9px;
|
||||
padding-right: 9px;
|
||||
}
|
||||
|
||||
.sb_row_string {
|
||||
grid-template-columns: 10px 1fr 1fr 10fr 1fr;
|
||||
}
|
||||
|
||||
.sb_col {
|
||||
border: 0px solid #000;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
flex-direction: row-reverse;
|
||||
flex-wrap: nowrap;
|
||||
align-content: flex-start;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.sb_inherit {
|
||||
display: inherit;
|
||||
}
|
||||
|
||||
.long_field {
|
||||
background: var(--bg-color);
|
||||
border: 2px solid var(--border-color);
|
||||
margin: 5px 5px 0 5px;
|
||||
border-radius: 10px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.sb_arrow {
|
||||
color: var(--fg-color);
|
||||
}
|
||||
|
||||
.sb_preview_badge {
|
||||
text-align: center;
|
||||
background: var(--comfy-input-bg);
|
||||
font-weight: bold;
|
||||
color: var(--error-text);
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,14 @@
|
||||
<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" />
|
||||
<AutoComplete
|
||||
<AutoCompletePlus
|
||||
:model-value="props.filters"
|
||||
class="comfy-vue-node-search-box"
|
||||
scrollHeight="28rem"
|
||||
@@ -12,6 +19,7 @@
|
||||
: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
|
||||
@@ -40,13 +48,13 @@
|
||||
{{ value[1] }}
|
||||
</Chip>
|
||||
</template>
|
||||
</AutoComplete>
|
||||
</AutoCompletePlus>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onMounted, Ref, ref } from "vue";
|
||||
import AutoComplete from "primevue/autocomplete";
|
||||
import AutoCompletePlus from "./primevueOverride/AutoCompletePlus.vue";
|
||||
import Chip from "primevue/chip";
|
||||
import Badge from "primevue/badge";
|
||||
import NodeSearchFilter from "@/components/NodeSearchFilter.vue";
|
||||
@@ -56,6 +64,7 @@ import {
|
||||
NodeSearchService,
|
||||
type FilterAndValue,
|
||||
} from "@/services/nodeSearchService";
|
||||
import NodePreview from "./NodePreview.vue";
|
||||
|
||||
const props = defineProps({
|
||||
filters: {
|
||||
@@ -73,6 +82,7 @@ const nodeSearchService = (
|
||||
|
||||
const inputId = `comfy-vue-node-search-box-input-${Math.random()}`;
|
||||
const suggestions = ref<ComfyNodeDef[]>([]);
|
||||
const hoveredSuggestion = ref<ComfyNodeDef | null>(null);
|
||||
const placeholder = computed(() => {
|
||||
return props.filters.length === 0 ? "Search for nodes" : "";
|
||||
});
|
||||
@@ -104,6 +114,14 @@ const onRemoveFilter = (event: Event, filterAndValue: FilterAndValue) => {
|
||||
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>
|
||||
@@ -115,12 +133,18 @@ const onRemoveFilter = (event: Event, filterAndValue: FilterAndValue) => {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.comfy-vue-node-preview-container {
|
||||
position: absolute;
|
||||
left: -350px;
|
||||
top: 50px;
|
||||
}
|
||||
|
||||
.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;
|
||||
@apply flex flex-col px-4 py-2 cursor-pointer overflow-hidden w-full;
|
||||
}
|
||||
|
||||
.option-container:hover .option-description {
|
||||
|
||||
24
src/components/primevueOverride/AutoCompletePlus.vue
Normal file
24
src/components/primevueOverride/AutoCompletePlus.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<!-- Auto complete with extra event "focused-option-changed" -->
|
||||
<script>
|
||||
import AutoComplete from "primevue/autocomplete";
|
||||
|
||||
export default {
|
||||
name: "AutoCompletePlus",
|
||||
extends: AutoComplete,
|
||||
emits: ["focused-option-changed"],
|
||||
mounted() {
|
||||
if (typeof AutoComplete.mounted === "function") {
|
||||
AutoComplete.mounted.call(this);
|
||||
}
|
||||
|
||||
// Add a watcher on the focusedOptionIndex property
|
||||
this.$watch(
|
||||
() => this.focusedOptionIndex,
|
||||
(newVal, oldVal) => {
|
||||
// Emit a custom event when focusedOptionIndex changes
|
||||
this.$emit("focused-option-changed", newVal);
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -223,6 +223,8 @@ const zComfyNodeDef = z.object({
|
||||
});
|
||||
|
||||
// `/object_info`
|
||||
export type ComfyInputSpec = z.infer<typeof zInputSpec>;
|
||||
export type ComfyOutputSpec = z.infer<typeof zComfyOutputSpec>;
|
||||
export type ComfyNodeDef = z.infer<typeof zComfyNodeDef>;
|
||||
|
||||
// TODO: validate `/object_info` API endpoint responses.
|
||||
|
||||
Reference in New Issue
Block a user