[refactor] Reorganize Vue nodes to domain-driven design architecture (#5085)

* refactor: Reorganize Vue nodes system to domain-driven design architecture

Move Vue nodes code from scattered technical layers to domain-focused structure:

- Widget system → src/renderer/extensions/vueNodes/widgets/
- LOD optimization → src/renderer/extensions/vueNodes/lod/
- Layout logic → src/renderer/extensions/vueNodes/layout/
- Node components → src/renderer/extensions/vueNodes/components/
- Test structure mirrors source organization

Benefits:
- Clear domain boundaries instead of technical layers
- Everything Vue nodes related in renderer domain (not workbench)
- camelCase naming (vueNodes vs vue-nodes)
- Tests co-located with source domains
- All imports updated to new DDD structure

* fix: Skip spatial index performance test on CI to avoid flaky timing

Performance tests are inherently flaky on CI due to variable system
performance. This test should only run locally like the other
performance tests.
This commit is contained in:
Christian Byrne
2025-08-18 16:58:45 -07:00
committed by Benjamin Lu
parent 0dd4ff2087
commit bfcbcf4873
68 changed files with 3767 additions and 89 deletions

View File

@@ -0,0 +1,84 @@
<template>
<div v-if="renderError" class="node-error p-1 text-red-500 text-xs"></div>
<div
v-else
class="lg-slot lg-slot--input flex items-center cursor-crosshair group"
:class="{
'opacity-70': readonly,
'lg-slot--connected': connected,
'lg-slot--compatible': compatible,
'lg-slot--dot-only': dotOnly,
'pr-2 hover:bg-black/5': !dotOnly
}"
:style="{
height: slotHeight + 'px'
}"
@pointerdown="handleClick"
>
<!-- Connection Dot -->
<div class="w-5 h-5 flex items-center justify-center group/slot">
<div
class="w-2 h-2 rounded-full bg-white transition-all duration-150 group-hover/slot:w-2.5 group-hover/slot:h-2.5 group-hover/slot:border-2 group-hover/slot:border-white"
:style="{
backgroundColor: slotColor
}"
/>
</div>
<!-- Slot Name -->
<span v-if="!dotOnly" class="text-xs text-surface-700 whitespace-nowrap">
{{ slotData.name || `Input ${index}` }}
</span>
</div>
</template>
<script setup lang="ts">
import { computed, onErrorCaptured, ref } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { getSlotColor } from '@/constants/slotColors'
import {
COMFY_VUE_NODE_DIMENSIONS,
INodeSlot,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
interface InputSlotProps {
node?: LGraphNode
slotData: INodeSlot
index: number
connected?: boolean
compatible?: boolean
readonly?: boolean
dotOnly?: boolean
}
const props = defineProps<InputSlotProps>()
const emit = defineEmits<{
'slot-click': [event: PointerEvent]
}>()
// Error boundary implementation
const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()
onErrorCaptured((error) => {
renderError.value = error.message
toastErrorHandler(error)
return false
})
// Get slot color based on type
const slotColor = computed(() => getSlotColor(props.slotData.type))
// Get slot height from litegraph constants
const slotHeight = COMFY_VUE_NODE_DIMENSIONS.components.SLOT_HEIGHT
// Handle click events
const handleClick = (event: PointerEvent) => {
if (!props.readonly) {
emit('slot-click', event)
}
}
</script>

View File

@@ -0,0 +1,263 @@
<template>
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
Node Render Error
</div>
<div
v-else
:data-node-id="nodeData.id"
:class="[
'lg-node absolute border-2 rounded-lg',
'contain-layout contain-style contain-paint',
selected ? 'border-blue-500 ring-2 ring-blue-300' : 'border-gray-600',
executing ? 'animate-pulse' : '',
nodeData.mode === 4 ? 'opacity-50' : '', // bypassed
error ? 'border-red-500 bg-red-50' : '',
isDragging ? 'will-change-transform' : '',
lodCssClass,
'hover:border-green-500' // Debug: visual feedback on hover
]"
:style="[
{
transform: `translate(${layoutPosition.x ?? position?.x ?? 0}px, ${(layoutPosition.y ?? position?.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
width: size ? `${size.width}px` : '200px',
height: size ? `${size.height}px` : 'auto',
backgroundColor: '#353535',
pointerEvents: 'auto'
},
dragStyle
]"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
>
<!-- Header only updates on title/color changes -->
<NodeHeader
v-memo="[nodeData.title, lodLevel, isCollapsed]"
:node-data="nodeData"
:readonly="readonly"
:lod-level="lodLevel"
:collapsed="isCollapsed"
@collapse="handleCollapse"
@update:title="handleTitleUpdate"
/>
<!-- Node Body - rendered based on LOD level and collapsed state -->
<div
v-if="!isMinimalLOD && !isCollapsed"
class="flex flex-col gap-2"
:data-testid="`node-body-${nodeData.id}`"
>
<!-- Slots only rendered at full detail -->
<NodeSlots
v-if="shouldRenderSlots"
v-memo="[nodeData.inputs?.length, nodeData.outputs?.length, lodLevel]"
:node-data="nodeData"
:readonly="readonly"
:lod-level="lodLevel"
@slot-click="handleSlotClick"
/>
<!-- Widgets rendered at reduced+ detail -->
<NodeWidgets
v-if="shouldRenderWidgets && nodeData.widgets?.length"
v-memo="[nodeData.widgets?.length, lodLevel]"
:node-data="nodeData"
:readonly="readonly"
:lod-level="lodLevel"
/>
<!-- Custom content at reduced+ detail -->
<NodeContent
v-if="shouldRenderContent && hasCustomContent"
:node-data="nodeData"
:readonly="readonly"
:lod-level="lodLevel"
/>
</div>
<!-- Progress bar for executing state -->
<div
v-if="executing && progress !== undefined"
class="absolute bottom-0 left-0 h-1 bg-primary-500 transition-all duration-300"
:style="{ width: `${progress * 100}%` }"
/>
</div>
</template>
<script setup lang="ts">
import log from 'loglevel'
import { computed, onErrorCaptured, ref, toRef, watch } from 'vue'
// Import the VueNodeData type
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
import NodeContent from './NodeContent.vue'
import NodeHeader from './NodeHeader.vue'
import NodeSlots from './NodeSlots.vue'
import NodeWidgets from './NodeWidgets.vue'
// Create logger for vue nodes
const logger = log.getLogger('vue-nodes')
// In dev mode, always show debug logs
if (import.meta.env.DEV) {
logger.setLevel('debug')
}
// Extended props for main node component
interface LGraphNodeProps {
nodeData: VueNodeData
position?: { x: number; y: number }
size?: { width: number; height: number }
readonly?: boolean
selected?: boolean
executing?: boolean
progress?: number
error?: string | null
zoomLevel?: number
}
const props = defineProps<LGraphNodeProps>()
const emit = defineEmits<{
'node-click': [event: PointerEvent, nodeData: VueNodeData]
'slot-click': [
event: PointerEvent,
nodeData: VueNodeData,
slotIndex: number,
isInput: boolean
]
'update:collapsed': [nodeId: string, collapsed: boolean]
'update:title': [nodeId: string, newTitle: string]
}>()
// LOD (Level of Detail) system based on zoom level
const zoomRef = toRef(() => props.zoomLevel ?? 1)
const {
lodLevel,
shouldRenderWidgets,
shouldRenderSlots,
shouldRenderContent,
lodCssClass
} = useLOD(zoomRef)
// Computed properties for template usage
const isMinimalLOD = computed(() => lodLevel.value === LODLevel.MINIMAL)
// Error boundary implementation
const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()
onErrorCaptured((error) => {
renderError.value = error.message
toastErrorHandler(error)
return false // Prevent error propagation
})
// Use layout system for node position and dragging
const {
position: layoutPosition,
startDrag,
handleDrag: handleLayoutDrag,
endDrag
} = useNodeLayout(props.nodeData.id)
// Debug layout position
watch(
layoutPosition,
(newPos, oldPos) => {
logger.debug(`Layout position changed for node ${props.nodeData.id}:`, {
newPos,
oldPos,
layoutPositionValue: layoutPosition.value
})
},
{ immediate: true, deep: true }
)
logger.debug(`LGraphNode mounted for ${props.nodeData.id}`, {
layoutPosition: layoutPosition.value,
propsPosition: props.position,
nodeDataId: props.nodeData.id
})
// Drag state for styling
const isDragging = ref(false)
const dragStyle = computed(() => ({
cursor: isDragging.value ? 'grabbing' : 'grab'
}))
// Track collapsed state
const isCollapsed = ref(props.nodeData.flags?.collapsed ?? false)
// Watch for external changes to the collapsed state
watch(
() => props.nodeData.flags?.collapsed,
(newCollapsed) => {
if (newCollapsed !== undefined && newCollapsed !== isCollapsed.value) {
isCollapsed.value = newCollapsed
}
}
)
// Check if node has custom content
const hasCustomContent = computed(() => {
// Currently all content is handled through widgets
// This remains false but provides extensibility point
return false
})
// Event handlers
const handlePointerDown = (event: PointerEvent) => {
if (!props.nodeData) {
console.warn('LGraphNode: nodeData is null/undefined in handlePointerDown')
return
}
// Start drag using layout system
isDragging.value = true
startDrag(event)
// Emit node-click for selection handling in GraphCanvas
emit('node-click', event, props.nodeData)
}
const handlePointerMove = (event: PointerEvent) => {
if (isDragging.value) {
void handleLayoutDrag(event)
}
}
const handlePointerUp = (event: PointerEvent) => {
if (isDragging.value) {
isDragging.value = false
void endDrag(event)
}
}
const handleCollapse = () => {
isCollapsed.value = !isCollapsed.value
// Emit event so parent can sync with LiteGraph if needed
emit('update:collapsed', props.nodeData.id, isCollapsed.value)
}
const handleSlotClick = (
event: PointerEvent,
slotIndex: number,
isInput: boolean
) => {
if (!props.nodeData) {
console.warn('LGraphNode: nodeData is null/undefined in handleSlotClick')
return
}
emit('slot-click', event, props.nodeData, slotIndex, isInput)
}
const handleTitleUpdate = (newTitle: string) => {
emit('update:title', props.nodeData.id, newTitle)
}
</script>

View File

@@ -0,0 +1,41 @@
<template>
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
Node Content Error
</div>
<div v-else class="lg-node-content">
<!-- Default slot for custom content -->
<slot>
<!-- This component serves as a placeholder for future extensibility -->
<!-- Currently all node content is rendered through the widget system -->
</slot>
</div>
</template>
<script setup lang="ts">
import { onErrorCaptured, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
interface NodeContentProps {
node?: LGraphNode // For backwards compatibility
nodeData?: VueNodeData // New clean data structure
readonly?: boolean
lodLevel?: LODLevel
}
defineProps<NodeContentProps>()
// Error boundary implementation
const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()
onErrorCaptured((error) => {
renderError.value = error.message
toastErrorHandler(error)
return false
})
</script>

View File

@@ -0,0 +1,148 @@
<template>
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
Node Header Error
</div>
<div
v-else
class="lg-node-header flex items-center justify-between p-2 rounded-t-lg cursor-move"
:data-testid="`node-header-${nodeInfo?.id || ''}`"
:style="{
backgroundColor: headerColor,
color: textColor
}"
@dblclick="handleDoubleClick"
>
<!-- Collapse/Expand Button -->
<button
v-show="!readonly"
class="bg-transparent border-transparent flex items-center"
data-testid="node-collapse-button"
@click.stop="handleCollapse"
@dblclick.stop
>
<i
:class="collapsed ? 'pi pi-chevron-right' : 'pi pi-chevron-down'"
class="text-xs leading-none relative top-[1px]"
></i>
</button>
<!-- Node Title -->
<div class="text-sm font-medium truncate flex-1" data-testid="node-title">
<EditableText
:model-value="displayTitle"
:is-editing="isEditing"
:input-attrs="{ 'data-testid': 'node-title-input' }"
@edit="handleTitleEdit"
@cancel="handleTitleCancel"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onErrorCaptured, ref, watch } from 'vue'
import EditableText from '@/components/common/EditableText.vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
interface NodeHeaderProps {
node?: LGraphNode // For backwards compatibility
nodeData?: VueNodeData // New clean data structure
readonly?: boolean
lodLevel?: LODLevel
collapsed?: boolean
}
const props = defineProps<NodeHeaderProps>()
const emit = defineEmits<{
collapse: []
'update:title': [newTitle: string]
}>()
// Error boundary implementation
const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()
onErrorCaptured((error) => {
renderError.value = error.message
toastErrorHandler(error)
return false
})
// Editing state
const isEditing = ref(false)
const nodeInfo = computed(() => props.nodeData || props.node)
// Local state for title to provide immediate feedback
const displayTitle = ref(nodeInfo.value?.title || 'Untitled')
// Watch for external changes to the node title
watch(
() => nodeInfo.value?.title,
(newTitle) => {
if (newTitle && newTitle !== displayTitle.value) {
displayTitle.value = newTitle
}
}
)
// Compute header color based on node color property or type
const headerColor = computed(() => {
const info = nodeInfo.value
if (!info) return '#353535'
if (info.mode === 4) return '#666' // Bypassed
if (info.mode === 2) return '#444' // Muted
return '#353535' // Default
})
// Compute text color for contrast
const textColor = computed(() => {
const color = headerColor.value
if (!color || color === '#353535' || color === '#444' || color === '#666') {
return '#fff'
}
const colorStr = String(color)
const rgb = parseInt(
colorStr.startsWith('#') ? colorStr.slice(1) : colorStr,
16
)
const r = (rgb >> 16) & 255
const g = (rgb >> 8) & 255
const b = rgb & 255
const brightness = (r * 299 + g * 587 + b * 114) / 1000
return brightness > 128 ? '#000' : '#fff'
})
// Event handlers
const handleCollapse = () => {
emit('collapse')
}
const handleDoubleClick = () => {
if (!props.readonly) {
isEditing.value = true
}
}
const handleTitleEdit = (newTitle: string) => {
isEditing.value = false
const trimmedTitle = newTitle.trim()
if (trimmedTitle && trimmedTitle !== displayTitle.value) {
// Emit for litegraph sync
emit('update:title', trimmedTitle)
}
}
const handleTitleCancel = () => {
isEditing.value = false
// Reset displayTitle to the current node title
displayTitle.value = nodeInfo.value?.title || 'Untitled'
}
</script>

View File

@@ -0,0 +1,137 @@
<template>
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
Node Slots Error
</div>
<div v-else class="lg-node-slots flex justify-between">
<div v-if="filteredInputs.length" class="flex flex-col">
<InputSlot
v-for="(input, index) in filteredInputs"
:key="`input-${index}`"
:slot-data="input"
:index="getActualInputIndex(input, index)"
:readonly="readonly"
@slot-click="
handleInputSlotClick(getActualInputIndex(input, index), $event)
"
/>
</div>
<div v-if="filteredOutputs.length" class="flex flex-col ml-auto">
<OutputSlot
v-for="(output, index) in filteredOutputs"
:key="`output-${index}`"
:slot-data="output"
:index="index"
:readonly="readonly"
@slot-click="handleOutputSlotClick(index, $event)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onErrorCaptured, onUnmounted, ref } from 'vue'
import { useEventForwarding } from '@/composables/graph/useEventForwarding'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { INodeSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
import { isSlotObject } from '@/utils/typeGuardUtil'
import InputSlot from './InputSlot.vue'
import OutputSlot from './OutputSlot.vue'
interface NodeSlotsProps {
node?: LGraphNode // For backwards compatibility
nodeData?: VueNodeData // New clean data structure
readonly?: boolean
lodLevel?: LODLevel
}
const props = defineProps<NodeSlotsProps>()
const nodeInfo = computed(() => props.nodeData || props.node)
// Filter out input slots that have corresponding widgets
const filteredInputs = computed(() => {
if (!nodeInfo.value?.inputs) return []
return nodeInfo.value.inputs
.filter((input) => {
// Check if this slot has a widget property (indicating it has a corresponding widget)
if (isSlotObject(input) && 'widget' in input && input.widget) {
// This slot has a widget, so we should not display it separately
return false
}
return true
})
.map((input) =>
isSlotObject(input)
? input
: ({
name: typeof input === 'string' ? input : '',
type: 'any',
boundingRect: [0, 0, 0, 0] as [number, number, number, number]
} as INodeSlot)
)
})
// Outputs don't have widgets, so we don't need to filter them
const filteredOutputs = computed(() => {
const outputs = nodeInfo.value?.outputs || []
return outputs.map((output) =>
isSlotObject(output)
? output
: ({
name: typeof output === 'string' ? output : '',
type: 'any',
boundingRect: [0, 0, 0, 0] as [number, number, number, number]
} as INodeSlot)
)
})
// Get the actual index of an input slot in the node's inputs array
// (accounting for filtered widget slots)
const getActualInputIndex = (
input: INodeSlot,
filteredIndex: number
): number => {
if (!nodeInfo.value?.inputs) return filteredIndex
// Find the actual index in the unfiltered inputs array
const actualIndex = nodeInfo.value.inputs.findIndex((i) => i === input)
return actualIndex !== -1 ? actualIndex : filteredIndex
}
// Set up event forwarding for slot interactions
const { handleSlotPointerDown, cleanup } = useEventForwarding()
// Handle input slot click
const handleInputSlotClick = (_index: number, event: PointerEvent) => {
// Forward the event to LiteGraph for native slot handling
handleSlotPointerDown(event)
}
// Handle output slot click
const handleOutputSlotClick = (_index: number, event: PointerEvent) => {
// Forward the event to LiteGraph for native slot handling
handleSlotPointerDown(event)
}
// Clean up event listeners on unmount
onUnmounted(() => {
cleanup()
})
// Error boundary implementation
const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()
onErrorCaptured((error) => {
renderError.value = error.message
toastErrorHandler(error)
return false
})
</script>

View File

@@ -0,0 +1,167 @@
<template>
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
Node Widgets Error
</div>
<div v-else class="lg-node-widgets flex flex-col gap-2 pr-4">
<div
v-for="(widget, index) in processedWidgets"
:key="`widget-${index}-${widget.name}`"
class="lg-widget-container relative flex items-center group"
>
<!-- Widget Input Slot Dot -->
<div
class="opacity-0 group-hover:opacity-100 transition-opacity duration-150"
>
<InputSlot
:slot-data="{
name: widget.name,
type: widget.type,
boundingRect: [0, 0, 0, 0]
}"
:index="index"
:readonly="readonly"
:dot-only="true"
@slot-click="handleWidgetSlotClick($event, widget)"
/>
</div>
<!-- Widget Component -->
<component
:is="widget.vueComponent"
:widget="widget.simplified"
:model-value="widget.value"
:readonly="readonly"
class="flex-1"
@update:model-value="widget.updateHandler"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onErrorCaptured, onUnmounted, ref } from 'vue'
import { useEventForwarding } from '@/composables/graph/useEventForwarding'
import type {
SafeWidgetData,
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
// Import widget components directly
import WidgetInputText from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue'
import {
ESSENTIAL_WIDGET_TYPES,
useWidgetRenderer
} from '@/renderer/extensions/vueNodes/widgets/composables/useWidgetRenderer'
import { widgetTypeToComponent } from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
import InputSlot from './InputSlot.vue'
interface NodeWidgetsProps {
node?: LGraphNode
nodeData?: VueNodeData
readonly?: boolean
lodLevel?: LODLevel
}
const props = defineProps<NodeWidgetsProps>()
// Set up event forwarding for slot interactions
const { handleSlotPointerDown, cleanup } = useEventForwarding()
// Use widget renderer composable
const { getWidgetComponent, shouldRenderAsVue } = useWidgetRenderer()
// Error boundary implementation
const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()
onErrorCaptured((error) => {
renderError.value = error.message
toastErrorHandler(error)
return false
})
const nodeInfo = computed(() => props.nodeData || props.node)
interface ProcessedWidget {
name: string
type: string
vueComponent: any
simplified: SimplifiedWidget
value: WidgetValue
updateHandler: (value: unknown) => void
}
const processedWidgets = computed((): ProcessedWidget[] => {
const info = nodeInfo.value
if (!info?.widgets) return []
const widgets = info.widgets as SafeWidgetData[]
const lodLevel = props.lodLevel
const result: ProcessedWidget[] = []
if (lodLevel === LODLevel.MINIMAL) {
return []
}
for (const widget of widgets) {
if (widget.options?.hidden) continue
if (widget.options?.canvasOnly) continue
if (!widget.type) continue
if (!shouldRenderAsVue(widget)) continue
if (
lodLevel === LODLevel.REDUCED &&
!ESSENTIAL_WIDGET_TYPES.has(widget.type)
)
continue
const componentName = getWidgetComponent(widget.type)
const vueComponent = widgetTypeToComponent[componentName] || WidgetInputText
const simplified: SimplifiedWidget = {
name: widget.name,
type: widget.type,
value: widget.value,
options: widget.options,
callback: widget.callback
}
const updateHandler = (value: unknown) => {
if (widget.callback) {
widget.callback(value)
}
}
result.push({
name: widget.name,
type: widget.type,
vueComponent,
simplified,
value: widget.value,
updateHandler
})
}
return result
})
// Handle widget slot click
const handleWidgetSlotClick = (
event: PointerEvent,
_widget: ProcessedWidget
) => {
// Forward the event to LiteGraph for native slot handling
handleSlotPointerDown(event)
}
// Clean up event listeners on unmount
onUnmounted(() => {
cleanup()
})
</script>

View File

@@ -0,0 +1,83 @@
<template>
<div v-if="renderError" class="node-error p-1 text-red-500 text-xs"></div>
<div
v-else
class="lg-slot lg-slot--output flex items-center cursor-crosshair justify-end group"
:class="{
'opacity-70': readonly,
'lg-slot--connected': connected,
'lg-slot--compatible': compatible,
'lg-slot--dot-only': dotOnly,
'pl-2 hover:bg-black/5': !dotOnly,
'justify-center': dotOnly
}"
:style="{
height: slotHeight + 'px'
}"
@pointerdown="handleClick"
>
<!-- Slot Name -->
<span v-if="!dotOnly" class="text-xs text-surface-700 whitespace-nowrap">
{{ slotData.name || `Output ${index}` }}
</span>
<!-- Connection Dot -->
<div class="w-5 h-5 flex items-center justify-center group/slot">
<div
class="w-2 h-2 rounded-full bg-white transition-all duration-150 group-hover/slot:w-2.5 group-hover/slot:h-2.5 group-hover/slot:border-2 group-hover/slot:border-white"
:style="{
backgroundColor: slotColor
}"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onErrorCaptured, ref } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { getSlotColor } from '@/constants/slotColors'
import type { INodeSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { COMFY_VUE_NODE_DIMENSIONS } from '@/lib/litegraph/src/litegraph'
interface OutputSlotProps {
node?: LGraphNode
slotData: INodeSlot
index: number
connected?: boolean
compatible?: boolean
readonly?: boolean
dotOnly?: boolean
}
const props = defineProps<OutputSlotProps>()
const emit = defineEmits<{
'slot-click': [event: PointerEvent]
}>()
// Error boundary implementation
const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()
onErrorCaptured((error) => {
renderError.value = error.message
toastErrorHandler(error)
return false
})
// Get slot color based on type
const slotColor = computed(() => getSlotColor(props.slotData.type))
// Get slot height from litegraph constants
const slotHeight = COMFY_VUE_NODE_DIMENSIONS.components.SLOT_HEIGHT
// Handle click events
const handleClick = (event: PointerEvent) => {
if (!props.readonly) {
emit('slot-click', event)
}
}
</script>

View File

@@ -0,0 +1,295 @@
# Level of Detail (LOD) Implementation Guide for Widgets
## What is Level of Detail (LOD)?
Level of Detail is a technique used to optimize performance by showing different amounts of detail based on how zoomed in the user is. Think of it like Google Maps - when you're zoomed out looking at the whole country, you only see major cities and highways. When you zoom in close, you see street names, building details, and restaurants.
For ComfyUI nodes, this means:
- **Zoomed out** (viewing many nodes): Show only essential controls, hide labels and descriptions
- **Zoomed in** (focusing on specific nodes): Show all details, labels, help text, and visual polish
## Why LOD Matters
Without LOD optimization:
- 1000+ nodes with full detail = browser lag and poor performance
- Text that's too small to read still gets rendered (wasted work)
- Visual effects that are invisible at distance still consume GPU
With LOD optimization:
- Smooth performance even with large node graphs
- Battery life improvement on laptops
- Better user experience across different zoom levels
## How to Implement LOD in Your Widget
### Step 1: Get the LOD Context
Every widget component gets a `zoomLevel` prop. Use this to determine how much detail to show:
```vue
<script setup lang="ts">
import { computed, toRef } from 'vue'
import { useLOD } from '@/composables/graph/useLOD'
const props = defineProps<{
widget: any
zoomLevel: number
// ... other props
}>()
// Get LOD information
const { lodScore, lodLevel } = useLOD(toRef(() => props.zoomLevel))
</script>
```
**Primary API:** Use `lodScore` (0-1) for granular control and smooth transitions
**Convenience API:** Use `lodLevel` ('minimal'|'reduced'|'full') for simple on/off decisions
### Step 2: Choose What to Show at Different Zoom Levels
#### Understanding the LOD Score
- `lodScore` is a number from 0 to 1
- 0 = completely zoomed out (show minimal detail)
- 1 = fully zoomed in (show everything)
- 0.5 = medium zoom (show some details)
#### Understanding LOD Levels
- `'minimal'` = zoom level 0.4 or below (very zoomed out)
- `'reduced'` = zoom level 0.4 to 0.8 (medium zoom)
- `'full'` = zoom level 0.8 or above (zoomed in close)
### Step 3: Implement Your Widget's LOD Strategy
Here's a complete example of a slider widget with LOD:
```vue
<template>
<div class="number-widget">
<!-- The main control always shows -->
<input
v-model="value"
type="range"
:min="widget.min"
:max="widget.max"
class="widget-slider"
/>
<!-- Show label only when zoomed in enough to read it -->
<label
v-if="showLabel"
class="widget-label"
>
{{ widget.name }}
</label>
<!-- Show precise value only when fully zoomed in -->
<span
v-if="showValue"
class="widget-value"
>
{{ formattedValue }}
</span>
<!-- Show description only at full detail -->
<div
v-if="showDescription && widget.description"
class="widget-description"
>
{{ widget.description }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed, toRef } from 'vue'
import { useLOD } from '@/composables/graph/useLOD'
const props = defineProps<{
widget: any
zoomLevel: number
}>()
const { lodScore, lodLevel } = useLOD(toRef(() => props.zoomLevel))
// Define when to show each element
const showLabel = computed(() => {
// Show label when user can actually read it
return lodScore.value > 0.4 // Roughly 12px+ text size
})
const showValue = computed(() => {
// Show precise value only when zoomed in close
return lodScore.value > 0.7 // User is focused on this specific widget
})
const showDescription = computed(() => {
// Description only at full detail
return lodLevel.value === 'full' // Maximum zoom level
})
// You can also use LOD for styling
const widgetClasses = computed(() => {
const classes = ['number-widget']
if (lodLevel.value === 'minimal') {
classes.push('widget--minimal')
}
return classes
})
</script>
<style scoped>
/* Apply different styles based on LOD */
.widget--minimal {
/* Simplified appearance when zoomed out */
.widget-slider {
height: 4px; /* Thinner slider */
opacity: 0.9;
}
}
/* Normal styling */
.widget-slider {
height: 8px;
transition: height 0.2s ease;
}
.widget-label {
font-size: 0.8rem;
color: var(--text-secondary);
}
.widget-value {
font-family: monospace;
font-size: 0.7rem;
color: var(--text-accent);
}
.widget-description {
font-size: 0.6rem;
color: var(--text-muted);
margin-top: 4px;
}
</style>
```
## Common LOD Patterns
### Pattern 1: Essential vs. Nice-to-Have
```typescript
// Always show the main functionality
const showMainControl = computed(() => true)
// Granular control with lodScore
const showLabels = computed(() => lodScore.value > 0.4)
const labelOpacity = computed(() => Math.max(0.3, lodScore.value))
// Simple control with lodLevel
const showExtras = computed(() => lodLevel.value === 'full')
```
### Pattern 2: Smooth Opacity Transitions
```typescript
// Gradually fade elements based on zoom
const labelOpacity = computed(() => {
// Fade in from zoom 0.3 to 0.6
return Math.max(0, Math.min(1, (lodScore.value - 0.3) / 0.3))
})
```
### Pattern 3: Progressive Detail
```typescript
const detailLevel = computed(() => {
if (lodScore.value < 0.3) return 'none'
if (lodScore.value < 0.6) return 'basic'
if (lodScore.value < 0.8) return 'standard'
return 'full'
})
```
## LOD Guidelines by Widget Type
### Text Input Widgets
- **Always show**: The input field itself
- **Medium zoom**: Show label
- **High zoom**: Show placeholder text, validation messages
- **Full zoom**: Show character count, format hints
### Button Widgets
- **Always show**: The button
- **Medium zoom**: Show button text
- **High zoom**: Show button description
- **Full zoom**: Show keyboard shortcuts, tooltips
### Selection Widgets (Dropdown, Radio)
- **Always show**: The current selection
- **Medium zoom**: Show option labels
- **High zoom**: Show all options when expanded
- **Full zoom**: Show option descriptions, icons
### Complex Widgets (Color Picker, File Browser)
- **Always show**: Simplified representation (color swatch, filename)
- **Medium zoom**: Show basic controls
- **High zoom**: Show full interface
- **Full zoom**: Show advanced options, previews
## Design Collaboration Guidelines
### For Designers
When designing widgets, consider creating variants for different zoom levels:
1. **Minimal Design** (far away view)
- Essential elements only
- Higher contrast for visibility
- Simplified shapes and fewer details
2. **Standard Design** (normal view)
- Balanced detail and simplicity
- Clear labels and readable text
- Good for most use cases
3. **Full Detail Design** (close-up view)
- All labels, descriptions, and help text
- Rich visual effects and polish
- Maximum information density
### Design Handoff Checklist
- [ ] Specify which elements are essential vs. nice-to-have
- [ ] Define minimum readable sizes for text elements
- [ ] Provide simplified versions for distant viewing
- [ ] Consider color contrast at different opacity levels
- [ ] Test designs at multiple zoom levels
## Testing Your LOD Implementation
### Manual Testing
1. Create a workflow with your widget
2. Zoom out until nodes are very small
3. Verify essential functionality still works
4. Zoom in gradually and check that details appear smoothly
5. Test performance with 50+ nodes containing your widget
### Performance Considerations
- Avoid complex calculations in LOD computed properties
- Use `v-if` instead of `v-show` for elements that won't render
- Consider using `v-memo` for expensive widget content
- Test on lower-end devices
### Common Mistakes
**Don't**: Hide the main widget functionality at any zoom level
**Don't**: Use complex animations that trigger at every zoom change
**Don't**: Make LOD thresholds too sensitive (causes flickering)
**Don't**: Forget to test with real content and edge cases
**Do**: Keep essential functionality always visible
**Do**: Use smooth transitions between LOD levels
**Do**: Test with varying content lengths and types
**Do**: Consider accessibility at all zoom levels
## Getting Help
- Check existing widgets in `src/components/graph/vueNodes/widgets/` for examples
- Ask in the ComfyUI frontend Discord for LOD implementation questions
- Test your changes with the LOD debug panel (top-right in GraphCanvas)
- Profile performance impact using browser dev tools

View File

@@ -0,0 +1,34 @@
/**
* Vue Nodes Renderer Extension
*
* This extension provides Vue-based node rendering capabilities for ComfyUI.
* Domain-driven architecture organizing concerns by function rather than technical layers.
*
* Architecture:
* - components/ - Vue node UI components (LGraphNode, NodeHeader, etc.)
* - widgets/ - Widget rendering system (components, composables, registry)
* - lod/ - Level of Detail system for performance
* - layout/ - Node positioning and layout logic
* - interaction/ - User interaction handling (planned)
*/
// Main node components
export { default as LGraphNode } from './components/LGraphNode.vue'
export { default as NodeHeader } from './components/NodeHeader.vue'
export { default as NodeContent } from './components/NodeContent.vue'
export { default as NodeSlots } from './components/NodeSlots.vue'
export { default as NodeWidgets } from './components/NodeWidgets.vue'
export { default as InputSlot } from './components/InputSlot.vue'
export { default as OutputSlot } from './components/OutputSlot.vue'
// Widget system exports
export * from './widgets/registry/widgetRegistry'
export * from './widgets/composables/useWidgetRenderer'
export * from './widgets/composables/useWidgetValue'
export * from './widgets/useNodeWidgets'
// Level of Detail system
export * from './lod/useLOD'
// Layout system exports
export * from './layout/useNodeLayout'

View File

@@ -0,0 +1,186 @@
/**
* Level of Detail (LOD) composable for Vue-based node rendering
*
* Provides dynamic quality adjustment based on zoom level to maintain
* performance with large node graphs. Uses zoom thresholds to determine
* how much detail to render for each node component.
*
* ## LOD Levels
*
* - **FULL** (zoom > 0.8): Complete rendering with all widgets, slots, and content
* - **REDUCED** (0.4 < zoom <= 0.8): Essential widgets only, simplified slots
* - **MINIMAL** (zoom <= 0.4): Title only, no widgets or slots
*
* ## Performance Benefits
*
* - Reduces DOM element count by up to 80% at low zoom levels
* - Minimizes layout calculations and paint operations
* - Enables smooth performance with 1000+ nodes
* - Maintains visual fidelity when detail is actually visible
*
* @example
* ```typescript
* const { lodLevel, shouldRenderWidgets, shouldRenderSlots } = useLOD(zoomRef)
*
* // In template
* <NodeWidgets v-if="shouldRenderWidgets" />
* <NodeSlots v-if="shouldRenderSlots" />
* ```
*/
import { type Ref, computed, readonly } from 'vue'
export enum LODLevel {
MINIMAL = 'minimal', // zoom <= 0.4
REDUCED = 'reduced', // 0.4 < zoom <= 0.8
FULL = 'full' // zoom > 0.8
}
export interface LODConfig {
renderWidgets: boolean
renderSlots: boolean
renderContent: boolean
renderSlotLabels: boolean
renderWidgetLabels: boolean
cssClass: string
}
// LOD configuration for each level
const LOD_CONFIGS: Record<LODLevel, LODConfig> = {
[LODLevel.FULL]: {
renderWidgets: true,
renderSlots: true,
renderContent: true,
renderSlotLabels: true,
renderWidgetLabels: true,
cssClass: 'lg-node--lod-full'
},
[LODLevel.REDUCED]: {
renderWidgets: true,
renderSlots: true,
renderContent: false,
renderSlotLabels: false,
renderWidgetLabels: false,
cssClass: 'lg-node--lod-reduced'
},
[LODLevel.MINIMAL]: {
renderWidgets: false,
renderSlots: false,
renderContent: false,
renderSlotLabels: false,
renderWidgetLabels: false,
cssClass: 'lg-node--lod-minimal'
}
}
/**
* Create LOD (Level of Detail) state based on zoom level
*
* @param zoomRef - Reactive reference to current zoom level (camera.z)
* @returns LOD state and configuration
*/
export function useLOD(zoomRef: Ref<number>) {
// Continuous LOD score (0-1) for smooth transitions
const lodScore = computed(() => {
const zoom = zoomRef.value
return Math.max(0, Math.min(1, zoom))
})
// Determine current LOD level based on zoom
const lodLevel = computed<LODLevel>(() => {
const zoom = zoomRef.value
if (zoom > 0.8) return LODLevel.FULL
if (zoom > 0.4) return LODLevel.REDUCED
return LODLevel.MINIMAL
})
// Get configuration for current LOD level
const lodConfig = computed<LODConfig>(() => LOD_CONFIGS[lodLevel.value])
// Convenience computed properties for common rendering decisions
const shouldRenderWidgets = computed(() => lodConfig.value.renderWidgets)
const shouldRenderSlots = computed(() => lodConfig.value.renderSlots)
const shouldRenderContent = computed(() => lodConfig.value.renderContent)
const shouldRenderSlotLabels = computed(
() => lodConfig.value.renderSlotLabels
)
const shouldRenderWidgetLabels = computed(
() => lodConfig.value.renderWidgetLabels
)
// CSS class for styling based on LOD level
const lodCssClass = computed(() => lodConfig.value.cssClass)
// Get essential widgets for reduced LOD (only interactive controls)
const getEssentialWidgets = (widgets: unknown[]): unknown[] => {
if (lodLevel.value === LODLevel.FULL) return widgets
if (lodLevel.value === LODLevel.MINIMAL) return []
// For reduced LOD, filter to essential widget types only
return widgets.filter((widget: any) => {
const type = widget?.type?.toLowerCase()
return [
'combo',
'select',
'toggle',
'boolean',
'slider',
'number'
].includes(type)
})
}
// Performance metrics for debugging
const lodMetrics = computed(() => ({
level: lodLevel.value,
zoom: zoomRef.value,
widgetCount: shouldRenderWidgets.value ? 'full' : 'none',
slotCount: shouldRenderSlots.value ? 'full' : 'none'
}))
return {
// Core LOD state
lodLevel: readonly(lodLevel),
lodConfig: readonly(lodConfig),
lodScore: readonly(lodScore),
// Rendering decisions
shouldRenderWidgets,
shouldRenderSlots,
shouldRenderContent,
shouldRenderSlotLabels,
shouldRenderWidgetLabels,
// Styling
lodCssClass,
// Utilities
getEssentialWidgets,
lodMetrics
}
}
/**
* Get LOD level thresholds for configuration or debugging
*/
export const LOD_THRESHOLDS = {
FULL_THRESHOLD: 0.8,
REDUCED_THRESHOLD: 0.4,
MINIMAL_THRESHOLD: 0.0
} as const
/**
* Check if zoom level supports a specific feature
*/
export function supportsFeatureAtZoom(
zoom: number,
feature: keyof LODConfig
): boolean {
const level =
zoom > 0.8
? LODLevel.FULL
: zoom > 0.4
? LODLevel.REDUCED
: LODLevel.MINIMAL
return LOD_CONFIGS[level][feature] as boolean
}

View File

@@ -0,0 +1,43 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<Button
v-bind="filteredProps"
:disabled="readonly"
size="small"
@click="handleClick"
/>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
BADGE_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
// Button widgets don't have a v-model value, they trigger actions
const props = defineProps<{
widget: SimplifiedWidget<void>
readonly?: boolean
}>()
// Button specific excluded props
const BUTTON_EXCLUDED_PROPS = [...BADGE_EXCLUDED_PROPS, 'iconClass'] as const
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, BUTTON_EXCLUDED_PROPS)
)
const handleClick = () => {
if (!props.readonly && props.widget.callback) {
props.widget.callback()
}
}
</script>

View File

@@ -0,0 +1,78 @@
<template>
<div class="flex flex-col gap-1">
<div
class="p-4 border border-gray-300 dark-theme:border-gray-600 rounded max-h-[48rem]"
>
<Chart :type="chartType" :data="chartData" :options="chartOptions" />
</div>
</div>
</template>
<script setup lang="ts">
import type { ChartData } from 'chart.js'
import Chart from 'primevue/chart'
import { computed } from 'vue'
import type { ChartInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
type ChartWidgetOptions = NonNullable<ChartInputSpec['options']>
const value = defineModel<ChartData>({ required: true })
const props = defineProps<{
widget: SimplifiedWidget<ChartData, ChartWidgetOptions>
readonly?: boolean
}>()
const chartType = computed(() => props.widget.options?.type ?? 'line')
const chartData = computed(() => value.value || { labels: [], datasets: [] })
const chartOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
color: '#FFF',
usePointStyle: true,
pointStyle: 'circle'
}
}
},
scales: {
x: {
ticks: {
color: '#9FA2BD'
},
grid: {
display: true,
color: '#9FA2BD',
drawTicks: false,
drawOnChartArea: true,
drawBorder: false
},
border: {
display: true,
color: '#9FA2BD'
}
},
y: {
ticks: {
color: '#9FA2BD'
},
grid: {
display: false,
drawTicks: false,
drawOnChartArea: false,
drawBorder: false
},
border: {
display: true,
color: '#9FA2BD'
}
}
}
}))
</script>

View File

@@ -0,0 +1,52 @@
<!-- Needs custom color picker for alpha support -->
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<ColorPicker
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
inline
@update:model-value="onChange"
/>
</div>
</template>
<script setup lang="ts">
import ColorPicker from 'primevue/colorpicker'
import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const props = defineProps<{
widget: SimplifiedWidget<string>
modelValue: string
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useWidgetValue({
widget: props.widget,
modelValue: props.modelValue,
defaultValue: '#000000',
emit
})
// ColorPicker specific excluded props include panel/overlay classes
const COLOR_PICKER_EXCLUDED_PROPS = [...PANEL_EXCLUDED_PROPS] as const
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, COLOR_PICKER_EXCLUDED_PROPS)
)
</script>

View File

@@ -0,0 +1,324 @@
<template>
<!-- Replace entire widget with image preview when image is loaded -->
<!-- Edge-to-edge: -mx-2 removes the parent's p-2 (8px) padding on each side -->
<div
v-if="hasImageFile"
class="relative -mx-2"
style="width: calc(100% + 1rem)"
>
<!-- Select section above image -->
<div class="flex items-center justify-between gap-4 mb-2 px-2">
<label
v-if="widget.name"
class="text-xs opacity-80 min-w-[4em] truncate"
>{{ widget.name }}</label
>
<!-- Group select and folder button together on the right -->
<div class="flex items-center gap-1">
<!-- TODO: finish once we finish value bindings with Litegraph -->
<Select
:model-value="selectedFile?.name"
:options="[selectedFile?.name || '']"
:disabled="true"
class="min-w-[8em] max-w-[20em] text-xs"
size="small"
:pt="{
option: 'text-xs'
}"
/>
<Button
icon="pi pi-folder"
size="small"
class="!w-8 !h-8"
:disabled="readonly"
@click="triggerFileInput"
/>
</div>
</div>
<!-- Image preview -->
<!-- TODO: change hardcoded colors when design system incorporated -->
<div class="relative group">
<img :src="imageUrl" :alt="selectedFile?.name" class="w-full h-auto" />
<!-- Darkening overlay on hover -->
<div
class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-all duration-200 pointer-events-none"
/>
<!-- Control buttons in top right on hover -->
<div
v-if="!readonly"
class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
>
<!-- Edit button -->
<button
class="w-6 h-6 rounded flex items-center justify-center transition-all duration-150 focus:outline-none border-none"
style="background-color: #262729"
@click="handleEdit"
>
<i class="pi pi-pencil text-white text-xs"></i>
</button>
<!-- Delete button -->
<button
class="w-6 h-6 rounded flex items-center justify-center transition-all duration-150 focus:outline-none border-none"
style="background-color: #262729"
@click="clearFile"
>
<i class="pi pi-times text-white text-xs"></i>
</button>
</div>
</div>
</div>
<!-- Audio preview when audio file is loaded -->
<div
v-else-if="hasAudioFile"
class="relative -mx-2"
style="width: calc(100% + 1rem)"
>
<!-- Select section above audio player -->
<div class="flex items-center justify-between gap-4 mb-2 px-2">
<label
v-if="widget.name"
class="text-xs opacity-80 min-w-[4em] truncate"
>{{ widget.name }}</label
>
<!-- Group select and folder button together on the right -->
<div class="flex items-center gap-1">
<Select
:model-value="selectedFile?.name"
:options="[selectedFile?.name || '']"
:disabled="true"
class="min-w-[8em] max-w-[20em] text-xs"
size="small"
:pt="{
option: 'text-xs'
}"
/>
<Button
icon="pi pi-folder"
size="small"
class="!w-8 !h-8"
:disabled="readonly"
@click="triggerFileInput"
/>
</div>
</div>
<!-- Audio player -->
<div class="relative group px-2">
<div
class="bg-[#1a1b1e] rounded-lg p-4 flex items-center gap-4"
style="border: 1px solid #262729"
>
<!-- Audio icon -->
<div class="flex-shrink-0">
<i class="pi pi-volume-up text-2xl opacity-60"></i>
</div>
<!-- File info and controls -->
<div class="flex-1">
<div class="text-sm font-medium mb-1">{{ selectedFile?.name }}</div>
<div class="text-xs opacity-60">
{{
selectedFile ? (selectedFile.size / 1024).toFixed(1) + ' KB' : ''
}}
</div>
</div>
<!-- Control buttons -->
<div v-if="!readonly" class="flex gap-1">
<!-- Delete button -->
<button
class="w-8 h-8 rounded flex items-center justify-center transition-all duration-150 focus:outline-none border-none hover:bg-[#262729]"
@click="clearFile"
>
<i class="pi pi-times text-white text-sm"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Show normal file upload UI when no image or audio is loaded -->
<div
v-else
class="flex flex-col gap-1 w-full border border-solid p-1 rounded-lg"
:style="{ borderColor: '#262729' }"
>
<div
class="border border-dashed p-1 rounded-md transition-colors duration-200 hover:border-[#5B5E7D]"
:style="{ borderColor: '#262729' }"
>
<div class="flex flex-col items-center gap-2 w-full py-4">
<!-- Quick and dirty file type detection for testing -->
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<span class="text-xs opacity-60"> Drop your file or </span>
<div>
<Button
label="Browse Files"
size="small"
class="text-xs"
:disabled="readonly"
@click="triggerFileInput"
/>
</div>
</div>
</div>
</div>
<!-- Hidden file input always available for both states -->
<input
ref="fileInputRef"
type="file"
class="hidden"
:accept="widget.options?.accept"
:multiple="false"
:disabled="readonly"
@change="handleFileChange"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Select from 'primevue/select'
import { computed, onUnmounted, ref, watch } from 'vue'
// import { useI18n } from 'vue-i18n' // Commented out for testing
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
// const { t } = useI18n() // Commented out for testing
const props = defineProps<{
widget: SimplifiedWidget<File[] | null>
modelValue: File[] | null
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: File[] | null]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useWidgetValue({
widget: props.widget,
modelValue: props.modelValue,
defaultValue: null,
emit
})
const fileInputRef = ref<HTMLInputElement | null>(null)
// Since we only support single file, get the first file
const selectedFile = computed(() => {
const files = localValue.value || []
return files.length > 0 ? files[0] : null
})
// Quick file type detection for testing
const detectFileType = (file: File) => {
const type = file.type?.toLowerCase() || ''
const name = file.name?.toLowerCase() || ''
if (
type.startsWith('image/') ||
name.match(/\.(jpg|jpeg|png|gif|webp|svg)$/)
) {
return 'image'
}
if (type.startsWith('video/') || name.match(/\.(mp4|webm|ogg|mov)$/)) {
return 'video'
}
if (type.startsWith('audio/') || name.match(/\.(mp3|wav|ogg|flac)$/)) {
return 'audio'
}
if (type === 'application/pdf' || name.endsWith('.pdf')) {
return 'pdf'
}
if (type.includes('zip') || name.match(/\.(zip|rar|7z|tar|gz)$/)) {
return 'archive'
}
return 'file'
}
// Check if we have an image file
const hasImageFile = computed(() => {
return selectedFile.value && detectFileType(selectedFile.value) === 'image'
})
// Check if we have an audio file
const hasAudioFile = computed(() => {
return selectedFile.value && detectFileType(selectedFile.value) === 'audio'
})
// Get image URL for preview
const imageUrl = computed(() => {
if (hasImageFile.value && selectedFile.value) {
return URL.createObjectURL(selectedFile.value)
}
return ''
})
// // Get audio URL for playback
// const audioUrl = computed(() => {
// if (hasAudioFile.value && selectedFile.value) {
// return URL.createObjectURL(selectedFile.value)
// }
// return ''
// })
// Clean up image URL when file changes
watch(imageUrl, (newUrl, oldUrl) => {
if (oldUrl && oldUrl !== newUrl) {
URL.revokeObjectURL(oldUrl)
}
})
const triggerFileInput = () => {
fileInputRef.value?.click()
}
const handleFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
if (!props.readonly && target.files && target.files.length > 0) {
// Since we only support single file, take the first one
const file = target.files[0]
// Use the composable's onChange handler with an array
onChange([file])
// Reset input to allow selecting same file again
target.value = ''
}
}
const clearFile = () => {
// Clear the file
onChange(null)
// Reset file input
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
const handleEdit = () => {
// TODO: hook up with maskeditor
}
// Clear file input when value is cleared externally
watch(localValue, (newValue) => {
if (!newValue || newValue.length === 0) {
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
})
// Clean up image URL on unmount
onUnmounted(() => {
if (imageUrl.value) {
URL.revokeObjectURL(imageUrl.value)
}
})
</script>

View File

@@ -0,0 +1,123 @@
<template>
<div class="flex flex-col gap-1">
<Galleria
v-model:activeIndex="activeIndex"
:value="galleryImages"
v-bind="filteredProps"
:disabled="readonly"
:show-thumbnails="showThumbnails"
:show-nav-buttons="showNavButtons"
class="max-w-full"
:pt="{
thumbnails: {
class: 'overflow-hidden'
},
thumbnailContent: {
class: 'py-4 px-2'
},
thumbnailPrevButton: {
class: 'm-0'
},
thumbnailNextButton: {
class: 'm-0'
}
}"
>
<template #item="{ item }">
<img
:src="item.itemImageSrc || item.src || item"
:alt="item.alt || 'Gallery image'"
class="w-full h-auto max-h-64 object-contain"
/>
</template>
<template #thumbnail="{ item }">
<div class="p-1 w-full h-full">
<img
:src="item.thumbnailImageSrc || item.src || item"
:alt="item.alt || 'Gallery thumbnail'"
class="w-full h-full object-cover rounded-lg"
/>
</div>
</template>
</Galleria>
</div>
</template>
<script setup lang="ts">
import Galleria from 'primevue/galleria'
import { computed, ref } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
GALLERIA_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
interface GalleryImage {
itemImageSrc?: string
thumbnailImageSrc?: string
src?: string
alt?: string
}
type GalleryValue = string[] | GalleryImage[]
const value = defineModel<GalleryValue>({ required: true })
const props = defineProps<{
widget: SimplifiedWidget<GalleryValue>
readonly?: boolean
}>()
const activeIndex = ref(0)
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, GALLERIA_EXCLUDED_PROPS)
)
const galleryImages = computed(() => {
if (!value.value || !Array.isArray(value.value)) return []
return value.value.map((item, index) => {
if (typeof item === 'string') {
return {
itemImageSrc: item,
thumbnailImageSrc: item,
alt: `Image ${index + 1}`
}
}
return item
})
})
const showThumbnails = computed(() => {
return (
props.widget.options?.showThumbnails !== false &&
galleryImages.value.length > 1
)
})
const showNavButtons = computed(() => {
return (
props.widget.options?.showNavButtons !== false &&
galleryImages.value.length > 1
)
})
</script>
<style scoped>
/* Ensure thumbnail container doesn't overflow */
:deep(.p-galleria-thumbnails) {
overflow: hidden;
}
/* Constrain thumbnail items to prevent overlap */
:deep(.p-galleria-thumbnail-item) {
flex-shrink: 0;
}
/* Ensure thumbnail wrapper maintains aspect ratio */
:deep(.p-galleria-thumbnail) {
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,29 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<Image v-bind="filteredProps" :src="widget.value" />
</div>
</template>
<script setup lang="ts">
import Image from 'primevue/image'
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
IMAGE_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
// Image widgets typically don't have v-model, they display a source URL/path
const props = defineProps<{
widget: SimplifiedWidget<string>
readonly?: boolean
}>()
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, IMAGE_EXCLUDED_PROPS)
)
</script>

View File

@@ -0,0 +1,70 @@
<template>
<ImageCompare
:tabindex="widget.options?.tabindex ?? 0"
:aria-label="widget.options?.ariaLabel"
:aria-labelledby="widget.options?.ariaLabelledby"
:pt="widget.options?.pt"
:pt-options="widget.options?.ptOptions"
:unstyled="widget.options?.unstyled"
>
<template #left>
<img
:src="beforeImage"
:alt="beforeAlt"
class="w-full h-full object-cover"
/>
</template>
<template #right>
<img
:src="afterImage"
:alt="afterAlt"
class="w-full h-full object-cover"
/>
</template>
</ImageCompare>
</template>
<script setup lang="ts">
import ImageCompare from 'primevue/imagecompare'
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
interface ImageCompareValue {
before: string
after: string
beforeAlt?: string
afterAlt?: string
initialPosition?: number
}
// Image compare widgets typically don't have v-model, they display comparison
const props = defineProps<{
widget: SimplifiedWidget<ImageCompareValue | string>
readonly?: boolean
}>()
const beforeImage = computed(() => {
const value = props.widget.value
return typeof value === 'string' ? value : value?.before || ''
})
const afterImage = computed(() => {
const value = props.widget.value
return typeof value === 'string' ? '' : value?.after || ''
})
const beforeAlt = computed(() => {
const value = props.widget.value
return typeof value === 'object' && value?.beforeAlt
? value.beforeAlt
: 'Before image'
})
const afterAlt = computed(() => {
const value = props.widget.value
return typeof value === 'object' && value?.afterAlt
? value.afterAlt
: 'After image'
})
</script>

View File

@@ -0,0 +1,48 @@
<template>
<div class="flex items-center justify-between gap-4">
<label v-if="widget.name" class="text-xs opacity-80 min-w-[4em] truncate">{{
widget.name
}}</label>
<InputText
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
class="flex-grow min-w-[8em] max-w-[20em] text-xs"
size="small"
@update:model-value="onChange"
/>
</div>
</template>
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import { computed } from 'vue'
import { useStringWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
INPUT_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const props = defineProps<{
widget: SimplifiedWidget<string>
modelValue: string
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useStringWidgetValue(
props.widget,
props.modelValue,
emit
)
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
)
</script>

View File

@@ -0,0 +1,95 @@
<template>
<div
class="widget-markdown relative w-full cursor-text"
@click="startEditing"
>
<!-- Display mode: Rendered markdown -->
<div
v-if="!isEditing"
class="comfy-markdown-content text-xs min-h-[60px] rounded-lg px-4 py-2 overflow-y-auto"
v-html="renderedHtml"
/>
<!-- Edit mode: Textarea -->
<Textarea
v-else
ref="textareaRef"
v-model="localValue"
:disabled="readonly"
class="w-full text-xs"
size="small"
rows="6"
:pt="{
root: {
onBlur: handleBlur
}
}"
@update:model-value="onChange"
@click.stop
@keydown.stop
/>
</div>
</template>
<script setup lang="ts">
import Textarea from 'primevue/textarea'
import { computed, nextTick, ref } from 'vue'
import { useStringWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
const props = defineProps<{
widget: SimplifiedWidget<string>
modelValue: string
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
// State
const isEditing = ref(false)
const textareaRef = ref<InstanceType<typeof Textarea> | undefined>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useStringWidgetValue(
props.widget,
props.modelValue,
emit
)
// Computed
const renderedHtml = computed(() => {
return renderMarkdownToHtml(localValue.value || '')
})
// Methods
const startEditing = async () => {
if (props.readonly || isEditing.value) return
isEditing.value = true
await nextTick()
// Focus the textarea
// @ts-expect-error - $el is an internal property of the Textarea component
textareaRef.value?.$el?.focus()
}
const handleBlur = () => {
isEditing.value = false
}
</script>
<style scoped>
.widget-markdown {
background-color: var(--p-muted-color);
border: 1px solid var(--p-border-color);
border-radius: var(--p-border-radius);
}
.widget-markdown:hover:not(:has(textarea)) {
background-color: var(--p-content-hover-background);
}
</style>

View File

@@ -0,0 +1,58 @@
<template>
<div class="flex items-center justify-between gap-4">
<label v-if="widget.name" class="text-xs opacity-80 min-w-[4em] truncate">{{
widget.name
}}</label>
<MultiSelect
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
class="flex-grow min-w-[8em] max-w-[20em] text-xs"
size="small"
:pt="{
option: 'text-xs'
}"
@update:model-value="onChange"
/>
</div>
</template>
<script setup lang="ts">
import MultiSelect from 'primevue/multiselect'
import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const props = defineProps<{
widget: SimplifiedWidget<any[]>
modelValue: any[]
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: any[]]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useWidgetValue({
widget: props.widget,
modelValue: props.modelValue,
defaultValue: [],
emit
})
// MultiSelect specific excluded props include overlay styles
const MULTISELECT_EXCLUDED_PROPS = [
...PANEL_EXCLUDED_PROPS,
'overlayStyle'
] as const
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, MULTISELECT_EXCLUDED_PROPS)
)
</script>

View File

@@ -0,0 +1,71 @@
<template>
<div
class="flex items-center justify-between gap-4"
:style="{ height: widgetHeight + 'px' }"
>
<label v-if="widget.name" class="text-xs opacity-80 min-w-[4em] truncate">{{
widget.name
}}</label>
<Select
v-model="localValue"
:options="selectOptions"
v-bind="filteredProps"
:disabled="readonly"
class="flex-grow min-w-[8em] max-w-[20em] text-xs"
size="small"
:pt="{
option: 'text-xs'
}"
@update:model-value="onChange"
/>
</div>
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import { COMFY_VUE_NODE_DIMENSIONS } from '@/lib/litegraph/src/litegraph'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const props = defineProps<{
widget: SimplifiedWidget<string | number | undefined>
modelValue: string | number | undefined
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string | number | undefined]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useWidgetValue({
widget: props.widget,
modelValue: props.modelValue,
defaultValue: props.widget.options?.values?.[0] || '',
emit
})
// Get widget height from litegraph constants
const widgetHeight = COMFY_VUE_NODE_DIMENSIONS.components.STANDARD_WIDGET_HEIGHT
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS)
)
// Extract select options from widget options
const selectOptions = computed(() => {
const options = props.widget.options
if (options?.values && Array.isArray(options.values)) {
return options.values
}
return []
})
</script>

View File

@@ -0,0 +1,63 @@
<template>
<div class="flex items-center justify-between gap-4">
<label v-if="widget.name" class="text-xs opacity-80 min-w-[4em] truncate">{{
widget.name
}}</label>
<SelectButton
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
class="flex-grow min-w-[8em] max-w-[20em] text-xs"
:pt="{
pcToggleButton: {
label: 'text-xs'
}
}"
@update:model-value="onChange"
/>
</div>
</template>
<script setup lang="ts">
import SelectButton from 'primevue/selectbutton'
import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
STANDARD_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const props = defineProps<{
widget: SimplifiedWidget<any>
modelValue: any
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: any]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useWidgetValue({
widget: props.widget,
modelValue: props.modelValue,
defaultValue: null,
emit
})
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
)
</script>
<style scoped>
:deep(.p-selectbutton) {
border: 1px solid transparent;
}
:deep(.p-selectbutton:hover) {
border-color: currentColor;
}
</style>

View File

@@ -0,0 +1,169 @@
<template>
<div class="flex items-center gap-4" :style="{ height: widgetHeight + 'px' }">
<label v-if="widget.name" class="text-xs opacity-80 min-w-[4em] truncate">{{
widget.name
}}</label>
<div class="flex items-center gap-2 flex-1 justify-end">
<Slider
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
class="flex-grow min-w-[8em] max-w-[20em] text-xs"
@update:model-value="onChange"
/>
<InputText
v-model="inputDisplayValue"
:disabled="readonly"
type="number"
:min="widget.options?.min"
:max="widget.options?.max"
:step="stepValue"
class="w-[4em] text-center text-xs px-0"
size="small"
@blur="handleInputBlur"
@keydown="handleInputKeydown"
/>
</div>
</div>
</template>
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import Slider from 'primevue/slider'
import { computed, ref, watch } from 'vue'
import { useNumberWidgetValue } from '@/composables/graph/useWidgetValue'
import { COMFY_VUE_NODE_DIMENSIONS } from '@/lib/litegraph/src/litegraph'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
STANDARD_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const props = defineProps<{
widget: SimplifiedWidget<number>
modelValue: number
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: number]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useNumberWidgetValue(
props.widget,
props.modelValue,
emit
)
// Get widget height from litegraph constants
const widgetHeight = COMFY_VUE_NODE_DIMENSIONS.components.STANDARD_WIDGET_HEIGHT
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
)
// Get the precision value for proper number formatting
const precision = computed(() => {
const p = props.widget.options?.precision
// Treat negative or non-numeric precision as undefined
return typeof p === 'number' && p >= 0 ? p : undefined
})
// Calculate the step value based on precision or widget options
const stepValue = computed(() => {
// If step is explicitly defined in options, use it
if (props.widget.options?.step !== undefined) {
return String(props.widget.options.step)
}
// Otherwise, derive from precision
if (precision.value !== undefined) {
if (precision.value === 0) {
return '1'
}
// For precision > 0, step = 1 / (10^precision)
// precision 1 → 0.1, precision 2 → 0.01, etc.
return (1 / Math.pow(10, precision.value)).toFixed(precision.value)
}
// Default to 'any' for unrestricted stepping
return 'any'
})
// Format a number according to the widget's precision
const formatNumber = (value: number): string => {
if (precision.value === undefined) {
// No precision specified, return as-is
return String(value)
}
// Use toFixed to ensure correct decimal places
return value.toFixed(precision.value)
}
// Apply precision-based rounding to a number
const applyPrecision = (value: number): number => {
if (precision.value === undefined) {
// No precision specified, return as-is
return value
}
if (precision.value === 0) {
// Integer precision
return Math.round(value)
}
// Round to the specified decimal places
const multiplier = Math.pow(10, precision.value)
return Math.round(value * multiplier) / multiplier
}
// Keep a separate display value for the input field
const inputDisplayValue = ref(formatNumber(localValue.value))
// Update display value when localValue changes from external sources
watch(localValue, (newValue) => {
inputDisplayValue.value = formatNumber(newValue)
})
const handleInputBlur = (event: Event) => {
const target = event.target as HTMLInputElement
const value = target.value || '0'
const parsed = parseFloat(value)
if (!isNaN(parsed)) {
// Apply precision-based rounding
const roundedValue = applyPrecision(parsed)
onChange(roundedValue)
// Update display value with proper formatting
inputDisplayValue.value = formatNumber(roundedValue)
}
}
const handleInputKeydown = (event: KeyboardEvent) => {
if (event.key === 'Enter') {
const target = event.target as HTMLInputElement
const value = target.value || '0'
const parsed = parseFloat(value)
if (!isNaN(parsed)) {
// Apply precision-based rounding
const roundedValue = applyPrecision(parsed)
onChange(roundedValue)
// Update display value with proper formatting
inputDisplayValue.value = formatNumber(roundedValue)
}
}
}
</script>
<style scoped>
/* Remove number input spinners */
:deep(input[type='number']::-webkit-inner-spin-button),
:deep(input[type='number']::-webkit-outer-spin-button) {
-webkit-appearance: none;
margin: 0;
}
:deep(input[type='number']) {
-moz-appearance: textfield;
appearance: textfield;
}
</style>

View File

@@ -0,0 +1,44 @@
<template>
<Textarea
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
class="w-full text-xs"
size="small"
rows="3"
@update:model-value="onChange"
/>
</template>
<script setup lang="ts">
import Textarea from 'primevue/textarea'
import { computed } from 'vue'
import { useStringWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
INPUT_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const props = defineProps<{
widget: SimplifiedWidget<string>
modelValue: string
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useStringWidgetValue(
props.widget,
props.modelValue,
emit
)
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
)
</script>

View File

@@ -0,0 +1,56 @@
<template>
<div class="flex items-center justify-between gap-4">
<label v-if="widget.name" class="text-xs opacity-80 min-w-[4em] truncate">{{
widget.name
}}</label>
<ToggleSwitch
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
@update:model-value="onChange"
/>
</div>
</template>
<script setup lang="ts">
import ToggleSwitch from 'primevue/toggleswitch'
import { computed } from 'vue'
import { useBooleanWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
STANDARD_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const props = defineProps<{
widget: SimplifiedWidget<boolean>
modelValue: boolean
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useBooleanWidgetValue(
props.widget,
props.modelValue,
emit
)
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
)
</script>
<style scoped>
:deep(.p-toggleswitch .p-toggleswitch-slider) {
border: 1px solid transparent;
}
:deep(.p-toggleswitch:hover .p-toggleswitch-slider) {
border-color: currentColor;
}
</style>

View File

@@ -0,0 +1,56 @@
<template>
<div class="flex items-center justify-between gap-4">
<label v-if="widget.name" class="text-xs opacity-80 min-w-[4em] truncate">{{
widget.name
}}</label>
<TreeSelect
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
class="flex-grow min-w-[8em] max-w-[20em] text-xs"
size="small"
@update:model-value="onChange"
/>
</div>
</template>
<script setup lang="ts">
import TreeSelect from 'primevue/treeselect'
import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const props = defineProps<{
widget: SimplifiedWidget<any>
modelValue: any
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: any]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useWidgetValue({
widget: props.widget,
modelValue: props.modelValue,
defaultValue: null,
emit
})
// TreeSelect specific excluded props
const TREE_SELECT_EXCLUDED_PROPS = [
...PANEL_EXCLUDED_PROPS,
'inputClass',
'inputStyle'
] as const
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, TREE_SELECT_EXCLUDED_PROPS)
)
</script>

View File

@@ -0,0 +1,33 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
type InputSpec,
isBooleanInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
export const useBooleanWidget = () => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
if (!isBooleanInputSpec(inputSpec)) {
throw new Error(`Invalid input data: ${inputSpec}`)
}
const defaultVal = inputSpec.default ?? false
const options = {
on: inputSpec.label_on,
off: inputSpec.label_off
}
return node.addWidget(
'toggle',
inputSpec.name,
defaultVal,
() => {},
options
)
}
return widgetConstructor
}

View File

@@ -0,0 +1,28 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IChartWidget } from '@/lib/litegraph/src/types/widgets'
import {
type ChartInputSpec,
type InputSpec as InputSpecV2,
isChartInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
export const useChartWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): IChartWidget => {
if (!isChartInputSpec(inputSpec)) {
throw new Error('Invalid input spec for chart widget')
}
const { name, options = {} } = inputSpec as ChartInputSpec
const chartType = options.type || 'line'
const widget = node.addWidget('chart', name, options.data || {}, () => {}, {
serialize: true,
type: chartType,
...options
}) as IChartWidget
return widget
}
}

View File

@@ -0,0 +1,52 @@
import { ref } from 'vue'
import ChatHistoryWidget from '@/components/graph/widgets/ChatHistoryWidget.vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import {
ComponentWidgetImpl,
type ComponentWidgetStandardProps,
addWidget
} from '@/scripts/domWidget'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
type ChatHistoryCustomProps = Omit<
InstanceType<typeof ChatHistoryWidget>['$props'],
ComponentWidgetStandardProps
>
const PADDING = 16
export const useChatHistoryWidget = (
options: {
props?: ChatHistoryCustomProps
} = {}
) => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
const widgetValue = ref<string>('')
const widget = new ComponentWidgetImpl<
string | object,
ChatHistoryCustomProps
>({
node,
name: inputSpec.name,
component: ChatHistoryWidget,
props: options.props,
inputSpec,
options: {
getValue: () => widgetValue.value,
setValue: (value: string | object) => {
widgetValue.value = typeof value === 'string' ? value : String(value)
},
getMinHeight: () => 400 + PADDING
}
})
addWidget(node, widget)
return widget
}
return widgetConstructor
}

View File

@@ -0,0 +1,20 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IColorWidget } from '@/lib/litegraph/src/types/widgets'
import type {
ColorInputSpec,
InputSpec as InputSpecV2
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
export const useColorWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): IColorWidget => {
const { name, options } = inputSpec as ColorInputSpec
const defaultValue = options?.default || '#000000'
const widget = node.addWidget('color', name, defaultValue, () => {}, {
serialize: true
}) as IColorWidget
return widget
}
}

View File

@@ -0,0 +1,111 @@
import { ref } from 'vue'
import MultiSelectWidget from '@/components/graph/widgets/MultiSelectWidget.vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import {
ComboInputSpec,
type InputSpec,
isComboInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import {
type BaseDOMWidget,
ComponentWidgetImpl,
addWidget
} from '@/scripts/domWidget'
import {
type ComfyWidgetConstructorV2,
addValueControlWidgets
} from '@/scripts/widgets'
import { useRemoteWidget } from './useRemoteWidget'
const getDefaultValue = (inputSpec: ComboInputSpec) => {
if (inputSpec.default) return inputSpec.default
if (inputSpec.options?.length) return inputSpec.options[0]
if (inputSpec.remote) return 'Loading...'
return undefined
}
const addMultiSelectWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
const widgetValue = ref<string[]>([])
const widget = new ComponentWidgetImpl({
node,
name: inputSpec.name,
component: MultiSelectWidget,
inputSpec,
options: {
getValue: () => widgetValue.value,
setValue: (value: string[]) => {
widgetValue.value = value
}
}
})
addWidget(node, widget as BaseDOMWidget<object | string>)
// TODO: Add remote support to multi-select widget
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3003
return widget
}
const addComboWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
const defaultValue = getDefaultValue(inputSpec)
const comboOptions = inputSpec.options ?? []
const widget = node.addWidget(
'combo',
inputSpec.name,
defaultValue,
() => {},
{
values: comboOptions
}
) as IComboWidget
if (inputSpec.remote) {
const remoteWidget = useRemoteWidget({
remoteConfig: inputSpec.remote,
defaultValue,
node,
widget
})
if (inputSpec.remote.refresh_button) remoteWidget.addRefreshButton()
const origOptions = widget.options
widget.options = new Proxy(origOptions, {
get(target, prop) {
// Assertion: Proxy handler passthrough
return prop !== 'values'
? target[prop as keyof typeof target]
: remoteWidget.getValue()
}
})
}
if (inputSpec.control_after_generate) {
widget.linkedWidgets = addValueControlWidgets(
node,
widget,
undefined,
undefined,
transformInputSpecV2ToV1(inputSpec)
)
}
return widget
}
export const useComboWidget = () => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
if (!isComboInputSpec(inputSpec)) {
throw new Error(`Invalid input data: ${inputSpec}`)
}
return inputSpec.multi_select
? addMultiSelectWidget(node, inputSpec)
: addComboWidget(node, inputSpec)
}
return widgetConstructor
}

View File

@@ -0,0 +1,20 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IFileUploadWidget } from '@/lib/litegraph/src/types/widgets'
import type {
FileUploadInputSpec,
InputSpec as InputSpecV2
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
export const useFileUploadWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): IFileUploadWidget => {
const { name, options = {} } = inputSpec as FileUploadInputSpec
const widget = node.addWidget('fileupload', name, '', () => {}, {
serialize: true,
...(options as Record<string, unknown>)
}) as IFileUploadWidget
return widget
}
}

View File

@@ -0,0 +1,81 @@
import _ from 'lodash'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
import {
type InputSpec,
isFloatInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useSettingStore } from '@/stores/settingStore'
function onFloatValueChange(this: INumericWidget, v: number) {
const round = this.options.round
if (round) {
const precision =
this.options.precision ?? Math.max(0, -Math.floor(Math.log10(round)))
const rounded = Math.round(v / round) * round
this.value = _.clamp(
Number(rounded.toFixed(precision)),
this.options.min ?? -Infinity,
this.options.max ?? Infinity
)
} else {
this.value = v
}
}
export const _for_testing = {
onFloatValueChange
}
export const useFloatWidget = () => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
if (!isFloatInputSpec(inputSpec)) {
throw new Error(`Invalid input data: ${inputSpec}`)
}
const settingStore = useSettingStore()
const sliderEnabled = !settingStore.get('Comfy.DisableSliders')
const display_type = inputSpec.display
const widgetType =
sliderEnabled && display_type == 'slider'
? 'slider'
: display_type == 'knob'
? 'knob'
: 'number'
const step = inputSpec.step ?? 0.5
const precision =
settingStore.get('Comfy.FloatRoundingPrecision') ||
Math.max(0, -Math.floor(Math.log10(step)))
const enableRounding = !settingStore.get('Comfy.DisableFloatRounding')
/** Assertion {@link inputSpec.default} */
const defaultValue = (inputSpec.default as number | undefined) ?? 0
return node.addWidget(
widgetType,
inputSpec.name,
defaultValue,
onFloatValueChange,
{
min: inputSpec.min ?? 0,
max: inputSpec.max ?? 2048,
round:
enableRounding && precision && !inputSpec.round
? Math.pow(10, -precision)
: (inputSpec.round as number),
/** @deprecated Use step2 instead. The 10x value is a legacy implementation. */
step: step * 10.0,
step2: step,
precision
}
)
}
return widgetConstructor
}

View File

@@ -0,0 +1,26 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IGalleriaWidget } from '@/lib/litegraph/src/types/widgets'
import type {
GalleriaInputSpec,
InputSpec as InputSpecV2
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
export const useGalleriaWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): IGalleriaWidget => {
const { name, options = {} } = inputSpec as GalleriaInputSpec
const widget = node.addWidget(
'galleria',
name,
options.images || [],
() => {},
{
serialize: true,
...options
}
) as IGalleriaWidget
return widget
}
}

View File

@@ -0,0 +1,20 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IImageCompareWidget } from '@/lib/litegraph/src/types/widgets'
import type {
ImageCompareInputSpec,
InputSpec as InputSpecV2
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
export const useImageCompareWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): IImageCompareWidget => {
const { name, options = {} } = inputSpec as ImageCompareInputSpec
const widget = node.addWidget('imagecompare', name, ['', ''], () => {}, {
serialize: true,
...options
}) as IImageCompareWidget
return widget
}
}

View File

@@ -0,0 +1,317 @@
import {
BaseWidget,
type CanvasPointer,
type LGraphNode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import { calculateImageGrid } from '@/scripts/ui/imagePreview'
import { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
import { is_all_same_aspect_ratio } from '@/utils/imageUtil'
const renderPreview = (
ctx: CanvasRenderingContext2D,
node: LGraphNode,
shiftY: number
) => {
const canvas = useCanvasStore().getCanvas()
const mouse = canvas.graph_mouse
if (!canvas.pointer_is_down && node.pointerDown) {
if (
mouse[0] === node.pointerDown.pos[0] &&
mouse[1] === node.pointerDown.pos[1]
) {
node.imageIndex = node.pointerDown.index
}
node.pointerDown = null
}
const imgs = node.imgs ?? []
let { imageIndex } = node
const numImages = imgs.length
if (numImages === 1 && !imageIndex) {
// This skips the thumbnail render section below
node.imageIndex = imageIndex = 0
}
const settingStore = useSettingStore()
const allowImageSizeDraw = settingStore.get('Comfy.Node.AllowImageSizeDraw')
const IMAGE_TEXT_SIZE_TEXT_HEIGHT = allowImageSizeDraw ? 15 : 0
const dw = node.size[0]
const dh = node.size[1] - shiftY - IMAGE_TEXT_SIZE_TEXT_HEIGHT
if (imageIndex == null) {
// No image selected; draw thumbnails of all
let cellWidth: number
let cellHeight: number
let shiftX: number
let cell_padding: number
let cols: number
const compact_mode = is_all_same_aspect_ratio(imgs)
if (!compact_mode) {
// use rectangle cell style and border line
cell_padding = 2
// Prevent infinite canvas2d scale-up
const largestDimension = imgs.reduce(
(acc, current) =>
Math.max(acc, current.naturalWidth, current.naturalHeight),
0
)
const fakeImgs = []
fakeImgs.length = imgs.length
fakeImgs[0] = {
naturalWidth: largestDimension,
naturalHeight: largestDimension
}
;({ cellWidth, cellHeight, cols, shiftX } = calculateImageGrid(
fakeImgs,
dw,
dh
))
} else {
cell_padding = 0
;({ cellWidth, cellHeight, cols, shiftX } = calculateImageGrid(
imgs,
dw,
dh
))
}
let anyHovered = false
node.imageRects = []
for (let i = 0; i < numImages; i++) {
const img = imgs[i]
const row = Math.floor(i / cols)
const col = i % cols
const x = col * cellWidth + shiftX
const y = row * cellHeight + shiftY
if (!anyHovered) {
anyHovered = LiteGraph.isInsideRectangle(
mouse[0],
mouse[1],
x + node.pos[0],
y + node.pos[1],
cellWidth,
cellHeight
)
if (anyHovered) {
node.overIndex = i
let value = 110
if (canvas.pointer_is_down) {
if (!node.pointerDown || node.pointerDown.index !== i) {
node.pointerDown = { index: i, pos: [...mouse] }
}
value = 125
}
ctx.filter = `contrast(${value}%) brightness(${value}%)`
canvas.canvas.style.cursor = 'pointer'
}
}
node.imageRects.push([x, y, cellWidth, cellHeight])
const wratio = cellWidth / img.width
const hratio = cellHeight / img.height
const ratio = Math.min(wratio, hratio)
const imgHeight = ratio * img.height
const imgY = row * cellHeight + shiftY + (cellHeight - imgHeight) / 2
const imgWidth = ratio * img.width
const imgX = col * cellWidth + shiftX + (cellWidth - imgWidth) / 2
ctx.drawImage(
img,
imgX + cell_padding,
imgY + cell_padding,
imgWidth - cell_padding * 2,
imgHeight - cell_padding * 2
)
if (!compact_mode) {
// rectangle cell and border line style
ctx.strokeStyle = '#8F8F8F'
ctx.lineWidth = 1
ctx.strokeRect(
x + cell_padding,
y + cell_padding,
cellWidth - cell_padding * 2,
cellHeight - cell_padding * 2
)
}
ctx.filter = 'none'
}
if (!anyHovered) {
node.pointerDown = null
node.overIndex = null
}
return
}
// Draw individual
const img = imgs[imageIndex]
let w = img.naturalWidth
let h = img.naturalHeight
const scaleX = dw / w
const scaleY = dh / h
const scale = Math.min(scaleX, scaleY, 1)
w *= scale
h *= scale
const x = (dw - w) / 2
const y = (dh - h) / 2 + shiftY
ctx.drawImage(img, x, y, w, h)
// Draw image size text below the image
if (allowImageSizeDraw) {
ctx.fillStyle = LiteGraph.NODE_TEXT_COLOR
ctx.textAlign = 'center'
ctx.font = '10px sans-serif'
const sizeText = `${Math.round(img.naturalWidth)} × ${Math.round(img.naturalHeight)}`
const textY = y + h + 10
ctx.fillText(sizeText, x + w / 2, textY)
}
const drawButton = (
x: number,
y: number,
sz: number,
text: string
): boolean => {
const hovered = LiteGraph.isInsideRectangle(
mouse[0],
mouse[1],
x + node.pos[0],
y + node.pos[1],
sz,
sz
)
let fill = '#333'
let textFill = '#fff'
let isClicking = false
if (hovered) {
canvas.canvas.style.cursor = 'pointer'
if (canvas.pointer_is_down) {
fill = '#1e90ff'
isClicking = true
} else {
fill = '#eee'
textFill = '#000'
}
}
ctx.fillStyle = fill
ctx.beginPath()
ctx.roundRect(x, y, sz, sz, [4])
ctx.fill()
ctx.fillStyle = textFill
ctx.font = '12px Arial'
ctx.textAlign = 'center'
ctx.fillText(text, x + 15, y + 20)
return isClicking
}
if (!(numImages > 1)) return
const imageNum = (node.imageIndex ?? 0) + 1
if (drawButton(dw - 40, dh + shiftY - 40, 30, `${imageNum}/${numImages}`)) {
const i = imageNum >= numImages ? 0 : imageNum
if (!node.pointerDown || node.pointerDown.index !== i) {
node.pointerDown = { index: i, pos: [...mouse] }
}
}
if (drawButton(dw - 40, shiftY + 10, 30, `x`)) {
if (!node.pointerDown || node.pointerDown.index !== null) {
node.pointerDown = { index: null, pos: [...mouse] }
}
}
}
class ImagePreviewWidget extends BaseWidget {
constructor(
node: LGraphNode,
name: string,
options: IWidgetOptions<string | object>
) {
const widget: IBaseWidget = {
name,
options,
type: 'custom',
/** Dummy value to satisfy type requirements. */
value: '',
y: 0
}
super(widget, node)
// Don't serialize the widget value
this.serialize = false
}
override drawWidget(ctx: CanvasRenderingContext2D): void {
renderPreview(ctx, this.node, this.y)
}
override onPointerDown(pointer: CanvasPointer, node: LGraphNode): boolean {
pointer.onDragStart = () => {
const { canvas } = app
const { graph } = canvas
canvas.emitBeforeChange()
graph?.beforeChange()
// Ensure that dragging is properly cleaned up, on success or failure.
pointer.finally = () => {
canvas.isDragging = false
graph?.afterChange()
canvas.emitAfterChange()
}
canvas.processSelect(node, pointer.eDown)
canvas.isDragging = true
}
pointer.onDragEnd = (e) => {
const { canvas } = app
if (e.shiftKey || LiteGraph.alwaysSnapToGrid)
canvas.graph?.snapToGrid(canvas.selectedItems)
canvas.setDirty(true, true)
}
return true
}
override onClick(): void {}
override computeLayoutSize() {
return {
minHeight: 220,
minWidth: 1
}
}
}
export const useImagePreviewWidget = () => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
return node.addCustomWidget(
new ImagePreviewWidget(node, inputSpec.name, {
serialize: false
})
)
}
return widgetConstructor
}

View File

@@ -0,0 +1,121 @@
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
import { useNodeImageUpload } from '@/composables/node/useNodeImageUpload'
import { useValueTransform } from '@/composables/useValueTransform'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { IComboWidget } from '@/lib/litegraph/src/types/widgets'
import type { ResultItem, ResultItemType } from '@/schemas/apiSchema'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { isImageUploadInput } from '@/types/nodeDefAugmentation'
import { createAnnotatedPath } from '@/utils/formatUtil'
import { addToComboValues } from '@/utils/litegraphUtil'
const ACCEPTED_IMAGE_TYPES = 'image/png,image/jpeg,image/webp'
const ACCEPTED_VIDEO_TYPES = 'video/webm,video/mp4'
type InternalFile = string | ResultItem
type InternalValue = InternalFile | InternalFile[]
type ExposedValue = string | string[]
const isImageFile = (file: File) => file.type.startsWith('image/')
const isVideoFile = (file: File) => file.type.startsWith('video/')
const findFileComboWidget = (node: LGraphNode, inputName: string) =>
node.widgets!.find((w) => w.name === inputName) as IComboWidget & {
value: ExposedValue
}
export const useImageUploadWidget = () => {
const widgetConstructor: ComfyWidgetConstructor = (
node: LGraphNode,
inputName: string,
inputData: InputSpec
) => {
if (!isImageUploadInput(inputData)) {
throw new Error(
'Image upload widget requires imageInputName augmentation'
)
}
const inputOptions = inputData[1]
const { imageInputName, allow_batch, image_folder = 'input' } = inputOptions
const folder: ResultItemType | undefined = image_folder
const nodeOutputStore = useNodeOutputStore()
const isAnimated = !!inputOptions.animated_image_upload
const isVideo = !!inputOptions.video_upload
const accept = isVideo ? ACCEPTED_VIDEO_TYPES : ACCEPTED_IMAGE_TYPES
const { showPreview } = isVideo ? useNodeVideo(node) : useNodeImage(node)
const fileFilter = isVideo ? isVideoFile : isImageFile
const fileComboWidget = findFileComboWidget(node, imageInputName)
const initialFile = `${fileComboWidget.value}`
const formatPath = (value: InternalFile) =>
createAnnotatedPath(value, { rootFolder: image_folder })
const transform = (internalValue: InternalValue): ExposedValue => {
if (!internalValue) return initialFile
if (Array.isArray(internalValue))
return allow_batch
? internalValue.map(formatPath)
: formatPath(internalValue[0])
return formatPath(internalValue)
}
Object.defineProperty(
fileComboWidget,
'value',
useValueTransform(transform, initialFile)
)
// Setup file upload handling
const { openFileSelection } = useNodeImageUpload(node, {
allow_batch,
fileFilter,
accept,
folder,
onUploadComplete: (output) => {
output.forEach((path) => addToComboValues(fileComboWidget, path))
// @ts-expect-error litegraph combo value type does not support arrays yet
fileComboWidget.value = output
fileComboWidget.callback?.(output)
}
})
// Create the button widget for selecting the files
const uploadWidget = node.addWidget(
'button',
inputName,
'image',
() => openFileSelection(),
{
serialize: false
}
)
uploadWidget.label = t('g.choose_file_to_upload')
// Add our own callback to the combo widget to render an image when it changes
fileComboWidget.callback = function () {
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
isAnimated
})
node.graph?.setDirtyCanvas(true)
}
// On load if we have a value then render the image
// The value isnt set immediately so we need to wait a moment
// No change callbacks seem to be fired on initial setting of the value
requestAnimationFrame(() => {
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
isAnimated
})
showPreview({ block: false })
})
return { widget: uploadWidget }
}
return widgetConstructor
}

View File

@@ -0,0 +1,20 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IImageWidget } from '@/lib/litegraph/src/types/widgets'
import type {
ImageInputSpec,
InputSpec as InputSpecV2
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
export const useImageWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): IImageWidget => {
const { name, options = {} } = inputSpec as ImageInputSpec
const widget = node.addWidget('image', name, '', () => {}, {
serialize: true,
...options
}) as IImageWidget
return widget
}
}

View File

@@ -0,0 +1,97 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import {
type InputSpec,
isIntInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import {
type ComfyWidgetConstructorV2,
addValueControlWidget
} from '@/scripts/widgets'
import { useSettingStore } from '@/stores/settingStore'
function onValueChange(this: INumericWidget, v: number) {
// For integers, always round to the nearest step
// step === 0 is invalid, assign 1 if options.step is 0
const step = this.options.step2 || 1
if (step === 1) {
// Simple case: round to nearest integer
this.value = Math.round(v)
} else {
// Round to nearest multiple of step
// First, determine if min value creates an offset
const min = this.options.min ?? 0
const offset = min % step
// Round to nearest step, accounting for offset
this.value = Math.round((v - offset) / step) * step + offset
}
}
export const _for_testing = {
onValueChange
}
export const useIntWidget = () => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
if (!isIntInputSpec(inputSpec)) {
throw new Error(`Invalid input data: ${inputSpec}`)
}
const settingStore = useSettingStore()
const sliderEnabled = !settingStore.get('Comfy.DisableSliders')
const display_type = inputSpec.display
const widgetType =
sliderEnabled && display_type == 'slider'
? 'slider'
: display_type == 'knob'
? 'knob'
: 'number'
const step = inputSpec.step ?? 1
/** Assertion {@link inputSpec.default} */
const defaultValue = (inputSpec.default as number | undefined) ?? 0
const widget = node.addWidget(
widgetType,
inputSpec.name,
defaultValue,
onValueChange,
{
min: inputSpec.min ?? 0,
max: inputSpec.max ?? 2048,
/** @deprecated Use step2 instead. The 10x value is a legacy implementation. */
step: step * 10,
step2: step,
precision: 0
}
)
const controlAfterGenerate =
inputSpec.control_after_generate ??
/**
* Compatibility with legacy node convention. Int input with name
* 'seed' or 'noise_seed' get automatically added a control widget.
*/
['seed', 'noise_seed'].includes(inputSpec.name)
if (controlAfterGenerate) {
const seedControl = addValueControlWidget(
node,
widget,
'randomize',
undefined,
undefined,
transformInputSpecV2ToV1(inputSpec)
)
widget.linkedWidgets = [seedControl]
}
return widget
}
return widgetConstructor
}

View File

@@ -0,0 +1,115 @@
import { Editor as TiptapEditor } from '@tiptap/core'
import TiptapLink from '@tiptap/extension-link'
import TiptapTable from '@tiptap/extension-table'
import TiptapTableCell from '@tiptap/extension-table-cell'
import TiptapTableHeader from '@tiptap/extension-table-header'
import TiptapTableRow from '@tiptap/extension-table-row'
import TiptapStarterKit from '@tiptap/starter-kit'
import { Markdown as TiptapMarkdown } from 'tiptap-markdown'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { type InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
function addMarkdownWidget(
node: LGraphNode,
name: string,
opts: { defaultVal: string }
) {
TiptapMarkdown.configure({
html: false,
breaks: true,
transformPastedText: true
})
const editor = new TiptapEditor({
extensions: [
TiptapStarterKit,
TiptapMarkdown,
TiptapLink,
TiptapTable,
TiptapTableCell,
TiptapTableHeader,
TiptapTableRow
],
content: opts.defaultVal,
editable: false
})
const inputEl = editor.options.element as HTMLElement
inputEl.classList.add('comfy-markdown')
const textarea = document.createElement('textarea')
inputEl.append(textarea)
const widget = node.addDOMWidget(name, 'MARKDOWN', inputEl, {
getValue(): string {
return textarea.value
},
setValue(v: string) {
textarea.value = v
editor.commands.setContent(v)
}
})
widget.inputEl = inputEl
widget.options.minNodeSize = [400, 200]
inputEl.addEventListener('pointerdown', (event: PointerEvent) => {
if (event.button !== 0) {
app.canvas.processMouseDown(event)
return
}
if (event.target instanceof HTMLAnchorElement) {
return
}
inputEl.classList.add('editing')
setTimeout(() => {
textarea.focus()
}, 0)
})
textarea.addEventListener('blur', () => {
inputEl.classList.remove('editing')
})
textarea.addEventListener('change', () => {
editor.commands.setContent(textarea.value)
widget.callback?.(widget.value)
})
inputEl.addEventListener('keydown', (event: KeyboardEvent) => {
event.stopPropagation()
})
inputEl.addEventListener('pointerdown', (event: PointerEvent) => {
if (event.button === 1) {
app.canvas.processMouseDown(event)
}
})
inputEl.addEventListener('pointermove', (event: PointerEvent) => {
if ((event.buttons & 4) === 4) {
app.canvas.processMouseMove(event)
}
})
inputEl.addEventListener('pointerup', (event: PointerEvent) => {
if (event.button === 1) {
app.canvas.processMouseUp(event)
}
})
return widget
}
export const useMarkdownWidget = () => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
return addMarkdownWidget(node, inputSpec.name, {
defaultVal: inputSpec.default ?? ''
})
}
return widgetConstructor
}

View File

@@ -0,0 +1,21 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IMultiSelectWidget } from '@/lib/litegraph/src/types/widgets'
import type {
InputSpec as InputSpecV2,
MultiSelectInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
export const useMultiSelectWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): IMultiSelectWidget => {
const { name, options = {} } = inputSpec as MultiSelectInputSpec
const widget = node.addWidget('multiselect', name, [], () => {}, {
serialize: true,
values: options.values || [],
...options
}) as IMultiSelectWidget
return widget
}
}

View File

@@ -0,0 +1,55 @@
import { ref } from 'vue'
import TextPreviewWidget from '@/components/graph/widgets/TextPreviewWidget.vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import {
ComponentWidgetImpl,
type ComponentWidgetStandardProps,
addWidget
} from '@/scripts/domWidget'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
type TextPreviewCustomProps = Omit<
InstanceType<typeof TextPreviewWidget>['$props'],
ComponentWidgetStandardProps
>
const PADDING = 16
export const useTextPreviewWidget = (
options: {
minHeight?: number
} = {}
) => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
const widgetValue = ref<string>('')
const widget = new ComponentWidgetImpl<
string | object,
TextPreviewCustomProps
>({
node,
name: inputSpec.name,
component: TextPreviewWidget,
inputSpec,
props: {
nodeId: node.id
},
options: {
getValue: () => widgetValue.value,
setValue: (value: string | object) => {
widgetValue.value = typeof value === 'string' ? value : String(value)
},
getMinHeight: () => options.minHeight ?? 42 + PADDING,
serialize: false
}
})
addWidget(node, widget)
return widget
}
return widgetConstructor
}

View File

@@ -0,0 +1,274 @@
import axios from 'axios'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { IWidget } from '@/lib/litegraph/src/litegraph'
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
import { api } from '@/scripts/api'
const MAX_RETRIES = 5
const TIMEOUT = 4096
export interface CacheEntry<T> {
data: T
timestamp?: number
error?: Error | null
fetchPromise?: Promise<T>
controller?: AbortController
lastErrorTime?: number
retryCount?: number
failed?: boolean
}
const dataCache = new Map<string, CacheEntry<any>>()
const createCacheKey = (config: RemoteWidgetConfig): string => {
const { route, query_params = {}, refresh = 0 } = config
const paramsKey = Object.entries(query_params)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}=${v}`)
.join('&')
return [route, `r=${refresh}`, paramsKey].join(';')
}
const getBackoff = (retryCount: number) =>
Math.min(1000 * Math.pow(2, retryCount), 512)
const isInitialized = (entry: CacheEntry<unknown> | undefined) =>
entry?.data && entry?.timestamp && entry.timestamp > 0
const isStale = (entry: CacheEntry<unknown> | undefined, ttl: number) =>
entry?.timestamp && Date.now() - entry.timestamp >= ttl
const isFetching = (entry: CacheEntry<unknown> | undefined) =>
entry?.fetchPromise !== undefined
const isFailed = (entry: CacheEntry<unknown> | undefined) =>
entry?.failed === true
const isBackingOff = (entry: CacheEntry<unknown> | undefined) =>
entry?.error &&
entry?.lastErrorTime &&
Date.now() - entry.lastErrorTime < getBackoff(entry.retryCount || 0)
const fetchData = async (
config: RemoteWidgetConfig,
controller: AbortController
) => {
const { route, response_key, query_params, timeout = TIMEOUT } = config
const res = await axios.get(route, {
params: query_params,
signal: controller.signal,
timeout
})
return response_key ? res.data[response_key] : res.data
}
export function useRemoteWidget<
T extends string | number | boolean | object
>(options: {
remoteConfig: RemoteWidgetConfig
defaultValue: T
node: LGraphNode
widget: IWidget
}) {
const { remoteConfig, defaultValue, node, widget } = options
const { refresh = 0, max_retries = MAX_RETRIES } = remoteConfig
const isPermanent = refresh <= 0
const cacheKey = createCacheKey(remoteConfig)
let isLoaded = false
let refreshQueued = false
const setSuccess = (entry: CacheEntry<T>, data: T) => {
entry.retryCount = 0
entry.lastErrorTime = 0
entry.error = null
entry.timestamp = Date.now()
entry.data = data ?? defaultValue
}
const setError = (entry: CacheEntry<T>, error: Error | unknown) => {
entry.retryCount = (entry.retryCount || 0) + 1
entry.lastErrorTime = Date.now()
entry.error = error instanceof Error ? error : new Error(String(error))
entry.data ??= defaultValue
entry.fetchPromise = undefined
if (entry.retryCount >= max_retries) {
setFailed(entry)
}
}
const setFailed = (entry: CacheEntry<T>) => {
dataCache.set(cacheKey, {
data: entry.data ?? defaultValue,
failed: true
})
}
const isFirstLoad = () => {
return !isLoaded && isInitialized(dataCache.get(cacheKey))
}
const onFirstLoad = (data: T[]) => {
isLoaded = true
widget.value = data[0]
widget.callback?.(widget.value)
node.graph?.setDirtyCanvas(true)
}
const fetchValue = async () => {
const entry = dataCache.get(cacheKey)
if (isFailed(entry)) return entry!.data
const isValid =
isInitialized(entry) && (isPermanent || !isStale(entry, refresh))
if (isValid || isBackingOff(entry) || isFetching(entry)) return entry!.data
const currentEntry: CacheEntry<T> = entry || { data: defaultValue }
dataCache.set(cacheKey, currentEntry)
try {
currentEntry.controller = new AbortController()
currentEntry.fetchPromise = fetchData(
remoteConfig,
currentEntry.controller
)
const data = await currentEntry.fetchPromise
setSuccess(currentEntry, data)
return currentEntry.data
} catch (err) {
setError(currentEntry, err)
return currentEntry.data
} finally {
currentEntry.fetchPromise = undefined
currentEntry.controller = undefined
}
}
const onRefresh = () => {
if (remoteConfig.control_after_refresh) {
const data = getCachedValue()
if (!Array.isArray(data)) return // control_after_refresh is only supported for array values
switch (remoteConfig.control_after_refresh) {
case 'first':
widget.value = data[0] ?? defaultValue
break
case 'last':
widget.value = data.at(-1) ?? defaultValue
break
}
widget.callback?.(widget.value)
node.graph?.setDirtyCanvas(true)
}
}
/**
* Clear the widget's cached value, forcing a refresh on next access (e.g., a new render)
*/
const clearCachedValue = () => {
const entry = dataCache.get(cacheKey)
if (!entry) return
if (entry.fetchPromise) entry.controller?.abort() // Abort in-flight request
dataCache.delete(cacheKey)
}
/**
* Get the cached value of the widget without starting a new fetch.
* @returns the most recently computed value of the widget.
*/
function getCachedValue() {
return dataCache.get(cacheKey)?.data as T
}
/**
* Getter of the remote property of the widget (e.g., options.values, value, etc.).
* Starts the fetch process then returns the cached value immediately.
* @returns the most recent value of the widget.
*/
function getValue(onFulfilled?: () => void) {
void fetchValue()
.then((data) => {
if (isFirstLoad()) onFirstLoad(data)
if (refreshQueued && data !== defaultValue) {
onRefresh()
refreshQueued = false
}
onFulfilled?.()
})
.catch((err) => {
console.error(err)
})
return getCachedValue() ?? defaultValue
}
/**
* Force the widget to refresh its value
*/
widget.refresh = function () {
refreshQueued = true
clearCachedValue()
getValue()
}
/**
* Add a refresh button to the node that, when clicked, will force the widget to refresh
*/
function addRefreshButton() {
node.addWidget('button', 'refresh', 'refresh', widget.refresh)
}
/**
* Add auto-refresh toggle widget and execution success listener
*/
function addAutoRefreshToggle() {
let autoRefreshEnabled = false
// Handler for execution success
const handleExecutionSuccess = () => {
if (autoRefreshEnabled && widget.refresh) {
widget.refresh()
}
}
// Add toggle widget
const autoRefreshWidget = node.addWidget(
'toggle',
'Auto-refresh after generation',
false,
(value: boolean) => {
autoRefreshEnabled = value
},
{
serialize: false
}
)
// Register event listener
api.addEventListener('execution_success', handleExecutionSuccess)
// Cleanup on node removal
node.onRemoved = useChainCallback(node.onRemoved, function () {
api.removeEventListener('execution_success', handleExecutionSuccess)
})
return autoRefreshWidget
}
// Always add auto-refresh toggle for remote widgets
addAutoRefreshToggle()
return {
getCachedValue,
getValue,
refreshValue: widget.refresh,
addRefreshButton,
getCacheEntry: () => dataCache.get(cacheKey),
cacheKey
}
}

