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,21 @@
<script setup lang="ts">
import ToggleSwitch from 'primevue/toggleswitch'
import LayoutField from './LayoutField.vue'
defineProps<{
label: string
tooltip?: string
}>()
const modelValue = defineModel<boolean>({ default: false })
</script>
<template>
<LayoutField singleline :label :tooltip>
<ToggleSwitch
v-model="modelValue"
class="transition-transform active:scale-90"
/>
</LayoutField>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
defineProps<{
label: string
tooltip?: string
singleline?: boolean
}>()
</script>
<template>
<div
:class="
cn('flex gap-2', singleline ? 'items-center justify-between' : 'flex-col')
"
>
<span
v-tooltip.left="
tooltip
? {
value: tooltip,
showDelay: 300
}
: null
"
:class="
cn(
'text-sm text-muted-foreground truncate',
tooltip ? 'cursor-help' : '',
singleline ? 'flex-1' : ''
)
"
>
{{ label }}
</span>
<slot />
</div>
</template>

View File

@@ -0,0 +1,61 @@
<template>
<div class="space-y-4 text-sm text-muted-foreground">
<SetNodeState
v-if="isNodes(targetNodes)"
:nodes="targetNodes"
@changed="handleChanged"
/>
<SetNodeColor :nodes="targetNodes" @changed="handleChanged" />
<SetPinned :nodes="targetNodes" @changed="handleChanged" />
</div>
</template>
<script setup lang="ts">
import { shallowRef, watchEffect } from 'vue'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { isLGraphGroup } from '@/utils/litegraphUtil'
import SetNodeColor from './SetNodeColor.vue'
import SetNodeState from './SetNodeState.vue'
import SetPinned from './SetPinned.vue'
const props = defineProps<{
/**
* - If the item is a Group, Node State cannot be set
* as Groups do not have a 'mode' property.
*
* - The nodes array can contain either all Nodes or all Groups,
* but it must not be a mix of both.
*/
nodes?: LGraphNode[] | LGraphGroup[]
}>()
const targetNodes = shallowRef<LGraphNode[] | LGraphGroup[]>([])
watchEffect(() => {
if (props.nodes) {
targetNodes.value = props.nodes
} else {
targetNodes.value = []
}
})
const canvasStore = useCanvasStore()
function isNodes(nodes: LGraphNode[] | LGraphGroup[]): nodes is LGraphNode[] {
return !nodes.some((node) => isLGraphGroup(node))
}
function handleChanged() {
/**
* This is not a random comment—it's crucial.
* Otherwise, the UI cannot update correctly.
* There is a bug with triggerRef here, so we can't use triggerRef.
* We'll work around it for now and later submit a Vue issue and pull request to fix it.
*/
targetNodes.value = targetNodes.value.slice()
canvasStore.canvas?.setDirty(true, true)
}
</script>

View File

