Compare commits

..

10 Commits

Author SHA1 Message Date
Terry Jia
639bcababf fix(vueNodes): sync extension-driven node.setSize() to Vue components 2026-01-24 10:48:42 -05:00
Alexander Brown
e8022f9dee Style: Dark mode body on load alternative (#8287)
## Summary

Alternative to https://github.com/Comfy-Org/ComfyUI_frontend/pull/8077

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8287-Style-Dark-mode-body-on-load-alternative-2f26d73d365081fb8231f167e75beb83)
by [Unito](https://www.unito.io)
2026-01-23 22:23:10 -08:00
AustinMroz
3bfd62b9fc Linear: progressbar, tooltips, and output fixes (#8250)
- Fixes only the first output being displayed in linear mode after the
jobs migration
- Fixes selected output no longer scrolling into view in history
- Adds a progress bar indicator on running job
<img width="113" height="102" alt="image"
src="https://github.com/user-attachments/assets/ca684dbe-12c8-44aa-98f0-2985c0159156"
/>
- Moves linear toggle button to v-tooltip
- Fixes placeholder sometimes continuing to display after a new output.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8250-Linear-progressbar-tooltips-and-output-fixes-2f06d73d365081ca9fa3ebf0e2516487)
by [Unito](https://www.unito.io)
2026-01-23 21:08:31 -08:00
Alexander Brown
15655ddb76 Updates: More Modal Modification (#8256)
Refactors modal dialog layouts for improved flexibility and consistency.

**Changes:**
- Add dedicated slot for left panel header title with dynamic
content/icons
- Consolidate side panel rendering within `BaseModalLayout`
- Remove redundant `PanelHeader` and `RightSidePanel` components
- Apply `select-none` to text elements to prevent accidental selection

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-01-23 20:41:35 -08:00
729_GM
e5583fe955 docs(locale zh): confused with the original EmptyImageLatent, two identical options are displayed on the interface but they aren't the same. (#8273)
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8273-docs-locale-zh-confused-with-the-original-EmptyImageLatent-two-identical-options-are-d-2f16d73d3650811b9a01dcfd058c1348)
by [Unito](https://www.unito.io)
2026-01-24 05:11:50 +01:00
Johnpaul Chiwetelu
b1d8bf0b13 refactor: eliminate unsafe type assertions from Group 2 test files (#8258)
## Summary
Improved type safety in test files by eliminating unsafe type assertions
and adopting official testing patterns. Reduced unsafe `as unknown as`
type assertions and eliminated all `null!` assertions.

## Changes
- **Adopted @pinia/testing patterns**
- Replaced manual Pinia store mocking with `createTestingPinia()` in
`useSelectionState.test.ts`
  - Eliminated ~120 lines of mock boilerplate
- Created `createMockSettingStore()` helper to replace duplicated store
mocks in `useCoreCommands.test.ts`

- **Eliminated unsafe null assertions**
- Created explicit `MockMaskEditorStore` interface with proper nullable
types in `useCanvasTools.test.ts`
- Replaced `null!` initializations with `null` and used `!` at point of
use or `?.` for optional chaining

- **Made partial mock intent explicit**
- Updated test utilities in `litegraphTestUtils.ts` to use explicit
`Partial<T>` typing
- Changed cast pattern from `as T` to `as Partial<T> as T` to show
incomplete mock intent
- Applied to `createMockLGraphNode()`, `createMockPositionable()`, and
`createMockLGraphGroup()`

- **Created centralized mock utilities** in
`src/utils/__tests__/litegraphTestUtils.ts`
- `createMockLGraphNode()`, `createMockPositionable()`,
`createMockLGraphGroup()`, `createMockSubgraphNode()`
  - Updated 8+ test files to use centralized utilities
- Used union types `Partial<T> | Record<string, unknown>` for flexible
mock creation

## Results
-  0 typecheck errors
-  0 lint errors  
-  All tests passing in modified files
-  Eliminated all `null!` assertions
-  Reduced unsafe double-cast patterns significantly

## Files Modified (18)
- `src/components/graph/SelectionToolbox.test.ts`
-
`src/components/graph/selectionToolbox/{BypassButton,ColorPickerButton,ExecuteButton}.test.ts`
- `src/components/sidebar/tabs/queue/ResultGallery.test.ts`
- `src/composables/canvas/useSelectedLiteGraphItems.test.ts`
- `src/composables/graph/{useGraphHierarchy,useSelectionState}.test.ts`
-
`src/composables/maskeditor/{useCanvasHistory,useCanvasManager,useCanvasTools,useCanvasTransform}.test.ts`
- `src/composables/node/{useNodePricing,useWatchWidget}.test.ts`
- `src/composables/{useBrowserTabTitle,useCoreCommands}.test.ts`
- `src/utils/__tests__/litegraphTestUtils.ts`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8258-refactor-eliminate-unsafe-type-assertions-from-Group-2-test-files-2f16d73d365081549c65fd546cc7c765)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-01-24 05:10:35 +01:00
Rizumu Ayaka
6b6b467e68 feat: implement fuzzy search for widgets and nodes using Fuse in Right Side Panel (#8043)
related
https://github.com/Comfy-Org/ComfyUI_frontend/pull/7812#discussion_r2685117810

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8043-feat-implement-fuzzy-search-for-widgets-and-nodes-using-Fuse-in-Right-Side-Panel-2e86d73d365081d7869cfa1956dfc0ad)
by [Unito](https://www.unito.io)
2026-01-23 20:43:09 -07:00
AustinMroz
ef2d34c560 Add 3d control buttons to linear mode (#8178)
Adds control buttons to the top left of the 3d preview in linear mode.
<img width="460" alt="image"
src="https://github.com/user-attachments/assets/35a83b9c-65af-46c3-a910-be5ad30c428e"
/>


This was deprioritized because I forgot the secret to magically
unwrapping a set of refs (wrap them in another ref).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8178-Add-3d-control-buttons-to-linear-mode-2ee6d73d3650816ab1a8e73ace1bdbc7)
by [Unito](https://www.unito.io)
2026-01-23 20:24:48 -07:00
Rizumu Ayaka
1b1356951e feat: add settings option to always show advanced widgets on all nodes (#8244)
Solved issue:
Currently, the display status of advanced widgets can only be set
individually for each node, but users would like to have a global switch
to always display all advanced widgets.

I also adjusted some related code to solve the issue of code
duplication.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8244-feat-add-settings-option-to-always-show-advanced-widgets-on-all-nodes-2f06d73d365081358023efa3e1ff3094)
by [Unito](https://www.unito.io)
2026-01-23 19:47:51 -07:00
Rizumu Ayaka
3e9a390c25 fix: letter sorting in image dropdown (#8277)
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8277-fix-letter-sorting-in-image-dropdown-2f16d73d3650818ea82dff944d345ec7)
by [Unito](https://www.unito.io)
2026-01-23 19:30:20 -07:00
91 changed files with 1644 additions and 2000 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -82,9 +82,7 @@ test.describe('Templates', () => {
await expect(comfyPage.templates.content).toBeVisible()
await comfyPage.page
.locator(
'nav > div:nth-child(3) > div > span:has-text("Getting Started")'
)
.getByRole('button', { name: 'Getting Started' })
.click()
await comfyPage.templates.loadTemplate('default')
await expect(comfyPage.templates.content).toBeHidden()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -10,7 +10,13 @@
<meta name="mobile-web-app-capable" content="yes">
<!-- Status bar style (eg. black or transparent) -->
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<style>
@media (prefers-color-scheme: dark) {
body {
background-color: #202020;
}
}
</style>
<link rel="manifest" href="manifest.json">
</head>

View File

@@ -11,6 +11,5 @@
}
],
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#000000"
}

View File

@@ -584,8 +584,6 @@ body {
height: 100vh;
margin: 0;
overflow: hidden;
background: var(--bg-color) var(--bg-img);
color: var(--fg-color);
min-height: -webkit-fill-available;
max-height: -webkit-fill-available;
min-width: -webkit-fill-available;

View File

@@ -2,8 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import type { MenuItem } from 'primevue/menuitem'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, defineComponent, h, nextTick, onMounted } from 'vue'
import type { Component } from 'vue'
import { computed, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import TopMenuSection from '@/components/TopMenuSection.vue'
@@ -15,7 +14,6 @@ import type {
} from '@/platform/remote/comfyui/jobs/jobTypes'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { isElectron } from '@/utils/envUtil'
@@ -38,17 +36,7 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
}))
}))
type WrapperOptions = {
pinia?: ReturnType<typeof createTestingPinia>
stubs?: Record<string, boolean | Component>
attachTo?: HTMLElement
}
function createWrapper({
pinia = createTestingPinia({ createSpy: vi.fn }),
stubs = {},
attachTo
}: WrapperOptions = {}) {
function createWrapper(pinia = createTestingPinia({ createSpy: vi.fn })) {
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -67,21 +55,18 @@ function createWrapper({
})
return mount(TopMenuSection, {
attachTo,
global: {
plugins: [pinia, i18n],
stubs: {
SubgraphBreadcrumb: true,
QueueProgressOverlay: true,
QueueInlineProgressSummary: true,
CurrentUserButton: true,
LoginButton: true,
ContextMenu: {
name: 'ContextMenu',
props: ['model'],
template: '<div />'
},
...stubs
}
},
directives: {
tooltip: () => {}
@@ -106,7 +91,6 @@ function createTask(id: string, status: JobStatus): TaskItemImpl {
describe('TopMenuSection', () => {
beforeEach(() => {
vi.resetAllMocks()
localStorage.clear()
})
describe('authentication state', () => {
@@ -167,7 +151,7 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper({ pinia })
const wrapper = createWrapper(pinia)
await nextTick()
@@ -185,7 +169,7 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? false : undefined
)
const wrapper = createWrapper({ pinia })
const wrapper = createWrapper(pinia)
const commandStore = useCommandStore(pinia)
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
@@ -201,7 +185,7 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper({ pinia })
const wrapper = createWrapper(pinia)
const sidebarTabStore = useSidebarTabStore(pinia)
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
@@ -215,7 +199,7 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper({ pinia })
const wrapper = createWrapper(pinia)
const sidebarTabStore = useSidebarTabStore(pinia)
const toggleButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
@@ -226,84 +210,6 @@ describe('TopMenuSection', () => {
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
})
describe('inline progress summary', () => {
const configureSettings = (
pinia: ReturnType<typeof createTestingPinia>,
qpoV2Enabled: boolean
) => {
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) => {
if (key === 'Comfy.Queue.QPOV2') return qpoV2Enabled
if (key === 'Comfy.UseNewMenu') return 'Top'
return undefined
})
}
it('renders inline progress summary when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const wrapper = createWrapper({ pinia })
await nextTick()
expect(
wrapper.findComponent({ name: 'QueueInlineProgressSummary' }).exists()
).toBe(true)
})
it('does not render inline progress summary when QPO V2 is disabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, false)
const wrapper = createWrapper({ pinia })
await nextTick()
expect(
wrapper.findComponent({ name: 'QueueInlineProgressSummary' }).exists()
).toBe(false)
})
it('teleports inline progress summary when actionbar is floating', async () => {
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
const actionbarTarget = document.createElement('div')
document.body.appendChild(actionbarTarget)
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const executionStore = useExecutionStore(pinia)
executionStore.activePromptId = 'prompt-1'
const ComfyActionbarStub = defineComponent({
name: 'ComfyActionbar',
setup(_, { emit }) {
onMounted(() => {
emit('update:progressTarget', actionbarTarget)
})
return () => h('div')
}
})
const wrapper = createWrapper({
pinia,
attachTo: document.body,
stubs: {
ComfyActionbar: ComfyActionbarStub,
QueueInlineProgressSummary: false
}
})
try {
await nextTick()
expect(actionbarTarget.querySelector('[role="status"]')).not.toBeNull()
} finally {
wrapper.unmount()
actionbarTarget.remove()
}
})
})
it('disables the clear queue context menu item when no queued jobs exist', () => {
const wrapper = createWrapper()
const menu = wrapper.findComponent({ name: 'ContextMenu' })

View File

@@ -1,130 +1,101 @@
<template>
<div
v-if="!workspaceStore.focusMode"
class="ml-1 flex flex-col gap-1 pt-1"
class="ml-1 flex gap-x-0.5 pt-1"
@mouseenter="isTopMenuHovered = true"
@mouseleave="isTopMenuHovered = false"
>
<div class="flex gap-x-0.5">
<div class="min-w-0 flex-1">
<SubgraphBreadcrumb />
</div>
<div class="mx-1 flex flex-col items-end gap-1">
<div class="flex items-center gap-2">
<div
v-if="managerState.shouldShowManagerButtons.value"
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<Button
v-tooltip.bottom="customNodesManagerTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('menu.customNodesManager')"
class="relative"
@click="openCustomNodeManager"
>
<i class="icon-[lucide--puzzle] size-4" />
<span
v-if="shouldShowRedDot"
class="absolute top-0.5 right-1 size-2 rounded-full bg-red-500"
/>
</Button>
</div>
<div
ref="actionbarContainerRef"
class="actionbar-container relative pointer-events-auto flex gap-2 h-12 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
ref="legacyCommandsContainerRef"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar
:top-menu-container="actionbarContainerRef"
:queue-overlay-expanded="isQueueOverlayExpanded"
@update:progress-target="updateProgressTarget"
/>
<Button
v-tooltip.bottom="queueHistoryTooltipConfig"
type="destructive"
size="md"
:aria-pressed="
isQueuePanelV2Enabled
? activeSidebarTabId === 'assets'
: isQueueProgressOverlayEnabled
? isQueueOverlayExpanded
: undefined
"
class="px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
>
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<span class="sr-only">
{{
isQueuePanelV2Enabled
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
}}
</span>
</Button>
<ContextMenu
ref="queueContextMenu"
:model="queueContextMenuItems"
/>
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
/>
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
<Button
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
type="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
</div>
</div>
<QueueProgressOverlay
v-if="isQueueProgressOverlayEnabled"
v-model:expanded="isQueueOverlayExpanded"
:menu-hovered="isTopMenuHovered"
/>
</div>
<div class="min-w-0 flex-1">
<SubgraphBreadcrumb />
</div>
<div>
<Teleport
v-if="inlineProgressSummaryTarget"
:to="inlineProgressSummaryTarget"
>
<div class="mx-1 flex flex-col items-end gap-1">
<div class="flex items-center gap-2">
<div
class="pointer-events-none absolute left-0 right-0 top-full mt-1 flex justify-end pr-1"
v-if="managerState.shouldShowManagerButtons.value"
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<QueueInlineProgressSummary :hidden="isQueueOverlayExpanded" />
<Button
v-tooltip.bottom="customNodesManagerTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('menu.customNodesManager')"
class="relative"
@click="openCustomNodeManager"
>
<i class="icon-[lucide--puzzle] size-4" />
<span
v-if="shouldShowRedDot"
class="absolute top-0.5 right-1 size-2 rounded-full bg-red-500"
/>
</Button>
</div>
</Teleport>
<QueueInlineProgressSummary
v-else-if="shouldShowInlineProgressSummary && !isActionbarFloating"
class="pr-1"
:hidden="isQueueOverlayExpanded"
<div
class="actionbar-container pointer-events-auto flex gap-2 h-12 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
ref="legacyCommandsContainerRef"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar />
<Button
v-tooltip.bottom="queueHistoryTooltipConfig"
type="destructive"
size="md"
:aria-pressed="
isQueuePanelV2Enabled
? activeSidebarTabId === 'assets'
: isQueueProgressOverlayEnabled
? isQueueOverlayExpanded
: undefined
"
class="px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
>
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<span class="sr-only">
{{
isQueuePanelV2Enabled
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
}}
</span>
</Button>
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
/>
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
<Button
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
type="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
</div>
</div>
<QueueProgressOverlay
v-if="isQueueProgressOverlayEnabled"
v-model:expanded="isQueueOverlayExpanded"
:menu-hovered="isTopMenuHovered"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
@@ -133,7 +104,6 @@ import { useI18n } from 'vue-i18n'
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
@@ -177,15 +147,6 @@ const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
const { shouldShowRedDot: shouldShowConflictRedDot } =
useConflictAcknowledgment()
const isTopMenuHovered = ref(false)
const actionbarContainerRef = ref<HTMLElement>()
const isActionbarDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
const actionbarPosition = computed(() => settingStore.get('Comfy.UseNewMenu'))
const isActionbarEnabled = computed(
() => actionbarPosition.value !== 'Disabled'
)
const isActionbarFloating = computed(
() => isActionbarEnabled.value && !isActionbarDocked.value
)
const activeJobsLabel = computed(() => {
const count = activeJobsCount.value
return t(
@@ -203,19 +164,6 @@ const isQueuePanelV2Enabled = computed(() =>
const isQueueProgressOverlayEnabled = computed(
() => !isQueuePanelV2Enabled.value
)
const shouldShowInlineProgressSummary = computed(
() => isQueuePanelV2Enabled.value && isActionbarEnabled.value
)
const progressTarget = ref<HTMLElement | null>(null)
function updateProgressTarget(target: HTMLElement | null) {
progressTarget.value = target
}
const inlineProgressSummaryTarget = computed(() => {
if (!shouldShowInlineProgressSummary.value || !isActionbarFloating.value) {
return null
}
return progressTarget.value
})
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
)

View File

@@ -10,7 +10,6 @@
</div>
<Panel
ref="panelRef"
class="pointer-events-auto"
:style="style"
:class="panelClass"
@@ -19,7 +18,7 @@
content: { class: isDocked ? 'p-0' : 'p-1' }
}"
>
<div class="relative flex items-center select-none gap-2">
<div ref="panelRef" class="flex items-center select-none gap-2">
<span
ref="dragHandleRef"
:class="
@@ -44,14 +43,6 @@
</Button>
</div>
</Panel>
<Teleport v-if="inlineProgressTarget" :to="inlineProgressTarget">
<QueueInlineProgress
:hidden="queueOverlayExpanded"
:radius-class="cn(isDocked ? 'rounded-[7px]' : 'rounded-[5px]')"
data-testid="queue-inline-progress"
/>
</Teleport>
</div>
</template>
@@ -60,17 +51,14 @@ import {
useDraggable,
useEventListener,
useLocalStorage,
unrefElement,
watchDebounced
} from '@vueuse/core'
import { clamp } from 'es-toolkit/compat'
import { storeToRefs } from 'pinia'
import Panel from 'primevue/panel'
import { computed, nextTick, ref, watch } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
import Button from '@/components/ui/button/Button.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -81,15 +69,6 @@ import { cn } from '@/utils/tailwindUtil'
import ComfyRunButton from './ComfyRunButton'
const { topMenuContainer, queueOverlayExpanded = false } = defineProps<{
topMenuContainer?: HTMLElement | null
queueOverlayExpanded?: boolean
}>()
const emit = defineEmits<{
(event: 'update:progressTarget', target: HTMLElement | null): void
}>()
const settingsStore = useSettingStore()
const commandStore = useCommandStore()
const { t } = useI18n()
@@ -97,22 +76,15 @@ const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
const visible = computed(() => position.value !== 'Disabled')
const isQueuePanelV2Enabled = computed(() =>
settingsStore.get('Comfy.Queue.QPOV2')
)
const panelRef = ref<ComponentPublicInstance | null>(null)
const panelElement = computed<HTMLElement | null>(() => {
const element = unrefElement(panelRef)
return element instanceof HTMLElement ? element : null
})
const panelRef = ref<HTMLElement | null>(null)
const dragHandleRef = ref<HTMLElement | null>(null)
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
const storedPosition = useLocalStorage('Comfy.MenuPosition.Floating', {
x: 0,
y: 0
})
const { x, y, style, isDragging } = useDraggable(panelElement, {
const { x, y, style, isDragging } = useDraggable(panelRef, {
initialValue: { x: 0, y: 0 },
handle: dragHandleRef,
containerElement: document.body
@@ -129,12 +101,11 @@ watchDebounced(
// Set initial position to bottom center
const setInitialPosition = () => {
const panel = panelElement.value
if (panel) {
if (panelRef.value) {
const screenWidth = window.innerWidth
const screenHeight = window.innerHeight
const menuWidth = panel.offsetWidth
const menuHeight = panel.offsetHeight
const menuWidth = panelRef.value.offsetWidth
const menuHeight = panelRef.value.offsetHeight
if (menuWidth === 0 || menuHeight === 0) {
return
@@ -210,12 +181,11 @@ watch(
)
const adjustMenuPosition = () => {
const panel = panelElement.value
if (panel) {
if (panelRef.value) {
const screenWidth = window.innerWidth
const screenHeight = window.innerHeight
const menuWidth = panel.offsetWidth
const menuHeight = panel.offsetHeight
const menuWidth = panelRef.value.offsetWidth
const menuHeight = panelRef.value.offsetHeight
// Calculate distances to all edges
const distanceLeft = lastDragState.value.x
@@ -282,19 +252,6 @@ const onMouseLeaveDropZone = () => {
}
}
const inlineProgressTarget = computed(() => {
if (!visible.value || !isQueuePanelV2Enabled.value) return null
if (isDocked.value) return topMenuContainer ?? null
return panelElement.value
})
watch(
panelElement,
(target) => {
emit('update:progressTarget', target)
},
{ immediate: true }
)
// Handle drag state changes
watch(isDragging, (dragging) => {
if (dragging) {

View File

@@ -3,17 +3,14 @@
:content-title="$t('templateWorkflows.title', 'Workflow Templates')"
class="workflow-template-selector-dialog"
>
<template #leftPanelHeaderTitle>
<i class="icon-[comfy--template]" />
<h2 class="text-neutral text-base">
{{ $t('sideToolbar.templates', 'Templates') }}
</h2>
</template>
<template #leftPanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="navItems">
<template #header-icon>
<i class="icon-[comfy--template]" />
</template>
<template #header-title>
<span class="text-neutral text-base">{{
$t('sideToolbar.templates', 'Templates')
}}</span>
</template>
</LeftSidePanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="navItems" />
</template>
<template #header>

View File

@@ -5,9 +5,26 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useExtensionService } from '@/services/extensionService'
import {
createMockCanvas,
createMockPositionable
} from '@/utils/__tests__/litegraphTestUtils'
function createMockExtensionService(): ReturnType<typeof useExtensionService> {
return {
extensionCommands: { value: new Map() },
loadExtensions: vi.fn(),
registerExtension: vi.fn(),
invokeExtensions: vi.fn(() => []),
invokeExtensionsAsync: vi.fn()
} as Partial<ReturnType<typeof useExtensionService>> as ReturnType<
typeof useExtensionService
>
}
// Mock the composables and services
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
@@ -112,12 +129,7 @@ describe('SelectionToolbox', () => {
canvasStore = useCanvasStore()
// Mock the canvas to avoid "getCanvas: canvas is null" errors
canvasStore.canvas = {
setDirty: vi.fn(),
state: {
selectionChanged: false
}
} as any
canvasStore.canvas = createMockCanvas()
vi.resetAllMocks()
})
@@ -184,30 +196,27 @@ describe('SelectionToolbox', () => {
describe('Button Visibility Logic', () => {
beforeEach(() => {
const mockExtensionService = vi.mocked(useExtensionService)
mockExtensionService.mockReturnValue({
extensionCommands: { value: new Map() },
invokeExtensions: vi.fn(() => [])
} as any)
mockExtensionService.mockReturnValue(createMockExtensionService())
})
it('should show info button only for single selections', () => {
// Single node selection
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
expect(wrapper.find('.info-button').exists()).toBe(true)
// Multiple node selection
canvasStore.selectedItems = [
{ type: 'TestNode1' },
{ type: 'TestNode2' }
] as any
createMockPositionable(),
createMockPositionable()
]
wrapper.unmount()
const wrapper2 = mountComponent()
expect(wrapper2.find('.info-button').exists()).toBe(false)
})
it('should not show info button when node definition is not found', () => {
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
// mock nodedef and return null
nodeDefMock = null
// remount component
@@ -217,7 +226,7 @@ describe('SelectionToolbox', () => {
it('should show color picker for all selections', () => {
// Single node selection
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
expect(wrapper.find('[data-testid="color-picker-button"]').exists()).toBe(
true
@@ -225,9 +234,9 @@ describe('SelectionToolbox', () => {
// Multiple node selection
canvasStore.selectedItems = [
{ type: 'TestNode1' },
{ type: 'TestNode2' }
] as any
createMockPositionable(),
createMockPositionable()
]
wrapper.unmount()
const wrapper2 = mountComponent()
expect(
@@ -237,15 +246,15 @@ describe('SelectionToolbox', () => {
it('should show frame nodes only for multiple selections', () => {
// Single node selection
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
expect(wrapper.find('.frame-nodes').exists()).toBe(false)
// Multiple node selection
canvasStore.selectedItems = [
{ type: 'TestNode1' },
{ type: 'TestNode2' }
] as any
createMockPositionable(),
createMockPositionable()
]
wrapper.unmount()
const wrapper2 = mountComponent()
expect(wrapper2.find('.frame-nodes').exists()).toBe(true)
@@ -253,22 +262,22 @@ describe('SelectionToolbox', () => {
it('should show bypass button for appropriate selections', () => {
// Single node selection
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
expect(wrapper.find('[data-testid="bypass-button"]').exists()).toBe(true)
// Multiple node selection
canvasStore.selectedItems = [
{ type: 'TestNode1' },
{ type: 'TestNode2' }
] as any
createMockPositionable(),
createMockPositionable()
]
wrapper.unmount()
const wrapper2 = mountComponent()
expect(wrapper2.find('[data-testid="bypass-button"]').exists()).toBe(true)
})
it('should show common buttons for all selections', () => {
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
expect(wrapper.find('[data-testid="delete-button"]').exists()).toBe(true)
@@ -286,13 +295,13 @@ describe('SelectionToolbox', () => {
// Single image node
isImageNodeSpy.mockReturnValue(true)
canvasStore.selectedItems = [{ type: 'ImageNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
expect(wrapper.find('.mask-editor-button').exists()).toBe(true)
// Single non-image node
isImageNodeSpy.mockReturnValue(false)
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
wrapper.unmount()
const wrapper2 = mountComponent()
expect(wrapper2.find('.mask-editor-button').exists()).toBe(false)
@@ -304,13 +313,13 @@ describe('SelectionToolbox', () => {
// Single Load3D node
isLoad3dNodeSpy.mockReturnValue(true)
canvasStore.selectedItems = [{ type: 'Load3DNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
expect(wrapper.find('.load-3d-viewer-button').exists()).toBe(true)
// Single non-Load3D node
isLoad3dNodeSpy.mockReturnValue(false)
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
wrapper.unmount()
const wrapper2 = mountComponent()
expect(wrapper2.find('.load-3d-viewer-button').exists()).toBe(false)
@@ -326,17 +335,17 @@ describe('SelectionToolbox', () => {
// With output node selected
isOutputNodeSpy.mockReturnValue(true)
filterOutputNodesSpy.mockReturnValue([{ type: 'SaveImage' }] as any)
canvasStore.selectedItems = [
{ type: 'SaveImage', constructor: { nodeData: { output_node: true } } }
] as any
filterOutputNodesSpy.mockReturnValue([
{ type: 'SaveImage' }
] as LGraphNode[])
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
expect(wrapper.find('.execute-button').exists()).toBe(true)
// Without output node selected
isOutputNodeSpy.mockReturnValue(false)
filterOutputNodesSpy.mockReturnValue([])
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
wrapper.unmount()
const wrapper2 = mountComponent()
expect(wrapper2.find('.execute-button').exists()).toBe(false)
@@ -352,7 +361,7 @@ describe('SelectionToolbox', () => {
describe('Divider Visibility Logic', () => {
it('should show dividers between button groups when both groups have buttons', () => {
// Setup single node to show info + other buttons
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
const dividers = wrapper.findAll('.vertical-divider')
@@ -378,10 +387,13 @@ describe('SelectionToolbox', () => {
['test-command', { id: 'test-command', title: 'Test Command' }]
])
},
invokeExtensions: vi.fn(() => ['test-command'])
} as any)
loadExtensions: vi.fn(),
registerExtension: vi.fn(),
invokeExtensions: vi.fn(() => ['test-command']),
invokeExtensionsAsync: vi.fn()
} as ReturnType<typeof useExtensionService>)
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
expect(wrapper.find('.extension-command-button').exists()).toBe(true)
@@ -389,12 +401,9 @@ describe('SelectionToolbox', () => {
it('should not render extension commands when none available', () => {
const mockExtensionService = vi.mocked(useExtensionService)
mockExtensionService.mockReturnValue({
extensionCommands: { value: new Map() },
invokeExtensions: vi.fn(() => [])
} as any)
mockExtensionService.mockReturnValue(createMockExtensionService())
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
expect(wrapper.find('.extension-command-button').exists()).toBe(false)
@@ -404,12 +413,9 @@ describe('SelectionToolbox', () => {
describe('Container Styling', () => {
it('should apply minimap container styles', () => {
const mockExtensionService = vi.mocked(useExtensionService)
mockExtensionService.mockReturnValue({
extensionCommands: { value: new Map() },
invokeExtensions: vi.fn(() => [])
} as any)
mockExtensionService.mockReturnValue(createMockExtensionService())
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
const panel = wrapper.find('.panel')
@@ -418,12 +424,9 @@ describe('SelectionToolbox', () => {
it('should have correct CSS classes', () => {
const mockExtensionService = vi.mocked(useExtensionService)
mockExtensionService.mockReturnValue({
extensionCommands: { value: new Map() },
invokeExtensions: vi.fn(() => [])
} as any)
mockExtensionService.mockReturnValue(createMockExtensionService())
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
const panel = wrapper.find('.panel')
@@ -435,12 +438,9 @@ describe('SelectionToolbox', () => {
it('should handle animation class conditionally', () => {
const mockExtensionService = vi.mocked(useExtensionService)
mockExtensionService.mockReturnValue({
extensionCommands: { value: new Map() },
invokeExtensions: vi.fn(() => [])
} as any)
mockExtensionService.mockReturnValue(createMockExtensionService())
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
const panel = wrapper.find('.panel')
@@ -453,16 +453,18 @@ describe('SelectionToolbox', () => {
const mockCanvasInteractions = vi.mocked(useCanvasInteractions)
const forwardEventToCanvasSpy = vi.fn()
mockCanvasInteractions.mockReturnValue({
forwardEventToCanvas: forwardEventToCanvasSpy
} as any)
handleWheel: vi.fn(),
handlePointer: vi.fn(),
forwardEventToCanvas: forwardEventToCanvasSpy,
shouldHandleNodePointerEvents: { value: true } as ReturnType<
typeof useCanvasInteractions
>['shouldHandleNodePointerEvents']
} as ReturnType<typeof useCanvasInteractions>)
const mockExtensionService = vi.mocked(useExtensionService)
mockExtensionService.mockReturnValue({
extensionCommands: { value: new Map() },
invokeExtensions: vi.fn(() => [])
} as any)
mockExtensionService.mockReturnValue(createMockExtensionService())
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
const panel = wrapper.find('.panel')
@@ -475,10 +477,7 @@ describe('SelectionToolbox', () => {
describe('No Selection State', () => {
beforeEach(() => {
const mockExtensionService = vi.mocked(useExtensionService)
mockExtensionService.mockReturnValue({
extensionCommands: { value: new Map() },
invokeExtensions: vi.fn(() => [])
} as any)
mockExtensionService.mockReturnValue(createMockExtensionService())
})
it('should hide most buttons when no items selected', () => {

View File

@@ -6,14 +6,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
const mockLGraphNode = {
type: 'TestNode',
title: 'Test Node',
mode: LGraphEventMode.ALWAYS
function getMockLGraphNode(): LGraphNode {
return createMockLGraphNode({ type: 'TestNode' })
}
vi.mock('@/utils/litegraphUtil', () => ({
@@ -59,21 +59,21 @@ describe('BypassButton', () => {
}
it('should render bypass button', () => {
canvasStore.selectedItems = [mockLGraphNode] as any
canvasStore.selectedItems = [getMockLGraphNode()]
const wrapper = mountComponent()
const button = wrapper.find('button')
expect(button.exists()).toBe(true)
})
it('should have correct test id', () => {
canvasStore.selectedItems = [mockLGraphNode] as any
canvasStore.selectedItems = [getMockLGraphNode()]
const wrapper = mountComponent()
const button = wrapper.find('[data-testid="bypass-button"]')
expect(button.exists()).toBe(true)
})
it('should execute bypass command when clicked', async () => {
canvasStore.selectedItems = [mockLGraphNode] as any
canvasStore.selectedItems = [getMockLGraphNode()]
const executeSpy = vi.spyOn(commandStore, 'execute').mockResolvedValue()
const wrapper = mountComponent()
@@ -85,8 +85,11 @@ describe('BypassButton', () => {
})
it('should show bypassed styling when node is bypassed', async () => {
const bypassedNode = { ...mockLGraphNode, mode: LGraphEventMode.BYPASS }
canvasStore.selectedItems = [bypassedNode] as any
const bypassedNode: Partial<LGraphNode> = {
...getMockLGraphNode(),
mode: LGraphEventMode.BYPASS
}
canvasStore.selectedItems = [bypassedNode as LGraphNode]
vi.spyOn(commandStore, 'execute').mockResolvedValue()
const wrapper = mountComponent()
@@ -100,7 +103,7 @@ describe('BypassButton', () => {
it('should handle multiple selected items', () => {
vi.spyOn(commandStore, 'execute').mockResolvedValue()
canvasStore.selectedItems = [mockLGraphNode, mockLGraphNode] as any
canvasStore.selectedItems = [getMockLGraphNode(), getMockLGraphNode()]
const wrapper = mountComponent()
const button = wrapper.find('button')
expect(button.exists()).toBe(true)

View File

@@ -1,3 +1,4 @@
import type { Mock } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
@@ -8,7 +9,20 @@ import { createI18n } from 'vue-i18n'
// Import after mocks
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { createMockPositionable } from '@/utils/__tests__/litegraphTestUtils'
function createMockWorkflow(
overrides: Partial<LoadedComfyWorkflow> = {}
): LoadedComfyWorkflow {
return {
changeTracker: {
checkState: vi.fn() as Mock
},
...overrides
} as Partial<LoadedComfyWorkflow> as LoadedComfyWorkflow
}
// Mock the litegraph module
vi.mock('@/lib/litegraph/src/litegraph', async () => {
@@ -70,11 +84,7 @@ describe('ColorPickerButton', () => {
canvasStore.selectedItems = []
// Mock workflow store
workflowStore.activeWorkflow = {
changeTracker: {
checkState: vi.fn()
}
} as any
workflowStore.activeWorkflow = createMockWorkflow()
})
const createWrapper = () => {
@@ -90,13 +100,13 @@ describe('ColorPickerButton', () => {
it('should render when nodes are selected', () => {
// Add a mock node to selectedItems
canvasStore.selectedItems = [{ type: 'LGraphNode' } as any]
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = createWrapper()
expect(wrapper.find('button').exists()).toBe(true)
})
it('should toggle color picker visibility on button click', async () => {
canvasStore.selectedItems = [{ type: 'LGraphNode' } as any]
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = createWrapper()
const button = wrapper.find('button')

View File

@@ -1,23 +1,16 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
// Mock the stores
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn()
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: vi.fn()
}))
// Mock the utils
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn((node) => !!node?.type)
@@ -37,10 +30,8 @@ vi.mock('@/composables/graph/useSelectionState', () => ({
}))
describe('ExecuteButton', () => {
let mockCanvas: any
let mockCanvasStore: any
let mockCommandStore: any
let mockSelectedNodes: any[]
let mockCanvas: LGraphCanvas
let mockSelectedNodes: LGraphNode[]
const i18n = createI18n({
legacy: false,
@@ -57,27 +48,27 @@ describe('ExecuteButton', () => {
})
beforeEach(async () => {
setActivePinia(createPinia())
// Set up Pinia with testing utilities
setActivePinia(
createTestingPinia({
createSpy: vi.fn
})
)
// Reset mocks
mockCanvas = {
const partialCanvas: Partial<LGraphCanvas> = {
setDirty: vi.fn()
}
mockCanvas = partialCanvas as Partial<LGraphCanvas> as LGraphCanvas
mockSelectedNodes = []
mockCanvasStore = {
getCanvas: vi.fn(() => mockCanvas),
selectedItems: []
}
// Get store instances and mock methods
const canvasStore = useCanvasStore()
const commandStore = useCommandStore()
mockCommandStore = {
execute: vi.fn()
}
// Setup store mocks
vi.mocked(useCanvasStore).mockReturnValue(mockCanvasStore as any)
vi.mocked(useCommandStore).mockReturnValue(mockCommandStore as any)
vi.spyOn(canvasStore, 'getCanvas').mockReturnValue(mockCanvas)
vi.spyOn(commandStore, 'execute').mockResolvedValue()
// Update the useSelectionState mock
const { useSelectionState } = vi.mocked(
@@ -87,7 +78,7 @@ describe('ExecuteButton', () => {
selectedNodes: {
value: mockSelectedNodes
}
} as any)
} as ReturnType<typeof useSelectionState>)
vi.clearAllMocks()
})
@@ -114,15 +105,16 @@ describe('ExecuteButton', () => {
describe('Click Handler', () => {
it('should execute Comfy.QueueSelectedOutputNodes command on click', async () => {
const commandStore = useCommandStore()
const wrapper = mountComponent()
const button = wrapper.find('button')
await button.trigger('click')
expect(mockCommandStore.execute).toHaveBeenCalledWith(
expect(commandStore.execute).toHaveBeenCalledWith(
'Comfy.QueueSelectedOutputNodes'
)
expect(mockCommandStore.execute).toHaveBeenCalledTimes(1)
expect(commandStore.execute).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -1,75 +0,0 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type { Ref } from 'vue'
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
const mockProgress = vi.hoisted(() => ({
totalPercent: null as unknown as Ref<number>,
currentNodePercent: null as unknown as Ref<number>
}))
vi.mock('@/composables/queue/useQueueProgress', () => ({
useQueueProgress: () => ({
totalPercent: mockProgress.totalPercent,
currentNodePercent: mockProgress.currentNodePercent
})
}))
const createWrapper = (props: { hidden?: boolean } = {}) =>
mount(QueueInlineProgress, { props })
describe('QueueInlineProgress', () => {
beforeEach(() => {
mockProgress.totalPercent = ref(0)
mockProgress.currentNodePercent = ref(0)
})
it('renders when total progress is non-zero', () => {
mockProgress.totalPercent.value = 12
const wrapper = createWrapper()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
})
it('renders when current node progress is non-zero', () => {
mockProgress.currentNodePercent.value = 33
const wrapper = createWrapper()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
})
it('does not render when hidden', () => {
mockProgress.totalPercent.value = 45
const wrapper = createWrapper({ hidden: true })
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(false)
})
it('shows when progress becomes non-zero', async () => {
const wrapper = createWrapper()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(false)
mockProgress.totalPercent.value = 10
await nextTick()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
})
it('hides when progress returns to zero', async () => {
mockProgress.totalPercent.value = 10
const wrapper = createWrapper()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
mockProgress.totalPercent.value = 0
mockProgress.currentNodePercent.value = 0
await nextTick()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(false)
})
})

View File

@@ -1,36 +0,0 @@
<template>
<div
v-if="shouldShow"
aria-hidden="true"
:class="
cn('pointer-events-none absolute inset-0 overflow-hidden', radiusClass)
"
>
<div
class="pointer-events-none absolute bottom-0 left-0 h-[3px] bg-interface-panel-job-progress-primary transition-[width]"
:style="{ width: `${totalPercent}%` }"
/>
<div
class="pointer-events-none absolute bottom-0 left-0 h-[3px] bg-interface-panel-job-progress-secondary transition-[width]"
:style="{ width: `${currentNodePercent}%` }"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { cn } from '@/utils/tailwindUtil'
const { hidden = false, radiusClass = 'rounded-[7px]' } = defineProps<{
hidden?: boolean
radiusClass?: string
}>()
const { totalPercent, currentNodePercent } = useQueueProgress()
const shouldShow = computed(
() => !hidden && (totalPercent.value > 0 || currentNodePercent.value > 0)
)
</script>

View File

@@ -1,70 +0,0 @@
<template>
<div v-if="shouldShow" class="flex justify-end">
<div
class="flex items-center whitespace-nowrap text-[0.75rem] leading-[normal] drop-shadow-[1px_1px_8px_rgba(0,0,0,0.4)]"
role="status"
aria-live="polite"
aria-atomic="true"
>
<div class="flex items-center text-base-foreground">
<span class="font-normal">
{{ t('sideToolbar.queueProgressOverlay.inlineTotalLabel') }}:
</span>
<span class="w-[5ch] shrink-0 text-right font-bold tabular-nums">
{{ totalPercentFormatted }}
</span>
</div>
<div class="flex items-center text-muted-foreground">
<span
class="w-[16ch] shrink-0 truncate text-right"
:title="currentNodeName"
>
{{ currentNodeName }}:
</span>
<span class="w-[5ch] shrink-0 text-right tabular-nums">
{{ currentNodePercentFormatted }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { st } from '@/i18n'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { useExecutionStore } from '@/stores/executionStore'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
const props = defineProps<{
hidden?: boolean
}>()
const { t } = useI18n()
const executionStore = useExecutionStore()
const {
totalPercent,
totalPercentFormatted,
currentNodePercent,
currentNodePercentFormatted
} = useQueueProgress()
const currentNodeName = computed(() => {
return resolveNodeDisplayName(executionStore.executingNode, {
emptyLabel: t('g.emDash'),
untitledLabel: t('g.untitled'),
st
})
})
const shouldShow = computed(
() =>
!props.hidden &&
(!executionStore.isIdle ||
totalPercent.value > 0 ||
currentNodePercent.value > 0)
)
</script>

View File

@@ -264,7 +264,6 @@ const focusAssetInSidebar = async (item: JobListItem) => {
throw new Error('Asset not found in media assets panel')
}
assetSelectionStore.setSelection([assetId])
assetSelectionStore.setLastSelectedAssetId(assetId)
}
const inspectJobAsset = wrapWithErrorHandlingAsync(

View File

@@ -8,14 +8,12 @@ import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import { useGraphHierarchy } from '@/composables/graph/useGraphHierarchy'
import { st } from '@/i18n'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@/utils/tailwindUtil'
import TabInfo from './info/TabInfo.vue'
@@ -148,12 +146,9 @@ function resolveTitle() {
return groups[0].title || t('rightSidePanel.fallbackGroupTitle')
}
if (nodes.length === 1) {
const fallbackNodeTitle = t('rightSidePanel.fallbackNodeTitle')
return resolveNodeDisplayName(nodes[0], {
emptyLabel: fallbackNodeTitle,
untitledLabel: fallbackNodeTitle,
st
})
return (
nodes[0].title || nodes[0].type || t('rightSidePanel.fallbackNodeTitle')
)
}
}
return t('rightSidePanel.title', { count: items.length })

View File

@@ -8,7 +8,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { searchWidgetsAndNodes } from '../shared'
import { computedSectionDataList, searchWidgetsAndNodes } from '../shared'
import type { NodeWidgetsListList } from '../shared'
import SectionWidgets from './SectionWidgets.vue'
@@ -24,18 +24,7 @@ const nodes = computed((): LGraphNode[] => {
const rightSidePanelStore = useRightSidePanelStore()
const { searchQuery } = storeToRefs(rightSidePanelStore)
const widgetsSectionDataList = computed((): NodeWidgetsListList => {
return nodes.value.map((node) => {
const { widgets = [] } = node
const shownWidgets = widgets
.filter((w) => !(w.options?.canvasOnly || w.options?.hidden))
.map((widget) => ({ node, widget }))
return {
widgets: shownWidgets,
node
}
})
})
const { widgetsSectionDataList } = computedSectionDataList(nodes)
const searchedWidgetsSectionDataList = shallowRef<NodeWidgetsListList>(
widgetsSectionDataList.value

View File

@@ -7,7 +7,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { searchWidgetsAndNodes } from '../shared'
import { computedSectionDataList, searchWidgetsAndNodes } from '../shared'
import type { NodeWidgetsListList } from '../shared'
import SectionWidgets from './SectionWidgets.vue'
@@ -21,21 +21,14 @@ const { t } = useI18n()
const rightSidePanelStore = useRightSidePanelStore()
const { searchQuery } = storeToRefs(rightSidePanelStore)
const widgetsSectionDataList = computed((): NodeWidgetsListList => {
return nodes.map((node) => {
const { widgets = [] } = node
const shownWidgets = widgets
.filter(
(w) =>
!(w.options?.canvasOnly || w.options?.hidden || w.options?.advanced)
)
.map((widget) => ({ node, widget }))
return { widgets: shownWidgets, node }
})
})
const { widgetsSectionDataList, includesAdvanced } = computedSectionDataList(
() => nodes
)
const advancedWidgetsSectionDataList = computed((): NodeWidgetsListList => {
if (includesAdvanced.value) {
return []
}
return nodes
.map((node) => {
const { widgets = [] } = node

View File

@@ -1,11 +1,9 @@
<script setup lang="ts">
import { computed, customRef, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import { getSharedWidgetEnhancements } from '@/composables/graph/useGraphNodeManager'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -17,7 +15,6 @@ import {
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@/utils/tailwindUtil'
import { renameWidget } from '@/utils/widgetUtil'
@@ -41,7 +38,6 @@ const {
isShownOnParents?: boolean
}>()
const { t } = useI18n()
const canvasStore = useCanvasStore()
const favoritedWidgetsStore = useFavoritedWidgetsStore()
const isEditing = ref(false)
@@ -63,13 +59,7 @@ const sourceNodeName = computed((): string | null => {
const { graph, nodeId } = widget._overlay
sourceNode = getNodeByExecutionId(graph, nodeId)
}
if (!sourceNode) return null
const fallbackNodeTitle = t('rightSidePanel.fallbackNodeTitle')
return resolveNodeDisplayName(sourceNode, {
emptyLabel: fallbackNodeTitle,
untitledLabel: fallbackNodeTitle,
st
})
return sourceNode ? sourceNode.title || sourceNode.type : null
})
const hasParents = computed(() => parents?.length > 0)

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import InputNumber from 'primevue/inputnumber'
import Select from 'primevue/select'
import { computed, ref } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
@@ -23,7 +23,11 @@ const settingStore = useSettingStore()
const dialogService = useDialogService()
// NODES settings
const showAdvancedParameters = ref(false) // Placeholder for future implementation
const showAdvancedParameters = computed({
get: () => settingStore.get('Comfy.Node.AlwaysShowAdvancedWidgets'),
set: (value) =>
settingStore.set('Comfy.Node.AlwaysShowAdvancedWidgets', value)
})
const showToolbox = computed({
get: () => settingStore.get('Comfy.Canvas.SelectionToolbox'),

View File

@@ -38,10 +38,22 @@ describe('searchWidgets', () => {
expect(searchWidgets(widgets, 'width')).toHaveLength(1)
expect(searchWidgets(widgets, 'slider')).toHaveLength(1)
expect(searchWidgets(widgets, 'high')).toHaveLength(1)
expect(searchWidgets(widgets, 'image')).toHaveLength(1)
})
it('should support fuzzy matching (e.g., "high" matches both "height" and value "high")', () => {
const widgets = [
createWidget('width', 'number', '100', 'Size Control'),
createWidget('height', 'slider', '200', 'Image Height'),
createWidget('quality', 'text', 'high', 'Quality')
]
const results = searchWidgets(widgets, 'high')
expect(results).toHaveLength(2)
expect(results.some((r) => r.widget.name === 'height')).toBe(true)
expect(results.some((r) => r.widget.name === 'quality')).toBe(true)
})
it('should handle multiple search words', () => {
const widgets = [
createWidget('width', 'number', '100', 'Image Width'),

View File

@@ -1,11 +1,14 @@
import type { InjectionKey, MaybeRefOrGetter } from 'vue'
import { computed, toValue } from 'vue'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
import type { Positionable } from '@/lib/litegraph/src/interfaces'
import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
import { useSettingStore } from '@/platform/settings/settingStore'
export const GetNodeParentGroupKey: InjectionKey<
(node: LGraphNode) => LGraphGroup | null
@@ -17,10 +20,18 @@ export type NodeWidgetsListList = Array<{
widgets: NodeWidgetsList
}>
interface WidgetSearchItem {
index: number
searchableLabel: string
searchableName: string
searchableType: string
searchableValue: string
}
/**
* Searches widgets in a list and returns search results.
* Searches widgets in a list using fuzzy search and returns search results.
* Uses Fuse.js for better matching with typo tolerance and relevance ranking.
* Filters by name, localized label, type, and user-input value.
* Performs basic tokenization of the query string.
*/
export function searchWidgets<T extends { widget: IBaseWidget }[]>(
list: T,
@@ -29,27 +40,48 @@ export function searchWidgets<T extends { widget: IBaseWidget }[]>(
if (query.trim() === '') {
return list
}
const words = query.trim().toLowerCase().split(' ')
return list.filter(({ widget }) => {
const label = widget.label?.toLowerCase()
const name = widget.name.toLowerCase()
const type = widget.type.toLowerCase()
const value = widget.value?.toString().toLowerCase()
return words.every(
(word) =>
name.includes(word) ||
label?.includes(word) ||
type?.includes(word) ||
value?.includes(word)
)
}) as T
const searchableList: WidgetSearchItem[] = list.map((item, index) => {
const searchableItem = {
index,
searchableLabel: item.widget.label?.toLowerCase() || '',
searchableName: item.widget.name.toLowerCase(),
searchableType: item.widget.type.toLowerCase(),
searchableValue: item.widget.value?.toString().toLowerCase() || ''
}
return searchableItem
})
const fuseOptions: IFuseOptions<WidgetSearchItem> = {
keys: [
{ name: 'searchableName', weight: 0.4 },
{ name: 'searchableLabel', weight: 0.3 },
{ name: 'searchableValue', weight: 0.3 },
{ name: 'searchableType', weight: 0.2 }
],
threshold: 0.3
}
const fuse = new Fuse(searchableList, fuseOptions)
const results = fuse.search(query.trim())
const matchedItems = new Set(
results.map((result) => list[result.item.index]!)
)
return list.filter((item) => matchedItems.has(item)) as T
}
type NodeSearchItem = {
nodeId: NodeId
searchableTitle: string
}
/**
* Searches widgets and nodes in a list and returns search results.
* Searches widgets and nodes in a list using fuzzy search and returns search results.
* Uses Fuse.js for node title matching with typo tolerance and relevance ranking.
* First checks if the node title matches the query (if so, keeps entire node).
* Otherwise, filters widgets using searchWidgets.
* Performs basic tokenization of the query string.
*/
export function searchWidgetsAndNodes(
list: NodeWidgetsListList,
@@ -58,12 +90,26 @@ export function searchWidgetsAndNodes(
if (query.trim() === '') {
return list
}
const words = query.trim().toLowerCase().split(' ')
const searchableList: NodeSearchItem[] = list.map((item) => ({
nodeId: item.node.id,
searchableTitle: (item.node.getTitle() ?? '').toLowerCase()
}))
const fuseOptions: IFuseOptions<NodeSearchItem> = {
keys: [{ name: 'searchableTitle', weight: 1.0 }],
threshold: 0.3
}
const fuse = new Fuse(searchableList, fuseOptions)
const nodeMatches = fuse.search(query.trim())
const matchedNodeIds = new Set(
nodeMatches.map((result) => result.item.nodeId)
)
return list
.map((item) => {
const { node } = item
const title = node.getTitle().toLowerCase()
if (words.every((word) => title.includes(word))) {
if (matchedNodeIds.has(item.node.id)) {
return { ...item, keep: true }
}
return {
@@ -203,3 +249,33 @@ function repeatItems<T>(items: T[]): T[] {
}
return result
}
export function computedSectionDataList(nodes: MaybeRefOrGetter<LGraphNode[]>) {
const settingStore = useSettingStore()
const includesAdvanced = computed(() =>
settingStore.get('Comfy.Node.AlwaysShowAdvancedWidgets')
)
const widgetsSectionDataList = computed((): NodeWidgetsListList => {
return toValue(nodes).map((node) => {
const { widgets = [] } = node
const shownWidgets = widgets
.filter(
(w) =>
!(
w.options?.canvasOnly ||
w.options?.hidden ||
(w.options?.advanced && !includesAdvanced.value)
)
)
.map((widget) => ({ node, widget }))
return { widgets: shownWidgets, node }
})
})
return {
widgetsSectionDataList,
includesAdvanced
}
}

View File

@@ -14,16 +14,24 @@ function toggleLinearMode() {
<template>
<div class="p-1 bg-secondary-background rounded-lg w-10">
<Button
v-tooltip="{
value: t('linearMode.linearMode'),
showDelay: 300,
hideDelay: 300
}"
size="icon"
:title="t('linearMode.linearMode')"
:variant="canvasStore.linearMode ? 'inverted' : 'secondary'"
@click="toggleLinearMode"
>
<i class="icon-[lucide--panels-top-left]" />
</Button>
<Button
v-tooltip="{
value: t('linearMode.graphMode'),
showDelay: 300,
hideDelay: 300
}"
size="icon"
:title="t('linearMode.graphMode')"
:variant="canvasStore.linearMode ? 'secondary' : 'inverted'"
@click="toggleLinearMode"
>

View File

@@ -228,9 +228,9 @@ import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAs
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { getJobDetail } from '@/services/jobOutputCache'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useExecutionStore } from '@/stores/executionStore'
@@ -238,6 +238,12 @@ import { ResultItemImpl, useQueueStore } from '@/stores/queueStore'
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
interface JobOutputItem {
filename: string
subfolder: string
type: string
}
const { t, n } = useI18n()
const commandStore = useCommandStore()
const queueStore = useQueueStore()
@@ -544,7 +550,7 @@ const enterFolderView = async (asset: AssetItem) => {
return
}
const { promptId, executionTimeInSeconds } = metadata
const { promptId, allOutputs, executionTimeInSeconds, outputCount } = metadata
if (!promptId) {
console.warn('Missing required folder view data')
@@ -554,16 +560,62 @@ const enterFolderView = async (asset: AssetItem) => {
folderPromptId.value = promptId
folderExecutionTime.value = executionTimeInSeconds
const folderItems = await resolveOutputAssetItems(metadata, {
createdAt: asset.created_at
})
// Determine which outputs to display
let outputsToDisplay = allOutputs ?? []
if (folderItems.length === 0) {
// If outputCount indicates more outputs than we have, fetch full outputs
const needsFullOutputs =
typeof outputCount === 'number' &&
outputCount > 1 &&
outputsToDisplay.length < outputCount
if (needsFullOutputs) {
try {
const jobDetail = await getJobDetail(promptId)
if (jobDetail?.outputs) {
// Convert job outputs to ResultItemImpl array
outputsToDisplay = Object.entries(jobDetail.outputs).flatMap(
([nodeId, nodeOutputs]) =>
Object.entries(nodeOutputs).flatMap(([mediaType, items]) =>
(items as JobOutputItem[])
.map(
(item) =>
new ResultItemImpl({
...item,
nodeId,
mediaType
})
)
.filter((r) => r.supportsPreview)
)
)
}
} catch (error) {
console.error('Failed to fetch job detail for folder view:', error)
outputsToDisplay = []
}
}
if (outputsToDisplay.length === 0) {
console.warn('No outputs available for folder view')
return
}
folderAssets.value = folderItems
folderAssets.value = outputsToDisplay.map((output) => ({
id: `${output.nodeId}-${output.filename}`,
name: output.filename,
size: 0,
created_at: asset.created_at,
tags: ['output'],
preview_url: output.url,
user_metadata: {
promptId,
nodeId: output.nodeId,
subfolder: output.subfolder,
executionTimeInSeconds,
workflow: metadata.workflow
}
}))
}
const exitFolderView = () => {

View File

@@ -92,7 +92,7 @@ describe('ResultGallery', () => {
}
},
props: {
allGalleryItems: mockGalleryItems as unknown as ResultItemImpl[],
allGalleryItems: mockGalleryItems as ResultItemImpl[],
activeIndex: 0,
...props
},
@@ -117,7 +117,10 @@ describe('ResultGallery', () => {
const wrapper = mountGallery({ activeIndex: -1 })
// Initially galleryVisible should be false
const vm: any = wrapper.vm
type GalleryVM = typeof wrapper.vm & {
galleryVisible: boolean
}
const vm = wrapper.vm as GalleryVM
expect(vm.galleryVisible).toBe(false)
// Change activeIndex
@@ -167,7 +170,11 @@ describe('ResultGallery', () => {
expect(galleria.exists()).toBe(true)
// Check that our PT props for positioning work correctly
const pt = galleria.props('pt') as any
interface GalleriaPT {
prevButton?: { style?: string }
nextButton?: { style?: string }
}
const pt = galleria.props('pt') as GalleriaPT
expect(pt?.prevButton?.style).toContain('position: fixed')
expect(pt?.nextButton?.style).toContain('position: fixed')
})

View File

@@ -4,6 +4,10 @@ import { nextTick } from 'vue'
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
type ComponentInstance = InstanceType<typeof BaseThumbnail> & {
error: boolean
}
vi.mock('@vueuse/core', () => ({
useEventListener: vi.fn()
}))
@@ -45,7 +49,7 @@ describe('BaseThumbnail', () => {
it('shows error state when image fails to load', async () => {
const wrapper = mountThumbnail()
const vm = wrapper.vm as any
const vm = wrapper.vm as ComponentInstance
// Manually set error since useEventListener is mocked
vm.error = true

View File

@@ -117,7 +117,7 @@
</template>
<template #rightPanel>
<RightSidePanel></RightSidePanel>
<div class="size-full bg-modal-panel-background pr-6 pb-8 pl-4"></div>
</template>
</BaseModalLayout>
</template>
@@ -136,7 +136,6 @@ import SingleSelect from '@/components/input/SingleSelect.vue'
import Button from '@/components/ui/button/Button.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import RightSidePanel from '@/components/widget/panel/RightSidePanel.vue'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
import { OnCloseKey } from '@/types/widgetTypes'
import { createGridStyle } from '@/utils/gridUtil'

View File

@@ -15,7 +15,6 @@ import { OnCloseKey } from '@/types/widgetTypes'
import { createGridStyle } from '@/utils/gridUtil'
import LeftSidePanel from '../panel/LeftSidePanel.vue'
import RightSidePanel from '../panel/RightSidePanel.vue'
import BaseModalLayout from './BaseModalLayout.vue'
interface StoryArgs {
@@ -69,7 +68,6 @@ const createStoryTemplate = (args: StoryArgs) => ({
components: {
BaseModalLayout,
LeftSidePanel,
RightSidePanel,
SearchBox,
MultiSelect,
SingleSelect,
@@ -175,16 +173,15 @@ const createStoryTemplate = (args: StoryArgs) => ({
template: `
<div>
<BaseModalLayout v-if="!args.hasRightPanel" :content-title="args.contentTitle || 'Content Title'">
<!-- Left Panel Header Title -->
<template v-if="args.hasLeftPanel" #leftPanelHeaderTitle>
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
<span class="text-neutral text-base">Title</span>
</template>
<!-- Left Panel -->
<template v-if="args.hasLeftPanel" #leftPanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
<template #header-icon>
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Title</span>
</template>
</LeftSidePanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation" />
</template>
<!-- Header -->
@@ -299,16 +296,15 @@ const createStoryTemplate = (args: StoryArgs) => ({
<BaseModalLayout v-else :content-title="args.contentTitle || 'Content Title'">
<!-- Same content but WITH right panel -->
<!-- Left Panel Header Title -->
<template v-if="args.hasLeftPanel" #leftPanelHeaderTitle>
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
<span class="text-neutral text-base">Title</span>
</template>
<!-- Left Panel -->
<template v-if="args.hasLeftPanel" #leftPanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
<template #header-icon>
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Title</span>
</template>
</LeftSidePanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation" />
</template>
<!-- Header -->
@@ -415,7 +411,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<!-- Right Panel - Only when hasRightPanel is true -->
<template #rightPanel>
<RightSidePanel />
<div class="size-full bg-modal-panel-background pr-6 pb-8 pl-4"></div>
</template>
</BaseModalLayout>
</div>

View File

@@ -8,13 +8,26 @@
:style="gridStyle"
>
<nav
class="h-full overflow-hidden"
class="h-full overflow-hidden bg-modal-panel-background flex flex-col"
:inert="!showLeftPanel"
:aria-hidden="!showLeftPanel"
>
<div v-if="hasLeftPanel" class="h-full min-w-40 max-w-56">
<slot name="leftPanel" />
</div>
<header
data-component-id="LeftPanelHeader"
class="flex w-full h-18 shrink-0 gap-2 pl-6 pr-3 items-center-safe"
>
<slot name="leftPanelHeaderTitle" />
<Button
v-if="!notMobile && showLeftPanel"
size="lg"
class="w-10 p-0 ml-auto"
:aria-label="t('g.hideLeftPanel')"
@click="toggleLeftPanel"
>
<i class="icon-[lucide--panel-left-close]" />
</Button>
</header>
<slot name="leftPanel" />
</nav>
<div class="flex flex-col bg-base-background overflow-hidden">
@@ -24,22 +37,13 @@
>
<div class="flex flex-1 shrink-0 gap-2">
<Button
v-if="!notMobile"
size="icon"
:aria-label="
showLeftPanel ? t('g.hideLeftPanel') : t('g.showLeftPanel')
"
v-if="!notMobile && !showLeftPanel"
size="lg"
class="w-10 p-0"
:aria-label="t('g.showLeftPanel')"
@click="toggleLeftPanel"
>
<i
:class="
cn(
showLeftPanel
? 'icon-[lucide--panel-left]'
: 'icon-[lucide--panel-left-close]'
)
"
/>
<i class="icon-[lucide--panel-left]" />
</Button>
<slot name="header" />
</div>
@@ -69,7 +73,7 @@
<slot name="contentFilter" />
<h2
v-if="!hasLeftPanel"
class="text-xxl m-0 px-6 pt-2 pb-6 capitalize"
class="text-xxl m-0 select-none px-6 pt-2 pb-6 capitalize"
>
{{ contentTitle }}
</h2>
@@ -94,7 +98,10 @@
data-component-id="RightPanelHeader"
class="flex h-18 shrink-0 items-center gap-2 px-6"
>
<h2 v-if="rightPanelTitle" class="flex-1 text-base font-semibold">
<h2
v-if="rightPanelTitle"
class="flex-1 select-none text-base font-semibold"
>
{{ rightPanelTitle }}
</h2>
<div v-else class="flex-1">
@@ -134,7 +141,6 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { OnCloseKey } from '@/types/widgetTypes'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()

View File

@@ -5,7 +5,7 @@
disabled: !isOverflowing,
pt: { text: { class: 'whitespace-nowrap' } }
}"
class="flex cursor-pointer items-center-safe gap-2 rounded-md px-4 py-3 text-sm transition-colors text-base-foreground"
class="flex cursor-pointer select-none items-center-safe gap-2 rounded-md px-4 py-3 text-sm transition-colors text-base-foreground"
:class="
active
? 'bg-interface-menu-component-surface-selected'

View File

@@ -7,20 +7,6 @@ const meta: Meta<typeof LeftSidePanel> = {
title: 'Components/Widget/Panel/LeftSidePanel',
component: LeftSidePanel,
argTypes: {
'header-icon': {
table: {
type: { summary: 'slot' },
defaultValue: { summary: 'undefined' }
},
control: false
},
'header-title': {
table: {
type: { summary: 'slot' },
defaultValue: { summary: 'undefined' }
},
control: false
},
'onUpdate:modelValue': {
table: { disable: true }
}
@@ -59,14 +45,7 @@ export const Default: Story = {
},
template: `
<div style="height: 500px; width: 256px;">
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
<template #header-icon>
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Navigation</span>
</template>
</LeftSidePanel>
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems" />
</div>
`
})
@@ -126,14 +105,7 @@ export const WithGroups: Story = {
},
template: `
<div style="height: 500px; width: 256px;">
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
<template #header-icon>
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Model Selector</span>
</template>
</LeftSidePanel>
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems" />
<div class="mt-4 p-2 text-sm">
Selected: {{ selectedItem }}
</div>
@@ -176,14 +148,7 @@ export const DefaultIcons: Story = {
},
template: `
<div style="height: 400px; width: 256px;">
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
<template #header-icon>
<i class="icon-[lucide--folder] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Files</span>
</template>
</LeftSidePanel>
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems" />
</div>
`
})
@@ -228,14 +193,7 @@ export const LongLabels: Story = {
},
template: `
<div style="height: 500px; width: 256px;">
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
<template #header-icon>
<i class="icon-[lucide--settings] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Settings</span>
</template>
</LeftSidePanel>
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems" />
</div>
`
})

View File

@@ -1,47 +1,41 @@
<template>
<div class="flex h-full w-full flex-col bg-modal-panel-background">
<PanelHeader>
<template #icon>
<slot name="header-icon"></slot>
</template>
<slot name="header-title"></slot>
</PanelHeader>
<nav
class="flex scrollbar-hide flex-1 flex-col gap-1 overflow-y-auto px-3 py-4"
<div
class="flex w-full flex-auto overflow-y-auto gap-1 min-h-0 flex-col bg-modal-panel-background scrollbar-hide px-3"
>
<template
v-for="item in navItems"
:key="'title' in item ? item.title : item.id"
>
<template v-for="(item, index) in navItems" :key="index">
<div v-if="'items' in item" class="flex flex-col gap-2">
<NavTitle
v-model="collapsedGroups[item.title]"
:title="item.title"
:collapsible="item.collapsible"
/>
<template v-if="!item.collapsible || !collapsedGroups[item.title]">
<NavItem
v-for="subItem in item.items"
:key="subItem.id"
:icon="subItem.icon"
:badge="subItem.badge"
:active="activeItem === subItem.id"
@click="activeItem = subItem.id"
>
{{ subItem.label }}
</NavItem>
</template>
</div>
<div v-else class="flex flex-col gap-2">
<div v-if="'items' in item" class="flex flex-col gap-2">
<NavTitle
v-model="collapsedGroups[item.title]"
:title="item.title"
:collapsible="item.collapsible"
/>
<template v-if="!item.collapsible || !collapsedGroups[item.title]">
<NavItem
:icon="item.icon"
:badge="item.badge"
:active="activeItem === item.id"
@click="activeItem = item.id"
v-for="subItem in item.items"
:key="subItem.id"
:icon="subItem.icon"
:badge="subItem.badge"
:active="activeItem === subItem.id"
@click="activeItem = subItem.id"
>
{{ item.label }}
{{ subItem.label }}
</NavItem>
</div>
</template>
</nav>
</template>
</div>
<div v-else class="flex flex-col gap-2">
<NavItem
:icon="item.icon"
:badge="item.badge"
:active="activeItem === item.id"
@click="activeItem = item.id"
>
{{ item.label }}
</NavItem>
</div>
</template>
</div>
</template>
@@ -52,8 +46,6 @@ import NavItem from '@/components/widget/nav/NavItem.vue'
import NavTitle from '@/components/widget/nav/NavTitle.vue'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
import PanelHeader from './PanelHeader.vue'
const { navItems = [], modelValue } = defineProps<{
navItems?: (NavItemData | NavGroupData)[]
modelValue?: string | null

View File

@@ -1,12 +0,0 @@
<template>
<header class="flex h-16 items-center justify-between px-6">
<div class="flex items-center gap-2 pl-1">
<slot name="icon">
<i class="text-neutral icon-[lucide--puzzle] text-base" />
</slot>
<h2 class="text-neutral text-base font-bold">
<slot></slot>
</h2>
</div>
</header>
</template>

View File

@@ -1,5 +0,0 @@
<template>
<div class="size-full bg-modal-panel-background pr-6 pb-8 pl-4">
<slot></slot>
</div>
</template>

View File

@@ -6,6 +6,9 @@ import type { LGraphNode, Positionable } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode, Reroute } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import type { NodeId } from '@/renderer/core/layout/types'
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import { createMockSubgraphNode } from '@/utils/__tests__/litegraphTestUtils'
// Mock the app module
vi.mock('@/scripts/app', () => ({
@@ -29,10 +32,12 @@ vi.mock('@/lib/litegraph/src/litegraph', () => ({
}))
// Mock Positionable objects
// @ts-expect-error - Mock implementation for testing
class MockNode implements Positionable {
pos: [number, number]
size: [number, number]
id: NodeId
boundingRect: ReadOnlyRect
constructor(
pos: [number, number] = [0, 0],
@@ -40,6 +45,13 @@ class MockNode implements Positionable {
) {
this.pos = pos
this.size = size
this.id = 'mock-node'
this.boundingRect = [0, 0, 0, 0]
}
move(): void {}
snapToGrid(_: number): boolean {
return true
}
}
@@ -61,7 +73,7 @@ class MockReroute extends Reroute implements Positionable {
describe('useSelectedLiteGraphItems', () => {
let canvasStore: ReturnType<typeof useCanvasStore>
let mockCanvas: any
let mockCanvas: { selectedItems: Set<Positionable> }
beforeEach(() => {
setActivePinia(createPinia())
@@ -73,7 +85,9 @@ describe('useSelectedLiteGraphItems', () => {
}
// Mock getCanvas to return our mock canvas
vi.spyOn(canvasStore, 'getCanvas').mockReturnValue(mockCanvas)
vi.spyOn(canvasStore, 'getCanvas').mockReturnValue(
mockCanvas as ReturnType<typeof canvasStore.getCanvas>
)
})
describe('isIgnoredItem', () => {
@@ -86,7 +100,6 @@ describe('useSelectedLiteGraphItems', () => {
it('should return false for non-Reroute items', () => {
const { isIgnoredItem } = useSelectedLiteGraphItems()
const node = new MockNode()
// @ts-expect-error - Test mock
expect(isIgnoredItem(node)).toBe(false)
})
})
@@ -98,14 +111,11 @@ describe('useSelectedLiteGraphItems', () => {
const node2 = new MockNode([100, 100])
const reroute = new MockReroute([50, 50])
// @ts-expect-error - Test mocks
const items = new Set<Positionable>([node1, node2, reroute])
const filtered = filterSelectableItems(items)
expect(filtered.size).toBe(2)
// @ts-expect-error - Test mocks
expect(filtered.has(node1)).toBe(true)
// @ts-expect-error - Test mocks
expect(filtered.has(node2)).toBe(true)
expect(filtered.has(reroute)).toBe(false)
})
@@ -143,9 +153,7 @@ describe('useSelectedLiteGraphItems', () => {
const selectableItems = getSelectableItems()
expect(selectableItems.size).toBe(2)
// @ts-expect-error - Test mock
expect(selectableItems.has(node1)).toBe(true)
// @ts-expect-error - Test mock
expect(selectableItems.has(node2)).toBe(true)
expect(selectableItems.has(reroute)).toBe(false)
})
@@ -255,14 +263,7 @@ describe('useSelectedLiteGraphItems', () => {
const { getSelectedNodes } = useSelectedLiteGraphItems()
const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode
const subNode2 = { id: 12, mode: LGraphEventMode.NEVER } as LGraphNode
const subgraphNode = {
id: 1,
mode: LGraphEventMode.ALWAYS,
isSubgraphNode: () => true,
subgraph: {
nodes: [subNode1, subNode2]
}
} as unknown as LGraphNode
const subgraphNode = createMockSubgraphNode([subNode1, subNode2])
const regularNode = { id: 2, mode: LGraphEventMode.NEVER } as LGraphNode
app.canvas.selected_nodes = { '0': subgraphNode, '1': regularNode }
@@ -279,14 +280,7 @@ describe('useSelectedLiteGraphItems', () => {
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode
const subNode2 = { id: 12, mode: LGraphEventMode.NEVER } as LGraphNode
const subgraphNode = {
id: 1,
mode: LGraphEventMode.ALWAYS,
isSubgraphNode: () => true,
subgraph: {
nodes: [subNode1, subNode2]
}
} as unknown as LGraphNode
const subgraphNode = createMockSubgraphNode([subNode1, subNode2])
const regularNode = { id: 2, mode: LGraphEventMode.BYPASS } as LGraphNode
app.canvas.selected_nodes = { '0': subgraphNode, '1': regularNode }
@@ -310,14 +304,10 @@ describe('useSelectedLiteGraphItems', () => {
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode
const subNode2 = { id: 12, mode: LGraphEventMode.BYPASS } as LGraphNode
const subgraphNode = {
const subgraphNode = createMockSubgraphNode([subNode1, subNode2], {
id: 1,
mode: LGraphEventMode.NEVER, // Already in NEVER mode
isSubgraphNode: () => true,
subgraph: {
nodes: [subNode1, subNode2]
}
} as unknown as LGraphNode
mode: LGraphEventMode.NEVER // Already in NEVER mode
})
app.canvas.selected_nodes = { '0': subgraphNode }

View File

@@ -1,19 +1,3 @@
/**
* Shorthand for {@link Parameters} of optional callbacks.
*
* @example
* ```ts
* const { onClick } = CustomClass.prototype
* CustomClass.prototype.onClick = function (...args: CallbackParams<typeof onClick>) {
* const r = onClick?.apply(this, args)
* // ...
* return r
* }
* ```
*/
export type CallbackParams<T extends ((...args: any) => any) | undefined> =
Parameters<Exclude<T, undefined>>
/**
* Chain multiple callbacks together.
*
@@ -21,15 +5,21 @@ export type CallbackParams<T extends ((...args: any) => any) | undefined> =
* @param callbacks - The callbacks to chain.
* @returns A new callback that chains the original callback with the callbacks.
*/
export const useChainCallback = <
O,
T extends (this: O, ...args: any[]) => void
>(
export function useChainCallback<O, T>(
originalCallback: T | undefined,
...callbacks: ((this: O, ...args: Parameters<T>) => void)[]
) => {
return function (this: O, ...args: Parameters<T>) {
originalCallback?.call(this, ...args)
for (const callback of callbacks) callback.call(this, ...args)
}
...callbacks: NonNullable<T> extends (this: O, ...args: infer P) => unknown
? ((this: O, ...args: P) => void)[]
: never
) {
type Args = NonNullable<T> extends (...args: infer P) => unknown ? P : never
type Ret = NonNullable<T> extends (...args: unknown[]) => infer R ? R : never
return function (this: O, ...args: Args) {
if (typeof originalCallback === 'function') {
;(originalCallback as (this: O, ...args: Args) => Ret).call(this, ...args)
}
for (const callback of callbacks) {
callback.call(this, ...args)
}
} as (this: O, ...args: Args) => Ret
}

View File

@@ -1,23 +1,37 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import * as measure from '@/lib/litegraph/src/measure'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import {
createMockLGraphNode,
createMockLGraphGroup
} from '@/utils/__tests__/litegraphTestUtils'
import { useGraphHierarchy } from './useGraphHierarchy'
vi.mock('@/renderer/core/canvas/canvasStore')
function createMockNode(overrides: Partial<LGraphNode> = {}): LGraphNode {
return {
...createMockLGraphNode(),
boundingRect: new Rectangle(100, 100, 50, 50),
...overrides
} as LGraphNode
}
function createMockGroup(overrides: Partial<LGraphGroup> = {}): LGraphGroup {
return createMockLGraphGroup(overrides)
}
describe('useGraphHierarchy', () => {
let mockCanvasStore: ReturnType<typeof useCanvasStore>
let mockCanvasStore: Partial<ReturnType<typeof useCanvasStore>>
let mockNode: LGraphNode
let mockGroups: LGraphGroup[]
beforeEach(() => {
mockNode = {
boundingRect: [100, 100, 50, 50]
} as unknown as LGraphNode
mockNode = createMockNode()
mockGroups = []
mockCanvasStore = {
@@ -25,10 +39,21 @@ describe('useGraphHierarchy', () => {
graph: {
groups: mockGroups
}
}
} as any
},
$id: 'canvas',
$state: {},
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$onAction: vi.fn(),
$dispose: vi.fn(),
_customProperties: new Set(),
_p: {}
} as unknown as Partial<ReturnType<typeof useCanvasStore>>
vi.mocked(useCanvasStore).mockReturnValue(mockCanvasStore)
vi.mocked(useCanvasStore).mockReturnValue(
mockCanvasStore as ReturnType<typeof useCanvasStore>
)
})
describe('findParentGroup', () => {
@@ -41,9 +66,9 @@ describe('useGraphHierarchy', () => {
})
it('returns null when node is not in any group', () => {
const group = {
boundingRect: [0, 0, 50, 50]
} as unknown as LGraphGroup
const group = createMockGroup({
boundingRect: new Rectangle(0, 0, 50, 50)
})
mockGroups.push(group)
vi.spyOn(measure, 'containsCentre').mockReturnValue(false)
@@ -55,9 +80,9 @@ describe('useGraphHierarchy', () => {
})
it('returns the only group when node is in exactly one group', () => {
const group = {
boundingRect: [0, 0, 200, 200]
} as unknown as LGraphGroup
const group = createMockGroup({
boundingRect: new Rectangle(0, 0, 200, 200)
})
mockGroups.push(group)
vi.spyOn(measure, 'containsCentre').mockReturnValue(true)
@@ -69,12 +94,12 @@ describe('useGraphHierarchy', () => {
})
it('returns the smallest group when node is in multiple groups', () => {
const largeGroup = {
boundingRect: [0, 0, 300, 300]
} as unknown as LGraphGroup
const smallGroup = {
boundingRect: [50, 50, 100, 100]
} as unknown as LGraphGroup
const largeGroup = createMockGroup({
boundingRect: new Rectangle(0, 0, 300, 300)
})
const smallGroup = createMockGroup({
boundingRect: new Rectangle(50, 50, 100, 100)
})
mockGroups.push(largeGroup, smallGroup)
vi.spyOn(measure, 'containsCentre').mockReturnValue(true)
@@ -87,12 +112,12 @@ describe('useGraphHierarchy', () => {
})
it('returns the inner group when one group contains another', () => {
const outerGroup = {
boundingRect: [0, 0, 300, 300]
} as unknown as LGraphGroup
const innerGroup = {
boundingRect: [50, 50, 100, 100]
} as unknown as LGraphGroup
const outerGroup = createMockGroup({
boundingRect: new Rectangle(0, 0, 300, 300)
})
const innerGroup = createMockGroup({
boundingRect: new Rectangle(50, 50, 100, 100)
})
mockGroups.push(outerGroup, innerGroup)
vi.spyOn(measure, 'containsCentre').mockReturnValue(true)
@@ -113,7 +138,7 @@ describe('useGraphHierarchy', () => {
})
it('handles null canvas gracefully', () => {
mockCanvasStore.canvas = null as any
mockCanvasStore.canvas = null
const { findParentGroup } = useGraphHierarchy()
const result = findParentGroup(mockNode)
@@ -122,7 +147,7 @@ describe('useGraphHierarchy', () => {
})
it('handles null graph gracefully', () => {
mockCanvasStore.canvas!.graph = null as any
mockCanvasStore.canvas!.graph = null
const { findParentGroup } = useGraphHierarchy()
const result = findParentGroup(mockNode)

View File

@@ -16,6 +16,7 @@ import type {
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeId } from '@/renderer/core/layout/types'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -322,7 +323,7 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Get layout mutations composable
const { createNode, deleteNode, setSource } = useLayoutMutations()
const { createNode, deleteNode, resizeNode, setSource } = useLayoutMutations()
// Safe reactive data extracted from LiteGraph nodes
const vueNodeData = reactive(new Map<string, VueNodeData>())
@@ -396,6 +397,14 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Store non-reactive reference to original node
nodeRefs.set(id, node)
// Chain onResize to propagate extension-driven size changes to the store
node.onResize = useChainCallback(node.onResize, (size) => {
const current = layoutStore.getNodeLayoutRef(id).value?.size
if (current?.width === size[0] && current?.height === size[1]) return
setSource(LayoutSource.Canvas)
resizeNode(id, { width: size[0], height: size[1] })
})
// Extract initial data for Vue (may be incomplete during graph configure)
vueNodeData.set(id, extractVueNodeData(node))

View File

@@ -1,55 +1,19 @@
import { createPinia, setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { ref } from 'vue'
import type { Ref } from 'vue'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
import {
createMockLGraphNode,
createMockPositionable
} from '@/utils/__tests__/litegraphTestUtils'
// Test interfaces
interface TestNodeConfig {
type?: string
mode?: LGraphEventMode
flags?: { collapsed?: boolean }
pinned?: boolean
removable?: boolean
}
interface TestNode {
type: string
mode: LGraphEventMode
flags?: { collapsed?: boolean }
pinned?: boolean
removable?: boolean
isSubgraphNode: () => boolean
}
type MockedItem = TestNode | { type: string; isNode: boolean }
// Mock all stores
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn()
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: vi.fn()
}))
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
useSidebarTabStore: vi.fn()
}))
vi.mock('@/stores/workspace/nodeHelpStore', () => ({
useNodeHelpStore: vi.fn()
}))
// Mock composables
vi.mock('@/composables/sidebarTabs/useNodeLibrarySidebarTab', () => ({
useNodeLibrarySidebarTab: vi.fn()
}))
@@ -63,102 +27,28 @@ vi.mock('@/utils/nodeFilterUtil', () => ({
filterOutputNodes: vi.fn()
}))
const createTestNode = (config: TestNodeConfig = {}): TestNode => {
return {
type: config.type || 'TestNode',
mode: config.mode || LGraphEventMode.ALWAYS,
flags: config.flags,
pinned: config.pinned,
removable: config.removable,
isSubgraphNode: () => false
}
// Mock comment/connection objects with additional properties
const mockComment = {
...createMockPositionable({ id: 999 }),
type: 'comment',
isNode: false
}
const mockConnection = {
...createMockPositionable({ id: 1000 }),
type: 'connection',
isNode: false
}
// Mock comment/connection objects
const mockComment = { type: 'comment', isNode: false }
const mockConnection = { type: 'connection', isNode: false }
describe('useSelectionState', () => {
// Mock store instances
let mockSelectedItems: Ref<MockedItem[]>
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
// Setup mock canvas store with proper ref
mockSelectedItems = ref([])
vi.mocked(useCanvasStore).mockReturnValue({
selectedItems: mockSelectedItems,
// Add minimal required properties for the store
$id: 'canvas',
$state: {} as any,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$onAction: vi.fn(),
$dispose: vi.fn(),
_customProperties: new Set(),
_p: {} as any
} as any)
// Setup mock node def store
vi.mocked(useNodeDefStore).mockReturnValue({
fromLGraphNode: vi.fn((node: TestNode) => {
if (node?.type === 'TestNode') {
return { nodePath: 'test.TestNode', name: 'TestNode' }
}
return null
}),
// Add minimal required properties for the store
$id: 'nodeDef',
$state: {} as any,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$onAction: vi.fn(),
$dispose: vi.fn(),
_customProperties: new Set(),
_p: {} as any
} as any)
// Setup mock sidebar tab store
const mockToggleSidebarTab = vi.fn()
vi.mocked(useSidebarTabStore).mockReturnValue({
activeSidebarTabId: null,
toggleSidebarTab: mockToggleSidebarTab,
// Add minimal required properties for the store
$id: 'sidebarTab',
$state: {} as any,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$onAction: vi.fn(),
$dispose: vi.fn(),
_customProperties: new Set(),
_p: {} as any
} as any)
// Setup mock node help store
const mockOpenHelp = vi.fn()
const mockCloseHelp = vi.fn()
const mockNodeHelpStore = {
isHelpOpen: false,
currentHelpNode: null,
openHelp: mockOpenHelp,
closeHelp: mockCloseHelp,
// Add minimal required properties for the store
$id: 'nodeHelp',
$state: {} as any,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$onAction: vi.fn(),
$dispose: vi.fn(),
_customProperties: new Set(),
_p: {} as any
}
vi.mocked(useNodeHelpStore).mockReturnValue(mockNodeHelpStore as any)
// Create testing Pinia instance
setActivePinia(
createTestingPinia({
createSpy: vi.fn
})
)
// Setup mock composables
vi.mocked(useNodeLibrarySidebarTab).mockReturnValue({
@@ -166,7 +56,7 @@ describe('useSelectionState', () => {
title: 'Node Library',
type: 'custom',
render: () => null
} as any)
} as ReturnType<typeof useNodeLibrarySidebarTab>)
// Setup mock utility functions
vi.mocked(isLGraphNode).mockImplementation((item: unknown) => {
@@ -177,8 +67,8 @@ describe('useSelectionState', () => {
const typedNode = node as { type?: string }
return typedNode?.type === 'ImageNode'
})
vi.mocked(filterOutputNodes).mockImplementation(
(nodes: TestNode[]) => nodes.filter((n) => n.type === 'OutputNode') as any
vi.mocked(filterOutputNodes).mockImplementation((nodes) =>
nodes.filter((n) => n.type === 'OutputNode')
)
})
@@ -189,10 +79,10 @@ describe('useSelectionState', () => {
})
test('should return true when items selected', () => {
// Update the mock data before creating the composable
const node1 = createTestNode()
const node2 = createTestNode()
mockSelectedItems.value = [node1, node2]
const canvasStore = useCanvasStore()
const node1 = createMockLGraphNode({ id: 1 })
const node2 = createMockLGraphNode({ id: 2 })
canvasStore.$state.selectedItems = [node1, node2]
const { hasAnySelection } = useSelectionState()
expect(hasAnySelection.value).toBe(true)
@@ -201,9 +91,13 @@ describe('useSelectionState', () => {
describe('Node Type Filtering', () => {
test('should pick only LGraphNodes from mixed selections', () => {
// Update the mock data before creating the composable
const graphNode = createTestNode()
mockSelectedItems.value = [graphNode, mockComment, mockConnection]
const canvasStore = useCanvasStore()
const graphNode = createMockLGraphNode({ id: 3 })
canvasStore.$state.selectedItems = [
graphNode,
mockComment,
mockConnection
]
const { selectedNodes } = useSelectionState()
expect(selectedNodes.value).toHaveLength(1)
@@ -213,9 +107,12 @@ describe('useSelectionState', () => {
describe('Node State Computation', () => {
test('should detect bypassed nodes', () => {
// Update the mock data before creating the composable
const bypassedNode = createTestNode({ mode: LGraphEventMode.BYPASS })
mockSelectedItems.value = [bypassedNode]
const canvasStore = useCanvasStore()
const bypassedNode = createMockLGraphNode({
id: 4,
mode: LGraphEventMode.BYPASS
})
canvasStore.$state.selectedItems = [bypassedNode]
const { selectedNodes } = useSelectionState()
const isBypassed = selectedNodes.value.some(
@@ -225,10 +122,13 @@ describe('useSelectionState', () => {
})
test('should detect pinned/collapsed states', () => {
// Update the mock data before creating the composable
const pinnedNode = createTestNode({ pinned: true })
const collapsedNode = createTestNode({ flags: { collapsed: true } })
mockSelectedItems.value = [pinnedNode, collapsedNode]
const canvasStore = useCanvasStore()
const pinnedNode = createMockLGraphNode({ id: 5, pinned: true })
const collapsedNode = createMockLGraphNode({
id: 6,
flags: { collapsed: true }
})
canvasStore.$state.selectedItems = [pinnedNode, collapsedNode]
const { selectedNodes } = useSelectionState()
const isPinned = selectedNodes.value.some((n) => n.pinned === true)
@@ -244,9 +144,9 @@ describe('useSelectionState', () => {
})
test('should provide non-reactive state computation', () => {
// Update the mock data before creating the composable
const node = createTestNode({ pinned: true })
mockSelectedItems.value = [node]
const canvasStore = useCanvasStore()
const node = createMockLGraphNode({ id: 7, pinned: true })
canvasStore.$state.selectedItems = [node]
const { selectedNodes } = useSelectionState()
const isPinned = selectedNodes.value.some((n) => n.pinned === true)
@@ -262,7 +162,7 @@ describe('useSelectionState', () => {
expect(isBypassed).toBe(false)
// Test with empty selection using new composable instance
mockSelectedItems.value = []
canvasStore.$state.selectedItems = []
const { selectedNodes: newSelectedNodes } = useSelectionState()
const newIsPinned = newSelectedNodes.value.some((n) => n.pinned === true)
expect(newIsPinned).toBe(false)

View File

@@ -75,7 +75,7 @@ if (typeof globalThis.ImageBitmap === 'undefined') {
this.height = height
}
close() {}
} as unknown as typeof globalThis.ImageBitmap
} as typeof ImageBitmap
}
describe('useCanvasHistory', () => {

View File

@@ -3,13 +3,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { MaskBlendMode } from '@/extensions/core/maskeditor/types'
import { useCanvasManager } from '@/composables/maskeditor/useCanvasManager'
const mockStore = {
imgCanvas: null as any,
maskCanvas: null as any,
rgbCanvas: null as any,
imgCtx: null as any,
maskCtx: null as any,
rgbCtx: null as any,
canvasBackground: null as any,
imgCanvas: null! as HTMLCanvasElement,
maskCanvas: null! as HTMLCanvasElement,
rgbCanvas: null! as HTMLCanvasElement,
imgCtx: null! as CanvasRenderingContext2D,
maskCtx: null! as CanvasRenderingContext2D,
rgbCtx: null! as CanvasRenderingContext2D,
canvasBackground: null! as HTMLElement,
maskColor: { r: 0, g: 0, b: 0 },
maskBlendMode: MaskBlendMode.Black,
maskOpacity: 0.8
@@ -38,26 +38,30 @@ describe('useCanvasManager', () => {
height: 100
} as ImageData
mockStore.imgCtx = {
const partialImgCtx: Partial<CanvasRenderingContext2D> = {
drawImage: vi.fn()
}
mockStore.imgCtx = partialImgCtx as CanvasRenderingContext2D
mockStore.maskCtx = {
const partialMaskCtx: Partial<CanvasRenderingContext2D> = {
drawImage: vi.fn(),
getImageData: vi.fn(() => mockImageData),
putImageData: vi.fn(),
globalCompositeOperation: 'source-over',
fillStyle: ''
}
mockStore.maskCtx = partialMaskCtx as CanvasRenderingContext2D
mockStore.rgbCtx = {
const partialRgbCtx: Partial<CanvasRenderingContext2D> = {
drawImage: vi.fn()
}
mockStore.rgbCtx = partialRgbCtx as CanvasRenderingContext2D
mockStore.imgCanvas = {
const partialImgCanvas: Partial<HTMLCanvasElement> = {
width: 0,
height: 0
}
mockStore.imgCanvas = partialImgCanvas as HTMLCanvasElement
mockStore.maskCanvas = {
width: 0,
@@ -65,19 +69,19 @@ describe('useCanvasManager', () => {
style: {
mixBlendMode: '',
opacity: ''
}
}
} as Pick<CSSStyleDeclaration, 'mixBlendMode' | 'opacity'>
} as HTMLCanvasElement
mockStore.rgbCanvas = {
width: 0,
height: 0
}
} as HTMLCanvasElement
mockStore.canvasBackground = {
style: {
backgroundColor: ''
}
}
} as Pick<CSSStyleDeclaration, 'backgroundColor'>
} as HTMLElement
mockStore.maskColor = { r: 0, g: 0, b: 0 }
mockStore.maskBlendMode = MaskBlendMode.Black
@@ -163,7 +167,7 @@ describe('useCanvasManager', () => {
it('should throw error when canvas missing', async () => {
const manager = useCanvasManager()
mockStore.imgCanvas = null
mockStore.imgCanvas = null! as HTMLCanvasElement
const origImage = createMockImage(512, 512)
const maskImage = createMockImage(512, 512)
@@ -176,7 +180,7 @@ describe('useCanvasManager', () => {
it('should throw error when context missing', async () => {
const manager = useCanvasManager()
mockStore.imgCtx = null
mockStore.imgCtx = null! as CanvasRenderingContext2D
const origImage = createMockImage(512, 512)
const maskImage = createMockImage(512, 512)
@@ -259,7 +263,7 @@ describe('useCanvasManager', () => {
it('should return early when canvas missing', async () => {
const manager = useCanvasManager()
mockStore.maskCanvas = null
mockStore.maskCanvas = null! as HTMLCanvasElement
await manager.updateMaskColor()
@@ -269,7 +273,7 @@ describe('useCanvasManager', () => {
it('should return early when context missing', async () => {
const manager = useCanvasManager()
mockStore.maskCtx = null
mockStore.maskCtx = null! as CanvasRenderingContext2D
await manager.updateMaskColor()

View File

@@ -4,17 +4,37 @@ import { ColorComparisonMethod } from '@/extensions/core/maskeditor/types'
import { useCanvasTools } from '@/composables/maskeditor/useCanvasTools'
// Mock store interface matching the real store's nullable fields
interface MockMaskEditorStore {
maskCtx: CanvasRenderingContext2D | null
imgCtx: CanvasRenderingContext2D | null
maskCanvas: HTMLCanvasElement | null
imgCanvas: HTMLCanvasElement | null
rgbCtx: CanvasRenderingContext2D | null
rgbCanvas: HTMLCanvasElement | null
maskColor: { r: number; g: number; b: number }
paintBucketTolerance: number
fillOpacity: number
colorSelectTolerance: number
colorComparisonMethod: ColorComparisonMethod
selectionOpacity: number
applyWholeImage: boolean
maskBoundary: boolean
maskTolerance: number
canvasHistory: { saveState: ReturnType<typeof vi.fn> }
}
const mockCanvasHistory = {
saveState: vi.fn()
}
const mockStore = {
maskCtx: null as any,
imgCtx: null as any,
maskCanvas: null as any,
imgCanvas: null as any,
rgbCtx: null as any,
rgbCanvas: null as any,
const mockStore: MockMaskEditorStore = {
maskCtx: null,
imgCtx: null,
maskCanvas: null,
imgCanvas: null,
rgbCtx: null,
rgbCanvas: null,
maskColor: { r: 255, g: 255, b: 255 },
paintBucketTolerance: 10,
fillOpacity: 100,
@@ -57,34 +77,40 @@ describe('useCanvasTools', () => {
mockImgImageData.data[i + 3] = 255
}
mockStore.maskCtx = {
const partialMaskCtx: Partial<CanvasRenderingContext2D> = {
getImageData: vi.fn(() => mockMaskImageData),
putImageData: vi.fn(),
clearRect: vi.fn()
}
mockStore.maskCtx = partialMaskCtx as CanvasRenderingContext2D
mockStore.imgCtx = {
const partialImgCtx: Partial<CanvasRenderingContext2D> = {
getImageData: vi.fn(() => mockImgImageData)
}
mockStore.imgCtx = partialImgCtx as CanvasRenderingContext2D
mockStore.rgbCtx = {
const partialRgbCtx: Partial<CanvasRenderingContext2D> = {
clearRect: vi.fn()
}
mockStore.rgbCtx = partialRgbCtx as CanvasRenderingContext2D
mockStore.maskCanvas = {
const partialMaskCanvas: Partial<HTMLCanvasElement> = {
width: 100,
height: 100
}
mockStore.maskCanvas = partialMaskCanvas as HTMLCanvasElement
mockStore.imgCanvas = {
const partialImgCanvas: Partial<HTMLCanvasElement> = {
width: 100,
height: 100
}
mockStore.imgCanvas = partialImgCanvas as HTMLCanvasElement
mockStore.rgbCanvas = {
const partialRgbCanvas: Partial<HTMLCanvasElement> = {
width: 100,
height: 100
}
mockStore.rgbCanvas = partialRgbCanvas as HTMLCanvasElement
mockStore.maskColor = { r: 255, g: 255, b: 255 }
mockStore.paintBucketTolerance = 10
@@ -103,13 +129,13 @@ describe('useCanvasTools', () => {
tools.paintBucketFill({ x: 50, y: 50 })
expect(mockStore.maskCtx.getImageData).toHaveBeenCalledWith(
expect(mockStore.maskCtx!.getImageData).toHaveBeenCalledWith(
0,
0,
100,
100
)
expect(mockStore.maskCtx.putImageData).toHaveBeenCalledWith(
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalledWith(
mockMaskImageData,
0,
0
@@ -154,7 +180,7 @@ describe('useCanvasTools', () => {
tools.paintBucketFill({ x: -1, y: 50 })
expect(mockStore.maskCtx.putImageData).not.toHaveBeenCalled()
expect(mockStore.maskCtx!.putImageData).not.toHaveBeenCalled()
})
it('should return early when canvas missing', () => {
@@ -164,7 +190,7 @@ describe('useCanvasTools', () => {
tools.paintBucketFill({ x: 50, y: 50 })
expect(mockStore.maskCtx.getImageData).not.toHaveBeenCalled()
expect(mockStore.maskCtx?.getImageData).not.toHaveBeenCalled()
})
it('should apply fill opacity', () => {
@@ -198,14 +224,19 @@ describe('useCanvasTools', () => {
await tools.colorSelectFill({ x: 50, y: 50 })
expect(mockStore.maskCtx.getImageData).toHaveBeenCalledWith(
expect(mockStore.maskCtx!.getImageData).toHaveBeenCalledWith(
0,
0,
100,
100
)
expect(mockStore.imgCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
expect(mockStore.imgCtx!.getImageData).toHaveBeenCalledWith(
0,
0,
100,
100
)
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled()
expect(mockCanvasHistory.saveState).toHaveBeenCalled()
})
@@ -216,7 +247,7 @@ describe('useCanvasTools', () => {
await tools.colorSelectFill({ x: 50, y: 50 })
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled()
})
it('should respect color tolerance', async () => {
@@ -239,7 +270,7 @@ describe('useCanvasTools', () => {
await tools.colorSelectFill({ x: -1, y: 50 })
expect(mockStore.maskCtx.putImageData).not.toHaveBeenCalled()
expect(mockStore.maskCtx!.putImageData).not.toHaveBeenCalled()
})
it('should return early when canvas missing', async () => {
@@ -249,7 +280,7 @@ describe('useCanvasTools', () => {
await tools.colorSelectFill({ x: 50, y: 50 })
expect(mockStore.maskCtx.getImageData).not.toHaveBeenCalled()
expect(mockStore.maskCtx?.getImageData).not.toHaveBeenCalled()
})
it('should apply selection opacity', async () => {
@@ -270,7 +301,7 @@ describe('useCanvasTools', () => {
await tools.colorSelectFill({ x: 50, y: 50 })
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled()
})
it('should use LAB color comparison method', async () => {
@@ -280,7 +311,7 @@ describe('useCanvasTools', () => {
await tools.colorSelectFill({ x: 50, y: 50 })
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled()
})
it('should respect mask boundary', async () => {
@@ -295,7 +326,7 @@ describe('useCanvasTools', () => {
await tools.colorSelectFill({ x: 50, y: 50 })
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled()
})
it('should update last color select point', async () => {
@@ -303,7 +334,7 @@ describe('useCanvasTools', () => {
await tools.colorSelectFill({ x: 30, y: 40 })
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled()
})
})
@@ -320,13 +351,13 @@ describe('useCanvasTools', () => {
tools.invertMask()
expect(mockStore.maskCtx.getImageData).toHaveBeenCalledWith(
expect(mockStore.maskCtx!.getImageData).toHaveBeenCalledWith(
0,
0,
100,
100
)
expect(mockStore.maskCtx.putImageData).toHaveBeenCalledWith(
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalledWith(
mockMaskImageData,
0,
0
@@ -369,7 +400,7 @@ describe('useCanvasTools', () => {
tools.invertMask()
expect(mockStore.maskCtx.getImageData).not.toHaveBeenCalled()
expect(mockStore.maskCtx?.getImageData).not.toHaveBeenCalled()
})
it('should return early when context missing', () => {
@@ -389,8 +420,8 @@ describe('useCanvasTools', () => {
tools.clearMask()
expect(mockStore.maskCtx.clearRect).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockStore.rgbCtx.clearRect).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockStore.maskCtx!.clearRect).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockStore.rgbCtx!.clearRect).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockCanvasHistory.saveState).toHaveBeenCalled()
})
@@ -401,7 +432,7 @@ describe('useCanvasTools', () => {
tools.clearMask()
expect(mockStore.maskCtx.clearRect).not.toHaveBeenCalled()
expect(mockStore.maskCtx?.clearRect).not.toHaveBeenCalled()
expect(mockCanvasHistory.saveState).toHaveBeenCalled()
})
@@ -412,8 +443,8 @@ describe('useCanvasTools', () => {
tools.clearMask()
expect(mockStore.maskCtx.clearRect).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockStore.rgbCtx.clearRect).not.toHaveBeenCalled()
expect(mockStore.maskCtx?.clearRect).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockStore.rgbCtx?.clearRect).not.toHaveBeenCalled()
expect(mockCanvasHistory.saveState).toHaveBeenCalled()
})
})
@@ -426,26 +457,26 @@ describe('useCanvasTools', () => {
tools.clearLastColorSelectPoint()
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled()
})
})
describe('edge cases', () => {
it('should handle small canvas', () => {
mockStore.maskCanvas.width = 1
mockStore.maskCanvas.height = 1
mockStore.maskCanvas!.width = 1
mockStore.maskCanvas!.height = 1
mockMaskImageData = {
data: new Uint8ClampedArray(1 * 1 * 4),
width: 1,
height: 1
} as ImageData
mockStore.maskCtx.getImageData = vi.fn(() => mockMaskImageData)
mockStore.maskCtx!.getImageData = vi.fn(() => mockMaskImageData)
const tools = useCanvasTools()
tools.paintBucketFill({ x: 0, y: 0 })
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled()
})
it('should handle fractional coordinates', () => {
@@ -453,7 +484,7 @@ describe('useCanvasTools', () => {
tools.paintBucketFill({ x: 50.7, y: 50.3 })
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled()
})
it('should handle maximum tolerance', () => {
@@ -463,7 +494,7 @@ describe('useCanvasTools', () => {
tools.paintBucketFill({ x: 50, y: 50 })
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled()
})
it('should handle zero opacity', () => {

View File

@@ -95,7 +95,7 @@ if (typeof globalThis.ImageData === 'undefined') {
this.data = new Uint8ClampedArray(dataOrWidth * widthOrHeight * 4)
}
}
} as unknown as typeof globalThis.ImageData
} as typeof ImageData
}
// Mock ImageBitmap for test environment using safe type casting
@@ -108,7 +108,7 @@ if (typeof globalThis.ImageBitmap === 'undefined') {
this.height = height
}
close() {}
} as unknown as typeof globalThis.ImageBitmap
} as typeof ImageBitmap
}
describe('useCanvasTransform', () => {

View File

@@ -2,22 +2,39 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useImageLoader } from '@/composables/maskeditor/useImageLoader'
type MockStore = {
imgCanvas: HTMLCanvasElement | null
maskCanvas: HTMLCanvasElement | null
rgbCanvas: HTMLCanvasElement | null
imgCtx: CanvasRenderingContext2D | null
maskCtx: CanvasRenderingContext2D | null
image: HTMLImageElement | null
}
type MockDataStore = {
inputData: {
baseLayer: { image: HTMLImageElement }
maskLayer: { image: HTMLImageElement }
paintLayer: { image: HTMLImageElement } | null
} | null
}
const mockCanvasManager = {
invalidateCanvas: vi.fn().mockResolvedValue(undefined),
updateMaskColor: vi.fn().mockResolvedValue(undefined)
}
const mockStore = {
imgCanvas: null as any,
maskCanvas: null as any,
rgbCanvas: null as any,
imgCtx: null as any,
maskCtx: null as any,
image: null as any
const mockStore: MockStore = {
imgCanvas: null,
maskCanvas: null,
rgbCanvas: null,
imgCtx: null,
maskCtx: null,
image: null
}
const mockDataStore = {
inputData: null as any
const mockDataStore: MockDataStore = {
inputData: null
}
vi.mock('@/stores/maskEditorStore', () => ({
@@ -33,7 +50,8 @@ vi.mock('@/composables/maskeditor/useCanvasManager', () => ({
}))
vi.mock('@vueuse/core', () => ({
createSharedComposable: (fn: any) => fn
createSharedComposable: <T extends (...args: unknown[]) => unknown>(fn: T) =>
fn
}))
describe('useImageLoader', () => {
@@ -61,26 +79,26 @@ describe('useImageLoader', () => {
mockStore.imgCtx = {
clearRect: vi.fn()
}
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
mockStore.maskCtx = {
clearRect: vi.fn()
}
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
mockStore.imgCanvas = {
width: 0,
height: 0
}
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
mockStore.maskCanvas = {
width: 0,
height: 0
}
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
mockStore.rgbCanvas = {
width: 0,
height: 0
}
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
mockDataStore.inputData = {
baseLayer: { image: mockBaseImage },
@@ -104,10 +122,10 @@ describe('useImageLoader', () => {
await loader.loadImages()
expect(mockStore.maskCanvas.width).toBe(512)
expect(mockStore.maskCanvas.height).toBe(512)
expect(mockStore.rgbCanvas.width).toBe(512)
expect(mockStore.rgbCanvas.height).toBe(512)
expect(mockStore.maskCanvas?.width).toBe(512)
expect(mockStore.maskCanvas?.height).toBe(512)
expect(mockStore.rgbCanvas?.width).toBe(512)
expect(mockStore.rgbCanvas?.height).toBe(512)
})
it('should clear canvas contexts', async () => {
@@ -115,8 +133,8 @@ describe('useImageLoader', () => {
await loader.loadImages()
expect(mockStore.imgCtx.clearRect).toHaveBeenCalledWith(0, 0, 0, 0)
expect(mockStore.maskCtx.clearRect).toHaveBeenCalledWith(0, 0, 0, 0)
expect(mockStore.imgCtx?.clearRect).toHaveBeenCalledWith(0, 0, 0, 0)
expect(mockStore.maskCtx?.clearRect).toHaveBeenCalledWith(0, 0, 0, 0)
})
it('should call canvasManager methods', async () => {
@@ -188,10 +206,10 @@ describe('useImageLoader', () => {
await loader.loadImages()
expect(mockStore.maskCanvas.width).toBe(1024)
expect(mockStore.maskCanvas.height).toBe(768)
expect(mockStore.rgbCanvas.width).toBe(1024)
expect(mockStore.rgbCanvas.height).toBe(768)
expect(mockStore.maskCanvas?.width).toBe(1024)
expect(mockStore.maskCanvas?.height).toBe(768)
expect(mockStore.rgbCanvas?.width).toBe(1024)
expect(mockStore.rgbCanvas?.height).toBe(768)
})
})
})

View File

@@ -4,6 +4,7 @@ import { CREDITS_PER_USD, formatCredits } from '@/base/credits/comfyCredits'
import { useNodePricing } from '@/composables/node/useNodePricing'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { PriceBadge } from '@/schemas/nodeDefSchema'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
// -----------------------------------------------------------------------------
// Test Types
@@ -26,13 +27,6 @@ interface MockNodeData {
price_badge?: PriceBadge
}
interface MockNode {
id: string
widgets: MockNodeWidget[]
inputs: MockNodeInput[]
constructor: { nodeData: MockNodeData }
}
// -----------------------------------------------------------------------------
// Test Helpers
// -----------------------------------------------------------------------------
@@ -80,8 +74,8 @@ function createMockNodeWithPriceBadge(
link: connected ? 1 : null
}))
const node: MockNode = {
id: Math.random().toString(),
const baseNode = createMockLGraphNode()
return Object.assign(baseNode, {
widgets: mockWidgets,
inputs: mockInputs,
constructor: {
@@ -91,9 +85,7 @@ function createMockNodeWithPriceBadge(
price_badge: priceBadge
}
}
}
return node as unknown as LGraphNode
})
}
/** Helper to create a price badge with defaults */
@@ -108,6 +100,20 @@ const priceBadge = (
depends_on: { widgets, inputs, input_groups: inputGroups }
})
/** Helper to create a mock node for edge case testing */
function createMockNode(
nodeData: MockNodeData,
widgets: MockNodeWidget[] = [],
inputs: MockNodeInput[] = []
): LGraphNode {
const baseNode = createMockLGraphNode()
return Object.assign(baseNode, {
widgets,
inputs,
constructor: { nodeData }
})
}
// -----------------------------------------------------------------------------
// Tests
// -----------------------------------------------------------------------------
@@ -456,37 +462,23 @@ describe('useNodePricing', () => {
describe('edge cases', () => {
it('should return empty string for non-API nodes', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node: MockNode = {
id: 'test',
widgets: [],
inputs: [],
constructor: {
nodeData: {
name: 'RegularNode',
api_node: false
}
}
}
const node = createMockNode({
name: 'RegularNode',
api_node: false
})
const price = getNodeDisplayPrice(node as unknown as LGraphNode)
const price = getNodeDisplayPrice(node)
expect(price).toBe('')
})
it('should return empty string for nodes without price_badge', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node: MockNode = {
id: 'test',
widgets: [],
inputs: [],
constructor: {
nodeData: {
name: 'ApiNodeNoPricing',
api_node: true
}
}
}
const node = createMockNode({
name: 'ApiNodeNoPricing',
api_node: true
})
const price = getNodeDisplayPrice(node as unknown as LGraphNode)
const price = getNodeDisplayPrice(node)
expect(price).toBe('')
})
@@ -559,37 +551,23 @@ describe('useNodePricing', () => {
it('should return undefined for nodes without price_badge', () => {
const { getNodePricingConfig } = useNodePricing()
const node: MockNode = {
id: 'test',
widgets: [],
inputs: [],
constructor: {
nodeData: {
name: 'NoPricingNode',
api_node: true
}
}
}
const node = createMockNode({
name: 'NoPricingNode',
api_node: true
})
const config = getNodePricingConfig(node as unknown as LGraphNode)
const config = getNodePricingConfig(node)
expect(config).toBeUndefined()
})
it('should return undefined for non-API nodes', () => {
const { getNodePricingConfig } = useNodePricing()
const node: MockNode = {
id: 'test',
widgets: [],
inputs: [],
constructor: {
nodeData: {
name: 'RegularNode',
api_node: false
}
}
}
const node = createMockNode({
name: 'RegularNode',
api_node: false
})
const config = getNodePricingConfig(node as unknown as LGraphNode)
const config = getNodePricingConfig(node)
expect(config).toBeUndefined()
})
})
@@ -642,21 +620,12 @@ describe('useNodePricing', () => {
it('should not throw for non-API nodes', () => {
const { triggerPriceRecalculation } = useNodePricing()
const node: MockNode = {
id: 'test',
widgets: [],
inputs: [],
constructor: {
nodeData: {
name: 'RegularNode',
api_node: false
}
}
}
const node = createMockNode({
name: 'RegularNode',
api_node: false
})
expect(() =>
triggerPriceRecalculation(node as unknown as LGraphNode)
).not.toThrow()
expect(() => triggerPriceRecalculation(node)).not.toThrow()
})
})
@@ -751,35 +720,32 @@ describe('useNodePricing', () => {
const { getNodeDisplayPrice } = useNodePricing()
// Create a node with autogrow-style inputs (group.input1, group.input2, etc.)
const node: MockNode = {
id: Math.random().toString(),
widgets: [],
inputs: [
const node = createMockNode(
{
name: 'TestInputGroupNode',
api_node: true,
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd": inputGroups.videos * 0.05}',
depends_on: {
widgets: [],
inputs: [],
input_groups: ['videos']
}
}
},
[],
[
{ name: 'videos.clip1', link: 1 }, // connected
{ name: 'videos.clip2', link: 2 }, // connected
{ name: 'videos.clip3', link: null }, // disconnected
{ name: 'other_input', link: 3 } // connected but not in group
],
constructor: {
nodeData: {
name: 'TestInputGroupNode',
api_node: true,
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd": inputGroups.videos * 0.05}',
depends_on: {
widgets: [],
inputs: [],
input_groups: ['videos']
}
}
}
}
}
]
)
getNodeDisplayPrice(node as unknown as LGraphNode)
getNodeDisplayPrice(node)
await new Promise((r) => setTimeout(r, 50))
const price = getNodeDisplayPrice(node as unknown as LGraphNode)
const price = getNodeDisplayPrice(node)
// 2 connected inputs in 'videos' group * 0.05 = 0.10
expect(price).toBe(creditsLabel(0.1))
})

View File

@@ -3,11 +3,12 @@ import { nextTick } from 'vue'
import { useComputedWithWidgetWatch } from '@/composables/node/useWatchWidget'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
// Mock useChainCallback
vi.mock('@/composables/functional/useChainCallback', () => ({
useChainCallback: vi.fn((original, newCallback) => {
return function (this: any, ...args: any[]) {
return function (this: unknown, ...args: unknown[]) {
original?.call(this, ...args)
newCallback.call(this, ...args)
}
@@ -18,11 +19,12 @@ describe('useComputedWithWidgetWatch', () => {
const createMockNode = (
widgets: Array<{
name: string
value: any
callback?: (...args: any[]) => void
value: unknown
callback?: (...args: unknown[]) => void
}> = []
) => {
const mockNode = {
): LGraphNode => {
const baseNode = createMockLGraphNode()
return Object.assign(baseNode, {
widgets: widgets.map((widget) => ({
name: widget.name,
value: widget.value,
@@ -31,9 +33,7 @@ describe('useComputedWithWidgetWatch', () => {
graph: {
setDirtyCanvas: vi.fn()
}
} as unknown as LGraphNode
return mockNode
})
}
it('should create a reactive computed that responds to widget changes', async () => {
@@ -59,9 +59,9 @@ describe('useComputedWithWidgetWatch', () => {
// Change widget value and trigger callback
const widthWidget = mockNode.widgets?.find((w) => w.name === 'width')
if (widthWidget) {
if (widthWidget && widthWidget.callback) {
widthWidget.value = 150
;(widthWidget.callback as any)?.()
widthWidget.callback(widthWidget.value)
}
await nextTick()
@@ -89,9 +89,9 @@ describe('useComputedWithWidgetWatch', () => {
// Change observed widget
const widthWidget = mockNode.widgets?.find((w) => w.name === 'width')
if (widthWidget) {
if (widthWidget && widthWidget.callback) {
widthWidget.value = 150
;(widthWidget.callback as any)?.()
widthWidget.callback(widthWidget.value)
}
await nextTick()
@@ -117,9 +117,9 @@ describe('useComputedWithWidgetWatch', () => {
// Change widget value
const widget = mockNode.widgets?.[0]
if (widget) {
if (widget && widget.callback) {
widget.value = 20
;(widget.callback as any)?.()
widget.callback(widget.value)
}
await nextTick()
@@ -139,9 +139,9 @@ describe('useComputedWithWidgetWatch', () => {
// Change widget value
const widget = mockNode.widgets?.[0]
if (widget) {
if (widget && widget.callback) {
widget.value = 20
;(widget.callback as any)?.()
widget.callback(widget.value)
}
await nextTick()
@@ -171,8 +171,8 @@ describe('useComputedWithWidgetWatch', () => {
// Trigger widget callback
const widget = mockNode.widgets?.[0]
if (widget) {
;(widget.callback as any)?.()
if (widget && widget.callback) {
widget.callback(widget.value)
}
await nextTick()

View File

@@ -17,7 +17,7 @@ import {
isToday,
isYesterday
} from '@/utils/dateTimeUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { buildJobDisplay } from '@/utils/queueDisplay'
import { jobStateFromTask } from '@/utils/queueUtil'
@@ -185,11 +185,13 @@ export function useJobList() {
executionStore.isPromptInitializing(promptId)
const currentNodeName = computed(() => {
return resolveNodeDisplayName(executionStore.executingNode, {
emptyLabel: t('g.emDash'),
untitledLabel: t('g.untitled'),
st
})
const node = executionStore.executingNode
if (!node) return t('g.emDash')
const title = (node.title ?? '').toString().trim()
if (title) return title
const nodeType = (node.type ?? '').toString().trim() || t('g.untitled')
const key = `nodeDefs.${normalizeI18nKey(nodeType)}.display_name`
return st(key, nodeType)
})
const selectedJobTab = ref<JobTab>('All')

View File

@@ -11,13 +11,18 @@ vi.mock('@/platform/distribution/types', () => ({
const downloadFileMock = vi.fn()
vi.mock('@/base/common/downloadUtil', () => ({
downloadFile: (...args: any[]) => downloadFileMock(...args)
downloadFile: (url: string, filename?: string) => {
if (filename === undefined) {
return downloadFileMock(url)
}
return downloadFileMock(url, filename)
}
}))
const copyToClipboardMock = vi.fn()
vi.mock('@/composables/useCopyToClipboard', () => ({
useCopyToClipboard: () => ({
copyToClipboard: (...args: any[]) => copyToClipboardMock(...args)
copyToClipboard: (text: string) => copyToClipboardMock(text)
})
}))
@@ -30,8 +35,8 @@ vi.mock('@/i18n', () => ({
const mapTaskOutputToAssetItemMock = vi.fn()
vi.mock('@/platform/assets/composables/media/assetMappers', () => ({
mapTaskOutputToAssetItem: (...args: any[]) =>
mapTaskOutputToAssetItemMock(...args)
mapTaskOutputToAssetItem: (taskItem: TaskItemImpl, output: ResultItemImpl) =>
mapTaskOutputToAssetItemMock(taskItem, output)
}))
const mediaAssetActionsMock = {
@@ -67,14 +72,16 @@ const interruptMock = vi.fn()
const deleteItemMock = vi.fn()
vi.mock('@/scripts/api', () => ({
api: {
interrupt: (...args: any[]) => interruptMock(...args),
deleteItem: (...args: any[]) => deleteItemMock(...args)
interrupt: (runningPromptId: string | null) =>
interruptMock(runningPromptId),
deleteItem: (type: string, id: string) => deleteItemMock(type, id)
}
}))
const downloadBlobMock = vi.fn()
vi.mock('@/scripts/utils', () => ({
downloadBlob: (...args: any[]) => downloadBlobMock(...args)
downloadBlob: (filename: string, blob: Blob) =>
downloadBlobMock(filename, blob)
}))
const dialogServiceMock = {
@@ -94,11 +101,14 @@ vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => litegraphServiceMock
}))
const nodeDefStoreMock = {
nodeDefsByName: {} as Record<string, any>
const nodeDefStoreMock: {
nodeDefsByName: Record<string, Partial<ComfyNodeDefImpl>>
} = {
nodeDefsByName: {}
}
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: () => nodeDefStoreMock
useNodeDefStore: () => nodeDefStoreMock,
ComfyNodeDefImpl: class {}
}))
const queueStoreMock = {
@@ -118,12 +128,13 @@ vi.mock('@/stores/executionStore', () => ({
const getJobWorkflowMock = vi.fn()
vi.mock('@/services/jobOutputCache', () => ({
getJobWorkflow: (...args: any[]) => getJobWorkflowMock(...args)
getJobWorkflow: (jobId: string) => getJobWorkflowMock(jobId)
}))
const createAnnotatedPathMock = vi.fn()
vi.mock('@/utils/createAnnotatedPath', () => ({
createAnnotatedPath: (...args: any[]) => createAnnotatedPathMock(...args)
createAnnotatedPath: (filename: string, subfolder: string, type: string) =>
createAnnotatedPathMock(filename, subfolder, type)
}))
const appendJsonExtMock = vi.fn((value: string) =>
@@ -135,7 +146,8 @@ vi.mock('@/utils/formatUtil', () => ({
}))
import { useJobMenu } from '@/composables/queue/useJobMenu'
import type { TaskItemImpl } from '@/stores/queueStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
type MockTaskRef = Record<string, unknown>
@@ -193,9 +205,9 @@ describe('useJobMenu', () => {
}))
createAnnotatedPathMock.mockReturnValue('annotated-path')
nodeDefStoreMock.nodeDefsByName = {
LoadImage: { id: 'LoadImage' },
LoadVideo: { id: 'LoadVideo' },
LoadAudio: { id: 'LoadAudio' }
LoadImage: { name: 'LoadImage' },
LoadVideo: { name: 'LoadVideo' },
LoadAudio: { name: 'LoadAudio' }
}
// Default: no workflow available via lazy loading
getJobWorkflowMock.mockResolvedValue(undefined)
@@ -257,7 +269,7 @@ describe('useJobMenu', () => {
['initialization', interruptMock, deleteItemMock]
])('cancels %s job via interrupt', async (state) => {
const { cancelJob } = mountJobMenu()
setCurrentItem(createJobItem({ state: state as any }))
setCurrentItem(createJobItem({ state: state as JobListItem['state'] }))
await cancelJob()
@@ -292,7 +304,9 @@ describe('useJobMenu', () => {
setCurrentItem(
createJobItem({
state: 'failed',
taskRef: { errorMessage: 'Something went wrong' } as any
taskRef: {
errorMessage: 'Something went wrong'
} as Partial<TaskItemImpl>
})
)
@@ -324,7 +338,7 @@ describe('useJobMenu', () => {
errorMessage: 'CUDA out of memory',
executionError,
createTime: 12345
} as any
} as Partial<TaskItemImpl>
})
)
@@ -344,7 +358,9 @@ describe('useJobMenu', () => {
setCurrentItem(
createJobItem({
state: 'failed',
taskRef: { errorMessage: 'Job failed with error' } as any
taskRef: {
errorMessage: 'Job failed with error'
} as Partial<TaskItemImpl>
})
)
@@ -366,7 +382,7 @@ describe('useJobMenu', () => {
setCurrentItem(
createJobItem({
state: 'failed',
taskRef: { errorMessage: undefined } as any
taskRef: { errorMessage: undefined } as Partial<TaskItemImpl>
})
)
@@ -514,7 +530,12 @@ describe('useJobMenu', () => {
it('ignores add-to-current entry when preview missing entirely', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'completed', taskRef: {} as any }))
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: {} as Partial<TaskItemImpl>
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
@@ -543,7 +564,12 @@ describe('useJobMenu', () => {
it('ignores download request when preview missing', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'completed', taskRef: {} as any }))
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: {} as Partial<TaskItemImpl>
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'download')
@@ -751,7 +777,7 @@ describe('useJobMenu', () => {
setCurrentItem(
createJobItem({
state: 'failed',
taskRef: { errorMessage: 'Some error' } as any
taskRef: { errorMessage: 'Some error' } as Partial<TaskItemImpl>
})
)

View File

@@ -11,13 +11,28 @@ vi.mock('@/i18n', () => ({
}))
// Mock the execution store
const executionStore = reactive({
const executionStore = reactive<{
isIdle: boolean
executionProgress: number
executingNode: unknown
executingNodeProgress: number
nodeProgressStates: Record<string, unknown>
activePrompt: {
workflow: {
changeTracker: {
activeState: {
nodes: { id: number; type: string }[]
}
}
}
} | null
}>({
isIdle: true,
executionProgress: 0,
executingNode: null as any,
executingNode: null,
executingNodeProgress: 0,
nodeProgressStates: {} as any,
activePrompt: null as any
nodeProgressStates: {},
activePrompt: null
})
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => executionStore
@@ -25,15 +40,21 @@ vi.mock('@/stores/executionStore', () => ({
// Mock the setting store
const settingStore = reactive({
get: vi.fn(() => 'Enabled')
get: vi.fn((_key: string) => 'Enabled')
})
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => settingStore
}))
// Mock the workflow store
const workflowStore = reactive({
activeWorkflow: null as any
const workflowStore = reactive<{
activeWorkflow: {
filename: string
isModified: boolean
isPersisted: boolean
} | null
}>({
activeWorkflow: null
})
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => workflowStore
@@ -52,13 +73,13 @@ describe('useBrowserTabTitle', () => {
// reset execution store
executionStore.isIdle = true
executionStore.executionProgress = 0
executionStore.executingNode = null as any
executionStore.executingNode = null
executionStore.executingNodeProgress = 0
executionStore.nodeProgressStates = {}
executionStore.activePrompt = null
// reset setting and workflow stores
;(settingStore.get as any).mockReturnValue('Enabled')
vi.mocked(settingStore.get).mockReturnValue('Enabled')
workflowStore.activeWorkflow = null
workspaceStore.shiftDown = false
@@ -74,7 +95,7 @@ describe('useBrowserTabTitle', () => {
})
it('sets workflow name as title when workflow exists and menu enabled', async () => {
;(settingStore.get as any).mockReturnValue('Enabled')
vi.mocked(settingStore.get).mockReturnValue('Enabled')
workflowStore.activeWorkflow = {
filename: 'myFlow',
isModified: false,
@@ -88,7 +109,7 @@ describe('useBrowserTabTitle', () => {
})
it('adds asterisk for unsaved workflow', async () => {
;(settingStore.get as any).mockReturnValue('Enabled')
vi.mocked(settingStore.get).mockReturnValue('Enabled')
workflowStore.activeWorkflow = {
filename: 'myFlow',
isModified: true,
@@ -102,7 +123,7 @@ describe('useBrowserTabTitle', () => {
})
it('hides asterisk when autosave is enabled', async () => {
;(settingStore.get as any).mockImplementation((key: string) => {
vi.mocked(settingStore.get).mockImplementation((key: string) => {
if (key === 'Comfy.Workflow.AutoSave') return 'after delay'
if (key === 'Comfy.UseNewMenu') return 'Enabled'
return 'Enabled'
@@ -118,7 +139,7 @@ describe('useBrowserTabTitle', () => {
})
it('hides asterisk while Shift key is held', async () => {
;(settingStore.get as any).mockImplementation((key: string) => {
vi.mocked(settingStore.get).mockImplementation((key: string) => {
if (key === 'Comfy.Workflow.AutoSave') return 'off'
if (key === 'Comfy.UseNewMenu') return 'Enabled'
return 'Enabled'
@@ -137,7 +158,7 @@ describe('useBrowserTabTitle', () => {
// Fails when run together with other tests. Suspect to be caused by leaked
// state from previous tests.
it.skip('disables workflow title when menu disabled', async () => {
;(settingStore.get as any).mockReturnValue('Disabled')
vi.mocked(settingStore.get).mockReturnValue('Disabled')
workflowStore.activeWorkflow = {
filename: 'myFlow',
isModified: false,

View File

@@ -4,7 +4,7 @@ import { useCachedRequest } from '@/composables/useCachedRequest'
describe('useCachedRequest', () => {
let mockRequestFn: (
params: any,
params: unknown,
signal?: AbortSignal
) => Promise<unknown | null>
let abortSpy: () => void
@@ -25,7 +25,7 @@ describe('useCachedRequest', () => {
)
// Create a mock request function that returns different results based on params
mockRequestFn = vi.fn(async (params: any) => {
mockRequestFn = vi.fn(async (params: unknown) => {
// Simulate a request that takes some time
await new Promise((resolve) => setTimeout(resolve, 8))
@@ -138,12 +138,18 @@ describe('useCachedRequest', () => {
it('should use custom cache key function if provided', async () => {
// Create a cache key function that sorts object keys
const cacheKeyFn = (params: any) => {
const cacheKeyFn = (params: unknown) => {
if (typeof params !== 'object' || params === null) return String(params)
return JSON.stringify(
Object.keys(params)
Object.keys(params as Record<string, unknown>)
.sort()
.reduce((acc, key) => ({ ...acc, [key]: params[key] }), {})
.reduce(
(acc, key) => ({
...acc,
[key]: (params as Record<string, unknown>)[key]
}),
{}
)
)
}

View File

@@ -3,9 +3,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useCoreCommands } from '@/composables/useCoreCommands'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
// Mock vue-i18n for useExternalLink
const mockLocale = ref('en')
@@ -106,30 +108,84 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
}))
describe('useCoreCommands', () => {
const mockSubgraph = {
nodes: [
// Mock input node
{
constructor: { comfyClass: 'SubgraphInputNode' },
id: 'input1'
},
// Mock output node
{
constructor: { comfyClass: 'SubgraphOutputNode' },
id: 'output1'
},
// Mock user node
{
constructor: { comfyClass: 'SomeUserNode' },
id: 'user1'
},
// Another mock user node
{
constructor: { comfyClass: 'AnotherUserNode' },
id: 'user2'
const createMockNode = (id: number, comfyClass: string): LGraphNode => {
const baseNode = createMockLGraphNode({ id })
return Object.assign(baseNode, {
constructor: {
...baseNode.constructor,
comfyClass
}
],
remove: vi.fn()
})
}
const createMockSubgraph = () => {
const mockNodes = [
// Mock input node
createMockNode(1, 'SubgraphInputNode'),
// Mock output node
createMockNode(2, 'SubgraphOutputNode'),
// Mock user node
createMockNode(3, 'SomeUserNode'),
// Another mock user node
createMockNode(4, 'AnotherUserNode')
]
return {
nodes: mockNodes,
remove: vi.fn(),
events: {
dispatch: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
},
name: 'test-subgraph',
inputNode: undefined,
outputNode: undefined,
add: vi.fn(),
clear: vi.fn(),
serialize: vi.fn(),
configure: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
runStep: vi.fn(),
findNodeByTitle: vi.fn(),
findNodesByTitle: vi.fn(),
findNodesByType: vi.fn(),
findNodeById: vi.fn(),
getNodeById: vi.fn(),
setDirtyCanvas: vi.fn(),
sendActionToCanvas: vi.fn()
} as Partial<typeof app.canvas.subgraph> as typeof app.canvas.subgraph
}
const mockSubgraph = createMockSubgraph()
function createMockSettingStore(
getReturnValue: boolean
): ReturnType<typeof useSettingStore> {
return {
get: vi.fn().mockReturnValue(getReturnValue),
addSetting: vi.fn(),
loadSettingValues: vi.fn(),
set: vi.fn(),
exists: vi.fn(),
getDefaultValue: vi.fn(),
settingValues: {},
settingsById: {},
$id: 'setting',
$state: {
settingValues: {},
settingsById: {}
},
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$onAction: vi.fn(),
$dispose: vi.fn(),
_customProperties: new Set(),
_p: {}
} as ReturnType<typeof useSettingStore>
}
beforeEach(() => {
@@ -142,9 +198,7 @@ describe('useCoreCommands', () => {
app.canvas.subgraph = undefined
// Mock settings store
vi.mocked(useSettingStore).mockReturnValue({
get: vi.fn().mockReturnValue(false) // Skip confirmation dialog
} as any)
vi.mocked(useSettingStore).mockReturnValue(createMockSettingStore(false))
// Mock global confirm
global.confirm = vi.fn().mockReturnValue(true)
@@ -167,7 +221,7 @@ describe('useCoreCommands', () => {
it('should preserve input/output nodes when clearing subgraph', async () => {
// Set up subgraph context
app.canvas.subgraph = mockSubgraph as any
app.canvas.subgraph = mockSubgraph
const commands = useCoreCommands()
const clearCommand = commands.find(
@@ -181,24 +235,19 @@ describe('useCoreCommands', () => {
expect(app.rootGraph.clear).not.toHaveBeenCalled()
// Should only remove user nodes, not input/output nodes
expect(mockSubgraph.remove).toHaveBeenCalledTimes(2)
expect(mockSubgraph.remove).toHaveBeenCalledWith(mockSubgraph.nodes[2]) // user1
expect(mockSubgraph.remove).toHaveBeenCalledWith(mockSubgraph.nodes[3]) // user2
expect(mockSubgraph.remove).not.toHaveBeenCalledWith(
mockSubgraph.nodes[0]
) // input1
expect(mockSubgraph.remove).not.toHaveBeenCalledWith(
mockSubgraph.nodes[1]
) // output1
const subgraph = app.canvas.subgraph!
expect(subgraph.remove).toHaveBeenCalledTimes(2)
expect(subgraph.remove).toHaveBeenCalledWith(subgraph.nodes[2]) // user1
expect(subgraph.remove).toHaveBeenCalledWith(subgraph.nodes[3]) // user2
expect(subgraph.remove).not.toHaveBeenCalledWith(subgraph.nodes[0]) // input1
expect(subgraph.remove).not.toHaveBeenCalledWith(subgraph.nodes[1]) // output1
expect(api.dispatchCustomEvent).toHaveBeenCalledWith('graphCleared')
})
it('should respect confirmation setting', async () => {
// Mock confirmation required
vi.mocked(useSettingStore).mockReturnValue({
get: vi.fn().mockReturnValue(true) // Require confirmation
} as any)
vi.mocked(useSettingStore).mockReturnValue(createMockSettingStore(true))
global.confirm = vi.fn().mockReturnValue(false) // User cancels

View File

@@ -630,6 +630,10 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
handleBackgroundImageUpdate,
handleModelDrop,
handleSeek,
cleanup
cleanup,
hasSkeleton: false,
intensity: lightIntensity,
showSkeleton: false
}
}

View File

@@ -1,7 +1,4 @@
import {
type CallbackParams,
useChainCallback
} from '@/composables/functional/useChainCallback'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type {
INodeInputSlot,
@@ -11,7 +8,10 @@ import type {
} from '@/lib/litegraph/src/litegraph'
import { NodeSlot } from '@/lib/litegraph/src/node/NodeSlot'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type {
IBaseWidget,
TWidgetValue
} from '@/lib/litegraph/src/types/widgets'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import { app } from '@/scripts/app'
import {
@@ -26,7 +26,7 @@ import { isPrimitiveNode } from '@/renderer/utils/nodeTypeGuards'
const replacePropertyName = 'Run widget replace on values'
export class PrimitiveNode extends LGraphNode {
controlValues?: any[]
controlValues?: TWidgetValue[]
lastType?: string
static override category: string
constructor(title: string) {
@@ -561,7 +561,7 @@ app.registerExtension({
const origOnInputDblClick = nodeType.prototype.onInputDblClick
nodeType.prototype.onInputDblClick = function (
this: LGraphNode,
...[slot, ...args]: CallbackParams<typeof origOnInputDblClick>
...[slot, ...args]: Parameters<NonNullable<typeof origOnInputDblClick>>
) {
const r = origOnInputDblClick?.apply(this, [slot, ...args])

View File

@@ -740,7 +740,6 @@
"title": "Queue Progress",
"total": "Total: {percent}",
"colonPercent": ": {percent}",
"inlineTotalLabel": "Total",
"currentNode": "Current node:",
"viewAllJobs": "View all jobs",
"viewList": "List view",
@@ -2684,7 +2683,8 @@
"noneSearchDesc": "No items match your search",
"nodesNoneDesc": "NO NODES",
"fallbackGroupTitle": "Group",
"fallbackNodeTitle": "Node"
"fallbackNodeTitle": "Node",
"hideAdvancedInputsButton": "Hide advanced inputs"
},
"help": {
"recentReleases": "Recent releases",

View File

@@ -2329,7 +2329,7 @@
}
},
"EmptyHunyuanImageLatent": {
"display_name": "空Latent图像",
"display_name": "空Latent图像Hunyuan",
"inputs": {
"batch_size": {
"name": "批次大小"

View File

@@ -1,5 +1,7 @@
<template>
<div class="absolute left-2 bottom-2 flex flex-wrap justify-start gap-1">
<div
class="absolute left-2 bottom-2 flex flex-wrap justify-start gap-1 select-none"
>
<span
v-for="badge in badges"
:key="badge.label"

View File

@@ -7,19 +7,18 @@
:right-panel-title="$t('assetBrowser.modelInfo.title')"
@close="handleClose"
>
<template v-if="shouldShowLeftPanel" #leftPanelHeaderTitle>
<i class="icon-[comfy--ai-model] size-4" />
<h2 class="flex-auto select-none text-base font-semibold text-nowrap">
{{ displayTitle }}
</h2>
</template>
<template v-if="shouldShowLeftPanel" #leftPanel>
<LeftSidePanel
v-model="selectedNavItem"
data-component-id="AssetBrowserModal-LeftSidePanel"
:nav-items
>
<template #header-icon>
<div class="icon-[comfy--ai-model] size-4" />
</template>
<template #header-title>
<span class="capitalize">{{ displayTitle }}</span>
</template>
</LeftSidePanel>
/>
</template>
<template #header>

View File

@@ -7,7 +7,7 @@
:tabindex="interactive ? 0 : -1"
:class="
cn(
'rounded-2xl overflow-hidden transition-all duration-200 bg-modal-card-background p-2 gap-2 flex flex-col h-full',
'select-none rounded-2xl overflow-hidden transition-all duration-200 bg-modal-card-background p-2 gap-2 flex flex-col h-full',
interactive &&
'group appearance-none bg-transparent m-0 outline-none text-left hover:bg-secondary-background focus:bg-secondary-background border-none focus:outline-solid outline-base-foreground outline-4',
focused && 'bg-secondary-background outline-solid'

View File

@@ -79,8 +79,9 @@ const fileFormats = ref<SelectOption[]>([])
const baseModels = ref<SelectOption[]>([])
const sortBy = ref<SortOption>('recent')
const { availableFileFormats, availableBaseModels } =
useAssetFilterOptions(assets)
const { availableFileFormats, availableBaseModels } = useAssetFilterOptions(
() => assets
)
const emit = defineEmits<{
filterChange: [filters: FilterState]

View File

@@ -15,7 +15,7 @@
</div>
<div
v-else-if="assets.length === 0"
class="flex h-full flex-col items-center justify-center py-16 text-muted-foreground"
class="flex h-full select-none flex-col items-center justify-center py-16 text-muted-foreground"
>
<i class="mb-4 icon-[lucide--search] size-10" />
<h3 class="mb-2 text-lg font-medium">

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex flex-col gap-2 px-4 py-2 text-sm text-base-foreground">
<div class="flex items-center justify-between relative">
<span>{{ label }}</span>
<span class="select-none">{{ label }}</span>
<slot name="label-action" />
</div>
<slot />

View File

@@ -5,7 +5,7 @@
>
<PropertiesAccordionItem :class="accordionClass">
<template #label>
<span class="text-xs uppercase font-inter">
<span class="text-xs uppercase font-inter select-none">
{{ t('assetBrowser.modelInfo.basicInfo') }}
</span>
</template>
@@ -58,7 +58,7 @@
<PropertiesAccordionItem :class="accordionClass">
<template #label>
<span class="text-xs uppercase font-inter">
<span class="text-xs uppercase font-inter select-none">
{{ t('assetBrowser.modelInfo.modelTagging') }}
</span>
</template>
@@ -134,7 +134,7 @@
<PropertiesAccordionItem :class="accordionClass">
<template #label>
<span class="text-xs uppercase font-inter">
<span class="text-xs uppercase font-inter select-none">
{{ t('assetBrowser.modelInfo.modelDescription') }}
</span>
</template>

View File

@@ -1,89 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useAssetSelection } from './useAssetSelection'
import { useAssetSelectionStore } from './useAssetSelectionStore'
vi.mock('@vueuse/core', () => ({
useKeyModifier: vi.fn(() => ref(false))
}))
describe('useAssetSelection', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('prunes selection to visible assets', () => {
const selection = useAssetSelection()
const store = useAssetSelectionStore()
const assets: AssetItem[] = [
{ id: 'a', name: 'a.png', tags: [] },
{ id: 'b', name: 'b.png', tags: [] }
]
store.setSelection(['a', 'b'])
store.setLastSelectedIndex(1)
store.setLastSelectedAssetId('b')
selection.reconcileSelection([assets[1]])
expect(Array.from(store.selectedAssetIds)).toEqual(['b'])
expect(store.lastSelectedIndex).toBe(0)
expect(store.lastSelectedAssetId).toBe('b')
})
it('clears selection when no visible assets remain', () => {
const selection = useAssetSelection()
const store = useAssetSelectionStore()
store.setSelection(['a'])
store.setLastSelectedIndex(0)
store.setLastSelectedAssetId('a')
selection.reconcileSelection([])
expect(store.selectedAssetIds.size).toBe(0)
expect(store.lastSelectedIndex).toBe(-1)
expect(store.lastSelectedAssetId).toBeNull()
})
it('recomputes the anchor index when assets reorder', () => {
const selection = useAssetSelection()
const store = useAssetSelectionStore()
const assets: AssetItem[] = [
{ id: 'a', name: 'a.png', tags: [] },
{ id: 'b', name: 'b.png', tags: [] }
]
store.setSelection(['a'])
store.setLastSelectedIndex(0)
store.setLastSelectedAssetId('a')
selection.reconcileSelection([assets[1], assets[0]])
expect(store.lastSelectedIndex).toBe(1)
expect(store.lastSelectedAssetId).toBe('a')
})
it('clears anchor when the anchored asset is no longer visible', () => {
const selection = useAssetSelection()
const store = useAssetSelectionStore()
const assets: AssetItem[] = [
{ id: 'a', name: 'a.png', tags: [] },
{ id: 'b', name: 'b.png', tags: [] }
]
store.setSelection(['a', 'b'])
store.setLastSelectedIndex(0)
store.setLastSelectedAssetId('a')
selection.reconcileSelection([assets[1]])
expect(Array.from(store.selectedAssetIds)).toEqual(['b'])
expect(store.lastSelectedIndex).toBe(-1)
expect(store.lastSelectedAssetId).toBeNull()
})
})

View File

@@ -21,25 +21,6 @@ export function useAssetSelection() {
const metaKey = computed(() => isActive.value && metaKeyRaw.value)
const cmdOrCtrlKey = computed(() => ctrlKey.value || metaKey.value)
function setAnchor(index: number, assetId: string | null) {
selectionStore.setLastSelectedIndex(index)
selectionStore.setLastSelectedAssetId(assetId)
}
function syncAnchorFromAssets(assets: AssetItem[]) {
const anchorId = selectionStore.lastSelectedAssetId
const anchorIndex = anchorId
? assets.findIndex((asset) => asset.id === anchorId)
: -1
if (anchorIndex !== -1) {
selectionStore.setLastSelectedIndex(anchorIndex)
return
}
setAnchor(-1, null)
}
/**
* Handle asset click with modifier keys for selection
* @param asset The clicked asset
@@ -79,14 +60,14 @@ export function useAssetSelection() {
// Ctrl/Cmd + Click: Toggle individual selection
if (cmdOrCtrlKey.value) {
selectionStore.toggleSelection(assetId)
setAnchor(index, assetId)
selectionStore.setLastSelectedIndex(index)
return
}
// Normal Click: Single selection
selectionStore.clearSelection()
selectionStore.addToSelection(assetId)
setAnchor(index, assetId)
selectionStore.setLastSelectedIndex(index)
}
/**
@@ -96,8 +77,7 @@ export function useAssetSelection() {
const allIds = allAssets.map((a) => a.id)
selectionStore.setSelection(allIds)
if (allAssets.length > 0) {
const lastIndex = allAssets.length - 1
setAnchor(lastIndex, allAssets[lastIndex].id)
selectionStore.setLastSelectedIndex(allAssets.length - 1)
}
}
@@ -108,39 +88,6 @@ export function useAssetSelection() {
return allAssets.filter((asset) => selectionStore.isSelected(asset.id))
}
function reconcileSelection(assets: AssetItem[]) {
if (selectionStore.selectedAssetIds.size === 0) {
return
}
if (assets.length === 0) {
selectionStore.clearSelection()
return
}
const visibleIds = new Set(assets.map((asset) => asset.id))
const nextSelectedIds: string[] = []
for (const id of selectionStore.selectedAssetIds) {
if (visibleIds.has(id)) {
nextSelectedIds.push(id)
}
}
if (nextSelectedIds.length === selectionStore.selectedAssetIds.size) {
syncAnchorFromAssets(assets)
return
}
if (nextSelectedIds.length === 0) {
selectionStore.clearSelection()
return
}
selectionStore.setSelection(nextSelectedIds)
syncAnchorFromAssets(assets)
}
/**
* Get the output count for a single asset
* Same logic as in AssetsSidebarTab.vue
@@ -170,7 +117,7 @@ export function useAssetSelection() {
function deactivate() {
isActive.value = false
// Reset selection state to ensure clean state when deactivated
selectionStore.clearSelection()
selectionStore.reset()
}
return {
@@ -185,9 +132,10 @@ export function useAssetSelection() {
selectAll,
clearSelection: () => selectionStore.clearSelection(),
getSelectedAssets,
reconcileSelection,
getOutputCount,
getTotalOutputCount,
reset: () => selectionStore.reset(),
// Lifecycle management
activate,
deactivate,

View File

@@ -5,7 +5,6 @@ export const useAssetSelectionStore = defineStore('assetSelection', () => {
// State
const selectedAssetIds = ref<Set<string>>(new Set())
const lastSelectedIndex = ref<number>(-1)
const lastSelectedAssetId = ref<string | null>(null)
// Getters
const selectedCount = computed(() => selectedAssetIds.value.size)
@@ -35,7 +34,6 @@ export const useAssetSelectionStore = defineStore('assetSelection', () => {
function clearSelection() {
selectedAssetIds.value.clear()
lastSelectedIndex.value = -1
lastSelectedAssetId.value = null
}
function toggleSelection(assetId: string) {
@@ -54,15 +52,16 @@ export const useAssetSelectionStore = defineStore('assetSelection', () => {
lastSelectedIndex.value = index
}
function setLastSelectedAssetId(assetId: string | null) {
lastSelectedAssetId.value = assetId
// Reset function for cleanup
function reset() {
selectedAssetIds.value.clear()
lastSelectedIndex.value = -1
}
return {
// State
selectedAssetIds: computed(() => selectedAssetIds.value),
lastSelectedIndex: computed(() => lastSelectedIndex.value),
lastSelectedAssetId: computed(() => lastSelectedAssetId.value),
// Getters
selectedCount,
@@ -77,6 +76,6 @@ export const useAssetSelectionStore = defineStore('assetSelection', () => {
toggleSelection,
isSelected,
setLastSelectedIndex,
setLastSelectedAssetId
reset
}
})

View File

@@ -1,123 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { ResultItemImpl } from '@/stores/queueStore'
import { resolveOutputAssetItems } from './outputAssetUtil'
const mocks = vi.hoisted(() => ({
getJobDetail: vi.fn(),
getPreviewableOutputsFromJobDetail: vi.fn()
}))
vi.mock('@/services/jobOutputCache', () => ({
getJobDetail: mocks.getJobDetail,
getPreviewableOutputsFromJobDetail: mocks.getPreviewableOutputsFromJobDetail
}))
type OutputOverrides = Partial<{
filename: string
subfolder: string
nodeId: string
url: string
}>
function createOutput(overrides: OutputOverrides = {}): ResultItemImpl {
return {
filename: 'file.png',
subfolder: 'sub',
nodeId: '1',
url: 'https://example.com/file.png',
...overrides
} as ResultItemImpl
}
describe('resolveOutputAssetItems', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('maps outputs and excludes a filename', async () => {
const outputA = createOutput({
filename: 'a.png',
nodeId: '1',
url: 'https://example.com/a.png'
})
const outputB = createOutput({
filename: 'b.png',
nodeId: '2',
url: 'https://example.com/b.png'
})
const metadata: OutputAssetMetadata = {
promptId: 'prompt-1',
nodeId: '1',
subfolder: 'sub',
executionTimeInSeconds: 12.5,
outputCount: 2,
allOutputs: [outputA, outputB]
}
const results = await resolveOutputAssetItems(metadata, {
createdAt: '2025-01-01T00:00:00.000Z',
excludeOutputKey: 'b.png'
})
expect(mocks.getJobDetail).not.toHaveBeenCalled()
expect(results).toHaveLength(1)
expect(results[0]).toEqual(
expect.objectContaining({
id: 'prompt-1-1-a.png',
name: 'a.png',
created_at: '2025-01-01T00:00:00.000Z',
tags: ['output'],
preview_url: 'https://example.com/a.png'
})
)
expect(results[0].user_metadata).toEqual(
expect.objectContaining({
promptId: 'prompt-1',
nodeId: '1',
subfolder: 'sub',
executionTimeInSeconds: 12.5
})
)
})
it('loads full outputs when metadata indicates more outputs', async () => {
const previewOutput = createOutput({
filename: 'preview.png',
nodeId: '1',
url: 'https://example.com/preview.png'
})
const fullOutput = createOutput({
filename: 'full.png',
nodeId: '2',
url: 'https://example.com/full.png'
})
const metadata: OutputAssetMetadata = {
promptId: 'prompt-2',
nodeId: '1',
subfolder: 'sub',
outputCount: 3,
allOutputs: [previewOutput]
}
const jobDetail = { id: 'job-1' }
mocks.getJobDetail.mockResolvedValue(jobDetail)
mocks.getPreviewableOutputsFromJobDetail.mockReturnValue([
fullOutput,
previewOutput
])
const results = await resolveOutputAssetItems(metadata)
expect(mocks.getJobDetail).toHaveBeenCalledWith('prompt-2')
expect(mocks.getPreviewableOutputsFromJobDetail).toHaveBeenCalledWith(
jobDetail
)
expect(results.map((asset) => asset.name)).toEqual([
'full.png',
'preview.png'
])
})
})

View File

@@ -1,84 +0,0 @@
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
getJobDetail,
getPreviewableOutputsFromJobDetail
} from '@/services/jobOutputCache'
import type { ResultItemImpl } from '@/stores/queueStore'
type OutputAssetMapOptions = {
promptId: string
outputs: readonly ResultItemImpl[]
createdAt?: string
executionTimeInSeconds?: number
workflow?: OutputAssetMetadata['workflow']
excludeFilename?: string
}
type ResolveOutputAssetItemsOptions = {
createdAt?: string
excludeOutputKey?: string
}
function shouldLoadFullOutputs(
outputCount: OutputAssetMetadata['outputCount'],
outputsLength: number
): boolean {
return (
typeof outputCount === 'number' &&
outputCount > 1 &&
outputsLength < outputCount
)
}
function mapOutputsToAssetItems({
promptId,
outputs,
createdAt,
executionTimeInSeconds,
workflow,
excludeFilename
}: OutputAssetMapOptions): AssetItem[] {
const createdAtValue = createdAt ?? new Date().toISOString()
return outputs
.filter((output) => output.filename && output.filename !== excludeFilename)
.map((output) => ({
id: `${promptId}-${output.nodeId}-${output.filename}`,
name: output.filename,
size: 0,
created_at: createdAtValue,
tags: ['output'],
preview_url: output.url,
user_metadata: {
promptId,
nodeId: output.nodeId,
subfolder: output.subfolder,
executionTimeInSeconds,
workflow
}
}))
}
export async function resolveOutputAssetItems(
metadata: OutputAssetMetadata,
{ createdAt, excludeOutputKey }: ResolveOutputAssetItemsOptions = {}
): Promise<AssetItem[]> {
let outputsToDisplay = metadata.allOutputs ?? []
if (shouldLoadFullOutputs(metadata.outputCount, outputsToDisplay.length)) {
const jobDetail = await getJobDetail(metadata.promptId)
const previewableOutputs = getPreviewableOutputsFromJobDetail(jobDetail)
if (previewableOutputs.length) {
outputsToDisplay = previewableOutputs
}
}
return mapOutputsToAssetItems({
promptId: metadata.promptId,
outputs: outputsToDisplay,
createdAt,
executionTimeInSeconds: metadata.executionTimeInSeconds,
workflow: metadata.workflow,
excludeFilename: excludeOutputKey
})
}

View File

@@ -1174,5 +1174,15 @@ export const CORE_SETTINGS: SettingParams[] = [
'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,
experimental: true
},
{
id: 'Comfy.Node.AlwaysShowAdvancedWidgets',
category: ['LiteGraph', 'Node Widget', 'AlwaysShowAdvancedWidgets'],
name: 'Always show advanced widgets on all nodes',
tooltip:
'When enabled, advanced widgets are always visible on all nodes without needing to expand them individually.',
type: 'boolean',
defaultValue: false,
versionAdded: '1.39.0'
}
]

View File

@@ -159,7 +159,7 @@ async function rerun(e: Event) {
<VideoPreview
v-else-if="getMediaType(selectedOutput) === 'video'"
:src="selectedOutput!.url"
class="object-contain flex-1 md:contain-size"
class="object-contain flex-1 md:contain-size md:p-3"
/>
<audio
v-else-if="getMediaType(selectedOutput) === 'audio'"

View File

@@ -1,12 +1,20 @@
<script setup lang="ts">
import { useEventListener, useInfiniteScroll, useScroll } from '@vueuse/core'
import { computed, ref, toRaw, useTemplateRef, watch } from 'vue'
import {
useAsyncState,
useEventListener,
useInfiniteScroll,
useScroll
} from '@vueuse/core'
import { computed, ref, toRaw, toValue, useTemplateRef, watch } from 'vue'
import type { MaybeRef } from 'vue'
import ModeToggle from '@/components/sidebar/ModeToggle.vue'
import SidebarIcon from '@/components/sidebar/SidebarIcon.vue'
import SidebarTemplatesButton from '@/components/sidebar/SidebarTemplatesButton.vue'
import WorkflowsSidebarTab from '@/components/sidebar/tabs/WorkflowsSidebarTab.vue'
import Button from '@/components/ui/button/Button.vue'
import { useProgressBarBackground } from '@/composables/useProgressBarBackground'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
@@ -16,13 +24,21 @@ import {
getMediaType,
mediaTypes
} from '@/renderer/extensions/linearMode/mediaTypes'
import { useQueueStore } from '@/stores/queueStore'
import type { ResultItemImpl } from '@/stores/queueStore'
import type { NodeExecutionOutput, ResultItem } from '@/schemas/apiSchema'
import { getJobDetail } from '@/services/jobOutputCache'
import { useQueueStore, ResultItemImpl } from '@/stores/queueStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { cn } from '@/utils/tailwindUtil'
const displayWorkflows = ref(false)
const outputs = useMediaAssets('output')
const {
progressBarContainerClass,
progressBarPrimaryClass,
progressBarSecondaryClass,
progressPercentStyle
} = useProgressBarBackground()
const { totalPercent, currentNodePercent } = useQueueProgress()
const queueStore = useQueueStore()
const settingStore = useSettingStore()
@@ -46,14 +62,14 @@ defineExpose({ onWheel })
const selectedIndex = ref<[number, number]>([-1, 0])
watch(selectedIndex, () => {
function doEmit() {
const [index] = selectedIndex.value
emit('updateSelection', [
outputs.media.value[index],
selectedOutput.value,
selectedIndex.value[0] <= 0
])
})
}
const outputsRef = useTemplateRef('outputsRef')
const { reset: resetInfiniteScroll } = useInfiniteScroll(
@@ -72,36 +88,76 @@ watch(selectedIndex, () => {
const [index, key] = selectedIndex.value
if (!outputsRef.value) return
const outputElement = outputsRef.value?.children?.[index]?.children?.[key]
const outputElement = outputsRef.value?.querySelectorAll(
`[data-output-index="${index}"]`
)?.[key]
if (!outputElement) return
//container: 'nearest' is nice, but bleeding edge and chrome only
outputElement.scrollIntoView({ block: 'nearest' })
})
function allOutputs(item?: AssetItem) {
function outputCount(item?: AssetItem) {
const user_metadata = getOutputAssetMetadata(item?.user_metadata)
if (!user_metadata?.allOutputs) return []
return user_metadata?.outputCount ?? 0
}
return user_metadata.allOutputs
const outputsCache: Record<string, MaybeRef<ResultItemImpl[]>> = {}
function flattenNodeOutput([nodeId, nodeOutput]: [
string | number,
NodeExecutionOutput
]): ResultItemImpl[] {
const knownOutputs: Record<string, ResultItem[]> = {}
if (nodeOutput.audio) knownOutputs.audio = nodeOutput.audio
if (nodeOutput.images) knownOutputs.images = nodeOutput.images
if (nodeOutput.video) knownOutputs.video = nodeOutput.video
if (nodeOutput.gifs) knownOutputs.gifs = nodeOutput.gifs as ResultItem[]
if (nodeOutput['3d']) knownOutputs['3d'] = nodeOutput['3d'] as ResultItem[]
return Object.entries(knownOutputs).flatMap(([mediaType, outputs]) =>
outputs.map(
(output) => new ResultItemImpl({ ...output, mediaType, nodeId })
)
)
}
function allOutputs(item?: AssetItem): MaybeRef<ResultItemImpl[]> {
if (item?.id && outputsCache[item.id]) return outputsCache[item.id]
const user_metadata = getOutputAssetMetadata(item?.user_metadata)
if (!user_metadata) return []
if (
user_metadata.allOutputs &&
user_metadata.outputCount &&
user_metadata.outputCount < user_metadata.allOutputs.length
)
return user_metadata.allOutputs
const outputRef = useAsyncState(
getJobDetail(user_metadata.promptId).then((jobDetail) => {
if (!jobDetail?.outputs) return []
return Object.entries(jobDetail.outputs).flatMap(flattenNodeOutput)
}),
[]
).state
outputsCache[item!.id] = outputRef
return outputRef
}
const selectedOutput = computed(() => {
const [index, key] = selectedIndex.value
if (index < 0) return undefined
const output = allOutputs(outputs.media.value[index])[key]
if (output) return output
return allOutputs(outputs.media.value[0])[0]
return toValue(allOutputs(outputs.media.value[index]))[key]
})
watch([selectedIndex, selectedOutput], doEmit)
watch(
() => outputs.media.value,
(newAssets, oldAssets) => {
if (newAssets.length === oldAssets.length || oldAssets.length === 0) return
if (selectedIndex.value[0] <= 0) {
//force update
selectedIndex.value = [0, 0]
return
}
@@ -120,8 +176,7 @@ function gotoNextOutput() {
selectedIndex.value = [0, 0]
return
}
const currentItem = outputs.media.value[index]
if (allOutputs(currentItem)[key + 1]) {
if (key + 1 < outputCount(outputs.media.value[index])) {
selectedIndex.value = [index, key + 1]
return
}
@@ -139,8 +194,8 @@ function gotoPreviousOutput() {
}
if (index > 0) {
const currentItem = outputs.media.value[index - 1]
selectedIndex.value = [index - 1, allOutputs(currentItem).length - 1]
const len = outputCount(outputs.media.value[index - 1])
selectedIndex.value = [index - 1, len - 1]
return
}
@@ -246,12 +301,24 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
queueStore.runningTasks.length + queueStore.pendingTasks.length
"
/>
<div class="absolute -bottom-1 w-full h-3 rounded-sm overflow-clip">
<div :class="progressBarContainerClass">
<div
:class="progressBarPrimaryClass"
:style="progressPercentStyle(totalPercent)"
/>
<div
:class="progressBarSecondaryClass"
:style="progressPercentStyle(currentNodePercent)"
/>
</div>
</div>
</section>
<template v-for="(item, index) in outputs.media.value" :key="index">
<div
class="border-border-subtle not-md:border-l md:border-t first:border-none not-md:h-21 md:w-full m-3"
/>
<template v-for="(output, key) in allOutputs(item)" :key>
<template v-for="(output, key) in toValue(allOutputs(item))" :key>
<img
v-if="getMediaType(output) === 'images'"
:class="
@@ -262,6 +329,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
'border-2'
)
"
:data-output-index="index"
:src="output.url"
@click="selectedIndex = [index, key]"
/>
@@ -275,6 +343,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
'border-2'
)
"
:data-output-index="index"
@click="selectedIndex = [index, key]"
>
<i

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { useTemplateRef, watch } from 'vue'
import { ref, useTemplateRef, watch } from 'vue'
import Load3DControls from '@/components/load3d/Load3DControls.vue'
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
import { useLoad3dViewer } from '@/composables/useLoad3dViewer'
@@ -10,12 +11,12 @@ const { modelUrl } = defineProps<{
const containerRef = useTemplateRef('containerRef')
const viewer = useLoad3dViewer()
const viewer = ref(useLoad3dViewer())
watch([containerRef, () => modelUrl], async () => {
if (!containerRef.value || !modelUrl) return
await viewer.initializeStandaloneViewer(containerRef.value, modelUrl)
await viewer.value.initializeStandaloneViewer(containerRef.value, modelUrl)
})
//TODO: refactor to add control buttons
@@ -29,14 +30,25 @@ watch([containerRef, () => modelUrl], async () => {
@resize="viewer.handleResize"
>
<div class="pointer-events-none absolute top-0 left-0 size-full">
<Load3DControls
v-model:scene-config="viewer"
v-model:model-config="viewer"
v-model:camera-config="viewer"
v-model:light-config="viewer"
:is-splat-model="viewer.isSplatModel"
:is-ply-model="viewer.isPlyModel"
:has-skeleton="viewer.hasSkeleton"
@update-background-image="viewer.handleBackgroundImageUpdate"
@export-model="viewer.exportModel"
/>
<AnimationControls
v-if="viewer.animations.value && viewer.animations.value.length > 0"
v-model:animations="viewer.animations.value"
v-model:playing="viewer.playing.value"
v-model:selected-speed="viewer.selectedSpeed.value"
v-model:selected-animation="viewer.selectedAnimation.value"
v-model:animation-progress="viewer.animationProgress.value"
v-model:animation-duration="viewer.animationDuration.value"
v-if="viewer.animations && viewer.animations.length > 0"
v-model:animations="viewer.animations"
v-model:playing="viewer.playing"
v-model:selected-speed="viewer.selectedSpeed"
v-model:selected-animation="viewer.selectedAnimation"
v-model:animation-progress="viewer.animationProgress"
v-model:animation-duration="viewer.animationDuration"
@seek="viewer.handleSeek"
/>
</div>

View File

@@ -130,10 +130,16 @@
'transition-all cursor-pointer hover:bg-accent-background duration-150 active:scale-95'
)
"
@click.stop="handleShowAdvancedInputs"
@click.stop="showAdvancedState = !showAdvancedState"
>
<i class="icon-[lucide--settings-2] size-4" />
<span>{{ t('rightSidePanel.showAdvancedInputsButton') }}</span>
<template v-if="showAdvancedState">
<i class="icon-[lucide--chevron-up] size-4" />
<span>{{ t('rightSidePanel.hideAdvancedInputsButton') }}</span>
</template>
<template v-else>
<i class="icon-[lucide--settings-2] size-4" />
<span>{{ t('rightSidePanel.showAdvancedInputsButton') }} </span>
</template>
</button>
</div>
</div>
@@ -152,7 +158,16 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, nextTick, onErrorCaptured, onMounted, ref, watch } from 'vue'
import {
computed,
customRef,
nextTick,
onErrorCaptured,
onMounted,
onUnmounted,
ref,
watch
} from 'vue'
import { useI18n } from 'vue-i18n'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
@@ -172,6 +187,7 @@ import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import SlotConnectionDot from '@/renderer/extensions/vueNodes/components/SlotConnectionDot.vue'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
@@ -212,6 +228,8 @@ const { nodeData, error = null } = defineProps<LGraphNodeProps>()
const { t } = useI18n()
const settingStore = useSettingStore()
const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeRightClick } =
useNodeEventHandlers()
const { bringNodeToFront } = useNodeZIndex()
@@ -248,7 +266,7 @@ const bypassed = computed(
const muted = computed((): boolean => nodeData.mode === LGraphEventMode.NEVER)
const nodeOpacity = computed(() => {
const globalOpacity = useSettingStore().get('Comfy.Node.Opacity') ?? 1
const globalOpacity = settingStore.get('Comfy.Node.Opacity') ?? 1
// For muted/bypassed nodes, apply the 0.5 multiplier on top of global opacity
if (bypassed.value || muted.value) {
@@ -327,6 +345,25 @@ function initSizeStyles() {
el.style.setProperty(`--node-height${suffix}`, `${height}px`)
}
// React to extension-driven resize operations (e.g., KJNodes spline editor)
const unsubscribeResize = layoutStore.onChange((change) => {
if (change.source !== LayoutSource.Canvas) return
if (change.operation.type !== 'resizeNode') return
if (!change.nodeIds.includes(nodeData.id)) return
if (isCollapsed.value) return
const el = nodeContainerRef.value
if (!el) return
const newSize = size.value
el.style.setProperty('--node-width', `${newSize.width}px`)
el.style.setProperty('--node-height', `${newSize.height}px`)
})
onUnmounted(() => {
unsubscribeResize()
})
const baseResizeHandleClasses =
'absolute h-3 w-3 opacity-0 pointer-events-auto focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/40'
@@ -493,20 +530,45 @@ const showAdvancedInputsButton = computed(() => {
// For regular nodes: show button if there are advanced widgets and they're currently hidden
const hasAdvancedWidgets = nodeData.widgets?.some((w) => w.options?.advanced)
return hasAdvancedWidgets && !node.showAdvanced
const alwaysShowAdvanced = settingStore.get(
'Comfy.Node.AlwaysShowAdvancedWidgets'
)
return hasAdvancedWidgets && !alwaysShowAdvanced
})
function handleShowAdvancedInputs() {
const node = lgraphNode.value
if (!node) return
const showAdvancedState = customRef((track, trigger) => {
let internalState = false
if (node instanceof SubgraphNode) {
const rightSidePanelStore = useRightSidePanelStore()
rightSidePanelStore.focusSection('advanced-inputs')
} else {
node.showAdvanced = true
const node = lgraphNode.value
if (node && !(node instanceof SubgraphNode)) {
internalState = !!node.showAdvanced
}
}
return {
get() {
track()
return internalState
},
set(value: boolean) {
const node = lgraphNode.value
if (!node) return
if (node instanceof SubgraphNode) {
// Do not modify internalState for subgraph nodes
const rightSidePanelStore = useRightSidePanelStore()
if (value) {
rightSidePanelStore.focusSection('advanced-inputs')
} else {
rightSidePanelStore.closePanel()
}
} else {
node.showAdvanced = value
internalState = value
}
trigger()
}
}
})
const nodeMedia = computed(() => {
const newOutputs = nodeOutputs.nodeOutputs[nodeOutputLocatorId.value]

View File

@@ -108,11 +108,11 @@ import NodeBadge from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
import { app } from '@/scripts/app'
import { normalizeI18nKey } from '@/utils/formatUtil'
import {
getLocatorIdFromNodeData,
getNodeByLocatorId
} from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@/utils/tailwindUtil'
import type { NodeBadgeProps } from './NodeBadge.vue'
@@ -160,12 +160,12 @@ const enterSubgraphTooltipConfig = computed(() => {
})
const resolveTitle = (info: VueNodeData | undefined) => {
const untitledLabel = st('g.untitled', 'Untitled')
return resolveNodeDisplayName(info ?? null, {
emptyLabel: untitledLabel,
untitledLabel,
st
})
const title = (info?.title ?? '').trim()
if (title.length > 0) return title
const nodeType = (info?.type ?? '').trim() || 'Untitled'
const key = `nodeDefs.${normalizeI18nKey(nodeType)}.display_name`
return st(key, nodeType)
}
// Local state for title to provide immediate feedback

View File

@@ -27,7 +27,7 @@
<div
v-if="
!widget.simplified.options?.hidden &&
(!widget.simplified.options?.advanced || nodeData?.showAdvanced)
(!widget.simplified.options?.advanced || showAdvanced)
"
class="lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch"
>
@@ -78,6 +78,7 @@ import type {
VueNodeData,
WidgetSlotMetadata
} from '@/composables/graph/useGraphNodeManager'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { st } from '@/i18n'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
@@ -130,6 +131,12 @@ onErrorCaptured((error) => {
})
const nodeType = computed(() => nodeData?.type || '')
const settingStore = useSettingStore()
const showAdvanced = computed(
() =>
nodeData?.showAdvanced ||
settingStore.get('Comfy.Node.AlwaysShowAdvancedWidgets')
)
const { getWidgetTooltip, createTooltipConfig } = useNodeTooltips(
nodeType.value
)
@@ -213,7 +220,7 @@ const gridTemplateRows = computed((): string => {
(w) =>
processedNames.has(w.name) &&
!w.options?.hidden &&
(!w.options?.advanced || nodeData?.showAdvanced)
(!w.options?.advanced || showAdvanced.value)
)
.map((w) =>
shouldExpand(w.type) || w.hasLayoutSize ? 'auto' : 'min-content'

View File

@@ -0,0 +1,151 @@
import { describe, expect, it } from 'vitest'
import { defaultSearcher, getDefaultSortOptions } from './shared'
import type { DropdownItem } from './types'
function createItem(name: string, label?: string): DropdownItem {
return {
id: name,
mediaSrc: '',
name,
label,
metadata: ''
}
}
describe('defaultSearcher', () => {
const items: DropdownItem[] = [
createItem('apple.png'),
createItem('banana.jpg'),
createItem('cherry.gif')
]
it('returns all items when query is empty', async () => {
const result = await defaultSearcher('', items)
expect(result).toEqual(items)
})
it('returns all items when query is whitespace', async () => {
const result = await defaultSearcher(' ', items)
expect(result).toEqual(items)
})
it('filters items by single word', async () => {
const result = await defaultSearcher('apple', items)
expect(result).toHaveLength(1)
expect(result[0].name).toBe('apple.png')
})
it('filters items case-insensitively', async () => {
const result = await defaultSearcher('APPLE', items)
expect(result).toHaveLength(1)
expect(result[0].name).toBe('apple.png')
})
it('filters items by multiple words (AND logic)', async () => {
const result = await defaultSearcher('a png', items)
expect(result).toHaveLength(1)
expect(result[0].name).toBe('apple.png')
})
it('returns empty array when no matches', async () => {
const result = await defaultSearcher('xyz', items)
expect(result).toHaveLength(0)
})
})
describe('getDefaultSortOptions', () => {
const sortOptions = getDefaultSortOptions()
describe('Default sorter', () => {
const defaultSorter = sortOptions.find((o) => o.id === 'default')!.sorter
it('returns items in original order', () => {
const items = [createItem('z'), createItem('a'), createItem('m')]
const result = defaultSorter({ items })
expect(result.map((i) => i.name)).toEqual(['z', 'a', 'm'])
})
it('does not mutate original array', () => {
const items = [createItem('z'), createItem('a')]
const result = defaultSorter({ items })
expect(result).not.toBe(items)
})
})
describe('A-Z sorter', () => {
const azSorter = sortOptions.find((o) => o.id === 'a-z')!.sorter
it('sorts items alphabetically by name', () => {
const items = [
createItem('cherry'),
createItem('apple'),
createItem('banana')
]
const result = azSorter({ items })
expect(result.map((i) => i.name)).toEqual(['apple', 'banana', 'cherry'])
})
it('sorts items alphabetically by label when available', () => {
const items = [
createItem('file_c.png', 'Cherry'),
createItem('file_a.png', 'Apple'),
createItem('file_b.png', 'Banana')
]
const result = azSorter({ items })
expect(result.map((i) => i.name)).toEqual([
'file_a.png',
'file_b.png',
'file_c.png'
])
})
it('uses natural sort for numeric values', () => {
const items = [
createItem('img_10.png'),
createItem('img_2.png'),
createItem('img_1.png'),
createItem('img_20.png')
]
const result = azSorter({ items })
expect(result.map((i) => i.name)).toEqual([
'img_1.png',
'img_2.png',
'img_10.png',
'img_20.png'
])
})
it('is case-insensitive', () => {
const items = [
createItem('Banana'),
createItem('apple'),
createItem('CHERRY')
]
const result = azSorter({ items })
expect(result.map((i) => i.name)).toEqual(['apple', 'Banana', 'CHERRY'])
})
it('falls back to name when label is undefined', () => {
const items = [
createItem('z_file.png', 'Alpha'),
createItem('a_file.png'),
createItem('m_file.png', 'Beta')
]
const result = azSorter({ items })
// 'a_file.png' (no label, uses name), 'Alpha', 'Beta'
expect(result.map((i) => i.name)).toEqual([
'a_file.png',
'z_file.png',
'm_file.png'
])
})
it('does not mutate original array', () => {
const items = [createItem('z'), createItem('a')]
const result = azSorter({ items })
expect(result).not.toBe(items)
expect(items[0].name).toBe('z')
})
})
})

View File

@@ -21,7 +21,12 @@ export function getDefaultSortOptions(): SortOption[] {
id: 'a-z',
sorter: ({ items }) =>
items.slice().sort((a, b) => {
return a.name.localeCompare(b.name)
const aLabel = a.label ?? a.name
const bLabel = b.label ?? b.name
return aLabel.localeCompare(bLabel, undefined, {
numeric: true,
sensitivity: 'base'
})
})
}
]

View File

@@ -430,7 +430,8 @@ const zSettings = z.object({
'LiteGraph.Node.DefaultPadding': z.boolean(),
'LiteGraph.Pointer.TrackpadGestures': z.boolean(),
'Comfy.VersionCompatibility.DisableWarnings': z.boolean(),
'Comfy.RightSidePanel.IsOpen': z.boolean()
'Comfy.RightSidePanel.IsOpen': z.boolean(),
'Comfy.Node.AlwaysShowAdvancedWidgets': z.boolean()
})
export type EmbeddingsResponse = z.infer<typeof zEmbeddingsResponse>

View File

@@ -162,7 +162,7 @@ export class ComfyApp {
// TODO: Migrate internal usage to the
/** @deprecated Use {@link rootGraph} instead */
get graph(): LGraph | undefined {
get graph() {
return this.rootGraphInternal!
}

View File

@@ -180,89 +180,6 @@ describe('jobOutputCache', () => {
})
})
describe('getPreviewableOutputsFromJobDetail', () => {
it('returns empty array when job detail or outputs are missing', async () => {
const { getPreviewableOutputsFromJobDetail } =
await import('@/services/jobOutputCache')
expect(getPreviewableOutputsFromJobDetail(undefined)).toEqual([])
const jobDetail: JobDetail = {
id: 'job-empty',
status: 'completed',
create_time: Date.now(),
priority: 0
}
expect(getPreviewableOutputsFromJobDetail(jobDetail)).toEqual([])
})
it('maps previewable outputs and skips animated/text entries', async () => {
const { getPreviewableOutputsFromJobDetail } =
await import('@/services/jobOutputCache')
const jobDetail: JobDetail = {
id: 'job-previewable',
status: 'completed',
create_time: Date.now(),
priority: 0,
outputs: {
'node-1': {
images: [
{ filename: 'image.png', subfolder: '', type: 'output' },
{ filename: 'image.webp', subfolder: '', type: 'temp' }
],
animated: [true],
text: 'hello'
},
'node-2': {
video: [{ filename: 'clip.mp4', subfolder: '', type: 'output' }],
audio: [{ filename: 'sound.mp3', subfolder: '', type: 'output' }]
}
}
}
const result = getPreviewableOutputsFromJobDetail(jobDetail)
expect(result).toHaveLength(4)
expect(result.map((item) => item.filename).sort()).toEqual(
['image.png', 'image.webp', 'clip.mp4', 'sound.mp3'].sort()
)
const image = result.find((item) => item.filename === 'image.png')
const video = result.find((item) => item.filename === 'clip.mp4')
const { ResultItemImpl: ResultItemImplClass } =
await import('@/stores/queueStore')
expect(image).toBeInstanceOf(ResultItemImplClass)
expect(image?.nodeId).toBe('node-1')
expect(image?.mediaType).toBe('images')
expect(video?.nodeId).toBe('node-2')
expect(video?.mediaType).toBe('video')
})
it('filters non-previewable outputs and non-object items', async () => {
const { getPreviewableOutputsFromJobDetail } =
await import('@/services/jobOutputCache')
const jobDetail: JobDetail = {
id: 'job-filter',
status: 'completed',
create_time: Date.now(),
priority: 0,
outputs: {
'node-3': {
images: [{ filename: 'valid.png', subfolder: '', type: 'output' }],
text: ['not-object'],
unknown: [{ filename: 'data.bin', subfolder: '', type: 'output' }]
}
}
}
const result = getPreviewableOutputsFromJobDetail(jobDetail)
expect(result.map((item) => item.filename)).toEqual(['valid.png'])
})
})
describe('getJobDetail', () => {
it('fetches and caches job detail', async () => {
const { getJobDetail } = await import('@/services/jobOutputCache')

View File

@@ -11,7 +11,6 @@ import QuickLRU from '@alloc/quick-lru'
import type { JobDetail } from '@/platform/remote/comfyui/jobs/jobTypes'
import { extractWorkflow } from '@/platform/remote/comfyui/jobs/fetchJobs'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ResultItem, TaskOutput } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { ResultItemImpl } from '@/stores/queueStore'
import type { TaskItemImpl } from '@/stores/queueStore'
@@ -76,40 +75,6 @@ export async function getOutputsForTask(
}
}
function getPreviewableOutputs(outputs?: TaskOutput): ResultItemImpl[] {
if (!outputs) return []
const resultItems = Object.entries(outputs).flatMap(([nodeId, nodeOutputs]) =>
Object.entries(nodeOutputs)
.filter(([mediaType, items]) => mediaType !== 'animated' && items)
.flatMap(([mediaType, items]) => {
if (!Array.isArray(items)) {
return []
}
return items.filter(isResultItem).map(
(item) =>
new ResultItemImpl({
...item,
nodeId,
mediaType
})
)
})
)
return ResultItemImpl.filterPreviewable(resultItems)
}
function isResultItem(item: unknown): item is ResultItem {
return typeof item === 'object' && item !== null
}
export function getPreviewableOutputsFromJobDetail(
jobDetail?: JobDetail
): ResultItemImpl[] {
return getPreviewableOutputs(jobDetail?.outputs)
}
// ===== Job Detail Caching =====
export async function getJobDetail(

View File

@@ -279,12 +279,15 @@ export const useAssetsStore = defineStore('assets', () => {
const pendingRequestByKey = new Map<string, ModelPaginationState>()
function createState(): ModelPaginationState {
function createState(
existingAssets?: Map<string, AssetItem>
): ModelPaginationState {
const assets = new Map(existingAssets)
return reactive({
assets: new Map(),
assets,
offset: 0,
hasMore: true,
isLoading: false
isLoading: true
})
}
@@ -336,8 +339,10 @@ export const useAssetsStore = defineStore('assets', () => {
key: string,
fetcher: (options: PaginationOptions) => Promise<AssetItem[]>
): Promise<void> {
const state = createState()
state.isLoading = true
const existingState = modelStateByKey.value.get(key)
const state = createState(existingState?.assets)
const seenIds = new Set<string>()
const hasExistingData = modelStateByKey.value.has(key)
if (hasExistingData) {
@@ -363,19 +368,15 @@ export const useAssetsStore = defineStore('assets', () => {
pendingRequestByKey.delete(key)
modelStateByKey.value.set(key, state)
}
state.assets = new Map(newAssets.map((a) => [a.id, a]))
} else {
const assetsToAdd = newAssets.filter(
(a) => !state.assets.has(a.id)
)
if (assetsToAdd.length > 0) {
assetsArrayCache.delete(key)
for (const asset of assetsToAdd) {
state.assets.set(asset.id, asset)
}
}
}
// Merge new assets into existing map and track seen IDs
for (const asset of newAssets) {
seenIds.add(asset.id)
state.assets.set(asset.id, asset)
}
state.assets = new Map(state.assets)
state.offset += newAssets.length
state.hasMore = newAssets.length === MODEL_BATCH_SIZE
@@ -388,17 +389,24 @@ export const useAssetsStore = defineStore('assets', () => {
}
} catch (err) {
if (isStale(key, state)) return
console.error(`Error loading batch for ${key}:`, err)
state.error = err instanceof Error ? err : new Error(String(err))
state.hasMore = false
console.error(`Error loading batch for ${key}:`, err)
if (state.offset === 0) {
state.isLoading = false
pendingRequestByKey.delete(key)
// TODO: Add toast indicator for first-batch load failures
}
state.isLoading = false
pendingRequestByKey.delete(key)
return
}
}
const staleIds = [...state.assets.keys()].filter(
(id) => !seenIds.has(id)
)
for (const id of staleIds) {
state.assets.delete(id)
}
assetsArrayCache.delete(key)
}
await loadBatches()

View File

@@ -1,14 +1,12 @@
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { st } from '@/i18n'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
/**
@@ -218,12 +216,7 @@ export const useFavoritedWidgetsStore = defineStore('favoritedWidgets', () => {
}
}
const fallbackNodeTitle = st('rightSidePanel.fallbackNodeTitle', 'Node')
const nodeTitle = resolveNodeDisplayName(node, {
emptyLabel: fallbackNodeTitle,
untitledLabel: fallbackNodeTitle,
st
})
const nodeTitle = node.title || node.type || 'Node'
const widgetLabel = widget.label || widget.name
return {
...id,

View File

@@ -0,0 +1,86 @@
import type { Positionable } from '@/lib/litegraph/src/interfaces'
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
import type {
LGraphCanvas,
LGraphGroup,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import { vi } from 'vitest'
/**
* Creates a mock LGraphNode with minimal required properties
*/
export function createMockLGraphNode(
overrides: Partial<LGraphNode> | Record<string, unknown> = {}
): LGraphNode {
const partial: Partial<LGraphNode> = {
id: 1,
pos: [0, 0],
size: [100, 100],
title: 'Test Node',
mode: LGraphEventMode.ALWAYS,
...(overrides as Partial<LGraphNode>)
}
return partial as Partial<LGraphNode> as LGraphNode
}
/**
* Creates a mock Positionable object
*/
export function createMockPositionable(
overrides: Partial<Positionable> = {}
): Positionable {
const partial: Partial<Positionable> = {
id: 1,
pos: [0, 0],
...overrides
}
return partial as Partial<Positionable> as Positionable
}
/**
* Creates a mock LGraphGroup with minimal required properties
*/
export function createMockLGraphGroup(
overrides: Partial<LGraphGroup> = {}
): LGraphGroup {
const partial: Partial<LGraphGroup> = {
id: 1,
pos: [0, 0],
boundingRect: new Rectangle(0, 0, 100, 100),
...overrides
}
return partial as Partial<LGraphGroup> as LGraphGroup
}
/**
* Creates a mock SubgraphNode with sub-nodes
*/
export function createMockSubgraphNode(
subNodes: LGraphNode[],
overrides: Partial<LGraphNode> | Record<string, unknown> = {}
): LGraphNode {
const baseNode = createMockLGraphNode(overrides)
return Object.assign(baseNode, {
isSubgraphNode: () => true,
subgraph: {
nodes: subNodes
}
})
}
/**
* Creates a mock LGraphCanvas with minimal required properties for testing
*/
export function createMockCanvas(
overrides: Partial<LGraphCanvas> = {}
): LGraphCanvas {
return {
setDirty: vi.fn(),
state: {
selectionChanged: false
},
...overrides
} as LGraphCanvas
}

View File

@@ -1,28 +0,0 @@
import { normalizeI18nKey } from '@/utils/formatUtil'
type NodeTitleInfo = {
title?: string | number | null
type?: string | number | null
}
type StaticTranslate = (key: string, fallbackMessage: string) => string
type ResolveNodeDisplayNameOptions = {
emptyLabel: string
untitledLabel: string
st: StaticTranslate
}
export function resolveNodeDisplayName(
node: NodeTitleInfo | null | undefined,
options: ResolveNodeDisplayNameOptions
): string {
if (!node) return options.emptyLabel
const title = (node.title ?? '').toString().trim()
if (title.length > 0) return title
const nodeType = (node.type ?? '').toString().trim() || options.untitledLabel
const key = `nodeDefs.${normalizeI18nKey(nodeType)}.display_name`
return options.st(key, nodeType)
}

View File

@@ -4,15 +4,12 @@
:content-title="$t('manager.discoverCommunityContent')"
class="manager-dialog"
>
<template #leftPanelHeaderTitle>
<i class="icon-[lucide--puzzle]" />
<h2 class="text-neutral text-base">{{ $t('manager.title') }}</h2>
</template>
<template #leftPanel>
<LeftSidePanel v-model="selectedNavId" :nav-items="navItems">
<template #header-icon>
<i class="icon-[lucide--puzzle]" />
</template>
<template #header-title>
<span class="text-neutral text-base">{{ $t('manager.title') }}</span>
</template>
</LeftSidePanel>
<LeftSidePanel v-model="selectedNavId" :nav-items="navItems" />
</template>
<template #header>