View File

@@ -0,0 +1,28 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { ISelectButtonWidget } from '@/lib/litegraph/src/types/widgets'
import type {
InputSpec as InputSpecV2,
SelectButtonInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
export const useSelectButtonWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): ISelectButtonWidget => {
const { name, options = {} } = inputSpec as SelectButtonInputSpec
const values = options.values || []
const widget = node.addWidget(
'selectbutton',
name,
values[0] || '',
(_value: string) => {},
{
serialize: true,
values,
...options
}
) as ISelectButtonWidget
return widget
}
}

View File

@@ -0,0 +1,139 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
type InputSpec,
isStringInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useSettingStore } from '@/stores/settingStore'
const TRACKPAD_DETECTION_THRESHOLD = 50
function addMultilineWidget(
node: LGraphNode,
name: string,
opts: { defaultVal: string; placeholder?: string }
) {
const inputEl = document.createElement('textarea')
inputEl.className = 'comfy-multiline-input'
inputEl.value = opts.defaultVal
inputEl.placeholder = opts.placeholder || name
inputEl.spellcheck = useSettingStore().get('Comfy.TextareaWidget.Spellcheck')
const widget = node.addDOMWidget(name, 'customtext', inputEl, {
getValue(): string {
return inputEl.value
},
setValue(v: string) {
inputEl.value = v
}
})
widget.inputEl = inputEl
widget.options.minNodeSize = [400, 200]
inputEl.addEventListener('input', () => {
widget.callback?.(widget.value)
})
// Allow middle mouse button panning
inputEl.addEventListener('pointerdown', (event: PointerEvent) => {
if (event.button === 1) {
app.canvas.processMouseDown(event)
}
})
inputEl.addEventListener('pointermove', (event: PointerEvent) => {
if ((event.buttons & 4) === 4) {
app.canvas.processMouseMove(event)
}
})
inputEl.addEventListener('pointerup', (event: PointerEvent) => {
if (event.button === 1) {
app.canvas.processMouseUp(event)
}
})
inputEl.addEventListener('wheel', (event: WheelEvent) => {
const gesturesEnabled = useSettingStore().get(
'LiteGraph.Pointer.TrackpadGestures'
)
const deltaX = event.deltaX
const deltaY = event.deltaY
const canScrollY = inputEl.scrollHeight > inputEl.clientHeight
const isHorizontal = Math.abs(deltaX) > Math.abs(deltaY)
// Prevent pinch zoom from zooming the page
if (event.ctrlKey) {
event.preventDefault()
event.stopPropagation()
app.canvas.processMouseWheel(event)
return
}
// Detect if this is likely a trackpad gesture vs mouse wheel
// Trackpads usually have deltaX or smaller deltaY values (< TRACKPAD_DETECTION_THRESHOLD)
// Mouse wheels typically have larger discrete deltaY values (>= TRACKPAD_DETECTION_THRESHOLD)
const isLikelyTrackpad =
Math.abs(deltaX) > 0 || Math.abs(deltaY) < TRACKPAD_DETECTION_THRESHOLD
// Trackpad gestures: when enabled, trackpad panning goes to canvas
if (gesturesEnabled && isLikelyTrackpad) {
event.preventDefault()
event.stopPropagation()
app.canvas.processMouseWheel(event)
return
}
// When gestures disabled: horizontal always goes to canvas (no horizontal scroll in textarea)
if (isHorizontal) {
event.preventDefault()
event.stopPropagation()
app.canvas.processMouseWheel(event)
return
}
// Vertical scrolling when gestures disabled: let textarea scroll if scrollable
if (canScrollY) {
event.stopPropagation()
return
}
// If textarea can't scroll vertically, pass to canvas
event.preventDefault()
app.canvas.processMouseWheel(event)
})
return widget
}
export const useStringWidget = () => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
if (!isStringInputSpec(inputSpec)) {
throw new Error(`Invalid input data: ${inputSpec}`)
}
const defaultVal = inputSpec.default ?? ''
const multiline = inputSpec.multiline
const widget = multiline
? addMultilineWidget(node, inputSpec.name, {
defaultVal,
placeholder: inputSpec.placeholder
})
: node.addWidget('text', inputSpec.name, defaultVal, () => {}, {})
if (typeof inputSpec.dynamicPrompts === 'boolean') {
widget.dynamicPrompts = inputSpec.dynamicPrompts
}
return widget
}
return widgetConstructor
}

