Compare commits

...

17 Commits

Author SHA1 Message Date
GitHub Action
8e0e57a6d5 [automated] Apply ESLint and Oxfmt fixes 2026-03-17 08:11:51 +00:00
bymyself
ecd0c588d6 test: use stable .slot-dot locators and node ID in e2e tests
- Replace coordinate-based right-clicks with .slot-dot locators
- Use node ID from evaluate to locate Vue node for disconnect test
  instead of re-querying by title

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/9045#discussion_r2933075094
https://github.com/Comfy-Org/ComfyUI_frontend/pull/9045#discussion_r2933075107
2026-03-17 08:00:30 +00:00
bymyself
098bf1ad7c refactor: move SlotContextMenu to src/components/graph/
Colocate with NodeContextMenu since both are siblings in the
GraphCanvas template tree.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/9045#discussion_r2925519408
2026-03-17 07:59:26 +00:00
bymyself
6fae638ff7 fix: improve SlotContextMenu accessibility and robustness
- Remove duplicate Escape handler (window listener already handles it)
- Add tabindex="0" to menu items for ARIA keyboard navigation
- Handle prompt dialog rejection to prevent unhandled promise errors

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/9045#discussion_r2944848258
https://github.com/Comfy-Org/ComfyUI_frontend/pull/9045#discussion_r2944857018
https://github.com/Comfy-Org/ComfyUI_frontend/pull/9045#discussion_r2944853440
2026-03-17 07:58:57 +00:00
bymyself
9287f1dd68 refactor: rename useSlotContextMenu to slotMenuService
Not a composable — returns no reactive state and has no lifecycle
integration.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/9045#discussion_r2925519398
2026-03-17 07:58:24 +00:00
bymyself
1604b2b52a refactor: remove unused return values from useCanvasAnchoredPosition
canvasLeft, canvasTop, and lgCanvas are internal implementation details.
Neither SlotContextMenu nor NodeContextMenu uses them.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/9045#discussion_r2944850495
2026-03-17 07:58:05 +00:00
bymyself
11f94ebacd feat: instrument slot label setter for reactive updates and add rename e2e test
- Add Object.defineProperty instrumentation on slot.label so setting it
  fires node:slot-label:changed trigger automatically
- Hook onInputAdded/onOutputAdded to instrument dynamically added slots
- Remove manual triggerSlotRefresh from renameSlot (setter handles it)
- Add Playwright e2e test verifying rename slot reactively updates DOM
- Simplify renameSlot to use getSlotInfo helper directly
2026-03-15 04:37:48 -07:00
bymyself
bae68f8296 fix: preserve shallowReactive array identity in refreshNodeSlots
Root cause: refreshNodeSlots replaced currentData.outputs with a new
array via assignment, severing the reference from the shallowReactive
backing array that Vue components track. After any slot-links or
slot-errors event, nodeData.outputs in the template pointed to a
detached copy while renameSlot mutated node.outputs (the original).

Fix: use splice instead of assignment for outputs (matching inputs),
so the array identity is preserved. renameSlot delegates reactivity
entirely to the trigger event + refreshNodeSlots, which now correctly
splice-replaces elements with shallow copies.
2026-03-15 04:34:50 -07:00
bymyself
851329d6b9 fix: slot rename reactivity and align menu to design system
- Fix reactivity: renameSlot now splice-replaces the slot element in the
  shallowReactive array instead of mutating a deep property (label) that
  shallowReactive doesn't track. The previous approach of spreading the
  same object references back via refreshNodeSlots was a no-op.
- Fix refreshNodeSlots: shallow-copy each element via map(o => ({...o}))
  so shallowReactive detects new references for deep property changes.
- Align SlotContextMenu styling to design system: use same classes as
  DropdownMenu.vue (z-1700, min-w-[220px], hover:bg-secondary-background-hover).
- Consolidate duplicate NodeSlotLabelChangedEvent interface in graphTriggers.ts.
2026-03-15 04:34:50 -07:00
GitHub Action
7f96fd1d3e [automated] Apply ESLint and Oxfmt fixes 2026-03-15 04:34:50 -07:00
bymyself
0bd97d7e65 test: add e2e tests for slot context menu in Vue nodes mode 2026-03-15 04:34:50 -07:00
bymyself
19d571cfba feat: rework slot context menu with full LiteGraph parity
- Drop PrimeVue ContextMenu, use custom teleported imperative menu
- Add Disconnect Links and Remove Slot actions (LiteGraph parity)
- Extract useCanvasAnchoredPosition composable from NodeContextMenu
- Fix slot label reactivity: add node:slot-label:changed graph event
  that triggers refreshNodeSlots to update both inputs and outputs
- Refresh shallowReactive inputs via splice for property changes
- Refresh outputs via spread copy for label changes
- Use hasAnySlotAction guard instead of canRenameSlot in slot components
- Add i18n keys: disconnectLinks, removeSlot
2026-03-15 04:34:50 -07:00
bymyself
17c7953930 test: add comprehensive slot context menu tests (TDD red phase)
Tests for all slot context menu actions:
- canRenameSlot, canDisconnectSlot, canRemoveSlot, hasAnySlotAction
- renameSlot, disconnectSlotLinks, removeSlot
- Guard conditions, happy paths, and slot refresh triggers
- 35 tests covering full LiteGraph slot menu parity
2026-03-15 04:34:50 -07:00
bymyself
c6a7e1be52 fix: reject empty string in handleRename to prevent ghost labels
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/9045#discussion_r2924258161
2026-03-15 04:34:50 -07:00
bymyself
b3a3111648 fix: guard onSlotContextMenu to prevent empty menu popup
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/9045#discussion_r2924250194
2026-03-15 04:34:50 -07:00
bymyself
9aa4e2cb8d fix: guard renameSlot with canRenameSlot check
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/9045#discussion_r2835782400
https://github.com/Comfy-Org/ComfyUI_frontend/pull/9045#discussion_r2835782394
2026-03-15 04:34:50 -07:00
bymyself
42ed4fca0e feat: add slot context menu with rename for Vue nodes
Add right-click context menu on slot connection dots in Vue nodes
(Nodes 2.0) with a 'Rename slot' option, matching litegraph parity.

