mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-03 05:38:26 +00:00
Compare commits
6 Commits
fix/typesc
...
sno-fix-pl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08275af74f | ||
|
|
6ea021d595 | ||
|
|
7245213ed6 | ||
|
|
b72e22f6be | ||
|
|
5f045b335d | ||
|
|
44e470488d |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -51,6 +51,7 @@ tests-ui/workflows/examples
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
browser_tests/**/*-win32.png
|
||||
browser-tests/local/
|
||||
|
||||
.env
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const nodeDefsPath = './src/locales/en/nodeDefs.json'
|
||||
|
||||
test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
// Mock view route
|
||||
comfyPage.page.route('**/view**', async (route) => {
|
||||
await comfyPage.page.route('**/view**', async (route) => {
|
||||
await route.fulfill({
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<template>
|
||||
<Button unstyled :class="buttonStyle" :disabled="disabled" @click="onClick">
|
||||
<Button
|
||||
v-bind="$attrs"
|
||||
unstyled
|
||||
:class="buttonStyle"
|
||||
:disabled="disabled"
|
||||
@click="onClick"
|
||||
>
|
||||
<slot></slot>
|
||||
</Button>
|
||||
</template>
|
||||
@@ -20,6 +26,10 @@ interface IconButtonProps extends BaseButtonProps {
|
||||
onClick: (event: Event) => void
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const {
|
||||
size = 'md',
|
||||
type = 'secondary',
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<template>
|
||||
<Button unstyled :class="buttonStyle" :disabled="disabled" @click="onClick">
|
||||
<Button
|
||||
v-bind="$attrs"
|
||||
unstyled
|
||||
:class="buttonStyle"
|
||||
:disabled="disabled"
|
||||
@click="onClick"
|
||||
>
|
||||
<slot v-if="iconPosition !== 'right'" name="icon"></slot>
|
||||
<span>{{ label }}</span>
|
||||
<slot v-if="iconPosition === 'right'" name="icon"></slot>
|
||||
@@ -18,6 +24,10 @@ import {
|
||||
getButtonTypeClasses
|
||||
} from '@/types/buttonTypes'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
interface IconTextButtonProps extends BaseButtonProps {
|
||||
iconPosition?: 'left' | 'right'
|
||||
label: string
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<template>
|
||||
<Button unstyled :class="buttonStyle" :disabled="disabled" @click="onClick">
|
||||
<Button
|
||||
v-bind="$attrs"
|
||||
unstyled
|
||||
:class="buttonStyle"
|
||||
:disabled="disabled"
|
||||
@click="onClick"
|
||||
>
|
||||
<span>{{ label }}</span>
|
||||
</Button>
|
||||
</template>
|
||||
@@ -21,6 +27,10 @@ interface TextButtonProps extends BaseButtonProps {
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const {
|
||||
size = 'md',
|
||||
type = 'primary',
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<NoResultsPlaceholder
|
||||
class="pb-0"
|
||||
icon="pi pi-exclamation-circle"
|
||||
title="Some Nodes Are Missing"
|
||||
message="When loading the graph, the following node types were not found"
|
||||
:title="$t('loadWorkflowWarning.missingNodesTitle')"
|
||||
:message="$t('loadWorkflowWarning.missingNodesDescription')"
|
||||
/>
|
||||
<MissingCoreNodesMessage :missing-core-nodes="missingCoreNodes" />
|
||||
<ListBox
|
||||
@@ -53,13 +53,16 @@
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import ListBox from 'primevue/listbox'
|
||||
import { computed } from 'vue'
|
||||
import { computed, nextTick, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
|
||||
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
|
||||
import { useManagerState } from '@/composables/useManagerState'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import { ManagerTab } from '@/types/comfyManagerTypes'
|
||||
|
||||
@@ -121,6 +124,35 @@ const openManager = async () => {
|
||||
showToastOnLegacyError: true
|
||||
})
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
// Computed to check if all missing nodes have been installed
|
||||
const allMissingNodesInstalled = computed(() => {
|
||||
return (
|
||||
!isLoading.value &&
|
||||
!isInstalling.value &&
|
||||
missingNodePacks.value?.length === 0
|
||||
)
|
||||
})
|
||||
// Watch for completion and close dialog
|
||||
watch(allMissingNodesInstalled, async (allInstalled) => {
|
||||
if (allInstalled) {
|
||||
// Use nextTick to ensure state updates are complete
|
||||
await nextTick()
|
||||
|
||||
dialogStore.closeDialog({ key: 'global-load-workflow-warning' })
|
||||
|
||||
// Show success toast
|
||||
useToastStore().add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('manager.allMissingNodesInstalled'),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { VueWrapper, mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
@@ -31,11 +32,14 @@ const mockInstalledPacks = {
|
||||
'installed-pack': { ver: '2.0.0' }
|
||||
}
|
||||
|
||||
const mockIsPackEnabled = vi.fn(() => true)
|
||||
|
||||
vi.mock('@/stores/comfyManagerStore', () => ({
|
||||
useComfyManagerStore: vi.fn(() => ({
|
||||
installedPacks: mockInstalledPacks,
|
||||
isPackInstalled: (id: string) =>
|
||||
!!mockInstalledPacks[id as keyof typeof mockInstalledPacks]
|
||||
!!mockInstalledPacks[id as keyof typeof mockInstalledPacks],
|
||||
isPackEnabled: mockIsPackEnabled
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -60,6 +64,7 @@ describe('PackVersionBadge', () => {
|
||||
beforeEach(() => {
|
||||
mockToggle.mockReset()
|
||||
mockHide.mockReset()
|
||||
mockIsPackEnabled.mockReturnValue(true) // Reset to default enabled state
|
||||
})
|
||||
|
||||
const mountComponent = ({
|
||||
@@ -79,6 +84,9 @@ describe('PackVersionBadge', () => {
|
||||
},
|
||||
global: {
|
||||
plugins: [PrimeVue, createPinia(), i18n],
|
||||
directives: {
|
||||
tooltip: Tooltip
|
||||
},
|
||||
stubs: {
|
||||
Popover: PopoverStub,
|
||||
PackVersionSelectorPopover: true
|
||||
@@ -229,4 +237,63 @@ describe('PackVersionBadge', () => {
|
||||
expect(mockHide).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('disabled state', () => {
|
||||
beforeEach(() => {
|
||||
mockIsPackEnabled.mockReturnValue(false) // Set all packs as disabled for these tests
|
||||
})
|
||||
|
||||
it('adds disabled styles when pack is disabled', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const badge = wrapper.find('[role="text"]') // role changes to "text" when disabled
|
||||
expect(badge.exists()).toBe(true)
|
||||
expect(badge.classes()).toContain('cursor-not-allowed')
|
||||
expect(badge.classes()).toContain('opacity-60')
|
||||
})
|
||||
|
||||
it('does not show chevron icon when disabled', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const chevronIcon = wrapper.find('.pi-chevron-right')
|
||||
expect(chevronIcon.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not show update arrow when disabled', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const updateIcon = wrapper.find('.pi-arrow-circle-up')
|
||||
expect(updateIcon.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not toggle popover when clicked while disabled', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const badge = wrapper.find('[role="text"]') // role changes to "text" when disabled
|
||||
expect(badge.exists()).toBe(true)
|
||||
await badge.trigger('click')
|
||||
|
||||
// Since it's disabled, the popover should not be toggled
|
||||
expect(mockToggle).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('has correct tabindex when disabled', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const badge = wrapper.find('[role="text"]') // role changes to "text" when disabled
|
||||
expect(badge.exists()).toBe(true)
|
||||
expect(badge.attributes('tabindex')).toBe('-1')
|
||||
})
|
||||
|
||||
it('does not respond to keyboard events when disabled', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const badge = wrapper.find('[role="text"]') // role changes to "text" when disabled
|
||||
expect(badge.exists()).toBe(true)
|
||||
await badge.trigger('keydown.enter')
|
||||
await badge.trigger('keydown.space')
|
||||
|
||||
expect(mockToggle).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="inline-flex items-center gap-1 rounded-2xl text-xs cursor-pointer py-1"
|
||||
:class="{ 'bg-gray-100 dark-theme:bg-neutral-700 px-1.5': fill }"
|
||||
aria-haspopup="true"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="toggleVersionSelector"
|
||||
@keydown.enter="toggleVersionSelector"
|
||||
@keydown.space="toggleVersionSelector"
|
||||
v-tooltip.top="
|
||||
isDisabled ? $t('manager.enablePackToChangeVersion') : null
|
||||
"
|
||||
class="inline-flex items-center gap-1 rounded-2xl text-xs py-1"
|
||||
:class="{
|
||||
'bg-gray-100 dark-theme:bg-neutral-700 px-1.5': fill,
|
||||
'cursor-pointer': !isDisabled,
|
||||
'cursor-not-allowed opacity-60': isDisabled
|
||||
}"
|
||||
:aria-haspopup="!isDisabled"
|
||||
:role="isDisabled ? 'text' : 'button'"
|
||||
:tabindex="isDisabled ? -1 : 0"
|
||||
@click="!isDisabled && toggleVersionSelector($event)"
|
||||
@keydown.enter="!isDisabled && toggleVersionSelector($event)"
|
||||
@keydown.space="!isDisabled && toggleVersionSelector($event)"
|
||||
>
|
||||
<i
|
||||
v-if="isUpdateAvailable"
|
||||
class="pi pi-arrow-circle-up text-blue-600 text-xs"
|
||||
/>
|
||||
<span>{{ installedVersion }}</span>
|
||||
<i class="pi pi-chevron-right text-xxs" />
|
||||
<i v-if="!isDisabled" class="pi pi-chevron-right text-xxs" />
|
||||
</div>
|
||||
|
||||
<Popover
|
||||
@@ -61,6 +68,11 @@ const popoverRef = ref()
|
||||
|
||||
const managerStore = useComfyManagerStore()
|
||||
|
||||
const isInstalled = computed(() => managerStore.isPackInstalled(nodePack?.id))
|
||||
const isDisabled = computed(
|
||||
() => isInstalled.value && !managerStore.isPackEnabled(nodePack?.id)
|
||||
)
|
||||
|
||||
const installedVersion = computed(() => {
|
||||
if (!nodePack.id) return 'nightly'
|
||||
const version =
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<IconTextButton
|
||||
v-tooltip.top="
|
||||
hasDisabledUpdatePacks ? $t('manager.disabledNodesWontUpdate') : null
|
||||
"
|
||||
v-bind="$attrs"
|
||||
type="transparent"
|
||||
:label="$t('manager.updateAll')"
|
||||
@@ -24,8 +27,9 @@ import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
type NodePack = components['schemas']['Node']
|
||||
|
||||
const { nodePacks } = defineProps<{
|
||||
const { nodePacks, hasDisabledUpdatePacks } = defineProps<{
|
||||
nodePacks: NodePack[]
|
||||
hasDisabledUpdatePacks?: boolean
|
||||
}>()
|
||||
|
||||
const isUpdating = ref<boolean>(false)
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
/>
|
||||
<PackUpdateButton
|
||||
v-if="isUpdateAvailableTab && hasUpdateAvailable"
|
||||
:node-packs="updateAvailableNodePacks"
|
||||
:node-packs="enabledUpdateAvailableNodePacks"
|
||||
:has-disabled-update-packs="hasDisabledUpdatePacks"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex mt-3 text-sm">
|
||||
@@ -103,8 +104,11 @@ const { t } = useI18n()
|
||||
const { missingNodePacks, isLoading, error } = useMissingNodes()
|
||||
|
||||
// Use the composable to get update available nodes
|
||||
const { hasUpdateAvailable, updateAvailableNodePacks } =
|
||||
useUpdateAvailableNodes()
|
||||
const {
|
||||
hasUpdateAvailable,
|
||||
enabledUpdateAvailableNodePacks,
|
||||
hasDisabledUpdatePacks
|
||||
} = useUpdateAvailableNodes()
|
||||
|
||||
const hasResults = computed(
|
||||
() => searchQuery.value?.trim() && searchResults?.length
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
v-if="isVueNodesEnabled && comfyApp.canvas && comfyAppReady"
|
||||
:canvas="comfyApp.canvas"
|
||||
@transform-update="handleTransformUpdate"
|
||||
@wheel.capture="canvasInteractions.forwardEventToCanvas"
|
||||
>
|
||||
<!-- Vue nodes rendered based on graph nodes -->
|
||||
<VueGraphNode
|
||||
@@ -96,6 +97,7 @@ import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vu
|
||||
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
|
||||
import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
|
||||
import { useViewportCulling } from '@/composables/graph/useViewportCulling'
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import { useNodeBadge } from '@/composables/node/useNodeBadge'
|
||||
@@ -147,6 +149,8 @@ const workspaceStore = useWorkspaceStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const toastStore = useToastStore()
|
||||
const canvasInteractions = useCanvasInteractions()
|
||||
|
||||
const betaMenuEnabled = computed(
|
||||
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
|
||||
)
|
||||
|
||||
@@ -3,8 +3,12 @@ import type { Ref } from 'vue'
|
||||
|
||||
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
import { createBounds } from '@/lib/litegraph/src/litegraph'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { computeUnionBounds } from '@/utils/mathUtil'
|
||||
|
||||
/**
|
||||
* Manages the position of the selection toolbox independently.
|
||||
@@ -16,6 +20,7 @@ export function useSelectionToolboxPosition(
|
||||
const canvasStore = useCanvasStore()
|
||||
const lgCanvas = canvasStore.getCanvas()
|
||||
const { getSelectableItems } = useSelectedLiteGraphItems()
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
|
||||
// World position of selection center
|
||||
const worldPosition = ref({ x: 0, y: 0 })
|
||||
@@ -34,17 +39,40 @@ export function useSelectionToolboxPosition(
|
||||
}
|
||||
|
||||
visible.value = true
|
||||
const bounds = createBounds(selectableItems)
|
||||
|
||||
if (!bounds) {
|
||||
return
|
||||
// Get bounds for all selected items
|
||||
const allBounds: ReadOnlyRect[] = []
|
||||
for (const item of selectableItems) {
|
||||
// Skip items without valid IDs
|
||||
if (item.id == null) continue
|
||||
|
||||
if (shouldRenderVueNodes.value && typeof item.id === 'string') {
|
||||
// Use layout store for Vue nodes (only works with string IDs)
|
||||
const layout = layoutStore.getNodeLayoutRef(item.id).value
|
||||
if (layout) {
|
||||
allBounds.push([
|
||||
layout.bounds.x,
|
||||
layout.bounds.y,
|
||||
layout.bounds.width,
|
||||
layout.bounds.height
|
||||
])
|
||||
}
|
||||
} else {
|
||||
// Fallback to LiteGraph bounds for regular nodes or non-string IDs
|
||||
if (item instanceof LGraphNode) {
|
||||
const bounds = item.getBounding()
|
||||
allBounds.push([bounds[0], bounds[1], bounds[2], bounds[3]] as const)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [xBase, y, width] = bounds
|
||||
// Compute union bounds
|
||||
const unionBounds = computeUnionBounds(allBounds)
|
||||
if (!unionBounds) return
|
||||
|
||||
worldPosition.value = {
|
||||
x: xBase + width / 2,
|
||||
y: y
|
||||
x: unionBounds.x + unionBounds.width / 2,
|
||||
y: unionBounds.y - 10
|
||||
}
|
||||
|
||||
updateTransform()
|
||||
|
||||
@@ -24,14 +24,12 @@ export function useCanvasInteractions() {
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
// In standard mode, Ctrl+wheel should go to canvas for zoom
|
||||
if (isStandardNavMode.value && (event.ctrlKey || event.metaKey)) {
|
||||
event.preventDefault() // Prevent browser zoom
|
||||
forwardEventToCanvas(event)
|
||||
return
|
||||
}
|
||||
|
||||
// In legacy mode, all wheel events go to canvas for zoom
|
||||
if (!isStandardNavMode.value) {
|
||||
event.preventDefault()
|
||||
forwardEventToCanvas(event)
|
||||
return
|
||||
}
|
||||
@@ -68,9 +66,30 @@ export function useCanvasInteractions() {
|
||||
) => {
|
||||
const canvasEl = app.canvas?.canvas
|
||||
if (!canvasEl) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
if (event instanceof WheelEvent) {
|
||||
const { clientX, clientY, deltaX, deltaY, ctrlKey, metaKey, shiftKey } =
|
||||
event
|
||||
canvasEl.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
clientX,
|
||||
clientY,
|
||||
deltaX,
|
||||
deltaY,
|
||||
ctrlKey,
|
||||
metaKey,
|
||||
shiftKey
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Create new event with same properties
|
||||
const EventConstructor = event.constructor as typeof WheelEvent
|
||||
const EventConstructor = event.constructor as
|
||||
| typeof MouseEvent
|
||||
| typeof PointerEvent
|
||||
const newEvent = new EventConstructor(event.type, event)
|
||||
canvasEl.dispatchEvent(newEvent)
|
||||
}
|
||||
|
||||
@@ -44,9 +44,24 @@ export const useUpdateAvailableNodes = () => {
|
||||
return filterOutdatedPacks(installedPacks.value)
|
||||
})
|
||||
|
||||
// Check if there are any outdated packs
|
||||
// Filter only enabled outdated packs
|
||||
const enabledUpdateAvailableNodePacks = computed(() => {
|
||||
return updateAvailableNodePacks.value.filter((pack) =>
|
||||
comfyManagerStore.isPackEnabled(pack.id)
|
||||
)
|
||||
})
|
||||
|
||||
// Check if there are any enabled outdated packs
|
||||
const hasUpdateAvailable = computed(() => {
|
||||
return updateAvailableNodePacks.value.length > 0
|
||||
return enabledUpdateAvailableNodePacks.value.length > 0
|
||||
})
|
||||
|
||||
// Check if there are disabled packs with updates
|
||||
const hasDisabledUpdatePacks = computed(() => {
|
||||
return (
|
||||
updateAvailableNodePacks.value.length >
|
||||
enabledUpdateAvailableNodePacks.value.length
|
||||
)
|
||||
})
|
||||
|
||||
// Automatically fetch installed pack data when composable is used
|
||||
@@ -58,7 +73,9 @@ export const useUpdateAvailableNodes = () => {
|
||||
|
||||
return {
|
||||
updateAvailableNodePacks,
|
||||
enabledUpdateAvailableNodePacks,
|
||||
hasUpdateAvailable,
|
||||
hasDisabledUpdatePacks,
|
||||
isLoading,
|
||||
error
|
||||
}
|
||||
|
||||
@@ -36,14 +36,7 @@ interface CivitaiModelVersionResponse {
|
||||
model: CivitaiModel
|
||||
modelId: number
|
||||
files: CivitaiModelFile[]
|
||||
// Common optional Civitai API fields
|
||||
createdAt?: string
|
||||
publishedAt?: string
|
||||
trainedWords?: string[]
|
||||
stats?: Record<string, number>
|
||||
description?: string
|
||||
// Allow additional API evolution
|
||||
[key: string]: string | number | boolean | object | undefined
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -220,9 +220,9 @@ export function useConflictDetection() {
|
||||
abortController.value?.signal
|
||||
)
|
||||
|
||||
if (bulkResponse?.node_versions) {
|
||||
if (bulkResponse && bulkResponse.node_versions) {
|
||||
// Process bulk response
|
||||
bulkResponse?.node_versions?.forEach((result) => {
|
||||
bulkResponse.node_versions.forEach((result) => {
|
||||
if (result.status === 'success' && result.node_version) {
|
||||
versionDataMap.set(
|
||||
result.identifier.node_id,
|
||||
@@ -265,7 +265,7 @@ export function useConflictDetection() {
|
||||
const requirement: NodePackRequirements = {
|
||||
// Basic package info
|
||||
id: packageId,
|
||||
name: packInfo?.name ?? packageId,
|
||||
name: packInfo?.name || packageId,
|
||||
installed_version: installedVersion,
|
||||
is_enabled: isEnabled,
|
||||
|
||||
@@ -291,7 +291,7 @@ export function useConflictDetection() {
|
||||
// Create fallback requirement without Registry data
|
||||
const fallbackRequirement: NodePackRequirements = {
|
||||
id: packageId,
|
||||
name: packInfo?.name ?? packageId,
|
||||
name: packInfo?.name || packageId,
|
||||
installed_version: installedVersion,
|
||||
is_enabled: isEnabled,
|
||||
is_banned: false,
|
||||
@@ -326,9 +326,7 @@ export function useConflictDetection() {
|
||||
const conflicts: ConflictDetail[] = []
|
||||
|
||||
// Helper function to check if a value indicates "compatible with all"
|
||||
const isCompatibleWithAll = (
|
||||
value: string | string[] | null | undefined
|
||||
): boolean => {
|
||||
const isCompatibleWithAll = (value: any): boolean => {
|
||||
if (value === null || value === undefined) return true
|
||||
if (typeof value === 'string' && value.trim() === '') return true
|
||||
if (Array.isArray(value) && value.length === 0) return true
|
||||
|
||||
@@ -8,11 +8,6 @@ import type {
|
||||
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
interface ContextMenuExtraInfo {
|
||||
inputs?: INodeInputSlot[]
|
||||
widgets?: IWidget[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Add translation for litegraph context menu.
|
||||
*/
|
||||
@@ -55,20 +50,18 @@ export const useContextMenuTranslation = () => {
|
||||
}
|
||||
|
||||
// for capture translation text of input and widget
|
||||
const extraInfo: ContextMenuExtraInfo | undefined =
|
||||
(options.extra as ContextMenuExtraInfo) ||
|
||||
(options.parentMenu?.options?.extra as ContextMenuExtraInfo)
|
||||
const extraInfo: any = options.extra || options.parentMenu?.options?.extra
|
||||
// widgets and inputs
|
||||
const matchInput = value.content?.match(reInput)
|
||||
if (matchInput) {
|
||||
let match = matchInput[1]
|
||||
extraInfo?.inputs?.find((i: INodeInputSlot) => {
|
||||
if (i.name != match) return false
|
||||
match = i.label ?? i.name
|
||||
match = i.label ? i.label : i.name
|
||||
})
|
||||
extraInfo?.widgets?.find((i: IWidget) => {
|
||||
if (i.name != match) return false
|
||||
match = i.label ?? i.name
|
||||
match = i.label ? i.label : i.name
|
||||
})
|
||||
value.content = cvt + match + tinp
|
||||
continue
|
||||
@@ -78,11 +71,11 @@ export const useContextMenuTranslation = () => {
|
||||
let match = matchWidget[1]
|
||||
extraInfo?.inputs?.find((i: INodeInputSlot) => {
|
||||
if (i.name != match) return false
|
||||
match = i.label ?? i.name
|
||||
match = i.label ? i.label : i.name
|
||||
})
|
||||
extraInfo?.widgets?.find((i: IWidget) => {
|
||||
if (i.name != match) return false
|
||||
match = i.label ?? i.name
|
||||
match = i.label ? i.label : i.name
|
||||
})
|
||||
value.content = cvt + match + twgt
|
||||
continue
|
||||
|
||||
@@ -30,7 +30,7 @@ import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
import { type ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
|
||||
@@ -106,7 +106,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
menubarLabel: 'Save',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
const workflow = useWorkflowStore().activeWorkflow
|
||||
const workflow = useWorkflowStore().activeWorkflow as ComfyWorkflow
|
||||
if (!workflow) return
|
||||
|
||||
await workflowService.saveWorkflow(workflow)
|
||||
@@ -128,7 +128,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
menubarLabel: 'Save As',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
const workflow = useWorkflowStore().activeWorkflow
|
||||
const workflow = useWorkflowStore().activeWorkflow as ComfyWorkflow
|
||||
if (!workflow) return
|
||||
|
||||
await workflowService.saveWorkflowAs(workflow)
|
||||
|
||||
@@ -41,7 +41,7 @@ export function useDownload(url: string, fileName?: string) {
|
||||
link.href = downloadUrlToHfRepoUrl(url)
|
||||
} else {
|
||||
link.href = url
|
||||
link.download = fileName ?? url.split('/').pop() ?? 'download'
|
||||
link.download = fileName || url.split('/').pop() || 'download'
|
||||
}
|
||||
link.target = '_blank' // Opens in new tab if download attribute is not supported
|
||||
link.rel = 'noopener noreferrer' // Security best practice for _blank links
|
||||
|
||||
@@ -13,7 +13,7 @@ export function useErrorHandling() {
|
||||
}
|
||||
|
||||
const wrapWithErrorHandling =
|
||||
<TArgs extends readonly unknown[], TReturn>(
|
||||
<TArgs extends any[], TReturn>(
|
||||
action: (...args: TArgs) => TReturn,
|
||||
errorHandler?: (error: any) => void,
|
||||
finallyHandler?: () => void
|
||||
@@ -29,7 +29,7 @@ export function useErrorHandling() {
|
||||
}
|
||||
|
||||
const wrapWithErrorHandlingAsync =
|
||||
<TArgs extends readonly unknown[], TReturn>(
|
||||
<TArgs extends any[], TReturn>(
|
||||
action: (...args: TArgs) => Promise<TReturn> | TReturn,
|
||||
errorHandler?: (error: any) => void,
|
||||
finallyHandler?: () => void
|
||||
|
||||
@@ -3,7 +3,6 @@ import { ref, toRaw, watch } from 'vue'
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import {
|
||||
CameraState,
|
||||
CameraType,
|
||||
MaterialMode,
|
||||
UpDirection
|
||||
@@ -19,7 +18,7 @@ interface Load3dViewerState {
|
||||
cameraType: CameraType
|
||||
fov: number
|
||||
lightIntensity: number
|
||||
cameraState: CameraState | null
|
||||
cameraState: any
|
||||
backgroundImage: string
|
||||
upDirection: UpDirection
|
||||
materialMode: MaterialMode
|
||||
@@ -275,9 +274,7 @@ export const useLoad3dViewer = (node: LGraphNode) => {
|
||||
nodeValue.properties['FOV'] = initialState.value.fov
|
||||
nodeValue.properties['Light Intensity'] =
|
||||
initialState.value.lightIntensity
|
||||
if (initialState.value.cameraState) {
|
||||
nodeValue.properties['Camera Info'] = initialState.value.cameraState
|
||||
}
|
||||
nodeValue.properties['Camera Info'] = initialState.value.cameraState
|
||||
nodeValue.properties['Background Image'] =
|
||||
initialState.value.backgroundImage
|
||||
}
|
||||
|
||||
@@ -404,8 +404,8 @@ export class LGraphNode
|
||||
selected?: boolean
|
||||
showAdvanced?: boolean
|
||||
|
||||
declare comfyClass?: string
|
||||
declare isVirtualNode?: boolean
|
||||
comfyClass?: string
|
||||
isVirtualNode?: boolean
|
||||
applyToGraph?(extraLinks?: LLink[]): void
|
||||
|
||||
isSubgraphNode(): this is SubgraphNode {
|
||||
|
||||
@@ -14,7 +14,9 @@ import type {
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { Point } from '@/lib/litegraph/src/interfaces'
|
||||
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
|
||||
import type { SubgraphInputNode } from '@/lib/litegraph/src/subgraph/SubgraphInputNode'
|
||||
import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput'
|
||||
import type { SubgraphOutputNode } from '@/lib/litegraph/src/subgraph/SubgraphOutputNode'
|
||||
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
|
||||
|
||||
import type { RenderLink } from './RenderLink'
|
||||
@@ -175,7 +177,9 @@ export class FloatingRenderLink implements RenderLink {
|
||||
): void {
|
||||
const floatingLink = this.link
|
||||
floatingLink.origin_id = SUBGRAPH_INPUT_ID
|
||||
floatingLink.origin_slot = input.parent.slots.indexOf(input)
|
||||
floatingLink.origin_slot = (
|
||||
input.parent as SubgraphInputNode
|
||||
).slots.indexOf(input)
|
||||
|
||||
this.fromSlot._floatingLinks?.delete(floatingLink)
|
||||
input._floatingLinks ??= new Set()
|
||||
@@ -188,7 +192,9 @@ export class FloatingRenderLink implements RenderLink {
|
||||
): void {
|
||||
const floatingLink = this.link
|
||||
floatingLink.origin_id = SUBGRAPH_OUTPUT_ID
|
||||
floatingLink.origin_slot = output.parent.slots.indexOf(output)
|
||||
floatingLink.origin_slot = (
|
||||
output.parent as SubgraphOutputNode
|
||||
).slots.indexOf(output)
|
||||
|
||||
this.fromSlot._floatingLinks?.delete(floatingLink)
|
||||
output._floatingLinks ??= new Set()
|
||||
|
||||
@@ -12,8 +12,6 @@ import type { SubgraphInputNode } from './SubgraphInputNode'
|
||||
* A virtual slot that simply creates a new input slot when connected to.
|
||||
*/
|
||||
export class EmptySubgraphInput extends SubgraphInput {
|
||||
declare parent: SubgraphInputNode
|
||||
|
||||
constructor(parent: SubgraphInputNode) {
|
||||
super(
|
||||
{
|
||||
@@ -30,7 +28,7 @@ export class EmptySubgraphInput extends SubgraphInput {
|
||||
node: LGraphNode,
|
||||
afterRerouteId?: RerouteId
|
||||
): LLink | undefined {
|
||||
const { subgraph } = this.parent
|
||||
const { subgraph } = this.parent as SubgraphInputNode
|
||||
const existingNames = subgraph.inputs.map((x) => x.name)
|
||||
|
||||
const name = nextUniqueName(slot.name, existingNames)
|
||||
|
||||
@@ -12,8 +12,6 @@ import type { SubgraphOutputNode } from './SubgraphOutputNode'
|
||||
* A virtual slot that simply creates a new output slot when connected to.
|
||||
*/
|
||||
export class EmptySubgraphOutput extends SubgraphOutput {
|
||||
declare parent: SubgraphOutputNode
|
||||
|
||||
constructor(parent: SubgraphOutputNode) {
|
||||
super(
|
||||
{
|
||||
@@ -30,7 +28,7 @@ export class EmptySubgraphOutput extends SubgraphOutput {
|
||||
node: LGraphNode,
|
||||
afterRerouteId?: RerouteId
|
||||
): LLink | undefined {
|
||||
const { subgraph } = this.parent
|
||||
const { subgraph } = this.parent as SubgraphOutputNode
|
||||
const existingNames = subgraph.outputs.map((x) => x.name)
|
||||
|
||||
const name = nextUniqueName(slot.name, existingNames)
|
||||
|
||||
@@ -30,8 +30,6 @@ import { isNodeSlot, isSubgraphOutput } from './subgraphUtils'
|
||||
* Functionally, however, when editing a subgraph, that "subgraph input" is the "origin" or "output side" of a link.
|
||||
*/
|
||||
export class SubgraphInput extends SubgraphSlot {
|
||||
declare parent: SubgraphInputNode
|
||||
|
||||
events = new CustomEventTarget<SubgraphInputEventMap>()
|
||||
|
||||
/** The linked widget that this slot is connected to. */
|
||||
@@ -50,13 +48,13 @@ export class SubgraphInput extends SubgraphSlot {
|
||||
node: LGraphNode,
|
||||
afterRerouteId?: RerouteId
|
||||
): LLink | undefined {
|
||||
const { subgraph } = this.parent
|
||||
const parent = this.parent as SubgraphInputNode
|
||||
const { subgraph } = parent
|
||||
|
||||
// Allow nodes to block connection
|
||||
const inputIndex = node.inputs.indexOf(slot)
|
||||
if (
|
||||
node.onConnectInput?.(inputIndex, this.type, this, this.parent, -1) ===
|
||||
false
|
||||
node.onConnectInput?.(inputIndex, this.type, this, parent, -1) === false
|
||||
)
|
||||
return
|
||||
|
||||
@@ -77,7 +75,7 @@ export class SubgraphInput extends SubgraphSlot {
|
||||
if (slot.link != null) {
|
||||
subgraph.beforeChange()
|
||||
const link = subgraph.getLink(slot.link)
|
||||
this.parent._disconnectNodeInput(node, slot, link)
|
||||
parent._disconnectNodeInput(node, slot, link)
|
||||
}
|
||||
|
||||
const inputWidget = node.getWidgetFromSlot(slot)
|
||||
@@ -97,8 +95,8 @@ export class SubgraphInput extends SubgraphSlot {
|
||||
const link = new LLink(
|
||||
++subgraph.state.lastLinkId,
|
||||
slot.type,
|
||||
this.parent.id,
|
||||
this.parent.slots.indexOf(this),
|
||||
parent.id,
|
||||
parent.slots.indexOf(this),
|
||||
node.id,
|
||||
inputIndex,
|
||||
afterRerouteId
|
||||
|
||||
@@ -35,7 +35,8 @@ import type { SubgraphInput } from './SubgraphInput'
|
||||
* An instance of a {@link Subgraph}, displayed as a node on the containing (parent) graph.
|
||||
*/
|
||||
export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
declare inputs: (INodeInputSlot & Partial<ISubgraphInput>)[]
|
||||
// Override inputs with proper typing for subgraph inputs
|
||||
override inputs: (INodeInputSlot & Partial<ISubgraphInput>)[] = []
|
||||
|
||||
override readonly type: UUID
|
||||
override readonly isVirtualNode = true as const
|
||||
|
||||
@@ -29,14 +29,13 @@ import { isNodeSlot, isSubgraphInput } from './subgraphUtils'
|
||||
* Functionally, however, when editing a subgraph, that "subgraph output" is the "target" or "input side" of a link.
|
||||
*/
|
||||
export class SubgraphOutput extends SubgraphSlot {
|
||||
declare parent: SubgraphOutputNode
|
||||
|
||||
override connect(
|
||||
slot: INodeOutputSlot,
|
||||
node: LGraphNode,
|
||||
afterRerouteId?: RerouteId
|
||||
): LLink | undefined {
|
||||
const { subgraph } = this.parent
|
||||
const parent = this.parent as SubgraphOutputNode
|
||||
const { subgraph } = parent
|
||||
|
||||
// Validate type compatibility
|
||||
if (!LiteGraph.isValidConnection(slot.type, this.type)) return
|
||||
@@ -47,8 +46,7 @@ export class SubgraphOutput extends SubgraphSlot {
|
||||
throw new Error('Slot is not an output of the given node')
|
||||
|
||||
if (
|
||||
node.onConnectOutput?.(outputIndex, this.type, this, this.parent, -1) ===
|
||||
false
|
||||
node.onConnectOutput?.(outputIndex, this.type, this, parent, -1) === false
|
||||
)
|
||||
return
|
||||
|
||||
@@ -68,8 +66,8 @@ export class SubgraphOutput extends SubgraphSlot {
|
||||
slot.type,
|
||||
node.id,
|
||||
outputIndex,
|
||||
this.parent.id,
|
||||
this.parent.slots.indexOf(this),
|
||||
parent.id,
|
||||
parent.slots.indexOf(this),
|
||||
afterRerouteId
|
||||
)
|
||||
|
||||
|
||||
@@ -47,8 +47,8 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
/** Minimum gap between label and value */
|
||||
static labelValueGap = 5
|
||||
|
||||
declare computedHeight?: number
|
||||
declare serialize?: boolean
|
||||
computedHeight?: number
|
||||
serialize?: boolean
|
||||
computeLayoutSize?(node: LGraphNode): {
|
||||
minHeight: number
|
||||
maxHeight?: number
|
||||
|
||||
@@ -193,6 +193,8 @@
|
||||
"updateSelected": "Update Selected",
|
||||
"updateAll": "Update All",
|
||||
"updatingAllPacks": "Updating all packages",
|
||||
"disabledNodesWontUpdate": "Disabled nodes will not be updated",
|
||||
"enablePackToChangeVersion": "Enable this pack to change versions",
|
||||
"license": "License",
|
||||
"nightlyVersion": "Nightly",
|
||||
"latestVersion": "Latest",
|
||||
@@ -211,6 +213,7 @@
|
||||
"noDescription": "No description available",
|
||||
"installSelected": "Install Selected",
|
||||
"installAllMissingNodes": "Install All Missing Nodes",
|
||||
"allMissingNodesInstalled": "All missing nodes have been successfully installed",
|
||||
"packsSelected": "packs selected",
|
||||
"mixedSelectionMessage": "Cannot perform bulk action on mixed selection",
|
||||
"notAvailable": "Not Available",
|
||||
@@ -1470,6 +1473,8 @@
|
||||
"missingModelsMessage": "When loading the graph, the following models were not found"
|
||||
},
|
||||
"loadWorkflowWarning": {
|
||||
"missingNodesTitle": "Some Nodes Are Missing",
|
||||
"missingNodesDescription": "When loading the graph, the following node types were not found.\nThis may also happen if your installed version is lower and that node type can’t be found.",
|
||||
"outdatedVersion": "Some nodes require a newer version of ComfyUI (current: {version}). Please update to use all nodes.",
|
||||
"outdatedVersionGeneric": "Some nodes require a newer version of ComfyUI. Please update to use all nodes.",
|
||||
"coreNodesFromVersion": "Requires ComfyUI {version}:"
|
||||
@@ -1757,6 +1762,9 @@
|
||||
"copiedTooltip": "Copied",
|
||||
"copyTooltip": "Copy message to clipboard"
|
||||
},
|
||||
"widgets": {
|
||||
"selectModel": "Select model"
|
||||
},
|
||||
"nodeHelpPage": {
|
||||
"inputs": "Inputs",
|
||||
"outputs": "Outputs",
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
LayoutOperation,
|
||||
MoveNodeOperation,
|
||||
MoveRerouteOperation,
|
||||
NodeBoundsUpdate,
|
||||
ResizeNodeOperation,
|
||||
SetNodeZIndexOperation
|
||||
} from '@/renderer/core/layout/types'
|
||||
@@ -1425,6 +1426,31 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
getStateAsUpdate(): Uint8Array {
|
||||
return Y.encodeStateAsUpdate(this.ydoc)
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch update node bounds using Yjs transaction for atomicity.
|
||||
*/
|
||||
batchUpdateNodeBounds(updates: NodeBoundsUpdate[]): void {
|
||||
if (updates.length === 0) return
|
||||
|
||||
// Set source to Vue for these DOM-driven updates
|
||||
const originalSource = this.currentSource
|
||||
this.currentSource = LayoutSource.Vue
|
||||
|
||||
this.ydoc.transact(() => {
|
||||
for (const { nodeId, bounds } of updates) {
|
||||
const ynode = this.ynodes.get(nodeId)
|
||||
if (!ynode) continue
|
||||
|
||||
this.spatialIndex.update(nodeId, bounds)
|
||||
ynode.set('bounds', bounds)
|
||||
ynode.set('size', { width: bounds.width, height: bounds.height })
|
||||
}
|
||||
}, this.currentActor)
|
||||
|
||||
// Restore original source
|
||||
this.currentSource = originalSource
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
|
||||
@@ -31,6 +31,11 @@ export interface Bounds {
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface NodeBoundsUpdate {
|
||||
nodeId: NodeId
|
||||
bounds: Bounds
|
||||
}
|
||||
|
||||
export type NodeId = string
|
||||
export type LinkId = number
|
||||
export type RerouteId = number
|
||||
@@ -320,4 +325,9 @@ export interface LayoutStore {
|
||||
setActor(actor: string): void
|
||||
getCurrentSource(): LayoutSource
|
||||
getCurrentActor(): string
|
||||
|
||||
// Batch updates
|
||||
batchUpdateNodeBounds(
|
||||
updates: Array<{ nodeId: NodeId; bounds: Bounds }>
|
||||
): void
|
||||
}
|
||||
|
||||
@@ -113,7 +113,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onErrorCaptured, ref, toRef, watch } from 'vue'
|
||||
|
||||
// Import the VueNodeData type
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -122,6 +121,7 @@ import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayo
|
||||
import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useVueElementTracking } from '../composables/useVueNodeResizeTracking'
|
||||
import NodeContent from './NodeContent.vue'
|
||||
import NodeHeader from './NodeHeader.vue'
|
||||
import NodeSlots from './NodeSlots.vue'
|
||||
@@ -154,6 +154,8 @@ const emit = defineEmits<{
|
||||
'update:title': [nodeId: string, newTitle: string]
|
||||
}>()
|
||||
|
||||
useVueElementTracking(props.nodeData.id, 'node')
|
||||
|
||||
// Inject selection state from parent
|
||||
const selectedNodeIds = inject(SelectedNodeIdsKey)
|
||||
if (!selectedNodeIds) {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Generic Vue Element Tracking System
|
||||
*
|
||||
* Automatically tracks DOM size and position changes for Vue-rendered elements
|
||||
* and syncs them to the layout store. Uses a single shared ResizeObserver for
|
||||
* performance, with elements identified by configurable data attributes.
|
||||
*
|
||||
* Supports different element types (nodes, slots, widgets, etc.) with
|
||||
* customizable data attributes and update handlers.
|
||||
*/
|
||||
import { getCurrentInstance, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import type { Bounds, NodeId } from '@/renderer/core/layout/types'
|
||||
|
||||
/**
|
||||
* Generic update item for element bounds tracking
|
||||
*/
|
||||
interface ElementBoundsUpdate {
|
||||
/** Element identifier (could be nodeId, widgetId, slotId, etc.) */
|
||||
id: string
|
||||
/** Updated bounds */
|
||||
bounds: Bounds
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for different types of tracked elements
|
||||
*/
|
||||
interface ElementTrackingConfig {
|
||||
/** Data attribute name (e.g., 'nodeId') */
|
||||
dataAttribute: string
|
||||
/** Handler for processing bounds updates */
|
||||
updateHandler: (updates: ElementBoundsUpdate[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of tracking configurations by element type
|
||||
*/
|
||||
const trackingConfigs: Map<string, ElementTrackingConfig> = new Map([
|
||||
[
|
||||
'node',
|
||||
{
|
||||
dataAttribute: 'nodeId',
|
||||
updateHandler: (updates) => {
|
||||
const nodeUpdates = updates.map(({ id, bounds }) => ({
|
||||
nodeId: id as NodeId,
|
||||
bounds
|
||||
}))
|
||||
layoutStore.batchUpdateNodeBounds(nodeUpdates)
|
||||
}
|
||||
}
|
||||
]
|
||||
])
|
||||
|
||||
// Single ResizeObserver instance for all Vue elements
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
// Group updates by element type
|
||||
const updatesByType = new Map<string, ElementBoundsUpdate[]>()
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!(entry.target instanceof HTMLElement)) continue
|
||||
const element = entry.target
|
||||
|
||||
// Find which type this element belongs to
|
||||
let elementType: string | undefined
|
||||
let elementId: string | undefined
|
||||
|
||||
for (const [type, config] of trackingConfigs) {
|
||||
const id = element.dataset[config.dataAttribute]
|
||||
if (id) {
|
||||
elementType = type
|
||||
elementId = id
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!elementType || !elementId) continue
|
||||
|
||||
const { inlineSize: width, blockSize: height } = entry.contentBoxSize[0]
|
||||
const rect = element.getBoundingClientRect()
|
||||
|
||||
const bounds: Bounds = {
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
width,
|
||||
height: height
|
||||
}
|
||||
|
||||
if (!updatesByType.has(elementType)) {
|
||||
updatesByType.set(elementType, [])
|
||||
}
|
||||
const updates = updatesByType.get(elementType)
|
||||
if (updates) {
|
||||
updates.push({ id: elementId, bounds })
|
||||
}
|
||||
}
|
||||
|
||||
// Process updates by type
|
||||
for (const [type, updates] of updatesByType) {
|
||||
const config = trackingConfigs.get(type)
|
||||
if (config && updates.length > 0) {
|
||||
config.updateHandler(updates)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Tracks DOM element size/position changes for a Vue component and syncs to layout store
|
||||
*
|
||||
* Sets up automatic ResizeObserver tracking when the component mounts and cleans up
|
||||
* when unmounted. The tracked element is identified by a data attribute set on the
|
||||
* component's root DOM element.
|
||||
*
|
||||
* @param appIdentifier - Application-level identifier for this tracked element (not a DOM ID)
|
||||
* Example: node ID like 'node-123', widget ID like 'widget-456'
|
||||
* @param trackingType - Type of element being tracked, determines which tracking config to use
|
||||
* Example: 'node' for Vue nodes, 'widget' for UI widgets
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Track a Vue node component with ID 'my-node-123'
|
||||
* useVueElementTracking('my-node-123', 'node')
|
||||
*
|
||||
* // Would set data-node-id="my-node-123" on the component's root element
|
||||
* // and sync size changes to layoutStore.batchUpdateNodeBounds()
|
||||
* ```
|
||||
*/
|
||||
export function useVueElementTracking(
|
||||
appIdentifier: string,
|
||||
trackingType: string
|
||||
) {
|
||||
onMounted(() => {
|
||||
const element = getCurrentInstance()?.proxy?.$el
|
||||
if (!(element instanceof HTMLElement) || !appIdentifier) return
|
||||
|
||||
const config = trackingConfigs.get(trackingType)
|
||||
if (config) {
|
||||
// Set the appropriate data attribute
|
||||
element.dataset[config.dataAttribute] = appIdentifier
|
||||
resizeObserver.observe(element)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
const element = getCurrentInstance()?.proxy?.$el
|
||||
if (!(element instanceof HTMLElement)) return
|
||||
|
||||
const config = trackingConfigs.get(trackingType)
|
||||
if (config) {
|
||||
// Remove the data attribute
|
||||
delete element.dataset[config.dataAttribute]
|
||||
resizeObserver.unobserve(element)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MultiSelectWidget from '@/components/graph/widgets/MultiSelectWidget.vue'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IComboWidget
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
||||
import {
|
||||
ComboInputSpec,
|
||||
@@ -18,6 +22,8 @@ import {
|
||||
type ComfyWidgetConstructorV2,
|
||||
addValueControlWidgets
|
||||
} from '@/scripts/widgets'
|
||||
import { assetService } from '@/services/assetService'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
import { useRemoteWidget } from './useRemoteWidget'
|
||||
|
||||
@@ -28,7 +34,10 @@ const getDefaultValue = (inputSpec: ComboInputSpec) => {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const addMultiSelectWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
|
||||
const addMultiSelectWidget = (
|
||||
node: LGraphNode,
|
||||
inputSpec: ComboInputSpec
|
||||
): IBaseWidget => {
|
||||
const widgetValue = ref<string[]>([])
|
||||
const widget = new ComponentWidgetImpl({
|
||||
node,
|
||||
@@ -48,7 +57,36 @@ const addMultiSelectWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
|
||||
return widget
|
||||
}
|
||||
|
||||
const addComboWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
|
||||
const addComboWidget = (
|
||||
node: LGraphNode,
|
||||
inputSpec: ComboInputSpec
|
||||
): IBaseWidget => {
|
||||
const settingStore = useSettingStore()
|
||||
const isUsingAssetAPI = settingStore.get('Comfy.Assets.UseAssetAPI')
|
||||
const isEligible = assetService.isAssetBrowserEligible(
|
||||
inputSpec.name,
|
||||
node.comfyClass || ''
|
||||
)
|
||||
|
||||
if (isUsingAssetAPI && isEligible) {
|
||||
// Create button widget for Asset Browser
|
||||
const currentValue = getDefaultValue(inputSpec)
|
||||
|
||||
const widget = node.addWidget(
|
||||
'button',
|
||||
inputSpec.name,
|
||||
t('widgets.selectModel'),
|
||||
() => {
|
||||
console.log(
|
||||
`Asset Browser would open here for:\nNode: ${node.type}\nWidget: ${inputSpec.name}\nCurrent Value:${currentValue}`
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
// Create normal combo widget
|
||||
const defaultValue = getDefaultValue(inputSpec)
|
||||
const comboOptions = inputSpec.options ?? []
|
||||
const widget = node.addWidget(
|
||||
@@ -59,14 +97,14 @@ const addComboWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
|
||||
{
|
||||
values: comboOptions
|
||||
}
|
||||
) as IComboWidget
|
||||
)
|
||||
|
||||
if (inputSpec.remote) {
|
||||
const remoteWidget = useRemoteWidget({
|
||||
remoteConfig: inputSpec.remote,
|
||||
defaultValue,
|
||||
node,
|
||||
widget
|
||||
widget: widget as IComboWidget
|
||||
})
|
||||
if (inputSpec.remote.refresh_button) remoteWidget.addRefreshButton()
|
||||
|
||||
@@ -84,7 +122,7 @@ const addComboWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
|
||||
if (inputSpec.control_after_generate) {
|
||||
widget.linkedWidgets = addValueControlWidgets(
|
||||
node,
|
||||
widget,
|
||||
widget as IComboWidget,
|
||||
undefined,
|
||||
undefined,
|
||||
transformInputSpecV2ToV1(inputSpec)
|
||||
|
||||
@@ -7,11 +7,17 @@ import {
|
||||
assetResponseSchema
|
||||
} from '@/schemas/assetSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
|
||||
const ASSETS_ENDPOINT = '/assets'
|
||||
const MODELS_TAG = 'models'
|
||||
const MISSING_TAG = 'missing'
|
||||
|
||||
/**
|
||||
* Input names that are eligible for asset browser
|
||||
*/
|
||||
const WHITELISTED_INPUTS = new Set(['ckpt_name', 'lora_name', 'vae_name'])
|
||||
|
||||
/**
|
||||
* Validates asset response data using Zod schema
|
||||
*/
|
||||
@@ -102,9 +108,29 @@ function createAssetService() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a widget input should use the asset browser based on both input name and node comfyClass
|
||||
*
|
||||
* @param inputName - The input name (e.g., 'ckpt_name', 'lora_name')
|
||||
* @param nodeType - The ComfyUI node comfyClass (e.g., 'CheckpointLoaderSimple', 'LoraLoader')
|
||||
* @returns true if this input should use asset browser
|
||||
*/
|
||||
function isAssetBrowserEligible(
|
||||
inputName: string,
|
||||
nodeType: string
|
||||
): boolean {
|
||||
return (
|
||||
// Must be an approved input name
|
||||
WHITELISTED_INPUTS.has(inputName) &&
|
||||
// Must be a registered node type
|
||||
useModelToNodeStore().getRegisteredNodeTypes().has(nodeType)
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
getAssetModelFolders,
|
||||
getAssetModels
|
||||
getAssetModels,
|
||||
isAssetBrowserEligible
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -484,7 +484,18 @@ export const useLitegraphService = () => {
|
||||
) ?? {}
|
||||
|
||||
if (widget) {
|
||||
widget.label = st(nameKey, widget.label ?? inputName)
|
||||
// Check if this is an Asset Browser button widget
|
||||
const isAssetBrowserButton =
|
||||
widget.type === 'button' && widget.value === 'Select model'
|
||||
|
||||
if (isAssetBrowserButton) {
|
||||
// Preserve Asset Browser button label (don't translate)
|
||||
widget.label = String(widget.value)
|
||||
} else {
|
||||
// Apply normal translation for other widgets
|
||||
widget.label = st(nameKey, widget.label ?? inputName)
|
||||
}
|
||||
|
||||
widget.options ??= {}
|
||||
Object.assign(widget.options, {
|
||||
advanced: inputSpec.advanced,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
@@ -22,6 +22,22 @@ export const useModelToNodeStore = defineStore('modelToNode', () => {
|
||||
const modelToNodeMap = ref<Record<string, ModelNodeProvider[]>>({})
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const haveDefaultsLoaded = ref(false)
|
||||
|
||||
/** Internal computed for reactive caching of registered node types */
|
||||
const registeredNodeTypes = computed(() => {
|
||||
return new Set(
|
||||
Object.values(modelToNodeMap.value)
|
||||
.flat()
|
||||
.map((provider) => provider.nodeDef.name)
|
||||
)
|
||||
})
|
||||
|
||||
/** Get set of all registered node types for efficient lookup */
|
||||
function getRegisteredNodeTypes(): Set<string> {
|
||||
registerDefaults()
|
||||
return registeredNodeTypes.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the node provider for the given model type name.
|
||||
* @param modelType The name of the model type to get the node provider for.
|
||||
@@ -91,6 +107,7 @@ export const useModelToNodeStore = defineStore('modelToNode', () => {
|
||||
|
||||
return {
|
||||
modelToNodeMap,
|
||||
getRegisteredNodeTypes,
|
||||
getNodeProvider,
|
||||
getAllNodeProviders,
|
||||
registerNodeProvider,
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
|
||||
/**
|
||||
* Finds the greatest common divisor (GCD) for two numbers.
|
||||
*
|
||||
@@ -5,7 +8,7 @@
|
||||
* @param b - The second number.
|
||||
* @returns The GCD of the two numbers.
|
||||
*/
|
||||
const gcd = (a: number, b: number): number => {
|
||||
export const gcd = (a: number, b: number): number => {
|
||||
return b === 0 ? a : gcd(b, a % b)
|
||||
}
|
||||
|
||||
@@ -19,3 +22,48 @@ const gcd = (a: number, b: number): number => {
|
||||
export const lcm = (a: number, b: number): number => {
|
||||
return Math.abs(a * b) / gcd(a, b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the union (bounding box) of multiple rectangles using a single-pass algorithm.
|
||||
*
|
||||
* Finds the minimum and maximum x/y coordinates across all rectangles to create
|
||||
* a single bounding rectangle that contains all input rectangles. Optimized for
|
||||
* performance with V8-friendly tuple access patterns.
|
||||
*
|
||||
* @param rectangles - Array of rectangle tuples in [x, y, width, height] format
|
||||
* @returns Bounds object with union rectangle, or null if no rectangles provided
|
||||
*/
|
||||
export function computeUnionBounds(
|
||||
rectangles: readonly ReadOnlyRect[]
|
||||
): Bounds | null {
|
||||
const n = rectangles.length
|
||||
if (n === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const r0 = rectangles[0]
|
||||
let minX = r0[0]
|
||||
let minY = r0[1]
|
||||
let maxX = minX + r0[2]
|
||||
let maxY = minY + r0[3]
|
||||
|
||||
for (let i = 1; i < n; i++) {
|
||||
const r = rectangles[i]
|
||||
const x1 = r[0]
|
||||
const y1 = r[1]
|
||||
const x2 = x1 + r[2]
|
||||
const y2 = y1 + r[3]
|
||||
|
||||
if (x1 < minX) minX = x1
|
||||
if (y1 < minY) minY = y1
|
||||
if (x2 > maxX) maxX = x2
|
||||
if (y2 > maxY) maxY = y2
|
||||
}
|
||||
|
||||
return {
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
// Mock stores
|
||||
vi.mock('@/stores/graphStore')
|
||||
vi.mock('@/stores/settingStore')
|
||||
vi.mock('@/stores/graphStore', () => {
|
||||
const getCanvas = vi.fn()
|
||||
return { useCanvasStore: vi.fn(() => ({ getCanvas })) }
|
||||
})
|
||||
vi.mock('@/stores/settingStore', () => {
|
||||
const getFn = vi.fn()
|
||||
return { useSettingStore: vi.fn(() => ({ get: getFn })) }
|
||||
})
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: {
|
||||
@@ -17,105 +24,86 @@ vi.mock('@/scripts/app', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
function createMockLGraphCanvas(read_only = true): LGraphCanvas {
|
||||
const mockCanvas: Partial<LGraphCanvas> = { read_only }
|
||||
return mockCanvas as LGraphCanvas
|
||||
}
|
||||
|
||||
function createMockPointerEvent(
|
||||
buttons: PointerEvent['buttons'] = 1
|
||||
): PointerEvent {
|
||||
const mockEvent: Partial<PointerEvent> = {
|
||||
buttons,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn()
|
||||
}
|
||||
return mockEvent as PointerEvent
|
||||
}
|
||||
|
||||
function createMockWheelEvent(ctrlKey = false, metaKey = false): WheelEvent {
|
||||
const mockEvent: Partial<WheelEvent> = {
|
||||
ctrlKey,
|
||||
metaKey,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn()
|
||||
}
|
||||
return mockEvent as WheelEvent
|
||||
}
|
||||
|
||||
describe('useCanvasInteractions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useCanvasStore, { partial: true }).mockReturnValue({
|
||||
getCanvas: vi.fn()
|
||||
})
|
||||
vi.mocked(useSettingStore, { partial: true }).mockReturnValue({
|
||||
get: vi.fn()
|
||||
})
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
describe('handlePointer', () => {
|
||||
it('should forward space+drag events to canvas when read_only is true', () => {
|
||||
// Setup
|
||||
const mockCanvas = { read_only: true }
|
||||
it('should intercept left mouse events when canvas is read_only to enable space+drag navigation', () => {
|
||||
const { getCanvas } = useCanvasStore()
|
||||
vi.mocked(getCanvas).mockReturnValue(mockCanvas as any)
|
||||
const mockCanvas = createMockLGraphCanvas(true)
|
||||
vi.mocked(getCanvas).mockReturnValue(mockCanvas)
|
||||
|
||||
const { handlePointer } = useCanvasInteractions()
|
||||
|
||||
// Create mock pointer event
|
||||
const mockEvent = {
|
||||
buttons: 1, // Left mouse button
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn()
|
||||
} satisfies Partial<PointerEvent>
|
||||
const mockEvent = createMockPointerEvent(1) // Left Mouse Button
|
||||
handlePointer(mockEvent)
|
||||
|
||||
// Test
|
||||
handlePointer(mockEvent as unknown as PointerEvent)
|
||||
|
||||
// Verify
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled()
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should forward middle mouse button events to canvas', () => {
|
||||
// Setup
|
||||
const mockCanvas = { read_only: false }
|
||||
const { getCanvas } = useCanvasStore()
|
||||
vi.mocked(getCanvas).mockReturnValue(mockCanvas as any)
|
||||
|
||||
const mockCanvas = createMockLGraphCanvas(false)
|
||||
vi.mocked(getCanvas).mockReturnValue(mockCanvas)
|
||||
const { handlePointer } = useCanvasInteractions()
|
||||
|
||||
// Create mock pointer event with middle button
|
||||
const mockEvent = {
|
||||
buttons: 4, // Middle mouse button
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn()
|
||||
} satisfies Partial<PointerEvent>
|
||||
const mockEvent = createMockPointerEvent(4) // Middle mouse button
|
||||
handlePointer(mockEvent)
|
||||
|
||||
// Test
|
||||
handlePointer(mockEvent as unknown as PointerEvent)
|
||||
|
||||
// Verify
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled()
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not prevent default when canvas is not in read_only mode and not middle button', () => {
|
||||
// Setup
|
||||
const mockCanvas = { read_only: false }
|
||||
const { getCanvas } = useCanvasStore()
|
||||
vi.mocked(getCanvas).mockReturnValue(mockCanvas as any)
|
||||
|
||||
const mockCanvas = createMockLGraphCanvas(false)
|
||||
vi.mocked(getCanvas).mockReturnValue(mockCanvas)
|
||||
const { handlePointer } = useCanvasInteractions()
|
||||
|
||||
// Create mock pointer event
|
||||
const mockEvent = {
|
||||
buttons: 1, // Left mouse button
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn()
|
||||
} satisfies Partial<PointerEvent>
|
||||
const mockEvent = createMockPointerEvent(1)
|
||||
handlePointer(mockEvent)
|
||||
|
||||
// Test
|
||||
handlePointer(mockEvent as unknown as PointerEvent)
|
||||
|
||||
// Verify - should not prevent default (let media handle normally)
|
||||
expect(mockEvent.preventDefault).not.toHaveBeenCalled()
|
||||
expect(mockEvent.stopPropagation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return early when canvas is null', () => {
|
||||
// Setup
|
||||
const { getCanvas } = useCanvasStore()
|
||||
vi.mocked(getCanvas).mockReturnValue(null as any)
|
||||
|
||||
vi.mocked(getCanvas).mockReturnValue(null as unknown as LGraphCanvas) // TODO: Fix misaligned types
|
||||
const { handlePointer } = useCanvasInteractions()
|
||||
|
||||
// Create mock pointer event that would normally trigger forwarding
|
||||
const mockEvent = {
|
||||
buttons: 1, // Left mouse button - would trigger space+drag if canvas had read_only=true
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn()
|
||||
} satisfies Partial<PointerEvent>
|
||||
const mockEvent = createMockPointerEvent(1)
|
||||
handlePointer(mockEvent)
|
||||
|
||||
// Test
|
||||
handlePointer(mockEvent as unknown as PointerEvent)
|
||||
|
||||
// Verify early return - no event methods should be called at all
|
||||
expect(getCanvas).toHaveBeenCalled()
|
||||
expect(mockEvent.preventDefault).not.toHaveBeenCalled()
|
||||
expect(mockEvent.stopPropagation).not.toHaveBeenCalled()
|
||||
@@ -124,66 +112,42 @@ describe('useCanvasInteractions', () => {
|
||||
|
||||
describe('handleWheel', () => {
|
||||
it('should forward ctrl+wheel events to canvas in standard nav mode', () => {
|
||||
// Setup
|
||||
const { get } = useSettingStore()
|
||||
vi.mocked(get).mockReturnValue('standard')
|
||||
|
||||
const { handleWheel } = useCanvasInteractions()
|
||||
|
||||
// Create mock wheel event with ctrl key
|
||||
const mockEvent = {
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
preventDefault: vi.fn()
|
||||
} satisfies Partial<WheelEvent>
|
||||
// Ctrl key pressed
|
||||
const mockEvent = createMockWheelEvent(true)
|
||||
|
||||
// Test
|
||||
handleWheel(mockEvent as unknown as WheelEvent)
|
||||
handleWheel(mockEvent)
|
||||
|
||||
// Verify
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled()
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should forward all wheel events to canvas in legacy nav mode', () => {
|
||||
// Setup
|
||||
const { get } = useSettingStore()
|
||||
vi.mocked(get).mockReturnValue('legacy')
|
||||
|
||||
const { handleWheel } = useCanvasInteractions()
|
||||
|
||||
// Create mock wheel event without modifiers
|
||||
const mockEvent = {
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
preventDefault: vi.fn()
|
||||
} satisfies Partial<WheelEvent>
|
||||
const mockEvent = createMockWheelEvent()
|
||||
handleWheel(mockEvent)
|
||||
|
||||
// Test
|
||||
handleWheel(mockEvent as unknown as WheelEvent)
|
||||
|
||||
// Verify
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled()
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not prevent default for regular wheel events in standard nav mode', () => {
|
||||
// Setup
|
||||
const { get } = useSettingStore()
|
||||
vi.mocked(get).mockReturnValue('standard')
|
||||
|
||||
const { handleWheel } = useCanvasInteractions()
|
||||
|
||||
// Create mock wheel event without modifiers
|
||||
const mockEvent = {
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
preventDefault: vi.fn()
|
||||
} satisfies Partial<WheelEvent>
|
||||
const mockEvent = createMockWheelEvent()
|
||||
handleWheel(mockEvent)
|
||||
|
||||
// Test
|
||||
handleWheel(mockEvent as unknown as WheelEvent)
|
||||
|
||||
// Verify - should not prevent default (let component handle normally)
|
||||
expect(mockEvent.preventDefault).not.toHaveBeenCalled()
|
||||
expect(mockEvent.stopPropagation).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -63,12 +63,14 @@ describe('useUpdateAvailableNodes', () => {
|
||||
const mockStartFetchInstalled = vi.fn()
|
||||
const mockIsPackInstalled = vi.fn()
|
||||
const mockGetInstalledPackVersion = vi.fn()
|
||||
const mockIsPackEnabled = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Default setup
|
||||
mockIsPackInstalled.mockReturnValue(true)
|
||||
mockIsPackEnabled.mockReturnValue(true) // Default: all packs are enabled
|
||||
mockGetInstalledPackVersion.mockImplementation((id: string) => {
|
||||
switch (id) {
|
||||
case 'pack-1':
|
||||
@@ -100,7 +102,8 @@ describe('useUpdateAvailableNodes', () => {
|
||||
|
||||
mockUseComfyManagerStore.mockReturnValue({
|
||||
isPackInstalled: mockIsPackInstalled,
|
||||
getInstalledPackVersion: mockGetInstalledPackVersion
|
||||
getInstalledPackVersion: mockGetInstalledPackVersion,
|
||||
isPackEnabled: mockIsPackEnabled
|
||||
} as any)
|
||||
|
||||
mockUseInstalledPacks.mockReturnValue({
|
||||
@@ -357,4 +360,127 @@ describe('useUpdateAvailableNodes', () => {
|
||||
expect(mockIsPackInstalled).toHaveBeenCalledWith('pack-4')
|
||||
})
|
||||
})
|
||||
|
||||
describe('enabledUpdateAvailableNodePacks', () => {
|
||||
it('returns only enabled packs with updates', () => {
|
||||
mockIsPackEnabled.mockImplementation((id: string) => {
|
||||
// pack-1 is disabled
|
||||
return id !== 'pack-1'
|
||||
})
|
||||
|
||||
mockUseInstalledPacks.mockReturnValue({
|
||||
installedPacks: ref([mockInstalledPacks[0], mockInstalledPacks[1]]),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchInstalled: mockStartFetchInstalled
|
||||
} as any)
|
||||
|
||||
const { updateAvailableNodePacks, enabledUpdateAvailableNodePacks } =
|
||||
useUpdateAvailableNodes()
|
||||
|
||||
// pack-1 has updates but is disabled
|
||||
expect(updateAvailableNodePacks.value).toHaveLength(1)
|
||||
expect(updateAvailableNodePacks.value[0].id).toBe('pack-1')
|
||||
|
||||
// enabledUpdateAvailableNodePacks should be empty
|
||||
expect(enabledUpdateAvailableNodePacks.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns all packs when all are enabled', () => {
|
||||
mockUseInstalledPacks.mockReturnValue({
|
||||
installedPacks: ref([mockInstalledPacks[0]]), // pack-1: outdated
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchInstalled: mockStartFetchInstalled
|
||||
} as any)
|
||||
|
||||
const { updateAvailableNodePacks, enabledUpdateAvailableNodePacks } =
|
||||
useUpdateAvailableNodes()
|
||||
|
||||
expect(updateAvailableNodePacks.value).toHaveLength(1)
|
||||
expect(enabledUpdateAvailableNodePacks.value).toHaveLength(1)
|
||||
expect(enabledUpdateAvailableNodePacks.value[0].id).toBe('pack-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasDisabledUpdatePacks', () => {
|
||||
it('returns true when there are disabled packs with updates', () => {
|
||||
mockIsPackEnabled.mockImplementation((id: string) => {
|
||||
// pack-1 is disabled
|
||||
return id !== 'pack-1'
|
||||
})
|
||||
|
||||
mockUseInstalledPacks.mockReturnValue({
|
||||
installedPacks: ref([mockInstalledPacks[0]]), // pack-1: outdated
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchInstalled: mockStartFetchInstalled
|
||||
} as any)
|
||||
|
||||
const { hasDisabledUpdatePacks } = useUpdateAvailableNodes()
|
||||
|
||||
expect(hasDisabledUpdatePacks.value).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when all packs with updates are enabled', () => {
|
||||
mockUseInstalledPacks.mockReturnValue({
|
||||
installedPacks: ref([mockInstalledPacks[0]]), // pack-1: outdated
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchInstalled: mockStartFetchInstalled
|
||||
} as any)
|
||||
|
||||
const { hasDisabledUpdatePacks } = useUpdateAvailableNodes()
|
||||
|
||||
expect(hasDisabledUpdatePacks.value).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when no packs have updates', () => {
|
||||
mockUseInstalledPacks.mockReturnValue({
|
||||
installedPacks: ref([mockInstalledPacks[1]]), // pack-2: up to date
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchInstalled: mockStartFetchInstalled
|
||||
} as any)
|
||||
|
||||
const { hasDisabledUpdatePacks } = useUpdateAvailableNodes()
|
||||
|
||||
expect(hasDisabledUpdatePacks.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasUpdateAvailable with disabled packs', () => {
|
||||
it('returns false when only disabled packs have updates', () => {
|
||||
mockIsPackEnabled.mockReturnValue(false) // All packs disabled
|
||||
|
||||
mockUseInstalledPacks.mockReturnValue({
|
||||
installedPacks: ref([mockInstalledPacks[0]]), // pack-1: outdated
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchInstalled: mockStartFetchInstalled
|
||||
} as any)
|
||||
|
||||
const { hasUpdateAvailable } = useUpdateAvailableNodes()
|
||||
|
||||
expect(hasUpdateAvailable.value).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when at least one enabled pack has updates', () => {
|
||||
mockIsPackEnabled.mockImplementation((id: string) => {
|
||||
// Only pack-1 is enabled
|
||||
return id === 'pack-1'
|
||||
})
|
||||
|
||||
mockUseInstalledPacks.mockReturnValue({
|
||||
installedPacks: ref([mockInstalledPacks[0]]), // pack-1: outdated
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchInstalled: mockStartFetchInstalled
|
||||
} as any)
|
||||
|
||||
const { hasUpdateAvailable } = useUpdateAvailableNodes()
|
||||
|
||||
expect(hasUpdateAvailable.value).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
|
||||
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
|
||||
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking',
|
||||
() => ({
|
||||
useVueElementTracking: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: () => ({
|
||||
toastErrorHandler: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/extensions/vueNodes/layout/useNodeLayout', () => ({
|
||||
useNodeLayout: () => ({
|
||||
position: { x: 100, y: 50 },
|
||||
startDrag: vi.fn(),
|
||||
handleDrag: vi.fn(),
|
||||
endDrag: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/extensions/vueNodes/lod/useLOD', () => ({
|
||||
useLOD: () => ({
|
||||
lodLevel: { value: 0 },
|
||||
shouldRenderWidgets: { value: true },
|
||||
shouldRenderSlots: { value: true },
|
||||
shouldRenderContent: { value: false },
|
||||
lodCssClass: { value: '' }
|
||||
}),
|
||||
LODLevel: { MINIMAL: 0 }
|
||||
}))
|
||||
|
||||
describe('LGraphNode', () => {
|
||||
const mockNodeData: VueNodeData = {
|
||||
id: 'test-node-123',
|
||||
title: 'Test Node',
|
||||
type: 'TestNode',
|
||||
mode: 0,
|
||||
flags: {},
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [],
|
||||
selected: false,
|
||||
executing: false
|
||||
}
|
||||
|
||||
const mountLGraphNode = (props: any, selectedNodeIds = new Set()) => {
|
||||
return mount(LGraphNode, {
|
||||
props,
|
||||
global: {
|
||||
provide: {
|
||||
[SelectedNodeIdsKey as symbol]: ref(selectedNodeIds)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should call resize tracking composable with node ID', () => {
|
||||
mountLGraphNode({ nodeData: mockNodeData })
|
||||
|
||||
expect(useVueElementTracking).toHaveBeenCalledWith('test-node-123', 'node')
|
||||
})
|
||||
|
||||
it('should render with data-node-id attribute', () => {
|
||||
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
|
||||
|
||||
expect(wrapper.attributes('data-node-id')).toBe('test-node-123')
|
||||
})
|
||||
|
||||
it('should render node title', () => {
|
||||
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
|
||||
|
||||
expect(wrapper.text()).toContain('Test Node')
|
||||
})
|
||||
|
||||
it('should apply selected styling when selected prop is true', () => {
|
||||
const wrapper = mountLGraphNode(
|
||||
{ nodeData: mockNodeData, selected: true },
|
||||
new Set(['test-node-123'])
|
||||
)
|
||||
|
||||
expect(wrapper.classes()).toContain('border-blue-500')
|
||||
expect(wrapper.classes()).toContain('ring-2')
|
||||
expect(wrapper.classes()).toContain('ring-blue-300')
|
||||
})
|
||||
|
||||
it('should apply executing animation when executing prop is true', () => {
|
||||
const wrapper = mountLGraphNode({ nodeData: mockNodeData, executing: true })
|
||||
|
||||
expect(wrapper.classes()).toContain('animate-pulse')
|
||||
})
|
||||
|
||||
it('should emit node-click event on pointer down', async () => {
|
||||
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
|
||||
|
||||
await wrapper.trigger('pointerdown')
|
||||
|
||||
expect(wrapper.emitted('node-click')).toHaveLength(1)
|
||||
expect(wrapper.emitted('node-click')?.[0]).toHaveLength(2)
|
||||
expect(wrapper.emitted('node-click')?.[0][1]).toEqual(mockNodeData)
|
||||
})
|
||||
})
|
||||
@@ -1,39 +1,211 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useComboWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useComboWidget'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { assetService } from '@/services/assetService'
|
||||
|
||||
vi.mock('@/scripts/widgets', () => ({
|
||||
addValueControlWidgets: vi.fn()
|
||||
}))
|
||||
|
||||
const mockSettingStoreGet = vi.fn(() => false)
|
||||
vi.mock('@/stores/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: mockSettingStoreGet
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: vi.fn((key: string) =>
|
||||
key === 'widgets.selectModel' ? 'Select model' : key
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/services/assetService', () => ({
|
||||
assetService: {
|
||||
isAssetBrowserEligible: vi.fn(() => false)
|
||||
}
|
||||
}))
|
||||
|
||||
// Test factory functions
|
||||
function createMockWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
|
||||
return {
|
||||
type: 'combo',
|
||||
options: {},
|
||||
name: 'testWidget',
|
||||
value: undefined,
|
||||
...overrides
|
||||
} as IBaseWidget
|
||||
}
|
||||
|
||||
function createMockNode(comfyClass = 'TestNode'): LGraphNode {
|
||||
const node = new LGraphNode('TestNode')
|
||||
node.comfyClass = comfyClass
|
||||
|
||||
// Spy on the addWidget method
|
||||
vi.spyOn(node, 'addWidget').mockReturnValue(createMockWidget())
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
function createMockInputSpec(overrides: Partial<InputSpec> = {}): InputSpec {
|
||||
return {
|
||||
type: 'COMBO',
|
||||
name: 'testInput',
|
||||
...overrides
|
||||
} as InputSpec
|
||||
}
|
||||
|
||||
describe('useComboWidget', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Reset to defaults
|
||||
mockSettingStoreGet.mockReturnValue(false)
|
||||
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('should handle undefined spec', () => {
|
||||
const constructor = useComboWidget()
|
||||
const mockNode = {
|
||||
addWidget: vi.fn().mockReturnValue({ options: {} } as any)
|
||||
}
|
||||
const mockWidget = createMockWidget()
|
||||
const mockNode = createMockNode()
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({ name: 'inputName' })
|
||||
|
||||
const inputSpec: InputSpec = {
|
||||
type: 'COMBO',
|
||||
name: 'inputName'
|
||||
}
|
||||
|
||||
const widget = constructor(mockNode as any, inputSpec)
|
||||
const widget = constructor(mockNode, inputSpec)
|
||||
|
||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
||||
'combo',
|
||||
'inputName',
|
||||
undefined, // default value
|
||||
expect.any(Function), // callback
|
||||
undefined,
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
values: []
|
||||
})
|
||||
)
|
||||
expect(widget).toEqual({ options: {} })
|
||||
expect(widget).toBe(mockWidget)
|
||||
})
|
||||
|
||||
it('should create normal combo widget when asset API is disabled', () => {
|
||||
mockSettingStoreGet.mockReturnValue(false)
|
||||
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true)
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget()
|
||||
const mockNode = createMockNode('CheckpointLoaderSimple')
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: 'ckpt_name',
|
||||
options: ['model1.safetensors', 'model2.safetensors']
|
||||
})
|
||||
|
||||
const widget = constructor(mockNode, inputSpec)
|
||||
|
||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
||||
'combo',
|
||||
'ckpt_name',
|
||||
'model1.safetensors',
|
||||
expect.any(Function),
|
||||
{ values: ['model1.safetensors', 'model2.safetensors'] }
|
||||
)
|
||||
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
|
||||
expect(widget).toBe(mockWidget)
|
||||
})
|
||||
|
||||
it('should create normal combo widget when widget is not eligible for asset browser', () => {
|
||||
mockSettingStoreGet.mockReturnValue(true)
|
||||
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(false)
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget()
|
||||
const mockNode = createMockNode()
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: 'not_eligible_widget',
|
||||
options: ['option1', 'option2']
|
||||
})
|
||||
|
||||
const widget = constructor(mockNode, inputSpec)
|
||||
|
||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
||||
'combo',
|
||||
'not_eligible_widget',
|
||||
'option1',
|
||||
expect.any(Function),
|
||||
{ values: ['option1', 'option2'] }
|
||||
)
|
||||
expect(vi.mocked(assetService.isAssetBrowserEligible)).toHaveBeenCalledWith(
|
||||
'not_eligible_widget',
|
||||
'TestNode'
|
||||
)
|
||||
expect(widget).toBe(mockWidget)
|
||||
})
|
||||
|
||||
it('should create asset browser button widget when API enabled and widget eligible', () => {
|
||||
mockSettingStoreGet.mockReturnValue(true)
|
||||
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true)
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget({
|
||||
type: 'button',
|
||||
name: 'ckpt_name',
|
||||
value: 'Select model'
|
||||
})
|
||||
const mockNode = createMockNode('CheckpointLoaderSimple')
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: 'ckpt_name',
|
||||
options: ['model1.safetensors', 'model2.safetensors']
|
||||
})
|
||||
|
||||
const widget = constructor(mockNode, inputSpec)
|
||||
|
||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
||||
'button',
|
||||
'ckpt_name',
|
||||
'Select model',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
|
||||
expect(vi.mocked(assetService.isAssetBrowserEligible)).toHaveBeenCalledWith(
|
||||
'ckpt_name',
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
expect(widget).toBe(mockWidget)
|
||||
})
|
||||
|
||||
it('should use asset browser button even when inputSpec has a default value but no options', () => {
|
||||
mockSettingStoreGet.mockReturnValue(true)
|
||||
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true)
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget({
|
||||
type: 'button',
|
||||
name: 'ckpt_name',
|
||||
value: 'Select model'
|
||||
})
|
||||
const mockNode = createMockNode('CheckpointLoaderSimple')
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: 'ckpt_name',
|
||||
default: 'fallback.safetensors'
|
||||
// Note: no options array provided
|
||||
})
|
||||
|
||||
const widget = constructor(mockNode, inputSpec)
|
||||
|
||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
||||
'button',
|
||||
'ckpt_name',
|
||||
'Select model',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
|
||||
expect(vi.mocked(assetService.isAssetBrowserEligible)).toHaveBeenCalledWith(
|
||||
'ckpt_name',
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
expect(widget).toBe(mockWidget)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api } from '@/scripts/api'
|
||||
import { assetService } from '@/services/assetService'
|
||||
|
||||
vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
useModelToNodeStore: vi.fn(() => ({
|
||||
getRegisteredNodeTypes: vi.fn(
|
||||
() =>
|
||||
new Set([
|
||||
'CheckpointLoaderSimple',
|
||||
'LoraLoader',
|
||||
'VAELoader',
|
||||
'TestNode'
|
||||
])
|
||||
)
|
||||
}))
|
||||
}))
|
||||
|
||||
// Test data constants
|
||||
const MOCK_ASSETS = {
|
||||
checkpoints: {
|
||||
@@ -147,4 +161,43 @@ describe('assetService', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAssetBrowserEligible', () => {
|
||||
it('should return true for eligible widget names with registered node types', () => {
|
||||
expect(
|
||||
assetService.isAssetBrowserEligible(
|
||||
'ckpt_name',
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
assetService.isAssetBrowserEligible('lora_name', 'LoraLoader')
|
||||
).toBe(true)
|
||||
expect(assetService.isAssetBrowserEligible('vae_name', 'VAELoader')).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('should return false for non-eligible widget names', () => {
|
||||
expect(assetService.isAssetBrowserEligible('seed', 'TestNode')).toBe(
|
||||
false
|
||||
)
|
||||
expect(assetService.isAssetBrowserEligible('steps', 'TestNode')).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
assetService.isAssetBrowserEligible('sampler_name', 'TestNode')
|
||||
).toBe(false)
|
||||
expect(assetService.isAssetBrowserEligible('', 'TestNode')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for eligible widget names with unregistered node types', () => {
|
||||
expect(
|
||||
assetService.isAssetBrowserEligible('ckpt_name', 'UnknownNode')
|
||||
).toBe(false)
|
||||
expect(
|
||||
assetService.isAssetBrowserEligible('lora_name', 'UnknownNode')
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,45 +21,44 @@ const EXPECTED_DEFAULT_TYPES = [
|
||||
|
||||
type NodeDefStoreType = typeof import('@/stores/nodeDefStore')
|
||||
|
||||
// Create minimal but valid ComfyNodeDefImpl for testing
|
||||
function createMockNodeDef(name: string): ComfyNodeDefImpl {
|
||||
const def: ComfyNodeDefV1 = {
|
||||
name,
|
||||
display_name: name,
|
||||
category: 'test',
|
||||
python_module: 'nodes',
|
||||
description: '',
|
||||
input: { required: {}, optional: {} },
|
||||
output: [],
|
||||
output_name: [],
|
||||
output_is_list: [],
|
||||
output_node: false
|
||||
}
|
||||
return new ComfyNodeDefImpl(def)
|
||||
}
|
||||
|
||||
const MOCK_NODE_NAMES = [
|
||||
'CheckpointLoaderSimple',
|
||||
'ImageOnlyCheckpointLoader',
|
||||
'LoraLoader',
|
||||
'LoraLoaderModelOnly',
|
||||
'VAELoader',
|
||||
'ControlNetLoader',
|
||||
'UNETLoader',
|
||||
'UpscaleModelLoader',
|
||||
'StyleModelLoader',
|
||||
'GLIGENLoader'
|
||||
] as const
|
||||
|
||||
const mockNodeDefsByName = Object.fromEntries(
|
||||
MOCK_NODE_NAMES.map((name) => [name, createMockNodeDef(name)])
|
||||
)
|
||||
|
||||
// Mock nodeDefStore dependency - modelToNodeStore relies on this for registration
|
||||
// Most tests expect this to be populated; tests that need empty state can override
|
||||
vi.mock('@/stores/nodeDefStore', async (importOriginal) => {
|
||||
const original = await importOriginal<NodeDefStoreType>()
|
||||
const { ComfyNodeDefImpl } = original
|
||||
|
||||
// Create minimal but valid ComfyNodeDefImpl for testing
|
||||
function createMockNodeDef(name: string): ComfyNodeDefImpl {
|
||||
const def: ComfyNodeDefV1 = {
|
||||
name,
|
||||
display_name: name,
|
||||
category: 'test',
|
||||
python_module: 'nodes',
|
||||
description: '',
|
||||
input: { required: {}, optional: {} },
|
||||
output: [],
|
||||
output_name: [],
|
||||
output_is_list: [],
|
||||
output_node: false
|
||||
}
|
||||
return new ComfyNodeDefImpl(def)
|
||||
}
|
||||
|
||||
const MOCK_NODE_NAMES = [
|
||||
'CheckpointLoaderSimple',
|
||||
'ImageOnlyCheckpointLoader',
|
||||
'LoraLoader',
|
||||
'LoraLoaderModelOnly',
|
||||
'VAELoader',
|
||||
'ControlNetLoader',
|
||||
'UNETLoader',
|
||||
'UpscaleModelLoader',
|
||||
'StyleModelLoader',
|
||||
'GLIGENLoader'
|
||||
] as const
|
||||
|
||||
const mockNodeDefsByName = Object.fromEntries(
|
||||
MOCK_NODE_NAMES.map((name) => [name, createMockNodeDef(name)])
|
||||
)
|
||||
|
||||
return {
|
||||
...original,
|
||||
@@ -72,6 +71,7 @@ vi.mock('@/stores/nodeDefStore', async (importOriginal) => {
|
||||
describe('useModelToNodeStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('modelToNodeMap', () => {
|
||||
@@ -288,12 +288,58 @@ describe('useModelToNodeStore', () => {
|
||||
})
|
||||
|
||||
it('should not register when nodeDefStore is empty', () => {
|
||||
// Create fresh Pinia for this test to avoid state persistence
|
||||
setActivePinia(createPinia())
|
||||
|
||||
vi.mocked(useNodeDefStore, { partial: true }).mockReturnValue({
|
||||
nodeDefsByName: {}
|
||||
})
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
modelToNodeStore.registerDefaults()
|
||||
expect(modelToNodeStore.getNodeProvider('checkpoints')).toBeUndefined()
|
||||
|
||||
// Restore original mock for subsequent tests
|
||||
vi.mocked(useNodeDefStore, { partial: true }).mockReturnValue({
|
||||
nodeDefsByName: mockNodeDefsByName
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRegisteredNodeTypes', () => {
|
||||
it('should return a Set instance', () => {
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
const result = modelToNodeStore.getRegisteredNodeTypes()
|
||||
expect(result).toBeInstanceOf(Set)
|
||||
})
|
||||
|
||||
it('should return empty set when nodeDefStore is empty', () => {
|
||||
// Create fresh Pinia for this test to avoid state persistence
|
||||
setActivePinia(createPinia())
|
||||
|
||||
vi.mocked(useNodeDefStore, { partial: true }).mockReturnValue({
|
||||
nodeDefsByName: {}
|
||||
})
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
|
||||
const result = modelToNodeStore.getRegisteredNodeTypes()
|
||||
expect(result.size).toBe(0)
|
||||
|
||||
// Restore original mock for subsequent tests
|
||||
vi.mocked(useNodeDefStore, { partial: true }).mockReturnValue({
|
||||
nodeDefsByName: mockNodeDefsByName
|
||||
})
|
||||
})
|
||||
|
||||
it('should contain node types for efficient Set.has() lookups', () => {
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
modelToNodeStore.registerDefaults()
|
||||
|
||||
const result = modelToNodeStore.getRegisteredNodeTypes()
|
||||
|
||||
// Test Set.has() functionality which assetService depends on
|
||||
expect(result.has('CheckpointLoaderSimple')).toBe(true)
|
||||
expect(result.has('LoraLoader')).toBe(true)
|
||||
expect(result.has('NonExistentNode')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
97
tests-ui/tests/utils/mathUtil.test.ts
Normal file
97
tests-ui/tests/utils/mathUtil.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
import { computeUnionBounds, gcd, lcm } from '@/utils/mathUtil'
|
||||
|
||||
describe('mathUtil', () => {
|
||||
describe('gcd', () => {
|
||||
it('should compute greatest common divisor correctly', () => {
|
||||
expect(gcd(48, 18)).toBe(6)
|
||||
expect(gcd(100, 25)).toBe(25)
|
||||
expect(gcd(17, 13)).toBe(1)
|
||||
expect(gcd(0, 5)).toBe(5)
|
||||
expect(gcd(5, 0)).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('lcm', () => {
|
||||
it('should compute least common multiple correctly', () => {
|
||||
expect(lcm(4, 6)).toBe(12)
|
||||
expect(lcm(15, 20)).toBe(60)
|
||||
expect(lcm(7, 11)).toBe(77)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeUnionBounds', () => {
|
||||
it('should return null for empty input', () => {
|
||||
expect(computeUnionBounds([])).toBe(null)
|
||||
})
|
||||
|
||||
// Tests for tuple format (ReadOnlyRect)
|
||||
it('should work with ReadOnlyRect tuple format', () => {
|
||||
const tuples: ReadOnlyRect[] = [
|
||||
[10, 20, 30, 40] as const, // bounds: 10,20 to 40,60
|
||||
[50, 10, 20, 30] as const // bounds: 50,10 to 70,40
|
||||
]
|
||||
|
||||
const result = computeUnionBounds(tuples)
|
||||
|
||||
expect(result).toEqual({
|
||||
x: 10, // min(10, 50)
|
||||
y: 10, // min(20, 10)
|
||||
width: 60, // max(40, 70) - min(10, 50) = 70 - 10
|
||||
height: 50 // max(60, 40) - min(20, 10) = 60 - 10
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle single ReadOnlyRect tuple', () => {
|
||||
const tuple: ReadOnlyRect = [10, 20, 30, 40] as const
|
||||
const result = computeUnionBounds([tuple])
|
||||
|
||||
expect(result).toEqual({
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 30,
|
||||
height: 40
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle tuple format with negative dimensions', () => {
|
||||
const tuples: ReadOnlyRect[] = [
|
||||
[100, 50, -20, -10] as const, // x+width=80, y+height=40
|
||||
[90, 45, 15, 20] as const // x+width=105, y+height=65
|
||||
]
|
||||
|
||||
const result = computeUnionBounds(tuples)
|
||||
|
||||
expect(result).toEqual({
|
||||
x: 90, // min(100, 90)
|
||||
y: 45, // min(50, 45)
|
||||
width: 15, // max(80, 105) - min(100, 90) = 105 - 90
|
||||
height: 20 // max(40, 65) - min(50, 45) = 65 - 45
|
||||
})
|
||||
})
|
||||
|
||||
it('should maintain optimal performance with SoA tuples', () => {
|
||||
// Test that array access is as expected for typical selection sizes
|
||||
const tuples: ReadOnlyRect[] = Array.from(
|
||||
{ length: 10 },
|
||||
(_, i) =>
|
||||
[
|
||||
i * 20, // x
|
||||
i * 15, // y
|
||||
100 + i * 5, // width
|
||||
80 + i * 3 // height
|
||||
] as const
|
||||
)
|
||||
|
||||
const result = computeUnionBounds(tuples)
|
||||
|
||||
expect(result).toBeTruthy()
|
||||
expect(result!.x).toBe(0)
|
||||
expect(result!.y).toBe(0)
|
||||
expect(result!.width).toBe(325)
|
||||
expect(result!.height).toBe(242)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user