mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-08 08:08:04 +00:00
refactor: render node preview from plain data, not the widget store
Extract the presentational grid from NodeWidgets into WidgetGrid, driven by a plain ProcessedWidget[] prop. NodeWidgets keeps the store-backed useProcessedWidgets path (and its interactivity, forwarded to the grid via attribute fallthrough); LGraphNodePreview builds ProcessedWidget[] straight from the node schema via a new pure usePreviewWidgets composable. The preview no longer fabricates widgetValueStore entries: gone are the synthetic graph-id counter, the register widget/render-state/spec writes, the manual state patching, and the onUnmounted cleanup. A static preview has no live graph, so it now touches no store-backed widget state at all.
This commit is contained in:
@@ -3,9 +3,8 @@ import { render, screen } from '@testing-library/vue'
|
||||
import { computed } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ProcessedWidget } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
|
||||
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
|
||||
@@ -13,12 +12,15 @@ vi.mock('@/stores/widgetStore', () => ({
|
||||
useWidgetStore: () => ({ inputIsWidget: () => true })
|
||||
}))
|
||||
|
||||
const NodeWidgetsProbe = {
|
||||
props: ['widgetIds'],
|
||||
setup(props: { widgetIds?: readonly WidgetId[] }) {
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
const WidgetGridProbe = {
|
||||
props: ['processedWidgets'],
|
||||
setup(props: { processedWidgets?: ProcessedWidget[] }) {
|
||||
const widgets = computed(() =>
|
||||
(props.widgetIds ?? []).map((id) => widgetValueStore.getWidget(id))
|
||||
(props.processedWidgets ?? []).map((widget) => ({
|
||||
name: widget.name,
|
||||
value: widget.simplified.value,
|
||||
options: { values: widget.simplified.options?.values }
|
||||
}))
|
||||
)
|
||||
return { widgets }
|
||||
},
|
||||
@@ -52,7 +54,7 @@ function renderedWidgets(
|
||||
stubs: {
|
||||
NodeHeader: true,
|
||||
NodeSlots: true,
|
||||
NodeWidgets: NodeWidgetsProbe
|
||||
WidgetGrid: WidgetGridProbe
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -19,10 +19,12 @@
|
||||
>
|
||||
<NodeSlots :node-data="nodeData" />
|
||||
|
||||
<NodeWidgets
|
||||
v-if="previewWidgetIds.length"
|
||||
:node-data="nodeData"
|
||||
:widget-ids="previewWidgetIds"
|
||||
<WidgetGrid
|
||||
v-if="processedWidgets.length"
|
||||
:processed-widgets="processedWidgets"
|
||||
:grid-template-rows="gridTemplateRows"
|
||||
:node-type="nodeData.type"
|
||||
:node-id="nodeData.id"
|
||||
class="pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
@@ -30,7 +32,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, watchEffect } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import type {
|
||||
@@ -38,24 +40,15 @@ import type {
|
||||
INodeOutputSlot
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import { RenderShape } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
|
||||
import NodeHeader from '@/renderer/extensions/vueNodes/components/NodeHeader.vue'
|
||||
import NodeSlots from '@/renderer/extensions/vueNodes/components/NodeSlots.vue'
|
||||
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
|
||||
import WidgetGrid from '@/renderer/extensions/vueNodes/components/WidgetGrid.vue'
|
||||
import { usePreviewWidgets } from '@/renderer/extensions/vueNodes/composables/usePreviewWidgets'
|
||||
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { useWidgetStore } from '@/stores/widgetStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
let previewInstanceCounter = 0
|
||||
|
||||
function nextPreviewGraphId() {
|
||||
previewInstanceCounter += 1
|
||||
return `preview-${previewInstanceCounter}`
|
||||
}
|
||||
const {
|
||||
nodeDef,
|
||||
position = 'absolute',
|
||||
@@ -67,12 +60,10 @@ const {
|
||||
}>()
|
||||
|
||||
const widgetStore = useWidgetStore()
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
const previewGraphId = nextPreviewGraphId()
|
||||
|
||||
const nodeData = computed<VueNodeData>(() => {
|
||||
const inputs: INodeInputSlot[] = Object.entries(nodeDef.inputs || {})
|
||||
.filter(([_, input]) => !widgetStore.inputIsWidget(input))
|
||||
.filter(([, input]) => !widgetStore.inputIsWidget(input))
|
||||
.map(([name, input]) => ({
|
||||
name,
|
||||
type: input.type,
|
||||
@@ -112,53 +103,8 @@ const nodeData = computed<VueNodeData>(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const previewWidgets = computed(() =>
|
||||
Object.entries(nodeDef.inputs || {})
|
||||
.filter(([_, input]) => widgetStore.inputIsWidget(input))
|
||||
.map(([name, input]) => {
|
||||
const comboValues =
|
||||
input.type === 'COMBO' && Array.isArray(input.options)
|
||||
? input.options
|
||||
: undefined
|
||||
const leadValue = widgetValues?.[name]
|
||||
const value =
|
||||
leadValue ??
|
||||
(input.default !== undefined ? input.default : (comboValues?.[0] ?? ''))
|
||||
const options = {
|
||||
hidden: input.hidden,
|
||||
advanced: input.advanced,
|
||||
values:
|
||||
leadValue && comboValues
|
||||
? [leadValue, ...comboValues.filter((o) => o !== leadValue)]
|
||||
: comboValues
|
||||
} satisfies IWidgetOptions
|
||||
return {
|
||||
id: widgetId(previewGraphId, nodeData.value.id, name),
|
||||
input,
|
||||
type: input.widgetType || input.type,
|
||||
value,
|
||||
options
|
||||
}
|
||||
})
|
||||
const { processedWidgets, gridTemplateRows } = usePreviewWidgets(
|
||||
() => nodeDef,
|
||||
() => widgetValues
|
||||
)
|
||||
|
||||
const previewWidgetIds = computed<WidgetId[]>(() =>
|
||||
previewWidgets.value.map((widget) => widget.id)
|
||||
)
|
||||
|
||||
watchEffect(() => {
|
||||
for (const { id, input, type, value, options } of previewWidgets.value) {
|
||||
const state = widgetValueStore.registerWidget(id, { type, value, options })
|
||||
state.value = value
|
||||
state.options = options
|
||||
widgetValueStore.registerWidgetRenderState(id, { advanced: input.advanced })
|
||||
widgetValueStore.registerWidgetSpec(id, input)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
for (const id of previewWidgetIds.value) {
|
||||
widgetValueStore.deleteWidget(id)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -2,82 +2,23 @@
|
||||
<div v-if="renderError" class="node-error p-2 text-sm text-red-500">
|
||||
{{ st('nodeErrors.widgets', 'Node Widgets Error') }}
|
||||
</div>
|
||||
<div
|
||||
<WidgetGrid
|
||||
v-else
|
||||
data-testid="node-widgets"
|
||||
:processed-widgets="processedWidgets"
|
||||
:grid-template-rows="gridTemplateRows"
|
||||
:node-type="nodeType"
|
||||
:can-select-inputs="canSelectInputs"
|
||||
:node-id="nodeData?.id"
|
||||
:class="
|
||||
cn(
|
||||
'lg-node-widgets grid grid-cols-[min-content_minmax(80px,min-content)_minmax(125px,1fr)] gap-y-1',
|
||||
shouldHandleNodePointerEvents
|
||||
? 'pointer-events-auto'
|
||||
: 'pointer-events-none'
|
||||
)
|
||||
shouldHandleNodePointerEvents
|
||||
? 'pointer-events-auto'
|
||||
: 'pointer-events-none'
|
||||
"
|
||||
:style="{
|
||||
'grid-template-rows': gridTemplateRows,
|
||||
flex: gridTemplateRows.includes('auto') ? 1 : undefined
|
||||
}"
|
||||
@pointerdown.capture="handleBringToFront"
|
||||
@pointerdown="handleWidgetPointerEvent"
|
||||
@pointermove="handleWidgetPointerEvent"
|
||||
@pointerup="handleWidgetPointerEvent"
|
||||
>
|
||||
<template v-for="widget in processedWidgets" :key="widget.renderKey">
|
||||
<div
|
||||
v-if="widget.visible"
|
||||
data-testid="node-widget"
|
||||
class="lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch pr-3"
|
||||
>
|
||||
<!-- Widget Input Slot Dot -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'z-10 flex w-3 items-stretch opacity-0 transition-opacity duration-150 group-hover:opacity-100',
|
||||
widget.slotMetadata?.linked && 'opacity-100'
|
||||
)
|
||||
"
|
||||
>
|
||||
<InputSlot
|
||||
v-if="widget.slotMetadata"
|
||||
:key="`widget-slot-${widget.name}-${widget.slotMetadata.index}`"
|
||||
:slot-data="{
|
||||
name: widget.name,
|
||||
type: widget.slotMetadata.type,
|
||||
boundingRect: [0, 0, 0, 0]
|
||||
}"
|
||||
:node-id="nodeData?.id"
|
||||
:has-error="widget.hasError"
|
||||
:index="widget.slotMetadata.index"
|
||||
:socketless="widget.simplified.spec?.socketless"
|
||||
dot-only
|
||||
/>
|
||||
</div>
|
||||
<!-- Widget Component -->
|
||||
<AppInput
|
||||
:widget-id="widget.widgetId"
|
||||
:name="widget.name"
|
||||
:enable="canSelectInputs && !widget.simplified.options?.disabled"
|
||||
>
|
||||
<component
|
||||
:is="widget.vueComponent"
|
||||
v-model="widget.value"
|
||||
v-tooltip.left="widget.tooltipConfig"
|
||||
:widget="widget.simplified"
|
||||
:node-id="nodeData?.id"
|
||||
:node-type="nodeType"
|
||||
:class="
|
||||
cn(
|
||||
'col-span-2',
|
||||
widget.hasError && 'font-bold text-node-stroke-error'
|
||||
)
|
||||
"
|
||||
@update:model-value="widget.updateHandler"
|
||||
@contextmenu="widget.handleContextMenu"
|
||||
/>
|
||||
</AppInput>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -88,13 +29,10 @@ import type { WidgetId } from '@/types/widgetId'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { st } from '@/i18n'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import AppInput from '@/renderer/extensions/linearMode/AppInput.vue'
|
||||
import WidgetGrid from '@/renderer/extensions/vueNodes/components/WidgetGrid.vue'
|
||||
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
|
||||
import { useProcessedWidgets } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
|
||||
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import InputSlot from './InputSlot.vue'
|
||||
|
||||
interface NodeWidgetsProps {
|
||||
nodeData?: VueNodeData
|
||||
|
||||
89
src/renderer/extensions/vueNodes/components/WidgetGrid.vue
Normal file
89
src/renderer/extensions/vueNodes/components/WidgetGrid.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div
|
||||
data-testid="node-widgets"
|
||||
class="lg-node-widgets grid grid-cols-[min-content_minmax(80px,min-content)_minmax(125px,1fr)] gap-y-1 pr-3"
|
||||
:style="{
|
||||
'grid-template-rows': gridTemplateRows,
|
||||
flex: gridTemplateRows.includes('auto') ? 1 : undefined
|
||||
}"
|
||||
>
|
||||
<template v-for="widget in processedWidgets" :key="widget.renderKey">
|
||||
<div
|
||||
v-if="widget.visible"
|
||||
data-testid="node-widget"
|
||||
class="lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch"
|
||||
>
|
||||
<!-- Widget Input Slot Dot -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'z-10 flex w-3 items-stretch opacity-0 transition-opacity duration-150 group-hover:opacity-100',
|
||||
widget.slotMetadata?.linked && 'opacity-100'
|
||||
)
|
||||
"
|
||||
>
|
||||
<InputSlot
|
||||
v-if="widget.slotMetadata"
|
||||
:key="`widget-slot-${widget.name}-${widget.slotMetadata.index}`"
|
||||
:slot-data="{
|
||||
name: widget.name,
|
||||
type: widget.slotMetadata.type,
|
||||
boundingRect: [0, 0, 0, 0]
|
||||
}"
|
||||
:node-id="nodeId"
|
||||
:has-error="widget.hasError"
|
||||
:index="widget.slotMetadata.index"
|
||||
:socketless="widget.simplified.spec?.socketless"
|
||||
dot-only
|
||||
/>
|
||||
</div>
|
||||
<!-- Widget Component -->
|
||||
<AppInput
|
||||
:widget-id="widget.widgetId"
|
||||
:name="widget.name"
|
||||
:enable="canSelectInputs && !widget.simplified.options?.disabled"
|
||||
>
|
||||
<component
|
||||
:is="widget.vueComponent"
|
||||
v-model="widget.value"
|
||||
v-tooltip.left="widget.tooltipConfig"
|
||||
:widget="widget.simplified"
|
||||
:node-id="nodeId"
|
||||
:node-type="nodeType"
|
||||
:class="
|
||||
cn(
|
||||
'col-span-2',
|
||||
widget.hasError && 'font-bold text-node-stroke-error'
|
||||
)
|
||||
"
|
||||
@update:model-value="widget.updateHandler"
|
||||
@contextmenu="widget.handleContextMenu"
|
||||
/>
|
||||
</AppInput>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ProcessedWidget } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
|
||||
import AppInput from '@/renderer/extensions/linearMode/AppInput.vue'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import InputSlot from './InputSlot.vue'
|
||||
|
||||
const {
|
||||
processedWidgets,
|
||||
gridTemplateRows,
|
||||
nodeType,
|
||||
canSelectInputs = false,
|
||||
nodeId
|
||||
} = defineProps<{
|
||||
processedWidgets: ProcessedWidget[]
|
||||
gridTemplateRows: string
|
||||
nodeType: string
|
||||
canSelectInputs?: boolean
|
||||
nodeId?: NodeId
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { TooltipOptions } from 'primevue'
|
||||
import { computed } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { ProcessedWidget } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
|
||||
import { isWidgetVisible } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
|
||||
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
|
||||
import {
|
||||
getComponent,
|
||||
shouldExpand
|
||||
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
||||
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { useWidgetStore } from '@/stores/widgetStore'
|
||||
import type { WidgetValue } from '@/types/simplifiedWidget'
|
||||
|
||||
const EMPTY_TOOLTIP: TooltipOptions = {}
|
||||
const noop = () => {}
|
||||
|
||||
/**
|
||||
* Builds render-ready widgets for a static node preview straight from its
|
||||
* schema, without registering anything in the widget stores. Previews have no
|
||||
* live graph, so nothing here needs (or should touch) store-backed state.
|
||||
*/
|
||||
export function usePreviewWidgets(
|
||||
nodeDefGetter: () => ComfyNodeDefV2,
|
||||
widgetValuesGetter: () => Record<string, string> | undefined
|
||||
) {
|
||||
const widgetStore = useWidgetStore()
|
||||
const settingStore = useSettingStore()
|
||||
const showAdvanced = computed(() =>
|
||||
Boolean(settingStore.get('Comfy.Node.AlwaysShowAdvancedWidgets'))
|
||||
)
|
||||
|
||||
const processedWidgets = computed<ProcessedWidget[]>(() => {
|
||||
const nodeDef = nodeDefGetter()
|
||||
const widgetValues = widgetValuesGetter()
|
||||
return Object.entries(nodeDef.inputs || {})
|
||||
.filter(([, input]) => widgetStore.inputIsWidget(input))
|
||||
.map(([name, input]): ProcessedWidget => {
|
||||
const comboValues =
|
||||
input.type === 'COMBO' && Array.isArray(input.options)
|
||||
? input.options
|
||||
: undefined
|
||||
const leadValue = widgetValues?.[name]
|
||||
const value = (leadValue ??
|
||||
(input.default !== undefined
|
||||
? input.default
|
||||
: (comboValues?.[0] ?? ''))) as WidgetValue
|
||||
const options = {
|
||||
hidden: input.hidden,
|
||||
advanced: input.advanced,
|
||||
values:
|
||||
leadValue && comboValues
|
||||
? [
|
||||
leadValue,
|
||||
...comboValues.filter((option) => option !== leadValue)
|
||||
]
|
||||
: comboValues
|
||||
} satisfies IWidgetOptions
|
||||
const type = input.widgetType || input.type
|
||||
const vueComponent: Component = getComponent(type) ?? WidgetLegacy
|
||||
return {
|
||||
advanced: Boolean(input.advanced),
|
||||
handleContextMenu: noop,
|
||||
hasLayoutSize: false,
|
||||
hasError: false,
|
||||
hidden: Boolean(input.hidden),
|
||||
name,
|
||||
renderKey: `preview:${nodeDef.name}:${name}`,
|
||||
simplified: { name, type, value, options, spec: input },
|
||||
tooltipConfig: EMPTY_TOOLTIP,
|
||||
type,
|
||||
updateHandler: noop,
|
||||
value,
|
||||
visible: isWidgetVisible(options, showAdvanced.value),
|
||||
vueComponent,
|
||||
slotMetadata: undefined
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const gridTemplateRows = computed(() =>
|
||||
processedWidgets.value
|
||||
.filter((widget) => widget.visible)
|
||||
.map((widget) => (shouldExpand(widget.type) ? 'auto' : 'min-content'))
|
||||
.join(' ')
|
||||
)
|
||||
|
||||
return { processedWidgets, gridTemplateRows }
|
||||
}
|
||||
@@ -60,7 +60,7 @@ function isTooltipValueType(val: unknown): val is TooltipValueType {
|
||||
return TOOLTIP_VALUE_TYPES.includes(val as TooltipValueType)
|
||||
}
|
||||
|
||||
interface WidgetSlotMetadata {
|
||||
export interface WidgetSlotMetadata {
|
||||
index: number
|
||||
linked: boolean
|
||||
originNodeId?: NodeId
|
||||
@@ -78,7 +78,7 @@ interface WidgetErrorTarget {
|
||||
widgetName: string
|
||||
}
|
||||
|
||||
interface ProcessedWidget {
|
||||
export interface ProcessedWidget {
|
||||
advanced: boolean
|
||||
handleContextMenu: (e: PointerEvent) => void
|
||||
hasLayoutSize: boolean
|
||||
|
||||
Reference in New Issue
Block a user