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:
DrJKL
2026-07-01 22:39:43 -07:00
parent 4ddf08240d
commit b49eac4753
6 changed files with 217 additions and 150 deletions

View File

@@ -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
}
}
})

View File

@@ -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>

View File

@@ -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

View 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>

View File

@@ -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 }
}

View File

@@ -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