Compare commits

...

5 Commits

Author SHA1 Message Date
bymyself
312b380c92 fix: address review comments and improve reset parameters
- Use named import { isEqual } from es-toolkit instead of default compat import
- Use WidgetValue type consistently in WidgetItem setter
- Remove unnecessary code comment in SectionWidgets
- Extract writeWidgetValue helper to reduce duplication
- Fix reset-all button visibility to show whenever widgets exist
- Add unit tests for getWidgetDefaultValue utility

Amp-Thread-ID: https://ampcode.com/threads/T-019c35dc-c58d-7198-8c31-cedd3ffeadac
2026-02-06 18:17:50 -08:00
bymyself
cdd77c4a9d fix: mock MoreButton in WidgetActions tests to render slot content directly
Amp-Thread-ID: https://ampcode.com/threads/T-019c3043-67a0-7482-8fa3-b56e7f166635
2026-02-05 16:17:46 -08:00
bymyself
cb5c2e9168 fix: address CodeRabbit review comments
- Use deep equality (es-toolkit isEqual) for default value comparison
- Use vi.hoisted() for mock isolation in tests
- Add required fields (options, y) to mock widgets
- Remove MoreButton stub, use real component
- Separate reset handler to allow undefined defaults
- Use WidgetValue type instead of inline union in WidgetItem

Amp-Thread-ID: https://ampcode.com/threads/T-019c2c25-150c-70fa-aca4-e29a230c1847
2026-02-05 15:56:12 -08:00
bymyself
00cf9a305d fix: ensure Vue reactivity is set up before resetting widget values
Call getSharedWidgetEnhancements before resetting to ensure the
widget's callback includes the Vue reactivity trigger.

Amp-Thread-ID: https://ampcode.com/threads/T-019c254e-8811-770b-ab13-1ea0e772b91f
2026-02-05 15:16:18 -08:00
bymyself
48655c94d5 feat: add reset parameters option to right side panel
- Add per-widget 'Reset to default' button in WidgetActions dropdown
- Add per-node 'Reset all parameters' button in SectionWidgets header
- Create shared getWidgetDefaultValue utility in widgetUtil.ts
- Add i18n strings for reset actions
- Add unit tests for reset functionality

Amp-Thread-ID: https://ampcode.com/threads/T-019c254e-8811-770b-ab13-1ea0e772b91f
2026-02-05 15:16:18 -08:00
7 changed files with 385 additions and 16 deletions

View File

