Files
ComfyUI_frontend/ComfyUI_vibe/src/components/v2/nodes/FlowNode.vue
orkhanart 1c9715de4e feat(ui): Enhanced bottom bar modal with expandable view, sidebar, and standardized cards
- Add expand mode for bottom bar modal (half-page size)
- Add collapsible left sidebar with category navigation
- Standardize all cards to square aspect ratio across all tabs
- Set canvas default zoom to 75% with auto-center on load
- Add sidebar toggle button in modal header
- Dynamic category lists per tab (Models, Workflows, Assets, Templates, Packages)
- Unified card component styles with hover effects
- Responsive grid columns (4 normal, 6 extended)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 20:59:59 -08:00

270 lines
7.4 KiB
Vue

<script setup lang="ts">
import { computed } from 'vue'
import { Handle, Position } from '@vue-flow/core'
import type { FlowNodeData } from '@/types/node'
import NodeHeader from './NodeHeader.vue'
import NodeSlots from './NodeSlots.vue'
import NodeWidgets from './NodeWidgets.vue'
import FlowNodeMinimized from './FlowNodeMinimized.vue'
interface Props {
id: string
data: FlowNodeData
selected?: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:data': [data: Partial<FlowNodeData>]
collapse: [collapsed: boolean]
minimize: [minimized: boolean]
'update:title': [title: string]
}>()
const isCollapsed = computed(() => props.data.flags.collapsed ?? false)
const isMinimized = computed(() => props.data.flags.minimized ?? false)
const isBypassed = computed(() => props.data.state === 'bypassed')
const isMuted = computed(() => props.data.state === 'muted')
const isExecuting = computed(() => props.data.state === 'executing')
const hasError = computed(() => props.data.state === 'error')
const nodeOpacity = computed(() => {
if (isBypassed.value || isMuted.value) return 0.5
return 1
})
const displayTitle = computed(() => {
return props.data.title || props.data.definition.displayName
})
const progressPercent = computed(() => {
if (props.data.progress === undefined) return 0
return Math.min(props.data.progress * 100, 100)
})
const borderClass = computed(() => {
if (hasError.value) return 'border-red-500'
if (isExecuting.value) return 'border-blue-500'
return 'border-zinc-700'
})
const outlineClass = computed(() => {
if (!props.selected) return ''
if (hasError.value) return 'outline outline-2 outline-red-500/50'
if (isExecuting.value) return 'outline outline-2 outline-blue-500/50'
return 'outline outline-2 outline-blue-500/50'
})
const headerStyle = computed(() => {
const color = props.data.headerColor || props.data.definition.headerColor
return color ? { backgroundColor: color } : {}
})
const bodyStyle = computed(() => {
const color = props.data.bodyColor || props.data.definition.bodyColor
return color ? { '--node-body-bg': color } : {}
})
function handleCollapse(): void {
emit('update:data', {
flags: { ...props.data.flags, collapsed: !isCollapsed.value }
})
emit('collapse', !isCollapsed.value)
}
function handleTitleUpdate(newTitle: string): void {
emit('update:data', { title: newTitle })
emit('update:title', newTitle)
}
function handleWidgetUpdate(name: string, value: unknown): void {
emit('update:data', {
widgetValues: { ...props.data.widgetValues, [name]: value }
})
}
function handleMinimize(): void {
emit('update:data', {
flags: { ...props.data.flags, minimized: !isMinimized.value }
})
emit('minimize', !isMinimized.value)
}
const hasInputs = computed(() => props.data.definition.inputs.length > 0)
const hasOutputs = computed(() => props.data.definition.outputs.length > 0)
// Handle positioning constants
const HEADER_HEIGHT = 36
const SLOT_HEIGHT = 24
const PROGRESS_BAR_HEIGHT = 4
const handleTopOffset = computed(() => {
const hasProgressBar = isExecuting.value && props.data.progress !== undefined
return HEADER_HEIGHT + (hasProgressBar ? PROGRESS_BAR_HEIGHT : 0) + (SLOT_HEIGHT / 2)
})
function getHandleTop(index: number): string {
return `${handleTopOffset.value + index * SLOT_HEIGHT}px`
}
</script>
<template>
<!-- Minimized View -->
<FlowNodeMinimized
v-if="isMinimized"
:title="displayTitle"
:selected="selected"
:is-executing="isExecuting"
:has-error="hasError"
:is-bypassed="isBypassed"
:is-muted="isMuted"
:node-opacity="nodeOpacity"
:header-style="headerStyle"
:body-style="bodyStyle"
:inputs="data.definition.inputs"
:outputs="data.definition.outputs"
@expand="handleMinimize"
/>
<!-- Full View -->
<div
v-else
:class="[
'flow-node relative min-w-[225px] rounded-lg',
'border transition-all duration-150',
'bg-zinc-900',
borderClass,
outlineClass,
{
'ring-4 ring-blue-500/30': selected && !hasError && !isExecuting,
}
]"
:style="[bodyStyle, { opacity: nodeOpacity }]"
>
<div
v-if="isBypassed || isMuted"
class="pointer-events-none absolute inset-0 rounded-lg"
:class="isBypassed ? 'bg-amber-500/20' : 'bg-zinc-500/30'"
/>
<NodeHeader
:title="displayTitle"
:collapsed="isCollapsed"
:pinned="data.flags.pinned"
:badges="data.badges"
:state="data.state"
:style="headerStyle"
@collapse="handleCollapse"
@update:title="handleTitleUpdate"
/>
<div
v-if="isExecuting && data.progress !== undefined"
class="relative h-1 mx-4"
:class="isCollapsed ? 'absolute bottom-0 left-0 right-0 mx-0' : ''"
>
<div class="absolute inset-0 bg-blue-500/30 rounded-full" />
<div
class="absolute left-0 top-0 bottom-0 bg-blue-500 rounded-full transition-all duration-300"
:style="{ width: `${progressPercent}%` }"
/>
</div>
<template v-if="isCollapsed">
<div class="flex items-center justify-between px-2 py-1">
<div
v-if="hasInputs"
class="h-3 w-3 rounded-full bg-zinc-600 border-2 border-zinc-800"
/>
<div v-else class="w-3" />
<div
v-if="hasOutputs"
class="h-3 w-3 rounded-full bg-zinc-600 border-2 border-zinc-800"
/>
<div v-else class="w-3" />
</div>
</template>
<template v-else>
<div class="flex flex-col gap-1 pb-2">
<NodeSlots
:inputs="data.definition.inputs"
:outputs="data.definition.outputs"
/>
<NodeWidgets
v-if="data.definition.widgets.length > 0"
:widgets="data.definition.widgets"
:values="data.widgetValues"
@update:value="handleWidgetUpdate"
/>
<div v-if="data.previewUrl" class="px-4 pt-2">
<img
:src="data.previewUrl"
alt="Preview"
class="w-full rounded-lg object-cover max-h-40"
/>
</div>
</div>
</template>
<!-- Vue Flow Handles (invisible, aligned with SlotDots) -->
<template v-if="!isCollapsed">
<Handle
v-for="(input, index) in data.definition.inputs"
:key="`input-${index}`"
:id="`input-${index}`"
type="target"
:position="Position.Left"
class="vue-flow-handle"
:style="{ top: getHandleTop(index) }"
/>
<Handle
v-for="(output, index) in data.definition.outputs"
:key="`output-${index}`"
:id="`output-${index}`"
type="source"
:position="Position.Right"
class="vue-flow-handle"
:style="{ top: getHandleTop(index) }"
/>
</template>
<template v-else>
<Handle
v-if="hasInputs"
id="input-collapsed"
type="target"
:position="Position.Left"
class="vue-flow-handle"
:style="{ top: '50%' }"
/>
<Handle
v-if="hasOutputs"
id="output-collapsed"
type="source"
:position="Position.Right"
class="vue-flow-handle"
:style="{ top: '50%' }"
/>
</template>
</div>
</template>
<style scoped>
.flow-node {
--node-body-bg: #18181b;
background-color: var(--node-body-bg);
}
.vue-flow-handle {
width: 16px !important;
height: 16px !important;
background: transparent !important;
border: none !important;
opacity: 0 !important;
}
</style>