mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-16 18:37:33 +00:00
Compare commits
34 Commits
fix/load-a
...
core/1.38
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
841cf55fbd | ||
|
|
43b66ec5e5 | ||
|
|
73e51572a0 | ||
|
|
fdd1bd3406 | ||
|
|
94101d81d7 | ||
|
|
f741fb51e7 | ||
|
|
7dfadb5f42 | ||
|
|
8d9243e841 | ||
|
|
b226b6db22 | ||
|
|
35e5f37221 | ||
|
|
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: 99 KiB After Width: | Height: | Size: 93 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 80 KiB |
42
index.html
42
index.html
@@ -35,18 +35,6 @@
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
#vue-app:has(#loading-logo) {
|
||||
display: contents;
|
||||
color: var(--fg-color);
|
||||
& #loading-logo {
|
||||
place-self: center;
|
||||
font-size: clamp(2px, 1vw, 6px);
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
max-width: 100vw;
|
||||
border-radius: 20ch;
|
||||
}
|
||||
}
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
@@ -65,36 +53,6 @@
|
||||
<body class="litegraph grid">
|
||||
<div id="vue-app">
|
||||
<span class="visually-hidden" role="status">Loading ComfyUI...</span>
|
||||
<svg
|
||||
width="520"
|
||||
height="520"
|
||||
viewBox="0 0 520 520"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
id="loading-logo"
|
||||
>
|
||||
<mask
|
||||
id="mask0_227_285"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="520"
|
||||
height="520"
|
||||
>
|
||||
<path
|
||||
d="M0 184.335C0 119.812 0 87.5502 12.5571 62.9055C23.6026 41.2274 41.2274 23.6026 62.9055 12.5571C87.5502 0 119.812 0 184.335 0H335.665C400.188 0 432.45 0 457.094 12.5571C478.773 23.6026 496.397 41.2274 507.443 62.9055C520 87.5502 520 119.812 520 184.335V335.665C520 400.188 520 432.45 507.443 457.094C496.397 478.773 478.773 496.397 457.094 507.443C432.45 520 400.188 520 335.665 520H184.335C119.812 520 87.5502 520 62.9055 507.443C41.2274 496.397 23.6026 478.773 12.5571 457.094C0 432.45 0 400.188 0 335.665V184.335Z"
|
||||
fill="#EEFF30"
|
||||
/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_227_285)">
|
||||
<rect y="0.751831" width="520" height="520" fill="#172DD7" />
|
||||
<path
|
||||
d="M176.484 428.831C168.649 428.831 162.327 425.919 158.204 420.412C153.966 414.755 152.861 406.857 155.171 398.749L164.447 366.178C165.187 363.585 164.672 360.794 163.059 358.636C161.446 356.483 158.921 355.216 156.241 355.216H129.571C121.731 355.216 115.409 352.308 111.289 346.802C107.051 341.14 105.946 333.242 108.258 325.134L140.124 213.748L143.642 201.51C148.371 184.904 165.62 171.407 182.097 171.407H214.009C217.817 171.407 221.167 168.868 222.215 165.183L232.769 128.135C237.494 111.545 254.742 98.048 271.219 98.048L339.468 97.9264L389.431 97.9221C397.268 97.9221 403.59 100.831 407.711 106.337C411.949 111.994 413.054 119.892 410.744 128L396.457 178.164C391.734 194.75 374.485 208.242 358.009 208.242L289.607 208.372H257.706C253.902 208.372 250.557 210.907 249.502 214.588L222.903 307.495C222.159 310.093 222.673 312.892 224.291 315.049C225.904 317.202 228.428 318.469 231.107 318.469C231.113 318.469 276.307 318.381 276.307 318.381H326.122C333.959 318.381 340.281 321.29 344.402 326.796C348.639 332.457 349.744 340.355 347.433 348.463L333.146 398.619C328.423 415.209 311.174 428.701 294.698 428.701L226.299 428.831H176.484Z"
|
||||
fill="#F0FF41"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<script type="module" src="src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.38.12",
|
||||
"version": "1.38.14",
|
||||
"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) {
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
<template v-if="comfyAppReady">
|
||||
<TitleEditor />
|
||||
<SelectionToolbox v-if="selectionToolboxEnabled" />
|
||||
<NodeContextMenu />
|
||||
<!-- Render legacy DOM widgets only when Vue nodes are disabled -->
|
||||
<DomWidgets v-if="!shouldRenderVueNodes" />
|
||||
</template>
|
||||
@@ -112,6 +113,7 @@ import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import DomWidgets from '@/components/graph/DomWidgets.vue'
|
||||
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
|
||||
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
|
||||
import NodeContextMenu from '@/components/graph/NodeContextMenu.vue'
|
||||
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
|
||||
import TitleEditor from '@/components/graph/TitleEditor.vue'
|
||||
import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'
|
||||
|
||||
@@ -42,7 +42,6 @@
|
||||
</Panel>
|
||||
</Transition>
|
||||
</div>
|
||||
<NodeContextMenu />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -69,7 +68,6 @@ import { useExtensionService } from '@/services/extensionService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import type { ComfyCommandImpl } from '@/stores/commandStore'
|
||||
|
||||
import NodeContextMenu from './NodeContextMenu.vue'
|
||||
import FrameNodes from './selectionToolbox/FrameNodes.vue'
|
||||
import NodeOptionsButton from './selectionToolbox/NodeOptionsButton.vue'
|
||||
import VerticalDivider from './selectionToolbox/VerticalDivider.vue'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
import type {
|
||||
ContextMenuDivElement,
|
||||
IContextMenuOptions,
|
||||
@@ -5,6 +7,38 @@ import type {
|
||||
} from './interfaces'
|
||||
import { LiteGraph } from './litegraph'
|
||||
|
||||
const ALLOWED_TAGS = ['span', 'b', 'i', 'em', 'strong']
|
||||
const ALLOWED_STYLE_PROPS = new Set([
|
||||
'display',
|
||||
'color',
|
||||
'background-color',
|
||||
'padding-left',
|
||||
'border-left'
|
||||
])
|
||||
|
||||
DOMPurify.addHook('uponSanitizeAttribute', (_node, data) => {
|
||||
if (data.attrName === 'style') {
|
||||
const sanitizedStyle = data.attrValue
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => {
|
||||
const colonIdx = s.indexOf(':')
|
||||
if (colonIdx === -1) return false
|
||||
const prop = s.slice(0, colonIdx).trim().toLowerCase()
|
||||
return ALLOWED_STYLE_PROPS.has(prop)
|
||||
})
|
||||
.join('; ')
|
||||
data.attrValue = sanitizedStyle
|
||||
}
|
||||
})
|
||||
|
||||
function sanitizeMenuHTML(html: string): string {
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS,
|
||||
ALLOWED_ATTR: ['style']
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: Replace this pattern with something more modern.
|
||||
export interface ContextMenu<TValue = unknown> {
|
||||
constructor: new (
|
||||
@@ -123,7 +157,7 @@ export class ContextMenu<TValue = unknown> {
|
||||
if (options.title) {
|
||||
const element = document.createElement('div')
|
||||
element.className = 'litemenu-title'
|
||||
element.innerHTML = options.title
|
||||
element.textContent = options.title
|
||||
root.append(element)
|
||||
}
|
||||
|
||||
@@ -218,11 +252,18 @@ export class ContextMenu<TValue = unknown> {
|
||||
if (value === null) {
|
||||
element.classList.add('separator')
|
||||
} else {
|
||||
const innerHtml = name === null ? '' : String(name)
|
||||
const label = name === null ? '' : String(name)
|
||||
if (typeof value === 'string') {
|
||||
element.innerHTML = innerHtml
|
||||
element.textContent = label
|
||||
} else {
|
||||
element.innerHTML = value?.title ?? innerHtml
|
||||
// Use innerHTML for content that contains HTML tags, textContent otherwise
|
||||
const hasHtmlContent =
|
||||
value?.content !== undefined && /<[a-z][\s\S]*>/i.test(value.content)
|
||||
if (hasHtmlContent) {
|
||||
element.innerHTML = sanitizeMenuHTML(value.content!)
|
||||
} else {
|
||||
element.textContent = value?.title ?? label
|
||||
}
|
||||
|
||||
if (value.disabled) {
|
||||
disabled = true
|
||||
|
||||
210
src/lib/litegraph/src/LGraphCanvas.slotHitDetection.test.ts
Normal file
210
src/lib/litegraph/src/LGraphCanvas.slotHitDetection.test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
LGraph,
|
||||
LGraphCanvas,
|
||||
LGraphNode,
|
||||
LiteGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
layoutStore: {
|
||||
querySlotAtPoint: vi.fn(),
|
||||
queryRerouteAtPoint: vi.fn(),
|
||||
getNodeLayoutRef: vi.fn(() => ({ value: null })),
|
||||
getSlotLayout: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
describe('LGraphCanvas slot hit detection', () => {
|
||||
let graph: LGraph
|
||||
let canvas: LGraphCanvas
|
||||
let node: LGraphNode
|
||||
let canvasElement: HTMLCanvasElement
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
canvasElement = document.createElement('canvas')
|
||||
canvasElement.width = 800
|
||||
canvasElement.height = 600
|
||||
|
||||
const ctx = {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
translate: vi.fn(),
|
||||
scale: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
strokeRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
measureText: vi.fn().mockReturnValue({ width: 50 }),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
closePath: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
rect: vi.fn(),
|
||||
clip: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
setTransform: vi.fn(),
|
||||
roundRect: vi.fn(),
|
||||
getTransform: vi
|
||||
.fn()
|
||||
.mockReturnValue({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }),
|
||||
font: '',
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 1,
|
||||
globalAlpha: 1,
|
||||
textAlign: 'left' as CanvasTextAlign,
|
||||
textBaseline: 'alphabetic' as CanvasTextBaseline
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
canvasElement.getContext = vi.fn().mockReturnValue(ctx)
|
||||
canvasElement.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 800,
|
||||
height: 600
|
||||
})
|
||||
|
||||
graph = new LGraph()
|
||||
canvas = new LGraphCanvas(canvasElement, graph, {
|
||||
skip_render: true
|
||||
})
|
||||
|
||||
// Create a test node with an output slot
|
||||
node = new LGraphNode('Test Node')
|
||||
node.pos = [100, 100]
|
||||
node.size = [150, 80]
|
||||
node.addOutput('output', 'number')
|
||||
graph.add(node)
|
||||
|
||||
// Enable Vue nodes mode for the test
|
||||
LiteGraph.vueNodesMode = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
LiteGraph.vueNodesMode = false
|
||||
})
|
||||
|
||||
describe('processMouseDown slot fallback in Vue nodes mode', () => {
|
||||
it('should query layoutStore.querySlotAtPoint when clicking outside node bounds', () => {
|
||||
// Click position outside node bounds (node is at 100,100 with size 150x80)
|
||||
// So node covers x: 100-250, y: 100-180
|
||||
// Click at x=255 is outside the right edge
|
||||
const clickX = 255
|
||||
const clickY = 120
|
||||
|
||||
// Verify the click is outside the node bounds
|
||||
expect(node.isPointInside(clickX, clickY)).toBe(false)
|
||||
expect(graph.getNodeOnPos(clickX, clickY)).toBeNull()
|
||||
|
||||
// Mock the slot query to return our node's slot
|
||||
vi.mocked(layoutStore.querySlotAtPoint).mockReturnValue({
|
||||
nodeId: String(node.id),
|
||||
index: 0,
|
||||
type: 'output',
|
||||
position: { x: 252, y: 120 },
|
||||
bounds: { x: 246, y: 110, width: 20, height: 20 }
|
||||
})
|
||||
|
||||
// Call processMouseDown - this should trigger the slot fallback
|
||||
canvas.processMouseDown(
|
||||
new MouseEvent('pointerdown', {
|
||||
button: 1, // Middle button
|
||||
clientX: clickX,
|
||||
clientY: clickY
|
||||
})
|
||||
)
|
||||
|
||||
// The fix should query the layout store when no node is found at click position
|
||||
expect(layoutStore.querySlotAtPoint).toHaveBeenCalledWith({
|
||||
x: clickX,
|
||||
y: clickY
|
||||
})
|
||||
})
|
||||
|
||||
it('should NOT query layoutStore when node is found directly at click position', () => {
|
||||
// Initialize node's bounding rect
|
||||
node.updateArea()
|
||||
|
||||
// Populate visible_nodes (normally done during render)
|
||||
canvas.visible_nodes = [node]
|
||||
|
||||
// Click inside the node bounds
|
||||
const clickX = 150
|
||||
const clickY = 140
|
||||
|
||||
// Verify the click is inside the node bounds
|
||||
expect(node.isPointInside(clickX, clickY)).toBe(true)
|
||||
expect(graph.getNodeOnPos(clickX, clickY)).toBe(node)
|
||||
|
||||
// Call processMouseDown
|
||||
canvas.processMouseDown(
|
||||
new MouseEvent('pointerdown', {
|
||||
button: 1,
|
||||
clientX: clickX,
|
||||
clientY: clickY
|
||||
})
|
||||
)
|
||||
|
||||
// Should NOT query the layout store since node was found directly
|
||||
expect(layoutStore.querySlotAtPoint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should NOT query layoutStore when not in Vue nodes mode', () => {
|
||||
LiteGraph.vueNodesMode = false
|
||||
|
||||
const clickX = 255
|
||||
const clickY = 120
|
||||
|
||||
// Call processMouseDown
|
||||
canvas.processMouseDown(
|
||||
new MouseEvent('pointerdown', {
|
||||
button: 1,
|
||||
clientX: clickX,
|
||||
clientY: clickY
|
||||
})
|
||||
)
|
||||
|
||||
// Should NOT query the layout store in non-Vue mode
|
||||
expect(layoutStore.querySlotAtPoint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should find node via slot query for input slots extending beyond left edge', () => {
|
||||
node.addInput('input', 'number')
|
||||
|
||||
// Click position left of node (node starts at x=100)
|
||||
const clickX = 95
|
||||
const clickY = 140
|
||||
|
||||
// Verify outside bounds
|
||||
expect(node.isPointInside(clickX, clickY)).toBe(false)
|
||||
|
||||
vi.mocked(layoutStore.querySlotAtPoint).mockReturnValue({
|
||||
nodeId: String(node.id),
|
||||
index: 0,
|
||||
type: 'input',
|
||||
position: { x: 98, y: 140 },
|
||||
bounds: { x: 88, y: 130, width: 20, height: 20 }
|
||||
})
|
||||
|
||||
canvas.processMouseDown(
|
||||
new MouseEvent('pointerdown', {
|
||||
button: 1,
|
||||
clientX: clickX,
|
||||
clientY: clickY
|
||||
})
|
||||
)
|
||||
|
||||
expect(layoutStore.querySlotAtPoint).toHaveBeenCalledWith({
|
||||
x: clickX,
|
||||
y: clickY
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2187,9 +2187,21 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
if (!is_inside) return
|
||||
|
||||
const node =
|
||||
let node =
|
||||
graph.getNodeOnPos(e.canvasX, e.canvasY, this.visible_nodes) ?? undefined
|
||||
|
||||
// In Vue nodes mode, slots extend beyond node bounds due to CSS transforms.
|
||||
// If no node was found, check if the click is on a slot and use its owning node.
|
||||
if (!node && LiteGraph.vueNodesMode) {
|
||||
const slotLayout = layoutStore.querySlotAtPoint({
|
||||
x: e.canvasX,
|
||||
y: e.canvasY
|
||||
})
|
||||
if (slotLayout) {
|
||||
node = graph.getNodeById(slotLayout.nodeId) ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
this.mouse[0] = x
|
||||
this.mouse[1] = y
|
||||
this.graph_mouse[0] = e.canvasX
|
||||
|
||||
@@ -73,6 +73,28 @@ describe('LinkConnector SubgraphInput connection validation', () => {
|
||||
expect(toTargetNode.onConnectionsChange).toHaveBeenCalledTimes(1)
|
||||
expect(fromTargetNode.onConnectionsChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('should allow reconnection to same target', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'number_input', type: 'number' }]
|
||||
})
|
||||
|
||||
const node = new LGraphNode('TargetNode')
|
||||
node.addInput('number_in', 'number')
|
||||
subgraph.add(node)
|
||||
|
||||
const link = subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const renderLink = new ToInputFromIoNodeLink(
|
||||
subgraph,
|
||||
subgraph.inputNode,
|
||||
subgraph.inputNode.slots[0],
|
||||
undefined,
|
||||
LinkDirection.CENTER,
|
||||
link
|
||||
)
|
||||
renderLink.connectToInput(node, node.inputs[0], connector.events)
|
||||
expect(node.inputs[0].link).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('MovingOutputLink validation', () => {
|
||||
|
||||
@@ -58,6 +58,12 @@ export class ToInputFromIoNodeLink implements RenderLink {
|
||||
events: CustomEventTarget<LinkConnectorEventMap>
|
||||
) {
|
||||
const { fromSlot, fromReroute, existingLink } = this
|
||||
if (
|
||||
existingLink &&
|
||||
node.id === existingLink.target_id &&
|
||||
node.inputs[existingLink.target_slot] === input
|
||||
)
|
||||
return
|
||||
|
||||
const newLink = fromSlot.connect(input, node, fromReroute?.id)
|
||||
|
||||
|
||||
@@ -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",
|
||||
@@ -2824,5 +2825,15 @@
|
||||
"label": "Preview Version",
|
||||
"tooltip": "You are using a nightly version of ComfyUI. Please use the feedback button to share your thoughts about these features."
|
||||
}
|
||||
},
|
||||
"nodeFilters": {
|
||||
"hideDeprecated": "Hide Deprecated Nodes",
|
||||
"hideDeprecatedDescription": "Hides nodes marked as deprecated unless explicitly enabled",
|
||||
"hideExperimental": "Hide Experimental Nodes",
|
||||
"hideExperimentalDescription": "Hides nodes marked as experimental unless explicitly enabled",
|
||||
"hideDevOnly": "Hide Dev-Only Nodes",
|
||||
"hideDevOnlyDescription": "Hides nodes marked as dev-only unless dev mode is enabled",
|
||||
"hideSubgraph": "Hide Subgraph Nodes",
|
||||
"hideSubgraphDescription": "Temporarily hides subgraph nodes from node library and search"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
{
|
||||
|
||||
@@ -215,6 +215,8 @@ export const useWorkflowService = () => {
|
||||
}
|
||||
}
|
||||
|
||||
workflowDraftStore.removeDraft(workflow.path)
|
||||
|
||||
// If this is the last workflow, create a new default temporary workflow
|
||||
if (workflowStore.openWorkflows.length === 1) {
|
||||
await loadDefaultWorkflow()
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
useWorkflowBookmarkStore,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { defaultGraph, defaultGraphJSON } from '@/scripts/defaultGraph'
|
||||
@@ -910,4 +911,41 @@ describe('useWorkflowStore', () => {
|
||||
expect(mostRecent).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeWorkflow draft cleanup', () => {
|
||||
it('should remove draft for persisted workflows on close', async () => {
|
||||
const draftStore = useWorkflowDraftStore()
|
||||
await syncRemoteWorkflows(['a.json'])
|
||||
const workflow = store.getWorkflowByPath('workflows/a.json')!
|
||||
|
||||
draftStore.saveDraft('workflows/a.json', {
|
||||
data: '{"dirty":true}',
|
||||
updatedAt: Date.now(),
|
||||
name: 'a.json',
|
||||
isTemporary: false
|
||||
})
|
||||
expect(draftStore.getDraft('workflows/a.json')).toBeDefined()
|
||||
|
||||
await store.closeWorkflow(workflow)
|
||||
|
||||
expect(draftStore.getDraft('workflows/a.json')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should remove draft for temporary workflows on close', async () => {
|
||||
const draftStore = useWorkflowDraftStore()
|
||||
const workflow = store.createTemporary('temp.json')
|
||||
|
||||
draftStore.saveDraft(workflow.path, {
|
||||
data: '{"dirty":true}',
|
||||
updatedAt: Date.now(),
|
||||
name: 'temp.json',
|
||||
isTemporary: true
|
||||
})
|
||||
expect(draftStore.getDraft(workflow.path)).toBeDefined()
|
||||
|
||||
await store.closeWorkflow(workflow)
|
||||
|
||||
expect(draftStore.getDraft(workflow.path)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -463,11 +463,9 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
openWorkflowPaths.value = openWorkflowPaths.value.filter(
|
||||
(path) => path !== workflow.path
|
||||
)
|
||||
useWorkflowDraftStore().removeDraft(workflow.path)
|
||||
if (workflow.isTemporary) {
|
||||
// Clear thumbnail when temporary workflow is closed
|
||||
clearThumbnail(workflow.key)
|
||||
// Clear draft when unsaved workflow tab is closed
|
||||
useWorkflowDraftStore().removeDraft(workflow.path)
|
||||
delete workflowLookup.value[workflow.path]
|
||||
} else {
|
||||
workflow.unload()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
cn(
|
||||
'lg-slot lg-slot--input flex items-center group rounded-r-lg m-0',
|
||||
'cursor-crosshair',
|
||||
props.dotOnly ? 'lg-slot--dot-only' : 'pr-6',
|
||||
dotOnly ? 'lg-slot--dot-only' : 'pr-6',
|
||||
{
|
||||
'lg-slot--connected': props.connected,
|
||||
'lg-slot--compatible': props.compatible,
|
||||
@@ -36,7 +36,7 @@
|
||||
<!-- Slot Name -->
|
||||
<div class="h-full flex items-center min-w-0">
|
||||
<span
|
||||
v-if="!dotOnly"
|
||||
v-if="!props.dotOnly && !hasNoLabel"
|
||||
:class="
|
||||
cn(
|
||||
'truncate text-node-component-slot-text',
|
||||
@@ -47,8 +47,7 @@
|
||||
{{
|
||||
slotData.label ||
|
||||
slotData.localized_name ||
|
||||
slotData.name ||
|
||||
`Input ${index}`
|
||||
(slotData.name ?? `Input ${index}`)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
@@ -84,6 +83,14 @@ interface InputSlotProps {
|
||||
|
||||
const props = defineProps<InputSlotProps>()
|
||||
|
||||
const hasNoLabel = computed(
|
||||
() =>
|
||||
!props.slotData.label &&
|
||||
!props.slotData.localized_name &&
|
||||
props.slotData.name === ''
|
||||
)
|
||||
const dotOnly = computed(() => props.dotOnly || hasNoLabel.value)
|
||||
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
const hasSlotError = computed(() => {
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
<div v-else v-tooltip.right="tooltipConfig" :class="slotWrapperClass">
|
||||
<div class="relative h-full flex items-center min-w-0">
|
||||
<!-- Slot Name -->
|
||||
<span v-if="!dotOnly" class="truncate text-node-component-slot-text">
|
||||
{{ slotData.localized_name || slotData.name || `Output ${index}` }}
|
||||
<span
|
||||
v-if="!props.dotOnly && !hasNoLabel"
|
||||
class="truncate text-node-component-slot-text"
|
||||
>
|
||||
{{ slotData.localized_name || (slotData.name ?? `Output ${index}`) }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Connection Dot -->
|
||||
@@ -44,6 +47,11 @@ interface OutputSlotProps {
|
||||
|
||||
const props = defineProps<OutputSlotProps>()
|
||||
|
||||
const hasNoLabel = computed(
|
||||
() => !props.slotData.localized_name && props.slotData.name === ''
|
||||
)
|
||||
const dotOnly = computed(() => props.dotOnly || hasNoLabel.value)
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
|
||||
@@ -79,7 +87,7 @@ const slotWrapperClass = computed(() =>
|
||||
cn(
|
||||
'lg-slot lg-slot--output flex items-center justify-end group rounded-l-lg h-6',
|
||||
'cursor-crosshair',
|
||||
props.dotOnly ? 'lg-slot--dot-only justify-center' : 'pl-6',
|
||||
dotOnly.value ? 'lg-slot--dot-only justify-center' : 'pl-6',
|
||||
{
|
||||
'lg-slot--connected': props.connected,
|
||||
'lg-slot--compatible': props.compatible,
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -124,7 +124,9 @@ export function getWebpMetadata(file: File) {
|
||||
break
|
||||
}
|
||||
|
||||
offset += 8 + chunk_length
|
||||
// RIFF spec requires odd-sized chunks to be padded with a single byte
|
||||
// https://developers.google.com/speed/webp/docs/riff_container#riff_file_format
|
||||
offset += 8 + chunk_length + (chunk_length % 2)
|
||||
}
|
||||
|
||||
r(txt_chunks)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import _ from 'es-toolkit/compat'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { transformNodeDefV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
@@ -408,17 +409,16 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
|
||||
// Deprecated nodes filter
|
||||
registerNodeDefFilter({
|
||||
id: 'core.deprecated',
|
||||
name: 'Hide Deprecated Nodes',
|
||||
description: 'Hides nodes marked as deprecated unless explicitly enabled',
|
||||
name: t('nodeFilters.hideDeprecated'),
|
||||
description: t('nodeFilters.hideDeprecatedDescription'),
|
||||
predicate: (nodeDef) => showDeprecated.value || !nodeDef.deprecated
|
||||
})
|
||||
|
||||
// Experimental nodes filter
|
||||
registerNodeDefFilter({
|
||||
id: 'core.experimental',
|
||||
name: 'Hide Experimental Nodes',
|
||||
description:
|
||||
'Hides nodes marked as experimental unless explicitly enabled',
|
||||
name: t('nodeFilters.hideExperimental'),
|
||||
description: t('nodeFilters.hideExperimentalDescription'),
|
||||
predicate: (nodeDef) => showExperimental.value || !nodeDef.experimental
|
||||
})
|
||||
|
||||
@@ -426,9 +426,8 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
|
||||
// Filter out litegraph typed subgraphs, saved blueprints are added in separately
|
||||
registerNodeDefFilter({
|
||||
id: 'core.subgraph',
|
||||
name: 'Hide Subgraph Nodes',
|
||||
description:
|
||||
'Temporarily hides subgraph nodes from node library and search',
|
||||
name: t('nodeFilters.hideSubgraph'),
|
||||
description: t('nodeFilters.hideSubgraphDescription'),
|
||||
predicate: (nodeDef) => {
|
||||
// Hide subgraph nodes (identified by category='subgraph' and python_module='nodes')
|
||||
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