Compare commits

...

24 Commits

Author SHA1 Message Date
DrJKL
8c97532afc fix: drop unused WidgetSlotMetadata export
Only ProcessedWidget needs to be public; it references WidgetSlotMetadata
internally, so exporting the latter tripped knip.
2026-07-01 22:42:29 -07:00
DrJKL
9eb5f0a212 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.
2026-07-01 22:39:43 -07:00
DrJKL
783a7cefbc refactor: move preview widget store writes out of computed
LGraphNodePreview derived previewWidgetIds in a computed whose body wrote
to widgetValueStore (register widget/render-state/spec), so a "pure"
derivation was the write path. Split it into a pure previewWidgets
computed plus a watchEffect that performs the store registration, and drop
the redundant state.type reassignment. Behaviour is unchanged; the writes
now live where side effects belong.
2026-07-01 22:07:26 -07:00
DrJKL
079b620555 fix: use uuid subgraph ids in nested promoted model asset
The nested promoted-missing-model test asset used readable slug ids for
its subgraph definitions. createNodeLocatorId only accepts UUID subgraph
ids, so getLiveWidget could not resolve the interior nodes, and the stale
promoted combobox never rendered disabled. Real subgraphs always get uuid
ids; the asset now matches, which is the actual fix for the interior
disabled-state assertion.

Revert the useProcessedWidgets disabled-derivation change: with valid
asset ids the original logic is correct, so the refactor was unnecessary.
2026-07-01 21:45:13 -07:00
DrJKL
624963c37a fix: derive vue widget disabled state from live input link
Query the live node's widget input slot (getSlotFromWidget) as the single
source of truth for whether a widget is linked/disabled, matching
LGraphNode.updateComputedDisabled. This replaces the buildSlotMetadata
snapshot derivation, which missed promoted interior subgraph widgets whose
input carries a link to the subgraph input node, leaving them enabled.