View File

@@ -0,0 +1,28 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { ITextareaWidget } from '@/lib/litegraph/src/types/widgets'
import type {
InputSpec as InputSpecV2,
TextareaInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
export const useTextareaWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): ITextareaWidget => {
const { name, options = {} } = inputSpec as TextareaInputSpec
const widget = node.addWidget(
'textarea',
name,
options.default || '',
() => {},
{
serialize: true,
rows: options.rows || 5,
cols: options.cols || 50,
...options
}
) as ITextareaWidget
return widget
}
}

View File

@@ -0,0 +1,24 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { ITreeSelectWidget } from '@/lib/litegraph/src/types/widgets'
import type {
InputSpec as InputSpecV2,
TreeSelectInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
export const useTreeSelectWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): ITreeSelectWidget => {
const { name, options = {} } = inputSpec as TreeSelectInputSpec
const isMultiple = options.multiple || false
const defaultValue = isMultiple ? [] : ''
const widget = node.addWidget('treeselect', name, defaultValue, () => {}, {
serialize: true,
values: options.values || [],
multiple: isMultiple,
...options
}) as ITreeSelectWidget
return widget
}
}

View File

@@ -0,0 +1,114 @@
/**
* Widget renderer composable for Vue node system
* Maps LiteGraph widget types to Vue components
*/
import { WidgetType, widgetTypeToComponent } from '../registry/widgetRegistry'
/**
* Static mapping of LiteGraph widget types to Vue widget component names
* Moved outside function to prevent recreation on every call
*/
const TYPE_TO_ENUM_MAP: Record<string, string> = {
// Number inputs
number: WidgetType.NUMBER,
slider: WidgetType.SLIDER,
INT: WidgetType.INT,
FLOAT: WidgetType.FLOAT,
// Text inputs
text: WidgetType.STRING,
string: WidgetType.STRING,
STRING: WidgetType.STRING,
// Selection
combo: WidgetType.COMBO,
COMBO: WidgetType.COMBO,
// Boolean
toggle: WidgetType.TOGGLESWITCH,
boolean: WidgetType.BOOLEAN,
BOOLEAN: WidgetType.BOOLEAN,
// Multiline text
multiline: WidgetType.TEXTAREA,
textarea: WidgetType.TEXTAREA,
customtext: WidgetType.TEXTAREA,
MARKDOWN: WidgetType.MARKDOWN,
// Advanced widgets
color: WidgetType.COLOR,
COLOR: WidgetType.COLOR,
image: WidgetType.IMAGE,
IMAGE: WidgetType.IMAGE,
imagecompare: WidgetType.IMAGECOMPARE,
IMAGECOMPARE: WidgetType.IMAGECOMPARE,
galleria: WidgetType.GALLERIA,
GALLERIA: WidgetType.GALLERIA,
file: WidgetType.FILEUPLOAD,
fileupload: WidgetType.FILEUPLOAD,
FILEUPLOAD: WidgetType.FILEUPLOAD,
// Button widget
button: WidgetType.BUTTON,
BUTTON: WidgetType.BUTTON,
// Chart widget
chart: WidgetType.CHART,
CHART: WidgetType.CHART
} as const
/**
* Pre-computed widget support map for O(1) lookups
* Maps widget type directly to boolean for fast shouldRenderAsVue checks
*/
const WIDGET_SUPPORT_MAP = new Map(
Object.entries(TYPE_TO_ENUM_MAP).map(([type, enumValue]) => [
type,
widgetTypeToComponent[enumValue] !== undefined
])
)
export const ESSENTIAL_WIDGET_TYPES = new Set([
'combo',
'COMBO',
'select',
'toggle',
'boolean',
'BOOLEAN',
'slider',
'number',
'INT',
'FLOAT'
])
export const useWidgetRenderer = () => {
const getWidgetComponent = (widgetType: string): string => {
const enumKey = TYPE_TO_ENUM_MAP[widgetType]
if (enumKey && widgetTypeToComponent[enumKey]) {
return enumKey
}
return WidgetType.STRING
}
const shouldRenderAsVue = (widget: {
type?: string
options?: Record<string, unknown>
}): boolean => {
if (widget.options?.canvasOnly) return false
if (!widget.type) return false
// Check if widget type is explicitly supported
const isSupported = WIDGET_SUPPORT_MAP.get(widget.type)
if (isSupported !== undefined) return isSupported
// Fallback: unknown types are rendered as STRING widget
return widgetTypeToComponent[WidgetType.STRING] !== undefined
}
return {
getWidgetComponent,
shouldRenderAsVue
}
}

