feat: add collapse/expand all toggle to right panel tabs (#9333)

## Summary
Adds a collapse/expand all toggle button to all parameter and error tabs
in the right side panel, letting users quickly collapse or expand all
accordion sections at once.

## Changes
- **New component**: `CollapseToggleButton.vue` — a reusable icon button
(list-collapse / list-tree icon) with tooltip, bound via `v-model`
- **Error tab**: Toggle collapses/expands all error groups; per-group
state is now managed through `isSectionCollapsed` /
`setSectionCollapsed` helpers
- **Nodes tab** (`TabNodes.vue`): Per-node `collapseMap`; toggle
overrides per-section state; defaults to collapsed (nodes tab default
was already collapsed)
- **Normal inputs tab** (`TabNormalInputs.vue`): Per-node `collapseMap`
+ `advancedCollapsed`; toggle covers both normal and advanced sections;
defaults to collapsed when multiple nodes selected
- **Subgraph inputs tab** (`TabSubgraphInputs.vue`): Toggle covers both
main and advanced inputs sections
- **Global parameters tab** (`TabGlobalParameters.vue`): Toggle bound to
single `SectionWidgets`
- **i18n**: Added `g.collapseAll` and `g.expandAll` keys

## Review Focus
- `isAllCollapsed` getter in each tab: reads from the same per-section
state so the toggle accurately reflects current state rather than being
independently tracked
- `TabNormalInputs`: multi-node selection default collapse behaviour is
preserved through `isSectionCollapsed` fallback logic

## Screenshots
<img width="778" height="643" alt="image"
src="https://github.com/user-attachments/assets/04d07f32-5135-47f9-b029-78ca78a996fb"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9333-feat-add-collapse-expand-all-toggle-to-right-panel-tabs-3176d73d36508123ba22d6e81983bb1b)
by [Unito](https://www.unito.io)
This commit is contained in:
jaeone94
2026-03-05 14:41:15 +09:00
committed by GitHub
parent d20cc82ef4
commit f00a65ced0
8 changed files with 247 additions and 31 deletions

View File

@@ -108,14 +108,22 @@ onMounted(() => {
onBeforeUnmount(() => {
draggableList.value?.dispose()
})
function onCollapseUpdate() {
// Rebuild draggable list after the section header is toggled
nextTick(setDraggableState)
}
</script>
<template>
<div class="px-4 pt-1 pb-4 flex gap-2 border-b border-interface-stroke">
<div
class="px-4 pt-1 pb-4 flex items-center border-b border-interface-stroke"
>
<FormSearchInput
v-model="searchQuery"
:searcher
:update-key="favoritedWidgets"
class="flex-1"
/>
</div>
<SectionWidgets
@@ -127,7 +135,7 @@ onBeforeUnmount(() => {
show-node-name
enable-empty-state
class="border-b border-interface-stroke"
@update:collapse="nextTick(setDraggableState)"
@update:collapse="onCollapseUpdate"
>
<template #empty>
<div class="text-sm text-muted-foreground px-4 text-center py-10">

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { computed, reactive, ref, shallowRef, watch } from 'vue'
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
@@ -30,6 +32,40 @@ const searchedWidgetsSectionDataList = shallowRef<NodeWidgetsListList>(
widgetsSectionDataList.value
)
const isSearching = ref(false)
const collapseMap = reactive<Record<string, boolean>>({})
watch(
() => workflowStore.activeWorkflow?.path,
() => {
for (const key of Object.keys(collapseMap)) {
delete collapseMap[key]
}
}
)
function isSectionCollapsed(nodeId: string): boolean {
// Defaults to collapsed when not explicitly set by the user
return collapseMap[nodeId] ?? true
}
function setSectionCollapsed(nodeId: string, collapsed: boolean) {
collapseMap[nodeId] = collapsed
}
const isAllCollapsed = computed({
get() {
return searchedWidgetsSectionDataList.value.every(({ node }) =>
isSectionCollapsed(String(node.id))
)
},
set(collapse: boolean) {
for (const { node } of widgetsSectionDataList.value) {
setSectionCollapsed(String(node.id), collapse)
}
}
})
async function searcher(query: string) {
const list = widgetsSectionDataList.value
const target = searchedWidgetsSectionDataList
@@ -39,11 +75,18 @@ async function searcher(query: string) {
</script>
<template>
<div class="px-4 pt-1 pb-4 flex gap-2 border-b border-interface-stroke">
<div
class="px-4 pt-1 pb-4 flex items-center border-b border-interface-stroke"
>
<FormSearchInput
v-model="searchQuery"
:searcher
:update-key="widgetsSectionDataList"
class="flex-1"
/>
<CollapseToggleButton
v-model="isAllCollapsed"
:show="!isSearching && widgetsSectionDataList.length > 1"
/>
</div>
<TransitionGroup tag="div" name="list-scale" class="relative">
@@ -58,7 +101,7 @@ async function searcher(query: string) {
:key="node.id"
:node
:widgets
:collapse="!isSearching"
:collapse="isSectionCollapsed(String(node.id)) && !isSearching"
:tooltip="
isSearching || widgets.length
? ''
@@ -66,6 +109,7 @@ async function searcher(query: string) {
"
show-locate-button
class="border-b border-interface-stroke"
@update:collapse="setSectionCollapsed(String(node.id), $event)"
/>
</TransitionGroup>
</template>

View File

@@ -1,10 +1,12 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { computed, reactive, ref, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { computedSectionDataList, searchWidgetsAndNodes } from '../shared'
@@ -17,6 +19,7 @@ const { nodes, mustShowNodeTitle } = defineProps<{
}>()
const { t } = useI18n()
const workflowStore = useWorkflowStore()
const rightSidePanelStore = useRightSidePanelStore()
const { searchQuery } = storeToRefs(rightSidePanelStore)
@@ -52,6 +55,46 @@ const searchedWidgetsSectionDataList = shallowRef<NodeWidgetsListList>(
)
const isSearching = ref(false)
const collapseMap = reactive<Record<string, boolean>>({})
const advancedCollapsed = ref(true)
watch(
() => workflowStore.activeWorkflow?.path,
() => {
for (const key of Object.keys(collapseMap)) {
delete collapseMap[key]
}
advancedCollapsed.value = true
}
)
function isSectionCollapsed(nodeId: string): boolean {
// When not explicitly set, sections are collapsed if multiple nodes are selected
return collapseMap[nodeId] ?? isMultipleNodesSelected.value
}
function setSectionCollapsed(nodeId: string, collapsed: boolean) {
collapseMap[nodeId] = collapsed
}
const isAllCollapsed = computed({
get() {
const normalAllCollapsed = searchedWidgetsSectionDataList.value.every(
({ node }) => isSectionCollapsed(String(node.id))
)
const hasAdvanced = advancedWidgetsSectionDataList.value.length > 0
return hasAdvanced
? normalAllCollapsed && advancedCollapsed.value
: normalAllCollapsed
},
set(collapse: boolean) {
for (const { node } of widgetsSectionDataList.value) {
setSectionCollapsed(String(node.id), collapse)
}
advancedCollapsed.value = collapse
}
})
async function searcher(query: string) {
const list = widgetsSectionDataList.value
const target = searchedWidgetsSectionDataList
@@ -76,11 +119,22 @@ const advancedLabel = computed(() => {
</script>
<template>
<div class="px-4 pt-1 pb-4 flex gap-2 border-b border-interface-stroke">
<div
class="px-4 pt-1 pb-4 flex items-center border-b border-interface-stroke"
>
<FormSearchInput
v-model="searchQuery"
:searcher
:update-key="widgetsSectionDataList"
class="flex-1"
/>
<CollapseToggleButton
v-model="isAllCollapsed"
:show="
!isSearching &&
widgetsSectionDataList.length + advancedWidgetsSectionDataList.length >
1
"
/>
</div>
<TransitionGroup tag="div" name="list-scale" class="relative">
@@ -100,7 +154,7 @@ const advancedLabel = computed(() => {
:node
:label
:widgets
:collapse="isMultipleNodesSelected && !isSearching"
:collapse="isSectionCollapsed(String(node.id)) && !isSearching"
:show-locate-button="isMultipleNodesSelected"
:tooltip="
isSearching || widgets.length
@@ -108,13 +162,14 @@ const advancedLabel = computed(() => {
: t('rightSidePanel.inputsNoneTooltip')
"
class="border-b border-interface-stroke"
@update:collapse="setSectionCollapsed(String(node.id), $event)"
/>
</TransitionGroup>
<template v-if="advancedWidgetsSectionDataList.length > 0 && !isSearching">
<SectionWidgets
v-for="{ widgets, node } in advancedWidgetsSectionDataList"
:key="`advanced-${node.id}`"
:collapse="true"
v-model:collapse="advancedCollapsed"
:node
:label="advancedLabel"
:widgets

View File

@@ -17,6 +17,7 @@ import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
import { DraggableList } from '@/scripts/ui/draggableList'
import { usePromotionStore } from '@/stores/promotionStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
@@ -36,6 +37,19 @@ const rightSidePanelStore = useRightSidePanelStore()
const { focusedSection, searchQuery } = storeToRefs(rightSidePanelStore)
const advancedInputsCollapsed = ref(true)
const firstSectionCollapsed = ref(false)
const isAllCollapsed = computed({
get() {
const hasAdvanced = advancedInputsWidgets.value.length > 0
return hasAdvanced
? firstSectionCollapsed.value && advancedInputsCollapsed.value
: firstSectionCollapsed.value
},
set(collapse: boolean) {
firstSectionCollapsed.value = collapse
advancedInputsCollapsed.value = collapse
}
})
const draggableList = ref<DraggableList | undefined>(undefined)
const sectionWidgetsRef = useTemplateRef('sectionWidgetsRef')
const advancedInputsSectionRef = useTemplateRef('advancedInputsSectionRef')
@@ -186,15 +200,23 @@ const label = computed(() => {
</script>
<template>
<div class="px-4 pt-1 pb-4 flex gap-2 border-b border-interface-stroke">
<div
class="px-4 pt-1 pb-4 flex items-center border-b border-interface-stroke"
>
<FormSearchInput
v-model="searchQuery"
:searcher
:update-key="widgetsList"
class="flex-1"
/>
<CollapseToggleButton
v-model="isAllCollapsed"
:show="!isSearching && advancedInputsWidgets.length > 0"
/>
</div>
<SectionWidgets
ref="sectionWidgetsRef"
:collapse="firstSectionCollapsed && !isSearching"
:node
:label
:parents
@@ -207,7 +229,12 @@ const label = computed(() => {
: t('rightSidePanel.inputsNoneTooltip')
"
class="border-b border-interface-stroke"
@update:collapse="nextTick(setDraggableState)"
@update:collapse="
(v) => {
firstSectionCollapsed = v
nextTick(setDraggableState)
}
"
>
<template #empty>
<div class="text-sm text-muted-foreground px-4 text-center pt-5 pb-15">