mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-08 09:00:05 +00:00
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:
21
src/components/rightSidePanel/settings/FieldSwitch.vue
Normal file
21
src/components/rightSidePanel/settings/FieldSwitch.vue
Normal 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>
|
||||
38
src/components/rightSidePanel/settings/LayoutField.vue
Normal file
38
src/components/rightSidePanel/settings/LayoutField.vue
Normal 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>
|
||||
61
src/components/rightSidePanel/settings/NodeSettings.vue
Normal file
61
src/components/rightSidePanel/settings/NodeSettings.vue
Normal 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>
|
||||
144
src/components/rightSidePanel/settings/SetNodeColor.vue
Normal file
144
src/components/rightSidePanel/settings/SetNodeColor.vue
Normal 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>
|
||||
71
src/components/rightSidePanel/settings/SetNodeState.vue
Normal file
71
src/components/rightSidePanel/settings/SetNodeState.vue
Normal 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>
|
||||
35
src/components/rightSidePanel/settings/SetPinned.vue
Normal file
35
src/components/rightSidePanel/settings/SetPinned.vue
Normal 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>
|
||||
205
src/components/rightSidePanel/settings/TabGlobalSettings.vue
Normal file
205
src/components/rightSidePanel/settings/TabGlobalSettings.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user