diff --git a/src/components/common/EditableText.vue b/src/components/common/EditableText.vue index 16b52e7a1..91b53541b 100644 --- a/src/components/common/EditableText.vue +++ b/src/components/common/EditableText.vue @@ -82,7 +82,7 @@ watch( : inputValue.value const start = 0 const end = fileName.length - el.setSelectionRange?.(start, end) + el.setSelectionRange(start, end) }) } }, diff --git a/src/components/common/FormItem.vue b/src/components/common/FormItem.vue index 2a7361566..d7f8cc82f 100644 --- a/src/components/common/FormItem.vue +++ b/src/components/common/FormItem.vue @@ -75,7 +75,11 @@ function getFormAttrs(item: FormItem) { : item.options attrs['options'] = options - if (options && typeof options[0] !== 'string') { + if ( + Array.isArray(options) && + options.length > 0 && + typeof options[0] !== 'string' + ) { attrs['optionLabel'] = 'text' attrs['optionValue'] = 'value' } diff --git a/src/components/common/TreeExplorerTreeNode.test.ts b/src/components/common/TreeExplorerTreeNode.test.ts index f09626cb9..192cce6bc 100644 --- a/src/components/common/TreeExplorerTreeNode.test.ts +++ b/src/components/common/TreeExplorerTreeNode.test.ts @@ -61,7 +61,7 @@ describe('TreeExplorerTreeNode', () => { expect(wrapper.findComponent(EditableText).props('modelValue')).toBe( 'Test Node' ) - const badgeValue = wrapper.findComponent(Badge).props()['value'] + const badgeValue = wrapper.findComponent(Badge).props('value') expect(String(badgeValue)).toBe('3') }) diff --git a/src/components/dialog/content/MissingCoreNodesMessage.test.ts b/src/components/dialog/content/MissingCoreNodesMessage.test.ts index 59d96b3f0..d8b481cdd 100644 --- a/src/components/dialog/content/MissingCoreNodesMessage.test.ts +++ b/src/components/dialog/content/MissingCoreNodesMessage.test.ts @@ -76,7 +76,9 @@ describe('MissingCoreNodesMessage', () => { vi.clearAllMocks() mockSystemStatsStore = createMockSystemStatsStore() vi.mocked(useSystemStatsStore).mockReturnValue( - mockSystemStatsStore as unknown as ReturnType + mockSystemStatsStore as Partial< + ReturnType + > as ReturnType ) }) diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue index 41ecfec97..836276822 100644 --- a/src/components/graph/GraphCanvas.vue +++ b/src/components/graph/GraphCanvas.vue @@ -428,7 +428,12 @@ onMounted(async () => { await newUserService().initializeIfNewUser(settingStore) - if (!canvasRef.value) return + if (!canvasRef.value) { + console.error( + '[GraphCanvas] canvasRef.value is null during onMounted - comfyApp.setup was skipped' + ) + return + } await comfyApp.setup(canvasRef.value) canvasStore.canvas = comfyApp.canvas canvasStore.canvas.render_canvas_border = false diff --git a/src/components/topbar/CurrentUserButton.vue b/src/components/topbar/CurrentUserButton.vue index 5dfa79223..026c6be1f 100644 --- a/src/components/topbar/CurrentUserButton.vue +++ b/src/components/topbar/CurrentUserButton.vue @@ -63,5 +63,13 @@ const closePopover = () => { popover.value?.hide() } -defineExpose({ popover, closePopover }) +const openPopover = (event: Event) => { + popover.value?.show(event) +} + +const togglePopover = (event: Event) => { + popover.value?.toggle(event) +} + +defineExpose({ closePopover, openPopover, togglePopover }) diff --git a/src/composables/maskeditor/useCanvasManager.test.ts b/src/composables/maskeditor/useCanvasManager.test.ts index 6ffe32f28..ebb3c7466 100644 --- a/src/composables/maskeditor/useCanvasManager.test.ts +++ b/src/composables/maskeditor/useCanvasManager.test.ts @@ -36,54 +36,76 @@ interface MockStore { maskOpacity: number } -function getImgCanvas(): MockCanvas { - if (!mockStore.imgCanvas) throw new Error('imgCanvas not initialized') - return mockStore.imgCanvas -} +const { + mockStore, + getImgCanvas, + getMaskCanvas, + getRgbCanvas, + getImgCtx, + getMaskCtx, + getRgbCtx, + getCanvasBackground +} = vi.hoisted(() => { + const mockStore: MockStore = { + imgCanvas: null, + maskCanvas: null, + rgbCanvas: null, + imgCtx: null, + maskCtx: null, + rgbCtx: null, + canvasBackground: null, + maskColor: { r: 0, g: 0, b: 0 }, + maskBlendMode: 'black' as MaskBlendMode, + maskOpacity: 0.8 + } -function getMaskCanvas(): MockCanvas { - if (!mockStore.maskCanvas) throw new Error('maskCanvas not initialized') - return mockStore.maskCanvas -} + function getImgCanvas(): MockCanvas { + if (!mockStore.imgCanvas) throw new Error('imgCanvas not initialized') + return mockStore.imgCanvas + } -function getRgbCanvas(): MockCanvas { - if (!mockStore.rgbCanvas) throw new Error('rgbCanvas not initialized') - return mockStore.rgbCanvas -} + function getMaskCanvas(): MockCanvas { + if (!mockStore.maskCanvas) throw new Error('maskCanvas not initialized') + return mockStore.maskCanvas + } -function getImgCtx(): MockContext { - if (!mockStore.imgCtx) throw new Error('imgCtx not initialized') - return mockStore.imgCtx -} + function getRgbCanvas(): MockCanvas { + if (!mockStore.rgbCanvas) throw new Error('rgbCanvas not initialized') + return mockStore.rgbCanvas + } -function getMaskCtx(): MockContext { - if (!mockStore.maskCtx) throw new Error('maskCtx not initialized') - return mockStore.maskCtx -} + function getImgCtx(): MockContext { + if (!mockStore.imgCtx) throw new Error('imgCtx not initialized') + return mockStore.imgCtx + } -function getRgbCtx(): MockContext { - if (!mockStore.rgbCtx) throw new Error('rgbCtx not initialized') - return mockStore.rgbCtx -} + function getMaskCtx(): MockContext { + if (!mockStore.maskCtx) throw new Error('maskCtx not initialized') + return mockStore.maskCtx + } -function getCanvasBackground(): { style: Partial } { - if (!mockStore.canvasBackground) - throw new Error('canvasBackground not initialized') - return mockStore.canvasBackground -} + function getRgbCtx(): MockContext { + if (!mockStore.rgbCtx) throw new Error('rgbCtx not initialized') + return mockStore.rgbCtx + } -const mockStore: MockStore = { - imgCanvas: null, - maskCanvas: null, - rgbCanvas: null, - imgCtx: null, - maskCtx: null, - rgbCtx: null, - canvasBackground: null, - maskColor: { r: 0, g: 0, b: 0 }, - maskBlendMode: MaskBlendMode.Black, - maskOpacity: 0.8 -} + function getCanvasBackground(): { style: Partial } { + if (!mockStore.canvasBackground) + throw new Error('canvasBackground not initialized') + return mockStore.canvasBackground + } + + return { + mockStore, + getImgCanvas, + getMaskCanvas, + getRgbCanvas, + getImgCtx, + getMaskCtx, + getRgbCtx, + getCanvasBackground + } +}) vi.mock('@/stores/maskEditorStore', () => ({ useMaskEditorStore: vi.fn(() => mockStore) diff --git a/src/extensions/core/contextMenuFilter.ts b/src/extensions/core/contextMenuFilter.ts index 4b166506a..5b1cd5a12 100644 --- a/src/extensions/core/contextMenuFilter.ts +++ b/src/extensions/core/contextMenuFilter.ts @@ -36,7 +36,10 @@ class FilteredContextMenu extends ContextMenu { // We must request an animation frame for the current node of the active canvas to update. requestAnimationFrame(() => { - const currentNode = LGraphCanvas.active_canvas.current_node + const activeCanvas = LGraphCanvas.active_canvas + if (!activeCanvas) return + + const currentNode = activeCanvas.current_node const clickedComboValue = currentNode?.widgets ?.filter( (w) => diff --git a/src/extensions/core/groupNode.ts b/src/extensions/core/groupNode.ts index c515a0456..61b9c34ba 100644 --- a/src/extensions/core/groupNode.ts +++ b/src/extensions/core/groupNode.ts @@ -666,9 +666,12 @@ export class GroupNodeConfig { unknown, Record ] + // Intentional casts: widget config types are dynamic at runtime and the + // compiler cannot infer the union shapes expected by mergeIfValid. const output = { widget: primitiveConfig } as unknown as Parameters< typeof mergeIfValid >[0] + // Intentional casts for targetWidget and primitiveConfig — see mergeIfValid. const config = mergeIfValid( output, targetWidget as Parameters[1], diff --git a/src/extensions/core/groupNodeTypes.ts b/src/extensions/core/groupNodeTypes.ts index 322018be7..badabbe63 100644 --- a/src/extensions/core/groupNodeTypes.ts +++ b/src/extensions/core/groupNodeTypes.ts @@ -51,7 +51,16 @@ export interface GroupNodeInputsSpec { export type GroupNodeOutputType = string | (string | number)[] /** - * Partial link info used internally by group node getInputLink override. - * Extends ILinkRouting to be compatible with the base getInputLink return type. + * Represents a partial or synthetic link used internally by group node's + * `getInputLink` override when resolving connections through collapsed group nodes. + * + * Unlike a full `ILinkRouting`, this represents a computed/virtual link that may not + * correspond to an actual link in the graph's link registry. It's constructed on-the-fly + * to represent the logical connection path through group node boundaries. + * + * This type aliases `ILinkRouting` (rather than narrowing it) because the consuming code + * expects the same shape for both real and synthetic links. The distinction is purely + * semantic: callers should be aware that these links are transient and may not have + * valid `link_id` references in the global link map. */ export interface PartialLinkInfo extends ILinkRouting {} diff --git a/src/extensions/core/webcamCapture.ts b/src/extensions/core/webcamCapture.ts index 60e9ad701..14e84ed86 100644 --- a/src/extensions/core/webcamCapture.ts +++ b/src/extensions/core/webcamCapture.ts @@ -37,12 +37,15 @@ app.registerExtension({ }) container.replaceChildren(video) - setTimeout(() => resolveVideo(video), 500) // Fallback as loadedmetadata doesnt fire sometimes? - video.addEventListener( - 'loadedmetadata', - () => resolveVideo(video), - false - ) + let resolved = false + const resolve = () => { + if (resolved) return + resolved = true + clearTimeout(fallbackTimeout) + resolveVideo(video) + } + const fallbackTimeout = setTimeout(resolve, 500) + video.addEventListener('loadedmetadata', resolve, { once: true }) video.srcObject = stream video.play() } catch (error) { diff --git a/src/extensions/core/widgetInputs.ts b/src/extensions/core/widgetInputs.ts index d60c69c06..637e256ce 100644 --- a/src/extensions/core/widgetInputs.ts +++ b/src/extensions/core/widgetInputs.ts @@ -112,8 +112,12 @@ export class PrimitiveNode extends LGraphNode { const newValues = rawValues.map(String) comboWidget.options.values = newValues if (!newValues.includes(String(comboWidget.value))) { - comboWidget.value = newValues[0] - comboWidget.callback?.(comboWidget.value) + if (newValues.length > 0) { + comboWidget.value = newValues[0] + comboWidget.callback?.(comboWidget.value) + } else { + comboWidget.value = '' + } } } } diff --git a/src/lib/litegraph/src/LGraph.ts b/src/lib/litegraph/src/LGraph.ts index 51592f1e2..8ca15e58a 100644 --- a/src/lib/litegraph/src/LGraph.ts +++ b/src/lib/litegraph/src/LGraph.ts @@ -705,15 +705,17 @@ export class LGraph const ctorA = A.constructor const ctorB = B.constructor const priorityA = - ('priority' in ctorA && typeof ctorA.priority === 'number' + 'priority' in ctorA && typeof ctorA.priority === 'number' ? ctorA.priority - : 0) ?? - ('priority' in A && typeof A.priority === 'number' ? A.priority : 0) + : 'priority' in A && typeof A.priority === 'number' + ? A.priority + : 0 const priorityB = - ('priority' in ctorB && typeof ctorB.priority === 'number' + 'priority' in ctorB && typeof ctorB.priority === 'number' ? ctorB.priority - : 0) ?? - ('priority' in B && typeof B.priority === 'number' ? B.priority : 0) + : 'priority' in B && typeof B.priority === 'number' + ? B.priority + : 0 // if same priority, sort by order return priorityA == priorityB ? A.order - B.order : priorityA - priorityB }) diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts index 628670918..481597cd7 100644 --- a/src/lib/litegraph/src/LGraphCanvas.ts +++ b/src/lib/litegraph/src/LGraphCanvas.ts @@ -104,6 +104,14 @@ import type { UUID } from './utils/uuid' import { BaseWidget } from './widgets/BaseWidget' import { toConcreteWidget } from './widgets/widgetMap' +function isContentEditable(el: HTMLElement | null): boolean { + while (el) { + if (el.isContentEditable) return true + el = el.parentElement + } + return false +} + interface IShowSearchOptions { node_to?: SubgraphOutputNode | LGraphNode | null node_from?: SubgraphInputNode | LGraphNode | null @@ -3681,6 +3689,8 @@ export class LGraphCanvas implements CustomEventDispatcher let block_default = false const targetEl = e.target if (targetEl instanceof HTMLInputElement) return + if (targetEl instanceof HTMLTextAreaElement) return + if (targetEl instanceof HTMLElement && isContentEditable(targetEl)) return if (e.type == 'keydown') { // TODO: Switch @@ -3717,18 +3727,13 @@ export class LGraphCanvas implements CustomEventDispatcher this.pasteFromClipboard({ connectInputs: e.shiftKey }) } else if (e.key === 'Delete' || e.key === 'Backspace') { // delete or backspace - if ( - !(targetEl instanceof HTMLInputElement) && - !(targetEl instanceof HTMLTextAreaElement) - ) { - if (this.selectedItems.size === 0) { - this.#noItemsSelected() - return - } - - this.deleteSelected() - block_default = true + if (this.selectedItems.size === 0) { + this.#noItemsSelected() + return } + + this.deleteSelected() + block_default = true } // TODO @@ -7839,6 +7844,8 @@ export class LGraphCanvas implements CustomEventDispatcher if (typeof root.onOpen == 'function') root.onOpen() + root.graph = this.graph + return root } diff --git a/src/lib/litegraph/src/LiteGraphGlobal.ts b/src/lib/litegraph/src/LiteGraphGlobal.ts index eaf1e4cc4..eb077b11d 100644 --- a/src/lib/litegraph/src/LiteGraphGlobal.ts +++ b/src/lib/litegraph/src/LiteGraphGlobal.ts @@ -856,22 +856,17 @@ export class LiteGraphGlobal { capture ) } - } - - if ( - pointerAndMouseEvents.includes(sEvent) || - pointerOnlyEvents.includes(sEvent) - ) { + } else if (pointerOnlyEvents.includes(sEvent)) { if (this.pointerevents_method == 'pointer') { - return oDOM.removeEventListener( + oDOM.removeEventListener( this.pointerevents_method + sEvent, fCall, capture ) } + } else { + oDOM.removeEventListener(sEvent, fCall, capture) } - - return oDOM.removeEventListener(sEvent, fCall, capture) } getTime(): number { diff --git a/src/lib/litegraph/src/node/NodeSlot.test.ts b/src/lib/litegraph/src/node/NodeSlot.test.ts index 4d7d4b8ea..49f249860 100644 --- a/src/lib/litegraph/src/node/NodeSlot.test.ts +++ b/src/lib/litegraph/src/node/NodeSlot.test.ts @@ -25,9 +25,10 @@ describe('NodeSlot', () => { const serialized = outputAsSerialisable(slot) expect(serialized).not.toHaveProperty('_data') }) + }) + describe('inputAsSerialisable', () => { it('removes pos from widget input slots', () => { - // Minimal slot for serialization test - boundingRect is calculated at runtime, not serialized const widgetInputSlot: INodeInputSlot = { name: 'test-id', pos: [10, 20], @@ -54,7 +55,6 @@ describe('NodeSlot', () => { }) it('preserves only widget name during serialization', () => { - // Extra widget properties simulate real data that should be stripped during serialization const widgetInputSlot: INodeInputSlot = { name: 'test-id', type: 'STRING', diff --git a/src/lib/litegraph/src/subgraph/SubgraphSerialization.test.ts b/src/lib/litegraph/src/subgraph/SubgraphSerialization.test.ts index 2393908d1..31e6a2822 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphSerialization.test.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphSerialization.test.ts @@ -289,10 +289,13 @@ describe.skip('SubgraphSerialization - Version Compatibility', () => { id: 'future-id', name: 'Future Subgraph' }) - // Test with unknown future fields - simulating a hypothetical future version + // Test with unknown future fields - simulating a hypothetical future version. + // NOTE: The "as unknown as ExportedSubgraph" assertion below is an intentional + // exception to normal type-safety rules. It simulates a future schema/version + // to validate deserialization resilience against forward-compatible data. const extendedFormat = { ...futureFormat, - version: 2 as const, // Type assertion for test purposes + version: 2 as const, futureFeature: 'unknown_data' } as unknown as ExportedSubgraph diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useImageUploadWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useImageUploadWidget.ts index cb93588b8..1a776569b 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/useImageUploadWidget.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/useImageUploadWidget.ts @@ -23,8 +23,11 @@ type WritableComboWidget = Omit & { value: ExposedValue } const isImageFile = (file: File) => file.type.startsWith('image/') const isVideoFile = (file: File) => file.type.startsWith('video/') -const findFileComboWidget = (node: LGraphNode, inputName: string) => - node.widgets!.find((w) => w.name === inputName) as IComboWidget +const findFileComboWidget = ( + node: LGraphNode, + inputName: string +): IComboWidget | undefined => + node.widgets?.find((w): w is IComboWidget => w.name === inputName) export const useImageUploadWidget = () => { const widgetConstructor: ComfyWidgetConstructor = ( @@ -50,6 +53,9 @@ export const useImageUploadWidget = () => { const fileFilter = isVideo ? isVideoFile : isImageFile const fileComboWidget = findFileComboWidget(node, imageInputName) + if (!fileComboWidget) { + throw new Error(`Widget "${imageInputName}" not found on node`) + } const initialFile = `${fileComboWidget.value}` const formatPath = (value: InternalFile) => createAnnotatedPath(value, { rootFolder: image_folder }) diff --git a/src/scripts/metadata/avif.ts b/src/scripts/metadata/avif.ts index 8c53c342f..9f9270d5f 100644 --- a/src/scripts/metadata/avif.ts +++ b/src/scripts/metadata/avif.ts @@ -190,7 +190,7 @@ function findBox( start: number, end: number, type: string -): IsobmffBoxContentRange | null { +): IsobmffBoxContentRange { let offset = start while (offset < end) { if (offset + 8 > end) break diff --git a/src/scripts/metadata/flac.ts b/src/scripts/metadata/flac.ts index b9c627351..99aca52e7 100644 --- a/src/scripts/metadata/flac.ts +++ b/src/scripts/metadata/flac.ts @@ -39,6 +39,7 @@ export function getFromFlacFile(file: File): Promise> { if (event.target?.result instanceof ArrayBuffer) { r(getFromFlacBuffer(event.target.result)) } else { + console.error('FileReader returned a non-ArrayBuffer result') r({}) } } diff --git a/src/scripts/ui/components/button.ts b/src/scripts/ui/components/button.ts index c4aacc7df..b29545ac9 100644 --- a/src/scripts/ui/components/button.ts +++ b/src/scripts/ui/components/button.ts @@ -28,15 +28,15 @@ export class ComfyButton implements ComfyComponent { contentElement = $el('span') popup: ComfyPopup | null = null element: HTMLElement - overIcon!: string - iconSize!: number - content!: string | HTMLElement - icon!: string - tooltip!: string - classList!: ClassList - hidden!: boolean - enabled!: boolean - action!: (e: Event, btn: ComfyButton) => void + overIcon: string | undefined + iconSize: number | undefined + content: string | HTMLElement | undefined + icon: string | undefined + tooltip: string | undefined + classList: ClassList | undefined + hidden: boolean | undefined + enabled: boolean | undefined + action: ((e: Event, btn: ComfyButton) => void) | undefined constructor({ icon, @@ -74,13 +74,13 @@ export class ComfyButton implements ComfyComponent { 'icon', icon, toggleElement(this.iconElement, { onShow: this.updateIcon }) - )! + ) this.overIcon = prop(this, 'overIcon', overIcon, () => { if (this.isOver) { this.updateIcon() } - })! - this.iconSize = prop(this, 'iconSize', iconSize, this.updateIcon)! + }) + this.iconSize = prop(this, 'iconSize', iconSize, this.updateIcon) this.content = prop( this, 'content', @@ -94,7 +94,7 @@ export class ComfyButton implements ComfyComponent { } } }) - )! + ) this.tooltip = prop(this, 'tooltip', tooltip, (v) => { if (v) { @@ -102,17 +102,17 @@ export class ComfyButton implements ComfyComponent { } else { this.element.removeAttribute('title') } - })! + }) if (tooltip !== undefined) { this.element.setAttribute('aria-label', tooltip) } - this.classList = prop(this, 'classList', classList, this.updateClasses)! - this.hidden = prop(this, 'hidden', false, this.updateClasses)! + this.classList = prop(this, 'classList', classList, this.updateClasses) + this.hidden = prop(this, 'hidden', false, this.updateClasses) this.enabled = prop(this, 'enabled', enabled, () => { this.updateClasses() ;(this.element as HTMLButtonElement).disabled = !this.enabled - })! - this.action = prop(this, 'action', action)! + }) + this.action = prop(this, 'action', action) this.element.addEventListener('click', (e) => { if (this.popup) { // we are either a touch device or triggered by click not hover @@ -154,7 +154,9 @@ export class ComfyButton implements ComfyComponent { internalClasses.push('popup-closed') } } - applyClasses(this.element, this.classList, ...internalClasses) + if (this.classList !== undefined) { + applyClasses(this.element, this.classList, ...internalClasses) + } } withPopup(popup: ComfyPopup, mode: 'click' | 'hover' = 'click') { diff --git a/src/scripts/ui/draggableList.ts b/src/scripts/ui/draggableList.ts index 3a523fdd2..626c2a343 100644 --- a/src/scripts/ui/draggableList.ts +++ b/src/scripts/ui/draggableList.ts @@ -114,6 +114,17 @@ export class DraggableList extends EventTarget { return () => source.removeEventListener(event, boundListener) } + getPointerCoordinates( + e: MouseEvent | TouchEvent + ): { clientX: number; clientY: number } | null { + if ('clientX' in e) { + return { clientX: e.clientX, clientY: e.clientY } + } + const touch = e.touches?.[0] ?? e.changedTouches?.[0] + if (!touch) return null + return { clientX: touch.clientX, clientY: touch.clientY } + } + dragStart(e: MouseEvent | TouchEvent) { const target = e.target if (!(target instanceof HTMLElement)) return @@ -123,8 +134,10 @@ export class DraggableList extends EventTarget { if (!this.draggableItem) return - const clientX = 'clientX' in e ? e.clientX : e.touches[0].clientX - const clientY = 'clientY' in e ? e.clientY : e.touches[0].clientY + const coords = this.getPointerCoordinates(e) + if (!coords) return + + const { clientX, clientY } = coords this.pointerStartX = clientX this.pointerStartY = clientY @@ -184,10 +197,12 @@ export class DraggableList extends EventTarget { drag(e: MouseEvent | TouchEvent) { if (!this.draggableItem) return + const coords = this.getPointerCoordinates(e) + if (!coords) return + e.preventDefault() - const clientX = 'clientX' in e ? e.clientX : e.touches[0].clientX - const clientY = 'clientY' in e ? e.clientY : e.touches[0].clientY + const { clientX, clientY } = coords const listRect = this.listContainer.getBoundingClientRect() diff --git a/src/scripts/ui/toggleSwitch.ts b/src/scripts/ui/toggleSwitch.ts index b859946e0..a131c6ce9 100644 --- a/src/scripts/ui/toggleSwitch.ts +++ b/src/scripts/ui/toggleSwitch.ts @@ -1,6 +1,6 @@ import { $el } from '../ui' -interface ToggleSwitchItem { +export interface ToggleSwitchItem { text: string value?: string tooltip?: string @@ -19,6 +19,13 @@ export function toggleSwitch( ) { const onChange = e?.onChange + const normalizedItems: ToggleSwitchItem[] = items.map((item) => { + if (typeof item === 'string') { + return { text: item, value: item } + } + return { ...item, value: item.value ?? item.text } + }) + let selectedIndex: number | null = null let elements: HTMLLabelElement[] @@ -27,20 +34,14 @@ export function toggleSwitch( elements[selectedIndex].classList.remove('comfy-toggle-selected') } onChange?.({ - item: items[index] as ToggleSwitchItem, - prev: - selectedIndex == null - ? undefined - : (items[selectedIndex] as ToggleSwitchItem) + item: normalizedItems[index], + prev: selectedIndex == null ? undefined : normalizedItems[selectedIndex] }) selectedIndex = index elements[selectedIndex].classList.add('comfy-toggle-selected') } - elements = items.map((item, i) => { - if (typeof item === 'string') item = { text: item } - if (!item.value) item.value = item.text - + elements = normalizedItems.map((item, i) => { const toggle = $el( 'label', { diff --git a/src/scripts/widgets.ts b/src/scripts/widgets.ts index a053165cc..bd846c7a5 100644 --- a/src/scripts/widgets.ts +++ b/src/scripts/widgets.ts @@ -7,24 +7,6 @@ import type { IStringWidget } from '@/lib/litegraph/src/types/widgets' import { useSettingStore } from '@/platform/settings/settingStore' - -type ComboValuesType = IComboWidget['options']['values'] - -/** - * Normalizes combo widget values to an array. - * Handles the case where values may be a dictionary (Record) - * or a legacy function type. - */ -function getComboValuesArray( - values: ComboValuesType | undefined, - widget?: IComboWidget, - node?: LGraphNode -): string[] { - if (!values) return [] - if (typeof values === 'function') return values(widget, node) - if (Array.isArray(values)) return values - return Object.keys(values) -} import { dynamicWidgets } from '@/core/graph/widgets/dynamicWidgets' import { useBooleanWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useBooleanWidget' import { useChartWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useChartWidget' @@ -46,6 +28,24 @@ import type { ComfyApp } from './app' import './domWidget' import './errorNodeWidgets' +type ComboValuesType = IComboWidget['options']['values'] + +/** + * Normalizes combo widget values to an array. + * Handles the case where values may be a dictionary (Record) + * or a legacy function type. + */ +function getComboValuesArray( + values: ComboValuesType | undefined, + widget?: IComboWidget, + node?: LGraphNode +): string[] { + if (!values) return [] + if (typeof values === 'function') return values(widget, node) + if (Array.isArray(values)) return values + return Object.keys(values) +} + export type ComfyWidgetConstructorV2 = ( node: LGraphNode, inputSpec: InputSpecV2 diff --git a/src/types/litegraph-augmentation.d.ts b/src/types/litegraph-augmentation.d.ts index 7bfacef33..beeaa4b63 100644 --- a/src/types/litegraph-augmentation.d.ts +++ b/src/types/litegraph-augmentation.d.ts @@ -118,6 +118,11 @@ declare module '@/lib/litegraph/src/litegraph' { * This method is a no-op stub for backward compatibility with extensions. */ convertWidgetToInput?(): boolean + /** + * Recreates this node, typically used to refresh node state after definition changes. + * Callers should await the result and handle the null case (recreation failed or was cancelled). + * @returns A promise resolving to the new node instance, or null if recreation failed. + */ recreate?(): Promise refreshComboInNode?(defs: Record) /** @deprecated groupNode */ diff --git a/src/utils/vintageClipboard.ts b/src/utils/vintageClipboard.ts index 9b6bb5f20..821b0be23 100644 --- a/src/utils/vintageClipboard.ts +++ b/src/utils/vintageClipboard.ts @@ -92,7 +92,7 @@ export function deserialiseAndCreate(data: string, canvas: LGraphCanvas): void { try { graph.beforeChange() - const deserialised = JSON.parse(data) + const deserialised = JSON.parse(data) as VintageSerialisable // Find the top left point of the boundary of all pasted nodes const topLeft = [Infinity, Infinity] @@ -128,7 +128,8 @@ export function deserialiseAndCreate(data: string, canvas: LGraphCanvas): void { const relativeId = info[0] const outNode = relativeId != null ? nodes[relativeId] : undefined - const inNode = nodes[info[2]] + const inNodeId = info[2] + const inNode = inNodeId != null ? nodes[inNodeId] : undefined if (outNode && inNode) outNode.connect(info[1], inNode, info[3]) else console.warn('Warning, nodes missing on pasting') } diff --git a/src/workbench/extensions/manager/composables/nodePack/useMissingNodes.test.ts b/src/workbench/extensions/manager/composables/nodePack/useMissingNodes.test.ts index c9ac717bc..3f3145ad4 100644 --- a/src/workbench/extensions/manager/composables/nodePack/useMissingNodes.test.ts +++ b/src/workbench/extensions/manager/composables/nodePack/useMissingNodes.test.ts @@ -9,22 +9,17 @@ import { useMissingNodes } from '@/workbench/extensions/manager/composables/node import { useWorkflowPacks } from '@/workbench/extensions/manager/composables/nodePack/useWorkflowPacks' import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore' -type PartialNodeDefStore = Pick< - ReturnType, - 'nodeDefsByName' -> +type NodeDefStore = ReturnType type PartialManagerStore = Pick< ReturnType, 'isPackInstalled' > -function createMockNodeDefStore( - names: string[] -): ReturnType { - const nodeDefsByName = Object.fromEntries( +function createMockNodeDefStore(names: string[]): NodeDefStore { + const nodeDefsByName: Record = Object.fromEntries( names.map((name) => [name, { name } as ComfyNodeDefImpl]) ) - return { nodeDefsByName } as ReturnType + return { nodeDefsByName } as unknown as NodeDefStore } vi.mock('@vueuse/core', async () => { @@ -117,12 +112,7 @@ describe('useMissingNodes', () => { }) // Reset node def store mock - const partialNodeDefStore: PartialNodeDefStore = { - nodeDefsByName: {} - } - mockUseNodeDefStore.mockReturnValue( - partialNodeDefStore as ReturnType - ) + mockUseNodeDefStore.mockReturnValue(createMockNodeDefStore([])) // Reset app.rootGraph.nodes mockApp.rootGraph = { nodes: [] } diff --git a/src/workbench/utils/nodeDefOrderingUtil.ts b/src/workbench/utils/nodeDefOrderingUtil.ts index 5e5a93829..9f391b89b 100644 --- a/src/workbench/utils/nodeDefOrderingUtil.ts +++ b/src/workbench/utils/nodeDefOrderingUtil.ts @@ -2,7 +2,7 @@ import type { TWidgetValue } from '@/lib/litegraph/src/litegraph' import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' interface HasInputOrder { - input_order?: Record + input_order?: { required?: string[]; optional?: string[] } } /**