Merge remote-tracking branch 'origin/main' into bl-update-slots

This commit is contained in:
Benjamin Lu
2025-09-11 16:36:45 -07:00
115 changed files with 3449 additions and 1709 deletions

View File

@@ -9,8 +9,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type {
INodeInputSlot,
INodeOutputSlot,
Point,
ReadOnlyPoint
Point
} from '@/lib/litegraph/src/interfaces'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { isWidgetInputSlot } from '@/lib/litegraph/src/node/slotUtils'
@@ -138,7 +137,7 @@ export function getSlotPosition(
node: LGraphNode,
slotIndex: number,
isInput: boolean
): ReadOnlyPoint {
): Point {
// Try to get precise position from slot layout (DOM-registered)
const slotKey = getSlotKey(String(node.id), slotIndex, isInput)
const slotLayout = layoutStore.getSlotLayout(slotKey)

View File

@@ -330,6 +330,7 @@ export interface LayoutStore {
batchUpdateNodeBounds(
updates: Array<{ nodeId: NodeId; bounds: Bounds }>
): void
batchUpdateSlotLayouts(
updates: Array<{ key: string; layout: SlotLayout }>
): void

View File

@@ -74,6 +74,10 @@ export const useTransformState = () => {
// Computed transform string for CSS
const transformStyle = computed(() => ({
// Match LiteGraph DragAndScale.toCanvasContext():
// ctx.scale(scale); ctx.translate(offset)
// CSS applies right-to-left, so "scale() translate()" -> translate first, then scale
// Effective mapping: screen = (canvas + offset) * scale
transform: `scale(${camera.z}) translate(${camera.x}px, ${camera.y}px)`,
transformOrigin: '0 0'
}))
@@ -103,15 +107,15 @@ export const useTransformState = () => {
* Applies the same transform that LiteGraph uses for rendering.
* Essential for positioning Vue components to align with canvas elements.
*
* Formula: screen = canvas * scale + offset
* Formula: screen = (canvas + offset) * scale
*
* @param point - Point in canvas coordinate system
* @returns Point in screen coordinate system
*/
const canvasToScreen = (point: Point): Point => {
return {
x: point.x * camera.z + camera.x,
y: point.y * camera.z + camera.y
x: (point.x + camera.x) * camera.z,
y: (point.y + camera.y) * camera.z
}
}
@@ -121,15 +125,15 @@ export const useTransformState = () => {
* Inverse of canvasToScreen. Useful for hit testing and converting
* mouse events back to canvas space.
*
* Formula: canvas = (screen - offset) / scale
* Formula: canvas = screen / scale - offset
*
* @param point - Point in screen coordinate system
* @returns Point in canvas coordinate system
*/
const screenToCanvas = (point: Point): Point => {
return {
x: (point.x - camera.x) / camera.z,
y: (point.y - camera.y) / camera.z
x: point.x / camera.z - camera.x,
y: point.y / camera.z - camera.y
}
}

View File

@@ -7,28 +7,34 @@
:data-node-id="nodeData.id"
:class="
cn(
'bg-white dark-theme:bg-[#15161A]',
'bg-white dark-theme:bg-charcoal-100',
'min-w-[445px]',
'lg-node absolute border border-solid rounded-2xl',
'outline outline-transparent outline-2',
'lg-node absolute rounded-2xl',
// border
'border border-solid border-sand-100 dark-theme:border-charcoal-300',
!!executing && 'border-blue-500 dark-theme:border-blue-500',
!!error && 'border-red-700 dark-theme:border-red-300',
// hover
'hover:ring-7 ring-gray-500/50 dark-theme:ring-gray-500/20',
// Selected
'outline-transparent -outline-offset-2 outline-2',
!!isSelected && 'outline-black dark-theme:outline-white',
!!(isSelected && executing) &&
'outline-blue-500 dark-theme:outline-blue-500',
!!(isSelected && error) && 'outline-red-500 dark-theme:outline-red-500',
{
'outline-black dark-theme:outline-white': isSelected
},
{
'border-blue-500 ring-2 ring-blue-300': isSelected,
'border-[#e1ded5] dark-theme:border-[#292A30]': !isSelected,
'animate-pulse': executing,
'opacity-50': nodeData.mode === 4,
'border-red-500 bg-red-50': error,
'will-change-transform': isDragging
},
lodCssClass
lodCssClass,
'pointer-events-auto'
)
"
:style="[
{
transform: `translate(${layoutPosition.x ?? position?.x ?? 0}px, ${(layoutPosition.y ?? position?.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
pointerEvents: 'auto'
zIndex: zIndex
},
dragStyle
]"
@@ -53,8 +59,35 @@
/>
</div>
<div
v-if="
(isMinimalLOD || isCollapsed) && executing && progress !== undefined
"
:class="
cn(
'absolute inset-x-4 -bottom-[1px] translate-y-1/2 rounded-full',
progressClasses
)
"
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
/>
<template v-if="!isMinimalLOD && !isCollapsed">
<div :class="cn(separatorClasses, 'mb-4')" />
<div class="mb-4 relative">
<div :class="separatorClasses" />
<!-- Progress bar for executing state -->
<div
v-if="executing && progress !== undefined"
:class="
cn(
'absolute inset-x-0 top-1/2 -translate-y-1/2',
!!(progress < 1) && 'rounded-r-full',
progressClasses
)
"
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
/>
</div>
<!-- Node Body - rendered based on LOD level and collapsed state -->
<div
@@ -99,13 +132,6 @@
/>
</div>
</template>
<!-- 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>
@@ -194,6 +220,7 @@ onErrorCaptured((error) => {
// Use layout system for node position and dragging
const {
position: layoutPosition,
zIndex,
startDrag,
handleDrag: handleLayoutDrag,
endDrag
@@ -226,7 +253,9 @@ const hasCustomContent = computed(() => {
})
// Computed classes and conditions for better reusability
const separatorClasses = 'bg-[#e1ded5] dark-theme:bg-[#292A30] h-[1px] mx-0'
const separatorClasses =
'bg-sand-100 dark-theme:bg-charcoal-300 h-[1px] mx-0 w-full'
const progressClasses = 'h-2 bg-primary-500 transition-all duration-300'
// Common condition computations to avoid repetition
const shouldShowWidgets = computed(

View File

@@ -0,0 +1,195 @@
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { describe, expect, it } from 'vitest'
import { type PropType, defineComponent } from 'vue'
import { createI18n } from 'vue-i18n'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import enMessages from '@/locales/en/main.json'
import NodeSlots from './NodeSlots.vue'
const makeNodeData = (overrides: Partial<VueNodeData> = {}): VueNodeData => ({
id: '123',
title: 'Test Node',
type: 'TestType',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: [],
widgets: [],
flags: { collapsed: false },
...overrides
})
// Explicit stubs to capture props for assertions
interface StubSlotData {
name?: string
type?: string
boundingRect?: [number, number, number, number]
}
const InputSlotStub = defineComponent({
name: 'InputSlot',
props: {
slotData: { type: Object as PropType<StubSlotData>, required: true },
nodeId: { type: String, required: false, default: '' },
index: { type: Number, required: true },
readonly: { type: Boolean, required: false, default: false }
},
template: `
<div
class="stub-input-slot"
:data-index="index"
:data-name="slotData && slotData.name ? slotData.name : ''"
:data-type="slotData && slotData.type ? slotData.type : ''"
:data-node-id="nodeId"
:data-readonly="readonly ? 'true' : 'false'"
/>
`
})
const OutputSlotStub = defineComponent({
name: 'OutputSlot',
props: {
slotData: { type: Object as PropType<StubSlotData>, required: true },
nodeId: { type: String, required: false, default: '' },
index: { type: Number, required: true },
readonly: { type: Boolean, required: false, default: false }
},
template: `
<div
class="stub-output-slot"
:data-index="index"
:data-name="slotData && slotData.name ? slotData.name : ''"
:data-type="slotData && slotData.type ? slotData.type : ''"
:data-node-id="nodeId"
:data-readonly="readonly ? 'true' : 'false'"
/>
`
})
const mountSlots = (nodeData: VueNodeData, readonly = false) => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(NodeSlots, {
global: {
plugins: [i18n, createPinia()],
stubs: {
InputSlot: InputSlotStub,
OutputSlot: OutputSlotStub
}
},
props: { nodeData, readonly }
})
}
describe('NodeSlots.vue', () => {
it('filters out inputs with widget property and maps indexes correctly', () => {
// Two inputs without widgets (object and string) and one with widget (filtered)
const inputObjNoWidget = {
name: 'objNoWidget',
type: 'number',
boundingRect: [0, 0, 0, 0]
}
const inputObjWithWidget = {
name: 'objWithWidget',
type: 'number',
boundingRect: [0, 0, 0, 0],
widget: { name: 'objWithWidget' }
}
const inputs = [inputObjNoWidget, inputObjWithWidget, 'stringInput']
const wrapper = mountSlots(makeNodeData({ inputs }))
const inputEls = wrapper
.findAll('.stub-input-slot')
.map((w) => w.element as HTMLElement)
// Should filter out the widget-backed input; expect 2 inputs rendered
expect(inputEls.length).toBe(2)
// Verify expected tuple of {index, name, nodeId}
const info = inputEls.map((el) => ({
index: Number(el.dataset.index),
name: el.dataset.name ?? '',
nodeId: el.dataset.nodeId ?? '',
type: el.dataset.type ?? '',
readonly: el.dataset.readonly === 'true'
}))
expect(info).toEqual([
{
index: 0,
name: 'objNoWidget',
nodeId: '123',
type: 'number',
readonly: false
},
// string input is converted to object with default type 'any'
{
index: 1,
name: 'stringInput',
nodeId: '123',
type: 'any',
readonly: false
}
])
// Ensure widget-backed input was indeed filtered out
expect(wrapper.find('[data-name="objWithWidget"]').exists()).toBe(false)
})
it('maps outputs and passes correct indexes', () => {
const outputObj = { name: 'outA', type: 'any', boundingRect: [0, 0, 0, 0] }
const outputs = [outputObj, 'outB']
const wrapper = mountSlots(makeNodeData({ outputs }))
const outputEls = wrapper
.findAll('.stub-output-slot')
.map((w) => w.element as HTMLElement)
expect(outputEls.length).toBe(2)
const outInfo = outputEls.map((el) => ({
index: Number(el.dataset.index),
name: el.dataset.name ?? '',
nodeId: el.dataset.nodeId ?? '',
type: el.dataset.type ?? '',
readonly: el.dataset.readonly === 'true'
}))
expect(outInfo).toEqual([
{ index: 0, name: 'outA', nodeId: '123', type: 'any', readonly: false },
// string output mapped to object with type 'any'
{ index: 1, name: 'outB', nodeId: '123', type: 'any', readonly: false }
])
})
it('renders nothing when there are no inputs/outputs', () => {
const wrapper = mountSlots(makeNodeData({ inputs: [], outputs: [] }))
expect(wrapper.findAll('.stub-input-slot').length).toBe(0)
expect(wrapper.findAll('.stub-output-slot').length).toBe(0)
})
it('passes readonly to child slots', () => {
const wrapper = mountSlots(
makeNodeData({ inputs: ['a'], outputs: ['b'] }),
/* readonly */ true
)
const all = [
...wrapper
.findAll('.stub-input-slot')
.filter((w) => w.element instanceof HTMLElement)
.map((w) => w.element as HTMLElement),
...wrapper
.findAll('.stub-output-slot')
.filter((w) => w.element instanceof HTMLElement)
.map((w) => w.element as HTMLElement)
]
expect(all.length).toBe(2)
for (const el of all) {
expect.soft(el.dataset.readonly).toBe('true')
}
})
})

View File

@@ -0,0 +1,214 @@
/**
* Node Event Handlers Composable
*
* Handles all Vue node interaction events including:
* - Node selection with multi-select support
* - Node collapse/expand state management
* - Node title editing and updates
* - Layout mutations for visual feedback
* - Integration with LiteGraph canvas selection system
*/
import type { Ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
import { useCanvasStore } from '@/stores/graphStore'
interface NodeManager {
getNode: (id: string) => any
}
export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
const canvasStore = useCanvasStore()
const { bringNodeToFront } = useNodeZIndex()
/**
* Handle node selection events
* Supports single selection and multi-select with Ctrl/Cmd
*/
const handleNodeSelect = (event: PointerEvent, nodeData: VueNodeData) => {
if (!canvasStore.canvas || !nodeManager.value) return
const node = nodeManager.value.getNode(nodeData.id)
if (!node) return
const isMultiSelect = event.ctrlKey || event.metaKey
if (isMultiSelect) {
// Ctrl/Cmd+click -> toggle selection
if (node.selected) {
canvasStore.canvas.deselect(node)
} else {
canvasStore.canvas.select(node)
}
} else {
// Regular click -> single select
canvasStore.canvas.deselectAll()
canvasStore.canvas.select(node)
}
// Bring node to front when clicked (similar to LiteGraph behavior)
// Skip if node is pinned to avoid unwanted movement
if (!node.flags?.pinned) {
bringNodeToFront(nodeData.id)
}
// Update canvas selection tracking
canvasStore.updateSelectedItems()
}
/**
* Handle node collapse/expand state changes
* Uses LiteGraph's native collapse method for proper state management
*/
const handleNodeCollapse = (nodeId: string, collapsed: boolean) => {
if (!nodeManager.value) return
const node = nodeManager.value.getNode(nodeId)
if (!node) return
// Use LiteGraph's collapse method if the state needs to change
const currentCollapsed = node.flags?.collapsed ?? false
if (currentCollapsed !== collapsed) {
node.collapse()
}
}
/**
* Handle node title updates
* Updates the title in LiteGraph for persistence across sessions
*/
const handleNodeTitleUpdate = (nodeId: string, newTitle: string) => {
if (!nodeManager.value) return
const node = nodeManager.value.getNode(nodeId)
if (!node) return
// Update the node title in LiteGraph for persistence
node.title = newTitle
}
/**
* Handle node double-click events
* Can be used for custom actions like opening node editor
*/
const handleNodeDoubleClick = (
event: PointerEvent,
nodeData: VueNodeData
) => {
if (!canvasStore.canvas || !nodeManager.value) return
const node = nodeManager.value.getNode(nodeData.id)
if (!node) return
// Prevent default browser behavior
event.preventDefault()
// TODO: add custom double-click behavior here
// For now, ensure node is selected
if (!node.selected) {
handleNodeSelect(event, nodeData)
}
}
/**
* Handle node right-click context menu events
* Integrates with LiteGraph's context menu system
*/
const handleNodeRightClick = (event: PointerEvent, nodeData: VueNodeData) => {
if (!canvasStore.canvas || !nodeManager.value) return
const node = nodeManager.value.getNode(nodeData.id)
if (!node) return
// Prevent default context menu
event.preventDefault()
// Select the node if not already selected
if (!node.selected) {
handleNodeSelect(event, nodeData)
}
// Let LiteGraph handle the context menu
// The canvas will handle showing the appropriate context menu
}
/**
* Handle node drag start events
* Prepares node for dragging and sets appropriate visual state
*/
const handleNodeDragStart = (event: DragEvent, nodeData: VueNodeData) => {
if (!canvasStore.canvas || !nodeManager.value) return
const node = nodeManager.value.getNode(nodeData.id)
if (!node) return
// Ensure node is selected before dragging
if (!node.selected) {
// Create a synthetic pointer event for selection
const syntheticEvent = new PointerEvent('pointerdown', {
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
bubbles: true
})
handleNodeSelect(syntheticEvent, nodeData)
}
// Set drag data for potential drop operations
if (event.dataTransfer) {
event.dataTransfer.setData('application/comfy-node-id', nodeData.id)
event.dataTransfer.effectAllowed = 'move'
}
}
/**
* Batch select multiple nodes
* Useful for selection toolbox or area selection
*/
const selectNodes = (nodeIds: string[], addToSelection = false) => {
if (!canvasStore.canvas || !nodeManager.value) return
if (!addToSelection) {
canvasStore.canvas.deselectAll()
}
nodeIds.forEach((nodeId) => {
const node = nodeManager.value?.getNode(nodeId)
if (node && canvasStore.canvas) {
canvasStore.canvas.select(node)
}
})
canvasStore.updateSelectedItems()
}
/**
* Deselect specific nodes
*/
const deselectNodes = (nodeIds: string[]) => {
if (!canvasStore.canvas || !nodeManager.value) return
nodeIds.forEach((nodeId) => {
const node = nodeManager.value?.getNode(nodeId)
if (node) {
node.selected = false
}
})
canvasStore.updateSelectedItems()
}
return {
// Core event handlers
handleNodeSelect,
handleNodeCollapse,
handleNodeTitleUpdate,
handleNodeDoubleClick,
handleNodeRightClick,
handleNodeDragStart,
// Batch operations
selectNodes,
deselectNodes
}
}

View File

@@ -0,0 +1,36 @@
/**
* Node Z-Index Management Composable
*
* Provides focused functionality for managing node layering through z-index.
* Integrates with the layout system to ensure proper visual ordering.
*/
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
interface NodeZIndexOptions {
/**
* Layout source for z-index mutations
* @default LayoutSource.Vue
*/
layoutSource?: LayoutSource
}
export function useNodeZIndex(options: NodeZIndexOptions = {}) {
const { layoutSource = LayoutSource.Vue } = options
const layoutMutations = useLayoutMutations()
/**
* Bring node to front (highest z-index)
* @param nodeId - The node to bring to front
* @param source - Optional source override
*/
function bringNodeToFront(nodeId: NodeId, source?: LayoutSource) {
layoutMutations.setSource(source ?? layoutSource)
layoutMutations.bringNodeToFront(nodeId)
}
return {
bringNodeToFront
}
}

View File

@@ -78,7 +78,7 @@ const resizeObserver = new ResizeObserver((entries) => {
if (!(entry.target instanceof HTMLElement)) continue
const element = entry.target
// Identify type + id via config dataAttribute
// Find which type this element belongs to
let elementType: string | undefined
let elementId: string | undefined

View File

@@ -20,6 +20,7 @@
:model-value="selectedFile?.name"
:options="[selectedFile?.name || '']"
:disabled="true"
v-bind="transformCompatProps"
class="min-w-[8em] max-w-[20em] text-xs"
size="small"
:pt="{
@@ -88,6 +89,7 @@
:model-value="selectedFile?.name"
:options="[selectedFile?.name || '']"
:disabled="true"
v-bind="transformCompatProps"
class="min-w-[8em] max-w-[20em] text-xs"
size="small"
:pt="{
@@ -182,6 +184,7 @@ import Select from 'primevue/select'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const props = defineProps<{
@@ -201,6 +204,9 @@ const { localValue, onChange } = useWidgetValue({
emit
})
// Transform compatibility props for overlay positioning
const transformCompatProps = useTransformCompatOverlayProps()
const fileInputRef = ref<HTMLInputElement | null>(null)
// Since we only support single file, get the first file

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetInputNumberInput from './WidgetInputNumberInput.vue'
import WidgetInputNumberSlider from './WidgetInputNumberSlider.vue'
defineProps<{
widget: SimplifiedWidget<number>
readonly?: boolean
}>()
const modelValue = defineModel<number>({ default: 0 })
</script>
<template>
<component
:is="
widget.type === 'slider'
? WidgetInputNumberSlider
: WidgetInputNumberInput
"
v-model="modelValue"
:widget="widget"
:readonly="readonly"
v-bind="$attrs"
/>
</template>

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import InputNumber from 'primevue/inputnumber'
import { computed } from 'vue'
import { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import {
INPUT_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<number>
readonly?: boolean
}>()
const modelValue = defineModel<number>({ default: 0 })
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, INPUT_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(() => {
// Use step2 (correct input spec value) instead of step (legacy 10x value)
if (props.widget.options?.step2 !== undefined) {
return Number(props.widget.options.step2)
}
// 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 Number((1 / Math.pow(10, precision.value)).toFixed(precision.value))
}
// Default to 'any' for unrestricted stepping
return 0
})
</script>
<template>
<WidgetLayoutField :widget>
<InputNumber
v-model="modelValue"
v-bind="filteredProps"
show-buttons
button-layout="horizontal"
size="small"
:disabled="readonly"
:step="stepValue"
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
:pt="{
incrementButton:
'!rounded-r-lg bg-transparent border-none hover:bg-zinc-500/30 active:bg-zinc-500/40',
decrementButton:
'!rounded-l-lg bg-transparent border-none hover:bg-zinc-500/30 active:bg-zinc-500/40'
}"
>
<template #incrementicon>
<span class="pi pi-plus text-sm" />
</template>
<template #decrementicon>
<span class="pi pi-minus text-sm" />
</template>
</InputNumber>
</WidgetLayoutField>
</template>
<style scoped>
:deep(.p-inputnumber-input) {
background-color: transparent;
border: 1px solid color-mix(in oklab, #d4d4d8 10%, transparent);
border-top: transparent;
border-bottom: transparent;
height: 1.625rem;
margin: 1px 0;
box-shadow: none;
}
</style>

View File

@@ -7,9 +7,9 @@ import { describe, expect, it } from 'vitest'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetSlider from './WidgetSlider.vue'
import WidgetInputNumberSlider from './WidgetInputNumberSlider.vue'
describe('WidgetSlider Value Binding', () => {
describe('WidgetInputNumberSlider Value Binding', () => {
const createMockWidget = (
value: number = 5,
options: Partial<SliderProps & { precision?: number }> = {},
@@ -27,7 +27,7 @@ describe('WidgetSlider Value Binding', () => {
modelValue: number,
readonly = false
) => {
return mount(WidgetSlider, {
return mount(WidgetInputNumberSlider, {
global: {
plugins: [PrimeVue],
components: { InputText, Slider }

View File

@@ -16,8 +16,6 @@
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 !border-none !shadow-none !bg-transparent"
size="small"

View File

@@ -18,7 +18,7 @@
:disabled="readonly"
class="w-full text-xs"
size="small"
rows="6"
:rows="6"
:pt="{
root: {
onBlur: handleBlur

View File

@@ -2,9 +2,9 @@
<WidgetLayoutField :widget="widget">
<MultiSelect
v-model="localValue"
v-bind="filteredProps"
v-bind="combinedProps"
:disabled="readonly"
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
class="w-full text-xs"
size="small"
display="chip"
:pt="{
@@ -20,14 +20,13 @@ import MultiSelect from 'primevue/multiselect'
import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
@@ -48,24 +47,17 @@ const { localValue, onChange } = useWidgetValue({
emit
})
// Transform compatibility props for overlay positioning
const transformCompatProps = useTransformCompatOverlayProps()
// MultiSelect specific excluded props include overlay styles
const MULTISELECT_EXCLUDED_PROPS = [
...PANEL_EXCLUDED_PROPS,
'overlayStyle'
] as const
const filteredProps = computed(() => {
const filtered = filterWidgetProps(
props.widget.options,
MULTISELECT_EXCLUDED_PROPS
)
// Ensure options array is available for MultiSelect
const values = props.widget.options?.values
if (values && Array.isArray(values)) {
filtered.options = values
}
return filtered
})
const combinedProps = computed(() => ({
...filterWidgetProps(props.widget.options, MULTISELECT_EXCLUDED_PROPS),
...transformCompatProps.value
}))
</script>

View File

@@ -3,9 +3,9 @@
<Select
v-model="localValue"
:options="selectOptions"
v-bind="filteredProps"
v-bind="combinedProps"
:disabled="readonly"
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
class="w-full text-xs bg-[#F9F8F4] dark-theme:bg-[#0E0E12] border-[#E1DED5] dark-theme:border-[#15161C] !rounded-lg"
size="small"
:pt="{
option: 'text-xs'
@@ -20,14 +20,13 @@ import Select from 'primevue/select'
import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
@@ -48,9 +47,13 @@ const { localValue, onChange } = useWidgetValue({
emit
})
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS)
)
// Transform compatibility props for overlay positioning
const transformCompatProps = useTransformCompatOverlayProps()
const combinedProps = computed(() => ({
...filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS),
...transformCompatProps.value
}))
// Extract select options from widget options
const selectOptions = computed(() => {

View File

@@ -2,9 +2,9 @@
<WidgetLayoutField :widget="widget">
<TreeSelect
v-model="localValue"
v-bind="filteredProps"
v-bind="combinedProps"
:disabled="readonly"
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
class="w-full text-xs"
size="small"
@update:model-value="onChange"
/>
@@ -16,14 +16,13 @@ import TreeSelect from 'primevue/treeselect'
import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
@@ -44,6 +43,9 @@ const { localValue, onChange } = useWidgetValue({
emit
})
// Transform compatibility props for overlay positioning
const transformCompatProps = useTransformCompatOverlayProps()
// TreeSelect specific excluded props
const TREE_SELECT_EXCLUDED_PROPS = [
...PANEL_EXCLUDED_PROPS,
@@ -51,7 +53,8 @@ const TREE_SELECT_EXCLUDED_PROPS = [
'inputStyle'
] as const
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, TREE_SELECT_EXCLUDED_PROPS)
)
const combinedProps = computed(() => ({
...filterWidgetProps(props.widget.options, TREE_SELECT_EXCLUDED_PROPS),
...transformCompatProps.value
}))
</script>

View File

@@ -1,8 +1,12 @@
import { ref } from 'vue'
import MultiSelectWidget from '@/components/graph/widgets/MultiSelectWidget.vue'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
import type {
IBaseWidget,
IComboWidget
} from '@/lib/litegraph/src/types/widgets'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import {
ComboInputSpec,
@@ -18,6 +22,8 @@ import {
type ComfyWidgetConstructorV2,
addValueControlWidgets
} from '@/scripts/widgets'
import { assetService } from '@/services/assetService'
import { useSettingStore } from '@/stores/settingStore'
import { useRemoteWidget } from './useRemoteWidget'
@@ -28,7 +34,10 @@ const getDefaultValue = (inputSpec: ComboInputSpec) => {
return undefined
}
const addMultiSelectWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
const addMultiSelectWidget = (
node: LGraphNode,
inputSpec: ComboInputSpec
): IBaseWidget => {
const widgetValue = ref<string[]>([])
const widget = new ComponentWidgetImpl({
node,
@@ -48,7 +57,32 @@ const addMultiSelectWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
return widget
}
const addComboWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
const addComboWidget = (
node: LGraphNode,
inputSpec: ComboInputSpec
): IBaseWidget => {
const settingStore = useSettingStore()
const isUsingAssetAPI = settingStore.get('Comfy.Assets.UseAssetAPI')
const isEligible = assetService.isAssetBrowserEligible(
inputSpec.name,
node.comfyClass || ''
)
if (isUsingAssetAPI && isEligible) {
// Get the default value for the button text (currently selected model)
const currentValue = getDefaultValue(inputSpec)
const displayLabel = currentValue ?? t('widgets.selectModel')
const widget = node.addWidget('asset', inputSpec.name, displayLabel, () => {
console.log(
`Asset Browser would open here for:\nNode: ${node.type}\nWidget: ${inputSpec.name}\nCurrent Value:${currentValue}`
)
})
return widget
}
// Create normal combo widget
const defaultValue = getDefaultValue(inputSpec)
const comboOptions = inputSpec.options ?? []
const widget = node.addWidget(
@@ -59,14 +93,14 @@ const addComboWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
{
values: comboOptions
}
) as IComboWidget
)
if (inputSpec.remote) {
const remoteWidget = useRemoteWidget({
remoteConfig: inputSpec.remote,
defaultValue,
node,
widget
widget: widget as IComboWidget
})
if (inputSpec.remote.refresh_button) remoteWidget.addRefreshButton()
@@ -84,14 +118,14 @@ const addComboWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
if (inputSpec.control_after_generate) {
widget.linkedWidgets = addValueControlWidgets(
node,
widget,
widget as IComboWidget,
undefined,
undefined,
transformInputSpecV2ToV1(inputSpec)
)
}
return widget
return widget as IBaseWidget
}
export const useComboWidget = () => {

View File

@@ -9,12 +9,12 @@ import WidgetColorPicker from '../components/WidgetColorPicker.vue'
import WidgetFileUpload from '../components/WidgetFileUpload.vue'
import WidgetGalleria from '../components/WidgetGalleria.vue'
import WidgetImageCompare from '../components/WidgetImageCompare.vue'
import WidgetInputNumber from '../components/WidgetInputNumber.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'
@@ -38,11 +38,11 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
essential: false
}
],
['int', { component: WidgetSlider, aliases: ['INT'], essential: true }],
['int', { component: WidgetInputNumber, aliases: ['INT'], essential: true }],
[
'float',
{
component: WidgetSlider,
component: WidgetInputNumber,
aliases: ['FLOAT', 'number', 'slider'],
essential: true
}