[style] Update error/subgraph node footer design with layered overlay approach (#9360)

## Summary

Refactors the error and subgraph node footer UI by extracting a
dedicated `NodeFooter` component and replacing the CSS `outline`
approach with a layered border overlay for selection/executing state
indicators.

## Changes

- **What**: Extracted `NodeFooter.vue` from `LGraphNode.vue` to
encapsulate the footer tab logic (subgraph enter, error, advanced
inputs). Replaced CSS `outline` with an absolutely-positioned border
overlay div for selection and executing state. Added a separate root
border overlay div for the node body border. Removed unused
`isTransparent` function from `colorUtil.ts`.
- **Dependencies**: None

## Review Focus

- The layered overlay approach (`absolute -inset-[3px] border-3`) for
selection/executing outlines vs the previous `outline-3` approach —
ensures the outline renders outside the node bounds correctly including
the footer area
- `NodeFooter` handles 4 cases: subgraph+error (dual tabs), error only,
subgraph only, advanced inputs — verify edge cases render correctly
- Resize handle bottom offset adjustments for nodes with footers
(`hasFooter`)

## Screenshots
<img width="1142" height="603" alt="image"
src="https://github.com/user-attachments/assets/e0d401f0-8516-4f5f-ab77-48a79530f4bd"
/>
<img width="1175" height="577" alt="image"
src="https://github.com/user-attachments/assets/bcf08fff-728a-491c-add9-5b96d2f3bfce"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9360-style-Update-error-subgraph-node-footer-design-with-layered-overlay-approach-3186d73d365081b2ac31f166f4d1944a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
jaeone94
2026-03-07 10:51:08 +09:00
committed by GitHub
parent 3366079f59
commit 8bfd93963f
31 changed files with 486 additions and 206 deletions

View File

@@ -172,6 +172,19 @@ export class VueNodeHelpers {
async enterSubgraph(nodeId?: string): Promise<void> {
const locator = nodeId ? this.getNodeLocator(nodeId) : this.page
const editButton = locator.getByTestId(TestIds.widgets.subgraphEnterButton)
await editButton.click()
// The footer tab button extends below the node body (visible area),
// but its bounding box center overlaps the node body div.
// Click at the bottom 25% of the button which is the genuinely visible
// and unobstructed area outside the node body boundary.
const box = await editButton.boundingBox()
if (!box) {
throw new Error(
'subgraph-enter-button has no bounding box: element may be hidden or not in DOM'
)
}
await editButton.click({
position: { x: box.width / 2, y: box.height * 0.75 }
})
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -22,8 +22,10 @@ test.describe('Vue Node Bypass', () => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
const checkpointNode =
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const checkpointNode = comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: 'Load Checkpoint' })
.getByTestId('node-inner-wrapper')
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
@@ -41,8 +43,14 @@ test.describe('Vue Node Bypass', () => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler')
const checkpointNode = comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: 'Load Checkpoint' })
.getByTestId('node-inner-wrapper')
const ksamplerNode = comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: 'KSampler' })
.getByTestId('node-inner-wrapper')
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 139 KiB

View File

