mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-29 11:07:15 +00:00
Compare commits
3 Commits
feature/qu
...
quick-conn
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee8ded10b8 | ||
|
|
c1e6d87c3d | ||
|
|
76373a6eea |
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div ref="container" class="h-full scrollbar-custom">
|
||||
<div
|
||||
ref="container"
|
||||
class="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-(--dialog-surface)"
|
||||
>
|
||||
<div :style="topSpacerStyle" />
|
||||
<div :style="mergedGridStyle">
|
||||
<div
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
</Transition>
|
||||
</div>
|
||||
<NodeContextMenu />
|
||||
<SlotContextMenu />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -70,6 +71,7 @@ import { useCommandStore } from '@/stores/commandStore'
|
||||
import type { ComfyCommandImpl } from '@/stores/commandStore'
|
||||
|
||||
import NodeContextMenu from './NodeContextMenu.vue'
|
||||
import SlotContextMenu from '@/renderer/extensions/vueNodes/components/SlotContextMenu.vue'
|
||||
import FrameNodes from './selectionToolbox/FrameNodes.vue'
|
||||
import NodeOptionsButton from './selectionToolbox/NodeOptionsButton.vue'
|
||||
import VerticalDivider from './selectionToolbox/VerticalDivider.vue'
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
<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="px-2 2xl:px-4">
|
||||
<div
|
||||
v-if="assets.length"
|
||||
:class="cn('px-2 2xl:px-4', activeJobItems.length && 'mt-2')"
|
||||
>
|
||||
<div
|
||||
class="flex items-center py-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
|
||||
>
|
||||
@@ -43,18 +59,25 @@ 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
|
||||
@@ -69,9 +92,19 @@ 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}`,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import AssetsSidebarListView from './AssetsSidebarListView.vue'
|
||||
|
||||
@@ -9,12 +10,51 @@ 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
|
||||
}))
|
||||
@@ -33,6 +73,7 @@ vi.mock('@/utils/formatUtil', () => ({
|
||||
describe('AssetsSidebarListView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockJobItems.value = []
|
||||
})
|
||||
|
||||
const defaultProps = {
|
||||
@@ -43,14 +84,67 @@ describe('AssetsSidebarListView', () => {
|
||||
toggleStack: async () => {}
|
||||
}
|
||||
|
||||
it('renders without errors with empty assets', () => {
|
||||
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
|
||||
}
|
||||
]
|
||||
|
||||
const wrapper = mount(AssetsSidebarListView, {
|
||||
props: defaultProps,
|
||||
shallow: true
|
||||
})
|
||||
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
const listItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
|
||||
expect(listItems).toHaveLength(0)
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,48 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<div v-if="assetItems.length" class="px-2">
|
||||
<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
|
||||
class="flex items-center p-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
|
||||
>
|
||||
@@ -77,25 +119,31 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, 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,
|
||||
@@ -122,8 +170,24 @@ 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',
|
||||
@@ -176,6 +240,16 @@ 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
|
||||
}
|
||||
@@ -185,4 +259,13 @@ 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>
|
||||
|
||||
@@ -26,15 +26,6 @@
|
||||
<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>
|
||||
@@ -52,9 +43,8 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Filter Bar (hidden on queue tab) -->
|
||||
<!-- Filter Bar -->
|
||||
<MediaAssetFilterBar
|
||||
v-if="!isQueueTab"
|
||||
v-model:search-query="searchQuery"
|
||||
v-model:sort-by="sortBy"
|
||||
v-model:view-mode="viewMode"
|
||||
@@ -63,14 +53,13 @@
|
||||
:show-generation-time-sort="activeTab === 'output'"
|
||||
/>
|
||||
<div
|
||||
v-if="isQueueTab && !isInFolderView"
|
||||
class="flex items-center justify-between px-4 2xl:px-6"
|
||||
v-if="isQueuePanelV2Enabled && !isInFolderView"
|
||||
class="flex items-center justify-between px-2 py-2 2xl:px-4"
|
||||
>
|
||||
<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>
|
||||
@@ -87,7 +76,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Divider v-else-if="!isQueueTab" type="dashed" class="my-2" />
|
||||
<Divider v-else type="dashed" class="my-2" />
|
||||
</template>
|
||||
<template #body>
|
||||
<div v-if="showLoadingState">
|
||||
@@ -98,32 +87,23 @@
|
||||
icon="pi pi-info-circle"
|
||||
:title="
|
||||
$t(
|
||||
isQueueTab
|
||||
? 'sideToolbar.noQueueItems'
|
||||
: activeTab === 'input'
|
||||
? 'sideToolbar.noImportedFiles'
|
||||
: 'sideToolbar.noGeneratedFiles'
|
||||
)
|
||||
"
|
||||
:message="
|
||||
$t(
|
||||
isQueueTab
|
||||
? 'sideToolbar.noQueueItemsMessage'
|
||||
: 'sideToolbar.noFilesFoundMessage'
|
||||
activeTab === 'input'
|
||||
? 'sideToolbar.noImportedFiles'
|
||||
: 'sideToolbar.noGeneratedFiles'
|
||||
)
|
||||
"
|
||||
:message="$t('sideToolbar.noFilesFoundMessage')"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="relative size-full" @click="handleEmptySpaceClick">
|
||||
<QueueAssetView v-if="isQueueTab" :view-mode="viewMode" />
|
||||
<AssetsSidebarListView
|
||||
v-else-if="isListView"
|
||||
v-if="isListView"
|
||||
:asset-items="listViewAssetItems"
|
||||
:is-selected="isSelected"
|
||||
:selectable-assets="listViewSelectableAssets"
|
||||
:is-stack-expanded="isListViewStackExpanded"
|
||||
:toggle-stack="toggleListViewStack"
|
||||
:asset-type="assetTabType"
|
||||
:asset-type="activeTab"
|
||||
@select-asset="handleAssetSelect"
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@approach-end="handleApproachEnd"
|
||||
@@ -132,7 +112,8 @@
|
||||
v-else
|
||||
:assets="displayAssets"
|
||||
:is-selected="isSelected"
|
||||
:asset-type="assetTabType"
|
||||
:is-in-folder-view="isInFolderView"
|
||||
:asset-type="activeTab"
|
||||
:show-output-count="shouldShowOutputCount"
|
||||
:get-output-count="getOutputCount"
|
||||
@select-asset="handleAssetSelect"
|
||||
@@ -243,7 +224,6 @@ 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'
|
||||
@@ -251,7 +231,6 @@ 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'
|
||||
@@ -278,7 +257,7 @@ const { activeJobsCount } = storeToRefs(queueStore)
|
||||
const executionStore = useExecutionStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const activeTab = ref<'input' | 'output' | 'queue'>('output')
|
||||
const activeTab = ref<'input' | 'output'>('output')
|
||||
const folderPromptId = ref<string | null>(null)
|
||||
const folderExecutionTime = ref<number | undefined>(undefined)
|
||||
const isInFolderView = computed(() => folderPromptId.value !== null)
|
||||
@@ -289,10 +268,6 @@ 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'
|
||||
)
|
||||
@@ -440,15 +415,18 @@ const isBulkMode = computed(
|
||||
)
|
||||
|
||||
const showLoadingState = computed(
|
||||
() => !isQueueTab.value && loading.value && displayAssets.value.length === 0
|
||||
() =>
|
||||
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
|
||||
})
|
||||
const showEmptyState = computed(
|
||||
() =>
|
||||
!loading.value &&
|
||||
displayAssets.value.length === 0 &&
|
||||
activeJobsCount.value === 0
|
||||
)
|
||||
|
||||
watch(visibleAssets, (newAssets) => {
|
||||
// Alternative: keep hidden selections and surface them in UI; for now prune
|
||||
@@ -505,21 +483,12 @@ watch(
|
||||
clearSelection()
|
||||
// Clear search when switching tabs
|
||||
searchQuery.value = ''
|
||||
// Skip asset fetch for queue tab
|
||||
if (activeTab.value !== 'queue') {
|
||||
void refreshAssets()
|
||||
}
|
||||
// Reset pagination state when tab changes
|
||||
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)
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
<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>
|
||||
@@ -755,8 +755,6 @@
|
||||
"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}",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
@click="onClick"
|
||||
@dblclick="onDoubleClick"
|
||||
@pointerdown="onPointerDown"
|
||||
@contextmenu.stop.prevent="onSlotContextMenu"
|
||||
/>
|
||||
|
||||
<!-- Slot Name -->
|
||||
@@ -65,6 +66,7 @@ import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDra
|
||||
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
|
||||
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
||||
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
|
||||
import { showSlotMenu } from '@/renderer/extensions/vueNodes/composables/useSlotContextMenu'
|
||||
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
@@ -147,4 +149,13 @@ const { onClick, onDoubleClick, onPointerDown } = useSlotLinkInteraction({
|
||||
index: props.index,
|
||||
type: 'input'
|
||||
})
|
||||
|
||||
function onSlotContextMenu(event: MouseEvent) {
|
||||
if (!props.nodeId) return
|
||||
showSlotMenu(event, {
|
||||
nodeId: props.nodeId,
|
||||
slotIndex: props.index,
|
||||
isInput: true
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
class="w-3 translate-x-1/2"
|
||||
:slot-data
|
||||
@pointerdown="onPointerDown"
|
||||
@contextmenu.stop.prevent="onSlotContextMenu"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -26,6 +27,7 @@ import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState'
|
||||
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
|
||||
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
||||
import { showSlotMenu } from '@/renderer/extensions/vueNodes/composables/useSlotContextMenu'
|
||||
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
|
||||
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
@@ -112,4 +114,13 @@ const { onPointerDown } = useSlotLinkInteraction({
|
||||
index: props.index,
|
||||
type: 'output'
|
||||
})
|
||||
|
||||
function onSlotContextMenu(event: MouseEvent) {
|
||||
if (!props.nodeId) return
|
||||
showSlotMenu(event, {
|
||||
nodeId: props.nodeId,
|
||||
slotIndex: props.index,
|
||||
isInput: false
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
169
src/renderer/extensions/vueNodes/components/SlotContextMenu.vue
Normal file
169
src/renderer/extensions/vueNodes/components/SlotContextMenu.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<ContextMenu
|
||||
ref="contextMenu"
|
||||
:model="menuItems"
|
||||
class="max-h-[80vh] overflow-y-auto"
|
||||
@show="onMenuShow"
|
||||
@hide="onMenuHide"
|
||||
>
|
||||
<template #item="{ item, props: itemProps }">
|
||||
<a v-bind="itemProps.action" class="flex items-center gap-2 px-3 py-1.5">
|
||||
<span class="flex-1">{{ item.label }}</span>
|
||||
</a>
|
||||
</template>
|
||||
</ContextMenu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementBounding, useRafFn } from '@vueuse/core'
|
||||
import ContextMenu from 'primevue/contextmenu'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import {
|
||||
canRenameSlot,
|
||||
connectSlots,
|
||||
findCompatibleTargets,
|
||||
registerSlotMenuInstance,
|
||||
renameSlot
|
||||
} from '@/renderer/extensions/vueNodes/composables/useSlotContextMenu'
|
||||
import type { SlotMenuContext } from '@/renderer/extensions/vueNodes/composables/useSlotContextMenu'
|
||||
|
||||
const contextMenu = ref<InstanceType<typeof ContextMenu>>()
|
||||
const isOpen = ref(false)
|
||||
const activeContext = ref<SlotMenuContext | null>(null)
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const lgCanvas = canvasStore.getCanvas()
|
||||
const { left: canvasLeft, top: canvasTop } = useElementBounding(lgCanvas.canvas)
|
||||
|
||||
const worldPosition = ref({ x: 0, y: 0 })
|
||||
let lastScale = 0
|
||||
let lastOffsetX = 0
|
||||
let lastOffsetY = 0
|
||||
|
||||
function updateMenuPosition() {
|
||||
if (!isOpen.value) return
|
||||
|
||||
const menuInstance = contextMenu.value as unknown as {
|
||||
container?: HTMLElement
|
||||
}
|
||||
const menuEl = menuInstance?.container
|
||||
if (!menuEl) return
|
||||
|
||||
const { scale, offset } = lgCanvas.ds
|
||||
|
||||
if (
|
||||
scale === lastScale &&
|
||||
offset[0] === lastOffsetX &&
|
||||
offset[1] === lastOffsetY
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
lastScale = scale
|
||||
lastOffsetX = offset[0]
|
||||
lastOffsetY = offset[1]
|
||||
|
||||
const screenX = (worldPosition.value.x + offset[0]) * scale + canvasLeft.value
|
||||
const screenY = (worldPosition.value.y + offset[1]) * scale + canvasTop.value
|
||||
|
||||
menuEl.style.left = `${screenX}px`
|
||||
menuEl.style.top = `${screenY}px`
|
||||
}
|
||||
|
||||
const { resume: startSync, pause: stopSync } = useRafFn(updateMenuPosition, {
|
||||
immediate: false
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
if (isOpen.value) {
|
||||
startSync()
|
||||
} else {
|
||||
stopSync()
|
||||
}
|
||||
})
|
||||
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
const ctx = activeContext.value
|
||||
if (!ctx) return []
|
||||
|
||||
const items: MenuItem[] = []
|
||||
|
||||
if (canRenameSlot(ctx)) {
|
||||
items.push({
|
||||
label: 'Rename slot',
|
||||
command: () => {
|
||||
const newLabel = window.prompt('New slot label:')
|
||||
if (newLabel !== null) {
|
||||
renameSlot(ctx, newLabel)
|
||||
}
|
||||
hide()
|
||||
}
|
||||
})
|
||||
items.push({ separator: true })
|
||||
}
|
||||
|
||||
const targets = findCompatibleTargets(ctx)
|
||||
if (targets.length === 0) {
|
||||
items.push({ label: 'No compatible nodes', disabled: true })
|
||||
} else {
|
||||
items.push({ label: 'Connect to...', disabled: true })
|
||||
items.push({ separator: true })
|
||||
items.push(
|
||||
...targets.map((target) => ({
|
||||
label: `${target.slotInfo.name} @ ${target.node.title || target.node.type}`,
|
||||
command: () => {
|
||||
connectSlots(ctx, target)
|
||||
hide()
|
||||
}
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
function show(event: MouseEvent, context: SlotMenuContext) {
|
||||
activeContext.value = context
|
||||
|
||||
const screenX = event.clientX - canvasLeft.value
|
||||
const screenY = event.clientY - canvasTop.value
|
||||
const { scale, offset } = lgCanvas.ds
|
||||
worldPosition.value = {
|
||||
x: screenX / scale - offset[0],
|
||||
y: screenY / scale - offset[1]
|
||||
}
|
||||
|
||||
lastScale = scale
|
||||
lastOffsetX = offset[0]
|
||||
lastOffsetY = offset[1]
|
||||
|
||||
isOpen.value = true
|
||||
contextMenu.value?.show(event)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
contextMenu.value?.hide()
|
||||
}
|
||||
|
||||
function onMenuShow() {
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
function onMenuHide() {
|
||||
isOpen.value = false
|
||||
activeContext.value = null
|
||||
}
|
||||
|
||||
defineExpose({ show, hide, isOpen })
|
||||
|
||||
onMounted(() => {
|
||||
registerSlotMenuInstance({ show, hide, isOpen })
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
registerSlotMenuInstance(null)
|
||||
})
|
||||
</script>
|
||||
179
src/renderer/extensions/vueNodes/composables/useAutoPan.test.ts
Normal file
179
src/renderer/extensions/vueNodes/composables/useAutoPan.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockDs, mockSetDirty } = vi.hoisted(() => {
|
||||
const mockDs = { offset: [0, 0] as number[], scale: 1 }
|
||||
const mockSetDirty = vi.fn()
|
||||
return { mockDs, mockSetDirty }
|
||||
})
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: {
|
||||
canvas: {
|
||||
getBoundingClientRect: () => ({
|
||||
left: 0,
|
||||
right: 800,
|
||||
top: 0,
|
||||
bottom: 600,
|
||||
width: 800,
|
||||
height: 600
|
||||
})
|
||||
},
|
||||
ds: mockDs,
|
||||
setDirty: mockSetDirty
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
import { useAutoPan } from './useAutoPan'
|
||||
|
||||
describe('useAutoPan', () => {
|
||||
let rafCallbacks: Array<(timestamp: number) => void>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDs.offset = [0, 0]
|
||||
mockDs.scale = 1
|
||||
rafCallbacks = []
|
||||
|
||||
vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((cb) => {
|
||||
rafCallbacks.push(cb as (timestamp: number) => void)
|
||||
return rafCallbacks.length
|
||||
})
|
||||
vi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation(() => {})
|
||||
vi.spyOn(performance, 'now').mockReturnValue(0)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('does not start panning when pointer is in the center', () => {
|
||||
const onPan = vi.fn()
|
||||
const { updatePointer } = useAutoPan(onPan)
|
||||
|
||||
updatePointer(400, 300)
|
||||
|
||||
expect(rafCallbacks).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('starts panning when pointer enters left edge zone', () => {
|
||||
const onPan = vi.fn()
|
||||
const { updatePointer } = useAutoPan(onPan)
|
||||
|
||||
updatePointer(10, 300)
|
||||
|
||||
expect(rafCallbacks).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('starts panning when pointer enters right edge zone', () => {
|
||||
const onPan = vi.fn()
|
||||
const { updatePointer } = useAutoPan(onPan)
|
||||
|
||||
updatePointer(790, 300)
|
||||
|
||||
expect(rafCallbacks).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('starts panning when pointer enters top edge zone', () => {
|
||||
const onPan = vi.fn()
|
||||
const { updatePointer } = useAutoPan(onPan)
|
||||
|
||||
updatePointer(400, 10)
|
||||
|
||||
expect(rafCallbacks).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('starts panning when pointer enters bottom edge zone', () => {
|
||||
const onPan = vi.fn()
|
||||
const { updatePointer } = useAutoPan(onPan)
|
||||
|
||||
updatePointer(400, 590)
|
||||
|
||||
expect(rafCallbacks).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('stops panning when stop() is called', () => {
|
||||
const onPan = vi.fn()
|
||||
const { updatePointer, stop } = useAutoPan(onPan)
|
||||
|
||||
updatePointer(10, 300)
|
||||
expect(rafCallbacks).toHaveLength(1)
|
||||
|
||||
stop()
|
||||
|
||||
rafCallbacks[0](100)
|
||||
expect(onPan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onPan callback with canvas-space deltas', () => {
|
||||
const onPan = vi.fn()
|
||||
const { updatePointer } = useAutoPan(onPan)
|
||||
|
||||
updatePointer(10, 300)
|
||||
expect(rafCallbacks).toHaveLength(1)
|
||||
|
||||
rafCallbacks[0](100)
|
||||
|
||||
expect(onPan).toHaveBeenCalledTimes(1)
|
||||
const [dx, dy] = onPan.mock.calls[0]
|
||||
expect(dx).toBeGreaterThan(0)
|
||||
expect(dy).toBe(0)
|
||||
})
|
||||
|
||||
it('modifies ds.offset when panning', () => {
|
||||
const onPan = vi.fn()
|
||||
const { updatePointer } = useAutoPan(onPan)
|
||||
|
||||
updatePointer(10, 300)
|
||||
rafCallbacks[0](100)
|
||||
|
||||
expect(mockDs.offset[0]).toBeGreaterThan(0)
|
||||
expect(mockDs.offset[1]).toBe(0)
|
||||
})
|
||||
|
||||
it('speed scales with proximity to edge', () => {
|
||||
const onPanClose = vi.fn()
|
||||
const controlsClose = useAutoPan(onPanClose)
|
||||
controlsClose.updatePointer(5, 300)
|
||||
rafCallbacks[0](100)
|
||||
controlsClose.stop()
|
||||
|
||||
const dxClose = onPanClose.mock.calls[0][0]
|
||||
|
||||
mockDs.offset = [0, 0]
|
||||
rafCallbacks = []
|
||||
|
||||
const onPanFar = vi.fn()
|
||||
const controlsFar = useAutoPan(onPanFar)
|
||||
controlsFar.updatePointer(40, 300)
|
||||
rafCallbacks[0](100)
|
||||
controlsFar.stop()
|
||||
|
||||
const dxFar = onPanFar.mock.calls[0][0]
|
||||
|
||||
expect(Math.abs(dxClose)).toBeGreaterThan(Math.abs(dxFar))
|
||||
})
|
||||
|
||||
it('marks canvas as dirty when panning', () => {
|
||||
const onPan = vi.fn()
|
||||
const { updatePointer } = useAutoPan(onPan)
|
||||
|
||||
updatePointer(10, 300)
|
||||
rafCallbacks[0](100)
|
||||
|
||||
expect(mockSetDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('does not call onPan when velocity is zero', () => {
|
||||
const onPan = vi.fn()
|
||||
const { updatePointer } = useAutoPan(onPan)
|
||||
|
||||
updatePointer(10, 300)
|
||||
|
||||
updatePointer(400, 300)
|
||||
rafCallbacks[0](100)
|
||||
|
||||
expect(onPan).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
116
src/renderer/extensions/vueNodes/composables/useAutoPan.ts
Normal file
116
src/renderer/extensions/vueNodes/composables/useAutoPan.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
const EDGE_PX = 48
|
||||
const MAX_SPEED = 900
|
||||
|
||||
interface AutoPanState {
|
||||
active: boolean
|
||||
rafId: number | null
|
||||
lastTime: number
|
||||
velocityX: number
|
||||
velocityY: number
|
||||
lastClientX: number
|
||||
lastClientY: number
|
||||
}
|
||||
|
||||
interface AutoPanControls {
|
||||
updatePointer: (clientX: number, clientY: number) => void
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
export function useAutoPan(
|
||||
onPan: (dxCanvas: number, dyCanvas: number) => void
|
||||
): AutoPanControls {
|
||||
const state: AutoPanState = {
|
||||
active: false,
|
||||
rafId: null,
|
||||
lastTime: 0,
|
||||
velocityX: 0,
|
||||
velocityY: 0,
|
||||
lastClientX: 0,
|
||||
lastClientY: 0
|
||||
}
|
||||
|
||||
function computeVelocity(clientX: number, clientY: number): [number, number] {
|
||||
const canvas = app.canvas?.canvas
|
||||
if (!canvas) return [0, 0]
|
||||
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
let vx = 0
|
||||
let vy = 0
|
||||
|
||||
const distLeft = clientX - rect.left
|
||||
const distRight = rect.right - clientX
|
||||
const distTop = clientY - rect.top
|
||||
const distBottom = rect.bottom - clientY
|
||||
|
||||
if (distLeft < EDGE_PX) vx = ((EDGE_PX - distLeft) / EDGE_PX) * MAX_SPEED
|
||||
else if (distRight < EDGE_PX)
|
||||
vx = -(((EDGE_PX - distRight) / EDGE_PX) * MAX_SPEED)
|
||||
|
||||
if (distTop < EDGE_PX) vy = ((EDGE_PX - distTop) / EDGE_PX) * MAX_SPEED
|
||||
else if (distBottom < EDGE_PX)
|
||||
vy = -(((EDGE_PX - distBottom) / EDGE_PX) * MAX_SPEED)
|
||||
|
||||
return [vx, vy]
|
||||
}
|
||||
|
||||
function tick(timestamp: number): void {
|
||||
if (!state.active) return
|
||||
|
||||
const [vx, vy] = computeVelocity(state.lastClientX, state.lastClientY)
|
||||
state.velocityX = vx
|
||||
state.velocityY = vy
|
||||
|
||||
if (vx === 0 && vy === 0) {
|
||||
state.rafId = requestAnimationFrame(tick)
|
||||
return
|
||||
}
|
||||
|
||||
const ds = app.canvas?.ds
|
||||
if (!ds) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
const dt = Math.min((timestamp - state.lastTime) / 1000, 0.1)
|
||||
state.lastTime = timestamp
|
||||
|
||||
const dxCanvas = (vx * dt) / ds.scale
|
||||
const dyCanvas = (vy * dt) / ds.scale
|
||||
|
||||
ds.offset[0] += dxCanvas
|
||||
ds.offset[1] += dyCanvas
|
||||
app.canvas?.setDirty(true, true)
|
||||
|
||||
onPan(dxCanvas, dyCanvas)
|
||||
|
||||
state.rafId = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
function updatePointer(clientX: number, clientY: number): void {
|
||||
state.lastClientX = clientX
|
||||
state.lastClientY = clientY
|
||||
|
||||
if (!state.active) {
|
||||
const [vx, vy] = computeVelocity(clientX, clientY)
|
||||
if (vx !== 0 || vy !== 0) {
|
||||
state.active = true
|
||||
state.lastTime = performance.now()
|
||||
state.rafId = requestAnimationFrame(tick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
state.active = false
|
||||
if (state.rafId !== null) {
|
||||
cancelAnimationFrame(state.rafId)
|
||||
state.rafId = null
|
||||
}
|
||||
state.velocityX = 0
|
||||
state.velocityY = 0
|
||||
}
|
||||
|
||||
return { updatePointer, stop }
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
|
||||
const { mockGraph, mockCanvas } = vi.hoisted(() => {
|
||||
const mockGraph = {
|
||||
_nodes: [] as any[],
|
||||
getNodeById: vi.fn(),
|
||||
beforeChange: vi.fn(),
|
||||
afterChange: vi.fn()
|
||||
}
|
||||
const mockCanvas = {
|
||||
graph: mockGraph as any,
|
||||
setDirty: vi.fn()
|
||||
}
|
||||
return { mockGraph, mockCanvas }
|
||||
})
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { canvas: mockCanvas }
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/litegraph/src/litegraph', () => ({
|
||||
LiteGraph: {
|
||||
isValidConnection: vi.fn((a: unknown, b: unknown) => a === b)
|
||||
}
|
||||
}))
|
||||
|
||||
import { connectSlots, findCompatibleTargets } from './useSlotContextMenu'
|
||||
|
||||
function createMockNode(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: overrides.id ?? '1',
|
||||
pos: overrides.pos ?? [0, 0],
|
||||
title: overrides.title ?? 'TestNode',
|
||||
type: overrides.type ?? 'TestType',
|
||||
mode: overrides.mode ?? LGraphEventMode.ALWAYS,
|
||||
inputs: overrides.inputs ?? [],
|
||||
outputs: overrides.outputs ?? [],
|
||||
connect: vi.fn(),
|
||||
...overrides
|
||||
} as unknown as LGraphNode & { connect: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
|
||||
describe('findCompatibleTargets', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGraph._nodes = []
|
||||
mockCanvas.graph = mockGraph
|
||||
})
|
||||
|
||||
it('returns empty array when graph is null', () => {
|
||||
mockCanvas.graph = null as any
|
||||
const result = findCompatibleTargets({
|
||||
nodeId: '1',
|
||||
slotIndex: 0,
|
||||
isInput: true
|
||||
})
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when source node is not found', () => {
|
||||
mockGraph.getNodeById.mockReturnValue(null)
|
||||
const result = findCompatibleTargets({
|
||||
nodeId: '99',
|
||||
slotIndex: 0,
|
||||
isInput: true
|
||||
})
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when source slot has wildcard type "*"', () => {
|
||||
const source = createMockNode({
|
||||
id: '1',
|
||||
inputs: [{ type: '*', link: null }]
|
||||
})
|
||||
mockGraph.getNodeById.mockReturnValue(source)
|
||||
mockGraph._nodes = [source]
|
||||
|
||||
const result = findCompatibleTargets({
|
||||
nodeId: '1',
|
||||
slotIndex: 0,
|
||||
isInput: true
|
||||
})
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when source slot has wildcard type ""', () => {
|
||||
const source = createMockNode({
|
||||
id: '1',
|
||||
outputs: [{ type: '', links: [] }]
|
||||
})
|
||||
mockGraph.getNodeById.mockReturnValue(source)
|
||||
mockGraph._nodes = [source]
|
||||
|
||||
const result = findCompatibleTargets({
|
||||
nodeId: '1',
|
||||
slotIndex: 0,
|
||||
isInput: false
|
||||
})
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when source slot has wildcard type 0', () => {
|
||||
const source = createMockNode({
|
||||
id: '1',
|
||||
outputs: [{ type: 0, links: [] }]
|
||||
})
|
||||
mockGraph.getNodeById.mockReturnValue(source)
|
||||
mockGraph._nodes = [source]
|
||||
|
||||
const result = findCompatibleTargets({
|
||||
nodeId: '1',
|
||||
slotIndex: 0,
|
||||
isInput: false
|
||||
})
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('finds compatible output nodes when source is input', () => {
|
||||
const source = createMockNode({
|
||||
id: '1',
|
||||
inputs: [{ type: 'IMAGE', link: null }]
|
||||
})
|
||||
const candidate = createMockNode({
|
||||
id: '2',
|
||||
outputs: [{ type: 'IMAGE', links: [] }]
|
||||
})
|
||||
mockGraph.getNodeById.mockReturnValue(source)
|
||||
mockGraph._nodes = [source, candidate]
|
||||
|
||||
const result = findCompatibleTargets({
|
||||
nodeId: '1',
|
||||
slotIndex: 0,
|
||||
isInput: true
|
||||
})
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].node).toBe(candidate)
|
||||
expect(result[0].slotIndex).toBe(0)
|
||||
})
|
||||
|
||||
it('finds compatible input nodes when source is output', () => {
|
||||
const source = createMockNode({
|
||||
id: '1',
|
||||
outputs: [{ type: 'MODEL', links: [] }]
|
||||
})
|
||||
const candidate = createMockNode({
|
||||
id: '2',
|
||||
inputs: [{ type: 'MODEL', link: null }]
|
||||
})
|
||||
mockGraph.getNodeById.mockReturnValue(source)
|
||||
mockGraph._nodes = [source, candidate]
|
||||
|
||||
const result = findCompatibleTargets({
|
||||
nodeId: '1',
|
||||
slotIndex: 0,
|
||||
isInput: false
|
||||
})
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].node).toBe(candidate)
|
||||
expect(result[0].slotIndex).toBe(0)
|
||||
})
|
||||
|
||||
it('skips bypassed nodes', () => {
|
||||
const source = createMockNode({
|
||||
id: '1',
|
||||
inputs: [{ type: 'IMAGE', link: null }]
|
||||
})
|
||||
const bypassed = createMockNode({
|
||||
id: '2',
|
||||
mode: LGraphEventMode.NEVER,
|
||||
outputs: [{ type: 'IMAGE', links: [] }]
|
||||
})
|
||||
mockGraph.getNodeById.mockReturnValue(source)
|
||||
mockGraph._nodes = [source, bypassed]
|
||||
|
||||
const result = findCompatibleTargets({
|
||||
nodeId: '1',
|
||||
slotIndex: 0,
|
||||
isInput: true
|
||||
})
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('skips already-connected inputs', () => {
|
||||
const source = createMockNode({
|
||||
id: '1',
|
||||
outputs: [{ type: 'MODEL', links: [] }]
|
||||
})
|
||||
const connected = createMockNode({
|
||||
id: '2',
|
||||
inputs: [{ type: 'MODEL', link: 42 }]
|
||||
})
|
||||
mockGraph.getNodeById.mockReturnValue(source)
|
||||
mockGraph._nodes = [source, connected]
|
||||
|
||||
const result = findCompatibleTargets({
|
||||
nodeId: '1',
|
||||
slotIndex: 0,
|
||||
isInput: false
|
||||
})
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('skips wildcard-typed candidate slots', () => {
|
||||
const source = createMockNode({
|
||||
id: '1',
|
||||
inputs: [{ type: 'IMAGE', link: null }]
|
||||
})
|
||||
const wildcardCandidate = createMockNode({
|
||||
id: '2',
|
||||
outputs: [{ type: '*', links: [] }]
|
||||
})
|
||||
mockGraph.getNodeById.mockReturnValue(source)
|
||||
mockGraph._nodes = [source, wildcardCandidate]
|
||||
|
||||
const result = findCompatibleTargets({
|
||||
nodeId: '1',
|
||||
slotIndex: 0,
|
||||
isInput: true
|
||||
})
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('sorts results by node Y position', () => {
|
||||
const source = createMockNode({
|
||||
id: '1',
|
||||
inputs: [{ type: 'IMAGE', link: null }]
|
||||
})
|
||||
const nodeHigh = createMockNode({
|
||||
id: '2',
|
||||
pos: [0, 300],
|
||||
outputs: [{ type: 'IMAGE', links: [] }]
|
||||
})
|
||||
const nodeLow = createMockNode({
|
||||
id: '3',
|
||||
pos: [0, 100],
|
||||
outputs: [{ type: 'IMAGE', links: [] }]
|
||||
})
|
||||
mockGraph.getNodeById.mockReturnValue(source)
|
||||
mockGraph._nodes = [source, nodeHigh, nodeLow]
|
||||
|
||||
const result = findCompatibleTargets({
|
||||
nodeId: '1',
|
||||
slotIndex: 0,
|
||||
isInput: true
|
||||
})
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].node).toBe(nodeLow)
|
||||
expect(result[1].node).toBe(nodeHigh)
|
||||
})
|
||||
|
||||
it('limits results to maxResults', () => {
|
||||
const source = createMockNode({
|
||||
id: '1',
|
||||
inputs: [{ type: 'IMAGE', link: null }]
|
||||
})
|
||||
const candidates = Array.from({ length: 5 }, (_, i) =>
|
||||
createMockNode({
|
||||
id: String(i + 2),
|
||||
pos: [0, i * 100],
|
||||
outputs: [{ type: 'IMAGE', links: [] }]
|
||||
})
|
||||
)
|
||||
mockGraph.getNodeById.mockReturnValue(source)
|
||||
mockGraph._nodes = [source, ...candidates]
|
||||
|
||||
const result = findCompatibleTargets(
|
||||
{ nodeId: '1', slotIndex: 0, isInput: true },
|
||||
3
|
||||
)
|
||||
expect(result).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('does not include the source node itself', () => {
|
||||
const source = createMockNode({
|
||||
id: '1',
|
||||
inputs: [{ type: 'IMAGE', link: null }],
|
||||
outputs: [{ type: 'IMAGE', links: [] }]
|
||||
})
|
||||
mockGraph.getNodeById.mockReturnValue(source)
|
||||
mockGraph._nodes = [source]
|
||||
|
||||
const result = findCompatibleTargets({
|
||||
nodeId: '1',
|
||||
slotIndex: 0,
|
||||
isInput: true
|
||||
})
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('connectSlots', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCanvas.graph = mockGraph
|
||||
})
|
||||
|
||||
it('calls graph.beforeChange and afterChange', () => {
|
||||
const source = createMockNode({ id: '1', outputs: [{ type: 'MODEL' }] })
|
||||
const target = createMockNode({
|
||||
id: '2',
|
||||
inputs: [{ type: 'MODEL', link: null }]
|
||||
})
|
||||
mockGraph.getNodeById.mockReturnValue(source)
|
||||
|
||||
connectSlots(
|
||||
{ nodeId: '1', slotIndex: 0, isInput: false },
|
||||
{ node: target, slotIndex: 0, slotInfo: target.inputs[0] }
|
||||
)
|
||||
|
||||
expect(mockGraph.beforeChange).toHaveBeenCalled()
|
||||
expect(mockGraph.afterChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('connects source output to target input', () => {
|
||||
const source = createMockNode({ id: '1', outputs: [{ type: 'MODEL' }] })
|
||||
const target = createMockNode({
|
||||
id: '2',
|
||||
inputs: [{ type: 'MODEL', link: null }]
|
||||
})
|
||||
mockGraph.getNodeById.mockReturnValue(source)
|
||||
|
||||
connectSlots(
|
||||
{ nodeId: '1', slotIndex: 0, isInput: false },
|
||||
{ node: target, slotIndex: 0, slotInfo: target.inputs[0] }
|
||||
)
|
||||
|
||||
expect(source.connect).toHaveBeenCalledWith(0, target, 0)
|
||||
})
|
||||
|
||||
it('connects target output to source input when source is input', () => {
|
||||
const source = createMockNode({
|
||||
id: '1',
|
||||
inputs: [{ type: 'IMAGE', link: null }]
|
||||
})
|
||||
const target = createMockNode({ id: '2', outputs: [{ type: 'IMAGE' }] })
|
||||
mockGraph.getNodeById.mockReturnValue(source)
|
||||
|
||||
connectSlots(
|
||||
{ nodeId: '1', slotIndex: 0, isInput: true },
|
||||
{ node: target, slotIndex: 0, slotInfo: target.outputs[0] }
|
||||
)
|
||||
|
||||
expect(target.connect).toHaveBeenCalledWith(0, source, 0)
|
||||
})
|
||||
|
||||
it('does nothing when graph is null', () => {
|
||||
mockCanvas.graph = null as any
|
||||
const target = createMockNode({ id: '2' })
|
||||
|
||||
connectSlots(
|
||||
{ nodeId: '1', slotIndex: 0, isInput: false },
|
||||
{ node: target, slotIndex: 0, slotInfo: {} as any }
|
||||
)
|
||||
|
||||
expect(target.connect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marks canvas as dirty after connecting', () => {
|
||||
const source = createMockNode({ id: '1', outputs: [{ type: 'MODEL' }] })
|
||||
const target = createMockNode({
|
||||
id: '2',
|
||||
inputs: [{ type: 'MODEL', link: null }]
|
||||
})
|
||||
mockGraph.getNodeById.mockReturnValue(source)
|
||||
|
||||
connectSlots(
|
||||
{ nodeId: '1', slotIndex: 0, isInput: false },
|
||||
{ node: target, slotIndex: 0, slotInfo: target.inputs[0] }
|
||||
)
|
||||
|
||||
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
IWidgetInputSlot
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
interface SlotMenuContext {
|
||||
nodeId: NodeId
|
||||
slotIndex: number
|
||||
isInput: boolean
|
||||
}
|
||||
|
||||
interface CompatibleTarget {
|
||||
node: LGraphNode
|
||||
slotIndex: number
|
||||
slotInfo: INodeInputSlot | INodeOutputSlot
|
||||
}
|
||||
|
||||
interface SlotMenuInstance {
|
||||
show: (event: MouseEvent, context: SlotMenuContext) => void
|
||||
hide: () => void
|
||||
isOpen: Ref<boolean>
|
||||
}
|
||||
|
||||
let slotMenuInstance: SlotMenuInstance | null = null
|
||||
|
||||
export function registerSlotMenuInstance(
|
||||
instance: SlotMenuInstance | null
|
||||
): void {
|
||||
slotMenuInstance = instance
|
||||
}
|
||||
|
||||
export function showSlotMenu(
|
||||
event: MouseEvent,
|
||||
context: SlotMenuContext
|
||||
): void {
|
||||
slotMenuInstance?.show(event, context)
|
||||
}
|
||||
|
||||
function isWildcardType(type: unknown): boolean {
|
||||
return type === '*' || type === '' || type === 0
|
||||
}
|
||||
|
||||
export function findCompatibleTargets(
|
||||
context: SlotMenuContext,
|
||||
maxResults: number = 15
|
||||
): CompatibleTarget[] {
|
||||
const graph = app.canvas?.graph
|
||||
if (!graph) return []
|
||||
|
||||
const sourceNode = graph.getNodeById(context.nodeId)
|
||||
if (!sourceNode) return []
|
||||
|
||||
const sourceSlot = context.isInput
|
||||
? sourceNode.inputs?.[context.slotIndex]
|
||||
: sourceNode.outputs?.[context.slotIndex]
|
||||
if (!sourceSlot) return []
|
||||
|
||||
if (isWildcardType(sourceSlot.type)) return []
|
||||
|
||||
const results: CompatibleTarget[] = []
|
||||
|
||||
for (const candidate of graph._nodes) {
|
||||
if (candidate.id === sourceNode.id) continue
|
||||
if (candidate.mode === LGraphEventMode.NEVER) continue
|
||||
|
||||
if (context.isInput) {
|
||||
if (!candidate.outputs) continue
|
||||
for (let i = 0; i < candidate.outputs.length; i++) {
|
||||
const output = candidate.outputs[i]
|
||||
if (isWildcardType(output.type)) continue
|
||||
if (LiteGraph.isValidConnection(output.type, sourceSlot.type)) {
|
||||
results.push({ node: candidate, slotIndex: i, slotInfo: output })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!candidate.inputs) continue
|
||||
for (let i = 0; i < candidate.inputs.length; i++) {
|
||||
const input = candidate.inputs[i]
|
||||
if (input.link != null) continue
|
||||
if (isWildcardType(input.type)) continue
|
||||
if (LiteGraph.isValidConnection(sourceSlot.type, input.type)) {
|
||||
results.push({ node: candidate, slotIndex: i, slotInfo: input })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.sort((a, b) => a.node.pos[1] - b.node.pos[1])
|
||||
return results.slice(0, maxResults)
|
||||
}
|
||||
|
||||
export function renameSlot(context: SlotMenuContext, newLabel: string): void {
|
||||
const graph = app.canvas?.graph
|
||||
if (!graph) return
|
||||
|
||||
const node = graph.getNodeById(context.nodeId)
|
||||
if (!node) return
|
||||
|
||||
const slotInfo = context.isInput
|
||||
? node.getInputInfo(context.slotIndex)
|
||||
: node.getOutputInfo(context.slotIndex)
|
||||
if (!slotInfo) return
|
||||
|
||||
graph.beforeChange()
|
||||
slotInfo.label = newLabel
|
||||
app.canvas?.setDirty(true, true)
|
||||
graph.afterChange()
|
||||
}
|
||||
|
||||
export function canRenameSlot(context: SlotMenuContext): boolean {
|
||||
const graph = app.canvas?.graph
|
||||
if (!graph) return false
|
||||
|
||||
const node = graph.getNodeById(context.nodeId)
|
||||
if (!node) return false
|
||||
|
||||
const slotInfo = context.isInput
|
||||
? node.inputs?.[context.slotIndex]
|
||||
: node.outputs?.[context.slotIndex]
|
||||
if (!slotInfo) return false
|
||||
|
||||
if (slotInfo.nameLocked) return false
|
||||
if (
|
||||
context.isInput &&
|
||||
'link' in slotInfo &&
|
||||
(slotInfo as IWidgetInputSlot).widget
|
||||
)
|
||||
return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function connectSlots(
|
||||
context: SlotMenuContext,
|
||||
target: CompatibleTarget
|
||||
): void {
|
||||
const graph = app.canvas?.graph
|
||||
if (!graph) return
|
||||
|
||||
const sourceNode = graph.getNodeById(context.nodeId)
|
||||
if (!sourceNode) return
|
||||
|
||||
graph.beforeChange()
|
||||
|
||||
if (context.isInput) {
|
||||
target.node.connect(target.slotIndex, sourceNode, context.slotIndex)
|
||||
} else {
|
||||
sourceNode.connect(context.slotIndex, target.node, target.slotIndex)
|
||||
}
|
||||
|
||||
graph.afterChange()
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
export type { SlotMenuContext }
|
||||
@@ -29,6 +29,7 @@ import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import type { Point } from '@/renderer/core/layout/types'
|
||||
import { toPoint } from '@/renderer/core/layout/utils/geometry'
|
||||
import { createSlotLinkDragContext } from '@/renderer/extensions/vueNodes/composables/slotLinkDragContext'
|
||||
import { useAutoPan } from '@/renderer/extensions/vueNodes/composables/useAutoPan'
|
||||
import { augmentToCanvasPointerEvent } from '@/renderer/extensions/vueNodes/utils/eventUtils'
|
||||
import { app } from '@/scripts/app'
|
||||
import { createRafBatch } from '@/utils/rafBatch'
|
||||
@@ -128,6 +129,21 @@ export function useSlotLinkInteraction({
|
||||
// Per-drag drag-state context (non-reactive caches + RAF batching)
|
||||
const dragContext = createSlotLinkDragContext()
|
||||
|
||||
const autoPan = useAutoPan(() => {
|
||||
const data = dragContext.pendingPointerMove
|
||||
const clientX = data?.clientX ?? state.pointer.client.x
|
||||
const clientY = data?.clientY ?? state.pointer.client.y
|
||||
const [canvasX, canvasY] = conversion.clientPosToCanvasPos([
|
||||
clientX,
|
||||
clientY
|
||||
])
|
||||
updatePointerPosition(clientX, clientY, canvasX, canvasY)
|
||||
if (activeAdapter) {
|
||||
activeAdapter.linkConnector.state.snapLinksPos = [canvasX, canvasY]
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
})
|
||||
|
||||
const resolveRenderLinkSource = (link: RenderLink): Point | null => {
|
||||
if (link.fromReroute) {
|
||||
const rerouteLayout = layoutStore.getRerouteLayout(link.fromReroute.id)
|
||||
@@ -286,6 +302,7 @@ export function useSlotLinkInteraction({
|
||||
if (state.pointerId != null) {
|
||||
clearCanvasPointerHistory(state.pointerId)
|
||||
}
|
||||
autoPan.stop()
|
||||
activeAdapter?.reset()
|
||||
pointerSession.clear()
|
||||
endDrag()
|
||||
@@ -416,6 +433,7 @@ export function useSlotLinkInteraction({
|
||||
clientY: event.clientY,
|
||||
target: event.target
|
||||
}
|
||||
autoPan.updatePointer(event.clientX, event.clientY)
|
||||
raf.schedule()
|
||||
}
|
||||
|
||||
|
||||
@@ -42,11 +42,11 @@ export function useTextPreviewWidget(
|
||||
widgetValue.value = typeof value === 'string' ? value : String(value)
|
||||
},
|
||||
getMinHeight: () => options.minHeight ?? 42 + PADDING,
|
||||
serialize: false,
|
||||
read_only: true
|
||||
},
|
||||
type: inputSpec.type
|
||||
})
|
||||
widget.serialize = false
|
||||
addWidget(node, widget)
|
||||
return widget
|
||||
}
|
||||
|
||||
@@ -89,30 +89,6 @@ describe('migrateWidgetsValues', () => {
|
||||
const result = migrateWidgetsValues(inputDefs, widgets, widgetValues)
|
||||
expect(result).toEqual(['first value', 'last value'])
|
||||
})
|
||||
it('should correctly handle seed with unexpected value', () => {
|
||||
const inputDefs: Record<string, InputSpec> = {
|
||||
normalInput: {
|
||||
type: 'INT',
|
||||
name: 'normalInput',
|
||||
control_after_generate: true
|
||||
},
|
||||
forceInputField: {
|
||||
type: 'STRING',
|
||||
name: 'forceInputField',
|
||||
forceInput: true
|
||||
}
|
||||
}
|
||||
|
||||
const widgets = [
|
||||
{ name: 'normalInput', type: 'number' },
|
||||
{ name: 'control_after_generate', type: 'string' }
|
||||
] as Partial<IWidget>[] as IWidget[]
|
||||
|
||||
const widgetValues = [42, 'fixed', 'unexpected widget value']
|
||||
|
||||
const result = migrateWidgetsValues(inputDefs, widgets, widgetValues)
|
||||
expect(result).toEqual([42, 'fixed'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('compressWidgetInputSlots', () => {
|
||||
|
||||
@@ -112,17 +112,23 @@ export function migrateWidgetsValues<TWidgetValue>(
|
||||
const originalWidgetsInputs = Object.values(inputDefs).filter(
|
||||
(input) => widgetNames.has(input.name) || input.forceInput
|
||||
)
|
||||
|
||||
const widgetIndexHasForceInput = originalWidgetsInputs.flatMap((input) =>
|
||||
input.control_after_generate
|
||||
? [!!input.forceInput, false]
|
||||
: [!!input.forceInput]
|
||||
// Count the number of original widgets inputs.
|
||||
const numOriginalWidgets = _.sum(
|
||||
originalWidgetsInputs.map((input) =>
|
||||
// If the input has control, it will have 2 widgets.
|
||||
input.control_after_generate ||
|
||||
['seed', 'noise_seed'].includes(input.name)
|
||||
? 2
|
||||
: 1
|
||||
)
|
||||
)
|
||||
|
||||
if (widgetIndexHasForceInput.length !== widgetsValues?.length)
|
||||
return widgetsValues
|
||||
|
||||
return widgetsValues.filter((_, index) => !widgetIndexHasForceInput[index])
|
||||
if (numOriginalWidgets === widgetsValues?.length) {
|
||||
return _.zip(originalWidgetsInputs, widgetsValues)
|
||||
.filter(([input]) => !input?.forceInput)
|
||||
.map(([_, value]) => value as TWidgetValue)
|
||||
}
|
||||
return widgetsValues
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user