View File

@@ -0,0 +1,155 @@
/**
* Composable for managing widget value synchronization between Vue and LiteGraph
* Provides consistent pattern for immediate UI updates and LiteGraph callbacks
*/
import { type Ref, ref, watch } from 'vue'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
export interface UseWidgetValueOptions<
T extends WidgetValue = WidgetValue,
U = T
> {
/** The widget configuration from LiteGraph */
widget: SimplifiedWidget<T>
/** The current value from parent component */
modelValue: T
/** Default value if modelValue is null/undefined */
defaultValue: T
/** Emit function from component setup */
emit: (event: 'update:modelValue', value: T) => void
/** Optional value transformer before sending to LiteGraph */
transform?: (value: U) => T
}
export interface UseWidgetValueReturn<
T extends WidgetValue = WidgetValue,
U = T
> {
/** Local value for immediate UI updates */
localValue: Ref<T>
/** Handler for user interactions */
onChange: (newValue: U) => void
}
/**
* Manages widget value synchronization with LiteGraph
*
* @example
* ```vue
* const { localValue, onChange } = useWidgetValue({
* widget: props.widget,
* modelValue: props.modelValue,
* defaultValue: ''
* })
* ```
*/
export function useWidgetValue<T extends WidgetValue = WidgetValue, U = T>({
widget,
modelValue,
defaultValue,
emit,
transform
}: UseWidgetValueOptions<T, U>): UseWidgetValueReturn<T, U> {
// Local value for immediate UI updates
const localValue = ref<T>(modelValue ?? defaultValue)
// Handle user changes
const onChange = (newValue: U) => {
// Handle different PrimeVue component signatures
let processedValue: T
if (transform) {
processedValue = transform(newValue)
} else {
// Ensure type safety - only cast when types are compatible
if (
typeof newValue === typeof defaultValue ||
newValue === null ||
newValue === undefined
) {
processedValue = (newValue ?? defaultValue) as T
} else {
console.warn(
`useWidgetValue: Type mismatch for widget ${widget.name}. Expected ${typeof defaultValue}, got ${typeof newValue}`
)
processedValue = defaultValue
}
}
// 1. Update local state for immediate UI feedback
localValue.value = processedValue
// 2. Emit to parent component
emit('update:modelValue', processedValue)
}
// Watch for external updates from LiteGraph
watch(
() => modelValue,
(newValue) => {
localValue.value = newValue ?? defaultValue
}
)
return {
localValue: localValue as Ref<T>,
onChange
}
}
/**
* Type-specific helper for string widgets
*/
export function useStringWidgetValue(
widget: SimplifiedWidget<string>,
modelValue: string,
emit: (event: 'update:modelValue', value: string) => void
) {
return useWidgetValue({
widget,
modelValue,
defaultValue: '',
emit,
transform: (value: string | undefined) => String(value || '') // Handle undefined from PrimeVue
})
}
/**
* Type-specific helper for number widgets
*/
export function useNumberWidgetValue(
widget: SimplifiedWidget<number>,
modelValue: number,
emit: (event: 'update:modelValue', value: number) => void
) {
return useWidgetValue({
widget,
modelValue,
defaultValue: 0,
emit,
transform: (value: number | number[]) => {
// Handle PrimeVue Slider which can emit number | number[]
if (Array.isArray(value)) {
return value.length > 0 ? value[0] ?? 0 : 0
}
return Number(value) || 0
}
})
}
/**
* Type-specific helper for boolean widgets
*/
export function useBooleanWidgetValue(
widget: SimplifiedWidget<boolean>,
modelValue: boolean,
emit: (event: 'update:modelValue', value: boolean) => void
) {
return useWidgetValue({
widget,
modelValue,
defaultValue: false,
emit,
transform: (value: boolean) => Boolean(value)
})
}

