Cleanup: Properties Panel (#7137)

## Summary

- Code cleanup
- Copy, padding, color, alignment of components
- Subgraph Edit mode changes
- Partial fix for the Node Info location (need to do context menu still)
- Editing node title

### Still to-do

- Bi-directionality in values

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7137-WIP-Cleanup-Properties-Panel-2be6d73d3650813e9430f6bcb09dfb4d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Alexander Brown
2025-12-05 21:33:52 -08:00
committed by GitHub
parent cde49d5b64
commit f74c176423
45 changed files with 419 additions and 386 deletions

View File

@@ -1,15 +1,17 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, watchEffect } from 'vue'
import { computed, ref, toValue, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import EditableText from '@/components/common/EditableText.vue'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
@@ -27,8 +29,8 @@ const { activeTab, isEditingSubgraph } = storeToRefs(rightSidePanelStore)
const hasSelection = computed(() => selectedItems.value.length > 0)
const selectedNodes = computed(() => {
return selectedItems.value.filter(isLGraphNode) as LGraphNode[]
const selectedNodes = computed((): LGraphNode[] => {
return selectedItems.value.filter(isLGraphNode)
})
const isSubgraphNode = computed(() => {
@@ -44,25 +46,29 @@ const selectedNode = computed(() => {
const selectionCount = computed(() => selectedItems.value.length)
const panelTitle = computed(() => {
if (!hasSelection.value) return t('rightSidePanel.properties')
if (isSingleNodeSelected.value && selectedNode.value) {
return selectedNode.value.title || selectedNode.value.type || 'Node'
}
return t('rightSidePanel.multipleSelection', { count: selectionCount.value })
return t('rightSidePanel.title', { count: selectionCount.value })
})
function closePanel() {
rightSidePanelStore.closePanel()
}
const tabs = computed<{ label: () => string; value: string }[]>(() => {
const list = [
type RightSidePanelTabList = Array<{
label: () => string
value: RightSidePanelTab
}>
const tabs = computed<RightSidePanelTabList>(() => {
const list: RightSidePanelTabList = [
{
label: () => t('rightSidePanel.parameters'),
value: 'parameters'
},
{
label: () => t('rightSidePanel.settings'),
label: () => t('g.settings'),
value: 'settings'
}
]
@@ -80,19 +86,57 @@ const tabs = computed<{ label: () => string; value: string }[]>(() => {
// Use global state for activeTab and ensure it's valid
watchEffect(() => {
if (!tabs.value.some((tab) => tab.value === activeTab.value)) {
activeTab.value = tabs.value[0].value as 'parameters' | 'settings' | 'info'
if (
!tabs.value.some((tab) => tab.value === activeTab.value) &&
!(activeTab.value === 'subgraph' && isSubgraphNode.value)
) {
rightSidePanelStore.openPanel(tabs.value[0].value)
}
})
const isEditing = ref(false)
function handleTitleEdit(newTitle: string) {
isEditing.value = false
const trimmedTitle = newTitle.trim()
if (!trimmedTitle) return
const node = toValue(selectedNode)
if (!node) return
if (trimmedTitle === node.title) return
node.title = trimmedTitle
canvasStore.canvas?.setDirty(true, false)
}
function handleTitleCancel() {
isEditing.value = false
}
</script>
<template>
<div class="flex h-full w-full flex-col bg-interface-panel-surface">
<div
data-testid="properties-panel"
class="flex size-full flex-col bg-interface-panel-surface"
>
<!-- Panel Header -->
<div class="border-b border-interface-stroke pt-1">
<section class="pt-1">
<div class="flex items-center justify-between pl-4 pr-3">
<h3 class="my-3.5 text-sm font-semibold line-clamp-2">
{{ panelTitle }}
<EditableText
v-if="isSingleNodeSelected"
:model-value="panelTitle"
:is-editing="isEditing"
:input-attrs="{ 'data-testid': 'node-title-input' }"
@edit="handleTitleEdit"
@cancel="handleTitleCancel"
@dblclick="isEditing = true"
/>
<template v-else>
{{ panelTitle }}
</template>
</h3>
<div class="flex gap-2">
@@ -100,36 +144,41 @@ watchEffect(() => {
v-if="isSubgraphNode"
type="transparent"
size="sm"
class="bg-secondary-background hover:bg-secondary-background-hover"
:class="
cn(
'bg-secondary-background hover:bg-secondary-background-hover',
isEditingSubgraph
? 'bg-secondary-background-selected'
: 'bg-secondary-background'
'bg-secondary-background hover:bg-secondary-background-hover text-base-foreground',
isEditingSubgraph && 'bg-secondary-background-selected'
)
"
@click="
rightSidePanelStore.openPanel(
isEditingSubgraph ? 'parameters' : 'subgraph'
)
"
@click="isEditingSubgraph = !isEditingSubgraph"
>
<i class="icon-[lucide--settings-2]" />
</IconButton>
<IconButton
type="transparent"
size="sm"
class="bg-secondary-background hover:bg-secondary-background-hover"
class="bg-secondary-background hover:bg-secondary-background-hover text-base-foreground"
:aria-pressed="rightSidePanelStore.isOpen"
:aria-label="t('rightSidePanel.togglePanel')"
@click="closePanel"
>
<i class="icon-[lucide--panel-right]" />
<i class="icon-[lucide--panel-right] size-4" />
</IconButton>
</div>
</div>
<div
v-if="hasSelection && !(isSubgraphNode && isEditingSubgraph)"
class="px-4 pb-2 pt-1"
>
<TabList v-model="activeTab">
<nav v-if="hasSelection" class="px-4 pb-2 pt-1">
<TabList
:model-value="activeTab"
@update:model-value="
(newTab: RightSidePanelTab) => {
rightSidePanelStore.openPanel(newTab)
}
"
>
<Tab
v-for="tab in tabs"
:key="tab.value"
@@ -139,23 +188,21 @@ watchEffect(() => {
{{ tab.label() }}
</Tab>
</TabList>
</div>
</div>
</nav>
</section>
<!-- Panel Content -->
<div class="scrollbar-thin flex-1 overflow-y-auto">
<div
v-if="!hasSelection"
class="flex size-full p-4 items-start justify-start text-sm text-muted-foreground"
>
{{ $t('rightSidePanel.noSelection') }}
</div>
<SubgraphEditor
v-if="isSubgraphNode && isEditingSubgraph"
v-else-if="isSubgraphNode && isEditingSubgraph"
:node="selectedNode"
/>
<div
v-else-if="!hasSelection"
class="flex h-full items-center justify-center text-center"
>
<div class="px-4 text-sm text-base-foreground-muted">
{{ $t('rightSidePanel.noSelection') }}
</div>
</div>
<template v-else>
<TabParameters
v-if="activeTab === 'parameters'"

View File

@@ -1,15 +1,16 @@
<script setup lang="ts">
import { computed, watch } from 'vue'
import { whenever } from '@vueuse/core'
import { computed } from 'vue'
import NodeHelpContent from '@/components/node/NodeHelpContent.vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
const props = defineProps<{
const { nodes } = defineProps<{
nodes: LGraphNode[]
}>()
const node = computed(() => props.nodes[0])
const node = computed(() => nodes[0])
const nodeDefStore = useNodeDefStore()
const nodeHelpStore = useNodeHelpStore()
@@ -19,19 +20,17 @@ const nodeInfo = computed(() => {
})
// Open node help when the selected node changes
watch(
whenever(
nodeInfo,
(info) => {
if (info) {
nodeHelpStore.openHelp(info)
}
nodeHelpStore.openHelp(info)
},
{ immediate: true }
)
</script>
<template>
<div v-if="nodeInfo" class="rounded-lg bg-interface-surface p-3">
<div v-if="nodeInfo" class="p-3">
<NodeHelpContent :node="nodeInfo" />
</div>
</template>

View File

@@ -0,0 +1,54 @@
<script lang="ts" setup>
import { cn } from '@/utils/tailwindUtil'
defineProps<{
isEmpty?: boolean
}>()
const isCollapse = defineModel<boolean>('collapse', { default: false })
</script>
<template>
<div class="flex flex-col bg-interface-panel-surface">
<div
class="sticky top-0 z-10 flex items-center justify-between backdrop-blur-xl bg-inherit"
>
<button
v-tooltip="
isEmpty
? {
value: $t('rightSidePanel.inputsNoneTooltip'),
showDelay: 1_000
}
: undefined
"
type="button"
:class="
cn(
'group min-h-12 bg-transparent border-0 outline-0 ring-0 w-full text-left flex items-center justify-between pl-4 pr-3',
!isEmpty && 'cursor-pointer'
)
"
:disabled="isEmpty"
@click="isCollapse = !isCollapse"
>
<span class="text-sm font-semibold line-clamp-2">
<slot name="label" />
</span>
<i
v-if="!isEmpty"
:class="
cn(
'text-muted-foreground group-hover:text-base-foreground group-focus:text-base-foreground icon-[lucide--chevron-up] size-4 transition-all',
isCollapse && '-rotate-180'
)
"
/>
</button>
</div>
<div v-if="!isCollapse && !isEmpty" class="pb-4">
<slot />
</div>
</div>
</template>

View File

@@ -1,51 +0,0 @@
<script lang="ts" setup>
import { watch } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<{
label?: string
defaultCollapse?: boolean
}>()
const isCollapse = defineModel<boolean>('collapse', { default: false })
if (props.defaultCollapse) {
isCollapse.value = true
}
watch(
() => props.defaultCollapse,
(value) => (isCollapse.value = value)
)
</script>
<template>
<div class="flex flex-col">
<div
class="sticky top-0 z-10 flex items-center justify-between backdrop-blur-xl min-h-12"
>
<button
class="group min-h-12 bg-transparent border-0 outline-0 ring-0 w-full text-left flex items-center justify-between pl-4 pr-3 cursor-pointer"
@click="isCollapse = !isCollapse"
>
<span class="text-sm font-semibold line-clamp-2">
<slot name="label">
{{ props.label ?? '' }}
</slot>
</span>
<i
:class="
cn(
'icon-[lucide--chevron-down] size-5 min-w-5 transition-all',
isCollapse && 'rotate-90'
)
"
class="relative top-px text-xs leading-none text-node-component-header-icon group-hover:text-base-foreground"
/>
</button>
</div>
<div v-if="!isCollapse" class="pb-4">
<slot />
</div>
</div>
</template>

View File

@@ -1,39 +1,31 @@
<script setup lang="ts">
import { refDebounced } from '@vueuse/core'
import { ref, toRef, watch } from 'vue'
import { ref, toRef, toValue, watch } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const props = withDefaults(
defineProps<{
searcher?: (
query: string,
onCleanup: (cleanupFn: () => void) => void
) => Promise<void>
updateKey?: (() => unknown) | unknown
}>(),
{
searcher: async () => {}
}
)
const { searcher = async () => {}, updateKey } = defineProps<{
searcher?: (
query: string,
onCleanup: (cleanupFn: () => void) => void
) => Promise<void>
updateKey?: MaybeRefOrGetter<unknown>
}>()
const searchQuery = defineModel<string>({ default: '' })
const isQuerying = ref(false)
const debouncedSearchQuery = refDebounced(searchQuery, 700, {
maxWait: 700
const debouncedSearchQuery = refDebounced(searchQuery, 100, {
maxWait: 100
})
watch(searchQuery, (value) => {
isQuerying.value = value !== debouncedSearchQuery.value
})
const updateKey =
typeof props.updateKey === 'function'
? props.updateKey
: toRef(props, 'updateKey')
const updateKeyRef = toRef(() => toValue(updateKey))
watch(
[debouncedSearchQuery, updateKey],
[debouncedSearchQuery, updateKeyRef],
(_, __, onCleanup) => {
let isCleanup = false
let cleanupFn: undefined | (() => void)
@@ -42,8 +34,10 @@ watch(
cleanupFn?.()
})
void props
.searcher(debouncedSearchQuery.value, (cb) => (cleanupFn = cb))
void searcher(debouncedSearchQuery.value, (cb) => (cleanupFn = cb))
.catch((error) => {
console.error('[SidePanelSearch] searcher failed', error)
})
.finally(() => {
if (!isCleanup) isQuerying.value = false
})
@@ -56,24 +50,28 @@ watch(
<label
:class="
cn(
'h-8 bg-zinc-500/20 rounded-lg outline outline-offset-[-1px] outline-node-component-border transition-all duration-150',
'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'
'mt-1 py-1.5 bg-secondary-background rounded-lg transition-all duration-150',
'flex-1 flex gap-2 px-2 items-center',
'text-base-foreground border-0',
'focus-within:ring focus-within:ring-component-node-widget-background-highlighted/80'
)
"
>
<i
v-if="isQuerying"
class="mr-2 icon-[lucide--loader-circle] size-4 animate-spin"
:class="
cn(
'size-4 text-muted-foreground',
isQuerying
? 'icon-[lucide--loader-circle] animate-spin'
: 'icon-[lucide--search]'
)
"
/>
<i v-else class="mr-2 icon-[lucide--search] size-4" />
<input
v-model="searchQuery"
type="text"
class="bg-transparent border-0 outline-0 ring-0 text-left"
:placeholder="$t('g.search')"
class="bg-transparent border-0 outline-0 ring-0 h-5"
:placeholder="$t('g.searchPlaceholder')"
/>
</label>
</template>

View File

@@ -1,15 +1,20 @@
<script setup lang="ts">
import { provide } from 'vue'
import { computed, provide } from 'vue'
import { useI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
import { getComponent } from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import {
getComponent,
shouldExpand
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { cn } from '@/utils/tailwindUtil'
import RightPanelSection from '../layout/RightPanelSection.vue'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
defineProps<{
const { label, widgets } = defineProps<{
label?: string
widgets: { widget: IBaseWidget; node: LGraphNode }[]
}>()
@@ -17,6 +22,7 @@ defineProps<{
provide('hideLayoutField', true)
const canvasStore = useCanvasStore()
const { t } = useI18n()
function getWidgetComponent(widget: IBaseWidget) {
const component = getComponent(widget.type, widget.name)
@@ -31,17 +37,27 @@ function onWidgetValueChange(
widget.callback?.(value)
canvasStore.canvas?.setDirty(true, true)
}
const isEmpty = computed(() => widgets.length === 0)
const displayLabel = computed(
() =>
label ??
(isEmpty.value
? t('rightSidePanel.inputsNone')
: t('rightSidePanel.inputs'))
)
</script>
<template>
<RightPanelSection>
<PropertiesAccordionItem :is-empty>
<template #label>
<slot name="label">
{{ label ?? $t('rightSidePanel.inputs') }}
{{ displayLabel }}
</slot>
</template>
<div class="space-y-4 rounded-lg bg-interface-surface px-4">
<div v-if="!isEmpty" class="space-y-4 rounded-lg bg-interface-surface px-4">
<div
v-for="({ widget, node }, index) in widgets"
:key="`widget-${index}-${widget.name}`"
@@ -58,7 +74,7 @@ function onWidgetValueChange(
:model-value="widget.value"
:node-id="String(node.id)"
:node-type="node.type"
class="col-span-1"
:class="cn('col-span-1', shouldExpand(widget.type) && 'min-h-36')"
@update:model-value="
(value: string | number | boolean | object) =>
onWidgetValueChange(widget, value)
@@ -66,5 +82,5 @@ function onWidgetValueChange(
/>
</div>
</div>
</RightPanelSection>
</PropertiesAccordionItem>
</template>

View File

@@ -7,35 +7,30 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import SidePanelSearch from '../layout/SidePanelSearch.vue'
import SectionWidgets from './SectionWidgets.vue'
const props = defineProps<{
const { nodes } = defineProps<{
nodes: LGraphNode[]
}>()
const widgetsSectionDataList = computed(() => {
const list: {
widgets: { node: LGraphNode; widget: IBaseWidget }[]
node: LGraphNode
}[] = []
for (const node of props.nodes) {
const shownWidgets: IBaseWidget[] = []
for (const widget of node.widgets ?? []) {
if (widget.options?.canvasOnly || widget.options?.hidden) continue
shownWidgets.push(widget)
}
list.push({
widgets: shownWidgets?.map((widget) => ({ node, widget })) ?? [],
type NodeWidgetsList = Array<{ node: LGraphNode; widget: IBaseWidget }>
type NodeWidgetsListList = Array<{
node: LGraphNode
widgets: NodeWidgetsList
}>
const widgetsSectionDataList = computed((): NodeWidgetsListList => {
return nodes.map((node) => {
const { widgets = [] } = node
const shownWidgets = widgets
.filter((w) => !(w.options?.canvasOnly || w.options?.hidden))
.map((widget) => ({ node, widget }))
return {
widgets: shownWidgets,
node
})
}
return list
}
})
})
const searchedWidgetsSectionDataList = shallowRef<
{
widgets: { node: LGraphNode; widget: IBaseWidget }[]
node: LGraphNode
}[]
>([])
const searchedWidgetsSectionDataList = shallowRef<NodeWidgetsListList>([])
/**
* Searches widgets in all selected nodes and returns search results.
@@ -72,7 +67,7 @@ async function searcher(query: string) {
</script>
<template>
<div class="p-4 flex gap-2 border-b border-interface-stroke">
<div class="px-4 pb-4 flex gap-2 border-b border-interface-stroke">
<SidePanelSearch :searcher :update-key="widgetsSectionDataList" />
</div>
<SectionWidgets

View File

@@ -1,8 +1,8 @@
<template>
<div class="space-y-4 rounded-lg bg-interface-surface p-3">
<div class="space-y-4 p-3 text-sm text-muted-foreground">
<!-- Node State -->
<div class="flex flex-col gap-2">
<span class="text-sm text-text-secondary">
<span>
{{ t('rightSidePanel.nodeState') }}
</span>
<FormSelectButton
@@ -27,11 +27,11 @@
<!-- Color Picker -->
<div class="flex flex-col gap-2">
<span class="text-sm text-text-secondary">
<span>
{{ t('rightSidePanel.color') }}
</span>
<div
class="bg-component-node-widget-background text-component-node-foreground border-none rounded-lg p-1 grid grid-cols-5 gap-1 justify-items-center"
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"
@@ -39,12 +39,9 @@
:class="
cn(
'size-8 rounded-lg bg-transparent border-0 outline-0 ring-0 text-left flex justify-center items-center cursor-pointer',
{
'bg-interface-menu-component-surface-selected':
option.name === nodeColor,
'hover:bg-interface-menu-component-surface-selected':
option.name !== nodeColor
}
option.name === nodeColor
? 'bg-interface-menu-component-surface-selected'
: 'hover:bg-interface-menu-component-surface-selected'
)
"
@click="nodeColor = option.name"
@@ -71,7 +68,7 @@
<!-- Pinned Toggle -->
<div class="flex items-center justify-between">
<span class="text-sm text-text-secondary">
<span>
{{ t('rightSidePanel.pinned') }}
</span>
<ToggleSwitch v-model="isPinned" />
@@ -81,7 +78,7 @@
<script setup lang="ts">
import ToggleSwitch from 'primevue/toggleswitch'
import { computed, shallowRef, triggerRef, watchEffect } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
@@ -93,8 +90,7 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<{
node?: LGraphNode
const { nodes = [] } = defineProps<{
nodes?: LGraphNode[]
}>()
@@ -106,36 +102,25 @@ const isLightTheme = computed(
() => colorPaletteStore.completedActivePalette.light_theme
)
const targetNodes = shallowRef<LGraphNode[]>([])
watchEffect(() => {
if (props.node) {
targetNodes.value = [props.node]
} else {
targetNodes.value = props.nodes || []
}
})
const nodeState = computed({
get() {
let mode: LGraphNode['mode'] | null = null
get(): LGraphNode['mode'] | null {
if (!nodes.length) return null
if (nodes.length === 1) {
return nodes[0].mode
}
// For multiple nodes, if all nodes have the same mode, return that mode, otherwise return null
if (targetNodes.value.length > 1) {
mode = targetNodes.value[0].mode
if (!targetNodes.value.every((node) => node.mode === mode)) {
mode = null
}
} else {
mode = targetNodes.value[0].mode
const mode: LGraphNode['mode'] = nodes[0].mode
if (!nodes.every((node) => node.mode === mode)) {
return null
}
return mode
},
set(value: LGraphNode['mode']) {
targetNodes.value.forEach((node) => {
nodes.forEach((node) => {
node.mode = value
})
triggerRef(targetNodes)
canvasStore.canvas?.setDirty(true, true)
}
})
@@ -143,11 +128,10 @@ const nodeState = computed({
// Pinned state
const isPinned = computed<boolean>({
get() {
return targetNodes.value.some((node) => node.pinned)
return nodes.some((node) => node.pinned)
},
set(value) {
targetNodes.value.forEach((node) => node.pin(value))
triggerRef(targetNodes)
nodes.forEach((node) => node.pin(value))
canvasStore.canvas?.setDirty(true, true)
}
})
@@ -191,10 +175,8 @@ const colorOptions: NodeColorOption[] = [
const nodeColor = computed<NodeColorOption['name'] | null>({
get() {
if (targetNodes.value.length === 0) return null
const theColorOptions = targetNodes.value.map((item) =>
item.getColorOption()
)
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)) {
@@ -220,10 +202,9 @@ const nodeColor = computed<NodeColorOption['name'] | null>({
? null
: LGraphCanvas.node_colors[colorName]
for (const item of targetNodes.value) {
for (const item of nodes) {
item.setColorOption(canvasColorOption)
}
triggerRef(targetNodes)
canvasStore.canvas?.setDirty(true, true)
}
})