Compare commits

...

4 Commits

Author SHA1 Message Date
JakeSchroeder
06f1a80479 refactor: remove unneeded usePresentation.ts 2025-09-23 18:58:40 -07:00
JakeSchroeder
45ce156e2a fix: unused exports 2025-09-23 18:32:46 -07:00
JakeSchroeder
a3f8b50bbd feat: vue node previews with refactored lgnode.vue 2025-09-23 18:20:48 -07:00
JakeSchroeder
10424bdd00 feat: vue node preivew when searchnig or using sidebar workbench 2025-09-23 17:59:26 -07:00
4 changed files with 347 additions and 151 deletions

View File

@@ -1,9 +1,9 @@
<!-- Reference:
https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c683087a3e168db/app/js/functions/sb_fn.js#L149
-->
<template>
<div class="_sb_node_preview">
<LGraphNodePreview v-if="shouldRenderVueNodes" :node-def="nodeDef" />
<div v-else class="_sb_node_preview">
<div class="_sb_table">
<div
class="node_header text-ellipsis mr-4"
@@ -85,6 +85,8 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
import _ from 'es-toolkit/compat'
import { computed } from 'vue'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useWidgetStore } from '@/stores/widgetStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
@@ -94,6 +96,8 @@ const { nodeDef } = defineProps<{
nodeDef: ComfyNodeDefV2
}>()
const { shouldRenderVueNodes } = useVueFeatureFlags()
const colorPaletteStore = useColorPaletteStore()
const litegraphColors = computed(
() => colorPaletteStore.completedActivePalette.colors.litegraph_base

View File

@@ -2,137 +2,39 @@
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
{{ $t('Node Render Error') }}
</div>
<div
<NodeBaseTemplate
v-else
ref="nodeContainerRef"
:data-node-id="nodeData.id"
:class="
cn(
'bg-white dark-theme:bg-charcoal-800',
'lg-node absolute rounded-2xl',
'border border-solid border-sand-100 dark-theme:border-charcoal-600',
// hover (only when node should handle events)
shouldHandleNodePointerEvents &&
'hover:ring-7 ring-gray-500/50 dark-theme:ring-gray-500/20',
'outline-transparent -outline-offset-2 outline-2',
borderClass,
outlineClass,
{
'animate-pulse': executing,
'opacity-50 before:rounded-2xl before:pointer-events-none before:absolute before:bg-bypass/60 before:inset-0':
bypassed,
'will-change-transform': isDragging
},
shouldHandleNodePointerEvents
? 'pointer-events-auto'
: 'pointer-events-none'
)
"
:style="[
{
transform: `translate(${position.x ?? 0}px, ${(position.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
zIndex: zIndex
},
dragStyle
]"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@wheel="handleWheel"
>
<div class="flex items-center">
<template v-if="isCollapsed">
<SlotConnectionDot multi class="absolute left-0 -translate-x-1/2" />
<SlotConnectionDot multi class="absolute right-0 translate-x-1/2" />
</template>
<!-- Header only updates on title/color changes -->
<NodeHeader
v-memo="[nodeData.title, isCollapsed]"
:node-data="nodeData"
:readonly="readonly"
:collapsed="isCollapsed"
@collapse="handleCollapse"
@update:title="handleHeaderTitleUpdate"
@enter-subgraph="handleEnterSubgraph"
/>
</div>
<div
v-if="isCollapsed && executing && progress !== undefined"
:class="
cn(
'absolute inset-x-4 -bottom-[1px] translate-y-1/2 rounded-full',
progressClasses
)
"
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
/>
<template v-if="!isCollapsed">
<div class="mb-4 relative">
<div :class="separatorClasses" />
<!-- 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>
<!-- Node Body - rendered based on LOD level and collapsed state -->
<div
class="flex flex-col gap-4 pb-4"
:data-testid="`node-body-${nodeData.id}`"
>
<!-- Slots only rendered at full detail -->
<NodeSlots
v-memo="[nodeData.inputs?.length, nodeData.outputs?.length]"
:node-data="nodeData"
:readonly="readonly"
/>
<!-- Widgets rendered at reduced+ detail -->
<NodeWidgets
v-if="nodeData.widgets?.length"
v-memo="[nodeData.widgets?.length]"
:node-data="nodeData"
:readonly="readonly"
/>
<!-- Custom content at reduced+ detail -->
<NodeContent
v-if="hasCustomContent"
:node-data="nodeData"
:readonly="readonly"
:image-urls="nodeImageUrls"
/>
<!-- Live preview image -->
<div
v-if="shouldShowPreviewImg"
v-memo="[latestPreviewUrl]"
class="px-4"
>
<img
:src="latestPreviewUrl"
alt="preview"
class="w-full max-h-64 object-contain"
/>
</div>
</div>
</template>
</div>
:node-data="nodeData"
:readonly="readonly"
:container-classes="containerClasses"
:container-style="containerStyle"
:is-collapsed="isCollapsed"
:separator-classes="separatorClasses"
:progress-classes="progressClasses"
:progress-bar-classes="progressBarClasses"
:show-progress="showProgress"
:progress-value="progress"
:progress-style="progressStyle"
:progress-bar-style="progressBarStyle"
:has-custom-content="hasCustomContent"
:image-urls="nodeImageUrls"
:show-preview-image="shouldShowPreviewImg"
:preview-image-url="latestPreviewUrl"
:event-handlers="{
onPointerdown: handlePointerDown,
onPointermove: handlePointerMove,
onPointerup: handlePointerUp,
onWheel: handleWheel
}"
@collapse="handleCollapse"
@update:title="handleHeaderTitleUpdate"
@enter-subgraph="handleEnterSubgraph"
/>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, inject, onErrorCaptured, onMounted, provide, ref } from 'vue'
import { computed, inject, onErrorCaptured, onMounted, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
@@ -155,11 +57,7 @@ import {
} from '@/utils/graphTraversalUtil'
import { cn } from '@/utils/tailwindUtil'
import NodeContent from './NodeContent.vue'
import NodeHeader from './NodeHeader.vue'
import NodeSlots from './NodeSlots.vue'
import NodeWidgets from './NodeWidgets.vue'
import SlotConnectionDot from './SlotConnectionDot.vue'
import NodeBaseTemplate from './NodeBaseTemplate.vue'
// Extended props for main node component
interface LGraphNodeProps {
@@ -240,27 +138,23 @@ onMounted(() => {
}
})
// Track collapsed state
// Collapsed state
const isCollapsed = computed(() => nodeData.flags?.collapsed ?? false)
// Check if node has custom content (like image outputs)
const hasCustomContent = computed(() => {
// Show custom content if node has image outputs
return nodeImageUrls.value.length > 0
// Show progress when executing with defined progress
const showProgress = computed(() => {
return !!(executing.value && progress.value !== undefined)
})
// Computed classes and conditions for better reusability
const separatorClasses =
'bg-sand-100 dark-theme:bg-charcoal-600 h-px mx-0 w-full lod-toggle'
const progressClasses = 'h-2 bg-primary-500 transition-all duration-300'
// Progress styles
const progressStyle = computed(() => {
if (!showProgress.value || !progress.value) return undefined
return { width: `${Math.min(progress.value * 100, 100)}%` }
})
const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
() => nodeData.id,
{
isCollapsed
}
)
const progressBarStyle = progressStyle
// Border class based on state
const borderClass = computed(() => {
if (hasAnyError.value) {
return 'border-error'
@@ -271,6 +165,7 @@ const borderClass = computed(() => {
return undefined
})
// Outline class based on selection and state
const outlineClass = computed(() => {
if (!isSelected.value) {
return undefined
@@ -284,6 +179,64 @@ const outlineClass = computed(() => {
return 'outline-black dark-theme:outline-white'
})
// Container classes
const containerClasses = computed(() => {
return cn(
'bg-white dark-theme:bg-charcoal-800',
'lg-node absolute rounded-2xl',
'border border-solid border-sand-100 dark-theme:border-charcoal-600',
// hover (only when node should handle events)
shouldHandleNodePointerEvents.value &&
'hover:ring-7 ring-gray-500/50 dark-theme:ring-gray-500/20',
'outline-transparent -outline-offset-2 outline-2',
borderClass.value,
outlineClass.value,
{
'animate-pulse': executing.value,
'opacity-50 before:rounded-2xl before:pointer-events-none before:absolute before:bg-bypass/60 before:inset-0':
bypassed.value,
'will-change-transform': isDragging.value
},
shouldHandleNodePointerEvents.value
? 'pointer-events-auto'
: 'pointer-events-none'
)
})
const progressBarClasses = computed(() => {
return cn(
'absolute inset-x-4 -bottom-[1px] translate-y-1/2 rounded-full',
progressClasses
)
})
// Static classes
const separatorClasses =
'bg-sand-100 dark-theme:bg-charcoal-600 h-px mx-0 w-full lod-toggle'
const progressClasses = 'h-2 bg-primary-500 transition-all duration-300'
// Container style combining position and drag styles
const containerStyle = computed(() => [
{
transform: `translate(${position.value.x ?? 0}px, ${(position.value.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
zIndex: zIndex.value
},
dragStyle.value
])
// Check if node has custom content (like image outputs)
const hasCustomContent = computed(() => {
// Show custom content if node has image outputs
return nodeImageUrls.value.length > 0
})
const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
() => nodeData.id,
{
isCollapsed
}
)
// Event handlers
const handleCollapse = () => {
handleNodeCollapse(nodeData.id, !isCollapsed.value)
@@ -344,7 +297,4 @@ const nodeImageUrls = computed(() => {
// Clear URLs if no outputs or no images
return []
})
const nodeContainerRef = ref()
provide('tooltipContainer', nodeContainerRef)
</script>

View File

@@ -0,0 +1,84 @@
<template>
<div class="scale-75">
<NodeBaseTemplate
:node-data="nodeData"
:readonly="true"
:container-classes="containerClasses"
:is-collapsed="false"
:separator-classes="separatorClasses"
:has-custom-content="false"
:image-urls="[]"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useWidgetStore } from '@/stores/widgetStore'
import NodeBaseTemplate from './NodeBaseTemplate.vue'
const { nodeDef } = defineProps<{
nodeDef: ComfyNodeDefV2
}>()
const widgetStore = useWidgetStore()
// Convert nodeDef into VueNodeData
const nodeData = computed<VueNodeData>(() => {
// Convert inputs to widgets (those that have widget constructors)
const widgets = Object.entries(nodeDef.inputs || {})
.filter(([_, input]) => widgetStore.inputIsWidget(input))
.map(([name, input]) => ({
name,
type: input.widgetType || input.type,
value:
input.default !== undefined
? input.default
: input.type === 'COMBO' &&
Array.isArray(input.options) &&
input.options.length > 0
? input.options[0]
: undefined,
options: {
...input,
hidden: input.hidden,
advanced: input.advanced,
values: input.type === 'COMBO' ? input.options : undefined // For combo widgets
}
}))
// Filter non-widget inputs for slots
const inputs = Object.entries(nodeDef.inputs || {})
.filter(([_, input]) => !widgetStore.inputIsWidget(input))
.map(([name, input]) => ({
name,
type: input.type,
shape: input.isOptional ? 'HollowCircle' : undefined
}))
return {
id: `preview-${nodeDef.name}`,
title: nodeDef.display_name || nodeDef.name,
type: nodeDef.name,
mode: 0, // Normal mode
selected: false,
executing: false,
widgets,
inputs,
outputs: nodeDef.outputs || [],
flags: {
collapsed: false
}
}
})
// Static classes for preview mode
const containerClasses =
'bg-white dark-theme:bg-charcoal-800 lg-node absolute rounded-2xl border border-solid border-sand-100 dark-theme:border-charcoal-600 outline-transparent -outline-offset-2 outline-2 pointer-events-none'
const separatorClasses =
'bg-sand-100 dark-theme:bg-charcoal-600 h-px mx-0 w-full'
</script>

View File

@@ -0,0 +1,158 @@
<template>
<div
ref="nodeContainerRef"
:data-node-id="nodeData?.id"
:class="containerClasses"
:style="containerStyle"
v-bind="eventHandlers"
>
<div class="flex items-center">
<template v-if="isCollapsed">
<SlotConnectionDot multi class="absolute left-0 -translate-x-1/2" />
<SlotConnectionDot multi class="absolute right-0 translate-x-1/2" />
</template>
<!-- Header only updates on title/color changes -->
<NodeHeader
v-memo="[nodeData?.title, isCollapsed]"
:node-data="nodeData"
:readonly="readonly"
:collapsed="isCollapsed"
@collapse="emit('collapse')"
@update:title="emit('update:title', $event)"
@enter-subgraph="emit('enter-subgraph')"
/>
</div>
<div
v-if="isCollapsed && showProgress"
:class="progressBarClasses"
:style="progressBarStyle"
/>
<template v-if="!isCollapsed">
<div class="mb-4 relative">
<div :class="separatorClasses" />
<!-- Progress bar for executing state -->
<div
v-if="showProgress"
:class="
cn(
'absolute inset-x-0 top-1/2 -translate-y-1/2',
!!(progressValue && progressValue < 1) && 'rounded-r-full',
progressClasses
)
"
:style="progressStyle"
/>
</div>
<!-- Node Body - rendered based on LOD level and collapsed state -->
<div
class="flex flex-col gap-4 pb-4"
:data-testid="`node-body-${nodeData?.id}`"
>
<!-- Slots only rendered at full detail -->
<NodeSlots
v-memo="[nodeData?.inputs?.length, nodeData?.outputs?.length]"
:node-data="nodeData"
:readonly="readonly"
/>
<!-- Widgets rendered at reduced+ detail -->
<NodeWidgets
v-if="nodeData?.widgets?.length"
v-memo="[nodeData?.widgets?.length]"
:node-data="nodeData"
:readonly="readonly"
/>
<!-- Custom content at reduced+ detail -->
<NodeContent
v-if="hasCustomContent"
:node-data="nodeData"
:readonly="readonly"
:image-urls="imageUrls"
/>
<!-- Live preview image -->
<div
v-if="showPreviewImage && previewImageUrl"
v-memo="[previewImageUrl]"
class="px-4"
>
<img
:src="previewImageUrl"
alt="preview"
class="w-full max-h-64 object-contain"
/>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { provide, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { cn } from '@/utils/tailwindUtil'
import NodeContent from './NodeContent.vue'
import NodeHeader from './NodeHeader.vue'
import NodeSlots from './NodeSlots.vue'
import NodeWidgets from './NodeWidgets.vue'
import SlotConnectionDot from './SlotConnectionDot.vue'
interface NodeBaseTemplateProps {
nodeData?: VueNodeData
readonly?: boolean
// Presentation state
containerClasses?: string
containerStyle?: any
isCollapsed?: boolean
separatorClasses?: string
progressClasses?: string
progressBarClasses?: string
// Progress state
showProgress?: boolean
progressValue?: number
progressStyle?: any
progressBarStyle?: any
// Content state
hasCustomContent?: boolean
imageUrls?: string[]
showPreviewImage?: boolean
previewImageUrl?: string
// Event handlers object for v-bind
eventHandlers?: Record<string, any>
}
const {
nodeData,
readonly = false,
containerClasses = '',
containerStyle = {},
isCollapsed = false,
separatorClasses = '',
progressClasses = '',
progressBarClasses = '',
showProgress = false,
progressValue,
progressStyle,
progressBarStyle,
hasCustomContent = false,
imageUrls = [],
showPreviewImage = false,
previewImageUrl,
eventHandlers = {}
} = defineProps<NodeBaseTemplateProps>()
const emit = defineEmits<{
collapse: []
'update:title': [newTitle: string]
'enter-subgraph': []
}>()
// Provide tooltip container for child components
const nodeContainerRef = ref<HTMLElement>()
provide('tooltipContainer', nodeContainerRef)
</script>