View File

@@ -0,0 +1,83 @@
/**
* Widget type registry and component mapping for Vue-based widgets
*/
import type { Component } from 'vue'
// Component imports
import WidgetButton from '../components/WidgetButton.vue'
import WidgetChart from '../components/WidgetChart.vue'
import WidgetColorPicker from '../components/WidgetColorPicker.vue'
import WidgetFileUpload from '../components/WidgetFileUpload.vue'
import WidgetGalleria from '../components/WidgetGalleria.vue'
import WidgetImage from '../components/WidgetImage.vue'
import WidgetImageCompare from '../components/WidgetImageCompare.vue'
import WidgetInputText from '../components/WidgetInputText.vue'
import WidgetMarkdown from '../components/WidgetMarkdown.vue'
import WidgetMultiSelect from '../components/WidgetMultiSelect.vue'
import WidgetSelect from '../components/WidgetSelect.vue'
import WidgetSelectButton from '../components/WidgetSelectButton.vue'
import WidgetSlider from '../components/WidgetSlider.vue'
import WidgetTextarea from '../components/WidgetTextarea.vue'
import WidgetToggleSwitch from '../components/WidgetToggleSwitch.vue'
import WidgetTreeSelect from '../components/WidgetTreeSelect.vue'
/**
* Enum of all available widget types
*/
export enum WidgetType {
BUTTON = 'BUTTON',
STRING = 'STRING',
INT = 'INT',
FLOAT = 'FLOAT',
NUMBER = 'NUMBER',
BOOLEAN = 'BOOLEAN',
COMBO = 'COMBO',
COLOR = 'COLOR',
MULTISELECT = 'MULTISELECT',
SELECTBUTTON = 'SELECTBUTTON',
SLIDER = 'SLIDER',
TEXTAREA = 'TEXTAREA',
TOGGLESWITCH = 'TOGGLESWITCH',
CHART = 'CHART',
IMAGE = 'IMAGE',
IMAGECOMPARE = 'IMAGECOMPARE',
GALLERIA = 'GALLERIA',
FILEUPLOAD = 'FILEUPLOAD',
TREESELECT = 'TREESELECT',
MARKDOWN = 'MARKDOWN'
}
/**
* Maps widget types to their corresponding Vue components
* Components will be added as they are implemented
*/
export const widgetTypeToComponent: Record<string, Component> = {
// Components will be uncommented as they are implemented
[WidgetType.BUTTON]: WidgetButton,
[WidgetType.STRING]: WidgetInputText,
[WidgetType.INT]: WidgetSlider,
[WidgetType.FLOAT]: WidgetSlider,
[WidgetType.NUMBER]: WidgetSlider, // For compatibility
[WidgetType.BOOLEAN]: WidgetToggleSwitch,
[WidgetType.COMBO]: WidgetSelect,
[WidgetType.COLOR]: WidgetColorPicker,
[WidgetType.MULTISELECT]: WidgetMultiSelect,
[WidgetType.SELECTBUTTON]: WidgetSelectButton,
[WidgetType.SLIDER]: WidgetSlider,
[WidgetType.TEXTAREA]: WidgetTextarea,
[WidgetType.TOGGLESWITCH]: WidgetToggleSwitch,
[WidgetType.CHART]: WidgetChart,
[WidgetType.IMAGE]: WidgetImage,
[WidgetType.IMAGECOMPARE]: WidgetImageCompare,
[WidgetType.GALLERIA]: WidgetGalleria,
[WidgetType.FILEUPLOAD]: WidgetFileUpload,
[WidgetType.TREESELECT]: WidgetTreeSelect,
[WidgetType.MARKDOWN]: WidgetMarkdown
}
/**
* Helper function to get widget component by type
*/
export function getWidgetComponent(type: string): Component | undefined {
return widgetTypeToComponent[type]
}

