Skip to content

Commit d8d0dcb

Browse files
feat: add reset-to-default for widget parameters in right side panel (#8861)
## Summary Add per-widget and reset-all-parameters functionality to the right side panel, allowing users to quickly revert widget values to their defaults. ## Changes - **What**: Per-widget "Reset to default" option in the WidgetActions overflow menu, plus a "Reset all parameters" button in each SectionWidgets header. Defaults are derived from the InputSpec (explicit default, then type-specific fallbacks: 0 for INT/FLOAT, false for BOOLEAN, empty string for STRING, first option for COMBO). - **Dependencies**: Builds on #8594 (WidgetValueStore) for reactive UI updates after reset. ## Review Focus - `getWidgetDefaultValue` fallback logic in `src/utils/widgetUtil.ts` — are the type-specific defaults appropriate? - Deep equality check (`isEqual`) for disabling the reset button when the value already matches the default. - Event flow: WidgetActions emits `resetToDefault` → WidgetItem forwards → SectionWidgets handles via `writeWidgetValue` (sets value, triggers callback, marks canvas dirty). ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8861-feat-add-reset-to-default-for-widget-parameters-in-right-side-panel-3076d73d365081d1aa08d5b965a16cf4) by [Unito](https://www.unito.io) Co-authored-by: Terry Jia <terryjia88@gmail.com>
1 parent 066a1f1 commit d8d0dcb

File tree

7 files changed

+369
-9
lines changed

7 files changed

+369
-9
lines changed

src/components/rightSidePanel/parameters/SectionWidgets.vue

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import type {
1212
} from '@/lib/litegraph/src/litegraph'
1313
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
1414
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
15+
import { useNodeDefStore } from '@/stores/nodeDefStore'
16+
import { getWidgetDefaultValue } from '@/utils/widgetUtil'
17+
import type { WidgetValue } from '@/utils/widgetUtil'
1518
1619
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
1720
import { HideLayoutFieldKey } from '@/types/widgetTypes'
@@ -57,6 +60,7 @@ watchEffect(() => (widgets.value = widgetsProp))
5760
provide(HideLayoutFieldKey, true)
5861
5962
const canvasStore = useCanvasStore()
63+
const nodeDefStore = useNodeDefStore()
6064
const { t } = useI18n()
6165
6266
const getNodeParentGroup = inject(GetNodeParentGroupKey, null)
@@ -118,15 +122,31 @@ function handleLocateNode() {
118122
}
119123
}
120124
121-
function handleWidgetValueUpdate(
122-
widget: IBaseWidget,
123-
newValue: string | number | boolean | object
124-
) {
125-
widget.value = newValue
126-
widget.callback?.(newValue)
125+
function writeWidgetValue(widget: IBaseWidget, value: WidgetValue) {
126+
widget.value = value
127+
widget.callback?.(value)
127128
canvasStore.canvas?.setDirty(true, true)
128129
}
129130
131+
function handleResetAllWidgets() {
132+
for (const { widget, node: widgetNode } of widgetsProp) {
133+
const spec = nodeDefStore.getInputSpecForWidget(widgetNode, widget.name)
134+
const defaultValue = getWidgetDefaultValue(spec)
135+
if (defaultValue !== undefined) {
136+
writeWidgetValue(widget, defaultValue)
137+
}
138+
}
139+
}
140+
141+
function handleWidgetValueUpdate(widget: IBaseWidget, newValue: WidgetValue) {
142+
if (newValue === undefined) return
143+
writeWidgetValue(widget, newValue)
144+
}
145+
146+
function handleWidgetReset(widget: IBaseWidget, newValue: WidgetValue) {
147+
writeWidgetValue(widget, newValue)
148+
}
149+
130150
defineExpose({
131151
widgetsContainer,
132152
rootElement
@@ -157,6 +177,17 @@ defineExpose({
157177
{{ parentGroup.title }}
158178
</span>
159179
</span>
180+
<Button
181+
v-if="!isEmpty"
182+
variant="textonly"
183+
size="icon-sm"
184+
class="subbutton shrink-0 size-8 cursor-pointer text-muted-foreground hover:text-base-foreground"
185+
:title="t('rightSidePanel.resetAllParameters')"
186+
:aria-label="t('rightSidePanel.resetAllParameters')"
187+
@click.stop="handleResetAllWidgets"
188+
>
189+
<i class="icon-[lucide--rotate-ccw] size-4" />
190+
</Button>
160191
<Button
161192
v-if="canShowLocateButton"
162193
variant="textonly"
@@ -189,6 +220,7 @@ defineExpose({
189220
:parents="parents"
190221
:is-shown-on-parents="isWidgetShownOnParents(node, widget)"
191222
@update:widget-value="handleWidgetValueUpdate(widget, $event)"
223+
@reset-to-default="handleWidgetReset(widget, $event)"
192224
/>
193225
</TransitionGroup>
194226
</div>
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import { createTestingPinia } from '@pinia/testing'
2+
import { mount } from '@vue/test-utils'
3+
import { setActivePinia } from 'pinia'
4+
import type { Slots } from 'vue'
5+
import { h } from 'vue'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
import { createI18n } from 'vue-i18n'
8+
9+
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
10+
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
11+
12+
import WidgetActions from './WidgetActions.vue'
13+
14+
const { mockGetInputSpecForWidget } = vi.hoisted(() => ({
15+
mockGetInputSpecForWidget: vi.fn()
16+
}))
17+
18+
vi.mock('@/stores/nodeDefStore', () => ({
19+
useNodeDefStore: () => ({
20+
getInputSpecForWidget: mockGetInputSpecForWidget
21+
})
22+
}))
23+
24+
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
25+
useCanvasStore: () => ({
26+
canvas: { setDirty: vi.fn() }
27+
})
28+
}))
29+
30+
vi.mock('@/stores/workspace/favoritedWidgetsStore', () => ({
31+
useFavoritedWidgetsStore: () => ({
32+
isFavorited: vi.fn().mockReturnValue(false),
33+
toggleFavorite: vi.fn()
34+
})
35+
}))
36+
37+
vi.mock('@/services/dialogService', () => ({
38+
useDialogService: () => ({
39+
prompt: vi.fn()
40+
})
41+
}))
42+
43+
vi.mock('@/components/button/MoreButton.vue', () => ({
44+
default: (_: unknown, { slots }: { slots: Slots }) =>
45+
h('div', slots.default?.({ close: () => {} }))
46+
}))
47+
48+
const i18n = createI18n({
49+
legacy: false,
50+
locale: 'en',
51+
messages: {
52+
en: {
53+
g: {
54+
rename: 'Rename',
55+
enterNewName: 'Enter new name'
56+
},
57+
rightSidePanel: {
58+
hideInput: 'Hide input',
59+
showInput: 'Show input',
60+
addFavorite: 'Favorite',
61+
removeFavorite: 'Unfavorite',
62+
resetToDefault: 'Reset to default'
63+
}
64+
}
65+
}
66+
})
67+
68+
describe('WidgetActions', () => {
69+
beforeEach(() => {
70+
setActivePinia(createTestingPinia({ stubActions: false }))
71+
vi.resetAllMocks()
72+
mockGetInputSpecForWidget.mockReturnValue({
73+
type: 'INT',
74+
default: 42
75+
})
76+
})
77+
78+
function createMockWidget(
79+
value: number = 100,
80+
callback?: () => void
81+
): IBaseWidget {
82+
return {
83+
name: 'test_widget',
84+
type: 'number',
85+
value,
86+
label: 'Test Widget',
87+
options: {},
88+
y: 0,
89+
callback
90+
} as IBaseWidget
91+
}
92+
93+
function createMockNode(): LGraphNode {
94+
return {
95+
id: 1,
96+
type: 'TestNode'
97+
} as LGraphNode
98+
}
99+
100+
function mountWidgetActions(widget: IBaseWidget, node: LGraphNode) {
101+
return mount(WidgetActions, {
102+
props: {
103+
widget,
104+
node,
105+
label: 'Test Widget'
106+
},
107+
global: {
108+
plugins: [i18n]
109+
}
110+
})
111+
}
112+
113+
it('shows reset button when widget has default value', () => {
114+
const widget = createMockWidget()
115+
const node = createMockNode()
116+
117+
const wrapper = mountWidgetActions(widget, node)
118+
119+
const resetButton = wrapper
120+
.findAll('button')
121+
.find((b) => b.text().includes('Reset'))
122+
expect(resetButton).toBeDefined()
123+
})
124+
125+
it('emits resetToDefault with default value when reset button clicked', async () => {
126+
const widget = createMockWidget(100)
127+
const node = createMockNode()
128+
129+
const wrapper = mountWidgetActions(widget, node)
130+
131+
const resetButton = wrapper
132+
.findAll('button')
133+
.find((b) => b.text().includes('Reset'))
134+
135+
await resetButton?.trigger('click')
136+
137+
expect(wrapper.emitted('resetToDefault')).toHaveLength(1)
138+
expect(wrapper.emitted('resetToDefault')![0]).toEqual([42])
139+
})
140+
141+
it('disables reset button when value equals default', () => {
142+
const widget = createMockWidget(42)
143+
const node = createMockNode()
144+
145+
const wrapper = mountWidgetActions(widget, node)
146+
147+
const resetButton = wrapper
148+
.findAll('button')
149+
.find((b) => b.text().includes('Reset'))
150+
151+
expect(resetButton?.attributes('disabled')).toBeDefined()
152+
})
153+
154+
it('does not show reset button when no default value exists', () => {
155+
mockGetInputSpecForWidget.mockReturnValue({
156+
type: 'CUSTOM'
157+
})
158+
159+
const widget = createMockWidget(100)
160+
const node = createMockNode()
161+
162+
const wrapper = mountWidgetActions(widget, node)
163+
164+
const resetButton = wrapper
165+
.findAll('button')
166+
.find((b) => b.text().includes('Reset'))
167+
168+
expect(resetButton).toBeUndefined()
169+
})
170+
171+
it('uses fallback default for INT type without explicit default', async () => {
172+
mockGetInputSpecForWidget.mockReturnValue({
173+
type: 'INT'
174+
})
175+
176+
const widget = createMockWidget(100)
177+
const node = createMockNode()
178+
179+
const wrapper = mountWidgetActions(widget, node)
180+
181+
const resetButton = wrapper
182+
.findAll('button')
183+
.find((b) => b.text().includes('Reset'))
184+
185+
await resetButton?.trigger('click')
186+
187+
expect(wrapper.emitted('resetToDefault')![0]).toEqual([0])
188+
})
189+
190+
it('uses first option as default for combo without explicit default', async () => {
191+
mockGetInputSpecForWidget.mockReturnValue({
192+
type: 'COMBO',
193+
options: ['option1', 'option2', 'option3']
194+
})
195+
196+
const widget = createMockWidget(100)
197+
const node = createMockNode()
198+
199+
const wrapper = mountWidgetActions(widget, node)
200+
201+
const resetButton = wrapper
202+
.findAll('button')
203+
.find((b) => b.text().includes('Reset'))
204+
205+
await resetButton?.trigger('click')
206+
207+
expect(wrapper.emitted('resetToDefault')![0]).toEqual(['option1'])
208+
})
209+
})

src/components/rightSidePanel/parameters/WidgetActions.vue

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script setup lang="ts">
22
import { cn } from '@comfyorg/tailwind-utils'
3+
import { isEqual } from 'es-toolkit'
34
import { computed } from 'vue'
45
import { useI18n } from 'vue-i18n'
56
@@ -14,7 +15,10 @@ import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
1415
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
1516
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
1617
import { useDialogService } from '@/services/dialogService'
18+
import { useNodeDefStore } from '@/stores/nodeDefStore'
1719
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
20+
import { getWidgetDefaultValue } from '@/utils/widgetUtil'
21+
import type { WidgetValue } from '@/utils/widgetUtil'
1822
1923
const {
2024
widget,
@@ -28,10 +32,15 @@ const {
2832
isShownOnParents?: boolean
2933
}>()
3034
35+
const emit = defineEmits<{
36+
resetToDefault: [value: WidgetValue]
37+
}>()
38+
3139
const label = defineModel<string>('label', { required: true })
3240
3341
const canvasStore = useCanvasStore()
3442
const favoritedWidgetsStore = useFavoritedWidgetsStore()
43+
const nodeDefStore = useNodeDefStore()
3544
const dialogService = useDialogService()
3645
const { t } = useI18n()
3746
@@ -43,6 +52,19 @@ const isFavorited = computed(() =>
4352
favoritedWidgetsStore.isFavorited(favoriteNode.value, widget.name)
4453
)
4554
55+
const inputSpec = computed(() =>
56+
nodeDefStore.getInputSpecForWidget(node, widget.name)
57+
)
58+
59+
const defaultValue = computed(() => getWidgetDefaultValue(inputSpec.value))
60+
61+
const hasDefault = computed(() => defaultValue.value !== undefined)
62+
63+
const isCurrentValueDefault = computed(() => {
64+
if (!hasDefault.value) return true
65+
return isEqual(widget.value, defaultValue.value)
66+
})
67+
4668
async function handleRename() {
4769
const newLabel = await dialogService.prompt({
4870
title: t('g.rename'),
@@ -97,6 +119,11 @@ function handleToggleFavorite() {
97119
favoritedWidgetsStore.toggleFavorite(favoriteNode.value, widget.name)
98120
}
99121
122+
function handleResetToDefault() {
123+
if (!hasDefault.value) return
124+
emit('resetToDefault', defaultValue.value)
125+
}
126+
100127
const buttonClasses = cn([
101128
'border-none bg-transparent',
102129
'w-full flex items-center gap-2 rounded px-3 py-2 text-sm',
@@ -162,6 +189,21 @@ const buttonClasses = cn([
162189
<span>{{ t('rightSidePanel.addFavorite') }}</span>
163190
</template>
164191
</button>
192+
193+
<button
194+
v-if="hasDefault"
195+
:class="cn(buttonClasses, isCurrentValueDefault && 'opacity-50')"
196+
:disabled="isCurrentValueDefault"
197+
@click="
198+
() => {
199+
handleResetToDefault()
200+
close()
201+
}
202+
"
203+
>
204+
<i class="icon-[lucide--rotate-ccw] size-4" />
205+
<span>{{ t('rightSidePanel.resetToDefault') }}</span>
206+
</button>
165207
</template>
166208
</MoreButton>
167209
</template>

0 commit comments

Comments
 (0)