@@ -0,0 +1,144 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ColorOption } from '@/lib/litegraph/src/litegraph'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
import { cn } from '@/utils/tailwindUtil'
import LayoutField from './LayoutField.vue'
/**
* Good design limits dependencies and simplifies the interface of the abstraction layer.
* Here, we only care about the getColorOption and setColorOption methods,
* and do not concern ourselves with other methods.
*/
type PickedNode = Pick<LGraphNode, 'getColorOption' | 'setColorOption'>
const { nodes } = defineProps<{ nodes: PickedNode[] }>()
const emit = defineEmits<{ (e: 'changed'): void }>()
const { t } = useI18n()
const colorPaletteStore = useColorPaletteStore()
type NodeColorOption = {
name: string
localizedName: () => string
value: {
dark: string
light: string
ringDark: string
ringLight: string
}
}
const nodeColorEntries = Object.entries(LGraphCanvas.node_colors)
function getColorValue(color: string): NodeColorOption['value'] {
return {
dark: adjustColor(color, { lightness: 0.3 }),
light: adjustColor(color, { lightness: 0.4 }),
ringDark: adjustColor(color, { lightness: 0.5 }),
ringLight: adjustColor(color, { lightness: 0.1 })
}
}
const NO_COLOR_OPTION: NodeColorOption = {
name: 'noColor',
localizedName: () => t('color.noColor'),
value: getColorValue(LiteGraph.NODE_DEFAULT_BGCOLOR)
}
const colorOptions: NodeColorOption[] = [
NO_COLOR_OPTION,
...nodeColorEntries.map(([name, color]) => ({
name,
localizedName: () => t(`color.${name}`),
value: getColorValue(color.bgcolor)
}))
]
const isLightTheme = computed(
() => colorPaletteStore.completedActivePalette.light_theme
)
const nodeColor = computed<NodeColorOption['name'] | null>({
get() {
if (nodes.length === 0) return null
const theColorOptions = nodes.map((item) => item.getColorOption())
let colorOption: ColorOption | null | false = theColorOptions[0]
if (!theColorOptions.every((option) => option === colorOption)) {
colorOption = false
}
if (colorOption === false) return null
if (colorOption == null || (!colorOption.bgcolor && !colorOption.color))
return NO_COLOR_OPTION.name
return (
nodeColorEntries.find(
([_, color]) =>
color.bgcolor === colorOption.bgcolor &&
color.color === colorOption.color
)?.[0] ?? null
)
},
set(colorName) {
if (colorName === null) return
const canvasColorOption =
colorName === NO_COLOR_OPTION.name
? null
: LGraphCanvas.node_colors[colorName]
for (const item of nodes) {
item.setColorOption(canvasColorOption)
}
emit('changed')
}
})
</script>
<template>
<LayoutField :label="t('rightSidePanel.color')">
<div
class="bg-secondary-background border-none rounded-lg p-1 grid grid-cols-5 gap-1 justify-items-center"
>
<button
v-for="option of colorOptions"
:key="option.name"
:class="
cn(
'size-8 rounded-lg bg-transparent border-0 outline-0 ring-0 text-left flex justify-center items-center cursor-pointer',
option.name === nodeColor
? 'bg-interface-menu-component-surface-selected'
: 'hover:bg-interface-menu-component-surface-selected'
)
"
@click="nodeColor = option.name"
>
<div
v-tooltip.top="option.localizedName()"
:class="cn('size-4 rounded-full ring-2 ring-gray-500/10')"
:style="{
backgroundColor: isLightTheme
? option.value.light
: option.value.dark,
'--tw-ring-color':
option.name === nodeColor
? isLightTheme
? option.value.ringLight
: option.value.ringDark
: undefined
}"
:data-testid="option.name"
/>
</button>
</div>
</LayoutField>
</template>

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import FormSelectButton from '@/renderer/extensions/vueNodes/widgets/components/form/FormSelectButton.vue'
import LayoutField from './LayoutField.vue'
/**
* Good design limits dependencies and simplifies the interface of the abstraction layer.
* Here, we only care about the mode method,
* and do not concern ourselves with other methods.
*/
type PickedNode = Pick<LGraphNode, 'mode'>
const { nodes } = defineProps<{ nodes: PickedNode[] }>()
const emit = defineEmits<{ (e: 'changed'): void }>()
const { t } = useI18n()
const nodeState = computed({
get() {
let mode: LGraphNode['mode'] | null = null
if (nodes.length === 0) return null
// For multiple nodes, if all nodes have the same mode, return that mode, otherwise return null
if (nodes.length > 1) {
mode = nodes[0].mode
if (!nodes.every((node) => node.mode === mode)) {
mode = null
}
} else {
mode = nodes[0].mode
}
return mode
},
set(value: LGraphNode['mode']) {
nodes.forEach((node) => {
node.mode = value
})
emit('changed')
}
})
</script>
<template>
<LayoutField :label="t('rightSidePanel.nodeState')">
<FormSelectButton
v-model="nodeState"
class="w-full"
:options="[
{
label: t('rightSidePanel.normal'),
value: LGraphEventMode.ALWAYS
},
{
label: t('rightSidePanel.bypass'),
value: LGraphEventMode.BYPASS
},
{
label: t('rightSidePanel.mute'),
value: LGraphEventMode.NEVER
}
]"
/>
</LayoutField>
</template>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import FieldSwitch from './FieldSwitch.vue'
type PickedNode = Pick<LGraphNode, 'pinned' | 'pin'>
/**
* Good design limits dependencies and simplifies the interface of the abstraction layer.
* Here, we only care about the pinned and pin methods,
* and do not concern ourselves with other methods.
*/
const { nodes } = defineProps<{ nodes: PickedNode[] }>()
const emit = defineEmits<{ (e: 'changed'): void }>()
const { t } = useI18n()
// Pinned state
const isPinned = computed<boolean>({
get() {
return nodes.some((node) => node.pinned)
},
set(value) {
nodes.forEach((node) => node.pin(value))
emit('changed')
}
})
</script>
<template>
<FieldSwitch v-model="isPinned" :label="t('rightSidePanel.pinned')" />
</template>