View File

@@ -0,0 +1,182 @@
/**
* Node Widget Management
*
* Handles widget state synchronization between LiteGraph and Vue.
* Provides wrapped callbacks to maintain consistency.
*/
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { WidgetValue } from '@/types/simplifiedWidget'
export type { WidgetValue }
export interface SafeWidgetData {
name: string
type: string
value: WidgetValue
options?: Record<string, unknown>
callback?: ((value: unknown) => void) | undefined
}
export interface VueNodeData {
id: string
title: string
type: string
mode: number
selected: boolean
executing: boolean
widgets?: SafeWidgetData[]
inputs?: unknown[]
outputs?: unknown[]
flags?: {
collapsed?: boolean
}
}
/**
* Validates that a value is a valid WidgetValue type
*/
export function validateWidgetValue(value: unknown): WidgetValue {
if (value === null || value === undefined || value === void 0) {
return undefined
}
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return value
}
if (typeof value === 'object') {
// Check if it's a File array
if (Array.isArray(value) && value.every((item) => item instanceof File)) {
return value as File[]
}
// Otherwise it's a generic object
return value as object
}
// If none of the above, return undefined
console.warn(`Invalid widget value type: ${typeof value}`, value)
return undefined
}
/**
* Extract safe widget data from LiteGraph widgets
*/
export function extractWidgetData(
widgets?: any[]
): SafeWidgetData[] | undefined {
if (!widgets) return undefined
return widgets.map((widget) => {
try {
let value = widget.value
// For combo widgets, if value is undefined, use the first option as default
if (
value === undefined &&
widget.type === 'combo' &&
widget.options?.values &&
Array.isArray(widget.options.values) &&
widget.options.values.length > 0
) {
value = widget.options.values[0]
}
return {
name: widget.name,
type: widget.type,
value: validateWidgetValue(value),
options: widget.options ? { ...widget.options } : undefined,
callback: widget.callback
}
} catch (error) {
return {
name: widget.name || 'unknown',
type: widget.type || 'text',
value: undefined,
options: undefined,
callback: undefined
}
}
})
}
/**
* Widget callback management for LiteGraph/Vue sync
*/
export function useNodeWidgets() {
/**
* Creates a wrapped callback for a widget that maintains LiteGraph/Vue sync
*/
const createWrappedCallback = (
widget: { value?: unknown; name: string },
originalCallback: ((value: unknown) => void) | undefined,
nodeId: string,
onUpdate: (nodeId: string, widgetName: string, value: unknown) => void
) => {
let updateInProgress = false
return (value: unknown) => {
if (updateInProgress) return
updateInProgress = true
try {
// Validate that the value is of an acceptable type
if (
value !== null &&
value !== undefined &&
typeof value !== 'string' &&
typeof value !== 'number' &&
typeof value !== 'boolean' &&
typeof value !== 'object'
) {
console.warn(`Invalid widget value type: ${typeof value}`)
updateInProgress = false
return
}
// Always update widget.value to ensure sync
widget.value = value
// Call the original callback if it exists
if (originalCallback) {
originalCallback.call(widget, value)
}
// Update Vue state to maintain synchronization
onUpdate(nodeId, widget.name, value)
} finally {
updateInProgress = false
}
}
}
/**
* Sets up widget callbacks for a node
*/
const setupNodeWidgetCallbacks = (
node: LGraphNode,
onUpdate: (nodeId: string, widgetName: string, value: unknown) => void
) => {
if (!node.widgets) return
const nodeId = String(node.id)
node.widgets.forEach((widget) => {
const originalCallback = widget.callback
widget.callback = createWrappedCallback(
widget,
originalCallback,
nodeId,
onUpdate
)
})
}
return {
validateWidgetValue,
extractWidgetData,
createWrappedCallback,
setupNodeWidgetCallbacks
}
}