@@ -1,8 +1,9 @@
<script setup lang="ts">
import { computed, inject, provide, ref, shallowRef, watchEffect } from 'vue'
import { computed, inject, provide, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { getSharedWidgetEnhancements } from '@/composables/graph/useGraphNodeManager'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import type {
@@ -12,6 +13,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'
@@ -51,16 +55,31 @@ const collapse = defineModel<boolean>('collapse', { default: false })
const widgetsContainer = ref<HTMLElement>()
const rootElement = ref<HTMLElement>()
const widgets = shallowRef(widgetsProp)
watchEffect(() => (widgets.value = widgetsProp))
provide(HideLayoutFieldKey, true)
const canvasStore = useCanvasStore()
const nodeDefStore = useNodeDefStore()
const { t } = useI18n()
const getNodeParentGroup = inject(GetNodeParentGroupKey, null)
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) {
getSharedWidgetEnhancements(widgetNode, widget)
writeWidgetValue(widget, defaultValue)
}
}
}
function isWidgetShownOnParents(
widgetNode: LGraphNode,
widget: IBaseWidget
@@ -84,7 +103,7 @@ function isWidgetShownOnParents(
)
}
const isEmpty = computed(() => widgets.value.length === 0)
const isEmpty = computed(() => widgetsProp.length === 0)
const displayLabel = computed(
() => label ?? (node ? node.title : t('rightSidePanel.inputs'))
@@ -94,10 +113,10 @@ const targetNode = computed<LGraphNode | null>(() => {
if (node) return node
if (isEmpty.value) return null
const firstNodeId = widgets.value[0].node.id
const allSameNode = widgets.value.every(({ node }) => node.id === firstNodeId)
const firstNodeId = widgetsProp[0].node.id
const allSameNode = widgetsProp.every(({ node }) => node.id === firstNodeId)
return allSameNode ? widgets.value[0].node : null
return allSameNode ? widgetsProp[0].node : null
})
const parentGroup = computed<LGraphGroup | null>(() => {
@@ -118,13 +137,18 @@ function handleLocateNode() {
}
}
function handleWidgetValueUpdate(
function handleWidgetValueUpdate(widget: IBaseWidget, newValue: WidgetValue) {
if (newValue === undefined) return
writeWidgetValue(widget, newValue)
}
function handleWidgetReset(
widgetNode: LGraphNode,
widget: IBaseWidget,
newValue: string | number | boolean | object
newValue: WidgetValue
) {
widget.value = newValue
widget.callback?.(newValue)
canvasStore.canvas?.setDirty(true, true)
getSharedWidgetEnhancements(widgetNode, widget)
writeWidgetValue(widget, newValue)
}
defineExpose({
@@ -157,6 +181,17 @@ defineExpose({
{{ parentGroup.title }}
</span>
</span>
<Button
v-if="!isEmpty"
variant="textonly"
size="icon-sm"
class="subbutton shrink-0 size-8 cursor-pointer text-muted-foreground hover:text-base-foreground"
:title="t('rightSidePanel.resetAllParameters')"
:aria-label="t('rightSidePanel.resetAllParameters')"
@click.stop="handleResetAllWidgets"
>
<i class="icon-[lucide--rotate-ccw] size-4" />
</Button>
<Button
v-if="canShowLocateButton"
variant="textonly"
@@ -179,7 +214,7 @@ defineExpose({
>
<TransitionGroup name="list-scale">
<WidgetItem
v-for="{ widget, node } in widgets"
v-for="{ widget, node } in widgetsProp"
:key="`${node.id}-${widget.name}-${widget.type}`"
:widget="widget"
:node="node"
@@ -189,6 +224,7 @@ defineExpose({
:parents="parents"
:is-shown-on-parents="isWidgetShownOnParents(node, widget)"
@update:widget-value="handleWidgetValueUpdate(widget, $event)"
@reset-to-default="handleWidgetReset(node, widget, $event)"
/>
</TransitionGroup>
</div>

View File

@@ -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'])
})
})

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { isEqual } from 'es-toolkit'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -14,7 +15,10 @@ import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useDialogService } from '@/services/dialogService'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { getWidgetDefaultValue } from '@/utils/widgetUtil'
import type { WidgetValue } from '@/utils/widgetUtil'
const {
widget,
@@ -28,10 +32,15 @@ const {
isShownOnParents?: boolean
}>()
const emit = defineEmits<{
resetToDefault: [value: WidgetValue]
}>()
const label = defineModel<string>('label', { required: true })
const canvasStore = useCanvasStore()
const favoritedWidgetsStore = useFavoritedWidgetsStore()
const nodeDefStore = useNodeDefStore()
const dialogService = useDialogService()
const { t } = useI18n()
@@ -43,6 +52,19 @@ const isFavorited = computed(() =>
favoritedWidgetsStore.isFavorited(favoriteNode.value, widget.name)
)
const inputSpec = computed(() =>
nodeDefStore.getInputSpecForWidget(node, widget.name)
)
const defaultValue = computed(() => getWidgetDefaultValue(inputSpec.value))
const hasDefault = computed(() => defaultValue.value !== undefined)
const isCurrentValueDefault = computed(() => {
if (!hasDefault.value) return true
return isEqual(widget.value, defaultValue.value)
})
async function handleRename() {
const newLabel = await dialogService.prompt({
title: t('g.rename'),
@@ -97,6 +119,11 @@ function handleToggleFavorite() {
favoritedWidgetsStore.toggleFavorite(favoriteNode.value, widget.name)
}
function handleResetToDefault() {
if (!hasDefault.value) return
emit('resetToDefault', defaultValue.value)
}
const buttonClasses = cn([
'border-none bg-transparent',
'w-full flex items-center gap-2 rounded px-3 py-2 text-sm',
@@ -162,6 +189,21 @@ const buttonClasses = cn([
<span>{{ t('rightSidePanel.addFavorite') }}</span>
</template>
</button>
<button
v-if="hasDefault"
:class="cn(buttonClasses, isCurrentValueDefault && 'opacity-50')"
:disabled="isCurrentValueDefault"
@click="
() => {
handleResetToDefault()
close()
}
"
>
<i class="icon-[lucide--rotate-ccw] size-4" />
<span>{{ t('rightSidePanel.resetToDefault') }}</span>
</button>
</template>
</MoreButton>
</template>

View File

@@ -20,6 +20,7 @@ import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@/utils/tailwindUtil'
import { renameWidget } from '@/utils/widgetUtil'
import type { WidgetValue } from '@/utils/widgetUtil'
import WidgetActions from './WidgetActions.vue'
@@ -42,7 +43,8 @@ const {
}>()
const emit = defineEmits<{
'update:widgetValue': [value: string | number | boolean | object]
'update:widgetValue': [value: WidgetValue]
resetToDefault: [value: WidgetValue]
}>()
const { t } = useI18n()
@@ -86,7 +88,7 @@ const widgetValue = computed({
widget.vueTrack?.()
return widget.value
},
set: (newValue: string | number | boolean | object) => {
set: (newValue: WidgetValue) => {
emit('update:widgetValue', newValue)
}
})
@@ -156,6 +158,7 @@ const displayLabel = customRef((track, trigger) => {
:node="node"
:parents="parents"
:is-shown-on-parents="isShownOnParents"
@reset-to-default="emit('resetToDefault', $event)"
/>
</div>
</div>

View File

@@ -2831,6 +2831,8 @@
"removeFavorite": "Unfavorite",
"hideInput": "Hide input",
"showInput": "Show input",
"resetToDefault": "Reset to default",
"resetAllParameters": "Reset all parameters",
"locateNode": "Locate node on canvas",
"favorites": "FAVORITED INPUTS",
"favoritesNone": "NO FAVORITED INPUTS",

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { getWidgetDefaultValue } from '@/utils/widgetUtil'
describe('getWidgetDefaultValue', () => {
it('returns undefined for undefined spec', () => {
expect(getWidgetDefaultValue(undefined)).toBeUndefined()
})
it('returns explicit default when provided', () => {
const spec = { type: 'INT', default: 42 } as InputSpec
expect(getWidgetDefaultValue(spec)).toBe(42)
})
it('returns 0 for INT type without default', () => {
const spec = { type: 'INT' } as InputSpec
expect(getWidgetDefaultValue(spec)).toBe(0)
})
it('returns 0 for FLOAT type without default', () => {
const spec = { type: 'FLOAT' } as InputSpec
expect(getWidgetDefaultValue(spec)).toBe(0)
})
it('returns false for BOOLEAN type without default', () => {
const spec = { type: 'BOOLEAN' } as InputSpec
expect(getWidgetDefaultValue(spec)).toBe(false)
})
it('returns empty string for STRING type without default', () => {
const spec = { type: 'STRING' } as InputSpec
expect(getWidgetDefaultValue(spec)).toBe('')
})
it('returns first option for array options without default', () => {
const spec = { type: 'COMBO', options: ['a', 'b', 'c'] } as InputSpec
expect(getWidgetDefaultValue(spec)).toBe('a')
})
it('returns undefined for unknown type without options', () => {
const spec = { type: 'CUSTOM' } as InputSpec
expect(getWidgetDefaultValue(spec)).toBeUndefined()
})
})

View File

@@ -2,6 +2,37 @@ import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
export type WidgetValue = boolean | number | string | object | undefined
/**
* Gets the default value for a widget based on its input spec.
* Returns the explicit default if defined, otherwise returns a sensible
* default based on the input type.
*/
export function getWidgetDefaultValue(
spec: InputSpec | undefined
): WidgetValue {
if (!spec) return undefined
if (spec.default !== undefined) return spec.default as WidgetValue
switch (spec.type) {
case 'INT':
case 'FLOAT':
return 0
case 'BOOLEAN':
return false
case 'STRING':
return ''
default:
if (Array.isArray(spec.options) && spec.options.length > 0) {
return spec.options[0] as WidgetValue
}
return undefined
}
}
/**
* Renames a widget and its corresponding input.