View File

@@ -0,0 +1,205 @@
<script setup lang="ts">
import InputNumber from 'primevue/inputnumber'
import Select from 'primevue/select'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import Slider from '@/components/ui/slider/Slider.vue'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LinkRenderType } from '@/lib/litegraph/src/types/globalEnums'
import { LinkMarkerShape } from '@/lib/litegraph/src/types/globalEnums'
import { useSettingStore } from '@/platform/settings/settingStore'
import { WidgetInputBaseClass } from '@/renderer/extensions/vueNodes/widgets/components/layout'
import { useDialogService } from '@/services/dialogService'
import { cn } from '@/utils/tailwindUtil'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import FieldSwitch from './FieldSwitch.vue'
import LayoutField from './LayoutField.vue'
const { t } = useI18n()
const settingStore = useSettingStore()
const dialogService = useDialogService()
// NODES settings
const showAdvancedParameters = ref(false) // Placeholder for future implementation
const showToolbox = computed({
get: () => settingStore.get('Comfy.Canvas.SelectionToolbox'),
set: (value) => settingStore.set('Comfy.Canvas.SelectionToolbox', value)
})
const nodes2Enabled = computed({
get: () => settingStore.get('Comfy.VueNodes.Enabled'),
set: (value) => settingStore.set('Comfy.VueNodes.Enabled', value)
})
// CANVAS settings
const gridSpacing = computed({
get: () => settingStore.get('Comfy.SnapToGrid.GridSize'),
set: (value) => settingStore.set('Comfy.SnapToGrid.GridSize', value)
})
const snapToGrid = computed({
get: () => settingStore.get('pysssss.SnapToGrid'),
set: (value) => settingStore.set('pysssss.SnapToGrid', value)
})
// CONNECTION LINKS settings
const linkShape = computed({
get: () => settingStore.get('Comfy.Graph.LinkMarkers'),
set: (value) => settingStore.set('Comfy.Graph.LinkMarkers', value)
})
const linkShapeOptions = computed(() => [
{ value: LinkMarkerShape.None, label: t('g.none') },
{ value: LinkMarkerShape.Circle, label: t('shape.circle') },
{ value: LinkMarkerShape.Arrow, label: t('shape.arrow') }
])
let theOldLinkRenderMode: LinkRenderType = LiteGraph.SPLINE_LINK
const showConnectedLinks = computed({
get: () => settingStore.get('Comfy.LinkRenderMode') !== LiteGraph.HIDDEN_LINK,
set: (value) => {
let oldLinkRenderMode = settingStore.get('Comfy.LinkRenderMode')
if (oldLinkRenderMode !== LiteGraph.HIDDEN_LINK) {
theOldLinkRenderMode = oldLinkRenderMode
}
const newMode = value ? theOldLinkRenderMode : LiteGraph.HIDDEN_LINK
settingStore.set('Comfy.LinkRenderMode', newMode)
}
})
const GRID_SIZE_MIN = 1
const GRID_SIZE_MAX = 100
const GRID_SIZE_STEP = 1
function updateGridSpacingFromSlider(values?: number[]) {
if (!values?.length) return
gridSpacing.value = values[0]
}
function updateGridSpacingFromInput(value: number | null | undefined) {
if (typeof value !== 'number') return
const clampedValue = Math.min(GRID_SIZE_MAX, Math.max(GRID_SIZE_MIN, value))
gridSpacing.value = Math.round(clampedValue / GRID_SIZE_STEP) * GRID_SIZE_STEP
}
function openFullSettings() {
dialogService.showSettingsDialog()
}
</script>
<template>
<div class="flex flex-col border-t border-interface-stroke">
<!-- NODES Section -->
<PropertiesAccordionItem class="border-b border-interface-stroke">
<template #label>
{{ t('rightSidePanel.globalSettings.nodes') }}
</template>
<div class="space-y-4 px-4 py-3">
<FieldSwitch
v-model="showAdvancedParameters"
:label="t('rightSidePanel.globalSettings.showAdvanced')"
:tooltip="t('rightSidePanel.globalSettings.showAdvancedTooltip')"
/>
<FieldSwitch
v-model="showToolbox"
:label="t('rightSidePanel.globalSettings.showToolbox')"
/>
<FieldSwitch
v-model="nodes2Enabled"
:label="t('rightSidePanel.globalSettings.nodes2')"
/>
</div>
</PropertiesAccordionItem>
<!-- CANVAS Section -->
<PropertiesAccordionItem class="border-b border-interface-stroke">
<template #label>
{{ t('rightSidePanel.globalSettings.canvas') }}
</template>
<div class="space-y-4 px-4 py-3">
<LayoutField :label="t('rightSidePanel.globalSettings.gridSpacing')">
<div
:class="
cn(WidgetInputBaseClass, 'flex items-center gap-2 pl-3 pr-2')
"
>
<Slider
:model-value="[gridSpacing]"
class="flex-grow text-xs"
:min="GRID_SIZE_MIN"
:max="GRID_SIZE_MAX"
:step="GRID_SIZE_STEP"
@update:model-value="updateGridSpacingFromSlider"
/>
<InputNumber
:model-value="gridSpacing"
class="w-16"
size="small"
pt:pc-input-text:root="min-w-[4ch] bg-transparent border-none text-center truncate"
:min="GRID_SIZE_MIN"
:max="GRID_SIZE_MAX"
:step="GRID_SIZE_STEP"
:allow-empty="false"
@update:model-value="updateGridSpacingFromInput"
/>
</div>
</LayoutField>
<FieldSwitch
v-model="snapToGrid"
:label="t('rightSidePanel.globalSettings.snapNodesToGrid')"
/>
</div>
</PropertiesAccordionItem>
<!-- CONNECTION LINKS Section -->
<PropertiesAccordionItem class="border-b border-interface-stroke">
<template #label>
{{ t('rightSidePanel.globalSettings.connectionLinks') }}
</template>
<div class="space-y-4 px-4 py-3">
<LayoutField :label="t('rightSidePanel.globalSettings.linkShape')">
<Select
v-model="linkShape"
:options="linkShapeOptions"
:aria-label="t('rightSidePanel.globalSettings.linkShape')"
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
size="small"
:pt="{
option: 'text-xs',
dropdown: 'w-8',
label: cn('truncate min-w-[4ch]', $slots.default && 'mr-5'),
overlay: 'w-fit min-w-full'
}"
data-capture-wheel="true"
option-label="label"
option-value="value"
/>
</LayoutField>
<FieldSwitch
v-model="showConnectedLinks"
:label="t('rightSidePanel.globalSettings.showConnectedLinks')"
/>
</div>
</PropertiesAccordionItem>
<!-- View all settings button -->
<div
class="flex items-center justify-center p-4 border-b border-interface-stroke"
>
<Button
variant="muted-textonly"
size="sm"
class="gap-2 text-sm"
@click="openFullSettings"
>
{{ t('rightSidePanel.globalSettings.viewAllSettings') }}
<i class="icon-[lucide--settings] size-4" />
</Button>
</div>
</div>
</template>

