feat: right side panel favorites, no selection state, and more... (#7812)

Most of the features in this pull request are completed and can be
reviewed and merged.

## TODO

- [x] no selection panel
- [x] group selected panel
- [x] tabs 
  - [x] favorites tab
  - [x] global settings tab
  - [x] nodes tab
- [x] widget actions menu 
  - [x] [Bug]: style bugs
- [x] button zoom to the node on canvas.
- [x] rename widgets on widget actions
  - [ ] [Bug]: the canvas has not been updated after renaming. 
- [x] global settings
  - [ ] setting item: "show advanced parameters"
    - blocked by other things. skip for now.
  - [x] setting item: show toolbox on selection 
  - [x] setting item: nodes 2.0
  - [ ] setting item: "background color"
    - blocked by other things. skip for now.
  - [x] setting item: grid spacing
  - [x] setting item: snap nodes to grid
  - [x] setting item: link shape
  - [x] setting item: show connected links
  - [x] form style reuses the form style of node widgets
- [x] group node cases
  - [x] group node settings
  - [x] show all nodes in group
  - [x] show frame name on nodes when multiple selections are made
  - [x] group multiple selections
- [x] [Bug]: nodes without widgets cannot display the location and their
group
  - [x] [Bug]: labels layout
- [x] favorites
  - [x] the indicator on widgets
  - [x] favorite and unfavorite buttons on widgets
- [x] [Bug]: show node name in favorite widgets + improve labels layout
- [ ] [Bug]: After canceling the like, the like list will not be updated
immediately.
- [x] [Bug]: The favorite function does not work for the project on
Subgraph.
- [x] subgraph
- [x] add the node name from where this parameter comes from when node
is subgraph
  - [x] show and hide directly on Inputs
    - [x] some bugs need to be fixed.
- [x] advanced widgets 
  - [x] button: show advanced inputs
- Clicking button expands the "Advanced Inputs" section on the right
side panel, regardless of whether the panel is open or not
    - [x] [Bug]: style bugs
  - [x] advanced inputs section when node is subgraph
- [x] inputs tab rearranging
  - [x] favorited inputs rearranging
  - [x] subgraph inputs rearranging
- [ ] review and reconstruction to improve complexity and architecture

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7812-feat-right-side-panel-favorites-no-selection-state-and-more-2da6d73d36508134b503d676f9b3d248)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: bymyself <cbyrne@comfy.org>
This commit is contained in:
Rizumu Ayaka
2026-01-14 10:37:17 +07:00
committed by GitHub
parent 069e94b325
commit b1b2fd8a4f
44 changed files with 3352 additions and 571 deletions

View File

