mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-03 13:48:49 +00:00
Compare commits
24 Commits
shihchi/co
...
drjkl/prev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c97532afc | ||
|
|
9eb5f0a212 | ||
|
|
783a7cefbc | ||
|
|
079b620555 | ||
|
|
624963c37a | ||
|
|
0c759912e3 | ||
|
|
f210590bdf | ||
|
|
bc918fef11 | ||
|
|
dfdf78f0d3 | ||
|
|
7cbdb94b3f | ||
|
|
9cf5c9a93f | ||
|
|
7907985db8 | ||
|
|
24258bf1a8 | ||
|
|
9e5fb67b76 | ||
|
|
61b87a467d | ||
|
|
6be63bd50c | ||
|
|
4f9077dd98 | ||
|
|
61658f604b | ||
|
|
54e688f912 | ||
|
|
638f0332b4 | ||
|
|
26a53d7d2c | ||
|
|
b45320ab3d | ||
|
|
34c11a07db | ||
|
|
c64b8678ec |
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
45
browser_tests/assets/linear-validation-warning.json
Normal file
45
browser_tests/assets/linear-validation-warning.json
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: {
|
||||
|
||||
106
browser_tests/tests/appModeValidationWarning.spec.ts
Normal file
106
browser_tests/tests/appModeValidationWarning.spec.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
105
src/composables/useViewErrorsInGraph.test.ts
Normal file
105
src/composables/useViewErrorsInGraph.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
22
src/composables/useViewErrorsInGraph.ts
Normal file
22
src/composables/useViewErrorsInGraph.ts
Normal 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 }
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
208
src/renderer/extensions/linearMode/LinearControls.test.ts
Normal file
208
src/renderer/extensions/linearMode/LinearControls.test.ts
Normal 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
63
src/renderer/extensions/linearMode/LinearRunErrorWarning.vue
Normal file
63
src/renderer/extensions/linearMode/LinearRunErrorWarning.vue
Normal 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>
|
||||
@@ -0,0 +1,2 @@
|
||||
export const LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID =
|
||||
'linear-run-error-warning'
|
||||
@@ -85,7 +85,6 @@ describe('Vue Node - Subgraph Functionality', () => {
|
||||
selected: false,
|
||||
executing: false,
|
||||
subgraphId,
|
||||
widgets: [],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
hasErrors: false,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -26,7 +26,6 @@ const makeNodeData = (overrides: Partial<VueNodeData> = {}): VueNodeData => ({
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
widgets: [],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
flags: { collapsed: false },
|
||||
|
||||
@@ -38,7 +38,6 @@ const makeNodeData = (overrides: Partial<VueNodeData> = {}): VueNodeData => ({
|
||||
executing: false,
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [],
|
||||
flags: { collapsed: false },
|
||||
...overrides
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
89
src/renderer/extensions/vueNodes/components/WidgetGrid.vue
Normal file
89
src/renderer/extensions/vueNodes/components/WidgetGrid.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div
|
||||
data-testid="node-widgets"
|
||||
class="lg-node-widgets grid grid-cols-[min-content_minmax(80px,min-content)_minmax(125px,1fr)] gap-y-1 pr-3"
|
||||
:style="{
|
||||
'grid-template-rows': gridTemplateRows,
|
||||
flex: gridTemplateRows.includes('auto') ? 1 : undefined
|
||||
}"
|
||||
>
|
||||
<template v-for="widget in processedWidgets" :key="widget.renderKey">
|
||||
<div
|
||||
v-if="widget.visible"
|
||||
data-testid="node-widget"
|
||||
class="lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch"
|
||||
>
|
||||
<!-- Widget Input Slot Dot -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'z-10 flex w-3 items-stretch opacity-0 transition-opacity duration-150 group-hover:opacity-100',
|
||||
widget.slotMetadata?.linked && 'opacity-100'
|
||||
)
|
||||
"
|
||||
>
|
||||
<InputSlot
|
||||
v-if="widget.slotMetadata"
|
||||
:key="`widget-slot-${widget.name}-${widget.slotMetadata.index}`"
|
||||
:slot-data="{
|
||||
name: widget.name,
|
||||
type: widget.slotMetadata.type,
|
||||
boundingRect: [0, 0, 0, 0]
|
||||
}"
|
||||
:node-id="nodeId"
|
||||
:has-error="widget.hasError"
|
||||
:index="widget.slotMetadata.index"
|
||||
:socketless="widget.simplified.spec?.socketless"
|
||||
dot-only
|
||||
/>
|
||||
</div>
|
||||
<!-- Widget Component -->
|
||||
<AppInput
|
||||
:widget-id="widget.widgetId"
|
||||
:name="widget.name"
|
||||
:enable="canSelectInputs && !widget.simplified.options?.disabled"
|
||||
>
|
||||
<component
|
||||
:is="widget.vueComponent"
|
||||
v-model="widget.value"
|
||||
v-tooltip.left="widget.tooltipConfig"
|
||||
:widget="widget.simplified"
|
||||
:node-id="nodeId"
|
||||
:node-type="nodeType"
|
||||
:class="
|
||||
cn(
|
||||
'col-span-2',
|
||||
widget.hasError && 'font-bold text-node-stroke-error'
|
||||
)
|
||||
"
|
||||
@update:model-value="widget.updateHandler"
|
||||
@contextmenu="widget.handleContextMenu"
|
||||
/>
|
||||
</AppInput>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ProcessedWidget } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
|
||||
import AppInput from '@/renderer/extensions/linearMode/AppInput.vue'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import InputSlot from './InputSlot.vue'
|
||||
|
||||
const {
|
||||
processedWidgets,
|
||||
gridTemplateRows,
|
||||
nodeType,
|
||||
canSelectInputs = false,
|
||||
nodeId
|
||||
} = defineProps<{
|
||||
processedWidgets: ProcessedWidget[]
|
||||
gridTemplateRows: string
|
||||
nodeType: string
|
||||
canSelectInputs?: boolean
|
||||
nodeId?: NodeId
|
||||
}>()
|
||||
</script>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,7 +19,6 @@ export interface WidgetState<
|
||||
| 'disabled'
|
||||
| 'y'
|
||||
> {
|
||||
isDOMWidget?: boolean
|
||||
nodeId: NodeId
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user