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

@@ -0,0 +1,358 @@
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
/**
* Unique identifier for a favorited widget.
* Combines node locator ID and widget name to locate a widget in the graph.
*/
interface FavoritedWidgetId {
/** The node locator ID in the graph */
nodeLocatorId: NodeLocatorId
/** The widget name on the node */
widgetName: string
}
/**
* A favorited widget with its resolved runtime instance.
* The widget instance may be null if the node or widget no longer exists.
*/
interface FavoritedWidget extends FavoritedWidgetId {
/** The resolved node instance (null if node was deleted) */
node: LGraphNode | null
/** The resolved widget instance (null if widget no longer exists) */
widget: IBaseWidget | null
/** Display label for the favorited item */
label: string
}
export interface ValidFavoritedWidget extends FavoritedWidget {
node: LGraphNode
widget: IBaseWidget
}
/**
* Storage format for persisted favorited widgets.
* Stored in workflow.extra.favoritedWidgets.
*/
interface FavoritedWidgetStorage {
/** Array of favorited widget identifiers */
favorites: FavoritedWidgetId[]
}
/**
* Store for managing favorited/starred widgets.
*
* Favorited widgets can be accessed and edited from the right side panel
* without needing to select the corresponding node. This store manages:
* - Persisting favorited widget IDs per workflow
* - Resolving widget IDs to actual widget instances
* - Handling cases where nodes/widgets are deleted
*
* Design decisions:
* - Scope: Per-workflow (not global user preference)
* - Identifier: node locator ID + widget.name
* - Persistence: Stored in workflow.extra.favoritedWidgets (serialized with workflow)
* - Future: Can be extended for Linear Mode
*/
export const useFavoritedWidgetsStore = defineStore('favoritedWidgets', () => {
const workflowStore = useWorkflowStore()
const canvasStore = useCanvasStore()
/** In-memory array of favorited widget IDs, ordered for display */
const favoritedIds = ref<string[]>([])
/**
* Generate a unique string key for a favorited widget ID.
*/
function getFavoriteKey(id: FavoritedWidgetId): string {
return JSON.stringify([id.nodeLocatorId, id.widgetName])
}
/**
* Parse a favorite key back into a FavoritedWidgetId.
*/
function parseFavoriteKey(key: string): FavoritedWidgetId | null {
try {
const [nodeLocatorId, widgetName] = JSON.parse(key) as [string, string]
if (!nodeLocatorId || !widgetName) return null
return { nodeLocatorId, widgetName }
} catch {
const separatorIndex = key.indexOf(':')
if (separatorIndex === -1) return null
const nodeLocatorId = key.slice(0, separatorIndex)
const widgetName = key.slice(separatorIndex + 1)
if (!nodeLocatorId || !widgetName) return null
return { nodeLocatorId, widgetName }
}
}
function normalizeFavoritedId(
id: FavoritedWidgetId | { nodeId?: unknown; widgetName?: unknown } | null
): FavoritedWidgetId | null {
if (!id || !id.widgetName) return null
if ('nodeLocatorId' in id && id.nodeLocatorId) {
return {
nodeLocatorId: String(id.nodeLocatorId),
widgetName: String(id.widgetName)
}
}
if ('nodeId' in id && id.nodeId !== undefined) {
return {
nodeLocatorId: workflowStore.nodeIdToNodeLocatorId(id.nodeId as NodeId),
widgetName: String(id.widgetName)
}
}
return null
}
function createFavoriteId(
node: LGraphNode,
widgetName: string
): FavoritedWidgetId {
return {
nodeLocatorId: workflowStore.nodeToNodeLocatorId(node),
widgetName
}
}
/**
* Load favorited widgets from the current workflow's extra data.
*/
function loadFromWorkflow() {
const graph = app.rootGraph
if (!graph) {
favoritedIds.value = []
return
}
try {
const storedData = graph.extra?.favoritedWidgets as
| FavoritedWidgetStorage
| undefined
if (storedData?.favorites) {
const normalized = storedData.favorites
.map((fav) => normalizeFavoritedId(fav))
.filter((fav): fav is FavoritedWidgetId => fav !== null)
favoritedIds.value = normalized.map(getFavoriteKey)
} else {
favoritedIds.value = []
}
} catch (error) {
console.error('Failed to load favorited widgets from workflow:', error)
favoritedIds.value = []
}
}
/**
* Save favorited widgets to the current workflow's extra data.
* Marks the workflow as modified.
*/
function saveToWorkflow() {
const graph = app.rootGraph
if (!graph) return
try {
const favorites: FavoritedWidgetId[] = favoritedIds.value
.map(parseFavoriteKey)
.filter((id): id is FavoritedWidgetId => id !== null)
const data: FavoritedWidgetStorage = { favorites }
// Ensure extra object exists
graph.extra ??= {}
graph.extra.favoritedWidgets = data
// Mark the workflow as modified
canvasStore.canvas?.setDirty(true, true)
} catch (error) {
console.error('Failed to save favorited widgets to workflow:', error)
}
}
/**
* Resolve a favorited widget ID to its actual widget instance.
* Returns null if the node or widget no longer exists.
*/
function resolveWidget(id: FavoritedWidgetId): FavoritedWidget {
const graph = app.rootGraph
if (!graph) {
return {
...id,
node: null,
widget: null,
label: `${id.widgetName} (graph not loaded)`
}
}
const node = getNodeByLocatorId(graph, id.nodeLocatorId)
if (!node) {
return {
...id,
node: null,
widget: null,
label: `${id.widgetName} (node deleted)`
}
}
const widget = node.widgets?.find((w) => w.name === id.widgetName)
if (!widget) {
return {
...id,
node,
widget: null,
label: `${id.widgetName} (widget not found)`
}
}
const nodeTitle = node.title || node.type || 'Node'
const widgetLabel = widget.label || widget.name
return {
...id,
node,
widget,
label: `${nodeTitle} / ${widgetLabel}`
}
}
/**
* Get all favorited widgets with their resolved instances.
* Widgets that no longer exist will have null node/widget properties.
*/
const favoritedWidgets = computed((): FavoritedWidget[] => {
return favoritedIds.value
.map(parseFavoriteKey)
.filter((id): id is FavoritedWidgetId => id !== null)
.map(resolveWidget)
})
/**
* Get only the valid favorited widgets (where both node and widget exist).
*/
const validFavoritedWidgets = computed((): ValidFavoritedWidget[] => {
return favoritedWidgets.value.filter(
(fw) => fw.node !== null && fw.widget !== null
) as ValidFavoritedWidget[]
})
/**
* Check if a widget is favorited.
*/
function isFavorited(node: LGraphNode, widgetName: string): boolean {
return favoritedIds.value.includes(
getFavoriteKey(createFavoriteId(node, widgetName))
)
}
/**
* Add a widget to favorites.
*/
function addFavorite(node: LGraphNode, widgetName: string) {
const key = getFavoriteKey(createFavoriteId(node, widgetName))
if (favoritedIds.value.includes(key)) return
favoritedIds.value.push(key)
saveToWorkflow()
}
/**
* Remove a widget from favorites.
*/
function removeFavorite(node: LGraphNode, widgetName: string) {
const key = getFavoriteKey(createFavoriteId(node, widgetName))
const index = favoritedIds.value.indexOf(key)
if (index === -1) return
favoritedIds.value.splice(index, 1)
saveToWorkflow()
}
/**
* Toggle a widget's favorite status.
*/
function toggleFavorite(node: LGraphNode, widgetName: string) {
if (isFavorited(node, widgetName)) {
removeFavorite(node, widgetName)
} else {
addFavorite(node, widgetName)
}
}
/**
* Clear all favorites for the current workflow.
*/
function clearFavorites() {
favoritedIds.value = []
saveToWorkflow()
}
/**
* Remove invalid favorites (where node or widget no longer exists).
* Useful for cleanup after loading a workflow.
*/
function pruneInvalidFavorites() {
const validKeys = validFavoritedWidgets.value.map((fw) =>
getFavoriteKey({
nodeLocatorId: fw.nodeLocatorId,
widgetName: fw.widgetName
})
)
const validSet = new Set(validKeys)
const filteredIds = favoritedIds.value.filter((key) => validSet.has(key))
if (filteredIds.length !== favoritedIds.value.length) {
favoritedIds.value = filteredIds
saveToWorkflow()
}
}
/**
* Reorder favorites based on the provided array of widgets.
* Used when dragging and dropping favorites to reorder them.
*/
function reorderFavorites(reorderedWidgets: ValidFavoritedWidget[]) {
favoritedIds.value = reorderedWidgets.map((fw) =>
getFavoriteKey({
nodeLocatorId: fw.nodeLocatorId,
widgetName: fw.widgetName
})
)
saveToWorkflow()
}
// Watch for workflow changes and reload favorites from workflow.extra
watch(
() => workflowStore.activeWorkflow?.path,
() => {
loadFromWorkflow()
},
{ immediate: true }
)
return {
// State
favoritedWidgets,
validFavoritedWidgets,
// Actions
isFavorited,
addFavorite,
removeFavorite,
toggleFavorite,
clearFavorites,
pruneInvalidFavorites,
reorderFavorites
}
})

