mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-06 08:00:05 +00:00
Categorize setting items (#338)
* Basic setting panel rework * refactor * Style the setting item * Reject invalid value * nit * nit * Sort settings by label * info chip as icon * nit
This commit is contained in:
@@ -12,7 +12,11 @@
|
||||
@unmaximize="maximized = false"
|
||||
>
|
||||
<template #header>
|
||||
<h3>{{ dialogStore.title || ' ' }}</h3>
|
||||
<component
|
||||
v-if="dialogStore.headerComponent"
|
||||
:is="dialogStore.headerComponent"
|
||||
/>
|
||||
<h3 v-else>{{ dialogStore.title || ' ' }}</h3>
|
||||
</template>
|
||||
|
||||
<component
|
||||
|
||||
@@ -1,116 +1,119 @@
|
||||
<template>
|
||||
<table class="comfy-modal-content comfy-table">
|
||||
<tbody>
|
||||
<tr v-for="setting in sortedSettings" :key="setting.id">
|
||||
<td>
|
||||
<span>
|
||||
{{ setting.name }}
|
||||
</span>
|
||||
<Chip
|
||||
v-if="setting.tooltip"
|
||||
icon="pi pi-info-circle"
|
||||
severity="secondary"
|
||||
v-tooltip="setting.tooltip"
|
||||
class="info-chip"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<component
|
||||
:is="markRaw(getSettingComponent(setting))"
|
||||
:id="setting.id"
|
||||
:modelValue="settingStore.get(setting.id)"
|
||||
@update:modelValue="updateSetting(setting, $event)"
|
||||
v-bind="getSettingAttrs(setting)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="settings-container">
|
||||
<div class="settings-sidebar">
|
||||
<Listbox
|
||||
v-model="activeCategory"
|
||||
:options="categories"
|
||||
optionLabel="label"
|
||||
scrollHeight="100%"
|
||||
:pt="{ root: { class: 'border-none' } }"
|
||||
/>
|
||||
</div>
|
||||
<Divider layout="vertical" />
|
||||
<div class="settings-content" v-if="activeCategory">
|
||||
<Tabs :value="activeCategory.label">
|
||||
<TabPanels>
|
||||
<TabPanel
|
||||
v-for="category in categories"
|
||||
:key="category.key"
|
||||
:value="category.label"
|
||||
>
|
||||
<SettingGroup
|
||||
v-for="group in sortedGroups(category)"
|
||||
:key="group.label"
|
||||
:group="{
|
||||
label: group.label,
|
||||
settings: flattenTree<SettingParams>(group)
|
||||
}"
|
||||
/>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type Component, computed, markRaw } from 'vue'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import Select from 'primevue/select'
|
||||
import Chip from 'primevue/chip'
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import Listbox from 'primevue/listbox'
|
||||
import Tabs from 'primevue/tabs'
|
||||
import TabPanels from 'primevue/tabpanels'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import Divider from 'primevue/divider'
|
||||
import { SettingTreeNode, useSettingStore } from '@/stores/settingStore'
|
||||
import { SettingParams } from '@/types/settingTypes'
|
||||
import CustomSettingValue from '@/components/dialog/content/setting/CustomSettingValue.vue'
|
||||
import InputSlider from '@/components/dialog/content/setting/InputSlider.vue'
|
||||
import SettingGroup from './setting/SettingGroup.vue'
|
||||
import { flattenTree } from '@/utils/treeUtil'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const sortedSettings = computed<SettingParams[]>(() => {
|
||||
return Object.values(settingStore.settings)
|
||||
.filter((setting: SettingParams) => setting.type !== 'hidden')
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
const settingRoot = computed<SettingTreeNode>(() => settingStore.settingTree)
|
||||
const categories = computed<SettingTreeNode[]>(
|
||||
() => settingRoot.value.children || []
|
||||
)
|
||||
|
||||
const activeCategory = ref<SettingTreeNode | null>(null)
|
||||
|
||||
watch(activeCategory, (newCategory, oldCategory) => {
|
||||
if (newCategory === null) {
|
||||
activeCategory.value = oldCategory
|
||||
}
|
||||
})
|
||||
|
||||
function getSettingAttrs(setting: SettingParams) {
|
||||
const attrs = { ...(setting.attrs || {}) }
|
||||
const settingType = setting.type
|
||||
if (typeof settingType === 'function') {
|
||||
attrs['renderFunction'] = () =>
|
||||
settingType(
|
||||
setting.name,
|
||||
(v) => updateSetting(setting, v),
|
||||
settingStore.get(setting.id),
|
||||
setting.attrs
|
||||
)
|
||||
}
|
||||
switch (setting.type) {
|
||||
case 'combo':
|
||||
attrs['options'] = setting.options
|
||||
if (typeof setting.options[0] !== 'string') {
|
||||
attrs['optionLabel'] = 'text'
|
||||
attrs['optionValue'] = 'value'
|
||||
}
|
||||
break
|
||||
}
|
||||
onMounted(() => {
|
||||
activeCategory.value = categories.value[0]
|
||||
})
|
||||
|
||||
attrs['class'] += ' comfy-vue-setting-input'
|
||||
return attrs
|
||||
}
|
||||
|
||||
function getSettingComponent(setting: SettingParams): Component {
|
||||
if (typeof setting.type === 'function') {
|
||||
// return setting.type(
|
||||
// setting.name, (v) => updateSetting(setting, v), settingStore.get(setting.id), setting.attrs)
|
||||
return CustomSettingValue
|
||||
}
|
||||
switch (setting.type) {
|
||||
case 'boolean':
|
||||
return ToggleSwitch
|
||||
case 'number':
|
||||
return InputNumber
|
||||
case 'slider':
|
||||
return InputSlider
|
||||
case 'combo':
|
||||
return Select
|
||||
default:
|
||||
return InputText
|
||||
}
|
||||
}
|
||||
|
||||
const updateSetting = (setting: SettingParams, value: any) => {
|
||||
if (setting.onChange) setting.onChange(value, settingStore.get(setting.id))
|
||||
|
||||
settingStore.set(setting.id, value)
|
||||
const sortedGroups = (category: SettingTreeNode) => {
|
||||
return [...(category.children || [])].sort((a, b) =>
|
||||
a.label.localeCompare(b.label)
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.info-chip {
|
||||
background: transparent !important;
|
||||
}
|
||||
.comfy-vue-setting-input {
|
||||
width: 100%;
|
||||
/* Remove after we have tailwind setup */
|
||||
.border-none {
|
||||
border: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.comfy-table {
|
||||
.settings-container {
|
||||
display: flex;
|
||||
height: 80vh;
|
||||
width: 60vw;
|
||||
max-width: 1000px;
|
||||
overflow: hidden;
|
||||
/* Prevents container from scrolling */
|
||||
}
|
||||
|
||||
.settings-sidebar {
|
||||
width: 250px;
|
||||
flex-shrink: 0;
|
||||
/* Prevents sidebar from shrinking */
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
/* Allows vertical scrolling */
|
||||
}
|
||||
|
||||
/* Ensure the Listbox takes full width of the sidebar */
|
||||
.settings-sidebar :deep(.p-listbox) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Optional: Style scrollbars for webkit browsers */
|
||||
.settings-sidebar::-webkit-scrollbar,
|
||||
.settings-content::-webkit-scrollbar {
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.settings-sidebar::-webkit-scrollbar-thumb,
|
||||
.settings-content::-webkit-scrollbar-thumb {
|
||||
background-color: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,37 +5,68 @@
|
||||
@update:modelValue="updateValue"
|
||||
class="slider-part"
|
||||
:class="sliderClass"
|
||||
v-bind="$attrs"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
/>
|
||||
<InputText
|
||||
:value="modelValue"
|
||||
@input="updateValue"
|
||||
<InputNumber
|
||||
:modelValue="modelValue"
|
||||
@update:modelValue="updateValue"
|
||||
class="input-part"
|
||||
:class="inputClass"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { ref, watch } from 'vue'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import Slider from 'primevue/slider'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: number
|
||||
inputClass?: string
|
||||
sliderClass?: string
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: number): void
|
||||
}>()
|
||||
|
||||
const updateValue = (newValue: string | number) => {
|
||||
const numValue =
|
||||
typeof newValue === 'string' ? parseFloat(newValue) : newValue
|
||||
if (!isNaN(numValue)) {
|
||||
emit('update:modelValue', numValue)
|
||||
const localValue = ref(props.modelValue)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
localValue.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
const updateValue = (newValue: number | null) => {
|
||||
if (newValue === null) {
|
||||
// If the input is cleared, reset to the minimum value or 0
|
||||
newValue = Number(props.min) || 0
|
||||
}
|
||||
|
||||
const min = Number(props.min) || Number.NEGATIVE_INFINITY
|
||||
const max = Number(props.max) || Number.POSITIVE_INFINITY
|
||||
const step = Number(props.step) || 1
|
||||
|
||||
// Ensure the value is within the allowed range
|
||||
newValue = Math.max(min, Math.min(max, newValue))
|
||||
|
||||
// Round to the nearest step
|
||||
newValue = Math.round(newValue / step) * step
|
||||
|
||||
// Update local value and emit change
|
||||
localValue.value = newValue
|
||||
emit('update:modelValue', newValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -44,7 +75,6 @@ const updateValue = (newValue: string | number) => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
/* Adjust this value to control space between slider and input */
|
||||
}
|
||||
|
||||
.slider-part {
|
||||
@@ -52,7 +82,6 @@ const updateValue = (newValue: string | number) => {
|
||||
}
|
||||
|
||||
.input-part {
|
||||
width: 5rem;
|
||||
/* Adjust this value to control input width */
|
||||
width: 5rem !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
143
src/components/dialog/content/setting/SettingGroup.vue
Normal file
143
src/components/dialog/content/setting/SettingGroup.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="setting-group">
|
||||
<Divider />
|
||||
<h3>{{ group.label }}</h3>
|
||||
<div
|
||||
v-for="setting in group.settings"
|
||||
:key="setting.id"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-label">
|
||||
<span
|
||||
>{{ setting.name }}
|
||||
<i
|
||||
v-if="setting.tooltip"
|
||||
class="pi pi-info-circle info-chip"
|
||||
v-tooltip="setting.tooltip"
|
||||
/></span>
|
||||
</div>
|
||||
<div class="setting-input">
|
||||
<component
|
||||
:is="markRaw(getSettingComponent(setting))"
|
||||
:id="setting.id"
|
||||
:modelValue="settingStore.get(setting.id)"
|
||||
@update:modelValue="updateSetting(setting, $event)"
|
||||
v-bind="getSettingAttrs(setting)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { SettingParams } from '@/types/settingTypes'
|
||||
import { markRaw, type Component } from 'vue'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import Select from 'primevue/select'
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import Divider from 'primevue/divider'
|
||||
import CustomSettingValue from '@/components/dialog/content/setting/CustomSettingValue.vue'
|
||||
import InputSlider from '@/components/dialog/content/setting/InputSlider.vue'
|
||||
|
||||
defineProps<{
|
||||
group: {
|
||||
label: string
|
||||
settings: SettingParams[]
|
||||
}
|
||||
}>()
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
function getSettingAttrs(setting: SettingParams) {
|
||||
const attrs = { ...(setting.attrs || {}) }
|
||||
const settingType = setting.type
|
||||
if (typeof settingType === 'function') {
|
||||
attrs['renderFunction'] = () =>
|
||||
settingType(
|
||||
setting.name,
|
||||
(v) => updateSetting(setting, v),
|
||||
settingStore.get(setting.id),
|
||||
setting.attrs
|
||||
)
|
||||
}
|
||||
switch (setting.type) {
|
||||
case 'combo':
|
||||
attrs['options'] = setting.options
|
||||
if (typeof setting.options[0] !== 'string') {
|
||||
attrs['optionLabel'] = 'text'
|
||||
attrs['optionValue'] = 'value'
|
||||
}
|
||||
break
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
const updateSetting = (setting: SettingParams, value: any) => {
|
||||
if (setting.onChange) setting.onChange(value, settingStore.get(setting.id))
|
||||
settingStore.set(setting.id, value)
|
||||
}
|
||||
|
||||
function getSettingComponent(setting: SettingParams): Component {
|
||||
if (typeof setting.type === 'function') {
|
||||
return CustomSettingValue
|
||||
}
|
||||
switch (setting.type) {
|
||||
case 'boolean':
|
||||
return ToggleSwitch
|
||||
case 'number':
|
||||
return InputNumber
|
||||
case 'slider':
|
||||
return InputSlider
|
||||
case 'combo':
|
||||
return Select
|
||||
default:
|
||||
return InputText
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.info-chip {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.setting-input {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
/* Ensure PrimeVue components take full width of their container */
|
||||
.setting-input :deep(.p-inputtext),
|
||||
.setting-input :deep(.input-slider),
|
||||
.setting-input :deep(.p-select),
|
||||
.setting-input :deep(.p-togglebutton) {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.setting-input :deep(.p-inputtext) {
|
||||
max-width: unset;
|
||||
}
|
||||
|
||||
/* Special case for ToggleSwitch to align it to the right */
|
||||
.setting-input :deep(.p-toggleswitch) {
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
24
src/components/dialog/header/SettingDialogHeader.vue
Normal file
24
src/components/dialog/header/SettingDialogHeader.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2>
|
||||
<i class="pi pi-cog"></i>
|
||||
<span>Settings</span>
|
||||
<Tag :value="frontendVersion" severity="secondary" class="version-tag" />
|
||||
</h2>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Tag from 'primevue/tag'
|
||||
const frontendVersion = 'v' + window['__COMFYUI_FRONTEND_VERSION__']
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pi-cog {
|
||||
font-size: 1.25rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
.version-tag {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -10,12 +10,12 @@
|
||||
import SideBarIcon from './SideBarIcon.vue'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import SettingDialogContent from '@/components/dialog/content/SettingDialogContent.vue'
|
||||
const frontendVersion = window['__COMFYUI_FRONTEND_VERSION__']
|
||||
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const showSetting = () => {
|
||||
dialogStore.showDialog({
|
||||
title: `Settings (v${frontendVersion})`,
|
||||
headerComponent: SettingDialogHeader,
|
||||
component: SettingDialogContent
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { $el } from '../ui'
|
||||
|
||||
export class ComfyDialog<
|
||||
|
||||
@@ -7,6 +7,7 @@ import { type Component, markRaw } from 'vue'
|
||||
interface DialogState {
|
||||
isVisible: boolean
|
||||
title: string
|
||||
headerComponent: Component | null
|
||||
component: Component | null
|
||||
props: Record<string, any>
|
||||
}
|
||||
@@ -15,6 +16,7 @@ export const useDialogStore = defineStore('dialog', {
|
||||
state: (): DialogState => ({
|
||||
isVisible: false,
|
||||
title: '',
|
||||
headerComponent: null,
|
||||
component: null,
|
||||
props: {}
|
||||
}),
|
||||
@@ -22,10 +24,12 @@ export const useDialogStore = defineStore('dialog', {
|
||||
actions: {
|
||||
showDialog(options: {
|
||||
title?: string
|
||||
headerComponent?: Component
|
||||
component: Component
|
||||
props?: Record<string, any>
|
||||
}) {
|
||||
this.title = options.title
|
||||
this.headerComponent = markRaw(options.headerComponent)
|
||||
this.component = markRaw(options.component)
|
||||
this.props = options.props || {}
|
||||
this.isVisible = true
|
||||
|
||||
@@ -10,7 +10,13 @@
|
||||
import { app } from '@/scripts/app'
|
||||
import { ComfySettingsDialog } from '@/scripts/ui/settings'
|
||||
import { SettingParams } from '@/types/settingTypes'
|
||||
import { buildTree } from '@/utils/treeUtil'
|
||||
import { defineStore } from 'pinia'
|
||||
import type { TreeNode } from 'primevue/treenode'
|
||||
|
||||
export interface SettingTreeNode extends TreeNode {
|
||||
data?: SettingParams
|
||||
}
|
||||
|
||||
interface State {
|
||||
settingValues: Record<string, any>
|
||||
@@ -22,6 +28,13 @@ export const useSettingStore = defineStore('setting', {
|
||||
settingValues: {},
|
||||
settings: {}
|
||||
}),
|
||||
getters: {
|
||||
settingTree(): SettingTreeNode {
|
||||
return buildTree(Object.values(this.settings), (setting: SettingParams) =>
|
||||
setting.id.split('.')
|
||||
)
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
addSettings(settings: ComfySettingsDialog) {
|
||||
for (const id in settings.settingsLookup) {
|
||||
|
||||
49
src/utils/treeUtil.ts
Normal file
49
src/utils/treeUtil.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { TreeNode } from 'primevue/treenode'
|
||||
|
||||
export function buildTree<T>(
|
||||
items: T[],
|
||||
key: string | ((item: T) => string[])
|
||||
): TreeNode {
|
||||
const root: TreeNode = {
|
||||
key: 'root',
|
||||
label: 'root',
|
||||
children: []
|
||||
}
|
||||
|
||||
const map: Record<string, TreeNode> = {
|
||||
root: root
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const keys = typeof key === 'string' ? item[key] : key(item)
|
||||
let parent = root
|
||||
for (const k of keys) {
|
||||
const id = parent.key + '/' + k
|
||||
if (!map[id]) {
|
||||
const node: TreeNode = {
|
||||
key: id,
|
||||
label: k,
|
||||
leaf: false,
|
||||
children: []
|
||||
}
|
||||
map[id] = node
|
||||
parent.children.push(node)
|
||||
}
|
||||
parent = map[id]
|
||||
}
|
||||
parent.leaf = true
|
||||
parent.data = item
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
export function flattenTree<T>(tree: TreeNode): T[] {
|
||||
const result: T[] = []
|
||||
const stack: TreeNode[] = [tree]
|
||||
while (stack.length) {
|
||||
const node = stack.pop()!
|
||||
if (node.leaf && node.data) result.push(node.data)
|
||||
stack.push(...(node.children || []))
|
||||
}
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user