@@ -119,6 +119,23 @@
<div v-if="shouldShowPreviewImg" class="min-h-0 flex-1 px-4">
<LivePreview :image-url="latestPreviewUrl || null" />
</div>
<!-- Show advanced inputs button for subgraph nodes -->
<div v-if="showAdvancedInputsButton" class="flex justify-center px-3">
<button
:class="
cn(
WidgetInputBaseClass,
'w-full h-7 flex justify-center items-center gap-2 text-sm px-3 outline-0 ring-0 truncate',
'transition-all cursor-pointer hover:bg-accent-background duration-150 active:scale-95'
)
"
@click.stop="handleShowAdvancedInputs"
>
<i class="icon-[lucide--settings-2] size-4" />
<span>{{ t('rightSidePanel.showAdvancedInputsButton') }}</span>
</button>
</div>
</div>
</template>
@@ -148,6 +165,7 @@ import {
LiteGraph,
RenderShape
} from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
@@ -169,6 +187,7 @@ import { app } from '@/scripts/app'
import { useExecutionStore } from '@/stores/executionStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { isTransparent } from '@/utils/colorUtil'
import {
getLocatorIdFromNodeData,
@@ -177,6 +196,7 @@ import {
import { cn } from '@/utils/tailwindUtil'
import { useNodeResize } from '../interactions/resize/useNodeResize'
import { WidgetInputBaseClass } from '../widgets/components/layout'
import LivePreview from './LivePreview.vue'
import NodeContent from './NodeContent.vue'
import NodeHeader from './NodeHeader.vue'
@@ -474,6 +494,22 @@ const lgraphNode = computed(() => {
return getNodeByLocatorId(app.rootGraph, locatorId)
})
const showAdvancedInputsButton = computed(() => {
const node = lgraphNode.value
if (!node || !(node instanceof SubgraphNode)) return false
// Check if there are hidden inputs (widgets not promoted)
const interiorNodes = node.subgraph.nodes
const allInteriorWidgets = interiorNodes.flatMap((n) => n.widgets ?? [])
return allInteriorWidgets.some((w) => !w.computedDisabled && !w.promoted)
})
function handleShowAdvancedInputs() {
const rightSidePanelStore = useRightSidePanelStore()
rightSidePanelStore.focusSection('advanced-inputs')
}
const nodeMedia = computed(() => {
const newOutputs = nodeOutputs.nodeOutputs[nodeOutputLocatorId.value]
const node = lgraphNode.value

View File

@@ -0,0 +1,104 @@
<script setup lang="ts">
import { refDebounced } from '@vueuse/core'
import { ref, toRef, toValue, watch } from 'vue'
import type { HTMLAttributes, MaybeRefOrGetter } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const {
searcher = async () => {},
updateKey,
autofocus = false,
class: customClass
} = defineProps<{
searcher?: (
query: string,
onCleanup: (cleanupFn: () => void) => void
) => Promise<void>
updateKey?: MaybeRefOrGetter<unknown>
autofocus?: boolean
class?: HTMLAttributes['class']
}>()
const searchQuery = defineModel<string>({ default: '' })
const isQuerying = ref(false)
const debouncedSearchQuery = refDebounced(searchQuery, 250, {
maxWait: 1000
})
watch(searchQuery, (value) => {
isQuerying.value = value !== debouncedSearchQuery.value
})
const updateKeyRef = toRef(() => toValue(updateKey))
watch(
[debouncedSearchQuery, updateKeyRef],
(_, __, onCleanup) => {
let isCleanup = false
let cleanupFn: undefined | (() => void)
onCleanup(() => {
isCleanup = true
cleanupFn?.()
})
void searcher(debouncedSearchQuery.value, (cb) => (cleanupFn = cb))
.catch((error) => {
console.error('[SidePanelSearch] searcher failed', error)
})
.finally(() => {
if (!isCleanup) isQuerying.value = false
})
},
{ immediate: true }
)
function handleFocus(event: FocusEvent) {
const target = event.target as HTMLInputElement
target.select()
}
</script>
<template>
<label
:class="
cn(
'group',
'bg-component-node-widget-background rounded-lg transition-all duration-150',
'flex-1 flex items-center',
'text-base-foreground border-0',
'focus-within:ring focus-within:ring-component-node-widget-background-highlighted/80',
customClass
)
"
>
<i
:class="
cn(
'size-4 ml-2 shrink-0 transition-colors duration-150',
isQuerying
? 'icon-[lucide--loader-circle] animate-spin'
: 'icon-[lucide--search]',
searchQuery?.trim() !== ''
? 'text-base-foreground'
: 'text-muted-foreground group-hover:text-base-foreground group-focus-within:text-base-foreground'
)
"
/>
<input
v-model="searchQuery"
type="text"
class="bg-transparent border-0 outline-0 ring-0 h-5 w-full my-1.5 mx-2"
:placeholder="$t('g.searchPlaceholder')"
:autofocus
@focus="handleFocus"
/>
<button
v-if="searchQuery.trim().length > 0"
class="text-muted-foreground hover:text-base-foreground bg-transparent shrink-0 border-0 outline-0 ring-0 p-0 m-0 pr-3 pl-1 flex items-center justify-center transition-all duration-150 hover:scale-108"
:aria-label="$t('g.clear')"
@click="searchQuery = ''"
>
<i :class="cn('icon-[lucide--delete] size-4 cursor-pointer')" />
</button>
</label>
</template>

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import { refDebounced } from '@vueuse/core'
import Popover from 'primevue/popover'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { computed, ref, useTemplateRef } from 'vue'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
@@ -68,10 +67,6 @@ const layoutMode = defineModel<LayoutMode>('layoutMode', {
const files = defineModel<File[]>('files', { default: [] })
const searchQuery = defineModel<string>('searchQuery', { default: '' })
const debouncedSearchQuery = refDebounced(searchQuery, 700, {
maxWait: 700
})
const isQuerying = ref(false)
const toastStore = useToastStore()
const popoverRef = ref<InstanceType<typeof Popover>>()
const triggerRef = useTemplateRef('triggerRef')
@@ -85,36 +80,6 @@ const maxSelectable = computed(() => {
const filteredItems = ref<DropdownItem[]>([])
watch(searchQuery, (value) => {
isQuerying.value = value !== debouncedSearchQuery.value
})
watch(
[debouncedSearchQuery, () => props.items],
(_, __, onCleanup) => {
let isCleanup = false
let cleanupFn: undefined | (() => void)
onCleanup(() => {
isCleanup = true
cleanupFn?.()
})
void props
.searcher(
debouncedSearchQuery.value,
props.items,
(cb) => (cleanupFn = cb)
)
.then((result) => {
if (!isCleanup) filteredItems.value = result
})
.finally(() => {
if (!isCleanup) isQuerying.value = false
})
},
{ immediate: true }
)
const defaultSorter = computed<SortOption['sorter']>(() => {
const sorter = props.sortOptions.find(
(option) => option.id === 'default'
@@ -183,6 +148,23 @@ function handleSelection(item: DropdownItem, index: number) {
closeDropdown()
}
}
async function customSearcher(
query: string,
onCleanup: (cleanupFn: () => void) => void
) {
let isCleanup = false
let cleanupFn: undefined | (() => void)
onCleanup(() => {
isCleanup = true
cleanupFn?.()
})
await props
.searcher(query, props.items, (cb) => (cleanupFn = cb))
.then((results) => {
if (!isCleanup) filteredItems.value = results
})
}
</script>
<template>
@@ -223,7 +205,7 @@ function handleSelection(item: DropdownItem, index: number) {
:filter-options="filterOptions"
:sort-options="sortOptions"
:disabled="disabled"
:is-querying="isQuerying"
:searcher="customSearcher"
:items="sortedItems"
:is-selected="internalIsSelected"
:max-selectable="maxSelectable"

View File

@@ -15,9 +15,12 @@ import type {
interface Props {
items: DropdownItem[]
isSelected: (item: DropdownItem, index: number) => boolean
isQuerying: boolean
filterOptions: FilterOption[]
sortOptions: SortOption[]
searcher?: (
query: string,
onCleanup: (cleanupFn: () => void) => void
) => Promise<void>
}
defineProps<Props>()
@@ -50,7 +53,7 @@ const searchQuery = defineModel<string>('searchQuery')
v-model:sort-selected="sortSelected"
v-model:search-query="searchQuery"
:sort-options="sortOptions"
:is-querying="isQuerying"
:searcher
/>
<!-- List -->
<div class="relative flex h-full mt-2 overflow-y-scroll">

View File

@@ -4,10 +4,14 @@ import { ref, useTemplateRef } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import FormSearchInput from '../FormSearchInput.vue'
import type { LayoutMode, OptionId, SortOption } from './types'
defineProps<{
isQuerying: boolean
searcher?: (
query: string,
onCleanup: (cleanupFn: () => void) => void
) => Promise<void>
sortOptions: SortOption[]
}>()
@@ -46,33 +50,18 @@ function handleSortSelected(item: SortOption) {
<template>
<div class="text-secondary flex gap-2 px-4">
<!-- TODO: Replace with a common Search input -->
<label
<FormSearchInput
v-model="searchQuery"
:searcher
:class="
cn(
actionButtonStyle,
'flex-1 flex px-2 items-center text-base leading-none cursor-text',
searchQuery?.trim() !== '' ? 'text-base-foreground' : '',
'hover:outline-component-node-widget-background-highlighted/80',
'focus-within:outline-component-node-widget-background-highlighted/80'
'focus-within:outline-component-node-widget-background-highlighted/80 focus-within:ring-0'
)
"
>
<i
v-if="isQuerying"
class="mr-2 icon-[lucide--loader-circle] size-4 animate-spin"
/>
<i v-else class="mr-2 icon-[lucide--search] size-4" />
<input
v-model="searchQuery"
type="text"
autofocus
:class="resetInputStyle"
:placeholder="$t('g.search')"
/>
</label>
/>
<!-- Sort Select -->
<button
ref="sortTriggerRef"
:class="