diff --git a/src/components/rightSidePanel/parameters/SectionWidgets.vue b/src/components/rightSidePanel/parameters/SectionWidgets.vue
index 6d87cab6d1..1ecac869f3 100644
--- a/src/components/rightSidePanel/parameters/SectionWidgets.vue
+++ b/src/components/rightSidePanel/parameters/SectionWidgets.vue
@@ -12,6 +12,9 @@ import type {
} from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
+import { useNodeDefStore } from '@/stores/nodeDefStore'
+import { getWidgetDefaultValue } from '@/utils/widgetUtil'
+import type { WidgetValue } from '@/utils/widgetUtil'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
@@ -57,6 +60,7 @@ watchEffect(() => (widgets.value = widgetsProp))
provide(HideLayoutFieldKey, true)
const canvasStore = useCanvasStore()
+const nodeDefStore = useNodeDefStore()
const { t } = useI18n()
const getNodeParentGroup = inject(GetNodeParentGroupKey, null)
@@ -118,15 +122,31 @@ function handleLocateNode() {
}
}
-function handleWidgetValueUpdate(
- widget: IBaseWidget,
- newValue: string | number | boolean | object
-) {
- widget.value = newValue
- widget.callback?.(newValue)
+function writeWidgetValue(widget: IBaseWidget, value: WidgetValue) {
+ widget.value = value
+ widget.callback?.(value)
canvasStore.canvas?.setDirty(true, true)
}
+function handleResetAllWidgets() {
+ for (const { widget, node: widgetNode } of widgetsProp) {
+ const spec = nodeDefStore.getInputSpecForWidget(widgetNode, widget.name)
+ const defaultValue = getWidgetDefaultValue(spec)
+ if (defaultValue !== undefined) {
+ writeWidgetValue(widget, defaultValue)
+ }
+ }
+}
+
+function handleWidgetValueUpdate(widget: IBaseWidget, newValue: WidgetValue) {
+ if (newValue === undefined) return
+ writeWidgetValue(widget, newValue)
+}
+
+function handleWidgetReset(widget: IBaseWidget, newValue: WidgetValue) {
+ writeWidgetValue(widget, newValue)
+}
+
defineExpose({
widgetsContainer,
rootElement
@@ -157,6 +177,17 @@ defineExpose({
{{ parentGroup.title }}
+
diff --git a/src/components/rightSidePanel/parameters/WidgetActions.test.ts b/src/components/rightSidePanel/parameters/WidgetActions.test.ts
new file mode 100644
index 0000000000..2dee71b1fd
--- /dev/null
+++ b/src/components/rightSidePanel/parameters/WidgetActions.test.ts
@@ -0,0 +1,209 @@
+import { createTestingPinia } from '@pinia/testing'
+import { mount } from '@vue/test-utils'
+import { setActivePinia } from 'pinia'
+import type { Slots } from 'vue'
+import { h } from 'vue'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { createI18n } from 'vue-i18n'
+
+import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
+import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
+
+import WidgetActions from './WidgetActions.vue'
+
+const { mockGetInputSpecForWidget } = vi.hoisted(() => ({
+ mockGetInputSpecForWidget: vi.fn()
+}))
+
+vi.mock('@/stores/nodeDefStore', () => ({
+ useNodeDefStore: () => ({
+ getInputSpecForWidget: mockGetInputSpecForWidget
+ })
+}))
+
+vi.mock('@/renderer/core/canvas/canvasStore', () => ({
+ useCanvasStore: () => ({
+ canvas: { setDirty: vi.fn() }
+ })
+}))
+
+vi.mock('@/stores/workspace/favoritedWidgetsStore', () => ({
+ useFavoritedWidgetsStore: () => ({
+ isFavorited: vi.fn().mockReturnValue(false),
+ toggleFavorite: vi.fn()
+ })
+}))
+
+vi.mock('@/services/dialogService', () => ({
+ useDialogService: () => ({
+ prompt: vi.fn()
+ })
+}))
+
+vi.mock('@/components/button/MoreButton.vue', () => ({
+ default: (_: unknown, { slots }: { slots: Slots }) =>
+ h('div', slots.default?.({ close: () => {} }))
+}))
+
+const i18n = createI18n({
+ legacy: false,
+ locale: 'en',
+ messages: {
+ en: {
+ g: {
+ rename: 'Rename',
+ enterNewName: 'Enter new name'
+ },
+ rightSidePanel: {
+ hideInput: 'Hide input',
+ showInput: 'Show input',
+ addFavorite: 'Favorite',
+ removeFavorite: 'Unfavorite',
+ resetToDefault: 'Reset to default'
+ }
+ }
+ }
+})
+
+describe('WidgetActions', () => {
+ beforeEach(() => {
+ setActivePinia(createTestingPinia({ stubActions: false }))
+ vi.resetAllMocks()
+ mockGetInputSpecForWidget.mockReturnValue({
+ type: 'INT',
+ default: 42
+ })
+ })
+
+ function createMockWidget(
+ value: number = 100,
+ callback?: () => void
+ ): IBaseWidget {
+ return {
+ name: 'test_widget',
+ type: 'number',
+ value,
+ label: 'Test Widget',
+ options: {},
+ y: 0,
+ callback
+ } as IBaseWidget
+ }
+
+ function createMockNode(): LGraphNode {
+ return {
+ id: 1,
+ type: 'TestNode'
+ } as LGraphNode
+ }
+
+ function mountWidgetActions(widget: IBaseWidget, node: LGraphNode) {
+ return mount(WidgetActions, {
+ props: {
+ widget,
+ node,
+ label: 'Test Widget'
+ },
+ global: {
+ plugins: [i18n]
+ }
+ })
+ }
+
+ it('shows reset button when widget has default value', () => {
+ const widget = createMockWidget()
+ const node = createMockNode()
+
+ const wrapper = mountWidgetActions(widget, node)
+
+ const resetButton = wrapper
+ .findAll('button')
+ .find((b) => b.text().includes('Reset'))
+ expect(resetButton).toBeDefined()
+ })
+
+ it('emits resetToDefault with default value when reset button clicked', async () => {
+ const widget = createMockWidget(100)
+ const node = createMockNode()
+
+ const wrapper = mountWidgetActions(widget, node)
+
+ const resetButton = wrapper
+ .findAll('button')
+ .find((b) => b.text().includes('Reset'))
+
+ await resetButton?.trigger('click')
+
+ expect(wrapper.emitted('resetToDefault')).toHaveLength(1)
+ expect(wrapper.emitted('resetToDefault')![0]).toEqual([42])
+ })
+
+ it('disables reset button when value equals default', () => {
+ const widget = createMockWidget(42)
+ const node = createMockNode()
+
+ const wrapper = mountWidgetActions(widget, node)
+
+ const resetButton = wrapper
+ .findAll('button')
+ .find((b) => b.text().includes('Reset'))
+
+ expect(resetButton?.attributes('disabled')).toBeDefined()
+ })
+
+ it('does not show reset button when no default value exists', () => {
+ mockGetInputSpecForWidget.mockReturnValue({
+ type: 'CUSTOM'
+ })
+
+ const widget = createMockWidget(100)
+ const node = createMockNode()
+
+ const wrapper = mountWidgetActions(widget, node)
+
+ const resetButton = wrapper
+ .findAll('button')
+ .find((b) => b.text().includes('Reset'))
+
+ expect(resetButton).toBeUndefined()
+ })
+
+ it('uses fallback default for INT type without explicit default', async () => {
+ mockGetInputSpecForWidget.mockReturnValue({
+ type: 'INT'
+ })
+
+ const widget = createMockWidget(100)
+ const node = createMockNode()
+
+ const wrapper = mountWidgetActions(widget, node)
+
+ const resetButton = wrapper
+ .findAll('button')
+ .find((b) => b.text().includes('Reset'))
+
+ await resetButton?.trigger('click')
+
+ expect(wrapper.emitted('resetToDefault')![0]).toEqual([0])
+ })
+
+ it('uses first option as default for combo without explicit default', async () => {
+ mockGetInputSpecForWidget.mockReturnValue({
+ type: 'COMBO',
+ options: ['option1', 'option2', 'option3']
+ })
+
+ const widget = createMockWidget(100)
+ const node = createMockNode()
+
+ const wrapper = mountWidgetActions(widget, node)
+
+ const resetButton = wrapper
+ .findAll('button')
+ .find((b) => b.text().includes('Reset'))
+
+ await resetButton?.trigger('click')
+
+ expect(wrapper.emitted('resetToDefault')![0]).toEqual(['option1'])
+ })
+})
diff --git a/src/components/rightSidePanel/parameters/WidgetActions.vue b/src/components/rightSidePanel/parameters/WidgetActions.vue
index e5c3cf16c9..800fb556e1 100644
--- a/src/components/rightSidePanel/parameters/WidgetActions.vue
+++ b/src/components/rightSidePanel/parameters/WidgetActions.vue
@@ -1,5 +1,6 @@