@@ -3,7 +3,7 @@ import {
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
const ERROR_CLASS = /border-node-stroke-error/
const ERROR_CLASS = /ring-destructive-background/
test.describe('Vue Node Error', () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -18,9 +18,10 @@ test.describe('Vue Node Error', () => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
// Expect error state on missing unknown node
const unknownNode = comfyPage.page.locator('[data-node-id]').filter({
hasText: 'UNKNOWN NODE'
})
const unknownNode = comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: 'UNKNOWN NODE' })
.getByTestId('node-inner-wrapper')
await expect(unknownNode).toHaveClass(ERROR_CLASS)
})
@@ -31,7 +32,10 @@ test.describe('Vue Node Error', () => {
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
await comfyPage.runButton.click()
const raiseErrorNode = comfyPage.vueNodes.getNodeByTitle('Raise Error')
const raiseErrorNode = comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: 'Raise Error' })
.getByTestId('node-inner-wrapper')
await expect(raiseErrorNode).toHaveClass(ERROR_CLASS)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -41,6 +41,23 @@ vi.mock(
})
)
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: { getNodeById: vi.fn() },
canvas: { setDirty: vi.fn() }
}
}))
vi.mock('@/utils/graphTraversalUtil', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
getNodeByLocatorId: vi.fn(() => ({
isSubgraphNode: () => false
}))
}
})
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
toastErrorHandler: vi.fn()
@@ -184,8 +201,13 @@ describe('LGraphNode', () => {
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
expect(wrapper.classes()).toContain('outline-3')
// Root div should have the selection class
expect(wrapper.classes()).toContain('outline-node-component-outline')
// The layered outline overlay should be present
const overlay = wrapper.find('[data-testid="node-state-outline-overlay"]')
expect(overlay.exists()).toBe(true)
expect(overlay.classes()).toContain('border-node-component-outline')
})
it('should render progress indicator when executing prop is true', () => {
@@ -193,7 +215,13 @@ describe('LGraphNode', () => {
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
// Root div should have the executing class
expect(wrapper.classes()).toContain('outline-node-stroke-executing')
// The layered outline overlay should be present
const overlay = wrapper.find('[data-testid="node-state-outline-overlay"]')
expect(overlay.exists()).toBe(true)
expect(overlay.classes()).toContain('border-node-stroke-executing')
})
it('should initialize height CSS vars for collapsed nodes', () => {

View File

@@ -9,25 +9,11 @@
:data-node-id="nodeData.id"
:class="
cn(
'group/node lg-node absolute bg-node-component-header-surface text-sm',
'min-h-(--node-height) w-(--node-width) min-w-[225px] contain-layout contain-style',
shapeClass,
'flex touch-none flex-col',
'border border-solid border-component-node-border',
// hover (only when node should handle events)
shouldHandleNodePointerEvents &&
'ring-node-component-ring hover:ring-7',
'outline-3 outline-transparent focus-visible:outline-node-component-outline',
borderClass,
outlineClass,
'group/node lg-node absolute text-sm',
'flex min-w-[225px] flex-col contain-layout contain-style',
cursorClass,
{
[`${beforeShapeClass} before:pointer-events-none before:absolute before:inset-0 before:bg-bypass/60`]:
bypassed,
[`${beforeShapeClass} before:pointer-events-none before:absolute before:inset-0`]:
muted,
'bg-primary-500/10 ring-4 ring-primary-500': isDraggingOver
},
isSelected && 'outline-node-component-outline',
executing && 'outline-node-stroke-executing',
shouldHandleNodePointerEvents && !nodeData.flags?.ghost
? 'pointer-events-auto'
: 'pointer-events-none'
@@ -37,9 +23,7 @@
{
transform: `translate(${position.x ?? 0}px, ${(position.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
zIndex: zIndex,
opacity: nodeOpacity,
'--component-node-background': applyLightThemeColor(nodeData.bgcolor),
backgroundColor: applyLightThemeColor(nodeData?.color)
opacity: nodeOpacity
}
]"
v-bind="remainingPointerHandlers"
@@ -50,6 +34,7 @@
@dragleave="handleDragLeave"
@drop.stop.prevent="handleDrop"
>
<!-- Selection/Execution Outline Overlay -->
<AppOutput
v-if="
lgraphNode?.constructor?.nodeData?.output_node &&
@@ -59,166 +44,163 @@
:id="nodeData.id"
/>
<div
v-if="displayHeader"
class="relative flex flex-col items-center justify-center"
>
<template v-if="isCollapsed">
<SlotConnectionDot
v-if="hasInputs"
multi
class="absolute left-0 -translate-x-1/2"
/>
<SlotConnectionDot
v-if="hasOutputs"
multi
class="absolute right-0 translate-x-1/2"
/>
<NodeSlots :node-data="nodeData" unified />
</template>
<NodeHeader
:node-data="nodeData"
:collapsed="isCollapsed"
:price-badges="badges.pricing"
@collapse="handleCollapse"
@update:title="handleHeaderTitleUpdate"
/>
</div>
<div
v-if="isCollapsed && executing && progress !== undefined"
v-if="isSelected || executing"
data-testid="node-state-outline-overlay"
:class="
cn(
'absolute inset-x-4 -bottom-px translate-y-1/2 rounded-full',
progressClasses
'pointer-events-none absolute z-0 border-3 outline-none',
selectionShapeClass,
hasAnyError ? '-inset-[7px]' : '-inset-[3px]',
isSelected
? 'border-node-component-outline'
: 'border-node-stroke-executing',
footerStateOutlineBottomClass
)
"
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
/>
<template v-if="!isCollapsed">
<div class="relative">
<!-- Progress bar for executing state -->
<div
v-if="executing && progress !== undefined"
:class="
cn(
'absolute inset-x-0 top-1/2 -translate-y-1/2',
!!(progress < 1) && 'rounded-r-full',
progressClasses
)
"
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
<!-- Root Border Overlay -->
<div
:class="
cn(
'pointer-events-none absolute border border-solid border-component-node-border',
rootBorderShapeClass,
hasAnyError ? '-inset-1' : 'inset-0',
footerRootBorderBottomClass
)
"
/>
<div
data-testid="node-inner-wrapper"
:class="
cn(
'flex flex-1 flex-col border border-solid border-transparent bg-node-component-header-surface',
'min-h-(--node-height) w-(--node-width)',
shapeClass,
hasAnyError && 'ring-4 ring-destructive-background',
{
[`${beforeShapeClass} before:pointer-events-none before:absolute before:inset-0 before:bg-bypass/60`]:
bypassed,
[`${beforeShapeClass} before:pointer-events-none before:absolute before:inset-0`]:
muted,
'bg-primary-500/10 ring-4 ring-primary-500': isDraggingOver
}
)
"
:style="{
'--component-node-background': applyLightThemeColor(nodeData.bgcolor),
backgroundColor: applyLightThemeColor(nodeData?.color)
}"
>
<AppOutput
v-if="lgraphNode?.constructor?.nodeData?.output_node && isSelectMode"
:id="nodeData.id"
/>
<div
v-if="displayHeader"
class="relative flex flex-col items-center justify-center"
>
<template v-if="isCollapsed">
<SlotConnectionDot
v-if="hasInputs"
multi
class="absolute left-0 -translate-x-1/2"
/>
<SlotConnectionDot
v-if="hasOutputs"
multi
class="absolute right-0 translate-x-1/2"
/>
<NodeSlots :node-data="nodeData" unified />
</template>
<NodeHeader
:node-data="nodeData"
:collapsed="isCollapsed"
:price-badges="badges.pricing"
@collapse="handleCollapse"
@update:title="handleHeaderTitleUpdate"
/>
</div>
<div
class="flex flex-1 flex-col gap-1 rounded-b-2xl bg-component-node-background pt-1 pb-3"
:data-testid="`node-body-${nodeData.id}`"
>
<NodeSlots :node-data="nodeData" />
v-if="isCollapsed && executing && progress !== undefined"
:class="
cn(
'absolute inset-x-4 -bottom-px translate-y-1/2 rounded-full',
progressClasses
)
"
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
/>
<NodeWidgets v-if="nodeData.widgets?.length" :node-data="nodeData" />
<div v-if="hasCustomContent" class="flex min-h-0 flex-1 flex-col">
<NodeContent
v-if="nodeMedia"
:node-data="nodeData"
:media="nodeMedia"
/>
<NodeContent
v-for="preview in promotedPreviews"
:key="`${preview.interiorNodeId}-${preview.widgetName}`"
:node-data="nodeData"
:media="preview"
<template v-if="!isCollapsed">
<div class="relative">
<!-- Progress bar for executing state -->
<div
v-if="executing && progress !== undefined"
:class="
cn(
'absolute inset-x-0 top-1/2 -translate-y-1/2',
!!(progress < 1) && 'rounded-r-full',
progressClasses
)
"
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
/>
</div>
<!-- Live mid-execution preview images -->
<LivePreview
v-if="shouldShowPreviewImg"
:image-url="latestPreviewUrl"
/>
<NodeBadges v-bind="badges" :pricing="undefined" class="mt-auto" />
</div>
</template>
<div
v-if="
(hasAnyError && showErrorsTabEnabled) ||
lgraphNode?.isSubgraphNode() ||
showAdvancedState ||
showAdvancedInputsButton
"
:class="
cn(
'-z-1 flex h-7 w-full divide-x divide-component-node-border overflow-hidden rounded-t-none rounded-b-2xl text-xs',
!isCollapsed && '-mt-5 h-12'
)
"
>
<Button
v-if="lgraphNode?.isSubgraphNode()"
variant="textonly"
:class="
cn(
'h-full flex-1 rounded-none',
hasAnyError &&
showErrorsTabEnabled &&
!nodeData.color &&
'bg-node-component-header-surface',
isCollapsed ? 'py-2' : 'pt-7 pb-2'
)
"
data-testid="subgraph-enter-button"
@click.stop="handleEnterSubgraph"
>
<span class="truncate">{{
hasAnyError && showErrorsTabEnabled
? t('g.enter')
: t('g.enterSubgraph')
}}</span>
<i class="icon-[comfy--workflow] size-4 shrink-0" />
</Button>
<Button
v-if="hasAnyError && showErrorsTabEnabled"
variant="textonly"
:class="
cn(
'h-full flex-1 rounded-none bg-error hover:bg-destructive-background-hover',
isCollapsed ? 'py-2' : 'pt-7 pb-2'
)
"
@click.stop="useRightSidePanelStore().openPanel('errors')"
>
<span class="truncate">{{ t('g.error') }}</span>
<i class="icon-[lucide--info] size-4 shrink-0" />
</Button>
<div
:class="
cn(
'flex flex-1 flex-col gap-1 bg-component-node-background pt-1 pb-3',
bodyRoundingClass
)
"
:data-testid="`node-body-${nodeData.id}`"
>
<NodeSlots :node-data="nodeData" />
<!-- Advanced inputs (non-subgraph nodes only) -->
<Button
v-if="
!lgraphNode?.isSubgraphNode() &&
(showAdvancedState || showAdvancedInputsButton)
"
variant="textonly"
:class="
cn('h-full flex-1 rounded-none', isCollapsed ? 'py-2' : 'pt-7 pb-2')
"
@click.stop="showAdvancedState = !showAdvancedState"
>
<template v-if="showAdvancedState">
<span class="truncate">{{
t('rightSidePanel.hideAdvancedInputsButton')
}}</span>
<i class="icon-[lucide--chevron-up] size-4 shrink-0" />
</template>
<template v-else>
<span class="truncate">{{
t('rightSidePanel.showAdvancedInputsButton')
}}</span>
<i class="icon-[lucide--settings-2] size-4 shrink-0" />
</template>
</Button>
<NodeWidgets v-if="nodeData.widgets?.length" :node-data="nodeData" />
<div v-if="hasCustomContent" class="flex min-h-0 flex-1 flex-col">
<NodeContent
v-if="nodeMedia"
:node-data="nodeData"
:media="nodeMedia"
/>
<NodeContent
v-for="preview in promotedPreviews"
:key="`${preview.interiorNodeId}-${preview.widgetName}`"
:node-data="nodeData"
:media="preview"
/>
</div>
<!-- Live mid-execution preview images -->
<LivePreview
v-if="shouldShowPreviewImg"
:image-url="latestPreviewUrl"
/>
<NodeBadges
v-if="!isTransparentHeaderless"
v-bind="badges"
:pricing="undefined"
class="mt-auto"
/>
</div>
</template>
</div>
<NodeFooter
:is-subgraph="!!lgraphNode?.isSubgraphNode()"
:has-any-error="hasAnyError"
:show-errors-tab-enabled="showErrorsTabEnabled"
:is-collapsed="isCollapsed"
:show-advanced-inputs-button="showAdvancedInputsButton"
:show-advanced-state="showAdvancedState"
:header-color="applyLightThemeColor(nodeData?.color)"
:shape="nodeData.shape"
@enter-subgraph="handleEnterSubgraph"
@open-errors="handleOpenErrors"
@toggle-advanced="handleToggleAdvanced"
/>
<template
v-if="!isCollapsed && nodeData.resizable !== false && !isSelectMode"
>
@@ -231,6 +213,8 @@
cn(
baseResizeHandleClasses,
handle.positionClasses,
(handle.corner === 'SE' || handle.corner === 'SW') &&
footerResizeHandleBottomClass,
handle.cursorClass,
'group-hover/node:opacity-100'
)
@@ -272,7 +256,6 @@ 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 { useAppMode } from '@/composables/useAppMode'
@@ -312,13 +295,13 @@ import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { isTransparent } from '@/utils/colorUtil'
import { isVideoOutput } from '@/utils/litegraphUtil'
import {
getLocatorIdFromNodeData,
getNodeByLocatorId
} from '@/utils/graphTraversalUtil'
import { cn } from '@/utils/tailwindUtil'
import { isTransparent } from '@/utils/colorUtil'
import type { CompassCorners } from '@/lib/litegraph/src/interfaces'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
@@ -328,6 +311,7 @@ import { useNodeResize } from '../interactions/resize/useNodeResize'
import LivePreview from './LivePreview.vue'
import NodeContent from './NodeContent.vue'
import NodeHeader from './NodeHeader.vue'
import NodeFooter from './NodeFooter.vue'
import NodeSlots from './NodeSlots.vue'
import NodeWidgets from './NodeWidgets.vue'
@@ -565,53 +549,104 @@ const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
}
)
const borderClass = computed(() => {
if (hasAnyError.value) return 'border-node-stroke-error bg-error'
//FIXME need a better way to detecting transparency
if (
!displayHeader.value &&
nodeData.bgcolor &&
isTransparent(nodeData.bgcolor)
const hasFooter = computed(() => {
return !!(
(hasAnyError.value && showErrorsTabEnabled.value) ||
lgraphNode.value?.isSubgraphNode() ||
(!lgraphNode.value?.isSubgraphNode() &&
(showAdvancedState.value || showAdvancedInputsButton.value))
)
return 'border-0'
return ''
})
const outlineClass = computed(() => {
return cn(
isSelected.value && 'outline-node-component-outline',
hasAnyError.value && 'outline-node-stroke-error',
executing.value && 'outline-node-stroke-executing'
)
// Footer offset computed classes
const footerStateOutlineBottomClass = computed(() =>
hasFooter.value ? '-bottom-[35px]' : ''
)
const footerRootBorderBottomClass = computed(() =>
hasFooter.value ? '-bottom-8' : ''
)
const footerResizeHandleBottomClass = computed(() => {
if (!hasFooter.value) return ''
return hasAnyError.value ? 'bottom-[-31px]' : 'bottom-[-35px]'
})
const cursorClass = computed(() => {
return cn(
nodeData.flags?.pinned
? 'cursor-default'
: layoutStore.isDraggingVueNodes.value
? 'cursor-grabbing'
: 'cursor-grab'
)
if (nodeData.flags?.pinned) return 'cursor-default'
return layoutStore.isDraggingVueNodes.value
? 'cursor-grabbing'
: 'cursor-grab'
})
const bodyRoundingClass = computed(() => {
switch (nodeData.shape) {
case RenderShape.BOX:
return ''
case RenderShape.CARD:
return 'rounded-br-2xl'
default:
return 'rounded-b-2xl'
}
})
const shapeClass = computed(() => {
switch (nodeData.shape) {
case RenderShape.BOX:
return 'rounded-none'
return ''
case RenderShape.CARD:
return 'rounded-tl-2xl rounded-br-2xl rounded-tr-none rounded-bl-none'
return 'rounded-tl-2xl rounded-br-2xl'
default:
return 'rounded-2xl'
}
})
const isTransparentHeaderless = computed(
() =>
!displayHeader.value &&
!!nodeData.bgcolor &&
isTransparent(nodeData.bgcolor)
)
const rootBorderShapeClass = computed(() => {
if (isTransparentHeaderless.value) return 'border-0'
const isExpanded = hasAnyError.value
switch (nodeData.shape) {
case RenderShape.BOX:
return ''
case RenderShape.CARD:
return isExpanded
? 'rounded-tl-[20px] rounded-br-[20px]'
: 'rounded-tl-2xl rounded-br-2xl'
default:
return isExpanded ? 'rounded-[20px]' : 'rounded-2xl'
}
})
const selectionShapeClass = computed(() => {
if (isTransparentHeaderless.value) return 'border-0'
const isExpanded = hasAnyError.value
switch (nodeData.shape) {
case RenderShape.BOX:
return ''
case RenderShape.CARD:
return isExpanded
? 'rounded-tl-[23px] rounded-br-[23px]'
: 'rounded-tl-[19px] rounded-br-[19px]'
default:
return isExpanded ? 'rounded-[23px]' : 'rounded-[19px]'
}
})
const beforeShapeClass = computed(() => {
switch (nodeData.shape) {
case RenderShape.BOX:
return 'before:rounded-none'
return ''
case RenderShape.CARD:
return 'before:rounded-tl-2xl before:rounded-br-2xl before:rounded-tr-none before:rounded-bl-none'
return 'before:rounded-tl-2xl before:rounded-br-2xl'
default:
return 'before:rounded-2xl'
}
@@ -626,6 +661,16 @@ const handleHeaderTitleUpdate = (newTitle: string) => {
handleNodeTitleUpdate(nodeData.id, newTitle)
}
const rightSidePanelStore = useRightSidePanelStore()
const handleOpenErrors = () => {
rightSidePanelStore.openPanel('errors')
}
const handleToggleAdvanced = () => {
showAdvancedState.value = !showAdvancedState.value
}
const handleEnterSubgraph = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'graph_node_open_subgraph_clicked'
@@ -705,7 +750,6 @@ const showAdvancedState = customRef((track, trigger) => {
if (node instanceof SubgraphNode) {
// Do not modify internalState for subgraph nodes
const rightSidePanelStore = useRightSidePanelStore()
if (value) {
rightSidePanelStore.focusSection('advanced-inputs')
} else {

View File

@@ -0,0 +1,183 @@
<template>
<!-- Case 1: Subgraph + Error (Dual Tabs) -->
<template v-if="isSubgraph && hasAnyError && showErrorsTabEnabled">
<Button
variant="textonly"
:class="
cn(
getTabStyles(false),
errorTabWidth,
'-z-5 bg-destructive-background text-white hover:bg-destructive-background-hover'
)
"
@click.stop="$emit('openErrors')"
>
<div class="flex size-full items-center justify-center gap-2">
<span class="truncate">{{ t('g.error') }}</span>
<i class="icon-[lucide--info] size-4 shrink-0" />
</div>
</Button>
<Button
variant="textonly"
data-testid="subgraph-enter-button"
:class="
cn(
getTabStyles(true),
enterTabFullWidth,
'-z-10 bg-node-component-header-surface'
)
"
:style="{ backgroundColor: headerColor }"
@click.stop="$emit('enterSubgraph')"
>
<div class="ml-auto flex h-full w-1/2 items-center justify-center gap-2">
<span class="truncate">{{ t('g.enter') }}</span>
<i class="icon-[comfy--workflow] size-4 shrink-0" />
</div>
</Button>
</template>
<!-- Case 2: Error Only (Full Width) -->
<template v-else-if="hasAnyError && showErrorsTabEnabled">
<Button
variant="textonly"
:class="
cn(
getTabStyles(false),
enterTabFullWidth,
'-z-5 bg-destructive-background text-white hover:bg-destructive-background-hover'
)
"
@click.stop="$emit('openErrors')"
>
<div class="flex size-full items-center justify-center gap-2">
<span class="truncate">{{ t('g.error') }}</span>
<i class="icon-[lucide--info] size-4 shrink-0" />
</div>
</Button>
</template>
<!-- Case 3: Subgraph only (Full Width) -->
<template v-else-if="isSubgraph">
<Button
variant="textonly"
data-testid="subgraph-enter-button"
:class="
cn(
getTabStyles(true),
hasAnyError ? 'w-[calc(100%+8px)]' : 'w-full',
'-z-10 bg-node-component-header-surface'
)
"
:style="{ backgroundColor: headerColor }"
@click.stop="$emit('enterSubgraph')"
>
<div class="flex size-full items-center justify-center gap-2">
<span class="truncate">{{ t('g.enterSubgraph') }}</span>
<i class="icon-[comfy--workflow] size-4 shrink-0" />
</div>
</Button>
</template>
<!-- Case 4: Advanced Footer (Regular Nodes) -->
<div
v-else-if="showAdvancedInputsButton || showAdvancedState"
class="relative -z-1 -mt-5 flex h-7 w-full divide-x divide-component-node-border overflow-hidden rounded-t-none rounded-b-2xl text-xs"
>
<Button
variant="textonly"
:class="
cn('h-full flex-1 rounded-none', isCollapsed ? 'py-2' : 'pt-7 pb-2')
"
@click.stop="$emit('toggleAdvanced')"
>
<template v-if="showAdvancedState">
<span class="truncate">{{
t('rightSidePanel.hideAdvancedInputsButton')
}}</span>
<i class="icon-[lucide--chevron-up] size-4 shrink-0" />
</template>
<template v-else>
<span class="truncate">{{
t('rightSidePanel.showAdvancedInputsButton')
}}</span>
<i class="icon-[lucide--settings-2] size-4 shrink-0" />
</template>
</Button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { RenderShape } from '@/lib/litegraph/src/litegraph'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
interface Props {
isSubgraph: boolean
hasAnyError: boolean
showErrorsTabEnabled: boolean
isCollapsed: boolean
showAdvancedInputsButton?: boolean
showAdvancedState?: boolean
headerColor?: string
shape?: RenderShape
}
const props = defineProps<Props>()
defineEmits<{
(e: 'enterSubgraph'): void
(e: 'openErrors'): void
(e: 'toggleAdvanced'): void
}>()
const footerRadiusClass = computed(() => {
const isExpanded = props.hasAnyError
switch (props.shape) {
case RenderShape.BOX:
return ''
case RenderShape.CARD:
return isExpanded ? 'rounded-br-[20px]' : 'rounded-br-2xl'
default:
return isExpanded ? 'rounded-b-[20px]' : 'rounded-b-2xl'
}
})
/**
* Returns shared size/position classes for footer tabs
* @param isBackground If true, calculates styles for the background/right tab (Enter Subgraph)
*/
const getTabStyles = (isBackground = false) => {
let sizeClasses = ''
if (props.isCollapsed) {
let pt = 'pt-10'
if (isBackground) {
pt = props.hasAnyError ? 'pt-10.5' : 'pt-9'
}
sizeClasses = cn('-mt-7.5 h-15', pt)
} else {
let pt = 'pt-12.5'
if (isBackground) {
pt = props.hasAnyError ? 'pt-12.5' : 'pt-11.5'
}
sizeClasses = cn('-mt-10 h-17.5', pt)
}
return cn(
'pointer-events-auto absolute top-full left-0 text-xs',
footerRadiusClass.value,
sizeClasses,
props.hasAnyError ? '-translate-x-1 translate-y-0.5' : 'translate-y-0.5'
)
}
// Case 1 context: Split widths
const errorTabWidth = 'w-[calc(50%+4px)]'
const enterTabFullWidth = 'w-[calc(100%+8px)]'
</script>