Compare commits

..

1 Commits

Author SHA1 Message Date
Jin Yi
9e6939290d feature: queue tab 2026-02-06 20:02:16 +09:00
43 changed files with 243 additions and 868 deletions

View File

@@ -60,6 +60,11 @@
{
"name": "primevue/sidebar",
"message": "Sidebar is deprecated in PrimeVue 4+. Use Drawer instead: import Drawer from 'primevue/drawer'"
},
{
"name": "@/i18n--to-enable",
"importNames": ["st", "t", "te", "d"],
"message": "Don't import `@/i18n` directly, prefer `useI18n()`"
}
]
}

View File

@@ -1,162 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
type ComfyPage = Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
async function setVueMode(comfyPage: ComfyPage, enabled: boolean) {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', enabled)
if (enabled) {
await comfyPage.vueNodes.waitForNodes()
}
}
async function addGhostAtCenter(comfyPage: ComfyPage) {
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nextFrame()
const viewport = comfyPage.page.viewportSize()!
const centerX = Math.round(viewport.width / 2)
const centerY = Math.round(viewport.height / 2)
await comfyPage.page.mouse.move(centerX, centerY)
await comfyPage.nextFrame()
const nodeId = await comfyPage.page.evaluate(
([clientX, clientY]) => {
const node = window.LiteGraph!.createNode('VAEDecode')!
const event = new MouseEvent('click', { clientX, clientY })
window.app!.graph.add(node, { ghost: true, dragEvent: event })
return node.id
},
[centerX, centerY] as const
)
await comfyPage.nextFrame()
return { nodeId, centerX, centerY }
}
function getNodeById(comfyPage: ComfyPage, nodeId: number | string) {
return comfyPage.page.evaluate((id) => {
const node = window.app!.graph.getNodeById(id)
if (!node) return null
return { ghost: !!node.flags.ghost }
}, nodeId)
}
for (const mode of ['litegraph', 'vue'] as const) {
test.describe(`Ghost node placement (${mode} mode)`, () => {
test.beforeEach(async ({ comfyPage }) => {
await setVueMode(comfyPage, mode === 'vue')
})
test('positions ghost node at cursor', async ({ comfyPage }) => {
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nextFrame()
const viewport = comfyPage.page.viewportSize()!
const centerX = Math.round(viewport.width / 2)
const centerY = Math.round(viewport.height / 2)
await comfyPage.page.mouse.move(centerX, centerY)
await comfyPage.nextFrame()
const result = await comfyPage.page.evaluate(
([clientX, clientY]) => {
const node = window.LiteGraph!.createNode('VAEDecode')!
const event = new MouseEvent('click', { clientX, clientY })
window.app!.graph.add(node, { ghost: true, dragEvent: event })
const canvas = window.app!.canvas
const rect = canvas.canvas.getBoundingClientRect()
const cursorCanvasX =
(clientX - rect.left) / canvas.ds.scale - canvas.ds.offset[0]
const cursorCanvasY =
(clientY - rect.top) / canvas.ds.scale - canvas.ds.offset[1]
return {
diffX: node.pos[0] + node.size[0] / 2 - cursorCanvasX,
diffY: node.pos[1] - 10 - cursorCanvasY
}
},
[centerX, centerY] as const
)
await comfyPage.nextFrame()
expect(Math.abs(result.diffX)).toBeLessThan(5)
expect(Math.abs(result.diffY)).toBeLessThan(5)
})
test('left-click confirms ghost placement', async ({ comfyPage }) => {
const { nodeId, centerX, centerY } = await addGhostAtCenter(comfyPage)
const before = await getNodeById(comfyPage, nodeId)
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.page.mouse.click(centerX, centerY)
await comfyPage.nextFrame()
const after = await getNodeById(comfyPage, nodeId)
expect(after).not.toBeNull()
expect(after!.ghost).toBe(false)
})
test('Escape cancels ghost placement', async ({ comfyPage }) => {
const { nodeId } = await addGhostAtCenter(comfyPage)
const before = await getNodeById(comfyPage, nodeId)
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
})
test('Delete cancels ghost placement', async ({ comfyPage }) => {
const { nodeId } = await addGhostAtCenter(comfyPage)
const before = await getNodeById(comfyPage, nodeId)
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
})
test('Backspace cancels ghost placement', async ({ comfyPage }) => {
const { nodeId } = await addGhostAtCenter(comfyPage)
const before = await getNodeById(comfyPage, nodeId)
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.page.keyboard.press('Backspace')
await comfyPage.nextFrame()
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
})
test('right-click cancels ghost placement', async ({ comfyPage }) => {
const { nodeId, centerX, centerY } = await addGhostAtCenter(comfyPage)
const before = await getNodeById(comfyPage, nodeId)
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.page.mouse.click(centerX, centerY, { button: 'right' })
await comfyPage.nextFrame()
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
})
})
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -31,12 +31,7 @@ test.describe('Vue Integer Widget', () => {
await expect(seedWidget).toBeVisible()
// Delete the node that is linked to the slot (freeing up the widget)
// Click on the header to select the node (clicking center may land on
// the widget area where pointerdown.stop prevents node selection)
await comfyPage.vueNodes
.getNodeByTitle('Int')
.locator('.lg-node-header')
.click()
await comfyPage.vueNodes.getNodeByTitle('Int').click()
await comfyPage.vueNodes.deleteSelected()
// Test widget works when unlinked

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -279,46 +279,5 @@ export default defineConfig([
'import-x/no-duplicates': 'off',
'import-x/consistent-type-specifier-style': 'off'
}
},
// i18n import enforcement
// Vue components must use the useI18n() composable, not the global t/d/st/te
{
files: ['**/*.vue'],
rules: {
'no-restricted-imports': [
'warn',
{
paths: [
{
name: '@/i18n',
importNames: ['t', 'd', 'st', 'te'],
message:
"In Vue components, use `const { t } = useI18n()` instead of importing from '@/i18n'."
}
]
}
]
}
},
// Non-composable .ts files must use the global t/d/st/te, not useI18n()
{
files: ['**/*.ts'],
ignores: ['**/use[A-Z]*.ts', '**/*.test.ts', 'src/i18n.ts'],
rules: {
'no-restricted-imports': [
'warn',
{
paths: [
{
name: 'vue-i18n',
importNames: ['useI18n'],
message:
"useI18n() requires Vue setup context. Use `import { t } from '@/i18n'` instead."
}
]
}
]
}
}
])

View File

@@ -1,8 +1,5 @@
<template>
<div
ref="container"
class="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-(--dialog-surface)"
>
<div ref="container" class="h-full scrollbar-custom">
<div :style="topSpacerStyle" />
<div :style="mergedGridStyle">
<div

View File

@@ -61,7 +61,7 @@ async function showTooltip(tooltip: string | null | undefined) {
function onIdle() {
const { canvas } = comfyApp
const node = canvas?.node_over
if (!node || node.flags?.ghost) return
if (!node) return
const ctor = node.constructor as { title_mode?: 0 | 1 | 2 | 3 }
const nodeDef = nodeDefStore.nodeDefsByName[node.type ?? '']

View File

@@ -1,23 +1,7 @@
<template>
<div class="flex h-full flex-col">
<!-- Active Jobs Grid -->
<div
v-if="!isInFolderView && isQueuePanelV2Enabled && activeJobItems.length"
class="grid max-h-[50%] scrollbar-custom overflow-y-auto"
:style="gridStyle"
>
<ActiveMediaAssetCard
v-for="job in activeJobItems"
:key="job.id"
:job="job"
/>
</div>
<!-- Assets Header -->
<div
v-if="assets.length"
:class="cn('px-2 2xl:px-4', activeJobItems.length && 'mt-2')"
>
<div v-if="assets.length" class="px-2 2xl:px-4">
<div
class="flex items-center py-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
>
@@ -59,25 +43,18 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import ActiveMediaAssetCard from '@/platform/assets/components/ActiveMediaAssetCard.vue'
import { useJobList } from '@/composables/queue/useJobList'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { isActiveJobState } from '@/utils/queueUtil'
import { cn } from '@/utils/tailwindUtil'
import { useSettingStore } from '@/platform/settings/settingStore'
const {
assets,
isSelected,
isInFolderView = false,
assetType = 'output',
showOutputCount,
getOutputCount
} = defineProps<{
assets: AssetItem[]
isSelected: (assetId: string) => boolean
isInFolderView?: boolean
assetType?: 'input' | 'output'
showOutputCount: (asset: AssetItem) => boolean
getOutputCount: (asset: AssetItem) => number
@@ -92,19 +69,9 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const { jobItems } = useJobList()
const settingStore = useSettingStore()
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
type AssetGridItem = { key: string; asset: AssetItem }
const activeJobItems = computed(() =>
jobItems.value.filter((item) => isActiveJobState(item.state)).toReversed()
)
const assetItems = computed<AssetGridItem[]>(() =>
assets.map((asset) => ({
key: `asset-${asset.id}`,

View File

@@ -1,6 +1,5 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import AssetsSidebarListView from './AssetsSidebarListView.vue'
@@ -10,51 +9,12 @@ vi.mock('vue-i18n', () => ({
})
}))
vi.mock('@/composables/queue/useJobActions', () => ({
useJobActions: () => ({
cancelAction: { variant: 'ghost', label: 'Cancel', icon: 'pi pi-times' },
canCancelJob: ref(false),
runCancelJob: vi.fn()
})
}))
const mockJobItems = ref<
Array<{
id: string
title: string
meta: string
state: string
createTime?: number
}>
>([])
vi.mock('@/composables/queue/useJobList', () => ({
useJobList: () => ({
jobItems: mockJobItems
})
}))
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
isAssetDeleting: () => false
})
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) => key === 'Comfy.Queue.QPOV2'
})
}))
vi.mock('@/utils/queueUtil', () => ({
isActiveJobState: (state: string) =>
state === 'pending' || state === 'running'
}))
vi.mock('@/utils/queueDisplay', () => ({
iconForJobState: () => 'pi pi-spinner'
}))
vi.mock('@/platform/assets/schemas/assetMetadataSchema', () => ({
getOutputAssetMetadata: () => undefined
}))
@@ -73,7 +33,6 @@ vi.mock('@/utils/formatUtil', () => ({
describe('AssetsSidebarListView', () => {
beforeEach(() => {
vi.clearAllMocks()
mockJobItems.value = []
})
const defaultProps = {
@@ -84,67 +43,14 @@ describe('AssetsSidebarListView', () => {
toggleStack: async () => {}
}
it('displays active jobs in oldest-first order (FIFO)', () => {
mockJobItems.value = [
{
id: 'newest',
title: 'Newest Job',
meta: '',
state: 'pending',
createTime: 3000
},
{
id: 'middle',
title: 'Middle Job',
meta: '',
state: 'running',
createTime: 2000
},
{
id: 'oldest',
title: 'Oldest Job',
meta: '',
state: 'pending',
createTime: 1000
}
]
it('renders without errors with empty assets', () => {
const wrapper = mount(AssetsSidebarListView, {
props: defaultProps,
shallow: true
})
const jobListItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
expect(jobListItems).toHaveLength(3)
const displayedTitles = jobListItems.map((item) =>
item.props('primaryText')
)
expect(displayedTitles).toEqual(['Oldest Job', 'Middle Job', 'Newest Job'])
})
it('excludes completed and failed jobs from active jobs section', () => {
mockJobItems.value = [
{ id: 'pending', title: 'Pending', meta: '', state: 'pending' },
{ id: 'completed', title: 'Completed', meta: '', state: 'completed' },
{ id: 'failed', title: 'Failed', meta: '', state: 'failed' },
{ id: 'running', title: 'Running', meta: '', state: 'running' }
]
const wrapper = mount(AssetsSidebarListView, {
props: defaultProps,
shallow: true
})
const jobListItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
expect(jobListItems).toHaveLength(2)
const displayedTitles = jobListItems.map((item) =>
item.props('primaryText')
)
expect(displayedTitles).toContain('Running')
expect(displayedTitles).toContain('Pending')
expect(displayedTitles).not.toContain('Completed')
expect(displayedTitles).not.toContain('Failed')
expect(wrapper.exists()).toBe(true)
const listItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
expect(listItems).toHaveLength(0)
})
})

View File

@@ -1,48 +1,6 @@
<template>
<div class="flex h-full flex-col">
<div
v-if="isQueuePanelV2Enabled && activeJobItems.length"
class="flex max-h-[50%] scrollbar-custom flex-col gap-2 overflow-y-auto px-2"
>
<AssetsListItem
v-for="job in activeJobItems"
:key="job.id"
:class="
cn(
'w-full shrink-0 text-text-primary transition-colors hover:bg-secondary-background-hover',
'cursor-default'
)
"
:preview-url="job.iconImageUrl"
:preview-alt="job.title"
:icon-name="job.iconName"
:icon-class="getJobIconClass(job)"
:primary-text="job.title"
:secondary-text="job.meta"
:progress-total-percent="job.progressTotalPercent"
:progress-current-percent="job.progressCurrentPercent"
@mouseenter="onJobEnter(job.id)"
@mouseleave="onJobLeave(job.id)"
@click.stop
>
<template v-if="hoveredJobId === job.id" #actions>
<Button
v-if="canCancelJob"
:variant="cancelAction.variant"
size="icon"
:aria-label="cancelAction.label"
@click.stop="runCancelJob()"
>
<i :class="cancelAction.icon" class="size-4" />
</Button>
</template>
</AssetsListItem>
</div>
<div
v-if="assetItems.length"
:class="cn('px-2', activeJobItems.length && 'mt-2')"
>
<div v-if="assetItems.length" class="px-2">
<div
class="flex items-center p-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
>
@@ -119,31 +77,25 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Button from '@/components/ui/button/Button.vue'
import { useJobActions } from '@/composables/queue/useJobActions'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useJobList } from '@/composables/queue/useJobList'
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
import { useAssetsStore } from '@/stores/assetsStore'
import { isActiveJobState } from '@/utils/queueUtil'
import {
formatDuration,
formatSize,
getMediaTypeFromFilename,
truncateFilename
} from '@/utils/formatUtil'
import { iconForJobState } from '@/utils/queueDisplay'
import { cn } from '@/utils/tailwindUtil'
import { useSettingStore } from '@/platform/settings/settingStore'
const {
assetItems,
@@ -170,24 +122,8 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const { jobItems } = useJobList()
const settingStore = useSettingStore()
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
const hoveredJobId = ref<string | null>(null)
const hoveredAssetId = ref<string | null>(null)
const activeJobItems = computed(() =>
jobItems.value.filter((item) => isActiveJobState(item.state)).toReversed()
)
const hoveredJob = computed(() =>
hoveredJobId.value
? (activeJobItems.value.find((job) => job.id === hoveredJobId.value) ??
null)
: null
)
const { cancelAction, canCancelJob, runCancelJob } = useJobActions(hoveredJob)
const listGridStyle = {
display: 'grid',
@@ -240,16 +176,6 @@ function getAssetCardClass(selected: boolean): string {
)
}
function onJobEnter(jobId: string) {
hoveredJobId.value = jobId
}
function onJobLeave(jobId: string) {
if (hoveredJobId.value === jobId) {
hoveredJobId.value = null
}
}
function onAssetEnter(assetId: string) {
hoveredAssetId.value = assetId
}
@@ -259,13 +185,4 @@ function onAssetLeave(assetId: string) {
hoveredAssetId.value = null
}
}
function getJobIconClass(job: JobListItem): string | undefined {
const classes = []
const iconName = job.iconName ?? iconForJobState(job.state)
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {
classes.push('animate-spin')
}
return classes.length ? classes.join(' ') : undefined
}
</script>

View File

@@ -26,6 +26,15 @@
<template #tool-buttons>
<!-- Normal Tab View -->
<TabList v-if="!isInFolderView" v-model="activeTab">
<Tab v-if="isQueuePanelV2Enabled" class="font-inter" value="queue">
{{ $t('sideToolbar.labels.queue') }}
<span
v-if="activeJobsCount > 0"
class="ml-1 inline-flex items-center justify-center rounded-full bg-primary px-1.5 text-xs text-base-foreground font-medium h-5"
>
{{ activeJobsCount }}
</span>
</Tab>
<Tab class="font-inter" value="output">{{
$t('sideToolbar.labels.generated')
}}</Tab>
@@ -43,8 +52,9 @@
</Button>
</div>
<!-- Filter Bar -->
<!-- Filter Bar (hidden on queue tab) -->
<MediaAssetFilterBar
v-if="!isQueueTab"
v-model:search-query="searchQuery"
v-model:sort-by="sortBy"
v-model:view-mode="viewMode"
@@ -53,13 +63,14 @@
:show-generation-time-sort="activeTab === 'output'"
/>
<div
v-if="isQueuePanelV2Enabled && !isInFolderView"
class="flex items-center justify-between px-2 py-2 2xl:px-4"
v-if="isQueueTab && !isInFolderView"
class="flex items-center justify-between px-4 2xl:px-6"
>
<span class="text-sm text-muted-foreground">
{{ activeJobsLabel }}
</span>
<div class="flex items-center gap-2">
<MediaAssetViewModeToggle v-model:view-mode="viewMode" />
<span class="text-sm text-base-foreground">
{{ t('sideToolbar.queueProgressOverlay.clearQueueTooltip') }}
</span>
@@ -76,7 +87,7 @@
</Button>
</div>
</div>
<Divider v-else type="dashed" class="my-2" />
<Divider v-else-if="!isQueueTab" type="dashed" class="my-2" />
</template>
<template #body>
<div v-if="showLoadingState">
@@ -87,23 +98,32 @@
icon="pi pi-info-circle"
:title="
$t(
activeTab === 'input'
? 'sideToolbar.noImportedFiles'
: 'sideToolbar.noGeneratedFiles'
isQueueTab
? 'sideToolbar.noQueueItems'
: activeTab === 'input'
? 'sideToolbar.noImportedFiles'
: 'sideToolbar.noGeneratedFiles'
)
"
:message="
$t(
isQueueTab
? 'sideToolbar.noQueueItemsMessage'
: 'sideToolbar.noFilesFoundMessage'
)
"
:message="$t('sideToolbar.noFilesFoundMessage')"
/>
</div>
<div v-else class="relative size-full" @click="handleEmptySpaceClick">
<QueueAssetView v-if="isQueueTab" :view-mode="viewMode" />
<AssetsSidebarListView
v-if="isListView"
v-else-if="isListView"
:asset-items="listViewAssetItems"
:is-selected="isSelected"
:selectable-assets="listViewSelectableAssets"
:is-stack-expanded="isListViewStackExpanded"
:toggle-stack="toggleListViewStack"
:asset-type="activeTab"
:asset-type="assetTabType"
@select-asset="handleAssetSelect"
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
@@ -112,8 +132,7 @@
v-else
:assets="displayAssets"
:is-selected="isSelected"
:is-in-folder-view="isInFolderView"
:asset-type="activeTab"
:asset-type="assetTabType"
:show-output-count="shouldShowOutputCount"
:get-output-count="getOutputCount"
@select-asset="handleAssetSelect"
@@ -224,6 +243,7 @@ const Load3dViewerContent = () =>
import('@/components/load3d/Load3dViewerContent.vue')
import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue'
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
import QueueAssetView from '@/components/sidebar/tabs/QueueAssetView.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import Tab from '@/components/tab/Tab.vue'
@@ -231,6 +251,7 @@ import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
import MediaAssetViewModeToggle from '@/platform/assets/components/MediaAssetViewModeToggle.vue'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
@@ -257,7 +278,7 @@ const { activeJobsCount } = storeToRefs(queueStore)
const executionStore = useExecutionStore()
const settingStore = useSettingStore()
const activeTab = ref<'input' | 'output'>('output')
const activeTab = ref<'input' | 'output' | 'queue'>('output')
const folderPromptId = ref<string | null>(null)
const folderExecutionTime = ref<number | undefined>(undefined)
const isInFolderView = computed(() => folderPromptId.value !== null)
@@ -268,6 +289,10 @@ const viewMode = useStorage<'list' | 'grid'>(
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
const isQueueTab = computed(() => activeTab.value === 'queue')
const assetTabType = computed<'input' | 'output'>(() =>
activeTab.value === 'input' ? 'input' : 'output'
)
const isListView = computed(
() => isQueuePanelV2Enabled.value && viewMode.value === 'list'
)
@@ -415,18 +440,15 @@ const isBulkMode = computed(
)
const showLoadingState = computed(
() =>
loading.value &&
displayAssets.value.length === 0 &&
activeJobsCount.value === 0
() => !isQueueTab.value && loading.value && displayAssets.value.length === 0
)
const showEmptyState = computed(
() =>
!loading.value &&
displayAssets.value.length === 0 &&
activeJobsCount.value === 0
)
const showEmptyState = computed(() => {
if (isQueueTab.value) {
return activeJobsCount.value === 0
}
return !loading.value && displayAssets.value.length === 0
})
watch(visibleAssets, (newAssets) => {
// Alternative: keep hidden selections and surface them in UI; for now prune
@@ -483,12 +505,21 @@ watch(
clearSelection()
// Clear search when switching tabs
searchQuery.value = ''
// Reset pagination state when tab changes
void refreshAssets()
// Skip asset fetch for queue tab
if (activeTab.value !== 'queue') {
void refreshAssets()
}
},
{ immediate: true }
)
// Reset to output tab if QPOV2 is disabled while on queue tab
watch(isQueuePanelV2Enabled, (enabled) => {
if (!enabled && activeTab.value === 'queue') {
activeTab.value = 'output'
}
})
function handleAssetSelect(asset: AssetItem, assets?: AssetItem[]) {
const assetList = assets ?? visibleAssets.value
const index = assetList.findIndex((a) => a.id === asset.id)

View File

@@ -0,0 +1,123 @@
<template>
<div class="flex h-full flex-col">
<!-- Grid View -->
<VirtualGrid
v-if="viewMode === 'grid'"
class="flex-1"
:items="gridItems"
:grid-style="gridStyle"
>
<template #item="{ item }">
<ActiveMediaAssetCard :job="item.job" />
</template>
</VirtualGrid>
<!-- List View -->
<div
v-else
class="flex flex-1 scrollbar-custom flex-col gap-2 overflow-y-auto px-2"
>
<AssetsListItem
v-for="job in activeJobItems"
:key="job.id"
:class="
cn(
'w-full shrink-0 text-text-primary transition-colors hover:bg-secondary-background-hover',
'cursor-default'
)
"
:preview-url="job.iconImageUrl"
:preview-alt="job.title"
:icon-name="job.iconName"
:icon-class="getJobIconClass(job)"
:primary-text="job.title"
:secondary-text="job.meta"
:progress-total-percent="job.progressTotalPercent"
:progress-current-percent="job.progressCurrentPercent"
@mouseenter="onJobEnter(job.id)"
@mouseleave="onJobLeave(job.id)"
@click.stop
>
<template v-if="hoveredJobId === job.id" #actions>
<Button
v-if="canCancelJob"
:variant="cancelAction.variant"
size="icon"
:aria-label="cancelAction.label"
@click.stop="runCancelJob()"
>
<i :class="cancelAction.icon" class="size-4" />
</Button>
</template>
</AssetsListItem>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Button from '@/components/ui/button/Button.vue'
import { useJobActions } from '@/composables/queue/useJobActions'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useJobList } from '@/composables/queue/useJobList'
import ActiveMediaAssetCard from '@/platform/assets/components/ActiveMediaAssetCard.vue'
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import { isActiveJobState } from '@/utils/queueUtil'
import { iconForJobState } from '@/utils/queueDisplay'
import { cn } from '@/utils/tailwindUtil'
const { viewMode = 'grid' } = defineProps<{
viewMode?: 'list' | 'grid'
}>()
const { jobItems } = useJobList()
const activeJobItems = computed(() =>
jobItems.value.filter((item) => isActiveJobState(item.state)).toReversed()
)
const gridItems = computed(() =>
activeJobItems.value.map((job) => ({
key: `queue-${job.id}`,
job
}))
)
const gridStyle = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
padding: '0 0.5rem',
gap: '0.5rem'
}
// List view hover & cancel logic
const hoveredJobId = ref<string | null>(null)
const hoveredJob = computed(() =>
hoveredJobId.value
? (activeJobItems.value.find((job) => job.id === hoveredJobId.value) ??
null)
: null
)
const { cancelAction, canCancelJob, runCancelJob } = useJobActions(hoveredJob)
function onJobEnter(jobId: string) {
hoveredJobId.value = jobId
}
function onJobLeave(jobId: string) {
if (hoveredJobId.value === jobId) {
hoveredJobId.value = null
}
}
function getJobIconClass(job: JobListItem): string | undefined {
const classes = []
const iconName = job.iconName ?? iconForJobState(job.state)
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {
classes.push('animate-spin')
}
return classes.length ? classes.join(' ') : undefined
}
</script>

View File

@@ -70,7 +70,6 @@ export interface VueNodeData {
color?: string
flags?: {
collapsed?: boolean
ghost?: boolean
pinned?: boolean
}
hasErrors?: boolean
@@ -527,15 +526,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}
})
break
case 'flags.ghost':
vueNodeData.set(nodeId, {
...currentData,
flags: {
...currentData.flags,
ghost: Boolean(propertyEvent.newValue)
}
})
break
case 'flags.pinned':
vueNodeData.set(nodeId, {
...currentData,

View File

@@ -9,6 +9,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
import { ensureCorrectLayoutScale } from '@/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale'
import { app as comfyApp } from '@/scripts/app'
@@ -32,7 +33,10 @@ function useVueNodeLifecycleIndividual() {
const nodes = activeGraph._nodes.map((node: LGraphNode) => ({
id: node.id.toString(),
pos: [node.pos[0], node.pos[1]] as [number, number],
size: [node.size[0], node.size[1]] as [number, number]
size: [node.size[0], removeNodeTitleHeight(node.size[1])] as [
number,
number
]
}))
layoutStore.initializeFromLiteGraph(nodes)

View File

@@ -102,16 +102,6 @@ export interface LGraphConfig {
links_ontop?: boolean
}
/** Options for {@link LGraph.add} method. */
interface GraphAddOptions {
/** If true, skip recomputing execution order after adding the node. */
skipComputeOrder?: boolean
/** If true, the node will be semi-transparent and follow the cursor until placed or cancelled. */
ghost?: boolean
/** Mouse event for ghost placement. Used to position node under cursor. */
dragEvent?: MouseEvent
}
export interface GroupNodeConfigEntry {
input?: Record<string, { name?: string; visible?: boolean }>
output?: Record<number, { name?: string; visible?: boolean }>
@@ -872,35 +862,12 @@ export class LGraph
/**
* Adds a new node instance to this graph
* @param node the instance of the node
* @param options Additional options for adding the node
*/
add(
node: LGraphNode | LGraphGroup,
options?: GraphAddOptions
): LGraphNode | null | undefined
/**
* Adds a new node instance to this graph
* @param node the instance of the node
* @param skipComputeOrder If true, skip recomputing execution order
* @deprecated Use options object instead
*/
add(
node: LGraphNode | LGraphGroup,
skipComputeOrder?: boolean
): LGraphNode | null | undefined
add(
node: LGraphNode | LGraphGroup,
skipComputeOrderOrOptions?: boolean | GraphAddOptions
skip_compute_order?: boolean
): LGraphNode | null | undefined {
if (!node) return
// Handle backwards compatibility: 2nd arg can be boolean or options
const opts: GraphAddOptions =
typeof skipComputeOrderOrOptions === 'object'
? skipComputeOrderOrOptions
: { skipComputeOrder: skipComputeOrderOrOptions ?? false }
const shouldSkipComputeOrder = opts.skipComputeOrder ?? false
const { state } = this
// Ensure created items are snapped
@@ -947,11 +914,6 @@ export class LGraph
}
}
// Set ghost flag before registration so VueNodeData picks it up
if (opts.ghost) {
node.flags.ghost = true
}
node.graph = this
this._version++
@@ -962,17 +924,13 @@ export class LGraph
if (this.config.align_to_grid) node.alignToGrid()
if (!shouldSkipComputeOrder) this.updateExecutionOrder()
if (!skip_compute_order) this.updateExecutionOrder()
this.onNodeAdded?.(node)
this.setDirtyCanvas(true)
this.change()
if (opts.ghost) {
this.canvasAction((c) => c.startGhostPlacement(node, opts.dragEvent))
}
// to chain actions
return node
}

View File

@@ -1,210 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
LGraph,
LGraphCanvas,
LGraphNode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
layoutStore: {
querySlotAtPoint: vi.fn(),
queryRerouteAtPoint: vi.fn(),
getNodeLayoutRef: vi.fn(() => ({ value: null })),
getSlotLayout: vi.fn()
}
}))
describe('LGraphCanvas slot hit detection', () => {
let graph: LGraph
let canvas: LGraphCanvas
let node: LGraphNode
let canvasElement: HTMLCanvasElement
beforeEach(() => {
vi.clearAllMocks()
canvasElement = document.createElement('canvas')
canvasElement.width = 800
canvasElement.height = 600
const ctx = {
save: vi.fn(),
restore: vi.fn(),
translate: vi.fn(),
scale: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
measureText: vi.fn().mockReturnValue({ width: 50 }),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fill: vi.fn(),
closePath: vi.fn(),
arc: vi.fn(),
rect: vi.fn(),
clip: vi.fn(),
clearRect: vi.fn(),
setTransform: vi.fn(),
roundRect: vi.fn(),
getTransform: vi
.fn()
.mockReturnValue({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }),
font: '',
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
globalAlpha: 1,
textAlign: 'left' as CanvasTextAlign,
textBaseline: 'alphabetic' as CanvasTextBaseline
} as unknown as CanvasRenderingContext2D
canvasElement.getContext = vi.fn().mockReturnValue(ctx)
canvasElement.getBoundingClientRect = vi.fn().mockReturnValue({
left: 0,
top: 0,
width: 800,
height: 600
})
graph = new LGraph()
canvas = new LGraphCanvas(canvasElement, graph, {
skip_render: true
})
// Create a test node with an output slot
node = new LGraphNode('Test Node')
node.pos = [100, 100]
node.size = [150, 80]
node.addOutput('output', 'number')
graph.add(node)
// Enable Vue nodes mode for the test
LiteGraph.vueNodesMode = true
})
afterEach(() => {
LiteGraph.vueNodesMode = false
})
describe('processMouseDown slot fallback in Vue nodes mode', () => {
it('should query layoutStore.querySlotAtPoint when clicking outside node bounds', () => {
// Click position outside node bounds (node is at 100,100 with size 150x80)
// So node covers x: 100-250, y: 100-180
// Click at x=255 is outside the right edge
const clickX = 255
const clickY = 120
// Verify the click is outside the node bounds
expect(node.isPointInside(clickX, clickY)).toBe(false)
expect(graph.getNodeOnPos(clickX, clickY)).toBeNull()
// Mock the slot query to return our node's slot
vi.mocked(layoutStore.querySlotAtPoint).mockReturnValue({
nodeId: String(node.id),
index: 0,
type: 'output',
position: { x: 252, y: 120 },
bounds: { x: 246, y: 110, width: 20, height: 20 }
})
// Call processMouseDown - this should trigger the slot fallback
canvas.processMouseDown(
new MouseEvent('pointerdown', {
button: 1, // Middle button
clientX: clickX,
clientY: clickY
})
)
// The fix should query the layout store when no node is found at click position
expect(layoutStore.querySlotAtPoint).toHaveBeenCalledWith({
x: clickX,
y: clickY
})
})
it('should NOT query layoutStore when node is found directly at click position', () => {
// Initialize node's bounding rect
node.updateArea()
// Populate visible_nodes (normally done during render)
canvas.visible_nodes = [node]
// Click inside the node bounds
const clickX = 150
const clickY = 140
// Verify the click is inside the node bounds
expect(node.isPointInside(clickX, clickY)).toBe(true)
expect(graph.getNodeOnPos(clickX, clickY)).toBe(node)
// Call processMouseDown
canvas.processMouseDown(
new MouseEvent('pointerdown', {
button: 1,
clientX: clickX,
clientY: clickY
})
)
// Should NOT query the layout store since node was found directly
expect(layoutStore.querySlotAtPoint).not.toHaveBeenCalled()
})
it('should NOT query layoutStore when not in Vue nodes mode', () => {
LiteGraph.vueNodesMode = false
const clickX = 255
const clickY = 120
// Call processMouseDown
canvas.processMouseDown(
new MouseEvent('pointerdown', {
button: 1,
clientX: clickX,
clientY: clickY
})
)
// Should NOT query the layout store in non-Vue mode
expect(layoutStore.querySlotAtPoint).not.toHaveBeenCalled()
})
it('should find node via slot query for input slots extending beyond left edge', () => {
node.addInput('input', 'number')
// Click position left of node (node starts at x=100)
const clickX = 95
const clickY = 140
// Verify outside bounds
expect(node.isPointInside(clickX, clickY)).toBe(false)
vi.mocked(layoutStore.querySlotAtPoint).mockReturnValue({
nodeId: String(node.id),
index: 0,
type: 'input',
position: { x: 98, y: 140 },
bounds: { x: 88, y: 130, width: 20, height: 20 }
})
canvas.processMouseDown(
new MouseEvent('pointerdown', {
button: 1,
clientX: clickX,
clientY: clickY
})
)
expect(layoutStore.querySlotAtPoint).toHaveBeenCalledWith({
x: clickX,
y: clickY
})
})
})
})

View File

@@ -203,9 +203,6 @@ interface LGraphCanvasState {
* Downstream consumers may reset to false once actioned.
*/
selectionChanged: boolean
/** ID of node currently in ghost placement mode (semi-transparent, following cursor). */
ghostNodeId: NodeId | null
}
/**
@@ -316,8 +313,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
readOnly: false,
hoveringOver: CanvasItem.Nothing,
shouldSetCursor: true,
selectionChanged: false,
ghostNodeId: null
selectionChanged: false
}
private _subgraph?: Subgraph
@@ -2167,14 +2163,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
processMouseDown(e: MouseEvent): void {
if (this.state.ghostNodeId != null) {
if (e.button === 0) this.finalizeGhostPlacement(false)
if (e.button === 2) this.finalizeGhostPlacement(true)
e.stopPropagation()
e.preventDefault()
return
}
if (
this.dragZoomEnabled &&
e.ctrlKey &&
@@ -2209,21 +2197,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (!is_inside) return
let node =
const node =
graph.getNodeOnPos(e.canvasX, e.canvasY, this.visible_nodes) ?? undefined
// In Vue nodes mode, slots extend beyond node bounds due to CSS transforms.
// If no node was found, check if the click is on a slot and use its owning node.
if (!node && LiteGraph.vueNodesMode) {
const slotLayout = layoutStore.querySlotAtPoint({
x: e.canvasX,
y: e.canvasY
})
if (slotLayout) {
node = graph.getNodeById(slotLayout.nodeId) ?? undefined
}
}
this.mouse[0] = x
this.mouse[1] = y
this.graph_mouse[0] = e.canvasX
@@ -3565,76 +3541,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.onNodeMoved?.(findFirstNode(this.selectedItems))
}
/**
* Starts ghost placement mode for a node.
* The node will be semi-transparent and follow the cursor until the user
* clicks to place it, or presses Escape/right-clicks to cancel.
* @param node The node to place
* @param dragEvent Optional mouse event for positioning under cursor
*/
startGhostPlacement(node: LGraphNode, dragEvent?: MouseEvent): void {
this.emitBeforeChange()
this.graph?.beforeChange()
if (dragEvent) {
this.adjustMouseEvent(dragEvent)
const e = dragEvent as CanvasPointerEvent
node.pos[0] = e.canvasX - node.size[0] / 2
node.pos[1] = e.canvasY + 10
// Update last_mouse to prevent jump on first drag move
this.last_mouse = [e.clientX, e.clientY]
} else {
node.pos[0] = this.graph_mouse[0] - node.size[0] / 2
node.pos[1] = this.graph_mouse[1] + 10
}
// Sync position to layout store for Vue node rendering
if (LiteGraph.vueNodesMode) {
const mutations = this.initLayoutMutations()
mutations.moveNode(node.id, { x: node.pos[0], y: node.pos[1] })
}
this.state.ghostNodeId = node.id
this.deselectAll()
this.select(node)
this.isDragging = true
}
/**
* Finalizes ghost placement mode.
* @param cancelled If true, the node is removed; otherwise it's placed
*/
finalizeGhostPlacement(cancelled: boolean): void {
const nodeId = this.state.ghostNodeId
if (nodeId == null) return
this.state.ghostNodeId = null
this.isDragging = false
const node = this.graph?.getNodeById(nodeId)
if (!node) return
if (cancelled) {
this.deselect(node)
this.graph?.remove(node)
} else {
delete node.flags.ghost
this.graph?.trigger('node:property:changed', {
nodeId: node.id,
property: 'flags.ghost',
oldValue: true,
newValue: false
})
}
this.dirty_canvas = true
this.dirty_bgcanvas = true
this.graph?.afterChange()
this.emitAfterChange()
}
/**
* Called when a mouse up event has to be processed
*/
@@ -3805,17 +3711,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
const { graph } = this
if (!graph) return
// Cancel ghost placement
if (
(e.key === 'Escape' || e.key === 'Delete' || e.key === 'Backspace') &&
this.state.ghostNodeId != null
) {
this.finalizeGhostPlacement(true)
e.stopPropagation()
e.preventDefault()
return
}
let block_default = false
// @ts-expect-error EventTarget.localName is not in standard types
if (e.target.localName == 'input') return
@@ -5898,7 +5793,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
private getNodeModeAlpha(node: LGraphNode) {
if (node.flags.ghost) return 0.3
return node.mode === LGraphEventMode.BYPASS
? 0.2
: node.mode === LGraphEventMode.NEVER

View File

@@ -332,8 +332,6 @@ export interface INodeFlags {
collapsed?: boolean
/** Configuration setting for {@link LGraphNode.connectInputToOutput} */
keepAllLinksOnBypass?: boolean
/** Node is in ghost placement mode (semi-transparent, following cursor) */
ghost?: boolean
}
/**

View File

@@ -755,6 +755,8 @@
"noFilesFound": "No files found",
"noImportedFiles": "No imported files found",
"noGeneratedFiles": "No generated files found",
"noQueueItems": "No active jobs",
"noQueueItemsMessage": "Queue a prompt to see active jobs here",
"generatedAssetsHeader": "Generated assets",
"importedAssetsHeader": "Imported assets",
"activeJobStatus": "Active job: {status}",

View File

@@ -206,7 +206,7 @@ describe('LGraphNode', () => {
expect(wrapper.element.style.getPropertyValue('--node-height')).toBe('')
expect(wrapper.element.style.getPropertyValue('--node-height-x')).toBe(
'130px'
'100px'
)
})
@@ -219,7 +219,7 @@ describe('LGraphNode', () => {
})
expect(wrapper.element.style.getPropertyValue('--node-height')).toBe(
'130px'
'100px'
)
expect(wrapper.element.style.getPropertyValue('--node-height-x')).toBe('')
})

View File

@@ -29,7 +29,7 @@
'ring-4 ring-primary-500 bg-primary-500/10': isDraggingOver
},
shouldHandleNodePointerEvents && !nodeData.flags?.ghost
shouldHandleNodePointerEvents
? 'pointer-events-auto'
: 'pointer-events-none',
!isCollapsed && ' pb-1'
@@ -268,8 +268,6 @@ const muted = computed((): boolean => nodeData.mode === LGraphEventMode.NEVER)
const nodeOpacity = computed(() => {
const globalOpacity = settingStore.get('Comfy.Node.Opacity') ?? 1
if (nodeData.flags?.ghost) return globalOpacity * 0.3
// For muted/bypassed nodes, apply the 0.5 multiplier on top of global opacity
if (bypassed.value || muted.value) {
return globalOpacity * 0.5
@@ -344,10 +342,7 @@ function initSizeStyles() {
const suffix = isCollapsed.value ? '-x' : ''
el.style.setProperty(`--node-width${suffix}`, `${width}px`)
el.style.setProperty(
`--node-height${suffix}`,
`${height + LiteGraph.NODE_TITLE_HEIGHT}px`
)
el.style.setProperty(`--node-height${suffix}`, `${height}px`)
}
const baseResizeHandleClasses =

View File

@@ -4,11 +4,11 @@ import { createBounds } from '@/lib/litegraph/src/measure'
import { useSettingStore } from '@/platform/settings/settingStore'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeBoundsUpdate } from '@/renderer/core/layout/types'
import { app as comfyApp } from '@/scripts/app'
import type { SubgraphInputNode } from '@/lib/litegraph/src/subgraph/SubgraphInputNode'
import type { SubgraphOutputNode } from '@/lib/litegraph/src/subgraph/SubgraphOutputNode'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
const SCALE_FACTOR = 1.2
@@ -67,7 +67,9 @@ export function ensureCorrectLayoutScale(
const scaledWidth = lgNode.width * scaleFactor
const scaledHeight = lgNode.size[1] * scaleFactor
const scaledHeight = needsUpscale
? lgNode.size[1] * scaleFactor + LiteGraph.NODE_TITLE_HEIGHT
: (lgNode.size[1] - LiteGraph.NODE_TITLE_HEIGHT) * scaleFactor
// Directly update LiteGraph node to ensure immediate consistency
// Dont need to reference vue directly because the pos and dims are already in yjs
@@ -91,7 +93,6 @@ export function ensureCorrectLayoutScale(
}
if (onActiveGraph && yjsMoveNodeUpdates.length > 0) {
layoutStore.setSource(LayoutSource.Canvas)
layoutStore.batchUpdateNodeBounds(yjsMoveNodeUpdates)
}

View File

@@ -3,8 +3,6 @@ import { WORKFLOW_ACCEPT_STRING } from '@/platform/workflow/core/types/formats'
import { type StatusWsMessageStatus } from '@/schemas/apiSchema'
import { useDialogService } from '@/services/dialogService'
import { isCloud } from '@/platform/distribution/types'
import { extractWorkflow } from '@/platform/remote/comfyui/jobs/fetchJobs'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { useTelemetry } from '@/platform/telemetry'
import { useLitegraphService } from '@/services/litegraphService'
import { useCommandStore } from '@/stores/commandStore'
@@ -35,6 +33,17 @@ type Props = {
type Children = Element[] | Element | string | string[]
/**
* @deprecated Legacy queue item structure from old history API.
* Will be removed when ComfyList is migrated to Jobs API.
*/
interface LegacyQueueItem {
prompt: [unknown, string, unknown, { extra_pnginfo: { workflow: unknown } }]
outputs?: Record<string, unknown>
meta?: Record<string, { display_node?: string }>
remove?: { name: string; cb: () => Promise<void> | void }
}
type ElementType<K extends string> = K extends keyof HTMLElementTagNameMap
? HTMLElementTagNameMap[K]
: HTMLElement
@@ -252,10 +261,7 @@ class ComfyList {
}
async load() {
const items =
this._type === 'history'
? { history: await api.getHistory() }
: await api.getQueue()
const items = await api.getItems(this._type)
this.element.replaceChildren(
...Object.keys(items).flatMap((section) => [
$el('h4', {
@@ -264,30 +270,29 @@ class ComfyList {
$el('div.comfy-list-items', [
// @ts-expect-error fixme ts strict error
...(this._reverse ? items[section].reverse() : items[section]).map(
(item: JobListItem) => {
(item: LegacyQueueItem) => {
// Allow items to specify a custom remove action (e.g. for interrupt current prompt)
const removeAction =
section === 'Running'
? {
name: 'Cancel',
cb: () => api.interrupt(item.id)
}
: {
name: 'Delete',
cb: () => api.deleteItem(this._type, item.id)
}
return $el('div', { textContent: item.priority + ': ' }, [
const removeAction = item.remove ?? {
name: 'Delete',
cb: () => api.deleteItem(this._type, item.prompt[1])
}
return $el('div', { textContent: item.prompt[0] + ': ' }, [
$el('button', {
textContent: 'Load',
onclick: async () => {
const job = await api.getJobDetail(item.id)
if (!job) return
const workflow = await extractWorkflow(job)
await app.loadGraphData(workflow, true, false)
if ('outputs' in job && job.outputs) {
await app.loadGraphData(
item.prompt[3].extra_pnginfo.workflow as Parameters<
typeof app.loadGraphData
>[0],
true,
false
)
if ('outputs' in item && item.outputs) {
app.nodeOutputs = {}
for (const [key, value] of Object.entries(job.outputs)) {
app.nodeOutputs[key] = value
for (const [key, value] of Object.entries(item.outputs)) {
const realKey = item['meta']?.[key]?.display_node ?? key
// @ts-expect-error fixme ts strict error
app.nodeOutputs[realKey] = value
}
}
}