[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>
@@ -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 }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 27 KiB |
@@ -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)
|
||||
|
||||
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 139 KiB |
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 107 KiB |
@@ -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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
183
src/renderer/extensions/vueNodes/components/NodeFooter.vue
Normal 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>
|
||||