New bottom button and badges (#8603)
- "Enter Subgraph" "Show advanced inputs" and a new "show node Errors" button now use a combined button design at the bottom of the node. - A new "Errors" tab is added to the right side panel - After a failed queue, the label of an invalid widget is now red. - Badges other than price are now displayed on the bottom of the node. - Price badge will now truncate from the first space, prioritizing the sizing of the node title - An indicator for the node resize handle is now displayed while mousing over the node. <img width="669" height="233" alt="image" src="https://github.com/user-attachments/assets/53b3b59c-830b-474d-8f20-07f557124af7" />  --------- Co-authored-by: github-actions <github-actions@github.com>
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 64 KiB |
4
packages/design-system/src/icons/comfy-c.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 9">
|
||||
<path d="M1.82148 8.68376C1.61587 8.68376 1.44996 8.60733 1.34177 8.46284C1.23057 8.31438 1.20157 8.10711 1.26219 7.89434L1.50561 7.03961C1.52502 6.97155 1.51151 6.89831 1.46918 6.8417C1.42684 6.7852 1.3606 6.75194 1.29025 6.75194H0.590376C0.384656 6.75194 0.21875 6.67562 0.110614 6.53113C-0.000591531 6.38256 -0.0295831 6.17529 0.0310774 5.96252L0.867308 3.03952L0.959638 2.71838C1.08375 2.28258 1.53638 1.9284 1.96878 1.9284H2.80622C2.90615 1.9284 2.99406 1.86177 3.02157 1.76508L3.29852 0.79284C3.4225 0.357484 3.87514 0.0033043 4.30753 0.0033043L6.09854 0.000112775L7.40967 0C7.61533 0 7.78124 0.0763259 7.88937 0.220813C8.00058 0.369269 8.02957 0.576538 7.96895 0.78931L7.59405 2.10572C7.4701 2.54096 7.01746 2.89503 6.58507 2.89503L4.79008 2.89844H3.95292C3.8531 2.89844 3.7653 2.96496 3.73762 3.06155L3.03961 5.49964C3.02008 5.56781 3.03359 5.64127 3.07604 5.69787C3.11837 5.75437 3.18461 5.78763 3.2549 5.78763C3.25507 5.78763 4.44105 5.78532 4.44105 5.78532H5.7483C5.95396 5.78532 6.11986 5.86164 6.228 6.00613C6.33921 6.1547 6.3682 6.36197 6.30754 6.57474L5.93263 7.89092C5.80869 8.32628 5.35605 8.68034 4.92366 8.68034L3.12872 8.68376H1.82148Z" fill="#8A8A8A"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -14,11 +14,13 @@ import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import TabError from './TabError.vue'
|
||||
import TabInfo from './info/TabInfo.vue'
|
||||
import TabGlobalParameters from './parameters/TabGlobalParameters.vue'
|
||||
import TabNodes from './parameters/TabNodes.vue'
|
||||
@@ -33,6 +35,7 @@ import {
|
||||
import SubgraphEditor from './subgraph/SubgraphEditor.vue'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const settingStore = useSettingStore()
|
||||
const { t } = useI18n()
|
||||
@@ -87,10 +90,25 @@ function closePanel() {
|
||||
type RightSidePanelTabList = Array<{
|
||||
label: () => string
|
||||
value: RightSidePanelTab
|
||||
icon?: string
|
||||
}>
|
||||
|
||||
//FIXME all errors if nothing selected?
|
||||
const selectedNodeErrors = computed(() =>
|
||||
selectedNodes.value
|
||||
.map((node) => executionStore.getNodeErrors(`${node.id}`))
|
||||
.filter((nodeError) => !!nodeError)
|
||||
)
|
||||
|
||||
const tabs = computed<RightSidePanelTabList>(() => {
|
||||
const list: RightSidePanelTabList = []
|
||||
if (selectedNodeErrors.value.length) {
|
||||
list.push({
|
||||
label: () => t('g.error'),
|
||||
value: 'error',
|
||||
icon: 'icon-[lucide--octagon-alert] bg-node-stroke-error ml-1'
|
||||
})
|
||||
}
|
||||
|
||||
list.push({
|
||||
label: () =>
|
||||
@@ -271,6 +289,7 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
|
||||
:value="tab.value"
|
||||
>
|
||||
{{ tab.label() }}
|
||||
<i v-if="tab.icon" :class="cn(tab.icon, 'size-4')" />
|
||||
</Tab>
|
||||
</TabList>
|
||||
</nav>
|
||||
@@ -288,6 +307,7 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
|
||||
:node="selectedSingleNode"
|
||||
/>
|
||||
<template v-else>
|
||||
<TabError v-if="activeTab === 'error'" :errors="selectedNodeErrors" />
|
||||
<TabSubgraphInputs
|
||||
v-if="activeTab === 'parameters' && isSingleSubgraphNode"
|
||||
:node="selectedSingleNode as SubgraphNode"
|
||||
|
||||
30
src/components/rightSidePanel/TabError.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
errors: NodeError[]
|
||||
}>()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
</script>
|
||||
<template>
|
||||
<div class="m-4">
|
||||
<Button class="w-full" @click="copyToClipboard(JSON.stringify(errors))">
|
||||
{{ t('g.copy') }}
|
||||
<i class="icon-[lucide--copy] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-for="(error, index) in errors.flatMap((ne) => ne.errors)"
|
||||
:key="index"
|
||||
class="px-2"
|
||||
>
|
||||
<h3 class="text-error" v-text="error.message" />
|
||||
<div class="text-muted-foreground" v-text="error.details" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -36,7 +36,9 @@ export const usePriceBadge = () => {
|
||||
return badges
|
||||
}
|
||||
|
||||
function isCreditsBadge(badge: LGraphBadge | (() => LGraphBadge)): boolean {
|
||||
function isCreditsBadge(
|
||||
badge: Partial<LGraphBadge> | (() => Partial<LGraphBadge>)
|
||||
): boolean {
|
||||
const badgeInstance = typeof badge === 'function' ? badge() : badge
|
||||
return badgeInstance.icon?.image === componentIconSvg
|
||||
}
|
||||
@@ -61,6 +63,7 @@ export const usePriceBadge = () => {
|
||||
}
|
||||
return {
|
||||
getCreditsBadge,
|
||||
isCreditsBadge,
|
||||
updateSubgraphCredits
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
"icon": "Icon",
|
||||
"color": "Color",
|
||||
"error": "Error",
|
||||
"enterSubgraph": "Enter Subgraph",
|
||||
"resizeFromBottomRight": "Resize from bottom-right corner",
|
||||
"resizeFromTopRight": "Resize from top-right corner",
|
||||
"resizeFromBottomLeft": "Resize from bottom-left corner",
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
:class="
|
||||
cn(
|
||||
'-translate-x-1/2 w-3',
|
||||
hasSlotError &&
|
||||
hasError &&
|
||||
'before:ring-2 before:ring-error before:ring-offset-0 before:size-4 before:absolute before:rounded-full before:pointer-events-none'
|
||||
)
|
||||
"
|
||||
@@ -40,7 +40,7 @@
|
||||
:class="
|
||||
cn(
|
||||
'truncate text-node-component-slot-text',
|
||||
hasSlotError && 'text-error font-medium'
|
||||
hasError && 'text-error font-medium'
|
||||
)
|
||||
"
|
||||
>
|
||||
@@ -65,19 +65,19 @@ 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 { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import SlotConnectionDot from './SlotConnectionDot.vue'
|
||||
|
||||
interface InputSlotProps {
|
||||
slotData: INodeSlot
|
||||
compatible?: boolean
|
||||
connected?: boolean
|
||||
dotOnly?: boolean
|
||||
hasError?: boolean
|
||||
index: number
|
||||
nodeType?: string
|
||||
nodeId?: string
|
||||
slotData: INodeSlot
|
||||
index: number
|
||||
connected?: boolean
|
||||
compatible?: boolean
|
||||
dotOnly?: boolean
|
||||
socketless?: boolean
|
||||
}
|
||||
|
||||
@@ -91,18 +91,6 @@ const hasNoLabel = computed(
|
||||
)
|
||||
const dotOnly = computed(() => props.dotOnly || hasNoLabel.value)
|
||||
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
const hasSlotError = computed(() => {
|
||||
const nodeErrors = executionStore.lastNodeErrors?.[props.nodeId ?? '']
|
||||
if (!nodeErrors) return false
|
||||
|
||||
const slotName = props.slotData.name
|
||||
return nodeErrors.errors.some(
|
||||
(error) => error.extra_info?.input_name === slotName
|
||||
)
|
||||
})
|
||||
|
||||
const renderError = ref<string | null>(null)
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
LGraphNode as LGLGraphNode,
|
||||
SubgraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import NodeHeader from '@/renderer/extensions/vueNodes/components/NodeHeader.vue'
|
||||
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
|
||||
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
|
||||
|
||||
const mockApp: { rootGraph?: Partial<LGraph> } = vi.hoisted(() => ({}))
|
||||
@@ -56,7 +56,7 @@ vi.mock('@/i18n', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
describe('NodeHeader - Subgraph Functionality', () => {
|
||||
describe('Vue Node - Subgraph Functionality', () => {
|
||||
// Helper to setup common mocks
|
||||
const setupMocks = async (isSubgraph = true, hasGraph = true) => {
|
||||
if (hasGraph) mockApp.rootGraph = {}
|
||||
@@ -64,7 +64,7 @@ describe('NodeHeader - Subgraph Functionality', () => {
|
||||
|
||||
vi.mocked(getNodeByLocatorId).mockReturnValue({
|
||||
isSubgraphNode: (): this is SubgraphNode => isSubgraph
|
||||
} as LGraphNode)
|
||||
} as LGLGraphNode)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -89,8 +89,8 @@ describe('NodeHeader - Subgraph Functionality', () => {
|
||||
flags: {}
|
||||
})
|
||||
|
||||
const createWrapper = (props = {}) => {
|
||||
return mount(NodeHeader, {
|
||||
const createWrapper = (props: { nodeData: VueNodeData }) => {
|
||||
return mount(LGraphNode, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn })],
|
||||
@@ -106,8 +106,7 @@ describe('NodeHeader - Subgraph Functionality', () => {
|
||||
await setupMocks(true) // isSubgraph = true
|
||||
|
||||
const wrapper = createWrapper({
|
||||
nodeData: createMockNodeData('test-node-1'),
|
||||
readonly: false
|
||||
nodeData: createMockNodeData('test-node-1')
|
||||
})
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
@@ -120,8 +119,7 @@ describe('NodeHeader - Subgraph Functionality', () => {
|
||||
await setupMocks(false) // isSubgraph = false
|
||||
|
||||
const wrapper = createWrapper({
|
||||
nodeData: createMockNodeData('test-node-1'),
|
||||
readonly: false
|
||||
nodeData: createMockNodeData('test-node-1')
|
||||
})
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
@@ -130,29 +128,11 @@ describe('NodeHeader - Subgraph Functionality', () => {
|
||||
expect(subgraphButton.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('should emit enter-subgraph event when button is clicked', async () => {
|
||||
await setupMocks(true) // isSubgraph = true
|
||||
|
||||
const wrapper = createWrapper({
|
||||
nodeData: createMockNodeData('test-node-1'),
|
||||
readonly: false
|
||||
})
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]')
|
||||
await subgraphButton.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('enter-subgraph')).toBeTruthy()
|
||||
expect(wrapper.emitted('enter-subgraph')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should handle subgraph context correctly', async () => {
|
||||
await setupMocks(true) // isSubgraph = true
|
||||
|
||||
const wrapper = createWrapper({
|
||||
nodeData: createMockNodeData('test-node-1', 'subgraph-id'),
|
||||
readonly: false
|
||||
nodeData: createMockNodeData('test-node-1', 'subgraph-id')
|
||||
})
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
@@ -167,26 +147,11 @@ describe('NodeHeader - Subgraph Functionality', () => {
|
||||
expect(subgraphButton.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle missing graph gracefully', async () => {
|
||||
await setupMocks(true, false) // isSubgraph = true, hasGraph = false
|
||||
|
||||
const wrapper = createWrapper({
|
||||
nodeData: createMockNodeData('test-node-1'),
|
||||
readonly: false
|
||||
})
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]')
|
||||
expect(subgraphButton.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('should prevent event propagation on double click', async () => {
|
||||
await setupMocks(true) // isSubgraph = true
|
||||
|
||||
const wrapper = createWrapper({
|
||||
nodeData: createMockNodeData('test-node-1'),
|
||||
readonly: false
|
||||
nodeData: createMockNodeData('test-node-1')
|
||||
})
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
@@ -9,7 +9,7 @@
|
||||
:data-node-id="nodeData.id"
|
||||
:class="
|
||||
cn(
|
||||
'bg-component-node-background lg-node absolute text-sm',
|
||||
'group/node bg-node-component-header-surface lg-node absolute text-sm',
|
||||
'contain-style contain-layout min-w-[225px] min-h-(--node-height) w-(--node-width)',
|
||||
shapeClass,
|
||||
'touch-none flex flex-col',
|
||||
@@ -28,11 +28,9 @@
|
||||
muted,
|
||||
'ring-4 ring-primary-500 bg-primary-500/10': isDraggingOver
|
||||
},
|
||||
|
||||
shouldHandleNodePointerEvents && !nodeData.flags?.ghost
|
||||
? 'pointer-events-auto'
|
||||
: 'pointer-events-none',
|
||||
!isCollapsed && ' pb-1'
|
||||
: 'pointer-events-none'
|
||||
)
|
||||
"
|
||||
:style="[
|
||||
@@ -40,7 +38,8 @@
|
||||
transform: `translate(${position.x ?? 0}px, ${(position.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
|
||||
zIndex: zIndex,
|
||||
opacity: nodeOpacity,
|
||||
'--component-node-background': applyLightThemeColor(nodeData.bgcolor)
|
||||
'--component-node-background': applyLightThemeColor(nodeData.bgcolor),
|
||||
backgroundColor: applyLightThemeColor(nodeData?.color)
|
||||
}
|
||||
]"
|
||||
v-bind="remainingPointerHandlers"
|
||||
@@ -71,9 +70,9 @@
|
||||
<NodeHeader
|
||||
:node-data="nodeData"
|
||||
:collapsed="isCollapsed"
|
||||
:price-badges="badges.pricing"
|
||||
@collapse="handleCollapse"
|
||||
@update:title="handleHeaderTitleUpdate"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -89,7 +88,7 @@
|
||||
/>
|
||||
|
||||
<template v-if="!isCollapsed">
|
||||
<div class="relative mb-1">
|
||||
<div class="relative">
|
||||
<!-- Progress bar for executing state -->
|
||||
<div
|
||||
v-if="executing && progress !== undefined"
|
||||
@@ -105,7 +104,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-1 flex-col gap-1 pb-2"
|
||||
class="flex flex-1 flex-col gap-1 pt-1 pb-3 bg-component-node-background rounded-b-2xl"
|
||||
:data-testid="`node-body-${nodeData.id}`"
|
||||
>
|
||||
<NodeSlots :node-data="nodeData" />
|
||||
@@ -120,42 +119,75 @@
|
||||
v-if="shouldShowPreviewImg"
|
||||
:image-url="latestPreviewUrl"
|
||||
/>
|
||||
|
||||
<!-- Show advanced inputs button for subgraph nodes -->
|
||||
<div v-if="showAdvancedInputsButton" class="flex justify-center px-3">
|
||||
<button
|
||||
:class="
|
||||
cn(
|
||||
WidgetInputBaseClass,
|
||||
'w-full h-7 flex justify-center items-center gap-2 text-sm px-3 outline-0 ring-0 truncate',
|
||||
'transition-all cursor-pointer hover:bg-accent-background duration-150 active:scale-95'
|
||||
)
|
||||
"
|
||||
@click.stop="showAdvancedState = !showAdvancedState"
|
||||
>
|
||||
<template v-if="showAdvancedState">
|
||||
<i class="icon-[lucide--chevron-up] size-4" />
|
||||
<span>{{ t('rightSidePanel.hideAdvancedInputsButton') }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="icon-[lucide--settings-2] size-4" />
|
||||
<span>{{ t('rightSidePanel.showAdvancedInputsButton') }} </span>
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
<NodeBadges v-bind="badges" :pricing="undefined" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Button
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
'w-full h-12 rounded-b-2xl -mt-5 pt-7 pb-2 -z-1 text-xs',
|
||||
hasAnyError && 'hover:bg-destructive-background-hover'
|
||||
)
|
||||
"
|
||||
as-child
|
||||
>
|
||||
<button
|
||||
v-if="hasAnyError"
|
||||
@click.stop="useRightSidePanelStore().openPanel('error')"
|
||||
>
|
||||
<span>{{ t('g.error') }}</span>
|
||||
<i class="icon-[lucide--info] size-4" />
|
||||
</button>
|
||||
<button
|
||||
v-else-if="lgraphNode?.isSubgraphNode()"
|
||||
data-testid="subgraph-enter-button"
|
||||
@click.stop="handleEnterSubgraph"
|
||||
>
|
||||
<span>{{ t('g.enterSubgraph') }}</span>
|
||||
<i class="icon-[comfy--workflow] size-4" />
|
||||
</button>
|
||||
<button
|
||||
v-else-if="showAdvancedState || showAdvancedInputsButton"
|
||||
@click.stop="showAdvancedState = !showAdvancedState"
|
||||
>
|
||||
<template v-if="showAdvancedState">
|
||||
<span>{{ t('rightSidePanel.hideAdvancedInputsButton') }}</span>
|
||||
<i class="icon-[lucide--chevron-up] size-4" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>{{ t('rightSidePanel.showAdvancedInputsButton') }} </span>
|
||||
<i class="icon-[lucide--settings-2] size-4" />
|
||||
</template>
|
||||
</button>
|
||||
</Button>
|
||||
<!-- Resize handle (bottom-right only) -->
|
||||
<div
|
||||
v-if="!isCollapsed && nodeData.resizable !== false"
|
||||
role="button"
|
||||
:aria-label="t('g.resizeFromBottomRight')"
|
||||
:class="
|
||||
cn(baseResizeHandleClasses, '-right-1 -bottom-1 cursor-se-resize')
|
||||
cn(
|
||||
baseResizeHandleClasses,
|
||||
'-right-1 -bottom-1 cursor-se-resize group-hover/node:opacity-100'
|
||||
)
|
||||
"
|
||||
@pointerdown.stop="handleResizePointerDown"
|
||||
/>
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 12 12"
|
||||
class="w-2/5 h-2/5 top-1 left-1 absolute"
|
||||
>
|
||||
<path
|
||||
d="M11 1L1 11M11 6L6 11"
|
||||
stroke="var(--color-muted-foreground)"
|
||||
stroke-width="0.975"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -172,6 +204,7 @@ import {
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
@@ -189,10 +222,12 @@ import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import NodeBadges from '@/renderer/extensions/vueNodes/components/NodeBadges.vue'
|
||||
import SlotConnectionDot from '@/renderer/extensions/vueNodes/components/SlotConnectionDot.vue'
|
||||
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
|
||||
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
|
||||
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
|
||||
import { usePartitionedBadges } from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
|
||||
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
|
||||
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
|
||||
import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag'
|
||||
@@ -212,7 +247,6 @@ import {
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useNodeResize } from '../interactions/resize/useNodeResize'
|
||||
import { WidgetInputBaseClass } from '../widgets/components/layout'
|
||||
import LivePreview from './LivePreview.vue'
|
||||
import NodeContent from './NodeContent.vue'
|
||||
import NodeHeader from './NodeHeader.vue'
|
||||
@@ -299,6 +333,7 @@ const { position, size, zIndex } = useNodeLayout(() => nodeData.id)
|
||||
const { pointerHandlers } = useNodePointerInteractions(() => nodeData.id)
|
||||
const { onPointerdown, ...remainingPointerHandlers } = pointerHandlers
|
||||
const { startDrag } = useNodeDrag()
|
||||
const badges = usePartitionedBadges(nodeData)
|
||||
|
||||
async function nodeOnPointerdown(event: PointerEvent) {
|
||||
if (event.altKey && lgraphNode.value) {
|
||||
@@ -405,7 +440,7 @@ const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
|
||||
)
|
||||
|
||||
const borderClass = computed(() => {
|
||||
if (hasAnyError.value) return 'border-node-stroke-error'
|
||||
if (hasAnyError.value) return 'border-node-stroke-error bg-error'
|
||||
//FIXME need a better way to detecting transparency
|
||||
if (
|
||||
!displayHeader.value &&
|
||||
|
||||
55
src/renderer/extensions/vueNodes/components/NodeBadges.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import NodeBadge from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
|
||||
import type { NodeBadgeProps } from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
|
||||
|
||||
defineProps<{
|
||||
hasComfyBadge: boolean
|
||||
core: NodeBadgeProps[]
|
||||
extension: NodeBadgeProps[]
|
||||
}>()
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
v-if="hasComfyBadge || core.length || extension.length"
|
||||
class="flex h-5 w-full gap-2 px-2 text-muted-foreground"
|
||||
>
|
||||
<div
|
||||
v-if="hasComfyBadge"
|
||||
class="rounded-full bg-component-node-widget-background size-6 flex justify-center items-center"
|
||||
>
|
||||
<i class="icon-[comfy--comfy-c] size-3" />
|
||||
</div>
|
||||
<div
|
||||
v-if="core.length"
|
||||
class="rounded-full bg-component-node-widget-background h-6 flex justify-center items-center overflow-clip"
|
||||
>
|
||||
<template v-for="(badge, index) of core" :key="badge.text">
|
||||
<div
|
||||
v-if="index !== 0"
|
||||
class="border-muted-foreground border-r h-4 mr-0.5 pr-0.5"
|
||||
/>
|
||||
<NodeBadge
|
||||
bg-color="transparent"
|
||||
v-bind="badge"
|
||||
class="h-6 first:pl-2 last:pr-2"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-if="extension.length"
|
||||
class="rounded-full bg-component-node-widget-background h-6 flex justify-center items-center overflow-clip"
|
||||
>
|
||||
<template v-for="(badge, index) of extension" :key="badge.text">
|
||||
<div
|
||||
v-if="index !== 0"
|
||||
class="border-muted-foreground border-r h-4 mr-0.5 pr-0.5"
|
||||
/>
|
||||
<NodeBadge
|
||||
bg-color="transparent"
|
||||
v-bind="badge"
|
||||
class="h-6 first:pl-2 last:pr-2"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -7,20 +7,16 @@
|
||||
:class="
|
||||
cn(
|
||||
'lg-node-header text-sm py-2 pl-2 pr-3 w-full min-w-0',
|
||||
'text-node-component-header bg-node-component-header-surface',
|
||||
'text-node-component-header',
|
||||
headerShapeClass
|
||||
)
|
||||
"
|
||||
:style="{
|
||||
backgroundColor: applyLightThemeColor(nodeData?.color),
|
||||
opacity: useSettingStore().get('Comfy.Node.Opacity') ?? 1
|
||||
}"
|
||||
:data-testid="`node-header-${nodeData?.id || ''}`"
|
||||
@dblclick="handleDoubleClick"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2.5 min-w-0">
|
||||
<!-- Collapse/Expand Button -->
|
||||
<div class="relative grow-1 flex items-center gap-2.5 min-w-0 flex-1">
|
||||
<div class="relative flex items-center gap-2.5 min-w-0 shrink-1 mr-auto">
|
||||
<div class="flex shrink-0 items-center px-0.5">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
@@ -41,17 +37,13 @@
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="isSubgraphNode" class="icon-[comfy--workflow] size-4" />
|
||||
<div v-if="isApiNode" class="icon-[lucide--component] size-4" />
|
||||
|
||||
<!-- Node Title -->
|
||||
<div
|
||||
v-tooltip.top="tooltipConfig"
|
||||
class="flex min-w-0 flex-1 items-center gap-2"
|
||||
data-testid="node-title"
|
||||
>
|
||||
<div class="truncate min-w-0 flex-1">
|
||||
<div class="truncate flex-1">
|
||||
<EditableText
|
||||
:model-value="displayTitle"
|
||||
:is-editing="isEditing"
|
||||
@@ -63,56 +55,46 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center justify-between gap-2">
|
||||
<NodeBadge
|
||||
v-for="badge of nodeBadges"
|
||||
:key="badge.text"
|
||||
v-bind="badge"
|
||||
/>
|
||||
<NodeBadge v-if="statusBadge" v-bind="statusBadge" />
|
||||
<i-comfy:pin
|
||||
v-if="isPinned"
|
||||
class="size-5"
|
||||
data-testid="node-pin-indicator"
|
||||
/>
|
||||
<Button
|
||||
v-if="isSubgraphNode"
|
||||
v-tooltip.top="enterSubgraphTooltipConfig"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
data-testid="subgraph-enter-button"
|
||||
class="text-node-component-header h-5 px-0.5"
|
||||
@click.stop="handleEnterSubgraph"
|
||||
@dblclick.stop
|
||||
<template v-for="badge in priceBadges ?? []" :key="badge.required">
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'flex h-5 bg-component-node-widget-background p-1 items-center text-xs shrink-0',
|
||||
badge.rest ? 'rounded-l-full pr-1' : 'rounded-full'
|
||||
)
|
||||
"
|
||||
>
|
||||
<span>{{ $t('g.edit') }}</span>
|
||||
<i class="icon-[lucide--scaling] size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
<i class="h-full icon-[lucide--component] bg-amber-400" />
|
||||
<span class="truncate" v-text="badge.required" />
|
||||
</span>
|
||||
<span
|
||||
v-if="badge.rest"
|
||||
class="truncate -ml-2.5 grow-1 basis-0 bg-component-node-widget-background rounded-r-full max-w-max min-w-0"
|
||||
>
|
||||
<span class="pr-2" v-text="badge.rest" />
|
||||
</span>
|
||||
</template>
|
||||
<NodeBadge v-if="statusBadge" v-bind="statusBadge" />
|
||||
<i
|
||||
v-if="isPinned"
|
||||
class="size-5 icon-[comfy--pin]"
|
||||
data-testid="node-pin-indicator"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onErrorCaptured, ref, toValue, watch } from 'vue'
|
||||
import { computed, onErrorCaptured, ref, watch } from 'vue'
|
||||
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useNodePricing } from '@/composables/node/useNodePricing'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { st } from '@/i18n'
|
||||
import { LGraphEventMode, RenderShape } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import NodeBadge from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
|
||||
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
||||
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import {
|
||||
getLocatorIdFromNodeData,
|
||||
getNodeByLocatorId
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
@@ -121,6 +103,7 @@ import type { NodeBadgeProps } from './NodeBadge.vue'
|
||||
interface NodeHeaderProps {
|
||||
nodeData?: VueNodeData
|
||||
collapsed?: boolean
|
||||
priceBadges?: { required: string; rest?: string }[]
|
||||
}
|
||||
|
||||
const { nodeData, collapsed } = defineProps<NodeHeaderProps>()
|
||||
@@ -128,7 +111,6 @@ const { nodeData, collapsed } = defineProps<NodeHeaderProps>()
|
||||
const emit = defineEmits<{
|
||||
collapse: []
|
||||
'update:title': [newTitle: string]
|
||||
'enter-subgraph': []
|
||||
}>()
|
||||
|
||||
// Error boundary implementation
|
||||
@@ -156,10 +138,6 @@ const tooltipConfig = computed(() => {
|
||||
return createTooltipConfig(description)
|
||||
})
|
||||
|
||||
const enterSubgraphTooltipConfig = computed(() => {
|
||||
return createTooltipConfig(st('enterSubgraph', 'Enter Subgraph'))
|
||||
})
|
||||
|
||||
const resolveTitle = (info: VueNodeData | undefined) => {
|
||||
const untitledLabel = st('g.untitled', 'Untitled')
|
||||
return resolveNodeDisplayName(info ?? null, {
|
||||
@@ -185,71 +163,7 @@ const statusBadge = computed((): NodeBadgeProps | undefined =>
|
||||
: undefined
|
||||
)
|
||||
|
||||
// Use per-node pricing revision to re-compute badges only when this node's pricing updates
|
||||
const {
|
||||
getRelevantWidgetNames,
|
||||
hasDynamicPricing,
|
||||
getInputGroupPrefixes,
|
||||
getInputNames,
|
||||
getNodeRevisionRef
|
||||
} = useNodePricing()
|
||||
// Cache pricing metadata (won't change during node lifetime)
|
||||
const isDynamicPricing = computed(() =>
|
||||
nodeData?.apiNode ? hasDynamicPricing(nodeData.type) : false
|
||||
)
|
||||
const relevantPricingWidgets = computed(() =>
|
||||
nodeData?.apiNode ? getRelevantWidgetNames(nodeData.type) : []
|
||||
)
|
||||
const inputGroupPrefixes = computed(() =>
|
||||
nodeData?.apiNode ? getInputGroupPrefixes(nodeData.type) : []
|
||||
)
|
||||
const relevantInputNames = computed(() =>
|
||||
nodeData?.apiNode ? getInputNames(nodeData.type) : []
|
||||
)
|
||||
const nodeBadges = computed<NodeBadgeProps[]>(() => {
|
||||
// For ALL API nodes: access per-node revision ref to detect when async pricing evaluation completes
|
||||
// This is needed even for static pricing because JSONata 2.x evaluation is async
|
||||
if (nodeData?.apiNode && nodeData?.id != null) {
|
||||
// Access per-node revision ref to establish dependency (each node has its own ref)
|
||||
void getNodeRevisionRef(nodeData.id).value
|
||||
|
||||
// For dynamic pricing, also track widget values and input connections
|
||||
if (isDynamicPricing.value) {
|
||||
// Access only the widget values that affect pricing (from widgetValueStore)
|
||||
const relevantNames = relevantPricingWidgets.value
|
||||
const widgetStore = useWidgetValueStore()
|
||||
if (relevantNames.length > 0 && nodeData?.id != null) {
|
||||
for (const name of relevantNames) {
|
||||
// Access value from store to create reactive dependency
|
||||
void widgetStore.getWidget(nodeData.id, name)?.value
|
||||
}
|
||||
}
|
||||
// Access input connections for regular inputs
|
||||
const inputNames = relevantInputNames.value
|
||||
if (inputNames.length > 0) {
|
||||
nodeData?.inputs?.forEach((inp) => {
|
||||
if (inp.name && inputNames.includes(inp.name)) {
|
||||
void inp.link // Access link to create reactive dependency
|
||||
}
|
||||
})
|
||||
}
|
||||
// Access input connections for input_groups (e.g., autogrow inputs)
|
||||
const groupPrefixes = inputGroupPrefixes.value
|
||||
if (groupPrefixes.length > 0) {
|
||||
nodeData?.inputs?.forEach((inp) => {
|
||||
if (
|
||||
groupPrefixes.some((prefix) => inp.name?.startsWith(prefix + '.'))
|
||||
) {
|
||||
void inp.link // Access link to create reactive dependency
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...(nodeData?.badges ?? [])].map(toValue)
|
||||
})
|
||||
const isPinned = computed(() => Boolean(nodeData?.flags?.pinned))
|
||||
const isApiNode = computed(() => Boolean(nodeData?.apiNode))
|
||||
|
||||
const headerShapeClass = computed(() => {
|
||||
if (collapsed) {
|
||||
@@ -272,22 +186,6 @@ const headerShapeClass = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// Subgraph detection
|
||||
const isSubgraphNode = computed(() => {
|
||||
if (!nodeData?.id) return false
|
||||
|
||||
// Get the underlying LiteGraph node
|
||||
const graph = app.rootGraph
|
||||
if (!graph) return false
|
||||
|
||||
const locatorId = getLocatorIdFromNodeData(nodeData)
|
||||
|
||||
const litegraphNode = getNodeByLocatorId(graph, locatorId)
|
||||
|
||||
// Use the official type guard method
|
||||
return litegraphNode?.isSubgraphNode() ?? false
|
||||
})
|
||||
|
||||
// Watch for external changes to the node title or type
|
||||
watch(
|
||||
() => [nodeData?.title, nodeData?.type] as const,
|
||||
@@ -320,8 +218,4 @@ const handleTitleEdit = (newTitle: string) => {
|
||||
const handleTitleCancel = () => {
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
const handleEnterSubgraph = () => {
|
||||
emit('enter-subgraph')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
boundingRect: [0, 0, 0, 0]
|
||||
}"
|
||||
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
|
||||
:has-error="widget.hasError"
|
||||
:index="widget.slotMetadata.index"
|
||||
:socketless="widget.simplified.spec?.socketless"
|
||||
dot-only
|
||||
@@ -60,7 +61,12 @@
|
||||
:widget="widget.simplified"
|
||||
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
|
||||
:node-type="nodeType"
|
||||
class="col-span-2"
|
||||
:class="
|
||||
cn(
|
||||
'col-span-2',
|
||||
widget.hasError && 'text-node-stroke-error font-bold'
|
||||
)
|
||||
"
|
||||
@update:model-value="widget.updateHandler"
|
||||
/>
|
||||
</div>
|
||||
@@ -95,6 +101,7 @@ import {
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
@@ -109,6 +116,7 @@ const { nodeData } = defineProps<NodeWidgetsProps>()
|
||||
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
|
||||
useCanvasInteractions()
|
||||
const { bringNodeToFront } = useNodeZIndex()
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
function handleWidgetPointerEvent(event: PointerEvent) {
|
||||
if (shouldHandleNodePointerEvents.value) return
|
||||
@@ -146,21 +154,23 @@ const { getWidgetTooltip, createTooltipConfig } = useNodeTooltips(
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
|
||||
interface ProcessedWidget {
|
||||
name: string
|
||||
type: string
|
||||
vueComponent: Component
|
||||
simplified: SimplifiedWidget
|
||||
value: WidgetValue
|
||||
updateHandler: (value: WidgetValue) => void
|
||||
tooltipConfig: TooltipOptions
|
||||
slotMetadata?: WidgetSlotMetadata
|
||||
hidden: boolean
|
||||
advanced: boolean
|
||||
hasLayoutSize: boolean
|
||||
hasError: boolean
|
||||
hidden: boolean
|
||||
name: string
|
||||
simplified: SimplifiedWidget
|
||||
tooltipConfig: TooltipOptions
|
||||
type: string
|
||||
updateHandler: (value: WidgetValue) => void
|
||||
value: WidgetValue
|
||||
vueComponent: Component
|
||||
slotMetadata?: WidgetSlotMetadata
|
||||
}
|
||||
|
||||
const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
if (!nodeData?.widgets) return []
|
||||
const nodeErrors = executionStore.lastNodeErrors?.[nodeData.id ?? '']
|
||||
|
||||
const nodeId = nodeData.id
|
||||
const { widgets } = nodeData
|
||||
@@ -220,6 +230,13 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
const tooltipConfig = createTooltipConfig(tooltipText)
|
||||
|
||||
result.push({
|
||||
advanced: widget.options?.advanced ?? false,
|
||||
hasLayoutSize: widget.hasLayoutSize ?? false,
|
||||
hasError:
|
||||
nodeErrors?.errors?.some(
|
||||
(error) => error.extra_info?.input_name === widget.name
|
||||
) ?? false,
|
||||
hidden: widget.options?.hidden ?? false,
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
vueComponent,
|
||||
@@ -227,10 +244,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
value,
|
||||
updateHandler,
|
||||
tooltipConfig,
|
||||
slotMetadata,
|
||||
hidden: widget.options?.hidden ?? false,
|
||||
advanced: widget.options?.advanced ?? false,
|
||||
hasLayoutSize: widget.hasLayoutSize ?? false
|
||||
slotMetadata
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { trim } from 'es-toolkit'
|
||||
import { computed, toValue } from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useNodePricing } from '@/composables/node/useNodePricing'
|
||||
import { usePriceBadge } from '@/composables/node/usePriceBadge'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { NodeBadgeProps } from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
|
||||
function splitAroundFirstSpace(text: string): [string, string | undefined] {
|
||||
const index = text.indexOf(' ')
|
||||
if (index === -1) return [text, undefined]
|
||||
return [text.slice(0, index), text.slice(index + 1)]
|
||||
}
|
||||
|
||||
export function usePartitionedBadges(nodeData: VueNodeData) {
|
||||
// Use per-node pricing revision to re-compute badges only when this node's pricing updates
|
||||
const {
|
||||
getRelevantWidgetNames,
|
||||
hasDynamicPricing,
|
||||
getInputGroupPrefixes,
|
||||
getInputNames,
|
||||
getNodeRevisionRef
|
||||
} = useNodePricing()
|
||||
|
||||
const { isCreditsBadge } = usePriceBadge()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
// Cache pricing metadata (won't change during node lifetime)
|
||||
const isDynamicPricing = computed(() =>
|
||||
nodeData?.apiNode ? hasDynamicPricing(nodeData.type) : false
|
||||
)
|
||||
const relevantPricingWidgets = computed(() =>
|
||||
nodeData?.apiNode ? getRelevantWidgetNames(nodeData.type) : []
|
||||
)
|
||||
const inputGroupPrefixes = computed(() =>
|
||||
nodeData?.apiNode ? getInputGroupPrefixes(nodeData.type) : []
|
||||
)
|
||||
const relevantInputNames = computed(() =>
|
||||
nodeData?.apiNode ? getInputNames(nodeData.type) : []
|
||||
)
|
||||
const unpartitionedBadges = computed<NodeBadgeProps[]>(() => {
|
||||
// For ALL API nodes: access per-node revision ref to detect when async pricing evaluation completes
|
||||
// This is needed even for static pricing because JSONata 2.x evaluation is async
|
||||
if (nodeData?.apiNode && nodeData?.id != null) {
|
||||
// Access per-node revision ref to establish dependency (each node has its own ref)
|
||||
void getNodeRevisionRef(nodeData.id).value
|
||||
|
||||
// For dynamic pricing, also track widget values and input connections
|
||||
if (isDynamicPricing.value) {
|
||||
// Access only the widget values that affect pricing (from widgetValueStore)
|
||||
const relevantNames = relevantPricingWidgets.value
|
||||
const widgetStore = useWidgetValueStore()
|
||||
if (relevantNames.length > 0 && nodeData?.id != null) {
|
||||
for (const name of relevantNames) {
|
||||
// Access value from store to create reactive dependency
|
||||
void widgetStore.getWidget(nodeData.id, name)?.value
|
||||
}
|
||||
}
|
||||
// Access input connections for regular inputs
|
||||
const inputNames = relevantInputNames.value
|
||||
if (inputNames.length > 0) {
|
||||
nodeData?.inputs?.forEach((inp) => {
|
||||
if (inp.name && inputNames.includes(inp.name)) {
|
||||
void inp.link // Access link to create reactive dependency
|
||||
}
|
||||
})
|
||||
}
|
||||
// Access input connections for input_groups (e.g., autogrow inputs)
|
||||
const groupPrefixes = inputGroupPrefixes.value
|
||||
if (groupPrefixes.length > 0) {
|
||||
nodeData?.inputs?.forEach((inp) => {
|
||||
if (
|
||||
groupPrefixes.some((prefix) => inp.name?.startsWith(prefix + '.'))
|
||||
) {
|
||||
void inp.link // Access link to create reactive dependency
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...(nodeData?.badges ?? [])].map(toValue)
|
||||
})
|
||||
const nodeDef = useNodeDefStore().nodeDefsByName[nodeData.type]
|
||||
return computed(() => {
|
||||
const displaySource = settingStore.get(
|
||||
'Comfy.NodeBadge.NodeSourceBadgeMode'
|
||||
)
|
||||
const isCoreNode =
|
||||
nodeDef?.isCoreNode && displaySource === NodeBadgeMode.ShowAll
|
||||
const core: NodeBadgeProps[] = []
|
||||
const extension: NodeBadgeProps[] = []
|
||||
const pricing: { required: string; rest?: string }[] = []
|
||||
if (
|
||||
settingStore.get('Comfy.NodeBadge.NodeLifeCycleBadgeMode') !==
|
||||
NodeBadgeMode.None
|
||||
) {
|
||||
const lifecycleText = nodeDef?.nodeLifeCycleBadgeText ?? ''
|
||||
const trimmed = trim(lifecycleText, ['[', ']'])
|
||||
if (trimmed) core.push({ text: trimmed })
|
||||
}
|
||||
if (
|
||||
settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') !== NodeBadgeMode.None
|
||||
)
|
||||
core.push({ text: `#${nodeData.id}` })
|
||||
const sourceText = nodeDef?.nodeSource?.badgeText
|
||||
if (
|
||||
!nodeDef?.isCoreNode &&
|
||||
displaySource !== NodeBadgeMode.None &&
|
||||
sourceText
|
||||
)
|
||||
core.push({ text: sourceText })
|
||||
|
||||
for (const badge of unpartitionedBadges.value.slice(1)) {
|
||||
if (!badge.text) continue
|
||||
|
||||
if (isCreditsBadge(badge)) {
|
||||
const [required, rest] = splitAroundFirstSpace(badge.text)
|
||||
pricing.push({ required, rest })
|
||||
continue
|
||||
}
|
||||
extension.push(badge)
|
||||
}
|
||||
|
||||
return {
|
||||
hasComfyBadge: isCoreNode && pricing.length === 0,
|
||||
core,
|
||||
extension,
|
||||
pricing
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { computed, ref, watch } from 'vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
export type RightSidePanelTab =
|
||||
| 'error'
|
||||
| 'parameters'
|
||||
| 'nodes'
|
||||
| 'settings'
|
||||
|
||||