- Slot rename respects nameLocked and widget-input filtering
- Uses dialogService.prompt() for rename input
- Menu position tracks canvas pan/zoom via RAF sync

Amp-Thread-ID: https://ampcode.com/threads/T-019c7db2-dbb3-7090-ba79-856d7f630a42
2026-03-15 04:34:50 -07:00
13 changed files with 1164 additions and 106 deletions

View File

@@ -0,0 +1,209 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../../../fixtures/ComfyPage'
test.describe('Vue Nodes - Slot Context Menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', false)
await comfyPage.setup()
await comfyPage.vueNodes.waitForNodes()
})
test('Right-clicking an input slot dot shows the slot context menu', async ({
comfyPage
}) => {
// KSampler has input slots with connections — find any input slot
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler')
const inputSlot = ksamplerNode.locator('.lg-slot--input').first()
await expect(inputSlot).toBeVisible()
await inputSlot.locator('.slot-dot').click({ button: 'right' })
// The custom slot context menu should appear (role="menu" is on the
// teleported menu, not on the PrimeVue node context menu)
const slotMenu = comfyPage.page.locator(
'div[role="menu"]:not(.p-contextmenu)'
)
await expect(slotMenu).toBeVisible()
// Should show "Rename" since standard slots are not nameLocked
await expect(
slotMenu.getByRole('menuitem', { name: 'Rename' })
).toBeVisible()
})
test('Right-clicking the node body shows the node context menu, not the slot menu', async ({
comfyPage
}) => {
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler')
const nodeBody = ksamplerNode.locator('[data-testid^="node-body-"]')
await expect(nodeBody).toBeVisible()
await nodeBody.click({ button: 'right' })
await comfyPage.nextFrame()
// The PrimeVue node context menu should appear
const nodeMenu = comfyPage.page.locator('.p-contextmenu')
await expect(nodeMenu).toBeVisible()
// Slot-specific items should NOT be present
await expect(
comfyPage.page.getByRole('menuitem', { name: 'Disconnect Links' })
).not.toBeVisible()
})
test('Right-clicking an output slot dot shows the slot context menu', async ({
comfyPage
}) => {
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const outputSlot = checkpointNode.locator('.lg-slot--output').first()
await expect(outputSlot).toBeVisible()
await outputSlot.locator('.slot-dot').click({ button: 'right' })
const slotMenu = comfyPage.page.locator(
'div[role="menu"]:not(.p-contextmenu)'
)
await expect(slotMenu).toBeVisible()
await expect(
slotMenu.getByRole('menuitem', { name: 'Rename' })
).toBeVisible()
})
test('Slot context menu closes when clicking outside', async ({
comfyPage
}) => {
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler')
const inputSlot = ksamplerNode.locator('.lg-slot--input').first()
await expect(inputSlot).toBeVisible()
await inputSlot.locator('.slot-dot').click({ button: 'right' })
const slotMenu = comfyPage.page.locator(
'div[role="menu"]:not(.p-contextmenu)'
)
await expect(slotMenu).toBeVisible()
// Press Escape to close the menu
await comfyPage.page.keyboard.press('Escape')
await expect(slotMenu).not.toBeVisible()
})
test('Rename Slot updates the slot label reactively in the DOM', async ({
comfyPage
}) => {
// Find a KSampler input slot that is renamable (not widget-backed)
const slotInfo = await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const node = graph.findNodesByType('KSampler', [])[0]
if (!node?.inputs) return null
const idx = node.inputs.findIndex(
(i) => !i.nameLocked && !('widget' in i && i.widget)
)
if (idx < 0) return null
return {
index: idx,
originalName: node.inputs[idx].label || node.inputs[idx].name
}
})
expect(slotInfo).not.toBeNull()
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler')
const inputSlot = ksamplerNode
.locator('.lg-slot--input')
.nth(slotInfo!.index)
await expect(inputSlot).toBeVisible()
// Right-click to open slot context menu
await inputSlot.locator('.slot-dot').click({ button: 'right' })
const slotMenu = comfyPage.page.locator(
'div[role="menu"]:not(.p-contextmenu)'
)
await expect(slotMenu).toBeVisible()
// Click "Rename"
await slotMenu.getByRole('menuitem', { name: 'Rename' }).click()
// Menu should close, prompt dialog should appear
await expect(slotMenu).not.toBeVisible()
const promptInput = comfyPage.page.locator('.prompt-dialog-content input')
await expect(promptInput).toBeVisible()
// Type a new name and confirm
await promptInput.fill('MyCustomSlot')
await comfyPage.page.locator('.prompt-dialog-content button').click()
// Wait for dialog to close
await expect(promptInput).not.toBeVisible()
await comfyPage.nextFrame()
// The slot label in the DOM should reactively reflect the new name
await expect(inputSlot).toContainText('MyCustomSlot')
// Verify the underlying graph data also changed
const labelInGraph = await comfyPage.page.evaluate((idx) => {
const graph = window.app!.graph!
const node = graph.findNodesByType('KSampler', [])[0]
return node?.inputs?.[idx]?.label
}, slotInfo!.index)
expect(labelInGraph).toBe('MyCustomSlot')
})
test('Slot context menu Disconnect Links removes connections', async ({
comfyPage
}) => {
// Use page.evaluate to find a KSampler input with an active link
const connectedSlot = await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const nodes = graph.findNodesByType('KSampler', [])
const ksamplerNode = nodes.find((n) =>
n.inputs?.some((i) => i.link != null)
)
if (!ksamplerNode) return null
const slotIndex = ksamplerNode.inputs!.findIndex((i) => i.link != null)
return { nodeId: ksamplerNode.id, slotIndex }
})
expect(connectedSlot).not.toBeNull()
// Find the corresponding Vue input slot by node ID
const ksamplerNode = comfyPage.page.locator(
`[data-node-id="${connectedSlot!.nodeId}"]`
)
const inputSlot = ksamplerNode
.locator('.lg-slot--input')
.nth(connectedSlot!.slotIndex)
await expect(inputSlot).toBeVisible()
await inputSlot.locator('.slot-dot').click({ button: 'right' })
const slotMenu = comfyPage.page.locator(
'div[role="menu"]:not(.p-contextmenu)'
)
await expect(slotMenu).toBeVisible()
await expect(
slotMenu.getByRole('menuitem', { name: 'Disconnect Links' })
).toBeVisible()
await slotMenu.getByRole('menuitem', { name: 'Disconnect Links' }).click()
// Menu should close
await expect(slotMenu).not.toBeVisible()
// Verify the link was removed in the graph
const linkRemoved = await comfyPage.page.evaluate(
({ nodeId, slotIndex }) => {
const graph = window.app!.graph!
const node = graph.getNodeById(nodeId)
return node?.inputs?.[slotIndex]?.link == null
},
connectedSlot!
)
expect(linkRemoved).toBe(true)
})
})