View File

@@ -1,245 +1,58 @@
<template>
<div class="space-y-4 p-3 text-sm text-muted-foreground">
<!-- Node State -->
<div class="flex flex-col gap-2">
<span>
{{ t('rightSidePanel.nodeState') }}
</span>
<FormSelectButton
v-model="nodeState"
class="w-full"
:options="[
{
label: t('rightSidePanel.normal'),
value: LGraphEventMode.ALWAYS
},
{
label: t('rightSidePanel.bypass'),
value: LGraphEventMode.BYPASS
},
{
label: t('rightSidePanel.mute'),
value: LGraphEventMode.NEVER
}
]"
/>
</div>
<!-- Color Picker -->
<div class="flex flex-col gap-2">
<span>
{{ t('rightSidePanel.color') }}
</span>
<div
class="bg-secondary-background border-none rounded-lg p-1 grid grid-cols-5 gap-1 justify-items-center"
>
<button
v-for="option of colorOptions"
:key="option.name"
:class="
cn(
'size-8 rounded-lg bg-transparent border-0 outline-0 ring-0 text-left flex justify-center items-center cursor-pointer',
option.name === nodeColor
? 'bg-interface-menu-component-surface-selected'
: 'hover:bg-interface-menu-component-surface-selected'
)
"
@click="nodeColor = option.name"
>
<div
v-tooltip.top="option.localizedName()"
:class="cn('size-4 rounded-full ring-2 ring-gray-500/10')"
:style="{
backgroundColor: isLightTheme
? option.value.light
: option.value.dark,
'--tw-ring-color':
option.name === nodeColor
? isLightTheme
? option.value.ringLight
: option.value.ringDark
: undefined
}"
:data-testid="option.name"
/>
</button>
</div>
</div>
<!-- Pinned Toggle -->
<div class="flex items-center justify-between">
<span>
{{ t('rightSidePanel.pinned') }}
</span>
<ToggleSwitch v-model="isPinned" />
</div>
<div v-if="isOnlyHasNodes" class="p-4">
<NodeSettings :nodes="theNodes" />
</div>
<div v-else class="border-t border-interface-stroke">
<PropertiesAccordionItem
v-if="hasNodes"
class="border-b border-interface-stroke"
:label="$t('rightSidePanel.nodes')"
>
<NodeSettings :nodes="theNodes" class="p-4" />
</PropertiesAccordionItem>
<PropertiesAccordionItem
v-if="hasGroups"
class="border-b border-interface-stroke"
:label="$t('rightSidePanel.groups')"
>
<NodeSettings :nodes="theGroups" class="p-4" />
</PropertiesAccordionItem>
</div>
</template>
<script setup lang="ts">
import ToggleSwitch from 'primevue/toggleswitch'
import { computed, shallowRef, triggerRef, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
/**
* If we only need to show settings for Nodes,
* there's no need to wrap them in PropertiesAccordionItem,
* making the UI cleaner.
* But if there are multiple types of settings,
* it's better to wrap them; otherwise,
* the UI would look messy.
*/
import { computed } from 'vue'
import type { Raw } from 'vue'
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ColorOption, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import FormSelectButton from '@/renderer/extensions/vueNodes/widgets/components/form/FormSelectButton.vue'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
import { cn } from '@/utils/tailwindUtil'
import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { Positionable } from '@/lib/litegraph/src/interfaces'
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import NodeSettings from './NodeSettings.vue'
const props = defineProps<{
nodes?: LGraphNode[]
nodes: Raw<Positionable>[]
}>()
/**
* This is not random writing. It is very important.
* Otherwise, the UI cannot be updated correctly.
*/
const targetNodes = shallowRef<LGraphNode[]>([])
watchEffect(() => {
if (props.nodes) {
targetNodes.value = props.nodes
} else {
targetNodes.value = []
}
})
const { t } = useI18n()
const canvasStore = useCanvasStore()
const colorPaletteStore = useColorPaletteStore()
const isLightTheme = computed(
() => colorPaletteStore.completedActivePalette.light_theme
const theNodes = computed<LGraphNode[]>(() =>
props.nodes.filter((node) => isLGraphNode(node))
)
const nodeState = computed({
get() {
let mode: LGraphNode['mode'] | null = null
const nodes = targetNodes.value
const theGroups = computed<LGraphGroup[]>(() =>
props.nodes.filter((node) => isLGraphGroup(node))
)
if (nodes.length === 0) return null
// For multiple nodes, if all nodes have the same mode, return that mode, otherwise return null
if (nodes.length > 1) {
mode = nodes[0].mode
if (!nodes.every((node) => node.mode === mode)) {
mode = null
}
} else {
mode = nodes[0].mode
}
return mode
},
set(value: LGraphNode['mode']) {
targetNodes.value.forEach((node) => {
node.mode = value
})
/*
* This is not random writing. It is very important.
* Otherwise, the UI cannot be updated correctly.
*/
triggerRef(targetNodes)
canvasStore.canvas?.setDirty(true, true)
}
})
// Pinned state
const isPinned = computed<boolean>({
get() {
return targetNodes.value.some((node) => node.pinned)
},
set(value) {
targetNodes.value.forEach((node) => node.pin(value))
/*
* This is not random writing. It is very important.
* Otherwise, the UI cannot be updated correctly.
*/
triggerRef(targetNodes)
canvasStore.canvas?.setDirty(true, true)
}
})
type NodeColorOption = {
name: string
localizedName: () => string
value: {
dark: string
light: string
ringDark: string
ringLight: string
}
}
function getColorValue(color: string): NodeColorOption['value'] {
return {
dark: adjustColor(color, { lightness: 0.3 }),
light: adjustColor(color, { lightness: 0.4 }),
ringDark: adjustColor(color, { lightness: 0.5 }),
ringLight: adjustColor(color, { lightness: 0.1 })
}
}
const NO_COLOR_OPTION: NodeColorOption = {
name: 'noColor',
localizedName: () => t('color.noColor'),
value: getColorValue(LiteGraph.NODE_DEFAULT_BGCOLOR)
}
const nodeColorEntries = Object.entries(LGraphCanvas.node_colors)
const colorOptions: NodeColorOption[] = [
NO_COLOR_OPTION,
...nodeColorEntries.map(([name, color]) => ({
name,
localizedName: () => t(`color.${name}`),
value: getColorValue(color.bgcolor)
}))
]
const nodeColor = computed<NodeColorOption['name'] | null>({
get() {
if (targetNodes.value.length === 0) return null
const theColorOptions = targetNodes.value.map((item) =>
item.getColorOption()
)
let colorOption: ColorOption | null | false = theColorOptions[0]
if (!theColorOptions.every((option) => option === colorOption)) {
colorOption = false
}
if (colorOption === false) return null
if (colorOption == null || (!colorOption.bgcolor && !colorOption.color))
return NO_COLOR_OPTION.name
return (
nodeColorEntries.find(
([_, color]) =>
color.bgcolor === colorOption.bgcolor &&
color.color === colorOption.color
)?.[0] ?? null
)
},
set(colorName) {
if (colorName === null) return
const canvasColorOption =
colorName === NO_COLOR_OPTION.name
? null
: LGraphCanvas.node_colors[colorName]
for (const item of targetNodes.value) {
item.setColorOption(canvasColorOption)
}
/*
* This is not random writing. It is very important.
* Otherwise, the UI cannot be updated correctly.
*/
triggerRef(targetNodes)
canvasStore.canvas?.setDirty(true, true)
}
})
const hasGroups = computed(() => theGroups.value.length > 0)
const hasNodes = computed(() => theNodes.value.length > 0)
const isOnlyHasNodes = computed(() => hasNodes.value && !hasGroups.value)
</script>