Also drop the runtime import('/src/...') setup from the legacy app-mode
spec (only resolvable against the dev server, not the CI dist build) in
favor of the native devtools widget plus enterAppModeWithInputs.
2026-07-01 21:01:16 -07:00
DrJKL
0c759912e3 test: finish pinia setup for vue node unit tests 2026-07-01 19:57:17 -07:00
DrJKL
f210590bdf test: set up pinia for graph replacement tests 2026-07-01 19:37:36 -07:00
Alexander Brown
bc918fef11 Merge branch 'main' into drjkl/safe-widget-cleanup 2026-07-01 16:21:40 -07:00
DrJKL
dfdf78f0d3 fix: resolve vue widget rendering regressions 2026-07-01 16:19:14 -07:00
DrJKL
7cbdb94b3f fix: separate widget spec state from rendering 2026-07-01 11:59:20 -07:00
Mobeen Abdullah
9cf5c9a93f refactor(website): tidy customer story review nits (#13324)
## Summary

Small follow-up to #13289 applying two non-blocking review nits from
Alex's review.

## Changes

- **What**: drop the redundant `before:content-['']` on the
customer-story list bullet (Tailwind emits the empty `content`
automatically once another `before:` utility is present), and rename
`HEADER_OFFSET` to `HEADER_OFFSET_PX` in `ArticleNav` so the scroll
constants use consistent unit suffixes.

## Review Focus

Both changes are cosmetic with no behavior change. Confirmed in the
browser that the list bullet still renders identically (6px yellow dot)
without the explicit `content` utility.

## Notes from the #13289 review (left as-is here, open to discussion)

Three other comments from the review are intentionally not changed in
this PR; reasoning below so the decisions are on record:

- **`Category` type in `ArticleNav`**: kept the `ComponentProps<typeof
CategoryNav>` derivation. AGENTS.md says to derive component types via
`vue-component-type-helpers` rather than redefining them, so the current
form follows the styleguide. Happy to switch to a plain named type if
preferred.
- **Section ids in frontmatter vs the body `<Section>`**: kept the
`customers.content.test.ts` parity test. The short TOC labels live only
in frontmatter and Astro can't introspect the rendered MDX body to build
the nav, so the frontmatter `sections` list and the body anchor ids
can't be trivially deduplicated. A real fix would need a remark plugin
(larger, separate change). The test guards against silent drift in the
meantime.
- **`nextStory` throw**: left as a fail-loud, build-time invariant. The
slug always comes from the same `getStaticPaths` collection, so the
throw is effectively unreachable; it surfaces a future-refactor bug
loudly instead of linking to the wrong story.
2026-07-01 12:45:24 +00:00
Alexander Brown
7907985db8 Merge branch 'main' into drjkl/safe-widget-cleanup 2026-07-01 01:08:42 -07:00
DrJKL
24258bf1a8 fix: preserve promoted widget render order 2026-07-01 00:52:07 -07:00
jaeone94
9e5fb67b76 Show app mode run validation warning (#12557)
## Summary
Adds an app mode validation warning so users can see when a workflow has
errors before running and jump directly back to graph mode to review
them.

## Changes
- **What**: Adds a reusable app mode warning banner above the Run button
when the execution error store reports workflow errors, including
validation and missing asset states.
- **What**: Reuses the existing graph-error navigation flow so the
warning action switches out of app mode and opens the Errors panel in
graph mode.
- **What**: Updates the app mode Run button icon and accessible label in
the warning state while keeping the Run action non-blocking.
- **What**: Adds unit coverage for the warning render/accessibility
state and an E2E flow that triggers a validation failure, dismisses the
overlay, and opens graph errors from the app mode warning.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus
The warning intentionally mirrors graph mode behavior: it surfaces the
error state but does not prevent the user from clicking Run. This avoids
turning display-level validation signals into hard execution blockers.

The warning is driven by the existing `hasAnyError` aggregate, so
missing nodes, missing models, and missing media are included alongside
prompt/node/execution errors.

## Tests
- `pnpm format`
- `pnpm lint`
- `pnpm typecheck`
- `pnpm test:unit`
- `pnpm knip`
- `pnpm test:browser:local
browser_tests/tests/appModeValidationWarning.spec.ts`

## Screenshots

<img width="461" height="994" alt="스크린샷 2026-06-25 오후 7 00 55"
src="https://github.com/user-attachments/assets/f8fc20bf-d572-46b5-9fa4-312e7c4c8076"
/>
2026-07-01 15:24:45 +09:00
DrJKL
61b87a467d fix: unify widget update handler paths 2026-06-30 20:49:22 -07:00
DrJKL
6be63bd50c fix: sync widget order through store 2026-06-30 20:13:26 -07:00
GitHub Action
4f9077dd98 [automated] Apply ESLint and Oxfmt fixes 2026-07-01 00:47:58 +00:00
DrJKL
61658f604b refactor: render widgets from widget ids 2026-06-30 17:42:14 -07:00
DrJKL
54e688f912 fix: restore Load3D widget rendering 2026-06-30 14:41:40 -07:00
DrJKL
638f0332b4 fix: stop renderer resolving promoted widgets 2026-06-30 13:17:20 -07:00
DrJKL
26a53d7d2c cleanup: minor cleanups 2026-06-30 11:58:38 -07:00
DrJKL
b45320ab3d knip unused exports 2026-06-30 11:55:49 -07:00
DrJKL
34c11a07db refactor: store widget render state separately 2026-06-30 11:48:50 -07:00
DrJKL
c64b8678ec refactor: store widget render state separately 2026-06-30 11:48:41 -07:00
61 changed files with 2666 additions and 1719 deletions

View File

@@ -15,7 +15,7 @@ const { categories } = defineProps<{
const activeSection = ref(categories[0]?.value ?? '')
const HEADER_OFFSET = -144
const HEADER_OFFSET_PX = -144
const BOTTOM_THRESHOLD_PX = 4
const SCROLL_SAFETY_MS = 1500
@@ -52,7 +52,7 @@ function scrollToSection(id: string) {
const el = document.getElementById(id)
if (el) {
scrollTo(el, {
offset: HEADER_OFFSET,
offset: HEADER_OFFSET_PX,
duration: 0.8,
immediate: prefersReducedMotion(),
onComplete: clearScrollLock

View File

@@ -1,5 +1,5 @@
<li
class="flex items-start gap-2 text-primary-comfy-canvas before:mt-1.5 before:size-1.5 before:shrink-0 before:rounded-full before:bg-primary-comfy-yellow before:content-['']"
class="flex items-start gap-2 text-primary-comfy-canvas before:mt-1.5 before:size-1.5 before:shrink-0 before:rounded-full before:bg-primary-comfy-yellow"
>
<slot />
</li>

View File

@@ -0,0 +1,45 @@
{
"last_node_id": 9,
"last_link_id": 9,
"nodes": [
{
"id": 9,
"type": "SaveImage",
"pos": {
"0": 64,
"1": 104
},
"size": {
"0": 210,
"1": 58
},
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": null
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
},
"linearData": {
"inputs": [],
"outputs": ["9"]
}
},
"version": 0.4
}

View File

@@ -6,7 +6,7 @@
"nodes": [
{
"id": 3,
"type": "outer-subgraph-with-promoted-missing-model",
"type": "4e7c1a2b-3d5f-4a6b-8c9d-0e1f2a3b4c5d",
"pos": [10, 250],
"size": [400, 200],
"flags": {},
@@ -20,7 +20,7 @@
},
{
"id": 4,
"type": "outer-subgraph-with-promoted-missing-model",
"type": "4e7c1a2b-3d5f-4a6b-8c9d-0e1f2a3b4c5d",
"pos": [450, 250],
"size": [400, 200],
"flags": {},
@@ -38,7 +38,7 @@
"definitions": {
"subgraphs": [
{
"id": "outer-subgraph-with-promoted-missing-model",
"id": "4e7c1a2b-3d5f-4a6b-8c9d-0e1f2a3b4c5d",
"version": 1,
"state": {
"lastGroupId": 0,
@@ -71,7 +71,7 @@
"nodes": [
{
"id": 2,
"type": "inner-subgraph-with-promoted-missing-model",
"type": "5f8d2b3c-4e6a-4b7c-9d0e-1f2a3b4c5d6e",
"pos": [250, 180],
"size": [400, 200],
"flags": {},
@@ -105,7 +105,7 @@
]
},
{
"id": "inner-subgraph-with-promoted-missing-model",
"id": "5f8d2b3c-4e6a-4b7c-9d0e-1f2a3b4c5d6e",
"version": 1,
"state": {
"lastGroupId": 0,

View File

@@ -34,6 +34,10 @@ export class AppModeHelper {
public readonly outputPlaceholder: Locator
/** The linear-mode widget list container (visible in app mode). */
public readonly linearWidgets: Locator
/** The validation warning shown above the app mode run button. */
public readonly validationWarning: Locator
/** The action that opens graph mode errors from the validation warning. */
public readonly viewErrorsInGraphButton: Locator
/** The PrimeVue Popover for the image picker (renders with role="dialog"). */
public readonly imagePickerPopover: Locator
/** The Run button in the app mode footer. */
@@ -92,13 +96,19 @@ export class AppModeHelper {
this.outputPlaceholder = this.page.getByTestId(
TestIds.builder.outputPlaceholder
)
this.linearWidgets = this.page.getByTestId('linear-widgets')
this.linearWidgets = this.page.getByTestId(TestIds.linear.widgetContainer)
this.validationWarning = this.page.getByTestId(
TestIds.linear.validationWarning
)
this.viewErrorsInGraphButton = this.validationWarning.getByTestId(
TestIds.linear.viewErrorsInGraph
)
this.imagePickerPopover = this.page
.getByRole('dialog')
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
.first()
this.runButton = this.page
.getByTestId('linear-run-button')
.getByTestId(TestIds.linear.runButton)
.getByRole('button', { name: /run/i })
this.welcome = this.page.getByTestId(TestIds.appMode.welcome)
this.emptyWorkflowText = this.page.getByTestId(

View File

@@ -172,6 +172,9 @@ export const TestIds = {
mobileNavigation: 'linear-mobile-navigation',
mobileWorkflows: 'linear-mobile-workflows',
outputInfo: 'linear-output-info',
runButton: 'linear-run-button',
validationWarning: 'linear-validation-warning',
viewErrorsInGraph: 'linear-view-errors',
widgetContainer: 'linear-widgets'
},
builder: {

View File

@@ -0,0 +1,106 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { NodeError, PromptResponse } from '@/schemas/apiSchema'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { enableErrorsOverlay } from '@e2e/fixtures/helpers/ErrorsTabHelper'
import { TestIds } from '@e2e/fixtures/selectors'
const SAVE_IMAGE_NODE_ID = '9'
function buildSaveImageRequiredInputError(): NodeError {
return {
class_type: 'SaveImage',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing: images',
details: '',
extra_info: { input_name: 'images' }
}
]
}
}
test.describe(
'App mode validation warning',
{ tag: ['@ui', '@workflow'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await enableErrorsOverlay(comfyPage)
await comfyPage.workflow.loadWorkflow('linear-validation-warning')
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
})
test('opens graph errors from the app mode validation warning', async ({
comfyPage
}) => {
await expect(comfyPage.appMode.validationWarning).toBeHidden()
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
})
await comfyPage.appMode.runButton.click()
const appModeOverlay = comfyPage.appMode.centerPanel.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(appModeOverlay).toBeHidden()
await expect(comfyPage.appMode.validationWarning).toBeVisible()
await expect(comfyPage.appMode.validationWarning).toContainText(
/Required input missing/i
)
await expect(comfyPage.appMode.viewErrorsInGraphButton).toBeVisible()
await comfyPage.appMode.viewErrorsInGraphButton.click()
await expect(comfyPage.appMode.linearWidgets).toBeHidden()
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.root)
).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
).toBeVisible()
})
test('keeps the app mode run button enabled when the warning is visible', async ({
comfyPage
}) => {
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
})
await comfyPage.appMode.runButton.click()
await expect(comfyPage.appMode.validationWarning).toBeVisible()
await expect(comfyPage.appMode.runButton).toBeEnabled()
let promptQueued = false
const mockResponse: PromptResponse = {
prompt_id: 'test-id',
node_errors: {},
error: ''
}
await comfyPage.page.route(
'**/api/prompt',
async (route) => {
promptQueued = true
await route.fulfill({
status: 200,
body: JSON.stringify(mockResponse)
})
},
{ times: 1 }
)
await comfyPage.appMode.runButton.click()
await expect.poll(() => promptQueued).toBe(true)
})
}
)

View File

@@ -1,5 +1,6 @@
import { expect } from '@playwright/test'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
@@ -13,11 +14,12 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
// Ref: https://github.com/Comfy-Org/ComfyUI_frontend/issues/3348
test('Fix link input slots', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('inputs/input_order_swap')
const linkId = toLinkId(1)
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
return window.app!.graph!.links.get(1)?.target_slot
})
comfyPage.page.evaluate((linkId) => {
return window.app!.graph!.links.get(linkId)?.target_slot
}, linkId)
)
.toBe(1)
})

View File

@@ -3,6 +3,7 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
test.describe('Linear Mode', { tag: '@ui' }, () => {
test('Displays linear controls when app mode active', async ({
@@ -16,7 +17,9 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
test('Run button visible in linear mode', async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(comfyPage.page.getByTestId('linear-run-button')).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.linear.runButton)
).toBeVisible()
})
test('Workflow info section visible', async ({ comfyPage }) => {

View File

@@ -8,25 +8,32 @@ test('@vue-nodes In App Mode, widget width updates with panel size', async ({
comfyPage,
comfyMouse
}) => {
let legacyNodeId = toNodeId(10)
await test.step('setup', async () => {
await comfyPage.nodeOps.addNode('DevToolsNodeWithLegacyWidget', undefined, {
x: 0,
y: 0
})
await comfyPage.appMode.enterAppModeWithInputs([['10', 'legacy_widget']])
const legacyNode = await comfyPage.nodeOps.addNode(
'DevToolsNodeWithLegacyWidget',
undefined,
{
x: 0,
y: 0
}
)
legacyNodeId = legacyNode.id
await comfyPage.appMode.enterAppModeWithInputs([
[String(legacyNodeId), 'legacy_widget']
])
})
const getWidth = () =>
comfyPage.page.evaluate(
(nodeId) => graph!.getNodeById(nodeId)!.widgets![0].width ?? 0,
toNodeId(10)
)
const getWidth = async () =>
(await comfyPage.appMode.linearWidgets.locator('canvas').boundingBox())
?.width ?? 0
await test.step('Mouse clicks resolve to button regions', async () => {
const legacyWidget = comfyPage.appMode.linearWidgets.locator('canvas')
const { width, height } = (await legacyWidget.boundingBox())!
const nodeRef = await comfyPage.nodeOps.getNodeRefById(10)
const nodeRef = await comfyPage.nodeOps.getNodeRefById(legacyNodeId)
const legacyWidgetRef = await nodeRef.getWidget(0)
expect(await legacyWidgetRef.getValue()).toBe(0)
await legacyWidget.click({ position: { x: 20, y: height / 2 } })
@@ -36,8 +43,8 @@ test('@vue-nodes In App Mode, widget width updates with panel size', async ({
})
await test.step('Resize to update width', async () => {
await expect.poll(getWidth).toBeGreaterThan(0)
const initialWidth = await getWidth()
expect(initialWidth).toBeGreaterThan(0)
const gutter = comfyPage.page.getByRole('separator')

View File

@@ -3,31 +3,43 @@ import {
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { TestGraphAccess } from '@e2e/types/globals'
import { toNodeId } from '@/types/nodeId'
test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
test('Should display added widgets', async ({ comfyPage }) => {
const loadCheckpointNode = comfyPage.page.locator(
'css=[data-testid="node-body-4"] > .lg-node-widgets > div'
const nodeId = toNodeId(
await comfyPage.page.evaluate(() => {
const node = window.app!.graph.nodes.find(
(node) => (node.widgets?.length ?? 0) === 1
)
if (!node) throw new Error('Node with one widget not found')
return String(node.id)
})
)
await expect(loadCheckpointNode).toHaveCount(1)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
const widgets = comfyPage.vueNodes
.getNodeLocator(nodeId)
.locator('.lg-node-widget')
await expect(widgets).toHaveCount(1)
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(nodeId)
if (!node) throw new Error(`Node ${nodeId} not found`)
node.addWidget('text', 'extra_widget_a', '', () => {})
})
await expect(loadCheckpointNode).toHaveCount(2)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
}, nodeId)
await expect(widgets).toHaveCount(2)
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(nodeId)
if (!node) throw new Error(`Node ${nodeId} not found`)
node.addWidget('text', 'extra_widget_b', '', () => {})
})
await expect(loadCheckpointNode).toHaveCount(3)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
}, nodeId)
await expect(widgets).toHaveCount(3)
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(nodeId)
if (!node) throw new Error(`Node ${nodeId} not found`)
node.addWidget('text', 'extra_widget_c', '', () => {})
})
await expect(loadCheckpointNode).toHaveCount(4)
}, nodeId)
await expect(widgets).toHaveCount(4)
})
test('Should hide removed widgets', async ({ comfyPage }) => {

View File

@@ -11,6 +11,7 @@ import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { WidgetId } from '@/types/widgetId'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { extractWidgetStringValue } from '@/composables/maskeditor/useMaskEditorLoader'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
@@ -19,6 +20,7 @@ import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.v
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { parseImageWidgetValue } from '@/utils/imageUtil'
import { cn } from '@comfyorg/tailwind-utils'
@@ -29,9 +31,8 @@ import { promptRenameWidget } from '@/utils/widgetUtil'
interface WidgetEntry {
key: string
persistedHeight: number | undefined
nodeData: ReturnType<typeof nodeToNodeData> & {
widgets: NonNullable<ReturnType<typeof nodeToNodeData>['widgets']>
}
nodeData: ReturnType<typeof nodeToNodeData>
widgetIds: readonly WidgetId[]
action: { widget: IBaseWidget; node: LGraphNode }
}
@@ -43,6 +44,7 @@ const { mobile = false, builderMode = false } = defineProps<{
const { t } = useI18n()
const executionErrorStore = useExecutionErrorStore()
const appModeStore = useAppModeStore()
const widgetValueStore = useWidgetValueStore()
const maskEditor = useMaskEditor()
const { onPointerDown } = useAppModeWidgetResizing((widget, config) =>
@@ -53,49 +55,72 @@ provide(HideLayoutFieldKey, true)
const resolvedInputs = useResolvedSelectedInputs()
const mappedSelections = computed((): WidgetEntry[] => {
const nodeDataByNode = new Map<
LGraphNode,
ReturnType<typeof nodeToNodeData>
>()
function isDOMBackedWidget(widget: IBaseWidget): boolean {
if ('isDOMWidget' in widget && typeof widget.isDOMWidget === 'boolean') {
return widget.isDOMWidget
}
return (
('element' in widget && !!widget.element) ||
('component' in widget && !!widget.component)
)
}
function ensureSelectedWidgetState(
widgetId: WidgetId,
widget: IBaseWidget
): void {
if (widgetValueStore.getWidget(widgetId)) return
widgetValueStore.registerWidget(widgetId, {
type: widget.type,
value: widget.value,
options: widget.options,
label: widget.label,
serialize: widget.serialize,
disabled: widget.disabled
})
widgetValueStore.registerWidgetRenderState(widgetId, {
advanced: widget.options?.advanced ?? widget.advanced,
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
isDOMWidget: isDOMBackedWidget(widget),
tooltip: widget.tooltip
})
}
const mappedSelections = computed((): WidgetEntry[] => {
return resolvedInputs.value.flatMap((entry) => {
if (entry.status !== 'resolved') return []
const { widgetId, node, widget, config } = entry
if (node.mode !== LGraphEventMode.ALWAYS) return []
if (!nodeDataByNode.has(node)) {
nodeDataByNode.set(node, nodeToNodeData(node))
ensureSelectedWidgetState(widgetId, widget)
const fullNodeData = nodeToNodeData(node, widgetId)
if (
node.inputs?.some(
(input) => input.widget?.name === widget.name && input.link != null
)
) {
return []
}
const fullNodeData = nodeDataByNode.get(node)!
const matchingWidget = fullNodeData.widgets?.find((vueWidget) => {
if (vueWidget.slotMetadata?.linked) return false
return vueWidget.widgetId === widgetId
})
if (!matchingWidget) return []
matchingWidget.slotMetadata = undefined
matchingWidget.nodeId = node.id
return [
{
key: widgetId,
persistedHeight: config?.height,
nodeData: {
...fullNodeData,
widgets: [matchingWidget]
},
nodeData: fullNodeData,
widgetIds: [widgetId],
action: { widget, node }
}
]
})
})
function getDropIndicator(node: LGraphNode) {
function getDropIndicator(node: LGraphNode, id: WidgetId) {
if (node.type !== 'LoadImage') return undefined
const stringValue = extractWidgetStringValue(node.widgets?.[0]?.value)
const stringValue = extractWidgetStringValue(
widgetValueStore.getWidget(id)?.value
)
const { filename, subfolder, type } = stringValue
? parseImageWidgetValue(stringValue)
@@ -119,8 +144,8 @@ function getDropIndicator(node: LGraphNode) {
}
}
function nodeToNodeData(node: LGraphNode) {
const dropIndicator = getDropIndicator(node)
function nodeToNodeData(node: LGraphNode, id: WidgetId) {
const dropIndicator = getDropIndicator(node, id)
const nodeData = extractVueNodeData(node)
return {
@@ -147,7 +172,13 @@ defineExpose({ handleDragDrop })
</script>
<template>
<div
v-for="{ key, persistedHeight, nodeData, action } in mappedSelections"
v-for="{
key,
persistedHeight,
nodeData,
widgetIds,
action
} in mappedSelections"
:key
:class="
cn(
@@ -234,6 +265,7 @@ defineExpose({ handleDragDrop })
>
<NodeWidgets
:node-data
:widget-ids="widgetIds"
:class="
cn(
'gap-y-3 rounded-lg py-1 [&_textarea]:resize-y **:[.col-span-2]:grid-cols-1 not-md:**:[.h-7]:h-10',

View File

@@ -37,7 +37,7 @@
size="unset"
class="min-h-8 rounded-lg px-3 py-2 text-xs font-normal"
data-testid="error-overlay-see-errors"
@click="seeErrors"
@click="viewErrorsInGraph"
>
{{
appMode
@@ -67,31 +67,18 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
import { useViewErrorsInGraph } from '@/composables/useViewErrorsInGraph'
const { appMode = false } = defineProps<{ appMode?: boolean }>()
const { t } = useI18n()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
const canvasStore = useCanvasStore()
const { viewErrorsInGraph } = useViewErrorsInGraph()
const { isVisible, overlayMessage, overlayTitle } = useErrorOverlayState()
function dismiss() {
executionErrorStore.dismissErrorOverlay()
}
function seeErrors() {
canvasStore.linearMode = false
if (canvasStore.canvas) {
canvasStore.canvas.deselectAll()
canvasStore.updateSelectedItems()
}
rightSidePanelStore.openPanel('errors')
executionErrorStore.dismissErrorOverlay()
}
</script>

View File

@@ -13,8 +13,8 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
import { isWidgetPromotedOnSubgraphNode } from '@/core/graph/subgraph/promotionUtils'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import { isWidgetPromotedOnSubgraphNode } from '@/core/graph/subgraph/promotionUtils'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -255,7 +255,10 @@ function clearWidgetErrors(
source.sourceWidgetName,
source.sourceWidgetName,
value,
options
{
min: source.sourceWidget.options?.min,
max: source.sourceWidget.options?.max
}
)
}

View File

@@ -2,14 +2,17 @@ import { createTestingPinia } from '@pinia/testing'
import { render } from '@testing-library/vue'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
import WidgetItem from './WidgetItem.vue'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
const { mockGetInputSpecForWidget, StubWidgetComponent } = vi.hoisted(() => ({
@@ -204,5 +207,60 @@ describe('WidgetItem', () => {
expect(stub.value).toBe('model_a.safetensors')
})
it('passes null from widget state to the widget component', () => {
const id = widgetId('test-graph-id', toNodeId(1), 'ckpt_name')
const widget = createMockWidget({ widgetId: id, value: 'source value' })
useWidgetValueStore().registerWidget(id, {
type: 'combo',
value: null,
options: {}
})
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)
expect(stub.value).toBe('null')
})
it('updates disabled options when the widget input is linked', async () => {
const graphEvents = new EventTarget()
const node = createMockNode(
fromAny<Partial<LGraphNode>, unknown>({
graph: {
rootGraph: { id: 'test-graph-id' },
events: graphEvents
},
inputs: [
{
name: 'seed',
type: 'INT',
widget: { name: 'seed' },
link: null,
boundingRect: [0, 0, 0, 0]
}
]
})
)
const widget = createMockWidget({ name: 'seed', options: {} })
const { container } = renderWidgetItem(widget, node)
expect(getStubWidget(container).options.disabled).toBeUndefined()
node.inputs![0].link = toLinkId(1)
graphEvents.dispatchEvent(
new CustomEvent('node:slot-links:changed', {
detail: {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: true,
linkId: 1
}
})
)
await nextTick()
expect(getStubWidget(container).options.disabled).toBe(true)
})
})
})

View File

@@ -1,13 +1,13 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { computed, customRef, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import { getControlWidget } from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
@@ -21,7 +21,11 @@ import {
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { getControlWidget } from '@/types/simplifiedWidget'
import type {
SimplifiedWidget,
WidgetValue as SimplifiedWidgetValue
} from '@/types/simplifiedWidget'
import { widgetId } from '@/types/widgetId'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@comfyorg/tailwind-utils'
@@ -68,13 +72,28 @@ const widgetComponent = computed(() => {
return component || WidgetLegacy
})
const linkRevision = ref(0)
useEventListener(
() => node.graph?.events,
'node:slot-links:changed',
(event) => {
const detail = (
event as CustomEvent<{ nodeId: unknown; slotType: NodeSlotType }>
).detail
if (
String(detail.nodeId) === String(node.id) &&
detail.slotType === NodeSlotType.INPUT
) {
linkRevision.value++
}
}
)
const isLinked = computed(() => {
const safeWidget = useVueNodeLifecycle()
.nodeManager.value?.vueNodeData.get(node.id)
?.widgets?.find((w) => w.name === widget.name)
return safeWidget?.slotMetadata
? !!safeWidget.slotMetadata.linked
: !!node.inputs?.find((inp) => inp.widget?.name === widget.name)?.link
void linkRevision.value
return !!node.inputs?.some(
(input) => input.widget?.name === widget.name && input.link != null
)
})
const simplifiedWidget = computed((): SimplifiedWidget => {
@@ -93,7 +112,9 @@ const simplifiedWidget = computed((): SimplifiedWidget => {
return {
name: widgetName,
type: widgetType,
value: widgetState?.value ?? widget.value,
value: (widgetState
? widgetState.value
: widget.value) as SimplifiedWidgetValue,
label: widgetState?.label ?? widget.label,
options: { ...baseOptions, disabled },
spec: nodeDefStore.getInputSpecForWidget(node, widgetName),

View File

@@ -87,7 +87,7 @@ describe('Node Reactivity', () => {
})
})
describe('Widget slotMetadata reactivity on link disconnect', () => {
describe('Widget input link reactivity', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
@@ -96,10 +96,8 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
// Add a widget and an associated input slot (simulates "widget converted to input")
node.addWidget('string', 'prompt', 'hello', () => undefined, {})
const input = node.addInput('prompt', 'STRING')
// Associate the input slot with the widget (as widgetInputs extension does)
input.widget = { name: 'prompt' }
graph.add(node)
@@ -112,31 +110,26 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
return { graph, node, upstream, linkId: link.id }
}
it('sets slotMetadata.linked to true when input has a link', () => {
it('exposes linked widget input slots through Vue node inputs', () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData?.slotMetadata).toBeDefined()
expect(widgetData?.slotMetadata?.linked).toBe(true)
expect(nodeData?.inputs?.[0]?.widget?.name).toBe('prompt')
expect(nodeData?.inputs?.[0]?.link).not.toBeNull()
})
it('updates slotMetadata.linked to false after link disconnect event', async () => {
it('updates input link state after link disconnect event', async () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
// Verify initially linked
expect(widgetData?.slotMetadata?.linked).toBe(true)
expect(nodeData?.inputs?.[0]?.link).not.toBeNull()
// Simulate link disconnection (as LiteGraph does before firing the event)
node.inputs[0].link = null
// Fire the trigger event that LiteGraph fires on disconnect
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
@@ -147,32 +140,19 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
await nextTick()
// slotMetadata.linked should now be false
expect(widgetData?.slotMetadata?.linked).toBe(false)
expect(nodeData?.inputs?.[0]?.link).toBeNull()
})
it('reactively updates disabled state in a derived computed after disconnect', async () => {
it('keeps widget input link state current after disconnect', async () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)!
// Mimic what processedWidgets does in NodeWidgets.vue:
// derive disabled from slotMetadata.linked
const derivedDisabled = computed(() => {
const widgets = nodeData.widgets ?? []
const widget = widgets.find((w) => w.name === 'prompt')
return widget?.slotMetadata?.linked ? true : false
})
expect(
nodeData.inputs?.find((slot) => slot.widget?.name === 'prompt')?.link
).not.toBeNull()
// Initially linked → disabled
expect(derivedDisabled.value).toBe(true)
// Track changes
const onChange = vi.fn()
watch(derivedDisabled, onChange)
// Simulate disconnect
node.inputs[0].link = null
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
@@ -184,9 +164,9 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
await nextTick()
// The derived computed should now return false
expect(derivedDisabled.value).toBe(false)
expect(onChange).toHaveBeenCalledTimes(1)
expect(
nodeData.inputs?.find((slot) => slot.widget?.name === 'prompt')?.link
).toBeNull()
})
it('marks a widget input slot as linked when connected to a SubgraphInput', () => {
@@ -205,15 +185,11 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const { vueNodeData } = useGraphNodeManager(subgraph)
const nodeData = vueNodeData.get(node.id)
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData?.slotMetadata?.linked).toBe(true)
expect(nodeData?.inputs?.[0]?.link).not.toBeNull()
})
it('names promoted widgets after the subgraph input slot and exposes the interior source name', () => {
// Subgraph input named "value" promotes an interior "prompt" widget. The
// projected widget's name is the input slot name "value"; the interior
// source widget name "prompt" is carried separately for backend lookups.
it('registers promoted widget render state separately from value state', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'STRING' }]
})
@@ -229,23 +205,34 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(subgraphNode.id)
useGraphNodeManager(graph)
const widgetData = nodeData?.widgets?.find((w) => w.name === 'value')
expect(widgetData).toBeDefined()
expect(widgetData?.sourceWidgetName).toBe('prompt')
expect(widgetData?.slotMetadata).toBeDefined()
const id = widgetId(graph.id, subgraphNode.id, 'value')
const store = useWidgetValueStore()
const valueState = store.getWidget(id)
const renderState = store.getWidgetRenderState(id)
expect(valueState?.name).toBe('value')
expect(valueState?.value).toBe('hello')
expect(renderState).toMatchObject({
hasLayoutSize: false,
isDOMWidget: false
})
expect(renderState).not.toHaveProperty('sourceWidgetName')
expect(subgraphNode.inputs[0].widget?.name).toBe('value')
})
it('clears stale slotMetadata when input no longer matches widget', async () => {
it('reflects input/widget renames after link refresh', async () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)!
const widgetData = nodeData.widgets!.find((w) => w.name === 'prompt')!
expect(widgetData.slotMetadata?.linked).toBe(true)
expect(
nodeData.inputs?.some(
(slot) => slot.name === 'prompt' && slot.widget?.name === 'prompt'
)
).toBe(true)
node.inputs[0].name = 'other'
node.inputs[0].widget = { name: 'other' }
@@ -261,7 +248,11 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
await nextTick()
expect(widgetData.slotMetadata).toBeUndefined()
expect(
nodeData.inputs?.some(
(slot) => slot.name === 'prompt' && slot.widget?.name === 'prompt'
)
).toBe(false)
})
})
@@ -368,15 +359,13 @@ describe('Nested promoted widget mapping', () => {
const graph = subgraphNodeB.graph as LGraph
graph.add(subgraphNodeB)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(subgraphNodeB.id)
const mappedWidget = nodeData?.widgets?.[0]
useGraphNodeManager(graph)
expect(mappedWidget).toBeDefined()
expect(mappedWidget?.type).toBe('combo')
expect(mappedWidget?.widgetId).toBe(
widgetId(graph.id, subgraphNodeB.id, 'b_input')
)
const id = widgetId(graph.id, subgraphNodeB.id, 'b_input')
const state = useWidgetValueStore().getWidget(id)
expect(state?.type).toBe('combo')
expect(subgraphNodeB.widgets[0]?.widgetId).toBe(id)
})
it('preserves distinct store identity for duplicate-named promoted widgets', () => {
@@ -405,27 +394,23 @@ describe('Nested promoted widget mapping', () => {
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(subgraphNode.id)
const widgets = nodeData?.widgets
useGraphNodeManager(graph)
expect(widgets).toHaveLength(2)
expect(widgets?.[0]?.widgetId).toBe(
widgetId(graph.id, subgraphNode.id, 'first_seed')
)
expect(widgets?.[1]?.widgetId).toBe(
const ids = subgraphNode.widgets.map((widget) => widget.widgetId)
expect(ids).toStrictEqual([
widgetId(graph.id, subgraphNode.id, 'first_seed'),
widgetId(graph.id, subgraphNode.id, 'second_seed')
)
expect(widgets?.[0]?.widgetId).not.toBe(widgets?.[1]?.widgetId)
])
expect(ids[0]).not.toBe(ids[1])
})
})
describe('Promoted widget sourceExecutionId', () => {
describe('Promoted widget render state', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('sets sourceExecutionId to the interior node execution ID for promoted widgets', () => {
it('registers plain render metadata for promoted widgets', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'ckpt_input', type: '*' }]
})
@@ -451,22 +436,21 @@ describe('Promoted widget sourceExecutionId', () => {
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(subgraphNode.id)
const promotedWidget = nodeData?.widgets?.find(
(w) => w.name === 'ckpt_input'
useGraphNodeManager(graph)
const renderState = useWidgetValueStore().getWidgetRenderState(
widgetId(graph.id, subgraphNode.id, 'ckpt_input')
)
expect(promotedWidget).toBeDefined()
expect(promotedWidget?.sourceWidgetName).toBe('ckpt_name')
// The interior node is inside subgraphNode (id=65),
// so its execution ID should be "65:<interiorNodeId>"
expect(promotedWidget?.sourceExecutionId).toBe(
`${subgraphNode.id}:${interiorNode.id}`
)
expect(renderState).toMatchObject({
hasLayoutSize: false,
isDOMWidget: false
})
expect(renderState).not.toHaveProperty('sourceWidgetName')
expect(renderState).not.toHaveProperty('sourceExecutionId')
})
it('does not set sourceExecutionId for non-promoted widgets', () => {
it('registers plain render metadata for non-promoted widgets', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addWidget('number', 'steps', 20, () => undefined, {})
@@ -474,12 +458,14 @@ describe('Promoted widget sourceExecutionId', () => {
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)
const widget = nodeData?.widgets?.find((w) => w.name === 'steps')
useGraphNodeManager(graph)
expect(widget).toBeDefined()
expect(widget?.sourceExecutionId).toBeUndefined()
const renderState = useWidgetValueStore().getWidgetRenderState(
widgetId(graph.id, node.id, 'steps')
)
expect(renderState).toBeDefined()
expect(renderState).not.toHaveProperty('sourceExecutionId')
})
})

View File

@@ -1,14 +1,6 @@
/**
* Vue node lifecycle management for LiteGraph integration
* Provides event-driven reactivity with performance optimizations
*/
import { reactiveComputed } from '@vueuse/core'
import cloneDeep from 'es-toolkit/compat/cloneDeep'
import { reactive, shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { promotedInputWidgets } from '@/core/graph/subgraph/promotedInputWidget'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import type {
INodeInputSlot,
INodeOutputSlot
@@ -19,16 +11,6 @@ import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import { toNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isDOMWidget } from '@/scripts/domWidget'
import { IS_CONTROL_WIDGET } from '@/scripts/widgets'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
import { normalizeControlOption } from '@/types/simplifiedWidget'
import { getWidgetIdForNode } from '@/utils/litegraphUtil'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import type { WidgetId } from '@/types/widgetId'
import type {
LGraph,
@@ -36,70 +18,13 @@ import type {
LGraphNode,
LGraphTriggerAction,
LGraphTriggerEvent,
LGraphTriggerParam,
SubgraphNode
LGraphTriggerParam
} from '@/lib/litegraph/src/litegraph'
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { app } from '@/scripts/app'
export interface WidgetSlotMetadata {
index: number
linked: boolean
originNodeId?: NodeId
originOutputName?: string
type: string
}
type Badges = (LGraphBadge | (() => LGraphBadge))[]
/**
* Minimal render-specific widget data extracted from LiteGraph widgets.
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
*/
export interface SafeWidgetData {
widgetId?: WidgetId
nodeId?: NodeId
name: string
type: string
/** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */
callback?: ((value: unknown) => void) | undefined
/** Control widget for seed randomization/increment/decrement */
controlWidget?: SafeControlWidget
/** Whether widget has custom layout size computation */
hasLayoutSize?: boolean
/** Whether widget is a DOM widget */
isDOMWidget?: boolean
/**
* Widget options needed for render decisions.
* Note: Most metadata should be accessed via widgetValueStore.getWidget().
*/
options?: {
canvasOnly?: boolean
advanced?: boolean
hidden?: boolean
read_only?: boolean
values?: unknown
}
/** Input specification from node definition */
spec?: InputSpec
/** Input slot metadata (index and link status) */
slotMetadata?: WidgetSlotMetadata
/**
* Execution ID of the interior node that owns the source widget.
* Only set for promoted widgets where the source node differs from the host
* subgraph node. Retained for source-scoped validation errors.
*/
sourceExecutionId?: NodeExecutionId
/**
* Interior source widget name. Only set for promoted widgets, where `name` is
* the host input slot name and the source widget name can differ.
*/
sourceWidgetName?: string
/** Tooltip text from the resolved widget. */
tooltip?: string
}
export interface VueNodeData {
executing: boolean
id: NodeId
@@ -124,260 +49,24 @@ export interface VueNodeData {
showAdvanced?: boolean
subgraphId?: string | null
titleMode?: TitleMode
widgets?: SafeWidgetData[]
}
export interface GraphNodeManager {
// Reactive state - safe data extracted from LiteGraph nodes
vueNodeData: ReadonlyMap<NodeId, VueNodeData>
// Access to original LiteGraph nodes (non-reactive)
getNode(id: NodeId): LGraphNode | undefined
// Lifecycle methods
cleanup(): void
}
export function getControlWidget(
widget: IBaseWidget
): SafeControlWidget | undefined {
const cagWidget = widget.linkedWidgets?.find((w) => w[IS_CONTROL_WIDGET])
if (!cagWidget) return
return {
value: normalizeControlOption(cagWidget.value),
update: (value) => (cagWidget.value = normalizeControlOption(value))
}
}
interface SharedWidgetEnhancements {
controlWidget?: SafeControlWidget
spec?: InputSpec
}
function getSharedWidgetEnhancements(
node: LGraphNode,
widget: IBaseWidget
): SharedWidgetEnhancements {
const nodeDefStore = useNodeDefStore()
return {
controlWidget: getControlWidget(widget),
spec: nodeDefStore.getInputSpecForWidget(node, widget.name)
}
}
/**
* Validates that a value is a valid WidgetValue type
*/
function normalizeWidgetValue(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.length > 0 &&
value.every((item): item is File => item instanceof File)
) {
return value
}
// Otherwise it's a generic object
return value
}
// If none of the above, return undefined
console.warn(`Invalid widget value type: ${typeof value}`, value)
return undefined
}
function extractWidgetDisplayOptions(
widget: IBaseWidget
): SafeWidgetData['options'] {
if (!widget.options) return undefined
return {
canvasOnly: widget.options.canvasOnly,
advanced: widget.options?.advanced ?? widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only
}
}
function isDOMBackedWidget(widget: IBaseWidget): boolean {
return (
('element' in widget && !!widget.element) ||
('component' in widget && !!widget.component)
)
}
interface PromotedWidgetMetadata {
controlWidget?: SafeControlWidget
isDOMWidget: boolean
sourceExecutionId?: NodeExecutionId
sourceWidgetName?: string
}
/**
* Resolves the interior source of a promoted subgraph input to derive the
* metadata that backend lookups key by (execution ID, interior widget name)
* plus the source widget's control + DOM nature. Also seeds host widget state
* if it is somehow missing. Returns undefined when the widget is not promoted.
*/
function resolvePromotedMetadata(
node: SubgraphNode,
widget: IBaseWidget
): PromotedWidgetMetadata | undefined {
const source = resolvePromotedWidgetSource(app.rootGraph, node, widget)
if (!source) return undefined
ensurePromotedHostWidgetState(
source.input.widgetId,
source.input,
source.sourceWidget
)
return {
controlWidget: getControlWidget(source.sourceWidget),
isDOMWidget: isDOMBackedWidget(source.sourceWidget),
sourceExecutionId: source.sourceExecutionId,
sourceWidgetName: source.sourceWidgetName
}
}
function safeWidgetMapper(
node: LGraphNode,
slotMetadata: Map<string, WidgetSlotMetadata>
): (widget: IBaseWidget) => SafeWidgetData {
const duplicateIndexByKey = new Map<string, number>()
return function (widget) {
try {
const duplicateKey = `${widget.name}:${widget.type}`
const duplicateIndex = duplicateIndexByKey.get(duplicateKey) ?? 0
duplicateIndexByKey.set(duplicateKey, duplicateIndex + 1)
const slotInfo = slotMetadata.get(widget.name)
// Wrapper callback specific to Nodes 2.0 rendering
const callback = (v: unknown) => {
const value = normalizeWidgetValue(v)
widget.value = value ?? undefined
// Match litegraph callback signature: (value, canvas, node, pos, event)
// Some extensions (e.g., Impact Pack) expect node as the 3rd parameter
widget.callback?.(value, app.canvas, node)
// Trigger redraw for all legacy widgets on this node (e.g., mask preview)
// This ensures widgets that depend on other widget values get updated
node.widgets?.forEach((w) => w.triggerDraw?.())
}
const promoted = node.isSubgraphNode()
? resolvePromotedMetadata(node, widget)
: undefined
return {
widgetId: getWidgetIdForNode(node, widget, duplicateIndex),
name: widget.name,
type: widget.type,
...getSharedWidgetEnhancements(node, widget),
...(promoted?.controlWidget && {
controlWidget: promoted.controlWidget
}),
callback,
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
isDOMWidget: promoted?.isDOMWidget ?? isDOMWidget(widget),
options: extractWidgetDisplayOptions(widget),
slotMetadata: slotInfo,
sourceExecutionId: promoted?.sourceExecutionId,
sourceWidgetName: promoted?.sourceWidgetName,
tooltip: widget.tooltip
}
} catch (error) {
console.warn(
'[safeWidgetMapper] Failed to map widget:',
widget.name,
error
)
return {
name: widget.name || 'unknown',
type: widget.type || 'text'
}
}
}
}
function ensurePromotedHostWidgetState(
id: WidgetId,
input: INodeInputSlot,
sourceWidget: IBaseWidget | undefined
): void {
if (!sourceWidget) return
const store = useWidgetValueStore()
if (store.getWidget(id)) return
store.registerWidget(id, {
type: sourceWidget.type,
value: sourceWidget.value,
options: cloneDeep(sourceWidget.options ?? {}),
label: input.label ?? input.name,
serialize: sourceWidget.serialize,
disabled: sourceWidget.disabled
})
}
function buildSlotMetadata(
inputs: INodeInputSlot[] | undefined,
graphRef: LGraph | null | undefined
): Map<string, WidgetSlotMetadata> {
const metadata = new Map<string, WidgetSlotMetadata>()
inputs?.forEach((input, index) => {
let originNodeId: NodeId | undefined
let originOutputName: string | undefined
if (input.link != null && graphRef) {
const link = graphRef.getLink(input.link)
const originNode = link ? graphRef.getNodeById(link.origin_id) : null
if (link && originNode) {
originNodeId = link.origin_id
originOutputName = originNode.outputs?.[link.origin_slot]?.name
}
}
const slotInfo: WidgetSlotMetadata = {
index,
linked: input.link != null,
originNodeId,
originOutputName,
type: String(input.type)
}
if (input.name) metadata.set(input.name, slotInfo)
if (input.widget?.name) metadata.set(input.widget.name, slotInfo)
})
return metadata
}
// Extract safe data from LiteGraph node for Vue consumption
export function extractVueNodeData(node: LGraphNode): VueNodeData {
// Determine subgraph ID - null for root graph, string for subgraphs
const subgraphId =
node.graph && 'id' in node.graph && node.graph !== node.graph.rootGraph
? String(node.graph.id)
: null
// Extract safe widget data
const slotMetadata = new Map<string, WidgetSlotMetadata>()
function makeReactiveNodeArrays(node: LGraphNode): {
inputs: INodeInputSlot[]
outputs: INodeOutputSlot[]
} {
const existingWidgetsDescriptor = Object.getOwnPropertyDescriptor(
node,
'widgets'
)
const reactiveWidgets = shallowReactive<IBaseWidget[]>(node.widgets ?? [])
if (existingWidgetsDescriptor?.get) {
// Node has a custom widgets getter (e.g. SubgraphNode's synthetic getter).
// Preserve it but sync results into a reactive array for Vue.
const originalGetter = existingWidgetsDescriptor.get
Object.defineProperty(node, 'widgets', {
get() {
@@ -406,6 +95,7 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
enumerable: true
})
}
const reactiveInputs = shallowReactive<INodeInputSlot[]>(node.inputs ?? [])
Object.defineProperty(node, 'inputs', {
get() {
@@ -417,6 +107,7 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
configurable: true,
enumerable: true
})
const reactiveOutputs = shallowReactive<INodeOutputSlot[]>(node.outputs ?? [])
Object.defineProperty(node, 'outputs', {
get() {
@@ -429,19 +120,16 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
enumerable: true
})
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
const freshMetadata = buildSlotMetadata(node.inputs, node.graph)
slotMetadata.clear()
for (const [key, value] of freshMetadata) {
slotMetadata.set(key, value)
}
return { inputs: reactiveInputs, outputs: reactiveOutputs }
}
const widgets = node.isSubgraphNode()
? promotedInputWidgets(node)
: (node.widgets ?? [])
return widgets.map(safeWidgetMapper(node, slotMetadata))
})
export function extractVueNodeData(node: LGraphNode): VueNodeData {
const subgraphId =
node.graph && 'id' in node.graph && node.graph !== node.graph.rootGraph
? String(node.graph.id)
: null
const { inputs, outputs } = makeReactiveNodeArrays(node)
const nodeType =
node.type ||
node.constructor?.comfyClass ||
@@ -449,9 +137,6 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
node.constructor?.name ||
'Unknown'
const apiNode = node.constructor?.nodeData?.api_node ?? false
const badges = node.badges
return {
id: node.id,
title: typeof node.title === 'string' ? node.title : '',
@@ -459,14 +144,13 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
mode: node.mode || 0,
titleMode: node.title_mode,
selected: node.selected || false,
executing: false, // Will be updated separately based on execution state
executing: false,
subgraphId,
apiNode,
badges,
apiNode: node.constructor?.nodeData?.api_node ?? false,
badges: node.badges,
hasErrors: !!node.has_errors,
widgets: safeWidgets,
inputs: reactiveInputs,
outputs: reactiveOutputs,
inputs,
outputs,
flags: node.flags ? { ...node.flags } : undefined,
color: node.color || undefined,
bgcolor: node.bgcolor || undefined,
@@ -477,39 +161,26 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
}
export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Get layout mutations composable
const { createNode, deleteNode, setSource } = useLayoutMutations()
// Safe reactive data extracted from LiteGraph nodes
const vueNodeData = reactive(new Map<NodeId, VueNodeData>())
// Non-reactive storage for original LiteGraph nodes
const nodeRefs = new Map<NodeId, LGraphNode>()
const refreshNodeSlots = (nodeId: NodeId) => {
const refreshNodeInputs = (nodeId: NodeId) => {
const nodeRef = nodeRefs.get(nodeId)
const currentData = vueNodeData.get(nodeId)
if (!nodeRef?.inputs || !currentData) return
if (!nodeRef || !currentData) return
const slotMetadata = buildSlotMetadata(nodeRef.inputs, graph)
// Update only widgets with new slot metadata, keeping other widget data intact
for (const widget of currentData.widgets ?? []) {
widget.slotMetadata = slotMetadata.get(widget.name)
}
nodeRef.inputs = [...nodeRef.inputs]
vueNodeData.set(nodeId, { ...currentData, inputs: nodeRef.inputs })
}
// Get access to original LiteGraph node (non-reactive)
const getNode = (id: NodeId): LGraphNode | undefined => {
return nodeRefs.get(id)
}
const getNode = (id: NodeId): LGraphNode | undefined => nodeRefs.get(id)
const syncWithGraph = () => {
if (!graph?._nodes) return
const currentNodes = new Set(graph._nodes.map((n) => n.id))
// Remove deleted nodes
for (const id of Array.from(vueNodeData.keys())) {
if (!currentNodes.has(id)) {
nodeRefs.delete(id)
@@ -517,76 +188,49 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}
}
// Add/update existing nodes
graph._nodes.forEach((node) => {
const id = node.id
// Store non-reactive reference
nodeRefs.set(id, node)
// Extract and store safe data for Vue
vueNodeData.set(id, extractVueNodeData(node))
})
}
/**
* Handles node addition to the graph - sets up Vue state and spatial indexing
* Defers position extraction until after potential configure() calls
*/
const handleNodeAdded = (
node: LGraphNode,
originalCallback?: (node: LGraphNode) => void
) => {
const id = node.id
// Store non-reactive reference to original node
nodeRefs.set(id, node)
// Extract initial data for Vue (may be incomplete during graph configure)
vueNodeData.set(id, extractVueNodeData(node))
const initializeVueNodeLayout = () => {
// Check if the node was removed mid-sequence
if (!nodeRefs.has(id)) return
// Extract actual positions after configure() has potentially updated them
const nodePosition = { x: node.pos[0], y: node.pos[1] }
const nodeSize = { width: node.size[0], height: node.size[1] }
// Skip layout creation if it already exists
// (e.g. in-place node replacement where the old node's layout is reused for the new node with the same ID).
const existingLayout = layoutStore.getNodeLayoutRef(id).value
if (existingLayout) return
// Add node to layout store with final positions
setSource(LayoutSource.Canvas)
void createNode(id, {
position: nodePosition,
size: nodeSize,
position: { x: node.pos[0], y: node.pos[1] },
size: { width: node.size[0], height: node.size[1] },
zIndex: node.order || 0,
visible: true
})
}
// Check if we're in the middle of configuring the graph (workflow loading)
if (window.app?.configuringGraph) {
// During workflow loading - defer layout initialization until configure completes
// Chain our callback with any existing onAfterGraphConfigured callback
node.onAfterGraphConfigured = useChainCallback(
node.onAfterGraphConfigured,
() => {
// Re-extract data now that configure() has populated title/slots/widgets/etc.
vueNodeData.set(id, extractVueNodeData(node))
initializeVueNodeLayout()
}
)
} else {
// Not during workflow loading - initialize layout immediately
// This handles individual node additions during normal operation
initializeVueNodeLayout()
}
// Call original callback if provided
if (originalCallback) {
void originalCallback(node)
}
@@ -603,16 +247,13 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
) => {
const id = node.id
// Remove node from layout store
setSource(LayoutSource.Canvas)
void deleteNode(id)
dropNodeReferences(id)
for (const nodeId of nodeRefs.keys()) refreshNodeInputs(nodeId)
originalCallback?.(node)
}
/**
* Creates cleanup function for event listeners and state
*/
const createCleanupFunction = (
originalOnNodeAdded: ((node: LGraphNode) => void) | undefined,
originalOnNodeRemoved: ((node: LGraphNode) => void) | undefined,
@@ -620,7 +261,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
beforeNodeRemovedListener: (e: CustomEvent<{ node: LGraphNode }>) => void
) => {
return () => {
// Restore original callbacks
graph.onNodeAdded = originalOnNodeAdded || undefined
graph.onNodeRemoved = originalOnNodeRemoved || undefined
graph.onTrigger = originalOnTrigger || undefined
@@ -630,19 +270,16 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
beforeNodeRemovedListener
)
// Clear all state maps
nodeRefs.clear()
vueNodeData.clear()
}
}
const setupEventListeners = (): (() => void) => {
// Store original callbacks
const originalOnNodeAdded = graph.onNodeAdded
const originalOnNodeRemoved = graph.onNodeRemoved
const originalOnTrigger = graph.onTrigger
// Set up graph event handlers
graph.onNodeAdded = (node: LGraphNode) => {
handleNodeAdded(node, originalOnNodeAdded)
}
@@ -761,11 +398,11 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}
},
'node:slot-errors:changed': (slotErrorsEvent) => {
refreshNodeSlots(toNodeId(slotErrorsEvent.nodeId))
refreshNodeInputs(toNodeId(slotErrorsEvent.nodeId))
},
'node:slot-links:changed': (slotLinksEvent) => {
if (slotLinksEvent.slotType === NodeSlotType.INPUT) {
refreshNodeSlots(toNodeId(slotLinksEvent.nodeId))
refreshNodeInputs(toNodeId(slotLinksEvent.nodeId))
}
},
'node:slot-label:changed': (slotLabelEvent) => {
@@ -773,16 +410,12 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
const nodeRef = nodeRefs.get(nodeId)
if (!nodeRef) return
// Force shallowReactive to detect the deep property change
// by re-assigning the affected array through the defineProperty setter.
if (slotLabelEvent.slotType !== NodeSlotType.OUTPUT && nodeRef.inputs) {
nodeRef.inputs = [...nodeRef.inputs]
}
if (slotLabelEvent.slotType !== NodeSlotType.INPUT && nodeRef.outputs) {
nodeRef.outputs = [...nodeRef.outputs]
}
// Re-extract widget data so the label reflects the rename
vueNodeData.set(nodeId, extractVueNodeData(nodeRef))
}
}
@@ -802,11 +435,9 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
break
}
// Chain to original handler
originalOnTrigger?.(event)
}
// Initialize state
syncWithGraph()
return createCleanupFunction(
@@ -817,10 +448,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
)
}
// Set up event listeners immediately
const cleanup = setupEventListeners()
// Process any existing nodes after event listeners are set up
if (graph._nodes && graph._nodes.length > 0) {
graph._nodes.forEach((node: LGraphNode) => {
if (graph.onNodeAdded) {

View File

@@ -0,0 +1,105 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { LGraph, LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useViewErrorsInGraph } from './useViewErrorsInGraph'
const apiMock = vi.hoisted(() => ({
getSettings: vi.fn(),
storeSetting: vi.fn(),
storeSettings: vi.fn()
}))
vi.mock('@/scripts/api', () => ({
api: apiMock
}))
const appMock = vi.hoisted(() => ({
ui: {
settings: {
dispatchChange: vi.fn()
}
},
rootGraph: {
events: new EventTarget(),
nodes: []
}
}))
vi.mock('@/scripts/app', () => ({
app: appMock
}))
function createSelectedCanvas() {
const graph = new LGraph()
const canvasElement = document.createElement('canvas')
canvasElement.width = 800
canvasElement.height = 600
canvasElement.getContext = vi
.fn()
.mockReturnValue(createMockCanvasRenderingContext2D())
const canvas = new LGraphCanvas(canvasElement, graph, {
skip_events: true,
skip_render: true
})
const node = new LGraphNode('Selected Node')
graph.add(node)
canvas.selectedItems.add(node)
node.selected = true
return { canvas, node }
}
describe('useViewErrorsInGraph', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
apiMock.getSettings.mockResolvedValue({})
apiMock.storeSetting.mockResolvedValue(undefined)
apiMock.storeSettings.mockResolvedValue(undefined)
})
it('opens graph errors and clears app-mode error UI state', () => {
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
const workflowStore = useWorkflowStore()
const { canvas, node } = createSelectedCanvas()
workflowStore.activeWorkflow = {
activeMode: 'app'
} as typeof workflowStore.activeWorkflow
canvasStore.canvas = canvas
canvasStore.selectedItems = [node]
executionErrorStore.showErrorOverlay()
useViewErrorsInGraph().viewErrorsInGraph()
expect(node.selected).toBe(false)
expect(canvasStore.linearMode).toBe(false)
expect(canvasStore.selectedItems).toEqual([])
expect(rightSidePanelStore.activeTab).toBe('errors')
expect(rightSidePanelStore.isOpen).toBe(true)
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
})
it('opens graph errors when the canvas is not initialized', () => {
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
canvasStore.canvas = null
executionErrorStore.showErrorOverlay()
expect(() => useViewErrorsInGraph().viewErrorsInGraph()).not.toThrow()
expect(rightSidePanelStore.activeTab).toBe('errors')
expect(rightSidePanelStore.isOpen).toBe(true)
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
})
})

View File

@@ -0,0 +1,22 @@
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
export function useViewErrorsInGraph() {
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
function viewErrorsInGraph() {
canvasStore.linearMode = false
if (canvasStore.canvas) {
canvasStore.canvas.deselectAll()
canvasStore.updateSelectedItems()
}
rightSidePanelStore.openPanel('errors')
executionErrorStore.dismissErrorOverlay()
}
return { viewErrorsInGraph }
}

View File

@@ -145,6 +145,13 @@ function applySubgraphInputOrder(
})
reorderSubgraphInputs(subgraphNode, orderedIndices)
useWidgetValueStore().setNodeWidgetOrder(
subgraphNode.rootGraph.id,
subgraphNode.id,
subgraphNode.inputs.flatMap((input) =>
input.widgetId ? [input.widgetId] : []
)
)
for (const [newIndex, oldIndex] of orderedIndices.entries()) {
const value = widgetValues[oldIndex]
@@ -281,21 +288,21 @@ function seedNestedPromotedInputState(
)
if (!hostInput || hostInput.widgetId) return
const sourceState = useWidgetValueStore().getWidget(sourceSlot.widgetId)
const store = useWidgetValueStore()
const sourceState = store.getWidget(sourceSlot.widgetId)
if (!sourceState) return
const id = widgetId(subgraphNode.rootGraph.id, subgraphNode.id, inputName)
hostInput.widget ??= { name: inputName }
hostInput.widget.name = inputName
hostInput.widgetId = id
useWidgetValueStore().registerWidget(id, {
store.registerWidget(id, {
type: sourceState.type,
value: sourceState.value,
options: cloneDeep(sourceState.options ?? {}),
label: hostInput.label ?? sourceSlot.label ?? inputName,
serialize: sourceState.serialize,
disabled: sourceState.disabled,
isDOMWidget: sourceState.isDOMWidget
disabled: sourceState.disabled
})
}

View File

@@ -11,7 +11,10 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LLink } from '@/lib/litegraph/src/LLink'
import { commonType } from '@/lib/litegraph/src/utils/type'
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/utils/widget'
import {
getWidgetIds,
resolveNodeRootGraphId
} from '@/lib/litegraph/src/utils/widget'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -48,6 +51,16 @@ type AutogrowNode = LGraphNode &
}
}
function syncNodeWidgetOrder(node: LGraphNode) {
const graphId = resolveNodeRootGraphId(node)
if (!graphId || !node.widgets) return
useWidgetValueStore().setNodeWidgetOrder(
graphId,
node.id,
getWidgetIds(node.widgets)
)
}
function ensureWidgetForInput(node: LGraphNode, input: INodeInputSlot) {
node.widgets ??= []
const { widget } = input
@@ -105,7 +118,10 @@ function dynamicComboWidget(
if (widget.widgetId) deleteWidget(widget.widgetId)
}
if (!newSpec) return
if (!newSpec) {
syncNodeWidgetOrder(node)
return
}
const insertionPoint = node.widgets.findIndex((w) => w === widget) + 1
const startingLength = node.widgets.length
@@ -140,6 +156,7 @@ function dynamicComboWidget(
node.inputs.findIndex((i) => i.name === widget.name) + 1
const addedWidgets = node.widgets.splice(startingLength)
node.widgets.splice(insertionPoint, 0, ...addedWidgets)
syncNodeWidgetOrder(node)
if (inputInsertionPoint === 0) {
if (
addedWidgets.length === 0 &&
@@ -541,8 +558,11 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
for (const input of toRemove) {
const widgetName = input?.widget?.name
if (!widgetName) continue
for (const widget of remove(node.widgets, (w) => w.name === widgetName))
for (const widget of remove(node.widgets, (w) => w.name === widgetName)) {
widget.onRemove?.()
if (widget.widgetId) useWidgetValueStore().deleteWidget(widget.widgetId)
}
syncNodeWidgetOrder(node)
}
node.size[1] = node.computeSize([...node.size])[1]
}

View File

@@ -1,3 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph } from '@/lib/litegraph/src/litegraph'
@@ -61,6 +63,8 @@ async function createNodeWithFilenamePrefix(
describe('Comfy.SaveImageExtraOutput', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
const graph = new LGraph()
graph.add({
properties: { 'Node name for S&R': 'Sampler' },

View File

@@ -59,6 +59,7 @@ import {
snapPoint
} from './measure'
import { warnDeprecated } from './utils/feedback'
import { getWidgetIds } from './utils/widget'
import { SubgraphInput } from './subgraph/SubgraphInput'
import { SubgraphInputNode } from './subgraph/SubgraphInputNode'
import { SubgraphOutput } from './subgraph/SubgraphOutput'
@@ -1006,9 +1007,15 @@ export class LGraph
// Register all widgets with the WidgetValueStore now that node has a
// valid ID and graph reference.
if (node.widgets) {
const widgetValueStore = useWidgetValueStore()
for (const widget of node.widgets) {
if (isNodeBindable(widget)) widget.setNodeId(node.id)
}
widgetValueStore.setNodeWidgetOrder(
this.rootGraph.id,
node.id,
getWidgetIds(node.widgets)
)
}
this._nodes.push(node)

View File

@@ -93,7 +93,7 @@ describe('drawConnections widget-input slot positioning', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia())
setActivePinia(createTestingPinia({ stubActions: false }))
canvasElement = document.createElement('canvas')
canvasElement.width = 800

View File

@@ -9,6 +9,7 @@ import type { SlotPositionContext } from '@/renderer/core/canvas/litegraph/slotC
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { toLinkId } from '@/types/linkId'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { UNASSIGNED_NODE_ID, toNodeId, serializeNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import { adjustColor } from '@/utils/colorUtil'
@@ -96,6 +97,7 @@ import type {
} from './types/widgets'
import { findFreeSlotOfType } from './utils/collections'
import { warnDeprecated } from './utils/feedback'
import { getWidgetIds } from './utils/widget'
import { distributeSpace } from './utils/spaceDistribution'
import { truncateText } from './utils/textUtils'
import { BaseWidget } from './widgets/BaseWidget'
@@ -2055,6 +2057,17 @@ export class LGraphNode
widget.onRemove?.()
this.widgets.splice(widgetIndex, 1)
const graphId = this.graph?.rootGraph.id
if (graphId) {
const widgetValueStore = useWidgetValueStore()
if (widget.widgetId) widgetValueStore.deleteWidget(widget.widgetId)
widgetValueStore.setNodeWidgetOrder(
graphId,
this.id,
getWidgetIds(this.widgets)
)
}
}
ensureWidgetRemoved(widget: IBaseWidget): void {

View File

@@ -53,6 +53,16 @@ workflowSvg.src =
const workflowBitmapCache = createBitmapCache(workflowSvg, 32)
function isDOMBackedWidget(widget: Readonly<IBaseWidget>): boolean {
if ('isDOMWidget' in widget && typeof widget.isDOMWidget === 'boolean') {
return widget.isDOMWidget
}
return (
('element' in widget && !!widget.element) ||
('component' in widget && !!widget.component)
)
}
export class SubgraphNode extends LGraphNode implements BaseLGraph {
declare inputs: (INodeInputSlot & Partial<ISubgraphInput>)[]
@@ -643,23 +653,25 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
if (inputWidget) Object.setPrototypeOf(input.widget, inputWidget)
const id = widgetId(this.rootGraph.id, this.id, subgraphInput.name)
const store = useWidgetValueStore()
input.widgetId = id
useWidgetValueStore().registerWidget(id, {
store.registerWidget(id, {
type: interiorWidget.type,
value: interiorWidget.value,
options: cloneDeep(interiorWidget.options ?? {}),
label: input.label ?? subgraphInput.name,
serialize: interiorWidget.serialize,
disabled: interiorWidget.disabled,
isDOMWidget:
'isDOMWidget' in interiorWidget &&
typeof interiorWidget.isDOMWidget === 'boolean'
? interiorWidget.isDOMWidget
: undefined
disabled: interiorWidget.disabled
})
input._widget =
this.createPromotedHostWidget(input, id, interiorWidget) ??
this._projectPromotedWidget(input)
store.registerWidgetRenderState(id, {
advanced: interiorWidget.options?.advanced ?? interiorWidget.advanced,
hasLayoutSize: typeof interiorWidget.computeLayoutSize === 'function',
isDOMWidget: isDOMBackedWidget(interiorWidget),
tooltip: interiorWidget.tooltip
})
this._setConcreteSlots()
this.subgraph.events.dispatch('widget-promoted', {

View File

@@ -2,12 +2,40 @@ import { describe, expect, test } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IWidgetOptions } from '@/lib/litegraph/src/litegraph'
import { toNodeId } from '@/types/nodeId'
import { widgetId } from '@/types/widgetId'
import { getNodeWidgetIds } from '@/lib/litegraph/src/utils/widget'
import {
evaluateInput,
getWidgetStep,
resolveNodeRootGraphId
} from '@/lib/litegraph/src/litegraph'
describe('getNodeWidgetIds', () => {
test('includes promoted widget ids stored on input slots', () => {
const seedId = widgetId('graph', toNodeId(10), 'seed')
const textId = widgetId('graph', toNodeId(10), 'text')
expect(
getNodeWidgetIds({
widgets: [{ widgetId: seedId }, {}],
inputs: [{}, { widgetId: textId }]
})
).toStrictEqual([seedId, textId])
})
test('deduplicates widget ids when the same id is on widget and input', () => {
const seedId = widgetId('graph', toNodeId(10), 'seed')
expect(
getNodeWidgetIds({
widgets: [{ widgetId: seedId }],
inputs: [{ widgetId: seedId }]
})
).toStrictEqual([seedId])
})
})
describe('getWidgetStep', () => {
test('should return step2 when available', () => {
const options: IWidgetOptions<unknown> = {

View File

@@ -1,5 +1,6 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import type { WidgetId } from '@/types/widgetId'
import type { UUID } from '@/utils/uuid'
import { evaluateMathExpression } from '@/lib/litegraph/src/utils/mathParser'
@@ -24,6 +25,26 @@ export function evaluateInput(input: string): number | undefined {
return newValue
}
export function getWidgetIds(
widgets: readonly { readonly widgetId?: WidgetId }[]
): WidgetId[] {
return widgets
.map((widget) => widget.widgetId)
.filter((id): id is WidgetId => id !== undefined)
}
export function getNodeWidgetIds(node: {
readonly widgets?: readonly { readonly widgetId?: WidgetId }[]
readonly inputs?: readonly { readonly widgetId?: WidgetId }[]
}): WidgetId[] {
return Array.from(
new Set([
...getWidgetIds(node.widgets ?? []),
...getWidgetIds(node.inputs ?? [])
])
)
}
export function resolveNodeRootGraphId(
node: Pick<LGraphNode, 'graph'>
): UUID | undefined

View File

@@ -4,7 +4,15 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
import type {
IBaseWidget,
INumericWidget
} from '@/lib/litegraph/src/types/widgets'
import { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
import type {
DrawWidgetOptions,
WidgetEventOptions
} from '@/lib/litegraph/src/widgets/BaseWidget'
import { NumberWidget } from '@/lib/litegraph/src/widgets/NumberWidget'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { toNodeId } from '@/types/nodeId'
@@ -27,6 +35,28 @@ function createTestWidget(
)
}
class MutableTypeWidget extends BaseWidget<IBaseWidget<number, string>> {
drawWidget(
_ctx: CanvasRenderingContext2D,
_options: DrawWidgetOptions
): void {}
onClick(_options: WidgetEventOptions): void {}
}
function createMutableTypeWidget(node: LGraphNode): MutableTypeWidget {
return new MutableTypeWidget(
{
type: 'number',
name: 'typeChangedWidget',
value: 42,
options: { min: 0, max: 100 },
y: 0
},
node
)
}
describe('BaseWidget store integration', () => {
let graph: LGraph
let node: LGraphNode
@@ -175,6 +205,31 @@ describe('BaseWidget store integration', () => {
store.getWidget(widgetId(graph.id, toNodeId(1), 'valuesWidget'))?.value
).toBe(77)
})
it('registers the live widget type', () => {
const widget = createMutableTypeWidget(node)
widget.type = 'number-custom'
widget.setNodeId(toNodeId(1))
expect(
store.getWidget(widgetId(graph.id, toNodeId(1), 'typeChangedWidget'))
?.type
).toBe('number-custom')
})
it('stores explicit isDOMWidget false over component presence', () => {
const widget = createTestWidget(node, { name: 'flaggedDomWidget' })
Object.assign(widget, { component: {}, isDOMWidget: false })
widget.setNodeId(toNodeId(1))
expect(
store.getWidgetRenderState(
widgetId(graph.id, toNodeId(1), 'flaggedDomWidget')
)?.isDOMWidget
).toBe(false)
})
})
describe('DOM widget value registration', () => {

View File

@@ -46,6 +46,16 @@ export interface WidgetEventOptions {
canvas: LGraphCanvas
}
function isDOMBackedWidget(widget: IBaseWidget): boolean {
if ('isDOMWidget' in widget && typeof widget.isDOMWidget === 'boolean') {
return widget.isDOMWidget
}
return (
('element' in widget && !!widget.element) ||
('component' in widget && !!widget.component)
)
}
export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
implements IBaseWidget, NodeBindable
{
@@ -147,13 +157,19 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
const graphId = this.node.graph?.rootGraph.id
if (!graphId) return
this._state = useWidgetValueStore().registerWidget(
widgetId(graphId, nodeId, this.name),
{
...this._state,
value: this.value
}
)
const store = useWidgetValueStore()
const id = widgetId(graphId, nodeId, this.name)
this._state = store.registerWidget(id, {
...this._state,
type: this.type,
value: this.value
})
store.registerWidgetRenderState(id, {
advanced: this.options?.advanced ?? this.advanced,
hasLayoutSize: typeof this.computeLayoutSize === 'function',
isDOMWidget: isDOMBackedWidget(this),
tooltip: this.tooltip
})
}
constructor(widget: TWidget & { node: LGraphNode })

View File

@@ -0,0 +1,208 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen, within } from '@testing-library/vue'
import { setActivePinia } from 'pinia'
import { createI18n } from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NodeError } from '@/schemas/apiSchema'
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
import { useAppModeStore } from '@/stores/appModeStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { toNodeId } from '@/types/nodeId'
const billingMock = vi.hoisted(() => ({
isActiveSubscription: true
}))
const overlayMock = vi.hoisted(() => ({
overlayMessage: 'KSampler is missing a required input: model',
overlayTitle: 'Required input missing'
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: billingMock.isActiveSubscription
})
}))
vi.mock('@/components/error/useErrorOverlayState', () => ({
useErrorOverlayState: () => ({
overlayMessage: overlayMock.overlayMessage,
overlayTitle: overlayMock.overlayTitle
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
linearMode: {
error: {
goto: 'Show errors in graph'
},
mobileNoWorkflow: 'No workflow',
runCount: 'Run count',
viewJob: 'View job'
},
menu: {
run: 'Run'
},
menuLabels: {
publish: 'Publish'
},
queue: {
jobAddedToQueue: 'Job added to queue',
jobQueueing: 'Queueing'
}
}
}
})
const nodeErrors: Record<string, NodeError> = {
'1': {
class_type: 'TestNode',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: 'Missing input',
details: '',
extra_info: { input_name: 'prompt' }
}
]
}
}
function renderControls({
hasError = false,
isActiveSubscription = true,
mobile = false
}: {
hasError?: boolean
isActiveSubscription?: boolean
mobile?: boolean
} = {}) {
billingMock.isActiveSubscription = isActiveSubscription
const pinia = createTestingPinia({
createSpy: vi.fn,
stubActions: false
})
setActivePinia(pinia)
useAppModeStore().selectedOutputs = [toNodeId(1)]
if (hasError) {
useExecutionErrorStore().lastNodeErrors = nodeErrors
}
const toastTarget = document.createElement('div')
return render(LinearControls, {
props: { mobile, toastTo: toastTarget },
global: {
plugins: [pinia, i18n],
stubs: {
AppModeWidgetList: true,
Loader: true,
PartnerNodesList: true,
Popover: {
template: '<div><slot name="button" /><slot /></div>'
},
ScrubableNumberInput: true,
SubscribeToRunButton: true
}
}
})
}
describe('LinearControls', () => {
beforeEach(() => {
vi.clearAllMocks()
billingMock.isActiveSubscription = true
overlayMock.overlayMessage = 'KSampler is missing a required input: model'
overlayMock.overlayTitle = 'Required input missing'
})
it.for([
{ label: 'desktop', mobile: false },
{ label: 'mobile', mobile: true }
])('shows a workflow error warning in $label controls', ({ mobile }) => {
renderControls({ hasError: true, mobile })
const warning = screen.getByRole('status')
expect(
within(warning).getByText('Required input missing')
).toBeInTheDocument()
expect(
within(warning).getByText('KSampler is missing a required input: model')
).toBeInTheDocument()
expect(
within(warning).getByRole('button', { name: 'Show errors in graph' })
).toBeInTheDocument()
expect(within(warning).queryByLabelText('Close')).not.toBeInTheDocument()
const runButton = screen.getByRole('button', { name: 'Run' })
expect(runButton).toHaveAttribute(
'aria-describedby',
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
)
const description = screen.getByTestId(
'linear-validation-warning-description'
)
expect(description).toHaveAttribute(
'id',
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
)
expect(description).toHaveTextContent('Required input missing')
expect(description).toHaveTextContent(
'KSampler is missing a required input: model'
)
expect(description).not.toHaveTextContent('Show errors in graph')
})
it.for([
{ label: 'desktop', mobile: false },
{ label: 'mobile', mobile: true }
])(
'does not show the workflow error warning in $label controls without graph errors',
({ mobile }) => {
renderControls({ mobile })
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Show errors in graph' })
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Run' })).not.toHaveAttribute(
'aria-describedby'
)
}
)
it.for([
{ label: 'desktop', mobile: false },
{ label: 'mobile', mobile: true }
])(
'does not show the workflow error warning in $label controls without an active subscription',
({ mobile }) => {
renderControls({
hasError: true,
isActiveSubscription: false,
mobile
})
expect(screen.queryByRole('status')).not.toBeInTheDocument()
}
)
it('does not show the warning when the error copy is empty', () => {
overlayMock.overlayMessage = ''
renderControls({ hasError: true })
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Run' })).not.toHaveAttribute(
'aria-describedby'
)
})
})

View File

@@ -1,10 +1,11 @@
<script setup lang="ts">
import { useTimeout } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { ref, useTemplateRef } from 'vue'
import { computed, ref, toValue, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
import Loader from '@/components/loader/Loader.vue'
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
import Popover from '@/components/ui/Popover.vue'
@@ -14,11 +15,15 @@ import SubscribeToRunButton from '@/platform/cloud/subscription/components/Subsc
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import LinearRunErrorWarning from '@/renderer/extensions/linearMode/LinearRunErrorWarning.vue'
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
import PartnerNodesList from '@/renderer/extensions/linearMode/PartnerNodesList.vue'
import { useCommandStore } from '@/stores/commandStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const { batchCount } = storeToRefs(useQueueSettingsStore())
@@ -28,6 +33,8 @@ const workflowStore = useWorkflowStore()
const { isBuilderMode } = useAppMode()
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const { hasAnyError } = storeToRefs(useExecutionErrorStore())
const { overlayMessage } = useErrorOverlayState()
const { toastTo, mobile } = defineProps<{
toastTo?: string | HTMLElement
@@ -43,6 +50,13 @@ const { ready: jobToastTimeout, start: resetJobToastTimeout } = useTimeout(
{ controls: true, immediate: false }
)
const widgetListRef = useTemplateRef('widgetListRef')
const linearRunButtonTestId = 'linear-run-button'
const showRunErrorWarning = computed(
() =>
hasAnyError.value &&
toValue(isActiveSubscription) &&
toValue(overlayMessage).trim().length > 0
)
//TODO: refactor out of this file.
//code length is small, but changes should propagate
@@ -134,9 +148,10 @@ function handleDragDrop() {
<PartnerNodesList v-if="!mobile" />
<section
v-if="mobile"
data-testid="linear-run-button"
:data-testid="linearRunButtonTestId"
class="border-t border-node-component-border p-4 pb-6"
>
<LinearRunErrorWarning v-if="showRunErrorWarning" />
<SubscribeToRunButton
v-if="!isActiveSubscription"
class="mt-4 w-full"
@@ -166,18 +181,24 @@ function handleDragDrop() {
variant="primary"
class="grow"
size="lg"
:aria-describedby="
showRunErrorWarning
? LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
: undefined
"
@click="runButtonClick"
>
<i class="icon-[lucide--play]" />
<i aria-hidden="true" class="icon-[lucide--play]" />
{{ t('menu.run') }}
</Button>
</div>
</section>
<section
v-else
data-testid="linear-run-button"
:data-testid="linearRunButtonTestId"
class="border-t border-node-component-border p-4 pb-6"
>
<LinearRunErrorWarning v-if="showRunErrorWarning" />
<div
class="m-1 mb-2 text-node-component-slot-text"
v-text="t('linearMode.runCount')"
@@ -198,9 +219,14 @@ function handleDragDrop() {
variant="primary"
class="mt-4 w-full text-sm"
size="lg"
:aria-describedby="
showRunErrorWarning
? LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
: undefined
"
@click="runButtonClick"
>
<i class="icon-[lucide--play]" />
<i aria-hidden="true" class="icon-[lucide--play]" />
{{ t('menu.run') }}
</Button>
</section>

View File

@@ -0,0 +1,92 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createI18n } from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import LinearRunErrorWarning from '@/renderer/extensions/linearMode/LinearRunErrorWarning.vue'
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
const mocks = vi.hoisted(() => ({
overlayMessage: 'KSampler is missing a required input: model',
overlayTitle: 'Required input missing',
viewErrorsInGraph: vi.fn()
}))
vi.mock('@/components/error/useErrorOverlayState', () => ({
useErrorOverlayState: () => ({
overlayMessage: mocks.overlayMessage,
overlayTitle: mocks.overlayTitle
})
}))
vi.mock('@/composables/useViewErrorsInGraph', () => ({
useViewErrorsInGraph: () => ({
viewErrorsInGraph: mocks.viewErrorsInGraph
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
linearMode: {
error: {
goto: 'Show errors in graph'
}
}
}
}
})
function renderWarning() {
const user = userEvent.setup()
const result = render(LinearRunErrorWarning, {
global: { plugins: [i18n] }
})
return { ...result, user }
}
describe('LinearRunErrorWarning', () => {
beforeEach(() => {
mocks.viewErrorsInGraph.mockReset()
})
it('shows the current error overlay title and message without a close action', () => {
renderWarning()
const warning = screen.getByRole('status')
expect(warning).toHaveTextContent('Required input missing')
expect(warning).toHaveTextContent(
'KSampler is missing a required input: model'
)
expect(screen.getByText('Required input missing')).toHaveAttribute(
'title',
'Required input missing'
)
const description = screen.getByTestId(
'linear-validation-warning-description'
)
expect(description).toHaveAttribute(
'id',
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
)
expect(description).toHaveTextContent('Required input missing')
expect(description).toHaveTextContent(
'KSampler is missing a required input: model'
)
expect(description).not.toHaveTextContent('Show errors in graph')
expect(screen.queryByLabelText('Close')).not.toBeInTheDocument()
})
it('opens graph errors when the action is clicked', async () => {
const { user } = renderWarning()
await user.click(
screen.getByRole('button', { name: 'Show errors in graph' })
)
expect(mocks.viewErrorsInGraph).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
import { useViewErrorsInGraph } from '@/composables/useViewErrorsInGraph'
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
const { t } = useI18n()
const { viewErrorsInGraph } = useViewErrorsInGraph()
const { overlayMessage, overlayTitle } = useErrorOverlayState()
</script>
<template>
<div
role="status"
data-testid="linear-validation-warning"
class="mb-3 flex w-full flex-col gap-2 overflow-hidden rounded-lg border border-l-4 border-border-default border-l-destructive-background bg-base-background p-3 shadow-interface transition-colors duration-200 ease-in-out"
>
<div
:id="LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID"
data-testid="linear-validation-warning-description"
class="flex flex-col gap-2"
>
<div class="flex w-full items-start gap-2">
<i
aria-hidden="true"
class="mt-0.5 icon-[lucide--circle-x] size-4 shrink-0 text-destructive-background"
/>
<span
class="min-w-0 flex-1 truncate text-sm text-base-foreground"
:title="overlayTitle"
>
{{ overlayTitle }}
</span>
</div>
<div
class="flex w-full items-start gap-2"
data-testid="linear-validation-warning-message"
>
<span class="size-4 shrink-0" aria-hidden="true" />
<p
class="m-0 line-clamp-3 min-w-0 flex-1 text-sm/snug wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ overlayMessage }}
</p>
</div>
</div>
<div class="flex w-full items-center justify-end pt-2">
<Button
variant="secondary"
size="unset"
class="min-h-8 rounded-lg px-3 py-2 text-xs font-normal"
data-testid="linear-view-errors"
@click="viewErrorsInGraph"
>
{{ t('linearMode.error.goto') }}
</Button>
</div>
</div>
</template>

View File

@@ -0,0 +1,2 @@
export const LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID =
'linear-run-error-warning'

View File

@@ -85,7 +85,6 @@ describe('Vue Node - Subgraph Functionality', () => {
selected: false,
executing: false,
subgraphId,
widgets: [],
inputs: [],
outputs: [],
hasErrors: false,

View File

@@ -15,6 +15,7 @@ import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composable
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
const mockData = vi.hoisted(() => ({
mockExecuting: false,
@@ -60,7 +61,7 @@ vi.mock(
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: { getNodeById: vi.fn() },
rootGraph: { id: 'graph-test', getNodeById: vi.fn() },
canvas: { setDirty: vi.fn() }
}
}))
@@ -161,7 +162,6 @@ const mockNodeData: VueNodeData = {
flags: {},
inputs: [],
outputs: [],
widgets: [],
selected: false,
executing: false
}
@@ -178,6 +178,7 @@ describe('LGraphNode', () => {
beforeEach(() => {
vi.resetAllMocks()
mockData.mockExecuting = false
mockData.mockLgraphNode = null
setActivePinia(pinia)
const canvasStore = useCanvasStore()
@@ -204,6 +205,18 @@ describe('LGraphNode', () => {
)
})
it('does not prune widget store state while rendering', () => {
mockData.mockLgraphNode = {
widgets: [],
isSubgraphNode: () => false
}
const widgetValueStore = useWidgetValueStore()
renderLGraphNode({ nodeData: mockNodeData })
expect(widgetValueStore.replaceNodeWidgetOrder).not.toHaveBeenCalled()
})
it('should render node title', () => {
const { container } = render(LGraphNode, {
props: { nodeData: mockNodeData },
@@ -274,17 +287,16 @@ describe('LGraphNode', () => {
})
it('should hide advanced footer button while the node is collapsed', () => {
mockData.mockLgraphNode = {
isSubgraphNode: () => false,
widgets: [
{ name: 'advancedWidget', type: 'number', options: { advanced: true } }
]
}
renderLGraphNode({
nodeData: {
...mockNodeData,
flags: { collapsed: true },
widgets: [
{
name: 'advancedWidget',
type: 'number',
options: { advanced: true }
}
]
flags: { collapsed: true }
}
})
@@ -294,18 +306,17 @@ describe('LGraphNode', () => {
})
it('should show error-only footer for collapsed nodes with advanced widgets', () => {
mockData.mockLgraphNode = {
isSubgraphNode: () => false,
widgets: [
{ name: 'advancedWidget', type: 'number', options: { advanced: true } }
]
}
renderLGraphNode({
nodeData: {
...mockNodeData,
flags: { collapsed: true },
hasErrors: true,
widgets: [
{
name: 'advancedWidget',
type: 'number',
options: { advanced: true }
}
]
hasErrors: true
}
})

View File

@@ -57,7 +57,7 @@
cn(
'pointer-events-none absolute z-0 border-3 outline-none',
selectionShapeClass,
hasAnyError ? 'inset-[-7px]' : 'inset-[-3px]',
hasAnyError ? '-inset-1.75' : '-inset-0.75',
isSelected
? 'border-node-component-outline'
: 'border-node-stroke-executing'
@@ -107,10 +107,10 @@
multi
class="absolute right-0 translate-x-1/2"
/>
<NodeSlots :node-data="nodeData" unified />
<NodeSlots :node-data unified />
</template>
<NodeHeader
:node-data="nodeData"
:node-data
:collapsed="isCollapsed"
:price-badges="badges.pricing"
@collapse="handleCollapse"
@@ -130,7 +130,7 @@
/>
<template v-if="!isCollapsed && isRerouteNode">
<NodeSlots :node-data="nodeData" />
<NodeSlots :node-data />
</template>
<template v-else-if="!isCollapsed">
@@ -157,20 +157,20 @@
"
:data-testid="`node-body-${nodeData.id}`"
>
<NodeSlots :node-data="nodeData" />
<NodeSlots :node-data />
<NodeWidgets v-if="nodeData.widgets?.length" :node-data="nodeData" />
<NodeWidgets
v-if="hasRenderableWidgets"
:node-data
:widget-ids="widgetIds"
/>
<div v-if="hasCustomContent" class="flex min-h-0 flex-1 flex-col">
<NodeContent
v-if="nodeMedia"
:node-data="nodeData"
:media="nodeMedia"
/>
<NodeContent v-if="nodeMedia" :node-data :media="nodeMedia" />
<NodeContent
v-for="preview in promotedPreviews"
:key="`${preview.sourceNodeId}-${preview.sourceWidgetName}`"
:node-data="nodeData"
:node-data
:media="preview"
/>
</div>
@@ -302,8 +302,12 @@ import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { isVideoOutput } from '@/utils/litegraphUtil'
import { getWidgetIdForNode, isVideoOutput } from '@/utils/litegraphUtil'
import {
getLocatorIdFromNodeData,
getNodeByLocatorId
@@ -736,18 +740,45 @@ const { promotedPreviews } = usePromotedPreviews(lgraphNode)
useGLSLPreview(lgraphNode)
const widgetValueStore = useWidgetValueStore()
const widgetIds = computed(() => {
const graphId = app.rootGraph?.id
const bareNodeId = stripGraphPrefix(nodeData.id)
if (!graphId || !bareNodeId) return []
const storedIds = widgetValueStore.getNodeWidgetIds(graphId, bareNodeId) ?? []
const node = lgraphNode.value
if (!node) return storedIds
if (!node.widgets?.length) return []
const duplicateIndexByKey = new Map<string, number>()
const liveIdSet = new Set(
node.widgets.flatMap((widget) => {
const duplicateKey = `${widget.name}:${widget.type}`
const duplicateIndex = duplicateIndexByKey.get(duplicateKey) ?? 0
duplicateIndexByKey.set(duplicateKey, duplicateIndex + 1)
const id = getWidgetIdForNode(node, widget, duplicateIndex)
return id ? [id] : []
})
)
return storedIds.filter((id) => liveIdSet.has(id))
})
const hasRenderableWidgets = computed(() => widgetIds.value.length > 0)
const showAdvancedInputsButton = computed(() => {
const node = lgraphNode.value
if (!node) return false
if (isCollapsed.value) return false
// For subgraph nodes: check for unpromoted widgets
if (node instanceof SubgraphNode) {
return hasUnpromotedWidgets(node)
}
// For regular nodes: show button if there are advanced widgets and they're currently hidden
const hasAdvancedWidgets = nodeData.widgets?.some((w) => w.options?.advanced)
const hasAdvancedWidgets = widgetIds.value.some((id) => {
const renderState = widgetValueStore.getWidgetRenderState(id)
const widgetState = widgetValueStore.getWidget(id)
return renderState?.advanced ?? widgetState?.options?.advanced
})
const alwaysShowAdvanced = settingStore.get(
'Comfy.Node.AlwaysShowAdvancedWidgets'
)

View File

@@ -1,6 +1,9 @@
import { createTestingPinia } from '@pinia/testing'
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 LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
import { fromPartial } from '@total-typescript/shoehorn'
@@ -9,12 +12,20 @@ vi.mock('@/stores/widgetStore', () => ({
useWidgetStore: () => ({ inputIsWidget: () => true })
}))
// Serializes the nodeData prop so tests can assert on the data contract
// LGraphNodePreview hands to NodeWidgets. How that data renders is covered
// by NodeWidgets.test.ts and browser_tests/tests/sidebar/modelLibrary.spec.ts.
const NodeWidgetsProbe = {
props: ['nodeData'],
template: '<div data-testid="node-data">{{ JSON.stringify(nodeData) }}</div>'
const WidgetGridProbe = {
props: ['processedWidgets'],
setup(props: { processedWidgets?: ProcessedWidget[] }) {
const widgets = computed(() =>
(props.processedWidgets ?? []).map((widget) => ({
name: widget.name,
value: widget.simplified.value,
options: { values: widget.simplified.options?.values }
}))
)
return { widgets }
},
template:
'<div data-testid="node-data">{{ JSON.stringify({ widgets }) }}</div>'
}
interface ProbedWidget {
@@ -39,10 +50,11 @@ function renderedWidgets(
render(LGraphNodePreview, {
props: { nodeDef: def, ...props },
global: {
plugins: [createTestingPinia({ stubActions: false })],
stubs: {
NodeHeader: true,
NodeSlots: true,
NodeWidgets: NodeWidgetsProbe
WidgetGrid: WidgetGridProbe
}
}
})

View File

@@ -19,9 +19,12 @@
>
<NodeSlots :node-data="nodeData" />
<NodeWidgets
v-if="nodeData.widgets?.length"
:node-data="nodeData"
<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>
@@ -36,11 +39,11 @@ import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import { RenderShape } from '@/lib/litegraph/src/litegraph'
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 { toNodeId } from '@/types/nodeId'
@@ -58,39 +61,9 @@ const {
const widgetStore = useWidgetStore()
// Convert nodeDef into VueNodeData
const nodeData = computed<VueNodeData>(() => {
const widgets = Object.entries(nodeDef.inputs || {})
.filter(([_, input]) => widgetStore.inputIsWidget(input))
.map(([name, input]) => {
const comboValues =
input.type === 'COMBO' && Array.isArray(input.options)
? input.options
: undefined
// Preview nodes have no widget-value store entry, so combo widgets
// render their first option; lead with the requested value to show it.
const leadValue = widgetValues?.[name]
return {
nodeId: toNodeId('-1'),
name,
type: input.widgetType || input.type,
value:
input.default !== undefined
? input.default
: (comboValues?.[0] ?? ''),
options: {
hidden: input.hidden,
advanced: input.advanced,
values:
leadValue && comboValues
? [leadValue, ...comboValues.filter((o) => o !== leadValue)]
: comboValues
} satisfies IWidgetOptions
}
})
const inputs: INodeInputSlot[] = Object.entries(nodeDef.inputs || {})
.filter(([_, input]) => !widgetStore.inputIsWidget(input))
.filter(([, input]) => !widgetStore.inputIsWidget(input))
.map(([name, input]) => ({
name,
type: input.type,
@@ -119,16 +92,19 @@ const nodeData = computed<VueNodeData>(() => {
id: toNodeId(`preview-${nodeDef.name}`),
title: nodeDef.display_name || nodeDef.name,
type: nodeDef.name,
mode: 0, // Normal mode
mode: 0,
selected: false,
executing: false,
widgets,
inputs,
outputs,
flags: {
collapsed: false
}
}
})
const { processedWidgets, gridTemplateRows } = usePreviewWidgets(
() => nodeDef,
() => widgetValues
)
</script>

View File

@@ -26,7 +26,6 @@ const makeNodeData = (overrides: Partial<VueNodeData> = {}): VueNodeData => ({
mode: 0,
selected: false,
executing: false,
widgets: [],
inputs: [],
outputs: [],
flags: { collapsed: false },

View File

@@ -38,7 +38,6 @@ const makeNodeData = (overrides: Partial<VueNodeData> = {}): VueNodeData => ({
executing: false,
inputs: [],
outputs: [],
widgets: [],
flags: { collapsed: false },
...overrides
})

View File

@@ -3,20 +3,17 @@
import { createTestingPinia } from '@pinia/testing'
import { render } from '@testing-library/vue'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { toNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import type {
SafeWidgetData,
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import { toNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import { widgetId } from '@/types/widgetId'
import type { WidgetId } from '@/types/widgetId'
const GRAPH_ID = 'graph-test'
@@ -36,7 +33,13 @@ const WidgetStub = {
name: 'WidgetStub',
props: ['widget', 'nodeId', 'nodeType', 'modelValue'],
template:
'<div class="widget-stub" :data-node-type="nodeType">{{ nodeType }}</div>'
'<div class="widget-stub" :data-node-type="nodeType" :data-name="widget.name">{{ nodeType }}</div>'
}
const AppInputStub = {
props: ['widgetId', 'name', 'enable'],
template:
'<div class="app-input-stub" :data-entity-id="widgetId"><slot /></div>'
}
vi.mock(
@@ -50,63 +53,78 @@ vi.mock(
}
)
describe('NodeWidgets', () => {
const createMockWidget = (
overrides: Partial<SafeWidgetData> = {}
): SafeWidgetData => ({
nodeId: toNodeId('test_node'),
name: 'test_widget',
type: 'combo',
options: undefined,
callback: undefined,
spec: undefined,
isDOMWidget: false,
slotMetadata: undefined,
...overrides
})
const createMockNodeData = (
nodeType: string = 'TestNode',
widgets: SafeWidgetData[] = [],
id: NodeId = toNodeId(1)
): VueNodeData => ({
function createMockNodeData(
nodeType = 'TestNode',
id: NodeId = toNodeId(1)
): VueNodeData {
return {
id,
type: nodeType,
widgets,
title: 'Test Node',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
})
function renderComponent(nodeData?: VueNodeData, setupStores?: () => void) {
const pinia = createTestingPinia({ stubActions: false })
setActivePinia(pinia)
setupStores?.()
return render(NodeWidgets, {
props: {
nodeData
},
global: {
plugins: [pinia],
stubs: {
InputSlot: true
},
mocks: {
$t: (key: string) => key
}
}
})
}
}
function registerWidgetState(
id: WidgetId,
init: {
type?: string
value?: unknown
options?: Record<string, unknown>
} = {}
) {
useWidgetValueStore().registerWidget(id, {
type: init.type ?? 'combo',
value: init.value ?? 'value',
options: init.options ?? {}
})
}
function renderComponent({
nodeData,
widgetIds,
setupStores
}: {
nodeData?: VueNodeData
widgetIds?: readonly WidgetId[]
setupStores?: () => void
}) {
const pinia = createTestingPinia({ stubActions: false })
setActivePinia(pinia)
setupStores?.()
return render(NodeWidgets, {
props: {
nodeData,
widgetIds
},
global: {
plugins: [pinia],
stubs: {
InputSlot: true,
AppInput: AppInputStub
},
mocks: {
$t: (key: string) => key
}
}
})
}
describe('NodeWidgets', () => {
describe('node-type prop passing', () => {
it('passes node type to widget components', () => {
const widget = createMockWidget()
const nodeData = createMockNodeData('CheckpointLoaderSimple', [widget])
const { container } = renderComponent(nodeData)
const id = widgetId(GRAPH_ID, toNodeId(1), 'test_widget')
const nodeData = createMockNodeData('CheckpointLoaderSimple')
const { container } = renderComponent({
nodeData,
widgetIds: [id],
setupStores: () => registerWidgetState(id)
})
const stub = container.querySelector('.widget-stub')
expect(stub).not.toBeNull()
@@ -116,15 +134,31 @@ describe('NodeWidgets', () => {
})
it('renders no widgets when nodeData is undefined', () => {
const { container } = renderComponent(undefined)
const id = widgetId(GRAPH_ID, toNodeId(1), 'test_widget')
const { container } = renderComponent({
widgetIds: [id],
setupStores: () => registerWidgetState(id)
})
expect(container.querySelectorAll('.widget-stub')).toHaveLength(0)
})
it('renders no widgets when no widget ids are registered or passed', () => {
const { container } = renderComponent({
nodeData: createMockNodeData('CheckpointLoaderSimple')
})
expect(container.querySelectorAll('.widget-stub')).toHaveLength(0)
})
it('passes empty string when nodeData.type is empty', () => {
const widget = createMockWidget()
const nodeData = createMockNodeData('', [widget])
const { container } = renderComponent(nodeData)
const id = widgetId(GRAPH_ID, toNodeId(1), 'test_widget')
const nodeData = createMockNodeData('')
const { container } = renderComponent({
nodeData,
widgetIds: [id],
setupStores: () => registerWidgetState(id)
})
const stub = container.querySelector('.widget-stub')
expect(stub).not.toBeNull()
@@ -132,7 +166,18 @@ describe('NodeWidgets', () => {
})
})
it('deduplicates widgets with identical render identity while keeping distinct promoted sources', () => {
it('derives widget ids from the store when ids are not passed', () => {
const nodeId = toNodeId('test_node')
const id = widgetId(GRAPH_ID, nodeId, 'test_widget')
const { container } = renderComponent({
nodeData: createMockNodeData('TestNode', nodeId),
setupStores: () => registerWidgetState(id)
})
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(1)
})
it('deduplicates repeated widget ids while keeping distinct widget ids', () => {
const duplicateEntityId = widgetId(
GRAPH_ID,
toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
@@ -143,163 +188,34 @@ describe('NodeWidgets', () => {
toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:20'),
'string_a'
)
const duplicateA = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: duplicateEntityId
})
const duplicateB = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: duplicateEntityId
})
const distinct = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:20'),
widgetId: distinctEntityId
})
const nodeData = createMockNodeData('SubgraphNode', [
duplicateA,
duplicateB,
distinct
])
const nodeData = createMockNodeData('SubgraphNode')
const { container } = renderComponent(nodeData)
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
})
it('prefers a visible duplicate over a hidden duplicate when identities collide', () => {
const sharedEntityId = widgetId(
GRAPH_ID,
toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
'string_a'
)
const hiddenDuplicate = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: sharedEntityId,
options: { hidden: true }
})
const visibleDuplicate = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: sharedEntityId,
options: { hidden: false }
})
const nodeData = createMockNodeData('SubgraphNode', [
hiddenDuplicate,
visibleDuplicate
])
const { container } = renderComponent(nodeData)
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(1)
})
it('does not deduplicate entries that share names but have different widget types', () => {
const sharedEntityId = widgetId(
GRAPH_ID,
toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
'string_a'
)
const textWidget = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: sharedEntityId
})
const comboWidget = createMockWidget({
name: 'string_a',
type: 'combo',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: sharedEntityId
})
const nodeData = createMockNodeData('SubgraphNode', [
textWidget,
comboWidget
])
const { container } = renderComponent(nodeData)
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
})
it('keeps unresolved same-name promoted entries distinct by source execution identity', () => {
const firstTransientEntry = createMockWidget({
nodeId: undefined,
name: 'string_a',
type: 'text',
sourceExecutionId: createNodeExecutionId([toNodeId(65), toNodeId(18)])
})
const secondTransientEntry = createMockWidget({
nodeId: undefined,
name: 'string_a',
type: 'text',
sourceExecutionId: createNodeExecutionId([toNodeId(65), toNodeId(19)])
})
const nodeData = createMockNodeData('SubgraphNode', [
firstTransientEntry,
secondTransientEntry
])
const { container } = renderComponent(nodeData)
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
})
it('does not deduplicate promoted duplicates that differ only by disambiguating source identity', () => {
const firstPromoted = createMockWidget({
name: 'text',
type: 'text',
nodeId: toNodeId('outer-subgraph:1'),
widgetId: widgetId(GRAPH_ID, toNodeId('outer-subgraph:1'), 'text')
})
const secondPromoted = createMockWidget({
name: 'text',
type: 'text',
nodeId: toNodeId('outer-subgraph:2'),
widgetId: widgetId(GRAPH_ID, toNodeId('outer-subgraph:2'), 'text')
})
const nodeData = createMockNodeData('SubgraphNode', [
firstPromoted,
secondPromoted
])
const { container } = renderComponent(nodeData)
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
})
it('hides widgets when merged store options mark them hidden', async () => {
const nodeData = createMockNodeData('TestNode', [
createMockWidget({
nodeId: toNodeId('test_node'),
name: 'test_widget',
options: { hidden: false }
})
])
const { container } = renderComponent(nodeData)
const widgetValueStore = useWidgetValueStore()
widgetValueStore.registerWidget(
widgetId('graph-test', toNodeId('test_node'), 'test_widget'),
{
type: 'combo',
value: 'value',
options: { hidden: true },
label: undefined,
serialize: true,
disabled: false
const { container } = renderComponent({
nodeData,
widgetIds: [duplicateEntityId, duplicateEntityId, distinctEntityId],
setupStores: () => {
registerWidgetState(duplicateEntityId, { type: 'text' })
registerWidgetState(distinctEntityId, { type: 'text' })
}
)
})
await nextTick()
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
})
it('hides widgets when store options mark them hidden', () => {
const nodeData = createMockNodeData('TestNode', toNodeId('test_node'))
const id = widgetId(GRAPH_ID, toNodeId('test_node'), 'test_widget')
const { container } = renderComponent({
nodeData,
widgetIds: [id],
setupStores: () => {
registerWidgetState(id, {
type: 'combo',
options: { hidden: true }
})
}
})
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(0)
})
@@ -307,44 +223,17 @@ describe('NodeWidgets', () => {
it('forwards canonical widgetId to AppInput for selection', () => {
const seedAEntityId = widgetId(GRAPH_ID, toNodeId('test_node'), 'seed_a')
const seedBEntityId = widgetId(GRAPH_ID, toNodeId('test_node'), 'seed_b')
const nodeData = createMockNodeData('TestNode', [
createMockWidget({
nodeId: toNodeId('test_node'),
name: 'seed_a',
type: 'text',
widgetId: seedAEntityId
}),
createMockWidget({
nodeId: toNodeId('test_node'),
name: 'seed_b',
type: 'text',
widgetId: seedBEntityId
})
])
const nodeData = createMockNodeData('TestNode', toNodeId('test_node'))
const { container } = render(NodeWidgets, {
props: { nodeData },
global: {
plugins: [
(() => {
const pinia = createTestingPinia({ stubActions: false })
setActivePinia(pinia)
return pinia
})()
],
stubs: {
InputSlot: true,
AppInput: {
props: ['widgetId', 'name', 'enable'],
template:
'<div class="app-input-stub" :data-entity-id="widgetId"><slot /></div>'
}
},
mocks: {
$t: (key: string) => key
}
const { container } = renderComponent({
nodeData,
widgetIds: [seedAEntityId, seedBEntityId],
setupStores: () => {
registerWidgetState(seedAEntityId, { type: 'text' })
registerWidgetState(seedBEntityId, { type: 'text' })
}
})
const appInputElements = container.querySelectorAll('.app-input-stub')
const ids = Array.from(appInputElements).map((el) =>
el.getAttribute('data-entity-id')
@@ -352,4 +241,35 @@ describe('NodeWidgets', () => {
expect(ids).toStrictEqual([seedAEntityId, seedBEntityId])
})
it('marks widgets with host execution errors', () => {
const nodeId = toNodeId('test_node')
const id = widgetId(GRAPH_ID, nodeId, 'seed')
const { container } = renderComponent({
nodeData: createMockNodeData('TestNode', nodeId),
widgetIds: [id],
setupStores: () => {
useExecutionErrorStore().lastNodeErrors = {
[createNodeExecutionId([nodeId])]: {
errors: [
{
type: 'value_not_in_list',
message: 'seed is invalid',
details: '',
extra_info: { input_name: 'seed' }
}
],
class_type: 'TestNode',
dependent_outputs: []
}
}
registerWidgetState(id, { type: 'text' })
}
})
expect(container.querySelector('.widget-stub')?.className).toContain(
'text-node-stroke-error'
)
})
})

View File

@@ -2,104 +2,44 @@
<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 pr-3',
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"
>
<!-- 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">
import { onErrorCaptured, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
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
widgetIds?: readonly WidgetId[]
}
const { nodeData } = defineProps<NodeWidgetsProps>()
const { nodeData, widgetIds } = defineProps<NodeWidgetsProps>()
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
useCanvasInteractions()
@@ -129,7 +69,10 @@ onErrorCaptured((error) => {
})
const { canSelectInputs, gridTemplateRows, nodeType, processedWidgets } =
useProcessedWidgets(() => nodeData)
useProcessedWidgets(
() => nodeData,
() => widgetIds
)
// Tracks widget-row growth that the node-level RO can't see
if (nodeData?.id != null) {

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

@@ -2,7 +2,6 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
import { i18n, te } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { Settings } from '@/schemas/apiSchema'
@@ -19,9 +18,8 @@ const positiveCoordsTooltipKey =
const outputTooltipKey = 'nodeDefs.SAM3_Detect.outputs.0.tooltip'
const positiveCoordsWidget: SafeWidgetData = {
name: 'positive_coords',
type: 'STRING'
const positiveCoordsWidget: { name: string; tooltip?: string } = {
name: 'positive_coords'
}
function mergeOutputTooltipMessage(tooltip: string | null) {

View File

@@ -5,7 +5,6 @@ import type {
import { computed, ref, unref } from 'vue'
import type { MaybeRef } from 'vue'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
import { st, stRaw } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
@@ -136,11 +135,11 @@ export function useNodeTooltips(nodeType: MaybeRef<string>) {
/**
* Get tooltip text for widgets
*/
const getWidgetTooltip = (widget: SafeWidgetData) => {
const getWidgetTooltip = (widget: { name: string; tooltip?: string }) => {
if (!tooltipsEnabled.value || !nodeDef.value) return ''
// First try widget-specific tooltip
const widgetTooltip = (widget as { tooltip?: string }).tooltip
const widgetTooltip = widget.tooltip
if (widgetTooltip) return widgetTooltip
// Then try input-based tooltip lookup

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

@@ -3,23 +3,28 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { toNodeId } from '@/types/nodeId'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import {
computeProcessedWidgets,
getWidgetIdentity,
hasWidgetError,
isWidgetVisible
} from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
import WidgetDOM from '@/renderer/extensions/vueNodes/widgets/components/WidgetDOM.vue'
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import {
createNodeExecutionId,
createNodeLocatorId
} from '@/types/nodeIdentification'
import { toNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import { widgetId } from '@/types/widgetId'
import type { WidgetId } from '@/types/widgetId'
const GRAPH_ID = 'graph-test'
@@ -28,80 +33,128 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
canvas: {
graph: {
rootGraph: {
id: toNodeId('graph-test')
id: GRAPH_ID
}
}
}
})
}))
const createMockWidget = (
overrides: Partial<SafeWidgetData> = {}
): SafeWidgetData => ({
nodeId: toNodeId('test_node'),
name: 'test_widget',
type: 'combo',
options: undefined,
callback: undefined,
spec: undefined,
isDOMWidget: false,
slotMetadata: undefined,
...overrides
})
function createMockWidget(
overrides: Partial<IBaseWidget> & { widgetId?: WidgetId } = {}
): IBaseWidget {
const { widgetId: id, ...rest } = overrides
const widget: IBaseWidget = {
name: 'test_widget',
type: 'combo',
options: {},
value: 'value',
y: 0,
...rest
}
if (id) {
Object.defineProperty(widget, 'widgetId', {
value: id,
configurable: true
})
}
return widget
}
function createNode(
widgets: IBaseWidget[],
id: NodeId = toNodeId(1),
type = 'TestNode'
): LGraphNode {
const node = new LGraphNode(type)
node.id = id
node.type = type
node.widgets = widgets
return node
}
function createGraphWithNode(
widgets: IBaseWidget[],
id: NodeId = toNodeId(1),
type = 'TestNode'
): { graph: LGraph; node: LGraphNode } {
const graph = new LGraph()
graph.id = GRAPH_ID
const node = createNode(widgets, id, type)
graph.add(node)
return { graph, node }
}
const noopUi = {
getTooltipConfig: () => ({}) as TooltipOptions,
handleNodeRightClick: () => {}
}
function registerWidgetState(
id: WidgetId,
init: {
type?: string
value?: unknown
label?: string
options?: IBaseWidget['options']
} = {}
) {
return useWidgetValueStore().registerWidget(id, {
type: init.type ?? 'combo',
value: 'value' in init ? init.value : 'value',
label: init.label,
options: init.options ?? {}
})
}
function processWidgets({
widgetIds,
nodeId = toNodeId(1),
nodeType = 'TestNode',
showAdvanced = false,
subgraphId,
rootGraph = null
}: {
widgetIds: readonly WidgetId[]
nodeId?: NodeId
nodeType?: string
showAdvanced?: boolean
subgraphId?: string | null
rootGraph?: LGraph | null
}) {
return computeProcessedWidgets({
nodeData: {
id: nodeId,
type: nodeType,
title: 'Test',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: [],
subgraphId
},
widgetIds,
graphId: GRAPH_ID,
showAdvanced,
isGraphReady: false,
rootGraph,
ui: noopUi
})
}
describe('getWidgetIdentity', () => {
it('keys dedupeIdentity by widgetId and widget type', () => {
it('keys render identity by widgetId and widget type', () => {
const id = widgetId(GRAPH_ID, toNodeId('subgraph:19'), 'text')
const widget = createMockWidget({
widgetId: id,
name: 'text',
type: 'text'
})
const { dedupeIdentity, renderKey } = getWidgetIdentity(
widget,
{ widgetId: id, type: 'text' },
toNodeId('1'),
0
)
expect(dedupeIdentity).toBe(`${id}:text`)
expect(renderKey).toBe(dedupeIdentity)
})
it('falls back to host nodeId so duplicate normal widgets dedupe', () => {
const widget = createMockWidget({
nodeId: undefined,
sourceExecutionId: undefined
})
const { dedupeIdentity, renderKey } = getWidgetIdentity(
widget,
toNodeId('5'),
3
)
expect(dedupeIdentity).toBe('node:5:test_widget:combo')
expect(renderKey).toBe(dedupeIdentity)
})
it('returns transient renderKey when no nodeId is available at all', () => {
const widget = createMockWidget({
nodeId: undefined,
sourceExecutionId: undefined
})
const { dedupeIdentity, renderKey } = getWidgetIdentity(
widget,
undefined,
3
)
expect(dedupeIdentity).toBeUndefined()
expect(renderKey).toBe('transient::test_widget:combo:3')
})
it('uses sourceExecutionId for identity when no nodeId', () => {
const widget = createMockWidget({
nodeId: undefined,
sourceExecutionId: createNodeExecutionId([toNodeId(65), toNodeId(18)])
})
const { dedupeIdentity } = getWidgetIdentity(widget, toNodeId('1'), 0)
expect(dedupeIdentity).toBe('exec:65:18:test_widget:combo')
})
})
describe('isWidgetVisible', () => {
@@ -141,10 +194,9 @@ describe('hasWidgetError', () => {
})
it('returns false when no errors', () => {
const widget = createMockWidget()
expect(
hasWidgetError(
widget,
{ name: 'test_widget' },
createNodeExecutionId([toNodeId(1)]),
undefined,
executionErrorStore,
@@ -154,13 +206,12 @@ describe('hasWidgetError', () => {
})
it('returns true when node has matching input error', () => {
const widget = createMockWidget({ name: 'seed' })
const nodeErrors = {
errors: [{ extra_info: { input_name: 'seed' } }]
}
expect(
hasWidgetError(
widget,
{ name: 'seed' },
createNodeExecutionId([toNodeId(1)]),
nodeErrors,
executionErrorStore,
@@ -169,13 +220,13 @@ describe('hasWidgetError', () => {
).toBe(true)
})
it('returns true via sourceExecutionId when execution store has matching error', () => {
const widget = createMockWidget({
name: 'seed',
sourceExecutionId: createNodeExecutionId([toNodeId(65), toNodeId(18)])
})
it('returns true when the resolved source target has a matching error', () => {
const sourceExecutionId = createNodeExecutionId([
toNodeId(65),
toNodeId(18)
])
executionErrorStore.lastNodeErrors = {
'65:18': {
[sourceExecutionId]: {
errors: [
{
type: 'required_input_missing',
@@ -188,9 +239,16 @@ describe('hasWidgetError', () => {
dependent_outputs: []
}
}
expect(
hasWidgetError(
widget,
{
name: 'display_seed',
errorTarget: {
executionId: sourceExecutionId,
widgetName: 'seed'
}
},
createNodeExecutionId([toNodeId(1)]),
undefined,
executionErrorStore,
@@ -200,11 +258,10 @@ describe('hasWidgetError', () => {
})
it('returns true when widget has missing model', () => {
const widget = createMockWidget({ name: 'ckpt_name' })
vi.spyOn(missingModelStore, 'isWidgetMissingModel').mockReturnValue(true)
expect(
hasWidgetError(
widget,
{ name: 'ckpt_name' },
createNodeExecutionId([toNodeId(1)]),
undefined,
executionErrorStore,
@@ -213,37 +270,13 @@ describe('hasWidgetError', () => {
).toBe(true)
})
it('matches errors by the slot name (widget.name) for promoted widgets', () => {
const widget = createMockWidget({
name: 'display_slot',
sourceWidgetName: 'internal_name'
})
const nodeErrors = {
errors: [{ extra_info: { input_name: 'display_slot' } }]
}
expect(
hasWidgetError(
widget,
createNodeExecutionId([toNodeId(1)]),
nodeErrors,
executionErrorStore,
missingModelStore
)
).toBe(true)
})
it('matches missing models by the host widget name', () => {
const widget = createMockWidget({
name: 'display_slot',
sourceExecutionId: createNodeExecutionId([toNodeId(65), toNodeId(18)]),
sourceWidgetName: 'ckpt_name'
})
const spy = vi
.spyOn(missingModelStore, 'isWidgetMissingModel')
.mockReturnValue(true)
expect(
hasWidgetError(
widget,
{ name: 'display_slot' },
createNodeExecutionId([toNodeId(1)]),
undefined,
executionErrorStore,
@@ -254,111 +287,16 @@ describe('hasWidgetError', () => {
})
})
const noopUi = {
getTooltipConfig: () => ({}) as TooltipOptions,
handleNodeRightClick: () => {}
}
describe('computeProcessedWidgets borderStyle', () => {
describe('computeProcessedWidgets', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('does not apply border styling to promoted widgets', () => {
const id = widgetId(GRAPH_ID, toNodeId('inner-subgraph:1'), 'text')
useWidgetValueStore().registerWidget(id, {
type: 'combo',
value: 'a',
options: {},
label: 'Text'
})
const promotedWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: toNodeId('inner-subgraph:1'),
widgetId: id
})
const result = computeProcessedWidgets({
nodeData: {
id: toNodeId('3'),
type: 'SubgraphNode',
widgets: [promotedWidget],
title: 'Test',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: 'graph-test',
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
expect(result[0].simplified.borderStyle).toBeUndefined()
expect(result[0].simplified.label).toBe('Text')
})
it('does not apply border styling to regular widgets', () => {
const widget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: toNodeId('inner-subgraph:1'),
widgetId: widgetId(GRAPH_ID, toNodeId('inner-subgraph:1'), 'text')
})
const result = computeProcessedWidgets({
nodeData: {
id: toNodeId('4'),
type: 'SubgraphNode',
widgets: [widget],
title: 'Test',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: 'graph-test',
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
expect(
result.some((w) => w.simplified.borderStyle?.includes('promoted'))
).toBe(false)
})
it('applies advanced border styling to advanced widgets', () => {
const advancedWidget = createMockWidget({
name: 'text',
type: 'combo',
options: { advanced: true }
})
const id = widgetId(GRAPH_ID, toNodeId(1), 'text')
registerWidgetState(id, { type: 'text', options: { advanced: true } })
const result = computeProcessedWidgets({
nodeData: {
id: toNodeId('1'),
type: 'TestNode',
widgets: [advancedWidget],
title: 'Test',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: 'graph-test',
showAdvanced: true,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
const result = processWidgets({ widgetIds: [id], showAdvanced: true })
expect(result[0].simplified.borderStyle).toBe(
'ring ring-component-node-widget-advanced'
@@ -367,37 +305,17 @@ describe('computeProcessedWidgets borderStyle', () => {
it('reads widget identity, value, label, and options from widgetId state', () => {
const id = widgetId(GRAPH_ID, toNodeId('host'), 'text')
useWidgetValueStore().registerWidget(id, {
registerWidgetState(id, {
type: 'combo',
value: 'state value',
label: 'State Label',
options: { values: ['state value'] }
})
const widget = createMockWidget({
widgetId: id,
nodeId: toNodeId('host'),
name: 'stale name',
type: 'combo',
options: { values: ['stale value'] }
})
const result = computeProcessedWidgets({
nodeData: {
id: toNodeId('3'),
type: 'SubgraphNode',
widgets: [widget],
title: 'Test',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: GRAPH_ID,
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
const result = processWidgets({
widgetIds: [id],
nodeId: toNodeId('host'),
nodeType: 'SubgraphNode'
})
expect(result[0]).toMatchObject({
@@ -413,265 +331,167 @@ describe('computeProcessedWidgets borderStyle', () => {
})
})
it('uses widget nodeId for simplified widget locator when present', () => {
const subgraphId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
const widget = createMockWidget({
name: 'text',
it('preserves null values from widgetId state', () => {
const id = widgetId(GRAPH_ID, toNodeId('host'), 'text')
registerWidgetState(id, {
type: 'combo',
nodeId: toNodeId('inner-node')
value: null,
options: {}
})
const result = computeProcessedWidgets({
nodeData: {
id: toNodeId('host-node'),
type: 'SubgraphNode',
widgets: [widget],
title: 'Test',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: [],
subgraphId
},
graphId: GRAPH_ID,
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
const result = processWidgets({
widgetIds: [id],
nodeId: toNodeId('host')
})
expect(result[0].value).toBeNull()
expect(result[0].simplified.value).toBeNull()
})
it('uses widget state nodeId for simplified widget locator', () => {
const subgraphId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
const id = widgetId(GRAPH_ID, toNodeId('inner-node'), 'text')
registerWidgetState(id, { type: 'combo', value: 'a', options: {} })
const result = processWidgets({
widgetIds: [id],
nodeId: toNodeId('host-node'),
nodeType: 'SubgraphNode',
subgraphId
})
expect(result[0].simplified.nodeLocatorId).toBe(
createNodeLocatorId(subgraphId, toNodeId('inner-node'))
)
})
it('deduplication keeps visible widget over hidden duplicate', () => {
const sharedWidgetId = widgetId(GRAPH_ID, toNodeId('1'), 'text')
const hiddenWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: toNodeId('1'),
widgetId: sharedWidgetId,
options: { hidden: true }
})
const visibleWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: toNodeId('1'),
widgetId: sharedWidgetId
})
it('deduplicates repeated widget ids', () => {
const id = widgetId(GRAPH_ID, toNodeId(1), 'text')
registerWidgetState(id, { type: 'text' })
const result = computeProcessedWidgets({
nodeData: {
id: toNodeId('1'),
type: 'TestNode',
widgets: [hiddenWidget, visibleWidget],
title: 'Test',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: 'graph-test',
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
const result = processWidgets({ widgetIds: [id, id] })
expect(result).toHaveLength(1)
expect(result[0].hidden).toBe(false)
expect(result[0].name).toBe('text')
})
it('collapses duplicate normal widgets on the same node to one render', () => {
const colorA = createMockWidget({
name: 'color',
type: 'color',
nodeId: undefined,
sourceExecutionId: undefined
})
const colorB = createMockWidget({
name: 'color',
type: 'color',
nodeId: undefined,
sourceExecutionId: undefined
it('keeps distinct widget ids separate even when names match', () => {
const firstId = widgetId(GRAPH_ID, toNodeId('outer-subgraph:1'), 'text')
const secondId = widgetId(GRAPH_ID, toNodeId('outer-subgraph:2'), 'text')
registerWidgetState(firstId, { type: 'text' })
registerWidgetState(secondId, { type: 'text' })
const result = processWidgets({
widgetIds: [firstId, secondId],
nodeType: 'SubgraphNode'
})
const result = computeProcessedWidgets({
nodeData: {
id: toNodeId('1'),
type: 'ColorToRGBInt',
widgets: [colorA, colorB],
title: 'Color to RGB Int',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: 'graph-test',
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
expect(result).toHaveLength(1)
expect(result[0].name).toBe('color')
expect(result[0].renderKey).toBe('node:1:color:color')
expect(result).toHaveLength(2)
expect(result.map((widget) => widget.widgetId)).toStrictEqual([
firstId,
secondId
])
})
it('omits the processed widget id when node id normalization fails', () => {
const widget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: toNodeId('')
it('reads render-only metadata from widgetValueStore render state', () => {
const id = widgetId(GRAPH_ID, toNodeId('host'), 'display_slot')
registerWidgetState(id, {
type: 'unknown',
value: 'model.safetensors',
options: {}
})
useWidgetValueStore().registerWidgetRenderState(id, {
advanced: true,
hasLayoutSize: true,
isDOMWidget: true,
tooltip: 'Choose checkpoint'
})
const result = computeProcessedWidgets({
nodeData: {
id: toNodeId('1'),
type: 'TestNode',
widgets: [widget],
title: 'Test',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: 'graph-test',
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
const result = processWidgets({
widgetIds: [id],
nodeId: toNodeId('host'),
showAdvanced: true
})
expect(result[0].id).toBeUndefined()
expect(result[0]).toMatchObject({
advanced: true,
hasLayoutSize: true,
simplified: {
name: 'display_slot'
}
})
expect(result[0].vueComponent).toBe(WidgetDOM)
})
it('passes input spec to simplified widgets', () => {
const id = widgetId(GRAPH_ID, toNodeId('host'), 'prompt')
const spec = {
type: 'STRING',
name: 'prompt',
socketless: true
} satisfies InputSpec
registerWidgetState(id, { type: 'text', value: 'hello' })
useWidgetValueStore().registerWidgetSpec(id, spec)
const result = processWidgets({ widgetIds: [id], nodeId: toNodeId('host') })
expect(result[0].simplified.spec).toStrictEqual(spec)
})
it('treats explicit isDOMWidget false as authoritative', () => {
const id = widgetId(GRAPH_ID, toNodeId(1), 'custom')
registerWidgetState(id, { type: 'unknown' })
useWidgetValueStore().registerWidgetRenderState(id, {
isDOMWidget: false
})
const result = processWidgets({ widgetIds: [id] })
expect(result[0].vueComponent).toBe(WidgetLegacy)
expect(result[0].vueComponent).not.toBe(WidgetDOM)
})
})
describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
const GRAPH_ID = 'graph-test'
const NODE_ID = toNodeId(1)
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
function processWidgets(widgets: SafeWidgetData[]) {
return computeProcessedWidgets({
nodeData: {
id: NODE_ID,
type: 'TestNode',
widgets,
title: 'Test',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: GRAPH_ID,
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
function processUpdateWidgets(widgets: IBaseWidget[]) {
const { graph } = createGraphWithNode(widgets, NODE_ID)
const ids = widgets
.map((widget) => widget.widgetId)
.filter((id): id is WidgetId => id !== undefined)
return processWidgets({ widgetIds: ids, nodeId: NODE_ID, rootGraph: graph })
}
it('calls widget.callback with the new value when widgetState exists', () => {
it('calls widget.callback with the new value when a live widget exists', () => {
const callback = vi.fn()
const widget = createMockWidget({
name: 'seed',
nodeId: NODE_ID,
callback
})
const id = widgetId(GRAPH_ID, NODE_ID, 'seed')
const widget = createMockWidget({ name: 'seed', widgetId: id, callback })
registerWidgetState(id, { type: 'combo', value: 0 })
useWidgetValueStore().registerWidget(widgetId(GRAPH_ID, NODE_ID, 'seed'), {
type: 'combo',
value: 0,
options: {}
})
const [processed] = processWidgets([widget])
const [processed] = processUpdateWidgets([widget])
processed.updateHandler(42)
expect(callback).toHaveBeenCalledWith(42)
})
it('calls widget.callback even when widgetState is undefined (no store entry)', () => {
const callback = vi.fn()
const widget = createMockWidget({
name: 'unregistered_widget',
nodeId: NODE_ID,
callback
})
const [processed] = processWidgets([widget])
processed.updateHandler('new-value')
expect(callback).toHaveBeenCalledWith('new-value')
expect(callback).toHaveBeenCalledWith(42, undefined, expect.any(LGraphNode))
})
it('updates widgetState.value when store entry exists', () => {
const widget = createMockWidget({
name: 'seed',
nodeId: NODE_ID
})
const id = widgetId(GRAPH_ID, NODE_ID, 'seed')
registerWidgetState(id, { type: 'combo', value: 0 })
useWidgetValueStore().registerWidget(widgetId(GRAPH_ID, NODE_ID, 'seed'), {
type: 'combo',
value: 0,
options: {}
})
const [processed] = processWidgets([widget])
const [processed] = processWidgets({ widgetIds: [id], nodeId: NODE_ID })
processed.updateHandler(99)
const state = useWidgetValueStore().getWidget(
widgetId(GRAPH_ID, NODE_ID, 'seed')
)
expect(state?.value).toBe(99)
})
it('clears promoted missing models through the host widget identity', () => {
const widget = createMockWidget({
name: 'display_slot',
nodeId: NODE_ID,
sourceExecutionId: createNodeExecutionId([65, 18]),
sourceWidgetName: 'ckpt_name'
})
const executionErrorStore = useExecutionErrorStore()
const clearSpy = vi.spyOn(executionErrorStore, 'clearWidgetRelatedErrors')
const [processed] = processWidgets([widget])
processed.updateHandler('real_model.safetensors')
expect(clearSpy).toHaveBeenCalledWith(
createNodeExecutionId([65, 18]),
'ckpt_name',
'ckpt_name',
'real_model.safetensors',
{ min: undefined, max: undefined }
)
expect(clearSpy).toHaveBeenCalledWith(
createNodeExecutionId([NODE_ID]),
'display_slot',
'display_slot',
'real_model.safetensors',
{ min: undefined, max: undefined }
)
expect(useWidgetValueStore().getWidget(id)?.value).toBe(99)
})
it('clears execution errors on update', () => {
const widget = createMockWidget({
name: 'seed',
nodeId: NODE_ID
})
const id = widgetId(GRAPH_ID, NODE_ID, 'seed')
registerWidgetState(id, { type: 'combo', value: 'bad-value' })
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
@@ -691,11 +511,11 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
}
}
const [processed] = processWidgets([widget])
const [processed] = processWidgets({ widgetIds: [id], nodeId: NODE_ID })
expect(
hasWidgetError(
widget,
{ name: 'seed' },
createNodeExecutionId([NODE_ID]),
executionErrorStore.lastNodeErrors[NODE_ID],
executionErrorStore,
@@ -707,7 +527,46 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
expect(
hasWidgetError(
widget,
{ name: 'seed' },
createNodeExecutionId([NODE_ID]),
executionErrorStore.lastNodeErrors?.[NODE_ID],
executionErrorStore,
missingModelStore
)
).toBe(false)
})
it('clears execution errors from simplified callback without a live widget', () => {
const id = widgetId(GRAPH_ID, NODE_ID, 'seed')
registerWidgetState(id, { type: 'combo', value: 'bad-value' })
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
executionErrorStore.lastNodeErrors = {
[NODE_ID]: {
errors: [
{
type: 'required_input_missing',
message: 'seed is required',
details: '',
extra_info: { input_name: 'seed' }
}
],
class_type: 'TestNode',
dependent_outputs: []
}
}
const [processed] = processWidgets({ widgetIds: [id], nodeId: NODE_ID })
expect(processed.simplified.callback).toBe(processed.updateHandler)
processed.simplified.callback?.('fixed-value')
expect(useWidgetValueStore().getWidget(id)?.value).toBe('fixed-value')
expect(
hasWidgetError(
{ name: 'seed' },
createNodeExecutionId([NODE_ID]),
executionErrorStore.lastNodeErrors?.[NODE_ID],
executionErrorStore,

View File

@@ -2,20 +2,22 @@ import type { TooltipOptions } from 'primevue'
import { computed } from 'vue'
import type { Component } from 'vue'
import type {
SafeWidgetData,
VueNodeData,
WidgetSlotMetadata
} from '@/composables/graph/useGraphNodeManager'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useAppMode } from '@/composables/useAppMode'
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import WidgetDOM from '@/renderer/extensions/vueNodes/widgets/components/WidgetDOM.vue'
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
import {
@@ -23,29 +25,34 @@ import {
shouldExpand,
shouldRenderAsVue
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { app } from '@/scripts/app'
import { nodeTypeValidForApp } from '@/stores/appModeStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import {
createNodeExecutionId,
createNodeLocatorId
} from '@/types/nodeIdentification'
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
import type { NodeId } from '@/types/nodeId'
import type { WidgetId } from '@/types/widgetId'
import { widgetId } from '@/types/widgetId'
import type { WidgetState } from '@/types/widgetState'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { getControlWidget } from '@/types/simplifiedWidget'
import type {
LinkedUpstreamInfo,
SimplifiedWidget,
WidgetValue
} from '@/types/simplifiedWidget'
import { getExecutionIdFromNodeData } from '@/utils/graphTraversalUtil'
import type { WidgetId } from '@/types/widgetId'
import { parseWidgetId } from '@/types/widgetId'
import {
getExecutionIdFromNodeData,
getLocatorIdFromNodeData,
getNodeByLocatorId
} from '@/utils/graphTraversalUtil'
import { getWidgetIdForNode } from '@/utils/litegraphUtil'
const TOOLTIP_VALUE_TYPES = ['asset', 'combo', 'number', 'text'] as const
type TooltipValueType = (typeof TOOLTIP_VALUE_TYPES)[number]
@@ -53,7 +60,25 @@ function isTooltipValueType(val: unknown): val is TooltipValueType {
return TOOLTIP_VALUE_TYPES.includes(val as TooltipValueType)
}
interface ProcessedWidget {
interface WidgetSlotMetadata {
index: number
linked: boolean
originNodeId?: NodeId
originOutputName?: string
type: string
}
interface WidgetTooltipSource {
name: string
tooltip?: string
}
interface WidgetErrorTarget {
executionId: NodeExecutionId
widgetName: string
}
export interface ProcessedWidget {
advanced: boolean
handleContextMenu: (e: PointerEvent) => void
hasLayoutSize: boolean
@@ -74,12 +99,16 @@ interface ProcessedWidget {
}
interface WidgetUiCallbacks {
getTooltipConfig: (widget: SafeWidgetData, fullVal?: string) => TooltipOptions
getTooltipConfig: (
widget: WidgetTooltipSource,
fullVal?: string
) => TooltipOptions
handleNodeRightClick: (e: PointerEvent, nodeId: NodeId) => void
}
interface ComputeProcessedWidgetsOptions {
nodeData: VueNodeData | undefined
widgetIds?: readonly WidgetId[]
graphId: string | undefined
showAdvanced: boolean
isGraphReady: boolean
@@ -87,86 +116,62 @@ interface ComputeProcessedWidgetsOptions {
ui: WidgetUiCallbacks
}
function createWidgetUpdateHandler(
widgetState: WidgetState | undefined,
widget: SafeWidgetData,
nodeExecId: NodeExecutionId,
widgetOptions: IWidgetOptions | Record<string, never>,
executionErrorStore: ReturnType<typeof useExecutionErrorStore>
): (newValue: WidgetValue) => void {
return (newValue: WidgetValue) => {
if (widgetState) widgetState.value = newValue
widget.callback?.(newValue)
const options = { min: widgetOptions?.min, max: widgetOptions?.max }
if (widget.sourceExecutionId) {
const sourceWidgetName = widget.sourceWidgetName ?? widget.name
executionErrorStore.clearWidgetRelatedErrors(
widget.sourceExecutionId,
sourceWidgetName,
sourceWidgetName,
newValue,
options
)
function normalizeWidgetValue(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') {
if (
Array.isArray(value) &&
value.length > 0 &&
value.every((item): item is File => item instanceof File)
) {
return value
}
executionErrorStore.clearWidgetRelatedErrors(
nodeExecId,
widget.name,
widget.name,
newValue,
options
)
return value
}
console.warn(`Invalid widget value type: ${typeof value}`, value)
return undefined
}
export function hasWidgetError(
widget: SafeWidgetData,
nodeExecId: NodeExecutionId,
nodeErrors:
| { errors: { extra_info?: { input_name?: string } }[] }
| undefined,
executionErrorStore: ReturnType<typeof useExecutionErrorStore>,
missingModelStore: ReturnType<typeof useMissingModelStore>
): boolean {
const errors = widget.sourceExecutionId
? executionErrorStore.lastNodeErrors?.[widget.sourceExecutionId]?.errors
: nodeErrors?.errors
return (
!!errors?.some((e) => e.extra_info?.input_name === widget.name) ||
missingModelStore.isWidgetMissingModel(nodeExecId, widget.name)
)
}
function buildSlotMetadata(
inputs: INodeInputSlot[] | undefined,
graphRef: LGraph | null | undefined
): Map<string, WidgetSlotMetadata> {
const metadata = new Map<string, WidgetSlotMetadata>()
inputs?.forEach((input, index) => {
let originNodeId: NodeId | undefined
let originOutputName: string | undefined
export function getWidgetIdentity(
widget: SafeWidgetData,
nodeId: NodeId | undefined,
index: number
): {
dedupeIdentity?: string
renderKey: string
} {
if (widget.widgetId) {
const dedupeIdentity = `${widget.widgetId}:${widget.type}`
return { dedupeIdentity, renderKey: dedupeIdentity }
}
const hostNodeIdRoot = nodeId ? stripGraphPrefix(nodeId) : null
const widgetNodeIdRoot = widget.nodeId
? stripGraphPrefix(widget.nodeId)
: null
const stableIdentityRoot = widgetNodeIdRoot
? `node:${widgetNodeIdRoot}`
: widget.sourceExecutionId
? `exec:${widget.sourceExecutionId}`
: hostNodeIdRoot
? `node:${hostNodeIdRoot}`
: undefined
let linked = input.link != null
if (input.link != null && graphRef) {
const link = graphRef.getLink(input.link)
linked = Boolean(link)
const originNode = link ? graphRef.getNodeById(link.origin_id) : null
if (link && originNode) {
originNodeId = link.origin_id
originOutputName = originNode.outputs?.[link.origin_slot]?.name
}
}
const dedupeIdentity = stableIdentityRoot
? `${stableIdentityRoot}:${widget.name}:${widget.type}`
: undefined
const renderKey =
dedupeIdentity ??
`transient:${String(nodeId ?? '')}:${widget.name}:${widget.type}:${index}`
return { dedupeIdentity, renderKey }
const slotInfo: WidgetSlotMetadata = {
index,
linked,
originNodeId,
originOutputName,
type: String(input.type)
}
if (input.name) metadata.set(input.name, slotInfo)
if (input.widget?.name) metadata.set(input.widget.name, slotInfo)
})
return metadata
}
function getProcessedNodeExecutionId(
@@ -190,6 +195,53 @@ function getWidgetNodeLocatorId(
)
}
function getHostNode(
rootGraph: LGraph | null,
nodeData: VueNodeData
): LGraphNode | null {
if (!rootGraph) return null
const locatorId = getLocatorIdFromNodeData(nodeData)
return locatorId ? getNodeByLocatorId(rootGraph, locatorId) : null
}
function getLiveWidget(
rootGraph: LGraph | null,
nodeData: VueNodeData,
id: WidgetId
): { node: LGraphNode; widget: IBaseWidget } | undefined {
if (!rootGraph) return undefined
const { nodeId } = parseWidgetId(id)
const locatorId = createNodeLocatorId(nodeData.subgraphId ?? null, nodeId)
const node = locatorId ? getNodeByLocatorId(rootGraph, locatorId) : null
if (!node) return undefined
const duplicateIndexByKey = new Map<string, number>()
for (const widget of node.widgets ?? []) {
const duplicateKey = `${widget.name}:${widget.type}`
const duplicateIndex = duplicateIndexByKey.get(duplicateKey) ?? 0
duplicateIndexByKey.set(duplicateKey, duplicateIndex + 1)
if (getWidgetIdForNode(node, widget, duplicateIndex) === id) {
return { node, widget }
}
}
}
function getWidgetErrorTarget(
rootGraph: LGraph | null,
hostNode: LGraphNode | null,
liveWidget: IBaseWidget | undefined
): WidgetErrorTarget | undefined {
if (!hostNode || !liveWidget) return undefined
const source = resolvePromotedWidgetSource(rootGraph, hostNode, liveWidget)
if (!source?.sourceExecutionId) return undefined
return {
executionId: source.sourceExecutionId,
widgetName: source.sourceWidgetName
}
}
export function isWidgetVisible(
options: IWidgetOptions,
showAdvanced: boolean,
@@ -200,19 +252,123 @@ export function isWidgetVisible(
return !hidden && (!advanced || showAdvanced || linked)
}
export function hasWidgetError(
widget: { name: string; errorTarget?: WidgetErrorTarget },
nodeExecId: NodeExecutionId,
nodeErrors:
| { errors: { extra_info?: { input_name?: string } }[] }
| undefined,
executionErrorStore: ReturnType<typeof useExecutionErrorStore>,
missingModelStore: ReturnType<typeof useMissingModelStore>
): boolean {
const hasHostError =
!!nodeErrors?.errors.some(
(e) => e.extra_info?.input_name === widget.name
) || missingModelStore.isWidgetMissingModel(nodeExecId, widget.name)
const target = widget.errorTarget
if (!target) return hasHostError
const sourceErrors = executionErrorStore.lastNodeErrors?.[target.executionId]
return (
hasHostError ||
!!sourceErrors?.errors.some(
(e) => e.extra_info?.input_name === target.widgetName
) ||
missingModelStore.isWidgetMissingModel(
target.executionId,
target.widgetName
)
)
}
export function getWidgetIdentity(
widget: { widgetId: WidgetId; type: string },
_nodeId: NodeId | undefined,
_index: number
): {
dedupeIdentity: string
renderKey: string
} {
const dedupeIdentity = `${widget.widgetId}:${widget.type}`
return { dedupeIdentity, renderKey: dedupeIdentity }
}
function createWidgetUpdateHandler({
id,
live,
errorTarget,
nodeExecId,
widgetName,
widgetOptions,
executionErrorStore,
widgetValueStore
}: {
id: WidgetId
live?: { node: LGraphNode; widget: IBaseWidget }
errorTarget?: WidgetErrorTarget
nodeExecId: NodeExecutionId
widgetName: string
widgetOptions: IWidgetOptions
executionErrorStore: ReturnType<typeof useExecutionErrorStore>
widgetValueStore: ReturnType<typeof useWidgetValueStore>
}): (newValue: WidgetValue) => void {
return (newValue: WidgetValue) => {
widgetValueStore.setValue(id, newValue)
if (live) {
const normalized = normalizeWidgetValue(newValue)
live.widget.value = normalized ?? undefined
live.widget.callback?.(normalized, app.canvas, live.node)
live.node.widgets?.forEach((w) => w.triggerDraw?.())
}
const options = { min: widgetOptions?.min, max: widgetOptions?.max }
if (errorTarget) {
executionErrorStore.clearWidgetRelatedErrors(
errorTarget.executionId,
errorTarget.widgetName,
errorTarget.widgetName,
newValue,
options
)
}
executionErrorStore.clearWidgetRelatedErrors(
nodeExecId,
widgetName,
widgetName,
newValue,
options
)
}
}
function getWidgetIds(
graphId: string | undefined,
nodeId: NodeId,
explicitWidgetIds: readonly WidgetId[] | undefined,
widgetValueStore: ReturnType<typeof useWidgetValueStore>
): readonly WidgetId[] {
if (explicitWidgetIds) return explicitWidgetIds
const bareNodeId = stripGraphPrefix(nodeId)
return graphId && bareNodeId
? widgetValueStore.getNodeWidgetIds(graphId, bareNodeId)
: []
}
export function computeProcessedWidgets({
nodeData,
widgetIds,
graphId,
showAdvanced,
isGraphReady,
rootGraph,
ui
}: ComputeProcessedWidgetsOptions): ProcessedWidget[] {
if (!nodeData?.widgets) return []
if (!nodeData) return []
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
const widgetValueStore = useWidgetValueStore()
const nodeDefStore = useNodeDefStore()
const nodeExecId = getProcessedNodeExecutionId(
isGraphReady,
@@ -221,185 +377,140 @@ export function computeProcessedWidgets({
)
if (!nodeExecId) return []
const ids = getWidgetIds(graphId, nodeData.id, widgetIds, widgetValueStore)
const hostNode = getHostNode(rootGraph, nodeData)
const slotMetadata = buildSlotMetadata(
nodeData.inputs ?? hostNode?.inputs,
hostNode?.graph ?? rootGraph
)
const nodeErrors = executionErrorStore.lastNodeErrors?.[nodeExecId]
const nodeId = nodeData.id
const { widgets } = nodeData
const result: ProcessedWidget[] = []
const uniqueWidgets: Array<{
widget: SafeWidgetData
identity: ReturnType<typeof getWidgetIdentity>
mergedOptions: IWidgetOptions
widgetState: WidgetState | undefined
isVisible: boolean
}> = []
const dedupeIndexByIdentity = new Map<string, number>()
const seenIdentities = new Set<string>()
for (const [index, widget] of widgets.entries()) {
if (!shouldRenderAsVue(widget)) continue
ids.forEach((id, index) => {
const widgetState = widgetValueStore.getWidget(id)
if (!widgetState) return
const identity = getWidgetIdentity(widget, nodeId, index)
const widgetNodeId = stripGraphPrefix(widget.nodeId ?? nodeId)
const widgetState = widget.widgetId
? widgetValueStore.getWidget(widget.widgetId)
: graphId && widgetNodeId
? widgetValueStore.getWidget(
widgetId(graphId, widgetNodeId, widget.name)
)
const renderState = widgetValueStore.getWidgetRenderState(id)
const live = getLiveWidget(rootGraph, nodeData, id)
const liveWidget = live?.widget
const sourceWidget =
hostNode && liveWidget
? resolvePromotedWidgetSource(rootGraph, hostNode, liveWidget)
?.sourceWidget
: undefined
const mergedOptions: IWidgetOptions = {
...(widget.options ?? {}),
...(widgetState?.options ?? {})
}
const visible = isWidgetVisible(
mergedOptions,
showAdvanced,
widget.slotMetadata?.linked
)
if (!identity.dedupeIdentity) {
uniqueWidgets.push({
widget,
identity,
mergedOptions,
widgetState,
isVisible: visible
})
continue
const options: IWidgetOptions = { ...(widgetState.options ?? {}) }
if (options.advanced === undefined) {
options.advanced = renderState?.advanced
}
if (!shouldRenderAsVue({ type: widgetState.type, options })) return
const existingIndex = dedupeIndexByIdentity.get(identity.dedupeIdentity)
if (existingIndex === undefined) {
dedupeIndexByIdentity.set(identity.dedupeIdentity, uniqueWidgets.length)
uniqueWidgets.push({
widget,
identity,
mergedOptions,
widgetState,
isVisible: visible
})
continue
}
const existingWidget = uniqueWidgets[existingIndex]
if (existingWidget && !existingWidget.isVisible && visible) {
uniqueWidgets[existingIndex] = {
widget,
identity,
mergedOptions,
widgetState,
isVisible: true
}
}
}
for (const {
widget,
mergedOptions,
widgetState,
isVisible: visible,
identity: { renderKey }
} of uniqueWidgets) {
const bareWidgetId = stripGraphPrefix(widget.nodeId ?? nodeId)
const slotInfo = slotMetadata.get(widgetState.name)
const visible = isWidgetVisible(options, showAdvanced, slotInfo?.linked)
const isDisabled = slotInfo?.linked || widgetState.disabled
const widgetOptions = isDisabled ? { ...options, disabled: true } : options
const value = widgetState.value as WidgetValue
const errorTarget = getWidgetErrorTarget(rootGraph, hostNode, liveWidget)
const tooltip = renderState?.tooltip
const hasLayoutSize = renderState?.hasLayoutSize ?? false
const isDOMWidget = renderState?.isDOMWidget ?? false
const vueComponent =
getComponent(widget.type) ||
(widget.isDOMWidget ? WidgetDOM : WidgetLegacy)
const { slotMetadata } = widget
const value = widgetState?.value as WidgetValue
const isDisabled = slotMetadata?.linked || widgetState?.disabled
const widgetOptions = isDisabled
? { ...mergedOptions, disabled: true }
: mergedOptions
const borderStyle = mergedOptions.advanced
? 'ring ring-component-node-widget-advanced'
: undefined
getComponent(widgetState.type) || (isDOMWidget ? WidgetDOM : WidgetLegacy)
const bareWidgetId = stripGraphPrefix(widgetState.nodeId)
const linkedUpstream: LinkedUpstreamInfo | undefined =
slotMetadata?.linked && slotMetadata.originNodeId
slotInfo?.linked && slotInfo.originNodeId
? {
nodeId: slotMetadata.originNodeId,
outputName: slotMetadata.originOutputName
nodeId: slotInfo.originNodeId,
outputName: slotInfo.originOutputName
}
: undefined
const nodeLocatorId = getWidgetNodeLocatorId(nodeData, bareWidgetId)
const simplified: SimplifiedWidget = {
name: widgetState?.name ?? widget.name,
type: widget.type,
value,
borderStyle,
callback: widget.callback,
controlWidget: widget.controlWidget,
label: widgetState?.label,
linkedUpstream,
nodeLocatorId,
options: widgetOptions,
spec: widget.spec
}
const updateHandler = createWidgetUpdateHandler(
widgetState,
widget,
const controlWidget =
(liveWidget ? getControlWidget(liveWidget) : undefined) ??
(sourceWidget ? getControlWidget(sourceWidget) : undefined)
const updateHandler = createWidgetUpdateHandler({
id,
live,
errorTarget,
nodeExecId,
widgetName: widgetState.name,
widgetOptions,
executionErrorStore
)
executionErrorStore,
widgetValueStore
})
const simplified: SimplifiedWidget = {
name: widgetState.name,
type: widgetState.type,
value,
borderStyle: widgetOptions.advanced
? 'ring ring-component-node-widget-advanced'
: undefined,
callback: updateHandler,
controlWidget,
label: widgetState.label,
linkedUpstream,
nodeLocatorId: getWidgetNodeLocatorId(nodeData, bareWidgetId),
options: widgetOptions,
spec:
widgetValueStore.getWidgetSpec(id)?.spec ??
(live
? nodeDefStore.getInputSpecForWidget(live.node, live.widget.name)
: undefined)
}
const valueTooltip =
isTooltipValueType(widget.type) && String(value).length > 10
isTooltipValueType(widgetState.type) && String(value).length > 10
? String(value)
: undefined
const tooltipConfig = ui.getTooltipConfig(widget, valueTooltip)
const tooltipConfig = ui.getTooltipConfig(
{ name: widgetState.name, tooltip },
valueTooltip
)
const handleContextMenu = (e: PointerEvent) => {
e.preventDefault()
e.stopPropagation()
if (nodeId !== undefined) ui.handleNodeRightClick(e, nodeId)
showNodeOptions(
e,
widget.name,
widget.nodeId !== undefined
? (stripGraphPrefix(widget.nodeId) ?? undefined)
: undefined
)
ui.handleNodeRightClick(e, nodeData.id)
showNodeOptions(e, widgetState.name)
}
const identity = getWidgetIdentity(
{ widgetId: id, type: widgetState.type },
nodeData.id,
index
)
if (seenIdentities.has(identity.dedupeIdentity)) return
seenIdentities.add(identity.dedupeIdentity)
result.push({
advanced: mergedOptions.advanced ?? false,
advanced: widgetOptions.advanced ?? false,
handleContextMenu,
hasLayoutSize: widget.hasLayoutSize ?? false,
hasLayoutSize,
hasError: hasWidgetError(
widget,
{ name: widgetState.name, errorTarget },
nodeExecId,
nodeErrors,
executionErrorStore,
missingModelStore
),
hidden: mergedOptions.hidden ?? false,
widgetId: widget.widgetId,
name: widget.name,
renderKey,
type: widget.type,
hidden: widgetOptions.hidden ?? false,
widgetId: id,
name: widgetState.name,
renderKey: identity.renderKey,
type: widgetState.type,
vueComponent,
simplified,
value,
visible,
updateHandler,
tooltipConfig,
slotMetadata,
slotMetadata: slotInfo,
...(bareWidgetId === null ? {} : { id: bareWidgetId })
})
}
})
return result
}
export function useProcessedWidgets(
nodeDataGetter: () => VueNodeData | undefined
nodeDataGetter: () => VueNodeData | undefined,
widgetIdsGetter: () => readonly WidgetId[] | undefined = () => undefined
) {
const canvasStore = useCanvasStore()
const settingStore = useSettingStore()
@@ -436,6 +547,7 @@ export function useProcessedWidgets(
const processedWidgets = computed((): ProcessedWidget[] =>
computeProcessedWidgets({
nodeData: nodeDataGetter(),
widgetIds: widgetIdsGetter(),
graphId: canvasStore.canvas?.graph?.rootGraph.id,
showAdvanced: showAdvanced.value,
isGraphReady: app.isGraphReady,

View File

@@ -78,7 +78,9 @@ watch(() => canvasStore.currentGraph, bindWidget)
function draw() {
if (!widgetInstance || !node) return
const width = canvasEl.value.parentElement.clientWidth
const width =
canvasEl.value.parentElement.clientWidth ||
canvasEl.value.getBoundingClientRect().width
// Priority: computedHeight (from litegraph) > computeLayoutSize > computeSize
let height = 20
if (widgetInstance.computedHeight) {
@@ -126,7 +128,7 @@ function handleMove(e: PointerEvent) {
</script>
<template>
<div
class="relative mx-[-12px] min-w-0 basis-0"
class="relative mx-[-12px] w-full min-w-0"
:style="{ minHeight: `${containerHeight}px` }"
>
<canvas

View File

@@ -8,7 +8,6 @@ import {
shouldRenderAsVue,
FOR_TESTING
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
const {
WidgetButton,
@@ -134,7 +133,7 @@ describe('widgetRegistry', () => {
})
it('should respect options while checking type', () => {
const widget: Partial<SafeWidgetData> = {
const widget: { type: string; options: { canvasOnly: boolean } } = {
type: 'text',
options: { canvasOnly: false }
}

View File

@@ -4,7 +4,7 @@
import { defineAsyncComponent } from 'vue'
import type { Component } from 'vue'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
const WidgetButton = defineAsyncComponent(
() => import('../components/WidgetButton.vue')
@@ -268,7 +268,10 @@ export const isEssential = (type: string): boolean => {
return widgets.get(canonicalType)?.essential || false
}
export const shouldRenderAsVue = (widget: Partial<SafeWidgetData>): boolean => {
export const shouldRenderAsVue = (widget: {
options?: Pick<IWidgetOptions, 'canvasOnly'>
type?: string
}): boolean => {
return !widget.options?.canvasOnly && !!widget.type
}

View File

@@ -141,7 +141,7 @@ describe('useWidgetValueStore', () => {
expect(registered?.value).toBe(100)
})
it('getNodeWidgets returns all widgets for a node', () => {
it('getNodeWidgets returns widgets in registration order', () => {
const store = useWidgetValueStore()
store.registerWidget(
widgetId(graphA, toNodeId('node-1'), 'seed'),
@@ -157,8 +157,57 @@ describe('useWidgetValueStore', () => {
)
const widgets = store.getNodeWidgets(graphA, toNodeId('node-1'))
expect(widgets).toHaveLength(2)
expect(widgets.map((w) => w.name).sort()).toEqual(['seed', 'steps'])
expect(widgets.map((w) => w.name)).toEqual(['seed', 'steps'])
})
it('getNodeWidgetIds returns the explicit node widget order', () => {
const store = useWidgetValueStore()
const seed = widgetId(graphA, toNodeId('node-1'), 'seed')
const steps = widgetId(graphA, toNodeId('node-1'), 'steps')
const cfg = widgetId(graphA, toNodeId('node-1'), 'cfg')
store.registerWidget(seed, state('number', 1))
store.registerWidget(steps, state('number', 20))
store.registerWidget(cfg, state('number', 7))
store.setNodeWidgetOrder(graphA, toNodeId('node-1'), [cfg, seed])
expect(store.getNodeWidgetIds(graphA, toNodeId('node-1'))).toEqual([
cfg,
seed,
steps
])
expect(
store.getNodeWidgets(graphA, toNodeId('node-1')).map((w) => w.name)
).toEqual(['cfg', 'seed', 'steps'])
})
it('ignores widget IDs from other nodes when setting order', () => {
const store = useWidgetValueStore()
const seed = widgetId(graphA, toNodeId('node-1'), 'seed')
const other = widgetId(graphA, toNodeId('node-2'), 'cfg')
store.registerWidget(seed, state('number', 1))
store.registerWidget(other, state('number', 7))
store.setNodeWidgetOrder(graphA, toNodeId('node-1'), [other, seed])
expect(store.getNodeWidgetIds(graphA, toNodeId('node-1'))).toEqual([seed])
})
it('replaceNodeWidgetOrder prunes widgets missing from the live order', () => {
const store = useWidgetValueStore()
const seed = widgetId(graphA, toNodeId('node-1'), 'seed')
const steps = widgetId(graphA, toNodeId('node-1'), 'steps')
const cfg = widgetId(graphA, toNodeId('node-1'), 'cfg')
store.registerWidget(seed, state('number', 1))
store.registerWidget(steps, state('number', 20))
store.registerWidget(cfg, state('number', 7))
store.replaceNodeWidgetOrder(graphA, toNodeId('node-1'), [cfg, seed])
expect(store.getWidget(steps)).toBeUndefined()
expect(store.getNodeWidgetIds(graphA, toNodeId('node-1'))).toEqual([
cfg,
seed
])
})
})
@@ -174,12 +223,17 @@ describe('useWidgetValueStore', () => {
).toBe(false)
})
it('deleteWidget removes registered widgets', () => {
it('deleteWidget removes registered widgets from node order', () => {
const store = useWidgetValueStore()
const steps = widgetId(graphA, toNodeId('node-1'), 'steps')
store.registerWidget(seedA, state('number', 100))
store.registerWidget(steps, state('number', 20))
expect(store.deleteWidget(seedA)).toBe(true)
expect(store.getWidget(seedA)).toBeUndefined()
expect(store.getNodeWidgetIds(graphA, toNodeId('node-1'))).toEqual([
steps
])
expect(store.deleteWidget(seedA)).toBe(false)
})
})

View File

@@ -2,18 +2,35 @@ import { defineStore } from 'pinia'
import { reactive, ref } from 'vue'
import type { UUID } from '@/utils/uuid'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { parseNodeId } from '@/types/nodeId'
import type { NodeId, SerializedNodeId } from '@/types/nodeId'
import { parseWidgetId } from '@/types/widgetId'
import type { WidgetId } from '@/types/widgetId'
import type { WidgetState, WidgetStateInit } from '@/types/widgetState'
export interface WidgetRenderState {
advanced?: boolean
hasLayoutSize?: boolean
isDOMWidget?: boolean
tooltip?: string
}
export interface WidgetSpec {
spec: InputSpecV2
}
export function stripGraphPrefix(scopedId: SerializedNodeId): NodeId | null {
return parseNodeId(String(scopedId).replace(/^(.*:)+/, ''))
}
export const useWidgetValueStore = defineStore('widgetValue', () => {
const graphWidgetStates = ref(new Map<UUID, Map<WidgetId, WidgetState>>())
const graphWidgetRenderStates = ref(
new Map<UUID, Map<WidgetId, WidgetRenderState>>()
)
const graphWidgetSpecs = ref(new Map<UUID, Map<WidgetId, WidgetSpec>>())
const graphNodeWidgetOrders = ref(new Map<UUID, Map<NodeId, WidgetId[]>>())
function getGraphWidgetStates(graphId: UUID): Map<WidgetId, WidgetState> {
const widgetStates = graphWidgetStates.value.get(graphId)
@@ -24,12 +41,73 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
return nextWidgetStates
}
function getGraphWidgetRenderStates(
graphId: UUID
): Map<WidgetId, WidgetRenderState> {
const widgetRenderStates = graphWidgetRenderStates.value.get(graphId)
if (widgetRenderStates) return widgetRenderStates
const nextWidgetRenderStates = reactive(
new Map<WidgetId, WidgetRenderState>()
)
graphWidgetRenderStates.value.set(graphId, nextWidgetRenderStates)
return nextWidgetRenderStates
}
function getGraphWidgetSpecs(graphId: UUID): Map<WidgetId, WidgetSpec> {
const widgetSpecs = graphWidgetSpecs.value.get(graphId)
if (widgetSpecs) return widgetSpecs
const nextWidgetSpecs = reactive(new Map<WidgetId, WidgetSpec>())
graphWidgetSpecs.value.set(graphId, nextWidgetSpecs)
return nextWidgetSpecs
}
function getGraphNodeWidgetOrders(graphId: UUID): Map<NodeId, WidgetId[]> {
const widgetOrders = graphNodeWidgetOrders.value.get(graphId)
if (widgetOrders) return widgetOrders
const nextWidgetOrders = reactive(new Map<NodeId, WidgetId[]>())
graphNodeWidgetOrders.value.set(graphId, nextWidgetOrders)
return nextWidgetOrders
}
function getNodeWidgetOrder(graphId: UUID, nodeId: NodeId): WidgetId[] {
const graphOrders = getGraphNodeWidgetOrders(graphId)
const order = graphOrders.get(nodeId)
if (order) return order
const nextOrder = reactive<WidgetId[]>([])
graphOrders.set(nodeId, nextOrder)
return nextOrder
}
function appendNodeWidgetOrder(widgetId: WidgetId): void {
const { graphId, nodeId } = parseWidgetId(widgetId)
const order = getNodeWidgetOrder(graphId, nodeId)
if (!order.includes(widgetId)) order.push(widgetId)
}
function removeNodeWidgetOrder(widgetId: WidgetId): void {
const { graphId, nodeId } = parseWidgetId(widgetId)
const graphOrders = getGraphNodeWidgetOrders(graphId)
const order = graphOrders.get(nodeId)
if (!order) return
const index = order.indexOf(widgetId)
if (index !== -1) order.splice(index, 1)
if (order.length === 0) graphOrders.delete(nodeId)
}
function registerWidget<TValue = unknown>(
widgetId: WidgetId,
init: WidgetStateInit<TValue>
): WidgetState<TValue> {
const existing = getWidget(widgetId)
if (existing) return existing as WidgetState<TValue>
if (existing) {
appendNodeWidgetOrder(widgetId)
return existing as WidgetState<TValue>
}
const { graphId, nodeId, name } = parseWidgetId(widgetId)
const state: WidgetState<TValue> = {
@@ -40,14 +118,61 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
}
const widgetStates = getGraphWidgetStates(graphId)
widgetStates.set(widgetId, state)
appendNodeWidgetOrder(widgetId)
return widgetStates.get(widgetId) as WidgetState<TValue>
}
function registerWidgetRenderState(
widgetId: WidgetId,
init: WidgetRenderState
): WidgetRenderState {
const { graphId } = parseWidgetId(widgetId)
const widgetRenderStates = getGraphWidgetRenderStates(graphId)
const existing = widgetRenderStates.get(widgetId)
if (existing) {
Object.assign(existing, init)
return existing
}
const state: WidgetRenderState = { ...init }
widgetRenderStates.set(widgetId, state)
return widgetRenderStates.get(widgetId) as WidgetRenderState
}
function getWidget(widgetId: WidgetId): WidgetState | undefined {
const { graphId } = parseWidgetId(widgetId)
return getGraphWidgetStates(graphId).get(widgetId)
}
function registerWidgetSpec(
widgetId: WidgetId,
spec: InputSpecV2
): WidgetSpec {
const { graphId } = parseWidgetId(widgetId)
const widgetSpecs = getGraphWidgetSpecs(graphId)
const existing = widgetSpecs.get(widgetId)
if (existing) {
existing.spec = spec
return existing
}
const component: WidgetSpec = { spec }
widgetSpecs.set(widgetId, component)
return widgetSpecs.get(widgetId) as WidgetSpec
}
function getWidgetSpec(widgetId: WidgetId): WidgetSpec | undefined {
const { graphId } = parseWidgetId(widgetId)
return getGraphWidgetSpecs(graphId).get(widgetId)
}
function getWidgetRenderState(
widgetId: WidgetId
): WidgetRenderState | undefined {
const { graphId } = parseWidgetId(widgetId)
return getGraphWidgetRenderStates(graphId).get(widgetId)
}
function setValue(widgetId: WidgetId, value: WidgetState['value']): boolean {
const state = getWidget(widgetId)
if (!state) return false
@@ -57,25 +182,125 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
function deleteWidget(widgetId: WidgetId): boolean {
const { graphId } = parseWidgetId(widgetId)
getGraphWidgetRenderStates(graphId).delete(widgetId)
getGraphWidgetSpecs(graphId).delete(widgetId)
removeNodeWidgetOrder(widgetId)
return getGraphWidgetStates(graphId).delete(widgetId)
}
function getNodeWidgets(graphId: UUID, localNodeId: NodeId): WidgetState[] {
return [...getGraphWidgetStates(graphId).values()].filter(
(state) => state.nodeId === localNodeId
return getNodeWidgetIds(graphId, localNodeId).flatMap((id) => {
const state = getWidget(id)
return state ? [state] : []
})
}
function getRegisteredNodeWidgetIds(
graphId: UUID,
localNodeId: NodeId
): WidgetId[] {
const widgetStates = getGraphWidgetStates(graphId)
return [...widgetStates.entries()]
.filter(([, state]) => state.nodeId === localNodeId)
.map(([id]) => id)
}
function getOrderedRegisteredNodeWidgetIds(
registeredIds: readonly WidgetId[],
orderedWidgetIds: readonly WidgetId[]
): WidgetId[] {
const registeredIdSet = new Set(registeredIds)
return orderedWidgetIds.filter((id) => registeredIdSet.has(id))
}
function getRegisteredNodeWidgetOrder(
graphId: UUID,
localNodeId: NodeId,
orderedWidgetIds: readonly WidgetId[]
): WidgetId[] {
const registeredIds = getRegisteredNodeWidgetIds(graphId, localNodeId)
const orderedIds = getOrderedRegisteredNodeWidgetIds(
registeredIds,
orderedWidgetIds
)
const orderedIdSet = new Set(orderedIds)
return [
...orderedIds,
...registeredIds.filter((id) => !orderedIdSet.has(id))
]
}
function getNodeWidgetIds(graphId: UUID, localNodeId: NodeId): WidgetId[] {
const order = getNodeWidgetOrder(graphId, localNodeId)
const nextOrder = getRegisteredNodeWidgetOrder(graphId, localNodeId, order)
if (
nextOrder.length !== order.length ||
nextOrder.some((id, index) => id !== order[index])
) {
order.splice(0, order.length, ...nextOrder)
}
return [...order]
}
function setNodeWidgetOrder(
graphId: UUID,
localNodeId: NodeId,
orderedWidgetIds: readonly WidgetId[]
): void {
const nextOrder = getRegisteredNodeWidgetOrder(
graphId,
localNodeId,
orderedWidgetIds
)
const order = getNodeWidgetOrder(graphId, localNodeId)
order.splice(0, order.length, ...nextOrder)
}
function replaceNodeWidgetOrder(
graphId: UUID,
localNodeId: NodeId,
orderedWidgetIds: readonly WidgetId[]
): void {
const widgetStates = getGraphWidgetStates(graphId)
const registeredIds = getRegisteredNodeWidgetIds(graphId, localNodeId)
const nextOrder = getOrderedRegisteredNodeWidgetIds(
registeredIds,
orderedWidgetIds
)
const nextOrderSet = new Set(nextOrder)
for (const [id, state] of widgetStates.entries()) {
if (state.nodeId !== localNodeId || nextOrderSet.has(id)) continue
widgetStates.delete(id)
getGraphWidgetRenderStates(graphId).delete(id)
getGraphWidgetSpecs(graphId).delete(id)
}
const order = getNodeWidgetOrder(graphId, localNodeId)
order.splice(0, order.length, ...nextOrder)
}
function clearGraph(graphId: UUID): void {
graphWidgetStates.value.delete(graphId)
graphWidgetRenderStates.value.delete(graphId)
graphWidgetSpecs.value.delete(graphId)
graphNodeWidgetOrders.value.delete(graphId)
}
return {
registerWidget,
registerWidgetRenderState,
registerWidgetSpec,
getWidget,
getWidgetRenderState,
getWidgetSpec,
setValue,
deleteWidget,
getNodeWidgets,
getNodeWidgetIds,
setNodeWidgetOrder,
replaceNodeWidgetOrder,
clearGraph
}
})

View File

@@ -3,7 +3,11 @@
* Removes all DOM manipulation and positioning concerns
*/
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import { IS_CONTROL_WIDGET } from '@/scripts/controlWidgetMarker'
import type { NodeId } from '@/types/nodeId'
import type { NodeLocatorId } from '@/types/nodeIdentification'
@@ -30,7 +34,7 @@ function isControlOption(val: WidgetValue): val is ControlOptions {
return CONTROL_OPTIONS.includes(val as ControlOptions)
}
export function normalizeControlOption(val: WidgetValue): ControlOptions {
function normalizeControlOption(val: WidgetValue): ControlOptions {
if (isControlOption(val)) return val
return 'randomize'
}
@@ -40,6 +44,17 @@ export type SafeControlWidget = {
update: (value: WidgetValue) => void
}
export function getControlWidget(
widget: IBaseWidget
): SafeControlWidget | undefined {
const controlWidget = widget.linkedWidgets?.find((w) => w[IS_CONTROL_WIDGET])
if (!controlWidget) return
return {
value: normalizeControlOption(controlWidget.value),
update: (value) => (controlWidget.value = normalizeControlOption(value))
}
}
export interface LinkedUpstreamInfo {
nodeId: NodeId
outputName?: string

View File

@@ -19,7 +19,6 @@ export interface WidgetState<
| 'disabled'
| 'y'
> {
isDOMWidget?: boolean
nodeId: NodeId
}

View File

@@ -1,10 +1,16 @@
import { describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { LGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { applyTextReplacements } from '@/utils/searchAndReplace'
describe('applyTextReplacements', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
// Test specifically the filename sanitization part
describe('filename sanitization', () => {
it('should replace invalid filename characters with underscores', () => {

View File

@@ -9,7 +9,6 @@ import { computed, useTemplateRef } from 'vue'
import AppBuilder from '@/components/builder/AppBuilder.vue'
import AppModeToolbar from '@/components/appMode/AppModeToolbar.vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import ErrorOverlay from '@/components/error/ErrorOverlay.vue'
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
@@ -165,7 +164,6 @@ function dragDrop(e: DragEvent) {
</div>
<div ref="bottomLeftRef" class="absolute bottom-7 left-4 z-20" />
<div ref="bottomRightRef" class="absolute right-4 bottom-7 z-20" />
<div class="absolute top-4 right-4 z-20"><ErrorOverlay app-mode /></div>
</SplitterPanel>
<SplitterPanel
v-if="hasRightPanel"

View File

@@ -4,7 +4,7 @@ import { app } from '../../scripts/app.js'
function legacyWidget(node, inputName, inputData) {
if (!node.widgets) node.widgets = []
node.widgets.push({
const widget = {
draw: function (ctx, node, widget_width, y, H) {
ctx.save()
ctx.fillStyle = '#7F7'
@@ -24,7 +24,9 @@ function legacyWidget(node, inputName, inputData) {
type: 'DEVTOOLS.LEGACYWIDGET',
value: 0,
y: 0
})
}
node.widgets.push(widget)
return { widget }
}
app.registerExtension({