mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-08 09:00:05 +00:00
## Summary
This PR removes `any` types from widgets, services, stores, and test
files, replacing them with proper TypeScript types.
### Key Changes
#### Type Safety Improvements
- Replaced `any` with `unknown`, explicit types, or proper interfaces
across widgets and services
- Added proper type imports (TgpuRoot, Point, StyleValue, etc.)
- Created typed interfaces (NumericWidgetOptions, TestWindow,
ImportFailureDetail, etc.)
- Fixed function return types to be non-nullable where appropriate
- Added type guards and null checks instead of non-null assertions
- Used `ComponentProps` from vue-component-type-helpers for component
testing
#### Widget System
- Added index signature to IWidgetOptions for Record compatibility
- Centralized disabled logic in WidgetInputNumberInput
- Moved template type assertions to computed properties
- Fixed ComboWidget getOptionLabel type assertions
- Improved remote widget type handling with runtime checks
#### Services & Stores
- Fixed getOrCreateViewer to return non-nullable values
- Updated addNodeOnGraph to use specific options type `{ pos?: Point }`
- Added proper type assertions for settings store retrieval
- Fixed executionIdToCurrentId return type (string | undefined)
#### Test Infrastructure
- Exported GraphOrSubgraph from litegraph barrel to avoid circular
dependencies
- Updated test fixtures with proper TypeScript types (TestInfo,
LGraphNode)
- Replaced loose Record types with ComponentProps in tests
- Added proper error handling in WebSocket fixture
#### Code Organization
- Created shared i18n-types module for locale data types
- Made ImportFailureDetail non-exported (internal use only)
- Added @public JSDoc tag to ElectronWindow type
- Fixed console.log usage in scripts to use allowed methods
### Files Changed
**Widgets & Components:**
-
src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberInput.vue
-
src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue
-
src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue
- src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.vue
-
src/renderer/extensions/vueNodes/widgets/composables/useRemoteWidget.ts
- src/lib/litegraph/src/widgets/ComboWidget.ts
- src/lib/litegraph/src/types/widgets.ts
- src/components/common/LazyImage.vue
- src/components/load3d/Load3dViewerContent.vue
**Services & Stores:**
- src/services/litegraphService.ts
- src/services/load3dService.ts
- src/services/colorPaletteService.ts
- src/stores/maskEditorStore.ts
- src/stores/nodeDefStore.ts
- src/platform/settings/settingStore.ts
- src/platform/workflow/management/stores/workflowStore.ts
**Composables & Utils:**
- src/composables/node/useWatchWidget.ts
- src/composables/useCanvasDrop.ts
- src/utils/widgetPropFilter.ts
- src/utils/queueDisplay.ts
- src/utils/envUtil.ts
**Test Files:**
- browser_tests/fixtures/ComfyPage.ts
- browser_tests/fixtures/ws.ts
- browser_tests/tests/actionbar.spec.ts
-
src/workbench/extensions/manager/components/manager/skeleton/PackCardGridSkeleton.test.ts
- src/lib/litegraph/src/subgraph/subgraphUtils.test.ts
- src/components/rightSidePanel/shared.test.ts
- src/platform/cloud/subscription/composables/useSubscription.test.ts
-
src/platform/workflow/persistence/composables/useWorkflowPersistence.test.ts
**Scripts & Types:**
- scripts/i18n-types.ts (new shared module)
- scripts/diff-i18n.ts
- scripts/check-unused-i18n-keys.ts
- src/workbench/extensions/manager/types/conflictDetectionTypes.ts
- src/types/algoliaTypes.ts
- src/types/simplifiedWidget.ts
**Infrastructure:**
- src/lib/litegraph/src/litegraph.ts (added GraphOrSubgraph export)
- src/lib/litegraph/src/infrastructure/CustomEventTarget.ts
- src/platform/assets/services/assetService.ts
**Stories:**
- apps/desktop-ui/src/views/InstallView.stories.ts
- src/components/queue/job/JobDetailsPopover.stories.ts
**Extension Manager:**
- src/workbench/extensions/manager/composables/useConflictDetection.ts
- src/workbench/extensions/manager/composables/useManagerQueue.ts
- src/workbench/extensions/manager/services/comfyManagerService.ts
- src/workbench/extensions/manager/utils/conflictMessageUtil.ts
### Testing
- [x] All TypeScript type checking passes (`pnpm typecheck`)
- [x] ESLint passes without errors (`pnpm lint`)
- [x] Format checks pass (`pnpm format:check`)
- [x] Knip (unused exports) passes (`pnpm knip`)
- [x] Pre-commit and pre-push hooks pass
Part of the "Road to No Explicit Any" initiative.
### Previous PRs in this series:
- Part 2: #7401
- Part 3: #7935
- Part 4: #7970
- Part 5: #8064
- Part 6: #8083
- Part 7: #8092
- Part 8 Group 1: #8253
- Part 8 Group 2: #8258
- Part 8 Group 3: #8304
- Part 8 Group 4: #8314
- Part 8 Group 5: #8329
- Part 8 Group 6: #8344
- Part 8 Group 7: #8459
- Part 8 Group 8: #8496
- Part 9: #8498
- Part 10: #8499
---------
Co-authored-by: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com>
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
305 lines
9.3 KiB
Vue
305 lines
9.3 KiB
Vue
<script setup lang="ts">
|
|
import { onClickOutside } from '@vueuse/core'
|
|
import { computed, ref, useTemplateRef } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
import Button from '@/components/ui/button/Button.vue'
|
|
import { evaluateInput } from '@/lib/litegraph/src/utils/widget'
|
|
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
|
import { cn } from '@/utils/tailwindUtil'
|
|
import {
|
|
INPUT_EXCLUDED_PROPS,
|
|
filterWidgetProps
|
|
} from '@/utils/widgetPropFilter'
|
|
|
|
import { WidgetInputBaseClass } from './layout'
|
|
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
|
|
|
const { locale } = useI18n()
|
|
|
|
const props = defineProps<{
|
|
widget: SimplifiedWidget<number>
|
|
}>()
|
|
|
|
const widgetContainer = useTemplateRef<HTMLDivElement>('widgetContainer')
|
|
const inputField = useTemplateRef<HTMLInputElement>('inputField')
|
|
const textEdit = ref(false)
|
|
onClickOutside(widgetContainer, () => {
|
|
if (textEdit.value) {
|
|
textEdit.value = false
|
|
}
|
|
})
|
|
|
|
function formatNumber(value: number, options?: Intl.NumberFormatOptions) {
|
|
return new Intl.NumberFormat(locale.value, options).format(value)
|
|
}
|
|
|
|
const decimalSeparator = computed(() =>
|
|
formatNumber(1.1).replace(/\p{Number}/gu, '')
|
|
)
|
|
const groupSeparator = computed(() =>
|
|
formatNumber(11111).replace(/\p{Number}/gu, '')
|
|
)
|
|
function unformatValue(value: string) {
|
|
return value
|
|
.replaceAll(groupSeparator.value, '')
|
|
.replaceAll(decimalSeparator.value, '.')
|
|
}
|
|
|
|
const modelValue = defineModel<number>({ default: 0 })
|
|
|
|
const formattedValue = computed(() => {
|
|
const unformattedValue = dragValue.value ?? modelValue.value
|
|
if ((unformattedValue as unknown) === '' || !isFinite(unformattedValue))
|
|
return `${unformattedValue}`
|
|
|
|
const options: Intl.NumberFormatOptions = {
|
|
useGrouping: useGrouping.value
|
|
}
|
|
if (precision.value !== undefined) {
|
|
options.minimumFractionDigits = precision.value
|
|
options.maximumFractionDigits = precision.value
|
|
}
|
|
return formatNumber(unformattedValue, options)
|
|
})
|
|
|
|
function updateValue(e: UIEvent) {
|
|
const { target } = e
|
|
if (!(target instanceof HTMLInputElement)) return
|
|
const parsed = evaluateInput(unformatValue(target.value))
|
|
if (parsed !== undefined) {
|
|
const max = filteredProps.value.max ?? Number.MAX_VALUE
|
|
const min = filteredProps.value.min ?? -Number.MAX_VALUE
|
|
modelValue.value = Math.min(max, Math.max(min, parsed))
|
|
} else target.value = formattedValue.value
|
|
|
|
textEdit.value = false
|
|
}
|
|
|
|
interface NumericWidgetOptions {
|
|
min: number
|
|
max: number
|
|
step?: number
|
|
step2?: number
|
|
precision?: number
|
|
disabled?: boolean
|
|
useGrouping?: boolean
|
|
}
|
|
|
|
const filteredProps = computed(() => {
|
|
const filtered = filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
|
|
return filtered as Partial<NumericWidgetOptions>
|
|
})
|
|
|
|
const isDisabled = computed(() => props.widget.options?.disabled ?? false)
|
|
|
|
const canDecrement = computed(() => {
|
|
const min = filteredProps.value.min ?? -Number.MAX_VALUE
|
|
return modelValue.value > min && !isDisabled.value
|
|
})
|
|
const canIncrement = computed(() => {
|
|
const max = filteredProps.value.max ?? Number.MAX_VALUE
|
|
return modelValue.value < max && !isDisabled.value
|
|
})
|
|
|
|
// Get the precision value for proper number formatting
|
|
const precision = computed(() => {
|
|
const p = props.widget.options?.precision
|
|
// Treat negative or non-numeric precision as undefined
|
|
return typeof p === 'number' && p >= 0 ? p : undefined
|
|
})
|
|
|
|
// Calculate the step value based on precision or widget options
|
|
const stepValue = computed(() => {
|
|
// Use step2 (correct input spec value) if available
|
|
if (props.widget.options?.step2 !== undefined) {
|
|
return Number(props.widget.options.step2)
|
|
}
|
|
// Use step / 10 for custom large step values (> 10) to match litegraph behavior
|
|
// This is important for extensions like Impact Pack that use custom step values (e.g., 640)
|
|
// We skip default step values (1, 10) to avoid affecting normal widgets
|
|
const step = props.widget.options?.step as number | undefined
|
|
if (step !== undefined && step > 10) {
|
|
return Number(step) / 10
|
|
}
|
|
// Otherwise, derive from precision
|
|
if (precision.value !== undefined) {
|
|
if (precision.value === 0) {
|
|
return 1
|
|
}
|
|
// For precision > 0, step = 1 / (10^precision)
|
|
// precision 1 → 0.1, precision 2 → 0.01, etc.
|
|
return Number((1 / Math.pow(10, precision.value)).toFixed(precision.value))
|
|
}
|
|
// Default to 'any' for unrestricted stepping
|
|
return 0
|
|
})
|
|
|
|
// Disable grouping separators by default unless explicitly enabled by the node author
|
|
const useGrouping = computed(() => {
|
|
return props.widget.options?.useGrouping === true
|
|
})
|
|
|
|
// Check if increment/decrement buttons should be disabled due to precision limits
|
|
const buttonsDisabled = computed(() => {
|
|
const currentValue = modelValue.value ?? 0
|
|
return (
|
|
!Number.isFinite(currentValue) ||
|
|
Math.abs(currentValue) > Number.MAX_SAFE_INTEGER
|
|
)
|
|
})
|
|
|
|
function updateValueBy(delta: number) {
|
|
const max = filteredProps.value.max ?? Number.MAX_VALUE
|
|
const min = filteredProps.value.min ?? -Number.MAX_VALUE
|
|
modelValue.value = Math.min(max, Math.max(min, modelValue.value + delta))
|
|
}
|
|
|
|
const dragValue = ref<number>()
|
|
const dragDelta = ref(0)
|
|
function handleMouseDown(e: PointerEvent) {
|
|
if (e.button > 0) return
|
|
if (isDisabled.value) return
|
|
const { target } = e
|
|
if (!(target instanceof HTMLElement)) return
|
|
target.setPointerCapture(e.pointerId)
|
|
dragValue.value = modelValue.value
|
|
dragDelta.value = 0
|
|
}
|
|
function handleMouseMove(e: PointerEvent) {
|
|
if (dragValue.value === undefined) return
|
|
dragDelta.value += e.movementX
|
|
const unclippedValue =
|
|
dragValue.value + ((dragDelta.value / 10) | 0) * stepValue.value
|
|
dragDelta.value %= 10
|
|
const max = filteredProps.value.max ?? Number.MAX_VALUE
|
|
const min = filteredProps.value.min ?? -Number.MAX_VALUE
|
|
dragValue.value = Math.min(max, Math.max(min, unclippedValue))
|
|
}
|
|
function handleMouseUp() {
|
|
const newValue = dragValue.value
|
|
if (newValue === undefined) return
|
|
|
|
if (newValue === modelValue.value) {
|
|
textEdit.value = true
|
|
inputField.value?.focus()
|
|
inputField.value?.setSelectionRange(0, -1)
|
|
}
|
|
|
|
modelValue.value = newValue
|
|
dragValue.value = undefined
|
|
dragDelta.value = 0
|
|
}
|
|
|
|
const buttonTooltip = computed(() => {
|
|
if (buttonsDisabled.value) {
|
|
return 'Increment/decrement disabled: value exceeds JavaScript precision limit (±2^53)'
|
|
}
|
|
return null
|
|
})
|
|
|
|
const sliderWidth = computed(() => {
|
|
const { max, min, step } = filteredProps.value
|
|
if (
|
|
min === undefined ||
|
|
max === undefined ||
|
|
step === undefined ||
|
|
(max - min) / step >= 100
|
|
)
|
|
return 0
|
|
const value = dragValue.value ?? modelValue.value
|
|
const ratio = (value - min) / (max - min)
|
|
return (ratio * 100).toFixed(0)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<WidgetLayoutField :widget>
|
|
<div
|
|
ref="widgetContainer"
|
|
v-tooltip="buttonTooltip"
|
|
v-bind="filteredProps"
|
|
:aria-label="widget.name"
|
|
:class="cn(WidgetInputBaseClass, 'grow text-xs flex h-7 relative')"
|
|
>
|
|
<div
|
|
class="absolute size-full rounded-lg pointer-events-none overflow-clip"
|
|
>
|
|
<div
|
|
class="bg-primary-background/15 size-full"
|
|
:style="{ width: `${sliderWidth}%` }"
|
|
/>
|
|
</div>
|
|
<Button
|
|
v-if="!buttonsDisabled"
|
|
data-testid="decrement"
|
|
class="h-full w-8 rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
|
|
variant="muted-textonly"
|
|
:disabled="!canDecrement"
|
|
tabindex="-1"
|
|
@click="modelValue -= stepValue"
|
|
>
|
|
<i class="pi pi-minus" />
|
|
</Button>
|
|
<div class="relative min-w-[4ch] flex-1 py-1.5 my-0.25">
|
|
<input
|
|
ref="inputField"
|
|
:aria-valuenow="dragValue ?? modelValue"
|
|
:aria-valuemin="filteredProps.min"
|
|
:aria-valuemax="filteredProps.max"
|
|
:class="
|
|
cn(
|
|
'bg-transparent border-0 focus:outline-0 p-1 truncate text-sm absolute inset-0'
|
|
)
|
|
"
|
|
inputmode="decimal"
|
|
:value="formattedValue"
|
|
role="spinbutton"
|
|
tabindex="0"
|
|
:disabled="isDisabled"
|
|
autocomplete="off"
|
|
autocorrect="off"
|
|
spellcheck="false"
|
|
@blur="updateValue"
|
|
@keyup.enter="updateValue"
|
|
@keydown.up.prevent="updateValueBy(stepValue)"
|
|
@keydown.down.prevent="updateValueBy(-stepValue)"
|
|
@keydown.page-up.prevent="updateValueBy(10 * stepValue)"
|
|
@keydown.page-down.prevent="updateValueBy(-10 * stepValue)"
|
|
@dragstart.prevent
|
|
/>
|
|
<div
|
|
:class="
|
|
cn(
|
|
'absolute inset-0 z-10 cursor-ew-resize',
|
|
textEdit && 'hidden pointer-events-none'
|
|
)
|
|
"
|
|
@pointerdown="handleMouseDown"
|
|
@pointermove="handleMouseMove"
|
|
@pointerup="handleMouseUp"
|
|
@pointercancel="
|
|
() => {
|
|
dragValue = undefined
|
|
dragDelta = 0
|
|
}
|
|
"
|
|
/>
|
|
</div>
|
|
|
|
<slot />
|
|
<Button
|
|
v-if="!buttonsDisabled"
|
|
data-testid="increment"
|
|
class="h-full w-8 rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
|
|
variant="muted-textonly"
|
|
:disabled="!canIncrement"
|
|
tabindex="-1"
|
|
@click="modelValue += stepValue"
|
|
>
|
|
<i class="pi pi-plus" />
|
|
</Button>
|
|
</div>
|
|
</WidgetLayoutField>
|
|
</template>
|