Compare commits

..

3 Commits

Author SHA1 Message Date
bymyself
ee8ded10b8 fix: flatten slot context menu to avoid submenu overflow clipping
Amp-Thread-ID: https://ampcode.com/threads/T-019c3a65-ccea-72d8-855c-0d55837a3d13
2026-02-08 12:57:32 -08:00
bymyself
c1e6d87c3d feat: add 'Rename slot' to slot context menu
Amp-Thread-ID: https://ampcode.com/threads/T-019c3056-108e-7248-9c30-7d236197edcc
2026-02-06 01:34:30 -08:00
bymyself
76373a6eea feat: slot context menu 'Connect to...' and auto-pan during link drag
Add right-click context menu on slot dots with 'Connect to...' submenu
listing compatible existing nodes. Uses Vue/PrimeVue ContextMenu pattern
matching NodeContextMenu.vue. Finds compatible nodes via
LiteGraph.isValidConnection, filters wildcards/bypassed/connected inputs,
sorts by Y position, caps at 15 results.

Add auto-panning when dragging links near canvas edges. Integrated into
useSlotLinkInteraction via useAutoPan composable. Velocity-based rAF
panning that recomputes canvas coordinates after each offset change.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3056-108e-7248-9c30-7d236197edcc
2026-02-06 01:22:35 -08:00
19 changed files with 1306 additions and 223 deletions

View File

@@ -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

View File

@@ -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'

View File

@@ -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}`,

View File

@@ -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')
})
})

View File

@@ -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>

View File

@@ -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)

View File

@@ -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>

View File

@@ -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}",

View File

@@ -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>

View File

@@ -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>

View 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>

View 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()
})
})

View 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 }
}

View File

@@ -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)
})
})

View File

@@ -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 }

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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', () => {

View File

@@ -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
}
/**