feat: update NumberControlPopover with semantic design tokens

This commit is contained in:
bymyself
2025-11-13 12:28:26 -08:00
parent 04bd165acb
commit 6acc7b07ca
15 changed files with 176 additions and 149 deletions

View File

@@ -17,8 +17,8 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isDOMWidget } from '@/scripts/domWidget'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { WidgetValue } from '@/types/simplifiedWidget'
import { IS_CONTROL_WIDGET } from '@/scripts/widgets'
<<<<<<< HEAD
import type {
LGraph,
LGraphBadge,
@@ -33,10 +33,11 @@ export interface WidgetSlotMetadata {
index: number
linked: boolean
}
=======
import type { LGraph, LGraphNode } from '../../lib/litegraph/src/litegraph'
import type { IBaseWidget } from '../../lib/litegraph/src/types/widgets'
>>>>>>> 06c19dad7 (handle legacy step value)
type NumericWidgetOptions = Record<string, unknown> & {
step?: number
step2?: number
}
export interface SafeWidgetData {
name: string
@@ -50,11 +51,6 @@ export interface SafeWidgetData {
isDOMWidget?: boolean
}
type NumericWidgetOptions = Record<string, unknown> & {
step?: number
step2?: number
}
export interface VueNodeData {
id: string
title: string
@@ -131,16 +127,12 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Extract safe data from LiteGraph node for Vue consumption
function extractVueNodeData(node: LGraphNode): VueNodeData {
type NumericWidgetOptions = Record<string, unknown> & {
step?: number
step2?: number
}
// Determine subgraph ID - null for root graph, string for subgraphs
const subgraphId =
node.graph && 'id' in node.graph && node.graph !== node.graph.rootGraph
? String(node.graph.id)
: null
// Extract safe widget data
const cloneWidgetOptions = (widget: IBaseWidget) => {
const options = widget.options
? ({ ...widget.options } as NumericWidgetOptions)
@@ -160,10 +152,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
return options
}
// Extract safe widget data
const slotMetadata = new Map<string, WidgetSlotMetadata>()
<<<<<<< HEAD
const reactiveWidgets = shallowReactive<IBaseWidget[]>(node.widgets ?? [])
Object.defineProperty(node, 'widgets', {
get() {
@@ -171,58 +161,11 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
},
set(v) {
reactiveWidgets.splice(0, reactiveWidgets.length, ...v)
=======
// For combo widgets, if value is undefined, use the first option as default
if (
value === undefined &&
widget.type === 'combo' &&
widget.options?.values &&
Array.isArray(widget.options.values) &&
widget.options.values.length > 0
) {
value = widget.options.values[0]
}
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
return {
name: widget.name,
type: widget.type,
value,
label: widget.label,
options: cloneWidgetOptions(widget),
callback: widget.callback,
spec
}
} catch (error) {
return {
name: widget.name || 'unknown',
type: widget.type || 'text',
value: undefined
}
>>>>>>> 06c19dad7 (handle legacy step value)
}
})
const cloneWidgetOptions = (widget: IBaseWidget) => {
const options = widget.options
? ({ ...widget.options } as NumericWidgetOptions)
: undefined
if (
options &&
(widget.type === 'number' || widget.type === 'slider') &&
options.step2 === undefined &&
typeof options.step === 'number'
) {
const baseStep = Number.isFinite(options.step)
? (options.step as number)
: 10
const legacyStep = baseStep === 0 ? 10 : baseStep
options.step2 = legacyStep * 0.1
}
return options
}
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
const widgets: SafeWidgetData[] = []
node.inputs?.forEach((input, index) => {
if (!input?.widget?.name) return
slotMetadata.set(input.widget.name, {
@@ -230,45 +173,54 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
linked: input.link != null
})
})
return (
node.widgets?.map((widget) => {
try {
// TODO: Use widget.getReactiveData() once TypeScript types are updated
let value = widget.value
// For combo widgets, if value is undefined, use the first option as default
if (
value === undefined &&
widget.type === 'combo' &&
widget.options?.values &&
Array.isArray(widget.options.values) &&
widget.options.values.length > 0
) {
value = widget.options.values[0]
}
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
const slotInfo = slotMetadata.get(widget.name)
node.widgets?.forEach((widget) => {
if (
(widget as IBaseWidget & { [IS_CONTROL_WIDGET]?: boolean })[
IS_CONTROL_WIDGET
]
) {
return
}
return {
name: widget.name,
type: widget.type,
value: value,
label: widget.label,
options: cloneWidgetOptions(widget),
callback: widget.callback,
spec,
slotMetadata: slotInfo,
isDOMWidget: isDOMWidget(widget)
}
} catch (error) {
return {
name: widget.name || 'unknown',
type: widget.type || 'text',
value: undefined
}
try {
// TODO: Use widget.getReactiveData() once TypeScript types are updated
let value = widget.value
// For combo widgets, if value is undefined, use the first option as default
if (
value === undefined &&
widget.type === 'combo' &&
widget.options?.values &&
Array.isArray(widget.options.values) &&
widget.options.values.length > 0
) {
value = widget.options.values[0]
}
}) ?? []
)
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
const slotInfo = slotMetadata.get(widget.name)
widgets.push({
name: widget.name,
type: widget.type,
value,
label: widget.label,
options: cloneWidgetOptions(widget),
callback: widget.callback,
spec,
slotMetadata: slotInfo,
isDOMWidget: isDOMWidget(widget)
})
} catch (error) {
widgets.push({
name: widget.name || 'unknown',
type: widget.type || 'text',
value: undefined
})
}
})
return widgets
})
const nodeType =

View File

@@ -87,11 +87,16 @@ const handleEditSettings = () => {
</script>
<template>
<Popover ref="popover">
<div class="w-105 p-4 space-y-4">
<p class="text-sm text-slate-100">
<Popover
ref="popover"
class="bg-interface-panel-surface border border-interface-stroke rounded-lg"
>
<!-- Responsive width with proper constraints -->
<div class="w-113 max-w-md p-4 space-y-4">
<!-- Header text with semantic tokens -->
<div class="text-sm text-muted-foreground leading-tight">
{{ $t('widgets.numberControl.controlHeaderBefore') }}
<span class="text-white">
<span class="text-base-foreground font-medium">
{{
widgetControlMode === 'before'
? $t('widgets.numberControl.controlHeaderBefore2')
@@ -99,25 +104,38 @@ const handleEditSettings = () => {
}}
</span>
{{ $t('widgets.numberControl.controlHeaderEnd') }}
</p>
</div>
<!-- Control options with proper spacing -->
<div class="space-y-2">
<div
v-for="option in controlOptions"
:key="option.mode"
class="flex items-center justify-between p-2 rounded"
class="flex items-center justify-between py-2 gap-7"
>
<div class="flex gap-3 flex-1">
<div class="flex items-center gap-2 flex-1 min-w-0">
<!-- Icon container with semantic background -->
<div
class="w-8 h-8 bg-charcoal-400 rounded-lg flex items-center justify-center flex-shrink-0"
class="flex items-center justify-center w-8 h-8 rounded-lg flex-shrink-0 bg-secondary-background border border-border-subtle"
>
<i v-if="option.icon" :class="`${option.icon} text-sm`" />
<span v-if="option.text" class="text-xs">
<i
v-if="option.icon"
:class="option.icon"
class="text-base text-base-foreground"
/>
<span
v-if="option.text"
class="text-xs font-normal text-base-foreground"
>
{{ option.text }}
</span>
</div>
<div class="min-w-0 flex-1">
<div class="text-sm font-normal">
<!-- Text content with proper semantic colors -->
<div class="flex flex-col gap-0.5 min-w-0 flex-1">
<div
class="text-sm font-normal text-base-foreground leading-tight"
>
<span v-if="option.mode === NumberControlMode.LINK_TO_GLOBAL">
{{ $t('widgets.numberControl.linkToGlobal') }}
<em>{{ $t('widgets.numberControl.linkToGlobalSeed') }}</em>
@@ -126,11 +144,15 @@ const handleEditSettings = () => {
{{ $t(`widgets.numberControl.${option.title}`) }}
</span>
</div>
<div class="text-sm font-normal text-slate-100">
<div
class="text-sm font-normal text-muted-foreground leading-tight"
>
{{ $t(`widgets.numberControl.${option.description}`) }}
</div>
</div>
</div>
<!-- Toggle switch with proper sizing -->
<ToggleSwitch
:model-value="isActive(option.mode)"
class="flex-shrink-0"
@@ -139,16 +161,20 @@ const handleEditSettings = () => {
</div>
</div>
<hr class="border-charcoal-400 border-1" />
<!-- Divider using semantic border -->
<div class="border-t border-border-subtle"></div>
<!-- Settings button with semantic styling -->
<Button
severity="secondary"
size="small"
class="w-full"
class="w-full bg-secondary-background hover:bg-secondary-background-hover border-0 rounded-lg p-2 text-sm"
@click="handleEditSettings"
>
<i class="pi pi-cog mr-2 text-xs" />
{{ $t('widgets.numberControl.editSettings') }}
<div class="flex items-center justify-center gap-1">
<i class="pi pi-cog text-xs text-muted-foreground" />
<span class="font-normal text-base-foreground">{{
$t('widgets.numberControl.editSettings')
}}</span>
</div>
</Button>
</div>
</Popover>

View File

@@ -63,7 +63,7 @@ const togglePopover = (event: Event) => {
<Button
variant="link"
size="small"
class="absolute right-12 top-1/2 -translate-y-1/2 h-4 w-7 p-0 bg-blue-100/30 rounded-xl"
class="absolute top-1/2 right-12 h-4 w-7 -translate-y-1/2 rounded-xl bg-blue-100/30 p-0"
@click="togglePopover"
>
<i :class="`${controlButtonIcon} text-blue-100 text-xs`" />

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { type Ref, computed, defineAsyncComponent, ref } from 'vue'
import { computed, defineAsyncComponent, ref } from 'vue'
import type { Ref } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
@@ -89,7 +90,7 @@ const setControlMode = (mode: NumberControlMode) => {
<Button
variant="link"
size="small"
class="absolute right-12 top-1/2 -translate-y-1/2 h-4 w-7 p-0 bg-blue-100/30 rounded-xl"
class="absolute top-1/2 right-12 h-4 w-7 -translate-y-1/2 rounded-xl bg-blue-100/30 p-0"
@click="togglePopover"
>
<i :class="`${controlButtonIcon} text-blue-100 text-xs`" />

View File

@@ -18,10 +18,8 @@
import { computed } from 'vue'
import type { ResultItemType } from '@/schemas/apiSchema'
import {
type ComboInputSpec,
isComboInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import type { AssetKind } from '@/types/widgetTypes'

View File

@@ -150,6 +150,10 @@ const outputItems = computed<DropdownItem[]>(() => {
}))
})
const allItems = computed<DropdownItem[]>(() => {
return [...inputItems.value, ...outputItems.value]
})
const dropdownItems = computed<DropdownItem[]>(() => {
if (props.isAssetMode) {
return allItems.value

View File

@@ -1,4 +1,5 @@
import { type ComputedRef, type Ref, onMounted, onUnmounted, ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import type { ComputedRef, Ref } from 'vue'
import { numberControlRegistry } from '../services/NumberControlRegistry'
import { NumberControlMode } from './useStepperControl'

View File

@@ -13,16 +13,16 @@ import {
import { assetService } from '@/platform/assets/services/assetService'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type {
ComboInputSpec,
InputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import type { BaseDOMWidget } from '@/scripts/domWidget'
import { addValueControlWidgets } from '@/scripts/widgets'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { addValueControlWidget, addValueControlWidgets } from '@/scripts/widgets'
import { useAssetsStore } from '@/stores/assetsStore'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
@@ -69,6 +69,16 @@ const addMultiSelectWidget = (
addWidget(node, widget as BaseDOMWidget<object | string>)
// TODO: Add remote support to multi-select widget
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3003
if (inputSpec.control_after_generate) {
widget.linkedWidgets = addValueControlWidgets(
node,
widget,
undefined,
undefined,
transformInputSpecV2ToV1(inputSpec)
)
}
return widget
}

View File

@@ -1,4 +1,5 @@
import { type Ref, computed } from 'vue'
import { computed } from 'vue'
import type { Ref } from 'vue'
import { NumberControlMode } from './useStepperControl'

View File

@@ -6,6 +6,8 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { isFloatInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { addValueControlWidget } from '@/scripts/widgets'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
function onFloatValueChange(this: INumericWidget, v: number) {
const round = this.options.round
@@ -55,7 +57,7 @@ export const useFloatWidget = () => {
/** Assertion {@link inputSpec.default} */
const defaultValue = (inputSpec.default as number | undefined) ?? 0
return node.addWidget(
const widget = node.addWidget(
widgetType,
inputSpec.name,
defaultValue,
@@ -73,6 +75,20 @@ export const useFloatWidget = () => {
precision
}
)
if (inputSpec.control_after_generate) {
const controlWidget = addValueControlWidget(
node,
widget,
'randomize',
undefined,
undefined,
transformInputSpecV2ToV1(inputSpec)
)
widget.linkedWidgets = [controlWidget]
}
return widget
}
return widgetConstructor

View File

@@ -1,11 +1,11 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
import { useSettingStore } from '@/platform/settings/settingStore'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import { isIntInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { addValueControlWidget } from '@/scripts/widgets'
import { isIntInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { addValueControlWidget } from '@/scripts/widgets'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
function onValueChange(this: INumericWidget, v: number) {
// For integers, always round to the nearest step
@@ -69,14 +69,10 @@ export const useIntWidget = () => {
const controlAfterGenerate =
inputSpec.control_after_generate ??
/**
* Compatibility with legacy node convention. Int input with name
* 'seed' or 'noise_seed' get automatically added a control widget.
*/
['seed', 'noise_seed'].includes(inputSpec.name)
if (controlAfterGenerate) {
const seedControl = addValueControlWidget(
const controlWidget = addValueControlWidget(
node,
widget,
'randomize',
@@ -84,7 +80,7 @@ export const useIntWidget = () => {
undefined,
transformInputSpecV2ToV1(inputSpec)
)
widget.linkedWidgets = [seedControl]
widget.linkedWidgets = [controlWidget]
}
return widget

View File

@@ -1,4 +1,5 @@
import { type Ref, computed } from 'vue'
import { computed } from 'vue'
import type { Ref } from 'vue'
interface NumberWidgetOptions {
step2?: number

View File

@@ -1,4 +1,5 @@
import { type Ref, onMounted, onUnmounted, ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import type { Ref } from 'vue'
import { useGlobalSeedStore } from '@/stores/globalSeedStore'

View File

@@ -72,7 +72,11 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
],
[
'multiselect',
{ component: WidgetMultiSelect, aliases: ['MULTISELECT'], essential: false }
{
component: WidgetMultiSelect,
aliases: ['MULTISELECT'],
essential: false
}
],
[
'selectbutton',
@@ -113,7 +117,11 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
],
[
'treeselect',
{ component: WidgetTreeSelect, aliases: ['TREESELECT'], essential: false }
{
component: WidgetTreeSelect,
aliases: ['TREESELECT'],
essential: false
}
],
[
'markdown',

View File

@@ -17,7 +17,6 @@ export function clone<T>(obj: T): T {
}
/**
* @knipIgnoreUnusedButUsedByCustomNodes
* @deprecated Use `applyTextReplacements` from `@/utils/searchAndReplace` instead
* There are external callers to this function, so we need to keep it for now
*/
@@ -25,7 +24,6 @@ export function applyTextReplacements(app: ComfyApp, value: string): string {
return _applyTextReplacements(app.graph, value)
}
/** @knipIgnoreUnusedButUsedByCustomNodes */
export async function addStylesheet(
urlOrFile: string,
relativeTo?: string
@@ -51,9 +49,23 @@ export async function addStylesheet(
})
}
/** @knipIgnoreUnusedButUsedByCustomNodes */
export { downloadBlob } from '@/base/common/downloadUtil'
if (typeof window !== 'undefined') {
import('@/base/common/downloadUtil')
.then((module) => {
const fn = (
module as {
downloadBlob?: typeof import('@/base/common/downloadUtil').downloadBlob
}
).downloadBlob
if (typeof fn === 'function') {
;(window as any).downloadBlob = fn
}
})
.catch(() => {})
}
export function uploadFile(accept: string) {
return new Promise<File>((resolve, reject) => {
const input = document.createElement('input')