feat: vue node previews with refactored lgnode.vue

This commit is contained in:
JakeSchroeder
2025-09-23 18:20:48 -07:00
parent 10424bdd00
commit a3f8b50bbd
4 changed files with 381 additions and 220 deletions

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="presentation.containerBaseClasses.value"
:container-style="containerStyle"
:is-collapsed="presentation.isCollapsed.value"
:separator-classes="presentation.separatorClasses"
:progress-classes="presentation.progressClasses"
:progress-bar-classes="presentation.progressBarClasses.value"
:show-progress="presentation.showProgress.value"
:progress-value="progress"
:progress-style="presentation.progressStyle.value"
:progress-bar-style="presentation.progressBarStyle.value"
: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'
@@ -142,6 +44,7 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
import { useNodePresentation } from '@/renderer/extensions/vueNodes/composables/useNodePresentation'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
@@ -153,13 +56,8 @@ import {
getLocatorIdFromNodeData,
getNodeByLocatorId
} 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,8 +138,28 @@ onMounted(() => {
}
})
// Track collapsed state
const isCollapsed = computed(() => nodeData.flags?.collapsed ?? false)
// Use the new presentation composable
const presentation = useNodePresentation(() => nodeData, {
readonly,
isPreview: false,
isSelected,
executing,
progress,
hasExecutionError,
hasAnyError,
bypassed,
isDragging,
shouldHandleNodePointerEvents
})
// 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(() => {
@@ -249,44 +167,16 @@ const hasCustomContent = computed(() => {
return nodeImageUrls.value.length > 0
})
// 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'
const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
() => nodeData.id,
{
isCollapsed
isCollapsed: presentation.isCollapsed
}
)
const borderClass = computed(() => {
if (hasAnyError.value) {
return 'border-error'
}
if (executing.value) {
return 'border-blue-500'
}
return undefined
})
const outlineClass = computed(() => {
if (!isSelected.value) {
return undefined
}
if (hasAnyError.value) {
return 'outline-error'
}
if (executing.value) {
return 'outline-blue-500'
}
return 'outline-black dark-theme:outline-white'
})
// Event handlers
const handleCollapse = () => {
handleNodeCollapse(nodeData.id, !isCollapsed.value)
handleNodeCollapse(nodeData.id, !presentation.isCollapsed.value)
}
const handleHeaderTitleUpdate = (newTitle: string) => {
@@ -344,7 +234,4 @@ const nodeImageUrls = computed(() => {
// Clear URLs if no outputs or no images
return []
})
const nodeContainerRef = ref()
provide('tooltipContainer', nodeContainerRef)
</script>

View File

@@ -1,46 +1,14 @@
<template>
<div class="scale-75">
<div
class="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"
>
<div class="flex items-center">
<NodeHeader :node-data="nodeData" :readonly="readonly" />
</div>
<div class="mb-4 relative">
<div class="bg-sand-100 dark-theme:bg-charcoal-600 h-px mx-0 w-full" />
</div>
<div class="flex flex-col gap-4 pb-4">
<NodeSlots
v-memo="[nodeData.inputs?.length, nodeData.outputs?.length]"
:node-data="nodeData"
:readonly="readonly"
/>
<NodeWidgets
v-if="nodeData.widgets?.length"
v-memo="[nodeData.widgets?.length]"
:node-data="nodeData"
:readonly="readonly"
/>
<NodeContent
v-if="hasCustomContent"
:node-data="nodeData"
:readonly="readonly"
:image-urls="nodeImageUrls"
/>
<!-- Live preview image -->
<!-- <div v-if="shouldShowPreviewImg" class="px-4">
<img
:src="latestPreviewUrl"
alt="preview"
class="w-full max-h-64 object-contain"
/>
</div> -->
</div>
</div>
<NodeBaseTemplate
:node-data="nodeData"
:readonly="true"
:container-classes="presentation.containerBaseClasses.value"
:is-collapsed="false"
:separator-classes="presentation.separatorClasses"
:has-custom-content="false"
:image-urls="[]"
/>
</div>
</template>
@@ -48,13 +16,12 @@
import { computed } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import NodeContent from '@/renderer/extensions/vueNodes/components/NodeContent.vue'
import NodeHeader from '@/renderer/extensions/vueNodes/components/NodeHeader.vue'
import NodeSlots from '@/renderer/extensions/vueNodes/components/NodeSlots.vue'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import { useNodePresentation } from '@/renderer/extensions/vueNodes/composables/useNodePresentation'
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useWidgetStore } from '@/stores/widgetStore'
import NodeBaseTemplate from './NodeBaseTemplate.vue'
const { nodeDef } = defineProps<{
nodeDef: ComfyNodeDefV2
}>()
@@ -110,13 +77,9 @@ const nodeData = computed<VueNodeData>(() => {
}
})
const readonly = true
const hasCustomContent = computed(() => {
return false
})
const nodeImageUrls = computed(() => {
return []
// Use the presentation composable with preview mode
const presentation = useNodePresentation(() => nodeData.value, {
readonly: true,
isPreview: true
})
</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>

View File

@@ -0,0 +1,153 @@
import { type ComputedRef, type Ref, computed, unref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { cn } from '@/utils/tailwindUtil'
export interface NodePresentationOptions {
readonly?: boolean
isPreview?: boolean
scale?: number
// Interactive node state
isSelected?: ComputedRef<boolean>
executing?: ComputedRef<boolean>
progress?: ComputedRef<number | undefined>
hasExecutionError?: ComputedRef<boolean>
hasAnyError?: ComputedRef<boolean>
bypassed?: ComputedRef<boolean>
isDragging?: ComputedRef<boolean> | Ref<boolean>
shouldHandleNodePointerEvents?: ComputedRef<boolean>
}
export interface NodePresentationState {
// Classes
containerBaseClasses: ComputedRef<string>
separatorClasses: string
progressClasses: string
// Computed states
isCollapsed: ComputedRef<boolean>
showProgress: ComputedRef<boolean>
progressStyle: ComputedRef<{ width: string } | undefined>
progressBarStyle: ComputedRef<{ width: string } | undefined>
progressBarClasses: ComputedRef<string>
borderClass: ComputedRef<string | undefined>
outlineClass: ComputedRef<string | undefined>
}
export function useNodePresentation(
nodeData: () => VueNodeData | undefined,
options: NodePresentationOptions = {}
): NodePresentationState {
const {
isPreview = false,
isSelected,
executing,
progress,
hasAnyError,
bypassed,
isDragging,
shouldHandleNodePointerEvents
} = options
// Collapsed state
const isCollapsed = computed(() => nodeData()?.flags?.collapsed ?? false)
// Show progress when executing with defined progress
const showProgress = computed(() => {
if (isPreview) return false
return !!(executing?.value && progress?.value !== undefined)
})
// Progress styles
const progressStyle = computed(() => {
if (!showProgress.value || !progress?.value) return undefined
return { width: `${Math.min(progress.value * 100, 100)}%` }
})
const progressBarStyle = progressStyle
// Border class based on state
const borderClass = computed(() => {
if (isPreview) return undefined
if (hasAnyError?.value) {
return 'border-error'
}
if (executing?.value) {
return 'border-blue-500'
}
return undefined
})
// Outline class based on selection and state
const outlineClass = computed(() => {
if (isPreview) return undefined
if (!isSelected?.value) {
return undefined
}
if (hasAnyError?.value) {
return 'outline-error'
}
if (executing?.value) {
return 'outline-blue-500'
}
return 'outline-black dark-theme:outline-white'
})
// Container base classes (without dynamic state classes)
const containerBaseClasses = computed(() => {
if (isPreview) {
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',
'outline-transparent -outline-offset-2 outline-2',
'pointer-events-none'
)
}
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': unref(isDragging)
},
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'
return {
containerBaseClasses,
separatorClasses,
progressClasses,
isCollapsed,
showProgress,
progressStyle,
progressBarStyle,
progressBarClasses,
borderClass,
outlineClass
}
}