diff --git a/browser_tests/assets/vueNodes/linked-standard-widgets.json b/browser_tests/assets/vueNodes/linked-standard-widgets.json
new file mode 100644
index 00000000000..a85a0299345
--- /dev/null
+++ b/browser_tests/assets/vueNodes/linked-standard-widgets.json
@@ -0,0 +1,134 @@
+{
+ "id": "bc8526ee-e7ce-4b81-9a55-12a34d43cc3a",
+ "revision": 0,
+ "last_node_id": 2,
+ "last_link_id": 9,
+ "nodes": [
+ {
+ "id": 1,
+ "type": "DevToolsLinkedStandardWidgetSource",
+ "pos": [-1600, 100],
+ "size": [360, 270],
+ "flags": {},
+ "order": 0,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ { "name": "plain_text", "type": "STRING", "links": [1] },
+ { "name": "integer", "type": "INT", "links": [2] },
+ { "name": "number", "type": "FLOAT", "links": [3] },
+ { "name": "slider", "type": "FLOAT", "links": [4] },
+ { "name": "switch", "type": "BOOLEAN", "links": [5] },
+ { "name": "labeled_toggle", "type": "BOOLEAN", "links": [6] },
+ { "name": "select", "type": "COMBO", "links": [7] },
+ { "name": "color", "type": "COLOR", "links": [8] },
+ { "name": "textarea", "type": "STRING", "links": [9] }
+ ],
+ "properties": {
+ "Node name for S&R": "DevToolsLinkedStandardWidgetSource"
+ },
+ "widgets_values": []
+ },
+ {
+ "id": 2,
+ "type": "DevToolsLinkedStandardWidgets",
+ "pos": [200, 100],
+ "size": [560, 620],
+ "flags": {},
+ "order": 1,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "plain_text",
+ "type": "STRING",
+ "widget": { "name": "plain_text" },
+ "link": 1
+ },
+ {
+ "name": "integer",
+ "type": "INT",
+ "widget": { "name": "integer" },
+ "link": 2
+ },
+ {
+ "name": "number",
+ "type": "FLOAT",
+ "widget": { "name": "number" },
+ "link": 3
+ },
+ {
+ "name": "slider",
+ "type": "FLOAT",
+ "widget": { "name": "slider" },
+ "link": 4
+ },
+ {
+ "name": "switch",
+ "type": "BOOLEAN",
+ "widget": { "name": "switch" },
+ "link": 5
+ },
+ {
+ "name": "labeled_toggle",
+ "type": "BOOLEAN",
+ "widget": { "name": "labeled_toggle" },
+ "link": 6
+ },
+ {
+ "name": "select",
+ "type": "COMBO",
+ "widget": { "name": "select" },
+ "link": 7
+ },
+ {
+ "name": "color",
+ "type": "COLOR",
+ "widget": { "name": "color" },
+ "link": 8
+ },
+ {
+ "name": "textarea",
+ "type": "STRING",
+ "widget": { "name": "textarea" },
+ "link": 9
+ }
+ ],
+ "outputs": [],
+ "properties": {
+ "Node name for S&R": "DevToolsLinkedStandardWidgets"
+ },
+ "widgets_values": [
+ "STALE PLAIN TEXT",
+ 314,
+ 9.875,
+ 0.73,
+ true,
+ false,
+ "STALE SELECT VALUE",
+ "#22c55d",
+ "STALE MULTILINE PROMPT"
+ ]
+ }
+ ],
+ "links": [
+ [1, 1, 0, 2, 0, "STRING"],
+ [2, 1, 1, 2, 1, "INT"],
+ [3, 1, 2, 2, 2, "FLOAT"],
+ [4, 1, 3, 2, 3, "FLOAT"],
+ [5, 1, 4, 2, 4, "BOOLEAN"],
+ [6, 1, 5, 2, 5, "BOOLEAN"],
+ [7, 1, 6, 2, 6, "COMBO"],
+ [8, 1, 7, 2, 7, "COLOR"],
+ [9, 1, 8, 2, 8, "STRING"]
+ ],
+ "groups": [],
+ "config": {},
+ "extra": {
+ "ds": {
+ "scale": 1,
+ "offset": [0, 0]
+ },
+ "frontendVersion": "1.28.6"
+ },
+ "version": 0.4
+}
diff --git a/browser_tests/assets/widgets/linked_multiline_string.json b/browser_tests/assets/widgets/linked_multiline_string.json
new file mode 100644
index 00000000000..87573f64630
--- /dev/null
+++ b/browser_tests/assets/widgets/linked_multiline_string.json
@@ -0,0 +1,72 @@
+{
+ "last_node_id": 2,
+ "last_link_id": 1,
+ "nodes": [
+ {
+ "id": 1,
+ "type": "CLIPTextEncode",
+ "pos": [440, 80],
+ "size": [520, 280],
+ "flags": {},
+ "order": 1,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "clip",
+ "type": "CLIP",
+ "link": null
+ },
+ {
+ "name": "text",
+ "type": "STRING",
+ "widget": {
+ "name": "text"
+ },
+ "link": 1
+ }
+ ],
+ "outputs": [
+ {
+ "name": "CONDITIONING",
+ "type": "CONDITIONING",
+ "links": null
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "CLIPTextEncode"
+ },
+ "widgets_values": ["stale local prompt"]
+ },
+ {
+ "id": 2,
+ "type": "PrimitiveString",
+ "pos": [40, 120],
+ "size": [315, 58],
+ "flags": {},
+ "order": 0,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "STRING",
+ "type": "STRING",
+ "links": [1]
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "PrimitiveString"
+ },
+ "widgets_values": ["authoritative upstream prompt"]
+ }
+ ],
+ "links": [[1, 2, 0, 1, 1, "STRING"]],
+ "groups": [],
+ "config": {},
+ "extra": {
+ "ds": {
+ "offset": [0, 0],
+ "scale": 1
+ }
+ },
+ "version": 0.4
+}
diff --git a/browser_tests/fixtures/selectors.ts b/browser_tests/fixtures/selectors.ts
index f6bddedea6e..49249dcd341 100644
--- a/browser_tests/fixtures/selectors.ts
+++ b/browser_tests/fixtures/selectors.ts
@@ -168,6 +168,8 @@ export const TestIds = {
widgets: {
container: 'node-widgets',
widget: 'node-widget',
+ linkedContent: 'linked-widget-content',
+ linkedPlaceholder: 'linked-widget-placeholder',
layoutFieldLabel: 'widget-layout-field-label',
formDropdownMenu: 'form-dropdown-menu',
decrement: 'decrement',
diff --git a/browser_tests/fixtures/utils/promotedMissingModel.ts b/browser_tests/fixtures/utils/promotedMissingModel.ts
index 1984e118701..9b13e1788ac 100644
--- a/browser_tests/fixtures/utils/promotedMissingModel.ts
+++ b/browser_tests/fixtures/utils/promotedMissingModel.ts
@@ -235,18 +235,23 @@ export async function expectResolvedPromotedModelSuppressesStaleInteriorErrors(
const node = comfyPage.vueNodes.getNodeByTitle(step.nodeTitle)
await expect(node).toBeVisible()
- const staleCombo = node.getByRole('combobox', {
- name: PROMOTED_MODEL_WIDGET_NAME,
+ const linkedStatus = node.getByRole('img', {
+ name: `${PROMOTED_MODEL_WIDGET_NAME}: Linked input`,
exact: true
})
await expect(
- staleCombo,
- `${step.nodeTitle} should expose the stale linked interior widget`
- ).toBeDisabled()
+ linkedStatus,
+ `${step.nodeTitle} should expose the linked interior status`
+ ).toBeVisible()
+ const staleValue = node.getByText(staleModelName, { exact: true })
await expect(
- staleCombo,
- `${step.nodeTitle} should keep the stale interior value`
- ).toContainText(staleModelName)
+ staleValue,
+ `${step.nodeTitle} should retain the stale interior value`
+ ).toBeAttached()
+ await expect(
+ staleValue,
+ `${step.nodeTitle} should hide the stale interior value`
+ ).toBeHidden()
await expectNoMissingModelUi(comfyPage)
}
}
diff --git a/browser_tests/tests/subgraph/subgraphNested.spec.ts b/browser_tests/tests/subgraph/subgraphNested.spec.ts
index 7b30330e96a..ec894f134f0 100644
--- a/browser_tests/tests/subgraph/subgraphNested.spec.ts
+++ b/browser_tests/tests/subgraph/subgraphNested.spec.ts
@@ -64,13 +64,21 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
await comfyExpect(innerNode).toBeVisible()
const innerTextboxes = innerNode.getByRole('textbox')
- await comfyExpect(innerTextboxes).toHaveCount(2)
- const innerValues = await innerTextboxes.evaluateAll<
- string[],
- HTMLInputElement
- >((boxes) => boxes.map((b) => b.value))
- comfyExpect(innerValues).toContain('11111111111')
- comfyExpect(innerValues).toContain('22222222222')
+ await comfyExpect(innerTextboxes).toHaveCount(1)
+ await comfyExpect(innerTextboxes).toHaveValue('11111111111')
+
+ await comfyExpect(
+ innerNode.getByRole('img', {
+ name: 'text_1: Linked input',
+ exact: true
+ })
+ ).toBeVisible()
+ const linkedTextbox = innerNode.getByTestId(
+ TestIds.widgets.linkedContent
+ )
+ await comfyExpect(linkedTextbox).toHaveCount(1)
+ await comfyExpect(linkedTextbox).toHaveValue('22222222222')
+ await comfyExpect(linkedTextbox).toBeHidden()
})
}
)
diff --git a/browser_tests/tests/subgraph/subgraphPromotion.spec.ts b/browser_tests/tests/subgraph/subgraphPromotion.spec.ts
index b7ebc8fea3d..d9dae3f6ecf 100644
--- a/browser_tests/tests/subgraph/subgraphPromotion.spec.ts
+++ b/browser_tests/tests/subgraph/subgraphPromotion.spec.ts
@@ -377,12 +377,18 @@ test.describe(
.toEqual(expect.arrayContaining(['string_a', 'value']))
const subgraphNode = comfyPage.vueNodes.getNodeLocator('5')
- const linkedTextarea = subgraphNode.getByRole('textbox', {
- name: 'string_a',
- exact: true
- })
- await expect(linkedTextarea).toBeVisible()
+ await expect(
+ subgraphNode.getByRole('img', {
+ name: 'string_a: Linked input',
+ exact: true
+ })
+ ).toBeVisible()
+ const linkedTextarea = subgraphNode.getByTestId(
+ TestIds.widgets.linkedContent
+ )
+ await expect(linkedTextarea).toHaveCount(1)
await expect(linkedTextarea).toBeDisabled()
+ await expect(linkedTextarea).toBeHidden()
const allTextareas = subgraphNode.getByRole('textbox')
await expect(allTextareas.first()).toBeVisible()
diff --git a/browser_tests/tests/subgraph/subgraphPromotionDom.spec.ts b/browser_tests/tests/subgraph/subgraphPromotionDom.spec.ts
index eb44157d899..e894afa4a53 100644
--- a/browser_tests/tests/subgraph/subgraphPromotionDom.spec.ts
+++ b/browser_tests/tests/subgraph/subgraphPromotionDom.spec.ts
@@ -152,10 +152,15 @@ test.describe(
await openSubgraphById(comfyPage, '11')
- const interiorTextareas = comfyPage.page
- .locator('[data-node-id]')
- .getByRole('textbox')
- await expect(interiorTextareas).toHaveCount(2)
+ const interiorNodes = comfyPage.page.locator('[data-node-id]')
+ await expect(interiorNodes.locator('textarea')).toHaveCount(2)
+ await expect(interiorNodes.getByRole('textbox')).toHaveCount(0)
+ await expect(
+ interiorNodes.getByRole('img', {
+ name: 'text: Linked input',
+ exact: true
+ })
+ ).toHaveCount(2)
await comfyPage.subgraph.exitViaBreadcrumb()
diff --git a/browser_tests/tests/subgraph/subgraphSerialization.spec.ts b/browser_tests/tests/subgraph/subgraphSerialization.spec.ts
index ebaae234d91..127bc57b92c 100644
--- a/browser_tests/tests/subgraph/subgraphSerialization.spec.ts
+++ b/browser_tests/tests/subgraph/subgraphSerialization.spec.ts
@@ -733,11 +733,16 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(outerNode).toBeVisible()
- const textarea = outerNode
- .getByRole('textbox', { name: 'string_a' })
- .first()
- await expect(textarea).toBeVisible()
+ await expect(
+ outerNode.getByRole('img', {
+ name: 'string_a: Linked input',
+ exact: true
+ })
+ ).toBeVisible()
+ const textarea = outerNode.getByTestId(TestIds.widgets.linkedContent)
+ await expect(textarea).toHaveCount(1)
await expect(textarea).toBeDisabled()
+ await expect(textarea).toBeHidden()
})
test('No legacy-prefixed or disconnected widgets remain on the node', async ({
diff --git a/browser_tests/tests/subgraph/subgraphSlots.spec.ts b/browser_tests/tests/subgraph/subgraphSlots.spec.ts
index a10b9cbfd17..a5bdb8de864 100644
--- a/browser_tests/tests/subgraph/subgraphSlots.spec.ts
+++ b/browser_tests/tests/subgraph/subgraphSlots.spec.ts
@@ -419,9 +419,12 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
const subgraphNode = comfyPage.vueNodes.getNodeLocator('19')
await expect(subgraphNode).toBeVisible()
- const seedWidget = subgraphNode.getByLabel('seed', { exact: true })
- await expect(seedWidget).toBeVisible()
- await SubgraphHelper.expectWidgetBelowHeader(subgraphNode, seedWidget)
+ const seedStatus = subgraphNode.getByRole('img', {
+ name: 'renamed_seed: Linked input',
+ exact: true
+ })
+ await expect(seedStatus).toBeVisible()
+ await SubgraphHelper.expectWidgetBelowHeader(subgraphNode, seedStatus)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
@@ -475,16 +478,17 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
)
.toBe(RENAMED_LABEL)
- const seedWidgetAfter = subgraphNodeAfter.getByLabel('seed', {
+ const seedStatusAfter = subgraphNodeAfter.getByRole('img', {
+ name: `${RENAMED_LABEL}: Linked input`,
exact: true
})
- await expect(seedWidgetAfter).toBeVisible()
+ await expect(seedStatusAfter).toBeVisible()
await expect(
subgraphNodeAfter.getByText(RENAMED_LABEL, { exact: true })
).toBeVisible()
await SubgraphHelper.expectWidgetBelowHeader(
subgraphNodeAfter,
- seedWidgetAfter
+ seedStatusAfter
)
})
})
diff --git a/browser_tests/tests/vueNodes/widgets/advancedWidgets.spec.ts b/browser_tests/tests/vueNodes/widgets/advancedWidgets.spec.ts
index 6452587bd71..88a7a7e1d9e 100644
--- a/browser_tests/tests/vueNodes/widgets/advancedWidgets.spec.ts
+++ b/browser_tests/tests/vueNodes/widgets/advancedWidgets.spec.ts
@@ -87,7 +87,7 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
await expect(widgets).toHaveCount(2)
})
- test('should keep connected advanced widgets visible when advanced inputs are hidden', async ({
+ test('should show linked status for connected advanced widgets when advanced inputs are hidden', async ({
comfyPage
}) => {
const node = getNode(comfyPage)
@@ -124,7 +124,10 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
await node.getByText(HIDE_ADVANCED_INPUTS).click()
- await expect(maxShiftWidget).toBeVisible()
+ await expect(
+ node.getByRole('img', { name: 'max_shift: Linked input' })
+ ).toBeVisible()
+ await expect(maxShiftWidget).toBeHidden()
await expect(baseShiftWidget).toBeHidden()
})
diff --git a/browser_tests/tests/vueNodes/widgets/int/integerWidget.spec.ts b/browser_tests/tests/vueNodes/widgets/int/integerWidget.spec.ts
index 985d9f17d05..ac618bf12ce 100644
--- a/browser_tests/tests/vueNodes/widgets/int/integerWidget.spec.ts
+++ b/browser_tests/tests/vueNodes/widgets/int/integerWidget.spec.ts
@@ -2,41 +2,58 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
+import { TestIds } from '@e2e/fixtures/selectors'
test.describe('Vue Integer Widget', { tag: '@vue-nodes' }, () => {
- test('should be disabled and not allow changing value when link connected to slot', async ({
+ test('should hide a linked value and restore the control after disconnect', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('vueNodes/linked-int-widget')
- const seedWidget = comfyPage.vueNodes
- .getWidgetByName('KSampler', 'seed')
- .first()
- const controls = comfyPage.vueNodes.getInputNumberControls(seedWidget)
- const initialValue = Number(await controls.input.inputValue())
-
- // Verify widget is disabled when linked
- await expect(controls.incrementButton).toBeDisabled()
- await expect(controls.decrementButton).toBeDisabled()
- await expect(controls.input).toHaveValue(initialValue.toString())
+ const samplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler').first()
+ const placeholder = samplerNode.getByTestId(
+ TestIds.widgets.linkedPlaceholder
+ )
+ const linkedContent = samplerNode.getByTestId(TestIds.widgets.linkedContent)
+ const hiddenInput = linkedContent.locator('input[role="spinbutton"]')
+ const nodeBounds = await samplerNode.boundingBox()
+ const [samplerNodeRef] =
+ await comfyPage.nodeOps.getNodeRefsByType('KSampler')
+ if (!samplerNodeRef || !nodeBounds) {
+ throw new Error('Linked KSampler node did not render')
+ }
+ const seedWidgetRef = await samplerNodeRef.getWidgetByName('seed')
+ const initialValue = Number(await seedWidgetRef.getValue())
- await expect(seedWidget).toBeVisible()
+ await expect(placeholder).toHaveAccessibleName('seed: Linked input')
+ await expect(linkedContent).toHaveAttribute('inert', '')
+ await expect(hiddenInput).toBeDisabled()
+ await expect(
+ samplerNode.getByRole('spinbutton', { name: 'seed' })
+ ).toHaveCount(0)
+ await placeholder.click()
+ await expect.poll(() => seedWidgetRef.getValue()).toBe(initialValue)
- // Delete the node that is linked to the slot (freeing up the widget)
- // Click on the header to select the node (clicking center may land on
- // the widget area where pointerdown.stop prevents node selection)
await comfyPage.vueNodes
.getNodeByTitle('Int')
.locator('.lg-node-header')
.click()
await comfyPage.vueNodes.deleteSelected()
- // Test widget works when unlinked
+ await expect(placeholder).toHaveCount(0)
+ const seedWidget = comfyPage.vueNodes
+ .getWidgetByName('KSampler', 'seed')
+ .first()
+ const controls = comfyPage.vueNodes.getInputNumberControls(seedWidget)
+ await expect(controls.input).toHaveValue(initialValue.toString())
await controls.incrementButton.click()
await expect(controls.input).toHaveValue((initialValue + 1).toString())
await controls.decrementButton.click()
await expect(controls.input).toHaveValue(initialValue.toString())
+ await expect
+ .poll(async () => (await samplerNode.boundingBox())?.height)
+ .toBeCloseTo(nodeBounds.height, 0)
})
test('displays control widgets with default state', async ({ comfyPage }) => {
diff --git a/browser_tests/tests/vueNodes/widgets/linked/linkedStandardWidgets.spec.ts b/browser_tests/tests/vueNodes/widgets/linked/linkedStandardWidgets.spec.ts
new file mode 100644
index 00000000000..1534ec6ca1e
--- /dev/null
+++ b/browser_tests/tests/vueNodes/widgets/linked/linkedStandardWidgets.spec.ts
@@ -0,0 +1,175 @@
+import {
+ comfyExpect as expect,
+ comfyPageFixture as test
+} from '@e2e/fixtures/ComfyPage'
+import { TestIds } from '@e2e/fixtures/selectors'
+
+const TARGET_NODE_TYPE = 'DevToolsLinkedStandardWidgets'
+const TARGET_NODE_TITLE = 'Linked Standard Widgets'
+const WIDGET_NAMES = [
+ 'plain_text',
+ 'integer',
+ 'number',
+ 'slider',
+ 'switch',
+ 'labeled_toggle',
+ 'select',
+ 'color',
+ 'textarea'
+] as const
+
+test.describe(
+ 'Linked standard Vue widgets',
+ { tag: ['@vue-nodes', '@widget', '@node'] },
+ () => {
+ test.beforeEach(async ({ comfyPage }) => {
+ await comfyPage.page.setViewportSize({ width: 1280, height: 900 })
+ await comfyPage.workflow.loadWorkflow('vueNodes/linked-standard-widgets')
+ })
+
+ test(
+ 'renders each linked control surface',
+ { tag: '@screenshot' },
+ async ({ comfyPage }) => {
+ const [targetNodeRef] =
+ await comfyPage.nodeOps.getNodeRefsByType(TARGET_NODE_TYPE)
+ if (!targetNodeRef)
+ throw new Error('Target DevTools node was not loaded')
+ await targetNodeRef.centerOnNode()
+
+ const targetNode = comfyPage.vueNodes
+ .getNodeByTitle(TARGET_NODE_TITLE)
+ .first()
+ await expect(targetNode).toBeVisible()
+
+ const statuses = targetNode.getByTestId(
+ TestIds.widgets.linkedPlaceholder
+ )
+ await expect(statuses).toHaveCount(WIDGET_NAMES.length)
+
+ for (const name of WIDGET_NAMES) {
+ await expect(
+ targetNode.getByRole('img', {
+ name: `${name}: Linked input`
+ })
+ ).toBeVisible()
+ }
+
+ await comfyPage.nextFrame()
+ await comfyPage.nextFrame()
+ await expect(targetNode).toHaveScreenshot('linked-standard-widgets.png')
+ }
+ )
+
+ test('keeps linked controls inaccessible to focus', async ({
+ comfyPage
+ }) => {
+ const targetNode = comfyPage.vueNodes
+ .getNodeByTitle(TARGET_NODE_TITLE)
+ .first()
+ await expect(targetNode).toBeVisible()
+
+ const linkedContent = targetNode.getByTestId(
+ TestIds.widgets.linkedContent
+ )
+ await expect(linkedContent).toHaveCount(WIDGET_NAMES.length)
+
+ for (let index = 0; index < WIDGET_NAMES.length; index++) {
+ await expect(linkedContent.nth(index)).toHaveAttribute('inert', '')
+ await expect(linkedContent.nth(index)).toHaveAttribute(
+ 'aria-hidden',
+ 'true'
+ )
+ }
+
+ const interactive = targetNode.locator(
+ `[data-testid="${TestIds.widgets.linkedContent}"]:is(input, textarea, button, [role="slider"]), ` +
+ `[data-testid="${TestIds.widgets.linkedContent}"] :is(input, textarea, button, [role="slider"])`
+ )
+ const interactiveCount = await interactive.count()
+ expect(interactiveCount).toBeGreaterThan(WIDGET_NAMES.length)
+
+ const controlStates = await interactive.evaluateAll((elements) =>
+ elements.map((element) => {
+ const disabled =
+ element instanceof HTMLButtonElement ||
+ element instanceof HTMLInputElement ||
+ element instanceof HTMLTextAreaElement
+ ? element.disabled
+ : element.getAttribute('aria-disabled') === 'true' ||
+ element.hasAttribute('data-disabled')
+
+ if (element instanceof HTMLElement) element.focus()
+
+ return {
+ disabled,
+ focused: document.activeElement === element
+ }
+ })
+ )
+ expect(controlStates.every(({ disabled }) => disabled)).toBe(true)
+ expect(controlStates.every(({ focused }) => !focused)).toBe(true)
+
+ for (let index = 0; index < interactiveCount; index++) {
+ await expect(interactive.nth(index)).toBeHidden()
+ }
+ })
+
+ test('ignores pointer and keyboard input on linked controls', async ({
+ comfyPage
+ }) => {
+ const [targetNodeRef] =
+ await comfyPage.nodeOps.getNodeRefsByType(TARGET_NODE_TYPE)
+ if (!targetNodeRef) throw new Error('Target DevTools node was not loaded')
+
+ const targetNode = comfyPage.vueNodes
+ .getNodeByTitle(TARGET_NODE_TITLE)
+ .first()
+ await expect(targetNode).toBeVisible()
+
+ const focusedLinkedContent = targetNode.locator(
+ `[data-testid="${TestIds.widgets.linkedContent}"]:focus-within`
+ )
+
+ for (const text of [
+ 'STALE SELECT VALUE',
+ 'STALE ON',
+ 'STALE OFF',
+ '#22c55d'
+ ]) {
+ const staleValue = targetNode.getByText(text, { exact: true })
+ await expect(staleValue).toBeAttached()
+ await expect(staleValue).toBeHidden()
+ }
+
+ const widgetRefs = await Promise.all(
+ WIDGET_NAMES.map((name) => targetNodeRef.getWidgetByName(name))
+ )
+ const valuesBefore = await Promise.all(
+ widgetRefs.map((widget) => widget.getValue())
+ )
+
+ await targetNode
+ .getByRole('img', { name: 'switch: Linked input' })
+ .click()
+ await comfyPage.page.keyboard.press('Space')
+
+ await targetNode
+ .getByRole('img', { name: 'select: Linked input' })
+ .click()
+ await comfyPage.page.keyboard.press('ArrowUp')
+ await comfyPage.page.keyboard.press('Enter')
+
+ await targetNode
+ .getByRole('img', { name: 'textarea: Linked input' })
+ .click()
+ await comfyPage.page.keyboard.type('ignored input')
+ await expect(focusedLinkedContent).toHaveCount(0)
+
+ await comfyPage.nextFrame()
+ await expect
+ .poll(() => Promise.all(widgetRefs.map((widget) => widget.getValue())))
+ .toStrictEqual(valuesBefore)
+ })
+ }
+)
diff --git a/browser_tests/tests/vueNodes/widgets/linked/linkedStandardWidgets.spec.ts-snapshots/linked-standard-widgets-chromium-linux.png b/browser_tests/tests/vueNodes/widgets/linked/linkedStandardWidgets.spec.ts-snapshots/linked-standard-widgets-chromium-linux.png
new file mode 100644
index 00000000000..3489e0af2f6
Binary files /dev/null and b/browser_tests/tests/vueNodes/widgets/linked/linkedStandardWidgets.spec.ts-snapshots/linked-standard-widgets-chromium-linux.png differ
diff --git a/browser_tests/tests/vueNodes/widgets/text/lockedWidgetHoverColor.spec.ts-snapshots/locked-widget-hover-color-chromium-linux.png b/browser_tests/tests/vueNodes/widgets/text/lockedWidgetHoverColor.spec.ts-snapshots/locked-widget-hover-color-chromium-linux.png
index 2eb6df89275..f460a7567ab 100644
Binary files a/browser_tests/tests/vueNodes/widgets/text/lockedWidgetHoverColor.spec.ts-snapshots/locked-widget-hover-color-chromium-linux.png and b/browser_tests/tests/vueNodes/widgets/text/lockedWidgetHoverColor.spec.ts-snapshots/locked-widget-hover-color-chromium-linux.png differ
diff --git a/browser_tests/tests/vueNodes/widgets/text/multilineStringWidget.spec.ts b/browser_tests/tests/vueNodes/widgets/text/multilineStringWidget.spec.ts
index 61b9c9131e9..578313c5b1f 100644
--- a/browser_tests/tests/vueNodes/widgets/text/multilineStringWidget.spec.ts
+++ b/browser_tests/tests/vueNodes/widgets/text/multilineStringWidget.spec.ts
@@ -3,6 +3,7 @@ import {
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
+import { TestIds } from '@e2e/fixtures/selectors'
test.describe('Vue Multiline String Widget', { tag: '@vue-nodes' }, () => {
test.afterEach(async ({ comfyPage }) => {
@@ -46,6 +47,56 @@ test.describe('Vue Multiline String Widget', { tag: '@vue-nodes' }, () => {
await expect(textarea).toHaveValue('Keep me around')
})
+ test('hides a linked core prompt without changing node geometry', async ({
+ comfyPage
+ }) => {
+ await comfyPage.workflow.loadWorkflow('widgets/linked_multiline_string')
+
+ const clipNode = getFirstClipNode(comfyPage)
+ const placeholder = clipNode.getByTestId(TestIds.widgets.linkedPlaceholder)
+ const linkedContent = clipNode.getByTestId(TestIds.widgets.linkedContent)
+ const hiddenTextarea = clipNode.locator('textarea')
+ const nodeBounds = await clipNode.boundingBox()
+
+ await expect(placeholder).toHaveAttribute(
+ 'data-linked-display',
+ 'expanding'
+ )
+ await expect(placeholder).toHaveAccessibleName('text: Linked input')
+ await expect(linkedContent).toHaveAttribute('inert', '')
+ await expect(linkedContent).toHaveAttribute('aria-hidden', 'true')
+ await expect(hiddenTextarea).toBeHidden()
+ await expect(hiddenTextarea).toBeDisabled()
+ await expect(clipNode.getByRole('textbox', { name: 'text' })).toHaveCount(0)
+ await expect(hiddenTextarea).toHaveValue('stale local prompt')
+
+ await hiddenTextarea.evaluate((element) => element.focus())
+ await expect
+ .poll(() =>
+ hiddenTextarea.evaluate((element) => document.activeElement === element)
+ )
+ .toBe(false)
+
+ const [clipNodeRef] =
+ await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
+ if (!clipNodeRef || !nodeBounds) {
+ throw new Error('Linked CLIPTextEncode node did not render')
+ }
+ const textInput = await clipNodeRef.getInput(1)
+ await textInput.removeLinks()
+ await comfyPage.nextFrame()
+
+ await expect(placeholder).toHaveCount(0)
+ const restoredTextarea = getFirstMultilineStringWidget(comfyPage)
+ await expect(restoredTextarea).toBeVisible()
+ await expect(restoredTextarea).toHaveValue('stale local prompt')
+ await restoredTextarea.fill('restored local prompt')
+ await expect(restoredTextarea).toHaveValue('restored local prompt')
+ await expect
+ .poll(async () => (await clipNode.boundingBox())?.height)
+ .toBeCloseTo(nodeBounds.height, 0)
+ })
+
test('should use native context menu when focused', async ({ comfyPage }) => {
const textarea = getFirstMultilineStringWidget(comfyPage)
const vueContextMenu = comfyPage.page.locator('.p-contextmenu')
diff --git a/src/components/rightSidePanel/parameters/WidgetItem.test.ts b/src/components/rightSidePanel/parameters/WidgetItem.test.ts
index 10fc410e265..c1a3d276875 100644
--- a/src/components/rightSidePanel/parameters/WidgetItem.test.ts
+++ b/src/components/rightSidePanel/parameters/WidgetItem.test.ts
@@ -1,27 +1,51 @@
-import { render } from '@testing-library/vue'
import { fromAny } from '@total-typescript/shoehorn'
-import { describe, expect, it, vi } from 'vitest'
+import userEvent from '@testing-library/user-event'
+import { render, screen } from '@testing-library/vue'
+import PrimeVue from 'primevue/config'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
+import type * as WidgetRegistry from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
+import { toLinkId } from '@/types/linkId'
+import { toNodeId } from '@/types/nodeId'
import { widgetId } from '@/types/widgetId'
+
import WidgetItem from './WidgetItem.vue'
-import { toNodeId } from '@/types/nodeId'
-const { mockGetInputSpecForWidget, StubWidgetComponent } = vi.hoisted(() => ({
+const {
+ mockFromLGraphNode,
+ mockGetInputSpecForWidget,
+ mockIsAssetAPIEnabled,
+ mockShouldUseAssetBrowser,
+ StubWidgetComponent
+} = vi.hoisted(() => ({
+ mockFromLGraphNode: vi.fn<() => { isCoreNode: boolean; name: string } | null>(
+ () => null
+ ),
mockGetInputSpecForWidget: vi.fn(),
+ mockIsAssetAPIEnabled: vi.fn(() => false),
+ mockShouldUseAssetBrowser: vi.fn(() => false),
StubWidgetComponent: {
name: 'StubWidget',
props: ['widget', 'modelValue', 'nodeId', 'nodeType'],
template:
- '
'
+ ''
+ }
+}))
+
+vi.mock('@/platform/assets/services/assetService', () => ({
+ assetService: {
+ isAssetAPIEnabled: mockIsAssetAPIEnabled,
+ shouldUseAssetBrowser: mockShouldUseAssetBrowser
}
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: () => ({
+ fromLGraphNode: mockFromLGraphNode,
getInputSpecForWidget: mockGetInputSpecForWidget
})
}))
@@ -45,10 +69,17 @@ vi.mock('@/composables/graph/useGraphNodeManager', () => ({
vi.mock(
'@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry',
- () => ({
- getComponent: () => StubWidgetComponent,
- shouldExpand: () => false
- })
+ async (importOriginal) => {
+ const original = await importOriginal()
+ const { default: WidgetInputText } =
+ await import('@/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue')
+ return {
+ ...original,
+ getComponent: (type: string) =>
+ type === 'text' ? WidgetInputText : StubWidgetComponent,
+ shouldExpand: () => false
+ }
+ }
)
vi.mock(
@@ -70,7 +101,9 @@ const i18n = createI18n({
}
})
-function createMockNode(overrides: Partial = {}): LGraphNode {
+function createMockNode(
+ overrides: Partial> = {}
+): LGraphNode {
return fromAny({
id: 1,
type: 'TestNode',
@@ -100,7 +133,7 @@ function renderWidgetItem(
return render(WidgetItem, {
props: { widget, node },
global: {
- plugins: [i18n],
+ plugins: [i18n, PrimeVue],
stubs: {
EditableText: { template: '' },
WidgetActions: { template: '' }
@@ -115,6 +148,7 @@ function getStubWidget(container: Element) {
if (!el) throw new Error('stub-widget not found')
return {
options: JSON.parse(el.getAttribute('data-widget-options') ?? 'null'),
+ linkedDisplay: el.getAttribute('data-linked-display'),
type: el.getAttribute('data-widget-type'),
name: el.getAttribute('data-widget-name'),
value: el.getAttribute('data-widget-value')
@@ -122,6 +156,13 @@ function getStubWidget(container: Element) {
}
describe('WidgetItem', () => {
+ beforeEach(() => {
+ mockIsAssetAPIEnabled.mockReturnValue(false)
+ mockShouldUseAssetBrowser.mockReturnValue(false)
+ mockFromLGraphNode.mockReturnValue(null)
+ mockGetInputSpecForWidget.mockReset()
+ })
+
describe('widget state rendering', () => {
it('passes options from a regular widget to the widget component', () => {
const widget = createMockWidget({
@@ -197,5 +238,143 @@ describe('WidgetItem', () => {
expect(stub.value).toBe('model_a.safetensors')
})
+
+ it('restores a linked text control in Parameters after disconnect', async () => {
+ const widget = createMockWidget({
+ name: 'prompt',
+ type: 'text',
+ value: 'STALE PARAMETER TEXT'
+ })
+ const promptInput = {
+ name: 'prompt',
+ type: 'STRING',
+ link: toLinkId(1),
+ boundingRect: [0, 0, 0, 0],
+ widget: { name: 'prompt' }
+ }
+ const node = createMockNode({
+ inputs: [promptInput]
+ })
+
+ const view = renderWidgetItem(widget, node)
+
+ const content = screen.getByTestId('linked-widget-content')
+ const input = screen.getByRole('textbox', { hidden: true })
+ expect(content).toHaveAttribute('inert')
+ expect(content).toHaveAttribute('aria-hidden', 'true')
+ expect(input).toBeDisabled()
+ expect(screen.queryByRole('textbox')).toBeNull()
+ expect(
+ screen.getByRole('img', { name: 'prompt: Linked input' })
+ ).toBeVisible()
+
+ await view.rerender({
+ widget,
+ node: createMockNode({
+ inputs: [{ ...promptInput, link: null }]
+ })
+ })
+
+ expect(screen.queryByRole('img')).toBeNull()
+ const restoredInput = screen.getByRole('textbox', { name: 'prompt' })
+ expect(restoredInput).toBeVisible()
+ expect(restoredInput).toBeEnabled()
+ expect(restoredInput).toHaveValue('STALE PARAMETER TEXT')
+
+ const user = userEvent.setup()
+ await user.clear(restoredInput)
+ await user.type(restoredInput, 'restored parameter text')
+
+ expect(view.emitted()['update:widgetValue']).toContainEqual([
+ 'restored parameter text'
+ ])
+ })
+
+ it('uses the bounded linked resolver for ordinary and upload combos', () => {
+ const widget = createMockWidget({ name: 'option', type: 'COMBO' })
+ const node = createMockNode({
+ inputs: [
+ {
+ name: 'option',
+ type: 'COMBO',
+ link: toLinkId(1),
+ boundingRect: [0, 0, 0, 0],
+ widget: { name: 'option' }
+ }
+ ]
+ })
+ const { container, unmount } = renderWidgetItem(widget, node)
+
+ expect(getStubWidget(container).linkedDisplay).toBe('control')
+ unmount()
+
+ mockGetInputSpecForWidget.mockReturnValue({
+ type: 'COMBO',
+ name: 'option',
+ image_upload: true
+ })
+ const upload = renderWidgetItem(widget, node)
+
+ expect(getStubWidget(upload.container).linkedDisplay).toBeNull()
+ expect(getStubWidget(upload.container).options.disabled).toBe(true)
+ })
+
+ it.for(['LoadImage', 'LoadImageMask', 'LoadImageOutput'])(
+ 'uses the linked presentation for the exact core %s selector',
+ (nodeType) => {
+ mockGetInputSpecForWidget.mockReturnValue({
+ type: 'COMBO',
+ name: 'image',
+ image_upload: true
+ })
+ mockFromLGraphNode.mockReturnValue({
+ name: nodeType,
+ isCoreNode: true
+ })
+ const widget = createMockWidget({ name: 'image', type: 'asset' })
+ const node = createMockNode({
+ type: nodeType,
+ inputs: [
+ {
+ name: 'image',
+ type: 'COMBO',
+ link: toLinkId(1),
+ boundingRect: [0, 0, 0, 0],
+ widget: { name: 'image' }
+ }
+ ]
+ })
+
+ const { container } = renderWidgetItem(widget, node)
+ const stub = getStubWidget(container)
+
+ expect(stub.linkedDisplay).toBe('control')
+ expect(stub.options.disabled).toBe(true)
+ }
+ )
+
+ it('does not add linked presentation to a special widget', () => {
+ const widget = createMockWidget({
+ name: 'gradient',
+ type: 'gradientslider'
+ })
+ const node = createMockNode({
+ inputs: [
+ {
+ name: 'gradient',
+ type: 'FLOAT',
+ link: toLinkId(1),
+ boundingRect: [0, 0, 0, 0],
+ widget: { name: 'gradient' }
+ }
+ ]
+ })
+ const { container } = renderWidgetItem(widget, node)
+ const stub = getStubWidget(container)
+
+ expect(stub.linkedDisplay).toBeNull()
+ expect(stub.options.disabled).toBe(true)
+ expect(screen.queryByTestId('linked-widget-placeholder')).toBeNull()
+ })
})
})
diff --git a/src/components/rightSidePanel/parameters/WidgetItem.vue b/src/components/rightSidePanel/parameters/WidgetItem.vue
index 643edb4a858..63a5dde944b 100644
--- a/src/components/rightSidePanel/parameters/WidgetItem.vue
+++ b/src/components/rightSidePanel/parameters/WidgetItem.vue
@@ -8,12 +8,14 @@ import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
+import { assetService } from '@/platform/assets/services/assetService'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
import {
getComponent,
shouldExpand
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
+import { resolveLinkedWidgetDisplay } from '@/renderer/extensions/vueNodes/widgets/utils/linkedWidgetDisplay'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import {
stripGraphPrefix,
@@ -88,14 +90,27 @@ const simplifiedWidget = computed((): SimplifiedWidget => {
const widgetType = widgetState?.type ?? widget.type
const baseOptions = widgetState?.options ?? widget.options
+ const spec = nodeDefStore.getInputSpecForWidget(node, widgetName)
+ const nodeDef = nodeDefStore.fromLGraphNode(node)
+ const linkedDisplay = resolveLinkedWidgetDisplay(
+ { name: widgetName, type: widgetType, spec },
+ baseOptions,
+ {
+ assetApiEnabled: assetService.isAssetAPIEnabled(),
+ coreNodeType: nodeDef?.isCoreNode ? nodeDef.name : undefined,
+ linked: isLinked.value,
+ useAssetBrowser: assetService.shouldUseAssetBrowser(node.type, widgetName)
+ }
+ )
const disabled = isLinked.value || !!widget.disabled || undefined
return {
name: widgetName,
type: widgetType,
value: widgetState?.value ?? widget.value,
label: widgetState?.label ?? widget.label,
+ linkedDisplay,
options: { ...baseOptions, disabled },
- spec: nodeDefStore.getInputSpecForWidget(node, widgetName),
+ spec,
controlWidget: getControlWidget(widget)
}
})
diff --git a/src/components/ui/color-picker/ColorPicker.test.ts b/src/components/ui/color-picker/ColorPicker.test.ts
new file mode 100644
index 00000000000..bec44b9c9e3
--- /dev/null
+++ b/src/components/ui/color-picker/ColorPicker.test.ts
@@ -0,0 +1,77 @@
+import { render, screen, waitFor } from '@testing-library/vue'
+import userEvent from '@testing-library/user-event'
+import { describe, expect, it } from 'vitest'
+import { createI18n } from 'vue-i18n'
+
+import ColorPicker from './ColorPicker.vue'
+
+const i18n = createI18n({
+ legacy: false,
+ locale: 'en',
+ messages: {
+ en: {
+ color: {
+ alpha: 'Alpha',
+ hex: 'Hex',
+ hue: 'Hue',
+ rgba: 'RGBA',
+ saturationBrightness: 'Color saturation and brightness'
+ }
+ }
+ }
+})
+
+describe('ColorPicker', () => {
+ it('closes an open popover when disabled', async () => {
+ const user = userEvent.setup()
+ const { rerender } = render(ColorPicker, {
+ global: { plugins: [i18n] },
+ props: { modelValue: '#112233' }
+ })
+ const trigger = screen.getByRole('button')
+
+ await user.click(trigger)
+ expect(
+ await screen.findByRole('textbox', { name: 'Hex' })
+ ).toBeInTheDocument()
+
+ await rerender({ disabled: true, modelValue: '#112233' })
+
+ await waitFor(() => {
+ expect(
+ screen.queryByRole('textbox', { name: 'Hex' })
+ ).not.toBeInTheDocument()
+ })
+ expect(trigger).toBeDisabled()
+ })
+
+ it('does not reopen from a custom trigger when disabled', async () => {
+ const user = userEvent.setup()
+ const { rerender } = render(ColorPicker, {
+ global: { plugins: [i18n] },
+ props: { modelValue: '#112233' },
+ slots: {
+ trigger: ''
+ }
+ })
+ const trigger = screen.getByRole('button', { name: 'Custom trigger' })
+
+ await user.click(trigger)
+ expect(
+ await screen.findByRole('textbox', { name: 'Hex' })
+ ).toBeInTheDocument()
+
+ await rerender({ disabled: true, modelValue: '#112233' })
+ await waitFor(() => {
+ expect(
+ screen.queryByRole('textbox', { name: 'Hex' })
+ ).not.toBeInTheDocument()
+ })
+
+ await user.click(trigger)
+
+ expect(
+ screen.queryByRole('textbox', { name: 'Hex' })
+ ).not.toBeInTheDocument()
+ })
+})
diff --git a/src/components/ui/color-picker/ColorPicker.vue b/src/components/ui/color-picker/ColorPicker.vue
index 8215e4e7a3f..057c3908785 100644
--- a/src/components/ui/color-picker/ColorPicker.vue
+++ b/src/components/ui/color-picker/ColorPicker.vue
@@ -14,7 +14,7 @@ import { cn } from '@comfyorg/tailwind-utils'
import ColorPickerPanel from './ColorPickerPanel.vue'
-const { alpha = true } = defineProps<{
+const { alpha = true, disabled = false } = defineProps<{
class?: string
disabled?: boolean
alpha?: boolean
@@ -69,15 +69,26 @@ const displayHex = computed(() => rgbToHex(baseRgb.value).toLowerCase())
const isOpen = ref(false)
const contentStyle = useModalLiftedZIndex(isOpen)
+
+function updateOpen(open: boolean) {
+ isOpen.value = open && !disabled
+}
+
+watch(
+ () => disabled,
+ (isDisabled) => {
+ if (isDisabled) isOpen.value = false
+ }
+)
-
+
diff --git a/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.test.ts b/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.test.ts
index a36bc0fe375..8bf04f15293 100644
--- a/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.test.ts
+++ b/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.test.ts
@@ -16,6 +16,8 @@ import { widgetId } from '@/types/widgetId'
import type * as GraphTraversalUtil from '@/utils/graphTraversalUtil'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
+import WidgetDOM from '@/renderer/extensions/vueNodes/widgets/components/WidgetDOM.vue'
+import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
import {
computeProcessedWidgets,
getWidgetIdentity,
@@ -27,8 +29,21 @@ import { validationError } from '@/utils/__tests__/nodeErrorHelpers'
const GRAPH_ID = 'graph-test'
const NODE_ID = toNodeId(1)
-const { executionIdToNodeLocatorId } = vi.hoisted(() => ({
- executionIdToNodeLocatorId: vi.fn()
+const {
+ executionIdToNodeLocatorId,
+ isAssetAPIEnabled,
+ shouldUseAssetBrowser,
+ showNodeOptions
+} = vi.hoisted(() => ({
+ executionIdToNodeLocatorId: vi.fn(),
+ isAssetAPIEnabled: vi.fn(() => false),
+ shouldUseAssetBrowser: vi.fn(() => false),
+ showNodeOptions: vi.fn()
+}))
+
+vi.mock('@/composables/graph/useMoreOptionsMenu', () => ({ showNodeOptions }))
+vi.mock('@/platform/assets/services/assetService', () => ({
+ assetService: { isAssetAPIEnabled, shouldUseAssetBrowser }
}))
vi.mock('@/utils/graphTraversalUtil', async (importActual) => {
@@ -70,11 +85,20 @@ const noopUi = {
handleNodeRightClick: () => {}
}
-function processWidgets(widgets: SafeWidgetData[]) {
+function processWidgets(widgets: SafeWidgetData[], ui: typeof noopUi = noopUi) {
+ return processNodeWidgets(widgets, 'TestNode', false, ui)
+}
+
+function processNodeWidgets(
+ widgets: SafeWidgetData[],
+ nodeType: string,
+ isCoreNode: boolean,
+ ui: typeof noopUi = noopUi
+) {
return computeProcessedWidgets({
nodeData: {
id: NODE_ID,
- type: 'TestNode',
+ type: nodeType,
widgets,
title: 'Test',
mode: 0,
@@ -83,11 +107,12 @@ function processWidgets(widgets: SafeWidgetData[]) {
inputs: [],
outputs: []
},
+ coreNodeType: isCoreNode ? nodeType : undefined,
graphId: GRAPH_ID,
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
- ui: noopUi
+ ui
})
}
@@ -737,6 +762,234 @@ describe('computeProcessedWidgets borderStyle', () => {
})
})
+describe('computeProcessedWidgets linked presentation', () => {
+ const linkedSlot = {
+ index: 0,
+ linked: true,
+ originNodeId: toNodeId(2),
+ originOutputName: 'value',
+ type: 'STRING'
+ }
+
+ beforeEach(() => {
+ isAssetAPIEnabled.mockReturnValue(false)
+ shouldUseAssetBrowser.mockReturnValue(false)
+ showNodeOptions.mockClear()
+ })
+
+ it('hides a linked standard value and disables its mounted control', () => {
+ const id = widgetId(GRAPH_ID, NODE_ID, 'prompt')
+ useWidgetValueStore().registerWidget(id, {
+ type: 'string',
+ value: 'stale local prompt',
+ options: {}
+ })
+ const widget = createMockWidget({
+ widgetId: id,
+ nodeId: NODE_ID,
+ name: 'prompt',
+ type: 'string',
+ slotMetadata: linkedSlot
+ })
+
+ const [linked] = processWidgets([widget])
+
+ expect(linked.linkedDisplay).toBe('control')
+ expect(linked.value).toBe('stale local prompt')
+ expect(linked.simplified.options?.disabled).toBe(true)
+ expect(linked.tooltipConfig.disabled).toBe(true)
+ expect(linked.simplified.linkedUpstream).toEqual({
+ nodeId: toNodeId(2),
+ outputName: 'value'
+ })
+ })
+
+ it('restores the standard widget state after disconnect', () => {
+ const id = widgetId(GRAPH_ID, NODE_ID, 'prompt')
+ useWidgetValueStore().registerWidget(id, {
+ type: 'string',
+ value: 'local prompt',
+ options: {}
+ })
+ const widget = createMockWidget({
+ widgetId: id,
+ nodeId: NODE_ID,
+ name: 'prompt',
+ type: 'string',
+ slotMetadata: linkedSlot
+ })
+
+ expect(processWidgets([widget])[0].linkedDisplay).toBe('control')
+
+ const [unlinked] = processWidgets([
+ {
+ ...widget,
+ slotMetadata: { ...linkedSlot, linked: false }
+ }
+ ])
+
+ expect(unlinked.linkedDisplay).toBeUndefined()
+ expect(unlinked.value).toBe('local prompt')
+ expect(unlinked.simplified.options?.disabled).toBeUndefined()
+ expect(unlinked.simplified.linkedUpstream).toBeUndefined()
+ expect(unlinked.tooltipConfig.disabled).toBeUndefined()
+ })
+
+ it('distinguishes unlabeled switches from labeled toggle groups', () => {
+ const switchWidget = createMockWidget({
+ name: 'enabled',
+ type: 'boolean',
+ slotMetadata: { ...linkedSlot, type: 'BOOLEAN' }
+ })
+ const toggleId = widgetId(GRAPH_ID, NODE_ID, 'mode')
+ useWidgetValueStore().registerWidget(toggleId, {
+ type: 'boolean',
+ value: false,
+ options: { off: 'Disabled', on: 'Enabled' }
+ })
+ const toggleWidget = createMockWidget({
+ widgetId: toggleId,
+ nodeId: NODE_ID,
+ name: 'mode',
+ type: 'toggle',
+ slotMetadata: { ...linkedSlot, type: 'BOOLEAN' }
+ })
+
+ expect(processWidgets([switchWidget])[0].linkedDisplay).toBe('switch')
+ expect(processWidgets([toggleWidget])[0].linkedDisplay).toBe('control')
+ })
+
+ it.for([
+ ['String', false, WidgetLegacy],
+ ['CoMbO', true, WidgetDOM]
+ ] as const)(
+ 'keeps the unregistered mixed-case %s fallback visible',
+ ([type, isDOMWidget, component]) => {
+ const widget = createMockWidget({
+ isDOMWidget,
+ type,
+ slotMetadata: linkedSlot
+ })
+
+ const [processed] = processWidgets([widget])
+
+ expect(processed.linkedDisplay).toBeUndefined()
+ expect(processed.simplified.options?.disabled).toBe(true)
+ expect(processed.vueComponent).toBe(component)
+ }
+ )
+
+ it.for([
+ { name: 'asset alias', type: 'asset', spec: undefined },
+ {
+ name: 'upload-media COMBO',
+ type: 'COMBO',
+ spec: {
+ type: 'COMBO',
+ name: 'image',
+ image_upload: true
+ }
+ }
+ ] as const)('keeps the linked $name rendered', ({ type, spec }) => {
+ const widget = createMockWidget({
+ name: 'image',
+ type,
+ spec,
+ slotMetadata: linkedSlot
+ })
+
+ const [processed] = processWidgets([widget])
+
+ expect(processed.linkedDisplay).toBeUndefined()
+ expect(processed.simplified.options?.disabled).toBe(true)
+ })
+
+ it.for(['LoadImage', 'LoadImageMask', 'LoadImageOutput'])(
+ 'hides and disables a linked core %s selector',
+ (nodeType) => {
+ const widget = createMockWidget({
+ name: 'image',
+ type: 'asset',
+ spec: { type: 'COMBO', name: 'image', image_upload: true },
+ slotMetadata: linkedSlot
+ })
+
+ const [processed] = processNodeWidgets([widget], nodeType, true)
+
+ expect(processed.linkedDisplay).toBe('control')
+ expect(processed.simplified.options?.disabled).toBe(true)
+ }
+ )
+
+ it('keeps an asset-browser combo rendered while hiding an ordinary combo', () => {
+ shouldUseAssetBrowser.mockReturnValue(true)
+ const widget = createMockWidget({
+ name: 'ckpt_name',
+ type: 'COMBO',
+ slotMetadata: linkedSlot
+ })
+
+ expect(processWidgets([widget])[0].linkedDisplay).toBeUndefined()
+
+ shouldUseAssetBrowser.mockReturnValue(false)
+ expect(processWidgets([widget])[0].linkedDisplay).toBe('control')
+ })
+
+ it('keeps upstream metadata for a linked relay widget', () => {
+ const widget = createMockWidget({
+ type: 'range',
+ slotMetadata: linkedSlot
+ })
+
+ const [processed] = processWidgets([widget])
+
+ expect(processed.linkedDisplay).toBeUndefined()
+ expect(processed.simplified.options?.disabled).toBe(true)
+ expect(processed.simplified.linkedUpstream).toEqual({
+ nodeId: toNodeId(2),
+ outputName: 'value'
+ })
+ })
+
+ it('retains widget actions for a hidden linked widget', () => {
+ const handleNodeRightClick = vi.fn()
+ const event = fromAny({
+ preventDefault: vi.fn(),
+ stopPropagation: vi.fn()
+ })
+ const widget = createMockWidget({
+ name: 'prompt',
+ nodeId: NODE_ID,
+ type: 'text',
+ slotMetadata: linkedSlot
+ })
+
+ processWidgets([widget], {
+ ...noopUi,
+ handleNodeRightClick
+ })[0].handleContextMenu(event)
+
+ expect(handleNodeRightClick).toHaveBeenCalledWith(event, NODE_ID)
+ expect(showNodeOptions).toHaveBeenCalledWith(event, 'prompt', NODE_ID)
+ })
+
+ it('retains widget actions when the widget is unlinked', () => {
+ const event = fromAny({
+ preventDefault: vi.fn(),
+ stopPropagation: vi.fn()
+ })
+ const widget = createMockWidget({
+ name: 'prompt',
+ nodeId: NODE_ID,
+ type: 'text'
+ })
+
+ processWidgets([widget])[0].handleContextMenu(event)
+
+ expect(showNodeOptions).toHaveBeenCalledWith(event, 'prompt', NODE_ID)
+ })
+})
+
describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
it('calls widget.callback with the new value when widgetState exists', () => {
const callback = vi.fn()
diff --git a/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.ts b/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.ts
index 664280d3cb2..f16cc7e85cf 100644
--- a/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.ts
+++ b/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.ts
@@ -12,6 +12,7 @@ import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { useSettingStore } from '@/platform/settings/settingStore'
+import { assetService } from '@/platform/assets/services/assetService'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import type { NodeError } from '@/schemas/apiSchema'
@@ -24,6 +25,7 @@ import {
shouldExpand,
shouldRenderAsVue
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
+import { resolveLinkedWidgetDisplay } from '@/renderer/extensions/vueNodes/widgets/utils/linkedWidgetDisplay'
import { nodeTypeValidForApp } from '@/stores/appModeStore'
import {
stripGraphPrefix,
@@ -32,6 +34,7 @@ import {
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
+import { useNodeDefStore } from '@/stores/nodeDefStore'
import {
createNodeExecutionId,
createNodeLocatorId
@@ -48,6 +51,7 @@ import {
} from '@/utils/graphTraversalUtil'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import type {
+ LinkedWidgetDisplay,
LinkedUpstreamInfo,
SimplifiedWidget,
WidgetValue
@@ -66,6 +70,7 @@ interface ProcessedWidget {
hasError: boolean
hidden: boolean
id?: string
+ linkedDisplay?: LinkedWidgetDisplay
widgetId?: WidgetId
name: string
renderKey: string
@@ -86,6 +91,7 @@ interface WidgetUiCallbacks {
interface ComputeProcessedWidgetsOptions {
nodeData: VueNodeData | undefined
+ coreNodeType?: string
graphId: string | undefined
showAdvanced: boolean
isGraphReady: boolean
@@ -222,6 +228,7 @@ export function isWidgetVisible(
export function computeProcessedWidgets({
nodeData,
+ coreNodeType,
graphId,
showAdvanced,
isGraphReady,
@@ -330,6 +337,15 @@ export function computeProcessedWidgets({
const value = widgetState?.value as WidgetValue
+ const linkedDisplay = resolveLinkedWidgetDisplay(widget, mergedOptions, {
+ assetApiEnabled: assetService.isAssetAPIEnabled(),
+ coreNodeType,
+ linked: Boolean(widget.slotMetadata?.linked),
+ useAssetBrowser: assetService.shouldUseAssetBrowser(
+ nodeData.type,
+ widget.name
+ )
+ })
const isDisabled = slotMetadata?.linked || widgetState?.disabled
const widgetOptions = isDisabled
? { ...mergedOptions, disabled: true }
@@ -362,6 +378,7 @@ export function computeProcessedWidgets({
callback: widget.callback,
controlWidget: widget.controlWidget,
label: widgetState?.label,
+ linkedDisplay,
linkedUpstream,
nodeLocatorId,
options: widgetOptions,
@@ -380,7 +397,9 @@ export function computeProcessedWidgets({
isTooltipValueType(widget.type) && String(value).length > 10
? String(value)
: undefined
- const tooltipConfig = ui.getTooltipConfig(widget, valueTooltip)
+ const tooltipConfig = linkedDisplay
+ ? { disabled: true }
+ : ui.getTooltipConfig(widget, valueTooltip)
const handleContextMenu = (e: PointerEvent) => {
e.preventDefault()
e.stopPropagation()
@@ -407,6 +426,7 @@ export function computeProcessedWidgets({
missingMediaStore
),
hidden: mergedOptions.hidden ?? false,
+ linkedDisplay,
widgetId: widget.widgetId,
name: widget.name,
renderKey,
@@ -430,6 +450,7 @@ export function useProcessedWidgets(
) {
const canvasStore = useCanvasStore()
const settingStore = useSettingStore()
+ const nodeDefStore = useNodeDefStore()
const { isSelectInputsMode } = useAppMode()
const { handleNodeRightClick } = useNodeEventHandlers()
@@ -460,16 +481,21 @@ export function useProcessedWidgets(
)
})
- const processedWidgets = computed((): ProcessedWidget[] =>
- computeProcessedWidgets({
- nodeData: nodeDataGetter(),
+ const processedWidgets = computed((): ProcessedWidget[] => {
+ const nodeData = nodeDataGetter()
+ const nodeDef = nodeData
+ ? nodeDefStore.nodeDefsByName[nodeData.type]
+ : undefined
+ return computeProcessedWidgets({
+ nodeData,
+ coreNodeType: nodeDef?.isCoreNode ? nodeDef.name : undefined,
graphId: canvasStore.canvas?.graph?.rootGraph.id,
showAdvanced: showAdvanced.value,
isGraphReady: app.isGraphReady,
rootGraph: app.isGraphReady ? app.rootGraph : null,
ui
})
- )
+ })
const visibleWidgets = computed(() =>
processedWidgets.value.filter((w) => w.visible)
diff --git a/src/renderer/extensions/vueNodes/widgets/components/LinkedWidgetStatus.test.ts b/src/renderer/extensions/vueNodes/widgets/components/LinkedWidgetStatus.test.ts
new file mode 100644
index 00000000000..056e5946ab7
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/widgets/components/LinkedWidgetStatus.test.ts
@@ -0,0 +1,72 @@
+import { render, screen } from '@testing-library/vue'
+import userEvent from '@testing-library/user-event'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { defineComponent, nextTick } from 'vue'
+
+import { i18n } from '@/i18n'
+
+import LinkedWidgetStatus from './LinkedWidgetStatus.vue'
+
+const originalLocale = i18n.global.locale.value
+const originalFrenchMessages = i18n.global.getLocaleMessage('fr')
+
+afterEach(() => {
+ i18n.global.locale.value = originalLocale
+ i18n.global.setLocaleMessage('fr', originalFrenchMessages)
+})
+
+describe('LinkedWidgetStatus', () => {
+ it('exposes the linked state as a named image', () => {
+ render(LinkedWidgetStatus, {
+ props: {
+ display: 'control',
+ widget: { name: 'prompt', label: 'Prompt' }
+ }
+ })
+
+ expect(
+ screen.getByRole('img', { name: 'Prompt: Linked input' })
+ ).toBeVisible()
+ })
+
+ it('allows pointer gestures to bubble to the canvas surface', async () => {
+ const onPointerdown = vi.fn()
+ const Harness = defineComponent({
+ components: { LinkedWidgetStatus },
+ setup: () => ({ onPointerdown }),
+ template: `
+
+
+
+ `
+ })
+ render(Harness)
+ const user = userEvent.setup()
+
+ await user.pointer({ keys: '[MouseLeft]', target: screen.getByRole('img') })
+
+ expect(onPointerdown).toHaveBeenCalledTimes(1)
+ })
+
+ it('updates its accessible name when the locale changes', async () => {
+ render(LinkedWidgetStatus, {
+ props: {
+ display: 'control',
+ widget: { name: 'prompt' }
+ }
+ })
+ i18n.global.mergeLocaleMessage('fr', {
+ widgets: { linkedInput: 'Entrée liée' }
+ })
+ i18n.global.locale.value = 'fr'
+
+ await nextTick()
+
+ expect(
+ screen.getByRole('img', { name: 'prompt: Entrée liée' })
+ ).toBeVisible()
+ })
+})
diff --git a/src/renderer/extensions/vueNodes/widgets/components/LinkedWidgetStatus.vue b/src/renderer/extensions/vueNodes/widgets/components/LinkedWidgetStatus.vue
new file mode 100644
index 00000000000..abae29536a0
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/widgets/components/LinkedWidgetStatus.vue
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
diff --git a/src/renderer/extensions/vueNodes/widgets/components/ValueControlButton.vue b/src/renderer/extensions/vueNodes/widgets/components/ValueControlButton.vue
index 0abffea10d8..c4b371e46b3 100644
--- a/src/renderer/extensions/vueNodes/widgets/components/ValueControlButton.vue
+++ b/src/renderer/extensions/vueNodes/widgets/components/ValueControlButton.vue
@@ -33,7 +33,7 @@ const textMap: Record = {
:aria-label="t('widgets.valueControl.' + mode)"
:class="
cn(
- 'flex shrink-0 cursor-pointer items-center justify-center border-none focus-visible:ring-2 focus-visible:ring-primary-background focus-visible:ring-offset-1 focus-visible:outline-none',
+ 'flex shrink-0 cursor-pointer items-center justify-center border-none focus-visible:ring-2 focus-visible:ring-primary-background focus-visible:ring-offset-1 focus-visible:outline-none disabled:cursor-default disabled:opacity-50',
variant === 'badge' ? 'h-4.5 w-8 rounded-full' : 'size-6 rounded-sm',
mode !== 'fixed'
? 'bg-primary-background/30 hover:bg-primary-background-hover/30'
diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.test.ts
index 6d05a06c6a8..984d227348c 100644
--- a/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.test.ts
+++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.test.ts
@@ -19,12 +19,14 @@ const WidgetLayoutFieldStub = defineComponent({
const ColorPickerStub = defineComponent({
name: 'ColorPicker',
props: {
+ disabled: { type: Boolean, default: false },
modelValue: { type: String, default: '' }
},
emits: ['update:modelValue'],
template: ``
})
@@ -99,6 +101,13 @@ describe('WidgetColorPicker Value Binding', () => {
expect(screen.getByTestId('color-picker-input')).toBeInTheDocument()
})
+ it('disables the color picker from widget options', () => {
+ const widget = createColorWidget('#ff0000', { disabled: true })
+ renderComponent(widget, '#ff0000')
+
+ expect(screen.getByTestId('color-picker-input')).toBeDisabled()
+ })
+
it('renders layout field wrapper', () => {
const widget = createColorWidget('#ff0000')
renderComponent(widget, '#ff0000')
diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue
index d71ae699296..4e619e4d2b4 100644
--- a/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue
+++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue
@@ -1,6 +1,10 @@
-
-
+
+
diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetInputText.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetInputText.test.ts
index 52997914862..da023918092 100644
--- a/src/renderer/extensions/vueNodes/widgets/components/WidgetInputText.test.ts
+++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetInputText.test.ts
@@ -168,6 +168,34 @@ describe('WidgetInputText Value Binding', () => {
const textarea = container.querySelector('textarea')
expect(textarea).not.toBeInTheDocument()
})
+
+ it('restores the same text control after a link is removed', async () => {
+ const widget = createInputTextWidget('stale prompt', {
+ disabled: true
+ })
+ widget.linkedDisplay = 'control'
+ const { rerender } = renderComponent(widget, 'stale prompt')
+
+ const linkedInput = screen.getByRole('textbox', { hidden: true })
+ expect(linkedInput).toBeDisabled()
+ expect(screen.queryByRole('textbox')).toBeNull()
+ expect(screen.getByTestId('linked-widget-content')).toHaveAttribute(
+ 'inert'
+ )
+ expect(
+ screen.getByRole('img', { name: 'test_input: Linked input' })
+ ).toBeVisible()
+
+ await rerender({
+ widget: createInputTextWidget('stale prompt'),
+ modelValue: 'stale prompt'
+ })
+
+ expect(screen.queryByRole('img')).toBeNull()
+ expect(screen.getByRole('textbox')).toBeVisible()
+ expect(screen.getByRole('textbox')).toBeEnabled()
+ expect(screen.getByRole('textbox')).toHaveValue('stale prompt')
+ })
})
describe('Locked Field Hover Styling', () => {
diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue
index 240d341d698..c4837e31a8c 100644
--- a/src/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue
+++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue
@@ -67,6 +67,7 @@ const isReadOnly = computed(() =>
const layoutWidget = computed(() => ({
name: widget.name,
label: widget.label,
+ linkedDisplay: widget.linkedDisplay,
borderStyle: cn(
widget.borderStyle,
invalid && 'border border-destructive-background'
diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue
index 1fef32bdd89..22fe423fd59 100644
--- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue
+++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue
@@ -28,15 +28,13 @@ import WidgetSelectDefault from '@/renderer/extensions/vueNodes/widgets/componen
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
import WidgetWithControl from '@/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue'
import type { LayoutMode } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
+import { resolveWidgetSelectMode } from '@/renderer/extensions/vueNodes/widgets/utils/widgetSelectMode'
import type { ResultItemType } from '@/schemas/apiSchema'
-import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
-import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type {
SimplifiedControlWidget,
SimplifiedWidget,
WidgetValue
} from '@/types/simplifiedWidget'
-import type { AssetKind } from '@/types/widgetTypes'
type SelectControlWidget = SimplifiedControlWidget
@@ -47,84 +45,24 @@ const props = defineProps<{
const modelValue = defineModel()
-const comboSpec = computed(() => {
- if (props.widget.spec && isComboInputSpec(props.widget.spec)) {
- return props.widget.spec
- }
- return undefined
-})
-
-const specDescriptor = computed<{
- kind: AssetKind
- allowUpload: boolean
- folder: ResultItemType | undefined
- subfolder: string | undefined
-}>(() => {
- const spec = comboSpec.value
- if (!spec) {
- return {
- kind: 'unknown',
- allowUpload: false,
- folder: undefined,
- subfolder: undefined
- }
- }
-
- const {
- image_upload,
- animated_image_upload,
- video_upload,
- image_folder,
- audio_upload,
- mesh_upload,
- upload_subfolder
- } = spec
-
- let kind: AssetKind = 'unknown'
- if (video_upload) {
- kind = 'video'
- } else if (image_upload || animated_image_upload) {
- kind = 'image'
- } else if (audio_upload) {
- kind = 'audio'
- } else if (mesh_upload) {
- kind = 'mesh'
- }
-
- // TODO: add support for models (checkpoints, VAE, LoRAs, etc.) -- get widgetType from spec
-
- const allowUpload =
- image_upload === true ||
- animated_image_upload === true ||
- video_upload === true ||
- audio_upload === true ||
- mesh_upload === true
-
- const folder = mesh_upload ? 'input' : image_folder
-
- return {
- kind,
- allowUpload,
- folder,
- subfolder: upload_subfolder
- }
-})
-
-const isAssetMode = computed(
- () =>
- assetService.shouldUseAssetBrowser(props.nodeType, props.widget.name) ||
- (assetService.isAssetAPIEnabled() && props.widget.type === 'asset')
+const mode = computed(() =>
+ resolveWidgetSelectMode(props.widget, {
+ assetApiEnabled: assetService.isAssetAPIEnabled(),
+ useAssetBrowser: assetService.shouldUseAssetBrowser(
+ props.nodeType,
+ props.widget.name
+ )
+ })
)
-const assetKind = computed(() => specDescriptor.value.kind)
-const isDropdownUIWidget = computed(
- () => isAssetMode.value || assetKind.value !== 'unknown'
-)
-const allowUpload = computed(() => specDescriptor.value.allowUpload)
+const assetKind = computed(() => mode.value.assetKind)
+const isAssetMode = computed(() => mode.value.isAssetMode)
+const isDropdownUIWidget = computed(() => mode.value.isDropdownUIWidget)
+const allowUpload = computed(() => mode.value.allowUpload)
const uploadFolder = computed(() => {
- return specDescriptor.value.folder ?? 'input'
+ return mode.value.uploadFolder ?? 'input'
})
-const uploadSubfolder = computed(() => specDescriptor.value.subfolder)
+const uploadSubfolder = computed(() => mode.value.uploadSubfolder)
const defaultLayoutMode = computed(() => {
return isAssetMode.value ? 'list' : 'grid'
})
diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.test.ts
index df0714ba643..bf8495efd7d 100644
--- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.test.ts
+++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.test.ts
@@ -426,6 +426,26 @@ describe('WidgetSelectDefault', () => {
expect(screen.getByTestId('widget-select-default-trigger')).toBeDisabled()
})
+ it('closes an open overlay when widget options become disabled', async () => {
+ const { rerender, user } = renderComponent(createWidget(['a', 'b']), 'a')
+
+ await openDropdown(user)
+ expect(
+ screen.getByTestId('widget-select-default-overlay')
+ ).toBeInTheDocument()
+
+ await rerender({
+ widget: createWidget(['a', 'b'], { disabled: true })
+ })
+
+ await waitFor(() => {
+ expect(
+ screen.queryByTestId('widget-select-default-overlay')
+ ).not.toBeInTheDocument()
+ })
+ expect(screen.getByTestId('widget-select-default-trigger')).toBeDisabled()
+ })
+
it('uses getOptionLabel for trigger and option labels', async () => {
const { user } = renderComponent(
createWidget(['hash-a'], {
diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue
index 5ba039de40b..ab7a2a51dbe 100644
--- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue
+++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue
@@ -162,7 +162,7 @@ import {
ComboboxRoot,
ComboboxTrigger
} from 'reka-ui'
-import { computed, ref } from 'vue'
+import { computed, ref, watch } from 'vue'
import type { CSSProperties } from 'vue'
import { useRestoreFocusOnViewportPointer } from '@/renderer/extensions/vueNodes/widgets/composables/useRestoreFocusOnViewportPointer'
@@ -378,4 +378,8 @@ function handleOpenChange(open: boolean) {
searchQuery.value = ''
}
}
+
+watch(disabled, (isDisabled) => {
+ if (isDisabled) handleOpenChange(false)
+})
diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.test.ts
index ad24302aeb9..b6bfbe24566 100644
--- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.test.ts
+++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.test.ts
@@ -1,7 +1,8 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { createTestingPinia } from '@pinia/testing'
-import { render, screen } from '@testing-library/vue'
+import { render, screen, waitFor, within } from '@testing-library/vue'
+import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { computed, nextTick, ref } from 'vue'
import type { Ref } from 'vue'
@@ -14,6 +15,12 @@ import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/compone
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { createMockWidget } from './widgetTestUtils'
+vi.mock('@/composables/useFeatureFlags', () => ({
+ useFeatureFlags: () => ({
+ flags: { modelUploadButtonEnabled: false }
+ })
+}))
+
const mockCheckState = vi.hoisted(() => vi.fn())
const mockAssetsData = vi.hoisted(() => ({ items: [] as AssetItem[] }))
@@ -38,7 +45,10 @@ vi.mock('@/scripts/api', () => ({
fetchApi: vi.fn(),
apiURL: vi.fn((url: string) => url),
addEventListener: vi.fn(),
- removeEventListener: vi.fn()
+ removeEventListener: vi.fn(),
+ getServerFeature: vi.fn(
+ (_name: string, defaultValue: unknown) => defaultValue
+ )
}
}))
@@ -211,6 +221,52 @@ describe('WidgetSelectDropdown', () => {
expect(screen.getByText('model_a.safetensors')).toBeDefined()
})
+ it('closes an open core selector when it becomes linked', async () => {
+ mockItemsRef.value = [
+ { id: 'input-0', name: 'stale.png', label: 'stale.png' }
+ ]
+ mockSelectedSetRef.value = new Set(['input-0'])
+ const widget = createMockWidget({
+ value: 'stale.png',
+ name: 'image',
+ type: 'asset',
+ options: {
+ values: ['stale.png']
+ }
+ })
+ const { rerender } = renderComponent(widget, 'stale.png')
+ const user = userEvent.setup()
+
+ await user.click(screen.getByRole('button', { name: 'stale.png' }))
+ expect(await screen.findByTestId('form-dropdown-menu')).toBeVisible()
+
+ const linkedWidget: SimplifiedWidget = {
+ ...widget,
+ linkedDisplay: 'control',
+ options: { ...widget.options, disabled: true }
+ }
+ await rerender({ widget: linkedWidget, modelValue: 'stale.png' })
+
+ await waitFor(() =>
+ expect(screen.queryByTestId('form-dropdown-menu')).not.toBeInTheDocument()
+ )
+
+ const content = screen.getByTestId('linked-widget-content')
+ expect(content).toHaveAttribute('inert')
+ expect(content).toHaveAttribute('aria-hidden', 'true')
+ expect(
+ screen.queryByRole('button', { name: 'stale.png' })
+ ).not.toBeInTheDocument()
+ expect(
+ screen.getByRole('img', { name: 'image: Linked input' })
+ ).toBeVisible()
+ for (const button of within(content).getAllByRole('button', {
+ hidden: true
+ })) {
+ expect(button).toBeDisabled()
+ }
+ })
+
describe('composable wiring', () => {
const items: FormDropdownItem[] = [
{ id: 'input-0', name: 'cat.png', label: 'cat.png' },
diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue
index ade6869453b..3d01964e74b 100644
--- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue
+++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue
@@ -200,6 +200,7 @@ async function updateFiles(files: File[]) {
:show-base-model-filter
:base-model-options
:is-uploading
+ :close-on-disable="Boolean(widget.linkedDisplay)"
v-bind="combinedProps"
:loading-more="outputMediaAssets.isLoadingMore.value"
class="w-full"
diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.test.ts
index 897c17cc3e3..1bdda93aabe 100644
--- a/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.test.ts
+++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.test.ts
@@ -174,6 +174,49 @@ describe('WidgetTextarea Value Binding', () => {
expect(textarea.value).toBe('initial content')
})
+ it('keeps an unlinked disabled textarea disabled and readonly', () => {
+ const widget = createTextareaWidget('readable content', {
+ disabled: true
+ })
+ renderComponent(widget, 'readable content')
+
+ const textarea = screen.getByRole('textbox')
+ expect(textarea).toHaveAttribute('readonly')
+ expect(textarea).toBeDisabled()
+ })
+
+ it('restores the textarea after a link is removed', async () => {
+ const widget = createTextareaWidget('stale multiline', {
+ disabled: true
+ })
+ widget.linkedDisplay = 'expanding'
+ const { rerender } = renderComponent(widget, 'stale multiline')
+ const textarea = screen.getByTestId('linked-widget-content')
+
+ expect(textarea).toBeDisabled()
+ expect(textarea).toHaveAttribute('inert')
+ expect(textarea).toHaveAttribute('aria-hidden', 'true')
+ expect(screen.queryByRole('textbox')).toBeNull()
+ textarea.focus()
+ expect(textarea).not.toHaveFocus()
+ expect(
+ screen.getByRole('img', {
+ name: 'test_textarea: Linked input'
+ })
+ ).toBeVisible()
+ expect(screen.queryByRole('button')).toBeNull()
+
+ await rerender({
+ widget: createTextareaWidget('stale multiline'),
+ modelValue: 'stale multiline'
+ })
+
+ expect(screen.queryByRole('img')).toBeNull()
+ expect(screen.getByRole('textbox')).toBeVisible()
+ expect(screen.getByRole('textbox')).toBeEnabled()
+ expect(screen.getByRole('textbox')).toHaveValue('stale multiline')
+ })
+
it('uses widget name as placeholder when no placeholder provided', () => {
const widget = createTextareaWidget('test')
const { container } = renderComponent(widget, 'test')
diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.vue
index d93dc889531..4f79f933538 100644
--- a/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.vue
+++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.vue
@@ -3,7 +3,9 @@
:class="
cn(
'group relative rounded-lg transition-all focus-within:ring focus-within:ring-component-node-widget-background-highlighted',
- !isReadOnly && 'hover:bg-component-node-widget-background-hovered',
+ !isReadOnly &&
+ !isLinked &&
+ 'hover:bg-component-node-widget-background-hovered',
widget.borderStyle
)
"
@@ -25,12 +27,18 @@
WidgetInputBaseClass,
'size-full resize-none text-(length:--comfy-textarea-font-size) leading-normal',
!hideLayoutField && 'pt-5',
+ isLinked && 'invisible',
// Avoid overflow-auto when idle to prevent per-textarea compositing layers.
'overflow-hidden hover:overflow-auto focus:overflow-auto'
)
"
:placeholder
:readonly="isReadOnly"
+ :disabled="isLinked || filteredProps.disabled"
+ :aria-hidden="isLinked ? 'true' : undefined"
+ :inert="isLinked ? true : undefined"
+ :tabindex="isLinked ? -1 : undefined"
+ :data-testid="isLinked ? 'linked-widget-content' : undefined"
data-capture-wheel="true"
@pointerdown.capture.stop="trackFocus"
@pointermove.capture.stop
@@ -38,7 +46,7 @@
@contextmenu.capture="handleContextMenu"
/>
+
@@ -68,6 +77,7 @@ import {
} from '@/utils/widgetPropFilter'
import { WidgetInputBaseClass } from './layout'
+import LinkedWidgetStatus from './LinkedWidgetStatus.vue'
const { widget, placeholder = '' } = defineProps<{
widget: SimplifiedWidget
@@ -96,6 +106,7 @@ const id = useId()
const isReadOnly = computed(() =>
Boolean(widget.options?.read_only || widget.options?.disabled)
)
+const isLinked = computed(() => widget.linkedDisplay === 'expanding')
function handleContextMenu(e: MouseEvent) {
if (isNodeOptionsOpen() || isFocused.value) {
diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.test.ts
index e0517fcc4f0..5956ff8e576 100644
--- a/src/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.test.ts
+++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.test.ts
@@ -286,6 +286,20 @@ describe('WidgetToggleSwitch Value Binding', () => {
}
})
+ it('disables ToggleGroup when disabled is set', () => {
+ const widget = createToggleWidget(false, {
+ on: 'yes',
+ off: 'no',
+ disabled: true
+ })
+ mountComponent(widget, false)
+
+ const buttons = screen.getAllByRole('button')
+ for (const button of buttons) {
+ expect(button).toBeDisabled()
+ }
+ })
+
it('keeps an implicit switch unchanged when read_only is set', async () => {
const widget = createToggleWidget(false, { read_only: true })
const onModelUpdate = vi.fn()
@@ -314,6 +328,47 @@ describe('WidgetToggleSwitch Value Binding', () => {
expect(control).not.toBeChecked()
})
+ it('exposes a linked status on the implicit switch surface', () => {
+ const widget = createToggleWidget(false)
+ widget.linkedDisplay = 'switch'
+ mountComponent(widget, false)
+
+ const content = screen.getByTestId('linked-widget-content')
+ const control = screen.getByRole('switch', { hidden: true })
+ expect(content).toHaveAttribute('inert')
+ expect(content).toHaveAttribute('aria-hidden', 'true')
+ expect(control).toBeDisabled()
+ expect(screen.queryByRole('switch')).toBeNull()
+ control.focus()
+ expect(control).not.toHaveFocus()
+ expect(
+ screen.getByRole('img', {
+ name: 'test_toggle: Linked input'
+ })
+ ).toHaveAttribute('data-linked-display', 'switch')
+ })
+
+ it('keeps labeled linked toggles disabled behind one control status', () => {
+ const widget = createToggleWidget(false, {
+ on: 'enabled',
+ off: 'disabled'
+ })
+ widget.linkedDisplay = 'control'
+ mountComponent(widget, false)
+
+ const content = screen.getByTestId('linked-widget-content')
+ const controls = screen.getAllByRole('button', { hidden: true })
+ expect(content).toHaveAttribute('inert')
+ expect(content).toHaveAttribute('aria-hidden', 'true')
+ expect(screen.queryByRole('button')).toBeNull()
+ for (const control of controls) expect(control).toBeDisabled()
+ expect(
+ screen.getByRole('img', {
+ name: 'test_toggle: Linked input'
+ })
+ ).toHaveAttribute('data-linked-display', 'control')
+ })
+
it('does not emit when clicking already-selected option', async () => {
const widget = createToggleWidget(false, { on: 'yes', off: 'no' })
const onModelUpdate = vi.fn()
diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.vue
index 30a02c1e14d..0704f61e887 100644
--- a/src/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.vue
+++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.vue
@@ -1,10 +1,21 @@
-
+
-
+
+
+
+
@@ -53,6 +72,7 @@ import { useHideLayoutField } from '@/types/widgetTypes'
import { cn } from '@comfyorg/tailwind-utils'
import { WidgetInputBaseClass } from './layout'
+import LinkedWidgetStatus from './LinkedWidgetStatus.vue'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const { widget } = defineProps<{
@@ -67,6 +87,7 @@ const { t } = useI18n()
const hasLabels = computed(() => {
return widget.options?.on != null || widget.options?.off != null
})
+const isLinkedSwitch = computed(() => widget.linkedDisplay === 'switch')
function handleOptionChange(value: string | undefined) {
if (value) {
diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.test.ts
index 4e66ae4ba4a..cb09ba7cf6c 100644
--- a/src/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.test.ts
+++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.test.ts
@@ -1,7 +1,10 @@
-import { render, screen } from '@testing-library/vue'
+import { createTestingPinia } from '@pinia/testing'
+import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
+import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
-import { defineComponent, nextTick } from 'vue'
+import { defineComponent, nextTick, ref } from 'vue'
+import { createI18n } from 'vue-i18n'
import type {
SafeControlWidget,
@@ -10,6 +13,7 @@ import type {
} from '@/types/simplifiedWidget'
import WidgetWithControl from './WidgetWithControl.vue'
+import ValueControlPopover from './ValueControlPopover.vue'
import { createMockWidget } from './widgetTestUtils'
const PopoverStub = defineComponent({
@@ -24,9 +28,12 @@ const PopoverStub = defineComponent({
const ValueControlButtonStub = defineComponent({
name: 'ValueControlButton',
- props: { mode: { type: String, default: '' } },
+ props: {
+ disabled: { type: Boolean, default: false },
+ mode: { type: String, default: '' }
+ },
template:
- '{{ mode }}'
+ '{{ mode }}'
})
const ValueControlPopoverStub = defineComponent({
@@ -43,6 +50,23 @@ const ValueControlPopoverStub = defineComponent({
`
})
+const PortaledPopoverStub = defineComponent({
+ name: 'Popover',
+ components: { ValueControlPopover },
+ setup() {
+ const open = ref(false)
+ return { open }
+ },
+ template: `
+
+
+
+
+
+
+ `
+})
+
const RenderedComponent = defineComponent({
name: 'RenderedComponent',
props: {
@@ -58,6 +82,12 @@ const RenderedComponent = defineComponent({
`
})
+const i18n = createI18n({
+ legacy: false,
+ locale: 'en',
+ messages: { en: {} }
+})
+
const makeControlWidget = (
update: (value: WidgetValue) => void = () => {},
initial = 'randomize'
@@ -87,6 +117,15 @@ const mount = (widget: SimplifiedControlWidget, modelValue = 0) =>
props: { widget, modelValue, component: RenderedComponent }
})
+const mountWithPortal = (widget: SimplifiedControlWidget, modelValue = 0) =>
+ render(WidgetWithControl, {
+ global: {
+ plugins: [createTestingPinia(), i18n, PrimeVue],
+ stubs: { Popover: PortaledPopoverStub }
+ },
+ props: { widget, modelValue, component: RenderedComponent }
+ })
+
describe('WidgetWithControl', () => {
it('renders the passed component with widget and modelValue', () => {
mount(makeControlWidget(), 42)
@@ -119,4 +158,43 @@ describe('WidgetWithControl', () => {
mount(makeControlWidget(update, 'randomize'))
expect(update).not.toHaveBeenCalled()
})
+
+ it('disables the value control with the wrapped widget', () => {
+ const widget = makeControlWidget()
+ widget.options = { disabled: true }
+ mount(widget)
+
+ expect(screen.getByTestId('control-button')).toBeDisabled()
+ })
+
+ it('unmounts an open value-control portal when linked', async () => {
+ const update = vi.fn<(value: WidgetValue) => void>()
+ const widget = makeControlWidget(update, 'randomize')
+ const { rerender } = mountWithPortal(widget)
+ const user = userEvent.setup()
+
+ await user.click(await screen.findByTestId('value-control'))
+ expect(await screen.findAllByRole('radio')).toHaveLength(4)
+
+ const linkedWidget: SimplifiedControlWidget = {
+ ...widget,
+ linkedDisplay: 'control',
+ options: { ...widget.options, disabled: true }
+ }
+ update.mockClear()
+ await rerender({
+ widget: linkedWidget,
+ modelValue: 0,
+ component: RenderedComponent
+ })
+
+ await waitFor(() => expect(screen.queryAllByRole('radio')).toHaveLength(0))
+ const linkedButton = screen.getByTestId('value-control')
+ expect(linkedButton).toBeDisabled()
+ linkedButton.focus()
+ expect(linkedButton).not.toHaveFocus()
+ await user.click(linkedButton)
+ await user.keyboard('{Enter}')
+ expect(update).not.toHaveBeenCalled()
+ })
})
diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue
index 13e35d916da..37fd480165e 100644
--- a/src/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue
+++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue
@@ -28,12 +28,22 @@ watch(controlModel, props.widget.controlWidget.update)
diff --git a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.test.ts b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.test.ts
index 01115dc8fb0..87ff7e36af7 100644
--- a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.test.ts
+++ b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.test.ts
@@ -81,6 +81,9 @@ const MockPopover = {
}
interface MountDropdownOptions {
+ closeOnDisable?: boolean
+ disabled?: boolean
+ isOpen?: boolean
searcher?: (
query: string,
items: FormDropdownItem[],
@@ -105,6 +108,9 @@ function mountDropdown(
const result = render(FormDropdown, {
props: {
items,
+ closeOnDisable: options.closeOnDisable,
+ disabled: options.disabled,
+ ...(options.isOpen === undefined ? {} : { isOpen: options.isOpen }),
multiple: options.multiple,
selected: options.selected,
searcher: options.searcher,
@@ -457,6 +463,56 @@ describe('FormDropdown', () => {
expect(onUpdateIsOpen).toHaveBeenLastCalledWith(false)
})
+ it('closes when a linked display disables the dropdown', async () => {
+ const onUpdateIsOpen = vi.fn()
+ const items = [createItem('1', 'alpha')]
+ const { rerender, user } = mountDropdown(items, {
+ closeOnDisable: true,
+ onUpdateIsOpen
+ })
+ await openDropdown(user)
+
+ expect(onUpdateIsOpen).toHaveBeenLastCalledWith(true)
+
+ await rerender({
+ items,
+ closeOnDisable: true,
+ disabled: true,
+ 'onUpdate:isOpen': onUpdateIsOpen
+ })
+
+ expect(onUpdateIsOpen).toHaveBeenLastCalledWith(false)
+ })
+
+ it('closes when mounted disabled with closeOnDisable', () => {
+ const onUpdateIsOpen = vi.fn()
+ mountDropdown([createItem('1', 'alpha')], {
+ closeOnDisable: true,
+ disabled: true,
+ isOpen: true,
+ onUpdateIsOpen
+ })
+
+ expect(onUpdateIsOpen).toHaveBeenCalledWith(false)
+ })
+
+ it('stays open when disabled without closeOnDisable', async () => {
+ const onUpdateIsOpen = vi.fn()
+ const items = [createItem('1', 'alpha')]
+ const { rerender, user } = mountDropdown(items, { onUpdateIsOpen })
+ await openDropdown(user)
+
+ expect(onUpdateIsOpen).toHaveBeenLastCalledWith(true)
+
+ await rerender({
+ items,
+ disabled: true,
+ 'onUpdate:isOpen': onUpdateIsOpen
+ })
+
+ expect(onUpdateIsOpen).toHaveBeenLastCalledWith(true)
+ })
+
it('stays open on a pointerdown inside the menu', async () => {
const onUpdateIsOpen = vi.fn()
const { user } = mountDropdown([createItem('1', 'alpha')], {
diff --git a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue
index 81136a03c7f..a65aa12a13b 100644
--- a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue
+++ b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue
@@ -7,7 +7,7 @@ import {
} from '@vueuse/core'
import Popover from 'primevue/popover'
import type { ComponentPublicInstance } from 'vue'
-import { computed, ref, useTemplateRef } from 'vue'
+import { computed, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
@@ -41,6 +41,7 @@ interface Props {
uploadable?: boolean
disabled?: boolean
+ closeOnDisable?: boolean
accept?: string
filterOptions?: FilterOption[]
sortOptions?: SortOption[]
@@ -69,6 +70,7 @@ const {
multiple = false,
uploadable = false,
disabled = false,
+ closeOnDisable = false,
accept,
filterOptions = [],
sortOptions = getDefaultSortOptions(),
@@ -210,12 +212,20 @@ function focusTrigger() {
const closeDropdown = ({ restoreFocus = false } = {}) => {
if (popoverRef.value) {
popoverRef.value.hide?.()
- isOpen.value = false
}
+ isOpen.value = false
if (restoreFocus) focusTrigger()
}
+watch(
+ [() => disabled, () => closeOnDisable],
+ ([isDisabled, shouldClose]) => {
+ if (isDisabled && shouldClose) closeDropdown()
+ },
+ { immediate: true }
+)
+
/**
* Dismiss on `pointerdown` rather than PrimeVue's default `click` (mouseup) so
* the dropdown closes the instant an outside press lands, and a focused inner
diff --git a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownInput.vue b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownInput.vue
index 66486215045..248134d9e11 100644
--- a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownInput.vue
+++ b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownInput.vue
@@ -78,6 +78,7 @@ defineExpose({ focus, showPicker })
>
stale prompt'
+ }
+ })
+
+ const content = screen.getByTestId('linked-widget-content')
+ const control = screen.getByRole('button', { hidden: true })
+ expect(content).toHaveAttribute('inert')
+ expect(content).toHaveAttribute('aria-hidden', 'true')
+ expect(control).toBeDisabled()
+ expect(screen.queryByRole('button')).toBeNull()
+ expect(
+ screen.getByRole('img', { name: 'prompt: Linked input' })
+ ).toBeVisible()
+
+ await rerender({ widget: { name: 'prompt' } })
+
+ expect(screen.queryByRole('img')).toBeNull()
+ expect(screen.getByRole('button', { name: 'stale prompt' })).toBeVisible()
+ })
})
// user-event models clicks/keyboard but not raw pointerdown/move/up.
diff --git a/src/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue b/src/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue
index d7824b8e482..ab4e07160dc 100644
--- a/src/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue
+++ b/src/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue
@@ -5,13 +5,22 @@ import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { useHideLayoutField } from '@/types/widgetTypes'
import { cn } from '@comfyorg/tailwind-utils'
-const { widget, rootClass } = defineProps<{
+import LinkedWidgetStatus from '../LinkedWidgetStatus.vue'
+
+const {
+ widget,
+ rootClass,
+ linkedStatusRounded = 'md',
+ showLinkedStatus = true
+} = defineProps<{
widget: Pick<
SimplifiedWidget,
- 'name' | 'label' | 'borderStyle'
+ 'name' | 'label' | 'borderStyle' | 'linkedDisplay'
>
rootClass?: string
noBorder?: boolean
+ linkedStatusRounded?: 'md' | 'lg'
+ showLinkedStatus?: boolean
}>()
const hideLayoutField = useHideLayoutField()
@@ -21,6 +30,11 @@ const borderStyle = computed(() =>
widget.borderStyle
)
)
+const linkedDisplay = computed(() =>
+ showLinkedStatus && widget.linkedDisplay === 'control'
+ ? widget.linkedDisplay
+ : undefined
+)
@@ -46,7 +60,7 @@ const borderStyle = computed(() =>
@pointermove.stop
@pointerup.stop
>
-
+
+
+
+
diff --git a/src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts b/src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts
index f197ed7b957..f0068ba9acf 100644
--- a/src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts
+++ b/src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts
@@ -306,6 +306,28 @@ export const shouldRenderAsVue = (widget: Partial): boolean => {
return !widget.options?.canvasOnly && !!widget.type
}
+export type LinkedWidgetFamily = 'boolean' | 'combo' | 'control' | 'textarea'
+
+const linkedWidgetFamilies = new Map([
+ [WidgetInputText, 'control'],
+ [WidgetInputNumber, 'control'],
+ [WidgetToggleSwitch, 'boolean'],
+ [WidgetSelect, 'combo'],
+ [WidgetColorPicker, 'control'],
+ [WidgetTextarea, 'textarea']
+])
+
+const LINKED_WIDGET_EXCLUDED_ALIASES = new Set(['asset', 'gradientslider'])
+
+export function getLinkedWidgetFamily(
+ type: string
+): LinkedWidgetFamily | undefined {
+ if (LINKED_WIDGET_EXCLUDED_ALIASES.has(type)) return undefined
+
+ const component = getComponent(type)
+ return component ? linkedWidgetFamilies.get(component) : undefined
+}
+
const EXPANDING_TYPES = [
'textarea',
'markdown',
diff --git a/src/renderer/extensions/vueNodes/widgets/utils/linkedWidgetDisplay.test.ts b/src/renderer/extensions/vueNodes/widgets/utils/linkedWidgetDisplay.test.ts
new file mode 100644
index 00000000000..cacbb28946b
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/widgets/utils/linkedWidgetDisplay.test.ts
@@ -0,0 +1,200 @@
+import { describe, expect, it } from 'vitest'
+
+import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
+
+import { resolveLinkedWidgetDisplay } from './linkedWidgetDisplay'
+
+const linkedContext = {
+ assetApiEnabled: false,
+ linked: true,
+ useAssetBrowser: false
+}
+
+describe('resolveLinkedWidgetDisplay', () => {
+ it.for([
+ ['text', 'control'],
+ ['string', 'control'],
+ ['STRING', 'control'],
+ ['int', 'control'],
+ ['INT', 'control'],
+ ['float', 'control'],
+ ['FLOAT', 'control'],
+ ['number', 'control'],
+ ['slider', 'control'],
+ ['boolean', 'switch'],
+ ['BOOLEAN', 'switch'],
+ ['toggle', 'switch'],
+ ['combo', 'control'],
+ ['COMBO', 'control'],
+ ['color', 'control'],
+ ['COLOR', 'control'],
+ ['textarea', 'expanding'],
+ ['TEXTAREA', 'expanding'],
+ ['multiline', 'expanding'],
+ ['customtext', 'expanding']
+ ] as const)('maps the registered %s type to %s', ([type, display]) => {
+ expect(
+ resolveLinkedWidgetDisplay(
+ { name: 'value', type },
+ undefined,
+ linkedContext
+ )
+ ).toBe(display)
+ })
+
+ it.for([
+ 'String',
+ 'CoMbO',
+ 'asset',
+ 'gradientslider',
+ 'range',
+ 'curve',
+ 'imagecrop',
+ 'boundingbox',
+ 'chart',
+ 'galleria',
+ 'colors',
+ 'audiorecord',
+ 'markdown',
+ 'load3D',
+ 'painter',
+ 'imagecompare',
+ 'boundingboxes',
+ 'videoedit',
+ 'legacy',
+ 'custom_widget'
+ ])('leaves the linked %s type rendered', (type) => {
+ expect(
+ resolveLinkedWidgetDisplay(
+ { name: 'value', type },
+ undefined,
+ linkedContext
+ )
+ ).toBeUndefined()
+ })
+
+ it('leaves upload and asset-browser combos rendered', () => {
+ expect(
+ resolveLinkedWidgetDisplay(
+ {
+ name: 'image',
+ type: 'COMBO',
+ spec: { type: 'COMBO', name: 'image', image_upload: true }
+ },
+ undefined,
+ linkedContext
+ )
+ ).toBeUndefined()
+ expect(
+ resolveLinkedWidgetDisplay(
+ { name: 'ckpt_name', type: 'COMBO' },
+ undefined,
+ { ...linkedContext, useAssetBrowser: true }
+ )
+ ).toBeUndefined()
+ })
+
+ it.for([
+ {
+ nodeType: 'LoadImage',
+ name: 'image',
+ type: 'asset',
+ spec: { type: 'COMBO', name: 'image', image_upload: true }
+ },
+ {
+ nodeType: 'LoadImageMask',
+ name: 'image',
+ type: 'COMBO',
+ spec: { type: 'COMBO', name: 'image', image_upload: true }
+ },
+ {
+ nodeType: 'LoadImageOutput',
+ name: 'image',
+ type: 'asset',
+ spec: { type: 'COMBO', name: 'image', image_upload: true }
+ },
+ {
+ nodeType: 'LoadVideo',
+ name: 'file',
+ type: 'COMBO',
+ spec: { type: 'COMBO', name: 'file', video_upload: true }
+ },
+ {
+ nodeType: 'LoadAudio',
+ name: 'audio',
+ type: 'combo',
+ spec: { type: 'COMBO', name: 'audio', audio_upload: true }
+ }
+ ] satisfies Array<{
+ nodeType: string
+ name: string
+ type: string
+ spec: InputSpec
+ }>)(
+ 'includes the linked core $nodeType $name selector',
+ ({ nodeType, name, type, spec }) => {
+ expect(
+ resolveLinkedWidgetDisplay({ name, type, spec }, undefined, {
+ ...linkedContext,
+ assetApiEnabled: type === 'asset',
+ coreNodeType: nodeType,
+ useAssetBrowser: type === 'asset'
+ })
+ ).toBe('control')
+ }
+ )
+
+ it.for([
+ {
+ label: 'custom node with a core type name',
+ widget: {
+ name: 'image',
+ type: 'COMBO',
+ spec: { type: 'COMBO', name: 'image', image_upload: true }
+ },
+ coreNodeType: undefined
+ },
+ {
+ label: 'core node with the wrong widget name',
+ widget: {
+ name: 'custom_image',
+ type: 'COMBO',
+ spec: {
+ type: 'COMBO',
+ name: 'custom_image',
+ image_upload: true
+ }
+ },
+ coreNodeType: 'LoadImage'
+ },
+ {
+ label: 'core node with an unregistered mixed-case widget',
+ widget: {
+ name: 'image',
+ type: 'CoMbO',
+ spec: { type: 'COMBO', name: 'image', image_upload: true }
+ },
+ coreNodeType: 'LoadImage'
+ },
+ {
+ label: 'out-of-scope core 3D loader',
+ widget: {
+ name: 'model_file',
+ type: 'load3D',
+ spec: { type: 'COMBO', name: 'model_file' }
+ },
+ coreNodeType: 'Load3D'
+ }
+ ] satisfies Array<{
+ label: string
+ widget: { name: string; type: string; spec: InputSpec }
+ coreNodeType: string | undefined
+ }>)('excludes a $label', ({ widget, coreNodeType }) => {
+ expect(
+ resolveLinkedWidgetDisplay(widget, undefined, {
+ ...linkedContext,
+ coreNodeType
+ })
+ ).toBeUndefined()
+ })
+})
diff --git a/src/renderer/extensions/vueNodes/widgets/utils/linkedWidgetDisplay.ts b/src/renderer/extensions/vueNodes/widgets/utils/linkedWidgetDisplay.ts
new file mode 100644
index 00000000000..01d3434b70d
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/widgets/utils/linkedWidgetDisplay.ts
@@ -0,0 +1,62 @@
+import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
+import { getLinkedWidgetFamily } from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
+import { resolveWidgetSelectMode } from '@/renderer/extensions/vueNodes/widgets/utils/widgetSelectMode'
+import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
+import type { LinkedWidgetDisplay } from '@/types/simplifiedWidget'
+
+interface LinkedWidgetDisplayWidget {
+ name: string
+ type: string
+ spec?: InputSpec
+}
+
+interface LinkedWidgetDisplayContext {
+ assetApiEnabled: boolean
+ coreNodeType?: string
+ linked: boolean
+ useAssetBrowser: boolean
+}
+
+const CORE_MEDIA_SELECTOR_WIDGETS = new Map([
+ ['LoadImage', 'image'],
+ ['LoadImageMask', 'image'],
+ ['LoadImageOutput', 'image'],
+ ['LoadVideo', 'file'],
+ ['LoadAudio', 'audio']
+])
+
+function isCoreMediaSelector(
+ widget: LinkedWidgetDisplayWidget,
+ context: LinkedWidgetDisplayContext
+): boolean {
+ return (
+ CORE_MEDIA_SELECTOR_WIDGETS.get(context.coreNodeType ?? '') ===
+ widget.name &&
+ (widget.type === 'asset' || getLinkedWidgetFamily(widget.type) === 'combo')
+ )
+}
+
+export function resolveLinkedWidgetDisplay(
+ widget: LinkedWidgetDisplayWidget,
+ options: IWidgetOptions | undefined,
+ context: LinkedWidgetDisplayContext
+): LinkedWidgetDisplay | undefined {
+ if (!context.linked) return undefined
+
+ const family = getLinkedWidgetFamily(widget.type)
+ if (isCoreMediaSelector(widget, context)) return 'control'
+ if (!family) return undefined
+
+ if (family === 'combo') {
+ const mode = resolveWidgetSelectMode(widget, context)
+ if (mode.isDropdownUIWidget) return undefined
+ }
+
+ if (family === 'textarea') return 'expanding'
+
+ if (family === 'boolean' && options?.on == null && options?.off == null) {
+ return 'switch'
+ }
+
+ return 'control'
+}
diff --git a/src/renderer/extensions/vueNodes/widgets/utils/widgetSelectMode.ts b/src/renderer/extensions/vueNodes/widgets/utils/widgetSelectMode.ts
new file mode 100644
index 00000000000..b21dfc557a7
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/widgets/utils/widgetSelectMode.ts
@@ -0,0 +1,54 @@
+import type { ResultItemType } from '@/schemas/apiSchema'
+import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
+import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
+import type { AssetKind } from '@/types/widgetTypes'
+
+interface WidgetSelectModeWidget {
+ type: string
+ spec?: InputSpec
+}
+
+interface WidgetSelectModeContext {
+ assetApiEnabled: boolean
+ useAssetBrowser: boolean
+}
+
+export function resolveWidgetSelectMode(
+ widget: WidgetSelectModeWidget,
+ { assetApiEnabled, useAssetBrowser }: WidgetSelectModeContext
+) {
+ const spec =
+ widget.spec && isComboInputSpec(widget.spec) ? widget.spec : undefined
+
+ let assetKind: AssetKind = 'unknown'
+ if (spec?.video_upload) {
+ assetKind = 'video'
+ } else if (spec?.image_upload || spec?.animated_image_upload) {
+ assetKind = 'image'
+ } else if (spec?.audio_upload) {
+ assetKind = 'audio'
+ } else if (spec?.mesh_upload) {
+ assetKind = 'mesh'
+ }
+
+ const allowUpload =
+ spec?.image_upload === true ||
+ spec?.animated_image_upload === true ||
+ spec?.video_upload === true ||
+ spec?.audio_upload === true ||
+ spec?.mesh_upload === true
+ const uploadFolder: ResultItemType | undefined = spec?.mesh_upload
+ ? 'input'
+ : spec?.image_folder
+ const isAssetMode =
+ useAssetBrowser || (assetApiEnabled && widget.type === 'asset')
+
+ return {
+ allowUpload,
+ assetKind,
+ isAssetMode,
+ isDropdownUIWidget: isAssetMode || assetKind !== 'unknown',
+ uploadFolder,
+ uploadSubfolder: spec?.upload_subfolder
+ }
+}
diff --git a/src/types/simplifiedWidget.ts b/src/types/simplifiedWidget.ts
index 54682225769..30152e15239 100644
--- a/src/types/simplifiedWidget.ts
+++ b/src/types/simplifiedWidget.ts
@@ -45,6 +45,8 @@ export interface LinkedUpstreamInfo {
outputName?: string
}
+export type LinkedWidgetDisplay = 'control' | 'expanding' | 'switch'
+
export interface SimplifiedWidget<
T extends WidgetValue = WidgetValue,
O extends IWidgetOptions = IWidgetOptions
@@ -89,6 +91,8 @@ export interface SimplifiedWidget<
controlWidget?: SafeControlWidget
linkedUpstream?: LinkedUpstreamInfo
+
+ linkedDisplay?: LinkedWidgetDisplay
}
export interface SimplifiedControlWidget<
diff --git a/tools/devtools/dev_nodes.py b/tools/devtools/dev_nodes.py
index 660518d846b..31763cdfa46 100644
--- a/tools/devtools/dev_nodes.py
+++ b/tools/devtools/dev_nodes.py
@@ -7,6 +7,8 @@
ErrorRaiseNodeWithMessage,
ExperimentalNode,
LoadAnimatedImageTest,
+ LinkedStandardWidgetSource,
+ LinkedStandardWidgets,
LongComboDropdown,
MultiSelectNode,
NodeWithBooleanInput,
@@ -40,6 +42,8 @@
"ErrorRaiseNodeWithMessage",
"ExperimentalNode",
"LoadAnimatedImageTest",
+ "LinkedStandardWidgetSource",
+ "LinkedStandardWidgets",
"LongComboDropdown",
"MultiSelectNode",
"NodeWithBooleanInput",
diff --git a/tools/devtools/nodes/__init__.py b/tools/devtools/nodes/__init__.py
index f0ac2d8ee8f..8e26c78df8c 100644
--- a/tools/devtools/nodes/__init__.py
+++ b/tools/devtools/nodes/__init__.py
@@ -9,6 +9,8 @@
NODE_DISPLAY_NAME_MAPPINGS as errors_display_name_mappings,
)
from .inputs import (
+ LinkedStandardWidgetSource,
+ LinkedStandardWidgets,
LongComboDropdown,
NodeWithBooleanInput,
NodeWithDefaultInput,
@@ -66,6 +68,8 @@
"ErrorRaiseNodeWithMessage",
"ExperimentalNode",
"LoadAnimatedImageTest",
+ "LinkedStandardWidgetSource",
+ "LinkedStandardWidgets",
"LongComboDropdown",
"MultiSelectNode",
"NodeWithBooleanInput",
diff --git a/tools/devtools/nodes/inputs.py b/tools/devtools/nodes/inputs.py
index a219865dcd1..ebe7ed1b46d 100644
--- a/tools/devtools/nodes/inputs.py
+++ b/tools/devtools/nodes/inputs.py
@@ -243,6 +243,113 @@ def execute(self, value):
return (value,)
+class LinkedStandardWidgetSource:
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {"required": {}}
+
+ RETURN_TYPES = (
+ "STRING",
+ "INT",
+ "FLOAT",
+ "FLOAT",
+ "BOOLEAN",
+ "BOOLEAN",
+ "COMBO",
+ "COLOR",
+ "STRING",
+ )
+ RETURN_NAMES = (
+ "plain_text",
+ "integer",
+ "number",
+ "slider",
+ "switch",
+ "labeled_toggle",
+ "select",
+ "color",
+ "textarea",
+ )
+ FUNCTION = "source_values"
+ CATEGORY = "DevTools"
+
+ def source_values(self):
+ return (
+ "upstream text",
+ 1,
+ 2.5,
+ 0.5,
+ False,
+ True,
+ "Option 1",
+ "#ffffff",
+ "upstream multiline text",
+ )
+
+
+class LinkedStandardWidgets:
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "plain_text": (
+ "STRING",
+ {"default": "STALE PLAIN TEXT"},
+ ),
+ "integer": (
+ "INT",
+ {"default": 314, "min": 0, "max": 1_000, "step": 1},
+ ),
+ "number": (
+ "FLOAT",
+ {"default": 9.875, "min": 0, "max": 10, "step": 0.125},
+ ),
+ "slider": (
+ "FLOAT",
+ {
+ "default": 0.73,
+ "display": "slider",
+ "min": 0,
+ "max": 1,
+ "step": 0.01,
+ },
+ ),
+ "switch": ("BOOLEAN", {"default": True}),
+ "labeled_toggle": (
+ "BOOLEAN",
+ {
+ "default": False,
+ "label_on": "STALE ON",
+ "label_off": "STALE OFF",
+ },
+ ),
+ "select": (
+ "COMBO",
+ {
+ "default": "STALE SELECT VALUE",
+ "options": ["Option 1", "STALE SELECT VALUE"],
+ },
+ ),
+ "color": ("COLOR", {"default": "#22c55d"}),
+ "textarea": (
+ "STRING",
+ {
+ "default": "STALE MULTILINE PROMPT",
+ "multiline": True,
+ },
+ ),
+ }
+ }
+
+ RETURN_TYPES = ()
+ OUTPUT_NODE = True
+ FUNCTION = "inspect"
+ CATEGORY = "DevTools"
+
+ def inspect(self, **_inputs):
+ return ()
+
+
class NodeWithSeedInput:
@classmethod
def INPUT_TYPES(cls):
@@ -422,6 +529,8 @@ def noop(self):
"DevToolsNodeWithUnionInput": NodeWithUnionInput,
"DevToolsNodeWithBooleanInput": NodeWithBooleanInput,
"DevToolsSimpleSlider": SimpleSlider,
+ "DevToolsLinkedStandardWidgetSource": LinkedStandardWidgetSource,
+ "DevToolsLinkedStandardWidgets": LinkedStandardWidgets,
"DevToolsNodeWithSeedInput": NodeWithSeedInput,
"DevToolsNodeWithValidation": NodeWithValidation,
"DevToolsNodeWithV2ComboInput": NodeWithV2ComboInput,
@@ -444,6 +553,8 @@ def noop(self):
"DevToolsNodeWithUnionInput": "Node With Union Input",
"DevToolsNodeWithBooleanInput": "Node With Boolean Input",
"DevToolsSimpleSlider": "Simple Slider",
+ "DevToolsLinkedStandardWidgetSource": "Linked Standard Widget Source",
+ "DevToolsLinkedStandardWidgets": "Linked Standard Widgets",
"DevToolsNodeWithSeedInput": "Node With Seed Input",
"DevToolsNodeWithValidation": "Node With Validation",
"DevToolsNodeWithV2ComboInput": "Node With V2 Combo Input",
@@ -465,6 +576,8 @@ def noop(self):
"NodeWithStringInput",
"NodeWithUnionInput",
"NodeWithBooleanInput",
+ "LinkedStandardWidgetSource",
+ "LinkedStandardWidgets",
"SimpleSlider",
"NodeWithSeedInput",
"NodeWithValidation",