mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-15 01:48:06 +00:00
Compare commits
24 Commits
fix/load-a
...
v1.38.13
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8fd9a5374 | ||
|
|
ded366008e | ||
|
|
d48e99db7c | ||
|
|
c138670bf6 | ||
|
|
e4f1950af5 | ||
|
|
44e630d00f | ||
|
|
d27f9faa9e | ||
|
|
c902869b2c | ||
|
|
ff9823e8f0 | ||
|
|
bc4e060e92 | ||
|
|
bc31970939 | ||
|
|
6bab72feb9 | ||
|
|
390deac188 | ||
|
|
e4d1554b80 | ||
|
|
94956089f1 | ||
|
|
af3f96c0ca | ||
|
|
ada3145c2d | ||
|
|
89c76f6861 | ||
|
|
b660638f22 | ||
|
|
a96938a495 | ||
|
|
f6b571013d | ||
|
|
54e8775acb | ||
|
|
13e8aa7466 | ||
|
|
7a224efaa0 |
2
.github/workflows/i18n-update-core.yaml
vendored
2
.github/workflows/i18n-update-core.yaml
vendored
@@ -41,7 +41,7 @@ jobs:
|
||||
env:
|
||||
PLAYWRIGHT_TEST_URL: http://localhost:5173
|
||||
- name: Update translations
|
||||
run: pnpm locale
|
||||
run: pnpm locale && pnpm format
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
- name: Commit updated locales
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 135 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 80 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.38.12",
|
||||
"version": "1.38.13",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -767,7 +767,7 @@ useIntersectionObserver(loadTrigger, () => {
|
||||
// Reset pagination when filters change
|
||||
watch(
|
||||
[
|
||||
searchQuery,
|
||||
filteredTemplates,
|
||||
selectedNavItem,
|
||||
sortBy,
|
||||
selectedModels,
|
||||
|
||||
@@ -265,18 +265,15 @@ function cancelEdit() {
|
||||
}
|
||||
|
||||
async function saveKeybinding() {
|
||||
if (currentEditingCommand.value && newBindingKeyCombo.value) {
|
||||
const updated = keybindingStore.updateKeybindingOnCommand(
|
||||
new KeybindingImpl({
|
||||
commandId: currentEditingCommand.value.id,
|
||||
combo: newBindingKeyCombo.value
|
||||
})
|
||||
)
|
||||
if (updated) {
|
||||
await keybindingService.persistUserKeybindings()
|
||||
}
|
||||
}
|
||||
const commandId = currentEditingCommand.value?.id
|
||||
const combo = newBindingKeyCombo.value
|
||||
cancelEdit()
|
||||
if (!combo || commandId == undefined) return
|
||||
|
||||
const updated = keybindingStore.updateKeybindingOnCommand(
|
||||
new KeybindingImpl({ commandId, combo })
|
||||
)
|
||||
if (updated) await keybindingService.persistUserKeybindings()
|
||||
}
|
||||
|
||||
async function resetKeybinding(commandData: ICommandData) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<!-- Help Center Popup positioned within canvas area -->
|
||||
<Teleport to="#graph-canvas-container">
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isHelpCenterVisible"
|
||||
class="help-center-popup"
|
||||
|
||||
@@ -76,14 +76,6 @@ describe('NodePreview', () => {
|
||||
expect(wrapper.find('._sb_preview_badge').text()).toBe('Preview')
|
||||
})
|
||||
|
||||
it('applies text-ellipsis class to node header for text truncation', () => {
|
||||
const wrapper = mountComponent()
|
||||
const nodeHeader = wrapper.find('.node_header')
|
||||
|
||||
expect(nodeHeader.classes()).toContain('text-ellipsis')
|
||||
expect(nodeHeader.classes()).toContain('mr-4')
|
||||
})
|
||||
|
||||
it('sets title attribute on node header with full display name', () => {
|
||||
const wrapper = mountComponent()
|
||||
const nodeHeader = wrapper.find('.node_header')
|
||||
|
||||
@@ -10,7 +10,7 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
|
||||
<div v-else class="_sb_node_preview bg-component-node-background">
|
||||
<div class="_sb_table">
|
||||
<div
|
||||
class="node_header mr-4 text-ellipsis"
|
||||
class="node_header text-ellipsis"
|
||||
:title="nodeDef.display_name"
|
||||
:style="{
|
||||
backgroundColor: litegraphColors.NODE_DEFAULT_COLOR,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="flex h-full flex-col">
|
||||
<!-- Active Jobs Grid -->
|
||||
<div
|
||||
v-if="activeJobItems.length"
|
||||
v-if="isQueuePanelV2Enabled && activeJobItems.length"
|
||||
class="grid max-h-[50%] scrollbar-custom overflow-y-auto"
|
||||
:style="gridStyle"
|
||||
>
|
||||
@@ -65,6 +65,7 @@ import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { isActiveJobState } from '@/utils/queueUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
const {
|
||||
assets,
|
||||
@@ -90,6 +91,11 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
const { jobItems } = useJobList()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
settingStore.get('Comfy.Queue.QPOV2')
|
||||
)
|
||||
|
||||
type AssetGridItem = { key: string; asset: AssetItem }
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<div
|
||||
v-if="activeJobItems.length"
|
||||
v-if="isQueuePanelV2Enabled && activeJobItems.length"
|
||||
class="flex max-h-[50%] scrollbar-custom flex-col gap-2 overflow-y-auto px-2"
|
||||
>
|
||||
<AssetsListItem
|
||||
@@ -133,6 +133,7 @@ import {
|
||||
} from '@/utils/formatUtil'
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
const {
|
||||
assets,
|
||||
@@ -154,6 +155,11 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
const { jobItems } = useJobList()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
settingStore.get('Comfy.Queue.QPOV2')
|
||||
)
|
||||
const hoveredJobId = ref<string | null>(null)
|
||||
const hoveredAssetId = ref<string | null>(null)
|
||||
|
||||
|
||||
@@ -13,10 +13,7 @@
|
||||
severity="danger"
|
||||
/>
|
||||
</template>
|
||||
<template
|
||||
v-if="nodeDef.name.startsWith(useSubgraphStore().typePrefix)"
|
||||
#actions
|
||||
>
|
||||
<template v-if="isUserBlueprint" #actions>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon-sm"
|
||||
@@ -128,8 +125,18 @@ const editBlueprint = async () => {
|
||||
await useSubgraphStore().editBlueprint(props.node.data.name)
|
||||
}
|
||||
const menu = ref<InstanceType<typeof ContextMenu> | null>(null)
|
||||
const subgraphStore = useSubgraphStore()
|
||||
const isUserBlueprint = computed(() => {
|
||||
const name = nodeDef.value.name
|
||||
if (!name.startsWith(subgraphStore.typePrefix)) return false
|
||||
return !subgraphStore.isGlobalBlueprint(
|
||||
name.slice(subgraphStore.typePrefix.length)
|
||||
)
|
||||
})
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
const items: MenuItem[] = [
|
||||
if (!isUserBlueprint.value) return []
|
||||
|
||||
return [
|
||||
{
|
||||
label: t('g.delete'),
|
||||
icon: 'pi pi-trash',
|
||||
@@ -137,15 +144,14 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
command: deleteBlueprint
|
||||
}
|
||||
]
|
||||
return items
|
||||
})
|
||||
function handleContextMenu(event: Event) {
|
||||
if (!nodeDef.value.name.startsWith(useSubgraphStore().typePrefix)) return
|
||||
if (!isUserBlueprint.value) return
|
||||
menu.value?.show(event)
|
||||
}
|
||||
function deleteBlueprint() {
|
||||
if (!props.node.data) return
|
||||
void useSubgraphStore().deleteBlueprint(props.node.data.name)
|
||||
void subgraphStore.deleteBlueprint(props.node.data.name)
|
||||
}
|
||||
|
||||
const nodePreviewStyle = ref<CSSProperties>({
|
||||
|
||||
@@ -134,6 +134,27 @@ describe('contextMenuConverter', () => {
|
||||
// Node Info (section 4) should come before or with Color (section 4)
|
||||
expect(getIndex('Node Info')).toBeLessThanOrEqual(getIndex('Color'))
|
||||
})
|
||||
|
||||
it('should recognize Frame Nodes as a core menu item', () => {
|
||||
const options: MenuOption[] = [
|
||||
{ label: 'Rename', source: 'vue' },
|
||||
{ label: 'Frame Nodes', source: 'vue' },
|
||||
{ label: 'Custom Extension', source: 'vue' }
|
||||
]
|
||||
|
||||
const result = buildStructuredMenu(options)
|
||||
|
||||
// Frame Nodes should appear in the core items section (before Extensions)
|
||||
const frameNodesIndex = result.findIndex(
|
||||
(opt) => opt.label === 'Frame Nodes'
|
||||
)
|
||||
const extensionsCategoryIndex = result.findIndex(
|
||||
(opt) => opt.label === 'Extensions' && opt.type === 'category'
|
||||
)
|
||||
|
||||
// Frame Nodes should come before Extensions category
|
||||
expect(frameNodesIndex).toBeLessThan(extensionsCategoryIndex)
|
||||
})
|
||||
})
|
||||
|
||||
describe('convertContextMenuToOptions', () => {
|
||||
|
||||
@@ -44,6 +44,7 @@ const CORE_MENU_ITEMS = new Set([
|
||||
// Structure operations
|
||||
'Convert to Subgraph',
|
||||
'Frame selection',
|
||||
'Frame Nodes',
|
||||
'Minimize Node',
|
||||
'Expand',
|
||||
'Collapse',
|
||||
@@ -103,7 +104,8 @@ function isDuplicateItem(label: string, existingItems: MenuOption[]): boolean {
|
||||
shape: ['shape', 'shapes'],
|
||||
pin: ['pin', 'unpin'],
|
||||
delete: ['remove', 'delete'],
|
||||
duplicate: ['clone', 'duplicate']
|
||||
duplicate: ['clone', 'duplicate'],
|
||||
frame: ['frame selection', 'frame nodes']
|
||||
}
|
||||
|
||||
return existingItems.some((item) => {
|
||||
@@ -226,6 +228,7 @@ const MENU_ORDER: string[] = [
|
||||
// Section 3: Structure operations
|
||||
'Convert to Subgraph',
|
||||
'Frame selection',
|
||||
'Frame Nodes',
|
||||
'Minimize Node',
|
||||
'Expand',
|
||||
'Collapse',
|
||||
|
||||
@@ -2,10 +2,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useSelectionMenuOptions } from '@/composables/graph/useSelectionMenuOptions'
|
||||
|
||||
const subgraphMocks = vi.hoisted(() => ({
|
||||
const mocks = vi.hoisted(() => ({
|
||||
convertToSubgraph: vi.fn(),
|
||||
unpackSubgraph: vi.fn(),
|
||||
addSubgraphToLibrary: vi.fn(),
|
||||
frameNodes: vi.fn(),
|
||||
createI18nMock: vi.fn(() => ({
|
||||
global: {
|
||||
t: vi.fn(),
|
||||
@@ -19,7 +20,7 @@ vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
}),
|
||||
createI18n: subgraphMocks.createI18nMock
|
||||
createI18n: mocks.createI18nMock
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useSelectionOperations', () => ({
|
||||
@@ -42,18 +43,46 @@ vi.mock('@/composables/graph/useNodeArrangement', () => ({
|
||||
|
||||
vi.mock('@/composables/graph/useSubgraphOperations', () => ({
|
||||
useSubgraphOperations: () => ({
|
||||
convertToSubgraph: subgraphMocks.convertToSubgraph,
|
||||
unpackSubgraph: subgraphMocks.unpackSubgraph,
|
||||
addSubgraphToLibrary: subgraphMocks.addSubgraphToLibrary
|
||||
convertToSubgraph: mocks.convertToSubgraph,
|
||||
unpackSubgraph: mocks.unpackSubgraph,
|
||||
addSubgraphToLibrary: mocks.addSubgraphToLibrary
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useFrameNodes', () => ({
|
||||
useFrameNodes: () => ({
|
||||
frameNodes: vi.fn()
|
||||
frameNodes: mocks.frameNodes
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useSelectionMenuOptions - multiple nodes options', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns Frame Nodes option that invokes frameNodes when called', () => {
|
||||
const { getMultipleNodesOptions } = useSelectionMenuOptions()
|
||||
const options = getMultipleNodesOptions()
|
||||
|
||||
const frameOption = options.find((opt) => opt.label === 'g.frameNodes')
|
||||
expect(frameOption).toBeDefined()
|
||||
expect(frameOption?.action).toBeDefined()
|
||||
|
||||
frameOption?.action?.()
|
||||
expect(mocks.frameNodes).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('returns Convert to Group Node option from getMultipleNodesOptions', () => {
|
||||
const { getMultipleNodesOptions } = useSelectionMenuOptions()
|
||||
const options = getMultipleNodesOptions()
|
||||
|
||||
const groupNodeOption = options.find(
|
||||
(opt) => opt.label === 'contextMenu.Convert to Group Node'
|
||||
)
|
||||
expect(groupNodeOption).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useSelectionMenuOptions - subgraph options', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -68,7 +97,7 @@ describe('useSelectionMenuOptions - subgraph options', () => {
|
||||
|
||||
expect(options).toHaveLength(1)
|
||||
expect(options[0]?.label).toBe('contextMenu.Convert to Subgraph')
|
||||
expect(options[0]?.action).toBe(subgraphMocks.convertToSubgraph)
|
||||
expect(options[0]?.action).toBe(mocks.convertToSubgraph)
|
||||
})
|
||||
|
||||
it('includes convert, add to library, and unpack when subgraphs are selected', () => {
|
||||
@@ -86,7 +115,7 @@ describe('useSelectionMenuOptions - subgraph options', () => {
|
||||
const convertOption = options.find(
|
||||
(option) => option.label === 'contextMenu.Convert to Subgraph'
|
||||
)
|
||||
expect(convertOption?.action).toBe(subgraphMocks.convertToSubgraph)
|
||||
expect(convertOption?.action).toBe(mocks.convertToSubgraph)
|
||||
})
|
||||
|
||||
it('hides convert option when only a single subgraph is selected', () => {
|
||||
|
||||
@@ -87,6 +87,25 @@ describe('useSelectionState', () => {
|
||||
const { hasAnySelection } = useSelectionState()
|
||||
expect(hasAnySelection.value).toBe(true)
|
||||
})
|
||||
|
||||
test('hasMultipleSelection should be true when 2+ items selected', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const node1 = createMockLGraphNode({ id: 1 })
|
||||
const node2 = createMockLGraphNode({ id: 2 })
|
||||
canvasStore.$state.selectedItems = [node1, node2]
|
||||
|
||||
const { hasMultipleSelection } = useSelectionState()
|
||||
expect(hasMultipleSelection.value).toBe(true)
|
||||
})
|
||||
|
||||
test('hasMultipleSelection should be false when only 1 item selected', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const node1 = createMockLGraphNode({ id: 1 })
|
||||
canvasStore.$state.selectedItems = [node1]
|
||||
|
||||
const { hasMultipleSelection } = useSelectionState()
|
||||
expect(hasMultipleSelection.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node Type Filtering', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { refThrottled, watchDebounced } from '@vueuse/core'
|
||||
import { refDebounced, watchDebounced } from '@vueuse/core'
|
||||
import Fuse from 'fuse.js'
|
||||
import type { IFuseOptions } from 'fuse.js'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
@@ -119,7 +119,7 @@ export function useTemplateFiltering(
|
||||
)
|
||||
})
|
||||
|
||||
const debouncedSearchQuery = refThrottled(searchQuery, 50)
|
||||
const debouncedSearchQuery = refDebounced(searchQuery, 150)
|
||||
|
||||
const filteredBySearch = computed(() => {
|
||||
if (!debouncedSearchQuery.value.trim()) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { remove } from 'es-toolkit'
|
||||
import { shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type {
|
||||
@@ -345,7 +344,6 @@ function applyMatchType(node: LGraphNode, inputSpec: InputSpecV2) {
|
||||
requestAnimationFrame(() => {
|
||||
const input = node.inputs[index]
|
||||
if (!input) return
|
||||
node.inputs[index] = shallowReactive(input)
|
||||
node.onConnectionsChange?.(
|
||||
LiteGraph.INPUT,
|
||||
index,
|
||||
|
||||
@@ -55,6 +55,7 @@ class Load3d {
|
||||
private rightMouseMoved: boolean = false
|
||||
private readonly dragThreshold: number = 5
|
||||
private contextMenuAbortController: AbortController | null = null
|
||||
private resizeObserver: ResizeObserver | null = null
|
||||
|
||||
constructor(container: Element | HTMLElement, options: Load3DOptions = {}) {
|
||||
this.clock = new THREE.Clock()
|
||||
@@ -145,6 +146,7 @@ class Load3d {
|
||||
this.STATUS_MOUSE_ON_VIEWER = false
|
||||
|
||||
this.initContextMenu()
|
||||
this.initResizeObserver(container)
|
||||
|
||||
this.handleResize()
|
||||
this.startAnimation()
|
||||
@@ -154,6 +156,14 @@ class Load3d {
|
||||
}, 100)
|
||||
}
|
||||
|
||||
private initResizeObserver(container: Element | HTMLElement): void {
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.handleResize()
|
||||
this.forceRender()
|
||||
})
|
||||
this.resizeObserver.observe(container)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize context menu on the Three.js canvas
|
||||
* Detects right-click vs right-drag to show menu only on click
|
||||
@@ -809,6 +819,11 @@ class Load3d {
|
||||
}
|
||||
|
||||
public remove(): void {
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect()
|
||||
this.resizeObserver = null
|
||||
}
|
||||
|
||||
if (this.contextMenuAbortController) {
|
||||
this.contextMenuAbortController.abort()
|
||||
this.contextMenuAbortController = null
|
||||
|
||||
@@ -993,7 +993,8 @@
|
||||
"showAll": "Show all",
|
||||
"hidden": "Hidden / nested parameters",
|
||||
"hideAll": "Hide all",
|
||||
"showRecommended": "Show recommended widgets"
|
||||
"showRecommended": "Show recommended widgets",
|
||||
"cannotDeleteGlobal": "Cannot delete installed blueprints"
|
||||
},
|
||||
"electronFileDownload": {
|
||||
"inProgress": "In Progress",
|
||||
|
||||
@@ -1178,7 +1178,7 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
type: 'boolean',
|
||||
tooltip:
|
||||
'Replaces the floating job queue panel with an equivalent job queue embedded in the Assets side panel. You can disable this to return to the floating panel layout.',
|
||||
defaultValue: true,
|
||||
defaultValue: false,
|
||||
experimental: true
|
||||
},
|
||||
{
|
||||
|
||||
@@ -394,6 +394,7 @@ interface SubgraphDefinitionBase<
|
||||
id: string
|
||||
revision: number
|
||||
name: string
|
||||
category?: string
|
||||
|
||||
inputNode: T extends ComfyWorkflow1BaseInput
|
||||
? z.input<typeof zExportedSubgraphIONode>
|
||||
@@ -425,6 +426,7 @@ const zSubgraphDefinition = zComfyWorkflow1
|
||||
id: z.string().uuid(),
|
||||
revision: z.number(),
|
||||
name: z.string(),
|
||||
category: z.string().optional(),
|
||||
inputNode: zExportedSubgraphIONode,
|
||||
outputNode: zExportedSubgraphIONode,
|
||||
|
||||
|
||||
@@ -158,7 +158,15 @@ const hasMultipleVideos = computed(() => props.imageUrls.length > 1)
|
||||
// Watch for URL changes and reset state
|
||||
watch(
|
||||
() => props.imageUrls,
|
||||
(newUrls) => {
|
||||
(newUrls, oldUrls) => {
|
||||
// Only reset state if URLs actually changed (not just array reference)
|
||||
const urlsChanged =
|
||||
!oldUrls ||
|
||||
newUrls.length !== oldUrls.length ||
|
||||
newUrls.some((url, i) => url !== oldUrls[i])
|
||||
|
||||
if (!urlsChanged) return
|
||||
|
||||
// Reset current index if it's out of bounds
|
||||
if (currentIndex.value >= newUrls.length) {
|
||||
currentIndex.value = 0
|
||||
@@ -169,7 +177,7 @@ watch(
|
||||
videoError.value = false
|
||||
showLoader.value = newUrls.length > 0
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Event handlers
|
||||
|
||||
@@ -308,4 +308,80 @@ describe('ImagePreview', () => {
|
||||
expect(imgElement.exists()).toBe(true)
|
||||
expect(imgElement.attributes('alt')).toBe('Node output 2')
|
||||
})
|
||||
|
||||
describe('URL change detection', () => {
|
||||
it('should NOT reset loading state when imageUrls prop is reassigned with identical URLs', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const urls = ['/api/view?filename=test.png&type=output']
|
||||
const wrapper = mountImagePreview({ imageUrls: urls })
|
||||
|
||||
// Simulate image load completing
|
||||
const img = wrapper.find('img')
|
||||
await img.trigger('load')
|
||||
await nextTick()
|
||||
|
||||
// Verify loader is hidden after load
|
||||
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
|
||||
|
||||
// Reassign with new array reference but same content
|
||||
await wrapper.setProps({ imageUrls: [...urls] })
|
||||
await nextTick()
|
||||
|
||||
// Advance past the 250ms delayed loader timeout
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
await nextTick()
|
||||
|
||||
// Loading state should NOT have been reset - aria-busy should still be false
|
||||
// because the URLs are identical (just a new array reference)
|
||||
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('should reset loading state when imageUrls prop changes to different URLs', async () => {
|
||||
const urls = ['/api/view?filename=test.png&type=output']
|
||||
const wrapper = mountImagePreview({ imageUrls: urls })
|
||||
|
||||
// Simulate image load completing
|
||||
const img = wrapper.find('img')
|
||||
await img.trigger('load')
|
||||
await nextTick()
|
||||
|
||||
// Verify loader is hidden
|
||||
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
|
||||
|
||||
// Change to different URL
|
||||
await wrapper.setProps({
|
||||
imageUrls: ['/api/view?filename=different.png&type=output']
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
// After 250ms timeout, loading state should be reset (aria-busy="true")
|
||||
// We can check the internal state via the Skeleton appearing
|
||||
// or wait for the timeout
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle empty to non-empty URL transitions correctly', async () => {
|
||||
const wrapper = mountImagePreview({ imageUrls: [] })
|
||||
|
||||
// No preview initially
|
||||
expect(wrapper.find('.image-preview').exists()).toBe(false)
|
||||
|
||||
// Add URLs
|
||||
await wrapper.setProps({
|
||||
imageUrls: ['/api/view?filename=test.png&type=output']
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
// Preview should appear
|
||||
expect(wrapper.find('.image-preview').exists()).toBe(true)
|
||||
expect(wrapper.find('img').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -176,7 +176,15 @@ const imageAltText = computed(() => `Node output ${currentIndex.value + 1}`)
|
||||
// Watch for URL changes and reset state
|
||||
watch(
|
||||
() => props.imageUrls,
|
||||
(newUrls) => {
|
||||
(newUrls, oldUrls) => {
|
||||
// Only reset state if URLs actually changed (not just array reference)
|
||||
const urlsChanged =
|
||||
!oldUrls ||
|
||||
newUrls.length !== oldUrls.length ||
|
||||
newUrls.some((url, i) => url !== oldUrls[i])
|
||||
|
||||
if (!urlsChanged) return
|
||||
|
||||
// Reset current index if it's out of bounds
|
||||
if (currentIndex.value >= newUrls.length) {
|
||||
currentIndex.value = 0
|
||||
@@ -188,7 +196,7 @@ watch(
|
||||
imageError.value = false
|
||||
if (newUrls.length > 0) startDelayedLoader()
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Event handlers
|
||||
|
||||
@@ -150,7 +150,9 @@
|
||||
v-if="!isCollapsed && nodeData.resizable !== false"
|
||||
role="button"
|
||||
:aria-label="t('g.resizeFromBottomRight')"
|
||||
:class="cn(baseResizeHandleClasses, 'right-0 bottom-0 cursor-se-resize')"
|
||||
:class="
|
||||
cn(baseResizeHandleClasses, '-right-1 -bottom-1 cursor-se-resize')
|
||||
"
|
||||
@pointerdown.stop="handleResizePointerDown"
|
||||
/>
|
||||
</div>
|
||||
@@ -344,7 +346,7 @@ function initSizeStyles() {
|
||||
}
|
||||
|
||||
const baseResizeHandleClasses =
|
||||
'absolute h-3 w-3 opacity-0 pointer-events-auto focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/40'
|
||||
'absolute h-5 w-5 opacity-0 pointer-events-auto focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/40'
|
||||
|
||||
const MIN_NODE_WIDTH = 225
|
||||
|
||||
@@ -549,6 +551,12 @@ const showAdvancedState = customRef((track, trigger) => {
|
||||
}
|
||||
})
|
||||
|
||||
const hasVideoInput = computed(() => {
|
||||
return (
|
||||
lgraphNode.value?.inputs?.some((input) => input.type === 'VIDEO') ?? false
|
||||
)
|
||||
})
|
||||
|
||||
const nodeMedia = computed(() => {
|
||||
const newOutputs = nodeOutputs.nodeOutputs[nodeOutputLocatorId.value]
|
||||
const node = lgraphNode.value
|
||||
@@ -558,13 +566,9 @@ const nodeMedia = computed(() => {
|
||||
const urls = nodeOutputs.getNodeImageUrls(node)
|
||||
if (!urls?.length) return undefined
|
||||
|
||||
// Determine media type from previewMediaType or fallback to input slot types
|
||||
// Note: Despite the field name "images", videos are also included in outputs
|
||||
// TODO: fix the backend to return videos using the videos key instead of the images key
|
||||
const hasVideoInput = node.inputs?.some((input) => input.type === 'VIDEO')
|
||||
const type =
|
||||
node.previewMediaType === 'video' ||
|
||||
(!node.previewMediaType && hasVideoInput)
|
||||
(!node.previewMediaType && hasVideoInput.value)
|
||||
? 'video'
|
||||
: 'image'
|
||||
|
||||
|
||||
@@ -206,6 +206,60 @@ describe('WidgetMarkdown Dual Mode Display', () => {
|
||||
expect(clickSpy).not.toHaveBeenCalled()
|
||||
expect(keydownSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('Pointer Event Propagation', () => {
|
||||
it('stops pointerdown propagation to prevent node drag during text selection', async () => {
|
||||
const widget = createMockWidget('# Test')
|
||||
const wrapper = mountComponent(widget, '# Test')
|
||||
|
||||
await clickToEdit(wrapper)
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
expect(textarea.exists()).toBe(true)
|
||||
|
||||
const parentPointerdownHandler = vi.fn()
|
||||
const wrapperEl = wrapper.element as HTMLElement
|
||||
wrapperEl.addEventListener('pointerdown', parentPointerdownHandler)
|
||||
|
||||
await textarea.trigger('pointerdown')
|
||||
|
||||
expect(parentPointerdownHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops pointermove propagation during text selection', async () => {
|
||||
const widget = createMockWidget('# Test')
|
||||
const wrapper = mountComponent(widget, '# Test')
|
||||
|
||||
await clickToEdit(wrapper)
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
|
||||
const parentPointermoveHandler = vi.fn()
|
||||
const wrapperEl = wrapper.element as HTMLElement
|
||||
wrapperEl.addEventListener('pointermove', parentPointermoveHandler)
|
||||
|
||||
await textarea.trigger('pointermove')
|
||||
|
||||
expect(parentPointermoveHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops pointerup propagation after text selection', async () => {
|
||||
const widget = createMockWidget('# Test')
|
||||
const wrapper = mountComponent(widget, '# Test')
|
||||
|
||||
await clickToEdit(wrapper)
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
|
||||
const parentPointerupHandler = vi.fn()
|
||||
const wrapperEl = wrapper.element as HTMLElement
|
||||
wrapperEl.addEventListener('pointerup', parentPointerupHandler)
|
||||
|
||||
await textarea.trigger('pointerup')
|
||||
|
||||
expect(parentPointerupHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Value Updates', () => {
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
}
|
||||
}"
|
||||
data-capture-wheel="true"
|
||||
@pointerdown.capture.stop
|
||||
@pointermove.capture.stop
|
||||
@pointerup.capture.stop
|
||||
@click.stop
|
||||
@keydown.stop
|
||||
/>
|
||||
|
||||
@@ -152,6 +152,50 @@ describe('WidgetSelectDropdown custom label mapping', () => {
|
||||
expect(consoleErrorSpy).toHaveBeenCalled()
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('falls back to original value when label mapping returns empty string', () => {
|
||||
const getOptionLabel = vi.fn((value: string | null) => {
|
||||
if (value === 'photo_abc.jpg') {
|
||||
return ''
|
||||
}
|
||||
return `Labeled: ${value}`
|
||||
})
|
||||
|
||||
const widget = createMockWidget('img_001.png', {
|
||||
getOptionLabel
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'img_001.png')
|
||||
|
||||
const inputItems = wrapper.vm.inputItems
|
||||
expect(inputItems[0].name).toBe('img_001.png')
|
||||
expect(inputItems[0].label).toBe('Labeled: img_001.png')
|
||||
expect(inputItems[1].name).toBe('photo_abc.jpg')
|
||||
expect(inputItems[1].label).toBe('photo_abc.jpg')
|
||||
expect(inputItems[2].name).toBe('hash789.png')
|
||||
expect(inputItems[2].label).toBe('Labeled: hash789.png')
|
||||
})
|
||||
|
||||
it('falls back to original value when label mapping returns undefined', () => {
|
||||
const getOptionLabel = vi.fn((value: string | null) => {
|
||||
if (value === 'hash789.png') {
|
||||
return undefined as unknown as string
|
||||
}
|
||||
return `Labeled: ${value}`
|
||||
})
|
||||
|
||||
const widget = createMockWidget('img_001.png', {
|
||||
getOptionLabel
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'img_001.png')
|
||||
|
||||
const inputItems = wrapper.vm.inputItems
|
||||
expect(inputItems[0].name).toBe('img_001.png')
|
||||
expect(inputItems[0].label).toBe('Labeled: img_001.png')
|
||||
expect(inputItems[1].name).toBe('photo_abc.jpg')
|
||||
expect(inputItems[1].label).toBe('Labeled: photo_abc.jpg')
|
||||
expect(inputItems[2].name).toBe('hash789.png')
|
||||
expect(inputItems[2].label).toBe('hash789.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('output items with custom label mapping', () => {
|
||||
@@ -171,4 +215,102 @@ describe('WidgetSelectDropdown custom label mapping', () => {
|
||||
expect(Array.isArray(outputItems)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('missing value handling for template-loaded nodes', () => {
|
||||
it('creates a fallback item in "all" filter when modelValue is not in available items', () => {
|
||||
const widget = createMockWidget('template_image.png', {
|
||||
values: ['img_001.png', 'photo_abc.jpg']
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'template_image.png')
|
||||
|
||||
const inputItems = wrapper.vm.inputItems
|
||||
expect(inputItems).toHaveLength(2)
|
||||
expect(
|
||||
inputItems.some((item) => item.name === 'template_image.png')
|
||||
).toBe(false)
|
||||
|
||||
// The missing value should be accessible via dropdownItems when filter is 'all' (default)
|
||||
const dropdownItems = (
|
||||
wrapper.vm as unknown as { dropdownItems: DropdownItem[] }
|
||||
).dropdownItems
|
||||
expect(
|
||||
dropdownItems.some((item) => item.name === 'template_image.png')
|
||||
).toBe(true)
|
||||
expect(dropdownItems[0].name).toBe('template_image.png')
|
||||
expect(dropdownItems[0].id).toBe('missing-template_image.png')
|
||||
})
|
||||
|
||||
it('does not include fallback item when filter is "inputs"', async () => {
|
||||
const widget = createMockWidget('template_image.png', {
|
||||
values: ['img_001.png', 'photo_abc.jpg']
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'template_image.png')
|
||||
|
||||
const vmWithFilter = wrapper.vm as unknown as {
|
||||
filterSelected: string
|
||||
dropdownItems: DropdownItem[]
|
||||
}
|
||||
|
||||
vmWithFilter.filterSelected = 'inputs'
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const dropdownItems = vmWithFilter.dropdownItems
|
||||
expect(dropdownItems).toHaveLength(2)
|
||||
expect(
|
||||
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not include fallback item when filter is "outputs"', async () => {
|
||||
const widget = createMockWidget('template_image.png', {
|
||||
values: ['img_001.png', 'photo_abc.jpg']
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'template_image.png')
|
||||
|
||||
const vmWithFilter = wrapper.vm as unknown as {
|
||||
filterSelected: string
|
||||
dropdownItems: DropdownItem[]
|
||||
outputItems: DropdownItem[]
|
||||
}
|
||||
|
||||
vmWithFilter.filterSelected = 'outputs'
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const dropdownItems = vmWithFilter.dropdownItems
|
||||
expect(dropdownItems).toHaveLength(wrapper.vm.outputItems.length)
|
||||
expect(
|
||||
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not create a fallback item when modelValue exists in available items', () => {
|
||||
const widget = createMockWidget('img_001.png', {
|
||||
values: ['img_001.png', 'photo_abc.jpg']
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'img_001.png')
|
||||
|
||||
const dropdownItems = (
|
||||
wrapper.vm as unknown as { dropdownItems: DropdownItem[] }
|
||||
).dropdownItems
|
||||
expect(dropdownItems).toHaveLength(2)
|
||||
expect(
|
||||
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not create a fallback item when modelValue is undefined', () => {
|
||||
const widget = createMockWidget(undefined as unknown as string, {
|
||||
values: ['img_001.png', 'photo_abc.jpg']
|
||||
})
|
||||
const wrapper = mountComponent(widget, undefined)
|
||||
|
||||
const dropdownItems = (
|
||||
wrapper.vm as unknown as { dropdownItems: DropdownItem[] }
|
||||
).dropdownItems
|
||||
expect(dropdownItems).toHaveLength(2)
|
||||
expect(
|
||||
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -85,14 +85,15 @@ const selectedSet = ref<Set<SelectedKey>>(new Set())
|
||||
|
||||
/**
|
||||
* Transforms a value using getOptionLabel if available.
|
||||
* Falls back to the original value if getOptionLabel is not provided or throws an error.
|
||||
* Falls back to the original value if getOptionLabel is not provided,
|
||||
* returns undefined/null, or throws an error.
|
||||
*/
|
||||
function getDisplayLabel(value: string): string {
|
||||
const getOptionLabel = props.widget.options?.getOptionLabel
|
||||
if (!getOptionLabel) return value
|
||||
|
||||
try {
|
||||
return getOptionLabel(value)
|
||||
return getOptionLabel(value) || value
|
||||
} catch (e) {
|
||||
console.error('Failed to map value:', e)
|
||||
return value
|
||||
@@ -146,11 +147,69 @@ const outputItems = computed<DropdownItem[]>(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
/**
|
||||
* Creates a fallback item for the current modelValue when it doesn't exist
|
||||
* in the available items list. This handles cases like template-loaded nodes
|
||||
* where the saved value may not exist in the current server environment.
|
||||
* Works for both local mode (inputItems/outputItems) and cloud mode (assetData).
|
||||
*/
|
||||
const missingValueItem = computed<DropdownItem | undefined>(() => {
|
||||
const currentValue = modelValue.value
|
||||
if (!currentValue) return undefined
|
||||
|
||||
// Check in cloud mode assets
|
||||
if (props.isAssetMode && assetData) {
|
||||
const existsInAssets = assetData.dropdownItems.value.some(
|
||||
(item) => item.name === currentValue
|
||||
)
|
||||
if (existsInAssets) return undefined
|
||||
|
||||
return {
|
||||
id: `missing-${currentValue}`,
|
||||
mediaSrc: '',
|
||||
name: currentValue,
|
||||
label: getDisplayLabel(currentValue),
|
||||
metadata: ''
|
||||
}
|
||||
}
|
||||
|
||||
// Check in local mode inputs/outputs
|
||||
const existsInInputs = inputItems.value.some(
|
||||
(item) => item.name === currentValue
|
||||
)
|
||||
const existsInOutputs = outputItems.value.some(
|
||||
(item) => item.name === currentValue
|
||||
)
|
||||
|
||||
if (existsInInputs || existsInOutputs) return undefined
|
||||
|
||||
const isOutput = currentValue.endsWith(' [output]')
|
||||
const strippedValue = isOutput
|
||||
? currentValue.replace(' [output]', '')
|
||||
: currentValue
|
||||
|
||||
return {
|
||||
id: `missing-${currentValue}`,
|
||||
mediaSrc: getMediaUrl(strippedValue, isOutput ? 'output' : 'input'),
|
||||
name: currentValue,
|
||||
label: getDisplayLabel(currentValue),
|
||||
metadata: ''
|
||||
}
|
||||
})
|
||||
|
||||
const allItems = computed<DropdownItem[]>(() => {
|
||||
if (props.isAssetMode && assetData) {
|
||||
return assetData.dropdownItems.value
|
||||
const items = assetData.dropdownItems.value
|
||||
if (missingValueItem.value) {
|
||||
return [missingValueItem.value, ...items]
|
||||
}
|
||||
return items
|
||||
}
|
||||
return [...inputItems.value, ...outputItems.value]
|
||||
return [
|
||||
...(missingValueItem.value ? [missingValueItem.value] : []),
|
||||
...inputItems.value,
|
||||
...outputItems.value
|
||||
]
|
||||
})
|
||||
|
||||
const dropdownItems = computed<DropdownItem[]>(() => {
|
||||
@@ -165,7 +224,7 @@ const dropdownItems = computed<DropdownItem[]>(() => {
|
||||
return outputItems.value
|
||||
case 'all':
|
||||
default:
|
||||
return [...inputItems.value, ...outputItems.value]
|
||||
return allItems.value
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -74,10 +74,14 @@ const addMultiSelectWidget = (
|
||||
// TODO: Add remote support to multi-select widget
|
||||
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3003
|
||||
if (inputSpec.control_after_generate) {
|
||||
const defaultType =
|
||||
typeof inputSpec.control_after_generate === 'string'
|
||||
? inputSpec.control_after_generate
|
||||
: 'fixed'
|
||||
widget.linkedWidgets = addValueControlWidgets(
|
||||
node,
|
||||
widget,
|
||||
'fixed',
|
||||
defaultType,
|
||||
undefined,
|
||||
transformInputSpecV2ToV1(inputSpec)
|
||||
)
|
||||
@@ -209,10 +213,14 @@ const createInputMappingWidget = (
|
||||
if (!isComboWidget(widget)) {
|
||||
throw new Error(`Expected combo widget but received ${widget.type}`)
|
||||
}
|
||||
const defaultType =
|
||||
typeof inputSpec.control_after_generate === 'string'
|
||||
? inputSpec.control_after_generate
|
||||
: 'randomize'
|
||||
widget.linkedWidgets = addValueControlWidgets(
|
||||
node,
|
||||
widget,
|
||||
undefined,
|
||||
defaultType,
|
||||
undefined,
|
||||
transformInputSpecV2ToV1(inputSpec)
|
||||
)
|
||||
@@ -284,10 +292,14 @@ const addComboWidget = (
|
||||
throw new Error(`Expected combo widget but received ${widget.type}`)
|
||||
}
|
||||
|
||||
const defaultType =
|
||||
typeof inputSpec.control_after_generate === 'string'
|
||||
? inputSpec.control_after_generate
|
||||
: 'randomize'
|
||||
widget.linkedWidgets = addValueControlWidgets(
|
||||
node,
|
||||
widget,
|
||||
undefined,
|
||||
defaultType,
|
||||
undefined,
|
||||
transformInputSpecV2ToV1(inputSpec)
|
||||
)
|
||||
|
||||
@@ -72,10 +72,14 @@ export const useIntWidget = () => {
|
||||
['seed', 'noise_seed'].includes(inputSpec.name)
|
||||
|
||||
if (controlAfterGenerate) {
|
||||
const defaultType =
|
||||
typeof inputSpec.control_after_generate === 'string'
|
||||
? inputSpec.control_after_generate
|
||||
: 'randomize'
|
||||
const controlWidget = addValueControlWidget(
|
||||
node,
|
||||
widget,
|
||||
'randomize',
|
||||
defaultType,
|
||||
undefined,
|
||||
undefined,
|
||||
transformInputSpecV2ToV1(inputSpec)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from 'zod'
|
||||
import { fromZodError } from 'zod-validation-error'
|
||||
|
||||
import { resultItemType } from '@/schemas/apiSchema'
|
||||
import { CONTROL_OPTIONS } from '@/types/simplifiedWidget'
|
||||
|
||||
const zComboOption = z.union([z.string(), z.number()])
|
||||
const zRemoteWidgetConfig = z.object({
|
||||
@@ -50,7 +51,9 @@ export const zIntInputOptions = zNumericInputOptions.extend({
|
||||
* If true, a linked widget will be added to the node to select the mode
|
||||
* of `control_after_generate`.
|
||||
*/
|
||||
control_after_generate: z.boolean().optional()
|
||||
control_after_generate: z
|
||||
.union([z.boolean(), z.enum(CONTROL_OPTIONS)])
|
||||
.optional()
|
||||
})
|
||||
|
||||
export const zFloatInputOptions = zNumericInputOptions.extend({
|
||||
@@ -74,7 +77,9 @@ export const zStringInputOptions = zBaseInputOptions.extend({
|
||||
})
|
||||
|
||||
export const zComboInputOptions = zBaseInputOptions.extend({
|
||||
control_after_generate: z.boolean().optional(),
|
||||
control_after_generate: z
|
||||
.union([z.boolean(), z.enum(CONTROL_OPTIONS)])
|
||||
.optional(),
|
||||
image_upload: z.boolean().optional(),
|
||||
image_folder: resultItemType.optional(),
|
||||
allow_batch: z.boolean().optional(),
|
||||
|
||||
@@ -231,7 +231,10 @@ type ComplexApiEvents = keyof NeverNever<ApiEventTypes>
|
||||
|
||||
export type GlobalSubgraphData = {
|
||||
name: string
|
||||
info: { node_pack: string }
|
||||
info: {
|
||||
node_pack: string
|
||||
category?: string
|
||||
}
|
||||
data: string | Promise<string>
|
||||
}
|
||||
|
||||
|
||||
@@ -876,7 +876,11 @@ export const useLitegraphService = () => {
|
||||
|
||||
function getCanvasCenter(): Point {
|
||||
const dpi = Math.max(window.devicePixelRatio ?? 1, 1)
|
||||
const [x, y, w, h] = app.canvas.ds.visible_area
|
||||
const visibleArea = app.canvas?.ds?.visible_area
|
||||
if (!visibleArea) {
|
||||
return [0, 0]
|
||||
}
|
||||
const [x, y, w, h] = visibleArea
|
||||
return [x + w / dpi / 2, y + h / dpi / 2]
|
||||
}
|
||||
|
||||
|
||||
@@ -278,7 +278,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||
}
|
||||
|
||||
const createCustomer = async (): Promise<CreateCustomerResponse> => {
|
||||
const authHeader = await getFirebaseAuthHeader()
|
||||
const authHeader = await getAuthHeader()
|
||||
if (!authHeader) {
|
||||
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -13,7 +14,9 @@ vi.mock('@/utils/litegraphUtil', () => ({
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
getPreviewFormatParam: vi.fn(() => '&format=test_webp')
|
||||
getPreviewFormatParam: vi.fn(() => '&format=test_webp'),
|
||||
nodeOutputs: {} as Record<string, unknown>,
|
||||
nodePreviewImages: {} as Record<string, string[]>
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -28,6 +31,62 @@ const createMockOutputs = (
|
||||
images?: ExecutedWsMessage['output']['images']
|
||||
): ExecutedWsMessage['output'] => ({ images })
|
||||
|
||||
vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: vi.fn(() => ({
|
||||
executionIdToNodeLocatorId: vi.fn((id: string) => id)
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: vi.fn(() => ({
|
||||
nodeIdToNodeLocatorId: vi.fn((id: string | number) => String(id)),
|
||||
nodeToNodeLocatorId: vi.fn((node: { id: number }) => String(node.id))
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('imagePreviewStore setNodeOutputsByExecutionId with merge', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
app.nodeOutputs = {}
|
||||
app.nodePreviewImages = {}
|
||||
})
|
||||
|
||||
it('should update reactive nodeOutputs.value when merging outputs', () => {
|
||||
const store = useNodeOutputStore()
|
||||
const executionId = '1'
|
||||
|
||||
const initialOutput = createMockOutputs([{ filename: 'a.png' }])
|
||||
store.setNodeOutputsByExecutionId(executionId, initialOutput)
|
||||
|
||||
expect(app.nodeOutputs[executionId]?.images).toHaveLength(1)
|
||||
expect(store.nodeOutputs[executionId]?.images).toHaveLength(1)
|
||||
|
||||
const newOutput = createMockOutputs([{ filename: 'b.png' }])
|
||||
store.setNodeOutputsByExecutionId(executionId, newOutput, { merge: true })
|
||||
|
||||
expect(app.nodeOutputs[executionId]?.images).toHaveLength(2)
|
||||
expect(store.nodeOutputs[executionId]?.images).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should assign to reactive ref after merge for Vue reactivity', () => {
|
||||
const store = useNodeOutputStore()
|
||||
const executionId = '1'
|
||||
|
||||
const initialOutput = createMockOutputs([{ filename: 'a.png' }])
|
||||
store.setNodeOutputsByExecutionId(executionId, initialOutput)
|
||||
|
||||
const newOutput = createMockOutputs([{ filename: 'b.png' }])
|
||||
|
||||
store.setNodeOutputsByExecutionId(executionId, newOutput, { merge: true })
|
||||
|
||||
expect(store.nodeOutputs[executionId]).toStrictEqual(
|
||||
app.nodeOutputs[executionId]
|
||||
)
|
||||
expect(store.nodeOutputs[executionId]?.images).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('imagePreviewStore getPreviewParam', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
|
||||
@@ -148,6 +148,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
existingOutput[k] = newValue
|
||||
}
|
||||
}
|
||||
nodeOutputs.value[nodeLocatorId] = existingOutput
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,9 @@ const createTaskOutput = (
|
||||
}
|
||||
})
|
||||
|
||||
type QueueResponse = { Running: JobListItem[]; Pending: JobListItem[] }
|
||||
type QueueResolver = (value: QueueResponse) => void
|
||||
|
||||
// Mock API
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
@@ -797,4 +800,106 @@ describe('useQueueStore', () => {
|
||||
expect(mockGetHistory).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('update deduplication', () => {
|
||||
it('should discard stale responses when newer request completes first', async () => {
|
||||
let resolveFirst: QueueResolver
|
||||
let resolveSecond: QueueResolver
|
||||
|
||||
const firstQueuePromise = new Promise<QueueResponse>((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
const secondQueuePromise = new Promise<QueueResponse>((resolve) => {
|
||||
resolveSecond = resolve
|
||||
})
|
||||
|
||||
mockGetHistory.mockResolvedValue([])
|
||||
|
||||
mockGetQueue
|
||||
.mockReturnValueOnce(firstQueuePromise)
|
||||
.mockReturnValueOnce(secondQueuePromise)
|
||||
|
||||
const firstUpdate = store.update()
|
||||
const secondUpdate = store.update()
|
||||
|
||||
resolveSecond!({ Running: [], Pending: [createPendingJob(2, 'new-job')] })
|
||||
await secondUpdate
|
||||
|
||||
expect(store.pendingTasks).toHaveLength(1)
|
||||
expect(store.pendingTasks[0].promptId).toBe('new-job')
|
||||
|
||||
resolveFirst!({
|
||||
Running: [],
|
||||
Pending: [createPendingJob(1, 'stale-job')]
|
||||
})
|
||||
await firstUpdate
|
||||
|
||||
expect(store.pendingTasks).toHaveLength(1)
|
||||
expect(store.pendingTasks[0].promptId).toBe('new-job')
|
||||
})
|
||||
|
||||
it('should set isLoading to false only for the latest request', async () => {
|
||||
let resolveFirst: QueueResolver
|
||||
let resolveSecond: QueueResolver
|
||||
|
||||
const firstQueuePromise = new Promise<QueueResponse>((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
const secondQueuePromise = new Promise<QueueResponse>((resolve) => {
|
||||
resolveSecond = resolve
|
||||
})
|
||||
|
||||
mockGetHistory.mockResolvedValue([])
|
||||
|
||||
mockGetQueue
|
||||
.mockReturnValueOnce(firstQueuePromise)
|
||||
.mockReturnValueOnce(secondQueuePromise)
|
||||
|
||||
const firstUpdate = store.update()
|
||||
expect(store.isLoading).toBe(true)
|
||||
|
||||
const secondUpdate = store.update()
|
||||
expect(store.isLoading).toBe(true)
|
||||
|
||||
resolveSecond!({ Running: [], Pending: [] })
|
||||
await secondUpdate
|
||||
|
||||
expect(store.isLoading).toBe(false)
|
||||
|
||||
resolveFirst!({ Running: [], Pending: [] })
|
||||
await firstUpdate
|
||||
|
||||
expect(store.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle stale request failure without affecting latest state', async () => {
|
||||
let resolveSecond: QueueResolver
|
||||
|
||||
const secondQueuePromise = new Promise<QueueResponse>((resolve) => {
|
||||
resolveSecond = resolve
|
||||
})
|
||||
|
||||
mockGetHistory.mockResolvedValue([])
|
||||
|
||||
mockGetQueue
|
||||
.mockRejectedValueOnce(new Error('stale network error'))
|
||||
.mockReturnValueOnce(secondQueuePromise)
|
||||
|
||||
const firstUpdate = store.update()
|
||||
const secondUpdate = store.update()
|
||||
|
||||
resolveSecond!({ Running: [], Pending: [createPendingJob(2, 'new-job')] })
|
||||
await secondUpdate
|
||||
|
||||
expect(store.pendingTasks).toHaveLength(1)
|
||||
expect(store.pendingTasks[0].promptId).toBe('new-job')
|
||||
expect(store.isLoading).toBe(false)
|
||||
|
||||
await expect(firstUpdate).rejects.toThrow('stale network error')
|
||||
|
||||
expect(store.pendingTasks).toHaveLength(1)
|
||||
expect(store.pendingTasks[0].promptId).toBe('new-job')
|
||||
expect(store.isLoading).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -475,6 +475,9 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
const maxHistoryItems = ref(64)
|
||||
const isLoading = ref(false)
|
||||
|
||||
// Scoped per-store instance; incremented to dedupe concurrent update() calls
|
||||
let updateRequestId = 0
|
||||
|
||||
const tasks = computed<TaskItemImpl[]>(
|
||||
() =>
|
||||
[
|
||||
@@ -498,6 +501,7 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
)
|
||||
|
||||
const update = async () => {
|
||||
const requestId = ++updateRequestId
|
||||
isLoading.value = true
|
||||
try {
|
||||
const [queue, history] = await Promise.all([
|
||||
@@ -505,6 +509,8 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
api.getHistory(maxHistoryItems.value)
|
||||
])
|
||||
|
||||
if (requestId !== updateRequestId) return
|
||||
|
||||
// API returns pre-sorted data (sort_by=create_time&order=desc)
|
||||
runningTasks.value = queue.Running.map((job) => new TaskItemImpl(job))
|
||||
pendingTasks.value = queue.Pending.map((job) => new TaskItemImpl(job))
|
||||
@@ -545,7 +551,12 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
return existing
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
// Only clear loading if this is the latest request.
|
||||
// A stale request completing (success or error) should not touch loading state
|
||||
// since a newer request is responsible for it.
|
||||
if (requestId === updateRequestId) {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
|
||||
import type { GlobalSubgraphData } from '@/scripts/api'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
@@ -24,6 +25,7 @@ vi.mock('@/scripts/api', () => ({
|
||||
getUserData: vi.fn(),
|
||||
storeUserData: vi.fn(),
|
||||
listUserDataFullInfo: vi.fn(),
|
||||
getGlobalSubgraphs: vi.fn(),
|
||||
apiURL: vi.fn(),
|
||||
addEventListener: vi.fn()
|
||||
}
|
||||
@@ -59,7 +61,10 @@ const mockGraph = {
|
||||
|
||||
describe('useSubgraphStore', () => {
|
||||
let store: ReturnType<typeof useSubgraphStore>
|
||||
const mockFetch = async (filenames: Record<string, unknown>) => {
|
||||
async function mockFetch(
|
||||
filenames: Record<string, unknown>,
|
||||
globalSubgraphs: Record<string, GlobalSubgraphData> = {}
|
||||
) {
|
||||
vi.mocked(api.listUserDataFullInfo).mockResolvedValue(
|
||||
Object.keys(filenames).map((filename) => ({
|
||||
path: filename,
|
||||
@@ -67,13 +72,13 @@ describe('useSubgraphStore', () => {
|
||||
size: 1 // size !== -1 for remote workflows
|
||||
}))
|
||||
)
|
||||
vi.mocked(api).getUserData = vi.fn(
|
||||
(f) =>
|
||||
({
|
||||
status: 200,
|
||||
text: () => JSON.stringify(filenames[f.slice(10)])
|
||||
}) as any
|
||||
vi.mocked(api).getUserData = vi.fn((f) =>
|
||||
Promise.resolve({
|
||||
status: 200,
|
||||
text: () => Promise.resolve(JSON.stringify(filenames[f.slice(10)]))
|
||||
} as Response)
|
||||
)
|
||||
vi.mocked(api.getGlobalSubgraphs).mockResolvedValue(globalSubgraphs)
|
||||
return await store.fetchSubgraphs()
|
||||
}
|
||||
|
||||
@@ -113,7 +118,7 @@ describe('useSubgraphStore', () => {
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
expect(
|
||||
useNodeDefStore().nodeDefs.filter(
|
||||
(d) => d.category == 'Subgraph Blueprints'
|
||||
(d) => d.category === 'Subgraph Blueprints/User'
|
||||
)
|
||||
).toHaveLength(1)
|
||||
})
|
||||
@@ -131,4 +136,25 @@ describe('useSubgraphStore', () => {
|
||||
} as ComfyNodeDefV1)
|
||||
expect(res).toBeTruthy()
|
||||
})
|
||||
it('should identify user blueprints as non-global', async () => {
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
expect(store.isGlobalBlueprint('test')).toBe(false)
|
||||
})
|
||||
it('should identify global blueprints loaded from getGlobalSubgraphs', async () => {
|
||||
await mockFetch(
|
||||
{},
|
||||
{
|
||||
global_test: {
|
||||
name: 'Global Test Blueprint',
|
||||
info: { node_pack: 'comfy_essentials' },
|
||||
data: JSON.stringify(mockGraph)
|
||||
}
|
||||
}
|
||||
)
|
||||
expect(store.isGlobalBlueprint('global_test')).toBe(true)
|
||||
})
|
||||
it('should return false for non-existent blueprints', async () => {
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
expect(store.isGlobalBlueprint('nonexistent')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -96,7 +96,9 @@ export const useSubgraphStore = defineStore('subgraph', () => {
|
||||
this.hasPromptedSave = true
|
||||
}
|
||||
const ret = await super.save()
|
||||
useSubgraphStore().updateDef(await this.load())
|
||||
registerNodeDef(await this.load(), {
|
||||
category: 'Subgraph Blueprints/User'
|
||||
})
|
||||
return ret
|
||||
}
|
||||
|
||||
@@ -104,7 +106,9 @@ export const useSubgraphStore = defineStore('subgraph', () => {
|
||||
this.validateSubgraph()
|
||||
this.hasPromptedSave = true
|
||||
const ret = await super.saveAs(path)
|
||||
useSubgraphStore().updateDef(await this.load())
|
||||
registerNodeDef(await this.load(), {
|
||||
category: 'Subgraph Blueprints/User'
|
||||
})
|
||||
return ret
|
||||
}
|
||||
override async load({ force = false }: { force?: boolean } = {}): Promise<
|
||||
@@ -151,7 +155,7 @@ export const useSubgraphStore = defineStore('subgraph', () => {
|
||||
options.path = SubgraphBlueprint.basePath + options.path
|
||||
const bp = await new SubgraphBlueprint(options, true).load()
|
||||
useWorkflowStore().attachWorkflow(bp)
|
||||
registerNodeDef(bp)
|
||||
registerNodeDef(bp, { category: 'Subgraph Blueprints/User' })
|
||||
}
|
||||
async function loadInstalledBlueprints() {
|
||||
async function loadGlobalBlueprint([k, v]: [string, GlobalSubgraphData]) {
|
||||
@@ -165,11 +169,15 @@ export const useSubgraphStore = defineStore('subgraph', () => {
|
||||
blueprint.filename = v.name
|
||||
useWorkflowStore().attachWorkflow(blueprint)
|
||||
const loaded = await blueprint.load()
|
||||
const category = v.info.category
|
||||
? `Subgraph Blueprints/${v.info.category}`
|
||||
: 'Subgraph Blueprints'
|
||||
registerNodeDef(
|
||||
loaded,
|
||||
{
|
||||
python_module: v.info.node_pack,
|
||||
display_name: v.name
|
||||
display_name: v.name,
|
||||
category
|
||||
},
|
||||
k
|
||||
)
|
||||
@@ -284,7 +292,6 @@ export const useSubgraphStore = defineStore('subgraph', () => {
|
||||
await workflow.save()
|
||||
//add to files list?
|
||||
useWorkflowStore().attachWorkflow(loadedWorkflow)
|
||||
registerNodeDef(loadedWorkflow)
|
||||
useToastStore().add({
|
||||
severity: 'success',
|
||||
summary: t('subgraphStore.publishSuccess'),
|
||||
@@ -292,9 +299,6 @@ export const useSubgraphStore = defineStore('subgraph', () => {
|
||||
life: 4000
|
||||
})
|
||||
}
|
||||
function updateDef(blueprint: LoadedComfyWorkflow) {
|
||||
registerNodeDef(blueprint)
|
||||
}
|
||||
async function editBlueprint(nodeType: string) {
|
||||
const name = nodeType.slice(typePrefix.length)
|
||||
if (!(name in subgraphCache))
|
||||
@@ -315,9 +319,17 @@ export const useSubgraphStore = defineStore('subgraph', () => {
|
||||
}
|
||||
async function deleteBlueprint(nodeType: string) {
|
||||
const name = nodeType.slice(typePrefix.length)
|
||||
if (!(name in subgraphCache))
|
||||
//As loading is blocked on in startup, this can likely be changed to invalid type
|
||||
throw new Error('not yet loaded')
|
||||
if (!(name in subgraphCache)) throw new Error('not yet loaded')
|
||||
|
||||
if (isGlobalBlueprint(name)) {
|
||||
useToastStore().add({
|
||||
severity: 'warn',
|
||||
summary: t('subgraphStore.cannotDeleteGlobal'),
|
||||
life: 4000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
!(await useDialogService().confirm({
|
||||
title: t('subgraphStore.confirmDeleteTitle'),
|
||||
@@ -338,15 +350,20 @@ export const useSubgraphStore = defineStore('subgraph', () => {
|
||||
return workflow instanceof SubgraphBlueprint
|
||||
}
|
||||
|
||||
function isGlobalBlueprint(name: string): boolean {
|
||||
const nodeDef = subgraphDefCache.value.get(name)
|
||||
return nodeDef !== undefined && nodeDef.python_module !== 'blueprint'
|
||||
}
|
||||
|
||||
return {
|
||||
deleteBlueprint,
|
||||
editBlueprint,
|
||||
fetchSubgraphs,
|
||||
getBlueprint,
|
||||
isGlobalBlueprint,
|
||||
isSubgraphBlueprint,
|
||||
publishSubgraph,
|
||||
subgraphBlueprints,
|
||||
typePrefix,
|
||||
updateDef
|
||||
typePrefix
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
@@ -19,8 +19,13 @@ type RightSidePanelSection = 'advanced-inputs' | string
|
||||
export const useRightSidePanelStore = defineStore('rightSidePanel', () => {
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const isLegacyMenu = computed(
|
||||
() => settingStore.get('Comfy.UseNewMenu') === 'Disabled'
|
||||
)
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => settingStore.get('Comfy.RightSidePanel.IsOpen'),
|
||||
get: () =>
|
||||
!isLegacyMenu.value && settingStore.get('Comfy.RightSidePanel.IsOpen'),
|
||||
set: (value: boolean) =>
|
||||
settingStore.set('Comfy.RightSidePanel.IsOpen', value)
|
||||
})
|
||||
@@ -29,7 +34,15 @@ export const useRightSidePanelStore = defineStore('rightSidePanel', () => {
|
||||
const focusedSection = ref<RightSidePanelSection | null>(null)
|
||||
const searchQuery = ref('')
|
||||
|
||||
// Auto-close panel when switching to legacy menu mode
|
||||
watch(isLegacyMenu, (legacy) => {
|
||||
if (legacy) {
|
||||
void settingStore.set('Comfy.RightSidePanel.IsOpen', false)
|
||||
}
|
||||
})
|
||||
|
||||
function openPanel(tab?: RightSidePanelTab) {
|
||||
if (isLegacyMenu.value) return
|
||||
isOpen.value = true
|
||||
if (tab) {
|
||||
activeTab.value = tab
|
||||
|
||||
@@ -15,7 +15,7 @@ export type WidgetValue =
|
||||
| void
|
||||
| File[]
|
||||
|
||||
const CONTROL_OPTIONS = [
|
||||
export const CONTROL_OPTIONS = [
|
||||
'fixed',
|
||||
'increment',
|
||||
'decrement',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -9,7 +10,6 @@ import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import { mapAllNodes } from '@/utils/graphTraversalUtil'
|
||||
import { useNodePacks } from '@/workbench/extensions/manager/composables/nodePack/useNodePacks'
|
||||
import type { UseNodePacksOptions } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
type WorkflowPack = {
|
||||
id:
|
||||
@@ -22,9 +22,10 @@ const CORE_NODES_PACK_NAME = 'comfy-core'
|
||||
|
||||
/**
|
||||
* Handles parsing node pack metadata from nodes on the graph and fetching the
|
||||
* associated node packs from the registry
|
||||
* associated node packs from the registry.
|
||||
* This is a shared singleton composable - all components use the same instance.
|
||||
*/
|
||||
export const useWorkflowPacks = (options: UseNodePacksOptions = {}) => {
|
||||
const _useWorkflowPacks = () => {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
const { inferPackFromNodeName } = useComfyRegistryStore()
|
||||
@@ -129,7 +130,7 @@ export const useWorkflowPacks = (options: UseNodePacksOptions = {}) => {
|
||||
)
|
||||
|
||||
const { startFetch, cleanup, error, isLoading, nodePacks, isReady } =
|
||||
useNodePacks(workflowPacksIds, options)
|
||||
useNodePacks(workflowPacksIds)
|
||||
|
||||
const isIdInWorkflow = (packId: string) =>
|
||||
workflowPacksIds.value.includes(packId)
|
||||
@@ -153,3 +154,5 @@ export const useWorkflowPacks = (options: UseNodePacksOptions = {}) => {
|
||||
filterWorkflowPack
|
||||
}
|
||||
}
|
||||
|
||||
export const useWorkflowPacks = createSharedComposable(_useWorkflowPacks)
|
||||
|
||||
Reference in New Issue
Block a user