View File

@@ -1,16 +1,33 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export type RightSidePanelTab = 'parameters' | 'settings' | 'info' | 'subgraph'
import { useSettingStore } from '@/platform/settings/settingStore'
export type RightSidePanelTab =
| 'parameters'
| 'nodes'
| 'settings'
| 'info'
| 'subgraph'
type RightSidePanelSection = 'advanced-inputs' | string
/**
* Store for managing the right side panel state.
* This panel displays properties and settings for selected nodes.
*/
export const useRightSidePanelStore = defineStore('rightSidePanel', () => {
const isOpen = ref(false)
const settingStore = useSettingStore()
const isOpen = computed({
get: () => settingStore.get('Comfy.RightSidePanel.IsOpen'),
set: (value: boolean) =>
settingStore.set('Comfy.RightSidePanel.IsOpen', value)
})
const activeTab = ref<RightSidePanelTab>('parameters')
const isEditingSubgraph = computed(() => activeTab.value === 'subgraph')
const focusedSection = ref<RightSidePanelSection | null>(null)
const searchQuery = ref('')
function openPanel(tab?: RightSidePanelTab) {
isOpen.value = true
@@ -27,12 +44,33 @@ export const useRightSidePanelStore = defineStore('rightSidePanel', () => {
isOpen.value = !isOpen.value
}
/**
* Focus on a specific section in the right side panel.
* This will open the panel, switch to the parameters tab, and signal
* the component to expand and scroll to the section.
*/
function focusSection(section: RightSidePanelSection) {
openPanel('parameters')
focusedSection.value = section
}
/**
* Clear the focused section after it has been handled.
*/
function clearFocusedSection() {
focusedSection.value = null
}
return {
isOpen,
activeTab,
isEditingSubgraph,
focusedSection,
searchQuery,
openPanel,
closePanel,
togglePanel
togglePanel,
focusSection,
clearFocusedSection
}
})