View File

@@ -104,6 +104,7 @@
<TitleEditor />
<SelectionToolbox v-if="selectionToolboxEnabled" />
<NodeContextMenu />
<SlotContextMenu v-if="shouldRenderVueNodes" />
<!-- Render legacy DOM widgets only when Vue nodes are disabled -->
<DomWidgets v-if="!shouldRenderVueNodes" />
</template>
@@ -135,6 +136,7 @@ import LinkOverlayCanvas from '@/components/graph/LinkOverlayCanvas.vue'
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
import NodeContextMenu from '@/components/graph/NodeContextMenu.vue'
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
import SlotContextMenu from '@/components/graph/SlotContextMenu.vue'
import TitleEditor from '@/components/graph/TitleEditor.vue'
import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'

View File

@@ -39,11 +39,12 @@
</template>
<script setup lang="ts">
import { useElementBounding, useEventListener, useRafFn } from '@vueuse/core'
import { useEventListener } from '@vueuse/core'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue'
import { useCanvasAnchoredPosition } from '@/composables/graph/useCanvasAnchoredPosition'
import {
registerNodeOptionsInstance,
useMoreOptionsMenu
@@ -52,7 +53,6 @@ import type {
MenuOption,
SubMenuOption
} from '@/composables/graph/useMoreOptionsMenu'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import ColorPickerMenu from './selectionToolbox/ColorPickerMenu.vue'
@@ -67,66 +67,17 @@ const colorPickerMenu = ref<InstanceType<typeof ColorPickerMenu>>()
const isOpen = ref(false)
const { menuOptions, bump } = useMoreOptionsMenu()
const canvasStore = useCanvasStore()
const { screenPosition, anchorToEvent } = useCanvasAnchoredPosition(isOpen)
// World position (canvas coordinates) where menu was opened
const worldPosition = ref({ x: 0, y: 0 })
// Get canvas bounding rect reactively
const lgCanvas = canvasStore.getCanvas()
const { left: canvasLeft, top: canvasTop } = useElementBounding(lgCanvas.canvas)
// Track last canvas transform to detect actual changes
let lastScale = 0
let lastOffsetX = 0
let lastOffsetY = 0
// Update menu position based on canvas transform
const updateMenuPosition = () => {
watchEffect(() => {
if (!isOpen.value) return
const menuInstance = contextMenu.value as unknown as {
container?: HTMLElement
}
const menuEl = menuInstance?.container
if (!menuEl) return
const { scale, offset } = lgCanvas.ds
// Only update if canvas transform actually changed
if (
scale === lastScale &&
offset[0] === lastOffsetX &&
offset[1] === lastOffsetY
) {
return
}
lastScale = scale
lastOffsetX = offset[0]
lastOffsetY = offset[1]
// Convert world position to screen position
const screenX = (worldPosition.value.x + offset[0]) * scale + canvasLeft.value
const screenY = (worldPosition.value.y + offset[1]) * scale + canvasTop.value
// Update menu position
menuEl.style.left = `${screenX}px`
menuEl.style.top = `${screenY}px`
}
// Sync with canvas transform using requestAnimationFrame
const { resume: startSync, pause: stopSync } = useRafFn(updateMenuPosition, {
immediate: false
})
// Start/stop syncing based on menu visibility
watchEffect(() => {
if (isOpen.value) {
startSync()
} else {
stopSync()
}
menuEl.style.left = `${screenPosition.value.x}px`
menuEl.style.top = `${screenPosition.value.y}px`
})
// Close on touch outside to handle mobile devices where click might be swallowed
@@ -207,25 +158,7 @@ const menuItems = computed<ExtendedMenuItem[]>(() =>
// Show context menu
function show(event: MouseEvent) {
bump()
// Convert screen position to world coordinates
// Screen position relative to canvas = event position - canvas offset
const screenX = event.clientX - canvasLeft.value
const screenY = event.clientY - canvasTop.value
// Convert to world coordinates using canvas transform
const { scale, offset } = lgCanvas.ds
worldPosition.value = {
x: screenX / scale - offset[0],
y: screenY / scale - offset[1]
}
// Initialize last* values to current transform to prevent updateMenuPosition
// from overwriting PrimeVue's flip-adjusted position on the first RAF tick
lastScale = scale
lastOffsetX = offset[0]
lastOffsetY = offset[1]
anchorToEvent(event)
isOpen.value = true
contextMenu.value?.show(event)
}

View File

@@ -0,0 +1,135 @@
<template>
<Teleport to="body">
<div
v-if="isOpen"
ref="menuEl"
:style="{
left: `${screenPosition.x}px`,
top: `${screenPosition.y}px`
}"
class="fixed z-1700 min-w-[220px] rounded-lg border border-border-subtle bg-base-background p-2 shadow-sm"
role="menu"
>
<div
v-if="showDisconnect"
class="m-1 flex cursor-pointer items-center gap-1 rounded-lg p-2 leading-none hover:bg-secondary-background-hover"
role="menuitem"
tabindex="0"
@click="handleDisconnect"
>
{{ t('g.disconnectLinks') }}
</div>
<div
v-if="showRename"
class="m-1 flex cursor-pointer items-center gap-1 rounded-lg p-2 leading-none hover:bg-secondary-background-hover"
role="menuitem"
tabindex="0"
@click="handleRename"
>
{{ t('g.rename') }}
</div>
<div
v-if="showRemove"
class="m-1 flex cursor-pointer items-center gap-1 rounded-lg p-2 leading-none text-error hover:bg-secondary-background-hover"
role="menuitem"
tabindex="0"
@click="handleRemove"
>
{{ t('g.removeSlot') }}
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { onClickOutside, useEventListener } from '@vueuse/core'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCanvasAnchoredPosition } from '@/composables/graph/useCanvasAnchoredPosition'
import {
canDisconnectSlot,
canRemoveSlot,
canRenameSlot,
disconnectSlotLinks,
registerSlotMenuInstance,
removeSlot,
renameSlot
} from '@/renderer/extensions/vueNodes/composables/slotMenuService'
import type { SlotMenuContext } from '@/renderer/extensions/vueNodes/composables/slotMenuService'
import { useDialogService } from '@/services/dialogService'
const { t } = useI18n()
const dialogService = useDialogService()
const isOpen = ref(false)
const activeContext = ref<SlotMenuContext | null>(null)
const menuEl = ref<HTMLElement | null>(null)
const { screenPosition, anchorToEvent } = useCanvasAnchoredPosition(isOpen)
const showDisconnect = computed(
() => activeContext.value && canDisconnectSlot(activeContext.value)
)
const showRename = computed(
() => activeContext.value && canRenameSlot(activeContext.value)
)
const showRemove = computed(
() => activeContext.value && canRemoveSlot(activeContext.value)
)
function show(event: MouseEvent, context: SlotMenuContext) {
activeContext.value = context
anchorToEvent(event)
isOpen.value = true
}
function hide() {
isOpen.value = false
activeContext.value = null
}
function handleDisconnect() {
if (!activeContext.value) return
disconnectSlotLinks(activeContext.value)
hide()
}
async function handleRename() {
const ctx = activeContext.value
if (!ctx) return
hide()
const newLabel = await dialogService
.prompt({
title: t('g.rename'),
message: t('g.enterNewNamePrompt')
})
.catch(() => null)
if (!newLabel) return
renameSlot(ctx, newLabel)
}
function handleRemove() {
if (!activeContext.value) return
removeSlot(activeContext.value)
hide()
}
onClickOutside(menuEl, () => {
if (isOpen.value) hide()
})
useEventListener(window, 'keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen.value) hide()
})
defineExpose({ show, hide, isOpen })
onMounted(() => {
registerSlotMenuInstance({ show, hide, isOpen })
})
onUnmounted(() => {
registerSlotMenuInstance(null)
})
</script>

View File

@@ -0,0 +1,77 @@
import { useElementBounding, useRafFn } from '@vueuse/core'
import type { Ref } from 'vue'
import { ref, watchEffect } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
export function useCanvasAnchoredPosition(isOpen: Ref<boolean>) {
const canvasStore = useCanvasStore()
const lgCanvas = canvasStore.getCanvas()
const { left: canvasLeft, top: canvasTop } = useElementBounding(
lgCanvas.canvas
)
const screenPosition = ref({ x: 0, y: 0 })
const worldPosition = ref({ x: 0, y: 0 })
let lastScale = 0
let lastOffsetX = 0
let lastOffsetY = 0
function anchorToEvent(event: MouseEvent) {
const screenX = event.clientX - canvasLeft.value
const screenY = event.clientY - canvasTop.value
const { scale, offset } = lgCanvas.ds
worldPosition.value = {
x: screenX / scale - offset[0],
y: screenY / scale - offset[1]
}
lastScale = scale
lastOffsetX = offset[0]
lastOffsetY = offset[1]
screenPosition.value = {
x: event.clientX,
y: event.clientY
}
}
function updateScreenPosition() {
if (!isOpen.value) return
const { scale, offset } = lgCanvas.ds
if (
scale === lastScale &&
offset[0] === lastOffsetX &&
offset[1] === lastOffsetY
) {
return
}
lastScale = scale
lastOffsetX = offset[0]
lastOffsetY = offset[1]
screenPosition.value = {
x: (worldPosition.value.x + offset[0]) * scale + canvasLeft.value,
y: (worldPosition.value.y + offset[1]) * scale + canvasTop.value
}
}
const { resume: startSync, pause: stopSync } = useRafFn(
updateScreenPosition,
{ immediate: false }
)
watchEffect(() => {
if (isOpen.value) {
startSync()
} else {
stopSync()
}
})
return { screenPosition, anchorToEvent }
}

View File

@@ -330,7 +330,7 @@ describe('Subgraph output slot label reactivity', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('updates output slot labels when node:slot-label:changed is triggered', async () => {
it('updates output slot labels reactively via instrumented label setter', async () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addOutput('original_name', 'STRING')
@@ -345,12 +345,8 @@ describe('Subgraph output slot label reactivity', () => {
expect(nodeData.outputs[0].label).toBeUndefined()
expect(nodeData.outputs[1].label).toBeUndefined()
// Simulate what SubgraphNode does: set the label, then fire the trigger
// Setting label directly triggers reactivity via instrumented setter
node.outputs[0].label = 'custom_label'
graph.trigger('node:slot-label:changed', {
nodeId: node.id,
slotType: NodeSlotType.OUTPUT
})
await nextTick()
@@ -359,7 +355,7 @@ describe('Subgraph output slot label reactivity', () => {
expect(updatedData?.outputs?.[1]?.label).toBeUndefined()
})
it('updates input slot labels when node:slot-label:changed is triggered', async () => {
it('updates input slot labels reactively via instrumented label setter', async () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addInput('original_name', 'STRING')
@@ -372,11 +368,8 @@ describe('Subgraph output slot label reactivity', () => {
expect(nodeData.inputs[0].label).toBeUndefined()
// Setting label directly triggers reactivity via instrumented setter
node.inputs[0].label = 'custom_label'
graph.trigger('node:slot-label:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT
})
await nextTick()
@@ -384,6 +377,29 @@ describe('Subgraph output slot label reactivity', () => {
expect(updatedData?.inputs?.[0]?.label).toBe('custom_label')
})
it('instruments labels on dynamically added slots', async () => {
const graph = new LGraph()
const node = new LGraphNode('test')
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
// Add a new output after instrumentation
node.addOutput('dynamic_output', 'STRING')
const nodeId = String(node.id)
const nodeData = vueNodeData.get(nodeId)
if (!nodeData?.outputs) throw new Error('Expected output data to exist')
// The dynamically added slot should also be instrumented
node.outputs[0].label = 'dynamic_label'
await nextTick()
const updatedData = vueNodeData.get(nodeId)
expect(updatedData?.outputs?.[0]?.label).toBe('dynamic_label')
})
it('ignores node:slot-label:changed for unknown node ids', () => {
const graph = new LGraph()
useGraphNodeManager(graph)

View File

@@ -435,29 +435,64 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
enumerable: true
})
}
// Instrument `label` on a slot so that setting it fires a graph trigger,
// following the same pattern as LGraphNodeProperties for node-level props.
const instrumentSlotLabel = (slot: INodeInputSlot | INodeOutputSlot) => {
const descriptor = Object.getOwnPropertyDescriptor(slot, 'label')
if (descriptor?.get) return // already instrumented
let value = slot.label
Object.defineProperty(slot, 'label', {
get: () => value,
set: (newValue: string | undefined) => {
if (value === newValue) return
value = newValue
node.graph?.trigger('node:slot-label:changed', { nodeId: node.id })
},
configurable: true,
enumerable: true
})
}
const reactiveInputs = shallowReactive<INodeInputSlot[]>(node.inputs ?? [])
reactiveInputs.forEach(instrumentSlotLabel)
Object.defineProperty(node, 'inputs', {
get() {
return reactiveInputs
},
set(v) {
set(v: INodeInputSlot[]) {
v.forEach(instrumentSlotLabel)
reactiveInputs.splice(0, reactiveInputs.length, ...v)
},
configurable: true,
enumerable: true
})
const reactiveOutputs = shallowReactive<INodeOutputSlot[]>(node.outputs ?? [])
reactiveOutputs.forEach(instrumentSlotLabel)
Object.defineProperty(node, 'outputs', {
get() {
return reactiveOutputs
},
set(v) {
set(v: INodeOutputSlot[]) {
v.forEach(instrumentSlotLabel)
reactiveOutputs.splice(0, reactiveOutputs.length, ...v)
},
configurable: true,
enumerable: true
})
// Hook into node callbacks to instrument dynamically added slots.
const origOnInputAdded = node.onInputAdded
node.onInputAdded = (slot) => {
instrumentSlotLabel(slot)
origOnInputAdded?.call(node, slot)
}
const origOnOutputAdded = node.onOutputAdded
node.onOutputAdded = (slot) => {
instrumentSlotLabel(slot)
origOnOutputAdded?.call(node, slot)
}
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
const widgetsSnapshot = node.widgets ?? []
@@ -524,6 +559,27 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
for (const widget of currentData.widgets ?? []) {
widget.slotMetadata = slotMetadata.get(widget.slotName ?? widget.name)
}
// node.inputs/outputs are shallowReactive arrays (via defineProperty).
// Use splice (not assignment) to preserve array identity so Vue components
// that hold a reference to this array continue to track changes.
// Shallow-copy each element so shallowReactive detects new references
// for deep property changes (e.g., label).
if (nodeRef.outputs && currentData.outputs) {
currentData.outputs.splice(
0,
currentData.outputs.length,
...nodeRef.outputs.map((o) => ({ ...o }))
)
}
if (nodeRef.inputs) {
currentData.inputs?.splice(
0,
currentData.inputs.length,
...nodeRef.inputs.map((i) => ({ ...i }))
)
}
}
// Get access to original LiteGraph node (non-reactive)
@@ -782,18 +838,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}
},
'node:slot-label:changed': (slotLabelEvent) => {
const nodeId = String(slotLabelEvent.nodeId)
const nodeRef = nodeRefs.get(nodeId)
if (!nodeRef) return
// Force shallowReactive to detect the deep property change
// by re-assigning the affected array through the defineProperty setter.
if (slotLabelEvent.slotType !== NodeSlotType.OUTPUT && nodeRef.inputs) {
nodeRef.inputs = [...nodeRef.inputs]
}
if (slotLabelEvent.slotType !== NodeSlotType.INPUT && nodeRef.outputs) {
nodeRef.outputs = [...nodeRef.outputs]
}
refreshNodeSlots(String(slotLabelEvent.nodeId))
}
}

View File

@@ -14,6 +14,12 @@ interface NodeSlotErrorsChangedEvent {
nodeId: NodeId
}
interface NodeSlotLabelChangedEvent {
type: 'node:slot-label:changed'
nodeId: NodeId
slotType?: NodeSlotType
}
interface NodeSlotLinksChangedEvent {
type: 'node:slot-links:changed'
nodeId: NodeId
@@ -23,17 +29,11 @@ interface NodeSlotLinksChangedEvent {
linkId: number
}
interface NodeSlotLabelChangedEvent {
type: 'node:slot-label:changed'
nodeId: NodeId
slotType?: NodeSlotType
}
export type LGraphTriggerEvent =
| NodePropertyChangedEvent
| NodeSlotErrorsChangedEvent
| NodeSlotLinksChangedEvent
| NodeSlotLabelChangedEvent
| NodeSlotLinksChangedEvent
export type LGraphTriggerAction = LGraphTriggerEvent['type']

View File

@@ -16,11 +16,13 @@
"decrement": "Decrement",
"deleteImage": "Delete image",
"deleteAudioFile": "Delete audio file",
"disconnectLinks": "Disconnect Links",
"increment": "Increment",
"removeImage": "Remove image",
"removeVideo": "Remove video",
"removeTag": "Remove tag",
"remove": "Remove",
"removeSlot": "Remove Slot",
"chart": "Chart",
"chartLowercase": "chart",
"file": "file",

View File

@@ -31,6 +31,7 @@
@click="onClick"
@dblclick="onDoubleClick"
@pointerdown="onPointerDown"
@contextmenu.stop.prevent="onSlotContextMenu"
/>
<!-- Slot Name -->
@@ -63,6 +64,10 @@ import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import {
hasAnySlotAction,
showSlotMenu
} from '@/renderer/extensions/vueNodes/composables/slotMenuService'
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
import { cn } from '@/utils/tailwindUtil'
@@ -142,4 +147,11 @@ const { onClick, onDoubleClick, onPointerDown } = useSlotLinkInteraction({
index: props.index,
type: 'input'
})
function onSlotContextMenu(event: MouseEvent) {
if (!props.nodeId) return
const ctx = { nodeId: props.nodeId, slotIndex: props.index, isInput: true }
if (!hasAnySlotAction(ctx)) return
showSlotMenu(event, ctx)
}
</script>

View File

@@ -20,6 +20,7 @@
class="w-3 translate-x-1/2"
:slot-data
@pointerdown="onPointerDown"
@contextmenu.stop.prevent="onSlotContextMenu"
/>
</div>
</template>
@@ -35,6 +36,10 @@ import { RenderShape } from '@/lib/litegraph/src/types/globalEnums'
import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import {
hasAnySlotAction,
showSlotMenu
} from '@/renderer/extensions/vueNodes/composables/slotMenuService'
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
import { cn } from '@/utils/tailwindUtil'
@@ -132,4 +137,11 @@ const { onPointerDown } = useSlotLinkInteraction({
index: props.index,
type: 'output'
})
function onSlotContextMenu(event: MouseEvent) {
if (!props.nodeId) return
const ctx = { nodeId: props.nodeId, slotIndex: props.index, isInput: false }
if (!hasAnySlotAction(ctx)) return
showSlotMenu(event, ctx)
}
</script>

View File

@@ -0,0 +1,468 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGraph, mockCanvas } = vi.hoisted(() => {
const mockGraph = {
getNodeById: vi.fn(),
beforeChange: vi.fn(),
afterChange: vi.fn(),
trigger: vi.fn()
}
const mockCanvas = {
graph: mockGraph as typeof mockGraph | null,
setDirty: vi.fn()
}
return { mockGraph, mockCanvas }
})
vi.mock('@/scripts/app', () => ({
app: { canvas: mockCanvas }
}))
import {
canDisconnectSlot,
canRemoveSlot,
canRenameSlot,
disconnectSlotLinks,
hasAnySlotAction,
removeSlot,
renameSlot
} from './slotMenuService'
describe(canRenameSlot, () => {
beforeEach(() => {
vi.clearAllMocks()
mockCanvas.graph = mockGraph
})
it('returns false when graph is null', () => {
mockCanvas.graph = null
expect(canRenameSlot({ nodeId: '1', slotIndex: 0, isInput: true })).toBe(
false
)
})
it('returns false when node not found', () => {
mockGraph.getNodeById.mockReturnValue(null)
expect(canRenameSlot({ nodeId: '99', slotIndex: 0, isInput: true })).toBe(
false
)
})
it('returns true for normal input slot', () => {
mockGraph.getNodeById.mockReturnValue({
inputs: [{ type: 'IMAGE', link: null, name: 'image' }],
outputs: []
})
expect(canRenameSlot({ nodeId: '1', slotIndex: 0, isInput: true })).toBe(
true
)
})
it('returns true for normal output slot', () => {
mockGraph.getNodeById.mockReturnValue({
inputs: [],
outputs: [{ type: 'IMAGE', links: [], name: 'image' }]
})
expect(canRenameSlot({ nodeId: '1', slotIndex: 0, isInput: false })).toBe(
true
)
})
it('returns false when slot is nameLocked', () => {
mockGraph.getNodeById.mockReturnValue({
inputs: [{ type: 'IMAGE', link: null, name: 'image', nameLocked: true }],
outputs: []
})
expect(canRenameSlot({ nodeId: '1', slotIndex: 0, isInput: true })).toBe(
false
)
})
it('returns false when input slot has a widget', () => {
mockGraph.getNodeById.mockReturnValue({
inputs: [
{ type: 'INT', link: 5, name: 'steps', widget: { name: 'steps' } }
],
outputs: []
})
expect(canRenameSlot({ nodeId: '1', slotIndex: 0, isInput: true })).toBe(
false
)
})
it('returns false when slot index out of range', () => {
mockGraph.getNodeById.mockReturnValue({
inputs: [],
outputs: []
})
expect(canRenameSlot({ nodeId: '1', slotIndex: 5, isInput: true })).toBe(
false
)
})
})
describe(canDisconnectSlot, () => {
beforeEach(() => {
vi.clearAllMocks()
mockCanvas.graph = mockGraph
})
it('returns false when graph is null', () => {
mockCanvas.graph = null
expect(
canDisconnectSlot({ nodeId: '1', slotIndex: 0, isInput: true })
).toBe(false)
})
it('returns true for input with link', () => {
mockGraph.getNodeById.mockReturnValue({
inputs: [{ type: 'IMAGE', link: 42, name: 'image' }],
outputs: []
})
expect(
canDisconnectSlot({ nodeId: '1', slotIndex: 0, isInput: true })
).toBe(true)
})
it('returns false for input without link', () => {
mockGraph.getNodeById.mockReturnValue({
inputs: [{ type: 'IMAGE', link: null, name: 'image' }],
outputs: []
})
expect(
canDisconnectSlot({ nodeId: '1', slotIndex: 0, isInput: true })
).toBe(false)
})
it('returns true for output with links', () => {
mockGraph.getNodeById.mockReturnValue({
inputs: [],
outputs: [{ type: 'IMAGE', links: [1, 2], name: 'image' }]
})
expect(
canDisconnectSlot({ nodeId: '1', slotIndex: 0, isInput: false })
).toBe(true)
})
it('returns false for output with empty links array', () => {
mockGraph.getNodeById.mockReturnValue({
inputs: [],
outputs: [{ type: 'IMAGE', links: [], name: 'image' }]
})
expect(
canDisconnectSlot({ nodeId: '1', slotIndex: 0, isInput: false })
).toBe(false)
})
it('returns false for output with null links', () => {
mockGraph.getNodeById.mockReturnValue({
inputs: [],
outputs: [{ type: 'IMAGE', links: null, name: 'image' }]
})
expect(
canDisconnectSlot({ nodeId: '1', slotIndex: 0, isInput: false })
).toBe(false)
})
})
describe(canRemoveSlot, () => {
beforeEach(() => {
vi.clearAllMocks()
mockCanvas.graph = mockGraph
})
it('returns false when graph is null', () => {
mockCanvas.graph = null
expect(canRemoveSlot({ nodeId: '1', slotIndex: 0, isInput: true })).toBe(
false
)
})
it('returns true when removable is true and not locked', () => {
mockGraph.getNodeById.mockReturnValue({
inputs: [{ type: 'IMAGE', link: null, name: 'image', removable: true }],
outputs: []
})
expect(canRemoveSlot({ nodeId: '1', slotIndex: 0, isInput: true })).toBe(
true
)
})
it('returns false when removable is false', () => {
mockGraph.getNodeById.mockReturnValue({
inputs: [{ type: 'IMAGE', link: null, name: 'image', removable: false }],
outputs: []
})
expect(canRemoveSlot({ nodeId: '1', slotIndex: 0, isInput: true })).toBe(
false
)
})
it('returns false when locked is true', () => {
mockGraph.getNodeById.mockReturnValue({
inputs: [
{
type: 'IMAGE',
link: null,
name: 'image',
removable: true,
locked: true
}
],
outputs: []
})
expect(canRemoveSlot({ nodeId: '1', slotIndex: 0, isInput: true })).toBe(
false
)
})
})
describe(hasAnySlotAction, () => {
beforeEach(() => {
vi.clearAllMocks()
mockCanvas.graph = mockGraph
})
it('returns true when rename is available', () => {
mockGraph.getNodeById.mockReturnValue({
inputs: [{ type: 'IMAGE', link: null, name: 'image' }],
outputs: []
})
expect(hasAnySlotAction({ nodeId: '1', slotIndex: 0, isInput: true })).toBe(
true
)
})
it('returns true when disconnect is available', () => {
mockGraph.getNodeById.mockReturnValue({
inputs: [{ type: 'IMAGE', link: 42, name: 'image', nameLocked: true }],
outputs: []
})
expect(hasAnySlotAction({ nodeId: '1', slotIndex: 0, isInput: true })).toBe(
true
)
})
it('returns false when no action available', () => {
mockGraph.getNodeById.mockReturnValue({
inputs: [
{
type: 'IMAGE',
link: null,
name: 'image',
nameLocked: true,
removable: false
}
],
outputs: []
})
expect(hasAnySlotAction({ nodeId: '1', slotIndex: 0, isInput: true })).toBe(
false
)
})
})
describe(renameSlot, () => {
beforeEach(() => {
vi.clearAllMocks()
mockCanvas.graph = mockGraph
})
it('does nothing when graph is null', () => {
mockCanvas.graph = null
renameSlot({ nodeId: '1', slotIndex: 0, isInput: true }, 'new')
expect(mockGraph.beforeChange).not.toHaveBeenCalled()
})
it('does nothing when nameLocked', () => {
const inputSlot = {
type: 'IMAGE',
link: null,
name: 'image',
label: '',
nameLocked: true
}
mockGraph.getNodeById.mockReturnValue({
inputs: [inputSlot],
outputs: [],
getInputInfo: () => inputSlot,
getOutputInfo: () => null
})
renameSlot({ nodeId: '1', slotIndex: 0, isInput: true }, 'new')
expect(mockGraph.beforeChange).not.toHaveBeenCalled()
expect(inputSlot.label).toBe('')
})
it('renames an input slot label', () => {
const inputSlot = { type: 'IMAGE', link: null, name: 'image', label: '' }
mockGraph.getNodeById.mockReturnValue({
inputs: [inputSlot],
outputs: []
})
renameSlot({ nodeId: '1', slotIndex: 0, isInput: true }, 'my_image')
expect(inputSlot.label).toBe('my_image')
expect(mockGraph.beforeChange).toHaveBeenCalled()
expect(mockGraph.afterChange).toHaveBeenCalled()
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('renames an output slot label', () => {
const outputSlot = {
type: 'MODEL',
links: [],
name: 'model',
label: ''
}
mockGraph.getNodeById.mockReturnValue({
inputs: [],
outputs: [outputSlot]
})
renameSlot({ nodeId: '1', slotIndex: 0, isInput: false }, 'my_model')
expect(outputSlot.label).toBe('my_model')
expect(mockGraph.beforeChange).toHaveBeenCalled()
expect(mockGraph.afterChange).toHaveBeenCalled()
})
it('does nothing when slot not found', () => {
mockGraph.getNodeById.mockReturnValue({
inputs: [],
outputs: []
})
renameSlot({ nodeId: '1', slotIndex: 0, isInput: true }, 'new')
expect(mockGraph.beforeChange).not.toHaveBeenCalled()
})
})
describe(disconnectSlotLinks, () => {
beforeEach(() => {
vi.clearAllMocks()
mockCanvas.graph = mockGraph
})
it('does nothing when graph is null', () => {
mockCanvas.graph = null
disconnectSlotLinks({ nodeId: '1', slotIndex: 0, isInput: true })
expect(mockGraph.beforeChange).not.toHaveBeenCalled()
})
it('disconnects an input slot', () => {
const disconnectInput = vi.fn()
mockGraph.getNodeById.mockReturnValue({
inputs: [{ type: 'IMAGE', link: 42, name: 'image' }],
outputs: [],
disconnectInput
})
disconnectSlotLinks({ nodeId: '1', slotIndex: 0, isInput: true })
expect(mockGraph.beforeChange).toHaveBeenCalled()
expect(disconnectInput).toHaveBeenCalledWith(0, true)
expect(mockGraph.afterChange).toHaveBeenCalled()
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('disconnects an output slot', () => {
const disconnectOutput = vi.fn()
mockGraph.getNodeById.mockReturnValue({
inputs: [],
outputs: [{ type: 'IMAGE', links: [1, 2], name: 'image' }],
disconnectOutput
})
disconnectSlotLinks({ nodeId: '1', slotIndex: 0, isInput: false })
expect(mockGraph.beforeChange).toHaveBeenCalled()
expect(disconnectOutput).toHaveBeenCalledWith(0)
expect(mockGraph.afterChange).toHaveBeenCalled()
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('triggers slot refresh event after disconnect', () => {
mockGraph.getNodeById.mockReturnValue({
inputs: [{ type: 'IMAGE', link: 42, name: 'image' }],
outputs: [],
disconnectInput: vi.fn()
})
disconnectSlotLinks({ nodeId: '1', slotIndex: 0, isInput: true })
expect(mockGraph.trigger).toHaveBeenCalledWith('node:slot-label:changed', {
nodeId: '1'
})
})
})
describe(removeSlot, () => {
beforeEach(() => {
vi.clearAllMocks()
mockCanvas.graph = mockGraph
})
it('does nothing when graph is null', () => {
mockCanvas.graph = null
removeSlot({ nodeId: '1', slotIndex: 0, isInput: true })
expect(mockGraph.beforeChange).not.toHaveBeenCalled()
})
it('removes an input slot', () => {
const removeInput = vi.fn()
mockGraph.getNodeById.mockReturnValue({
inputs: [{ type: 'IMAGE', link: null, name: 'image', removable: true }],
outputs: [],
removeInput
})
removeSlot({ nodeId: '1', slotIndex: 0, isInput: true })
expect(mockGraph.beforeChange).toHaveBeenCalled()
expect(removeInput).toHaveBeenCalledWith(0)
expect(mockGraph.afterChange).toHaveBeenCalled()
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('removes an output slot', () => {
const removeOutput = vi.fn()
mockGraph.getNodeById.mockReturnValue({
inputs: [],
outputs: [{ type: 'IMAGE', links: [], name: 'image', removable: true }],
removeOutput
})
removeSlot({ nodeId: '1', slotIndex: 0, isInput: false })
expect(mockGraph.beforeChange).toHaveBeenCalled()
expect(removeOutput).toHaveBeenCalledWith(0)
expect(mockGraph.afterChange).toHaveBeenCalled()
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('does nothing when not removable', () => {
mockGraph.getNodeById.mockReturnValue({
inputs: [{ type: 'IMAGE', link: null, name: 'image', removable: false }],
outputs: []
})
removeSlot({ nodeId: '1', slotIndex: 0, isInput: true })
expect(mockGraph.beforeChange).not.toHaveBeenCalled()
})
it('triggers slot refresh event after remove', () => {
mockGraph.getNodeById.mockReturnValue({
inputs: [{ type: 'IMAGE', link: null, name: 'image', removable: true }],
outputs: [],
removeInput: vi.fn()
})
removeSlot({ nodeId: '1', slotIndex: 0, isInput: true })
expect(mockGraph.trigger).toHaveBeenCalledWith('node:slot-label:changed', {
nodeId: '1'
})
})
})

View File

@@ -0,0 +1,147 @@
import type { Ref } from 'vue'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import { app } from '@/scripts/app'
export interface SlotMenuContext {
nodeId: NodeId
slotIndex: number
isInput: boolean
}
interface SlotMenuInstance {
show: (event: MouseEvent, context: SlotMenuContext) => void
hide: () => void
isOpen: Ref<boolean>
}
let slotMenuInstance: SlotMenuInstance | null = null
export function registerSlotMenuInstance(
instance: SlotMenuInstance | null
): void {
slotMenuInstance = instance
}
export function showSlotMenu(
event: MouseEvent,
context: SlotMenuContext
): void {
slotMenuInstance?.show(event, context)
}
function getSlotInfo(context: SlotMenuContext) {
const graph = app.canvas?.graph
if (!graph) return null
const node = graph.getNodeById(context.nodeId)
if (!node) return null
const slotInfo = context.isInput
? node.inputs?.[context.slotIndex]
: node.outputs?.[context.slotIndex]
if (!slotInfo) return null
return { graph, node, slotInfo }
}
export function canRenameSlot(context: SlotMenuContext): boolean {
const result = getSlotInfo(context)
if (!result) return false
const { slotInfo } = result
if (slotInfo.nameLocked) return false
if (context.isInput && 'widget' in slotInfo && slotInfo.widget) return false
return true
}
export function canDisconnectSlot(context: SlotMenuContext): boolean {
const result = getSlotInfo(context)
if (!result) return false
const { slotInfo } = result
if (context.isInput) {
return 'link' in slotInfo && slotInfo.link != null
}
return (
'links' in slotInfo &&
Array.isArray(slotInfo.links) &&
slotInfo.links.length > 0
)
}
export function canRemoveSlot(context: SlotMenuContext): boolean {
const result = getSlotInfo(context)
if (!result) return false
const { slotInfo } = result
return Boolean(slotInfo.removable) && !slotInfo.locked
}
export function hasAnySlotAction(context: SlotMenuContext): boolean {
return (
canRenameSlot(context) ||
canDisconnectSlot(context) ||
canRemoveSlot(context)
)
}
function triggerSlotRefresh(context: SlotMenuContext): void {
const graph = app.canvas?.graph
graph?.trigger('node:slot-label:changed', {
nodeId: context.nodeId
})
}
export function renameSlot(context: SlotMenuContext, newLabel: string): void {
if (!canRenameSlot(context)) return
const result = getSlotInfo(context)
if (!result) return
const { graph, slotInfo } = result
graph.beforeChange()
slotInfo.label = newLabel
app.canvas?.setDirty(true, true)
graph.afterChange()
}
export function disconnectSlotLinks(context: SlotMenuContext): void {
if (!canDisconnectSlot(context)) return
const result = getSlotInfo(context)
if (!result) return
const { graph, node } = result
graph.beforeChange()
if (context.isInput) {
node.disconnectInput(context.slotIndex, true)
} else {
node.disconnectOutput(context.slotIndex)
}
graph.afterChange()
app.canvas?.setDirty(true, true)
triggerSlotRefresh(context)
}
export function removeSlot(context: SlotMenuContext): void {
if (!canRemoveSlot(context)) return
const result = getSlotInfo(context)
if (!result) return
const { graph, node } = result
graph.beforeChange()
if (context.isInput) {
node.removeInput(context.slotIndex)
} else {
node.removeOutput(context.slotIndex)
}
graph.afterChange()
app.canvas?.setDirty(true, true)
triggerSlotRefresh(context)
}