mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-23 06:10:32 +00:00
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
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
77
src/composables/graph/useCanvasAnchoredPosition.ts
Normal file
77
src/composables/graph/useCanvasAnchoredPosition.ts
Normal 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, canvasLeft, canvasTop, lgCanvas }
|
||||
}
|
||||
@@ -540,6 +540,20 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
for (const widget of currentData.widgets ?? []) {
|
||||
widget.slotMetadata = slotMetadata.get(widget.slotName ?? widget.name)
|
||||
}
|
||||
|
||||
// Refresh output slot snapshots to pick up label changes
|
||||
if (nodeRef.outputs) {
|
||||
currentData.outputs = [...nodeRef.outputs]
|
||||
}
|
||||
|
||||
// Force shallowReactive inputs to re-trigger for property changes (e.g., label)
|
||||
if (nodeRef.inputs) {
|
||||
currentData.inputs?.splice(
|
||||
0,
|
||||
currentData.inputs.length,
|
||||
...nodeRef.inputs
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Get access to original LiteGraph node (non-reactive)
|
||||
|
||||
@@ -14,6 +14,11 @@ interface NodeSlotErrorsChangedEvent {
|
||||
nodeId: NodeId
|
||||
}
|
||||
|
||||
interface NodeSlotLabelChangedEvent {
|
||||
type: 'node:slot-label:changed'
|
||||
nodeId: NodeId
|
||||
}
|
||||
|
||||
interface NodeSlotLinksChangedEvent {
|
||||
type: 'node:slot-links:changed'
|
||||
nodeId: NodeId
|
||||
@@ -32,6 +37,7 @@ interface NodeSlotLabelChangedEvent {
|
||||
export type LGraphTriggerEvent =
|
||||
| NodePropertyChangedEvent
|
||||
| NodeSlotErrorsChangedEvent
|
||||
| NodeSlotLabelChangedEvent
|
||||
| NodeSlotLinksChangedEvent
|
||||
| NodeSlotLabelChangedEvent
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -65,7 +65,7 @@ import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDra
|
||||
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
|
||||
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
||||
import {
|
||||
canRenameSlot,
|
||||
hasAnySlotAction,
|
||||
showSlotMenu
|
||||
} from '@/renderer/extensions/vueNodes/composables/useSlotContextMenu'
|
||||
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
|
||||
@@ -151,7 +151,7 @@ const { onClick, onDoubleClick, onPointerDown } = useSlotLinkInteraction({
|
||||
function onSlotContextMenu(event: MouseEvent) {
|
||||
if (!props.nodeId) return
|
||||
const ctx = { nodeId: props.nodeId, slotIndex: props.index, isInput: true }
|
||||
if (!canRenameSlot(ctx)) return
|
||||
if (!hasAnySlotAction(ctx)) return
|
||||
showSlotMenu(event, ctx)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -37,7 +37,7 @@ import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDra
|
||||
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
|
||||
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
||||
import {
|
||||
canRenameSlot,
|
||||
hasAnySlotAction,
|
||||
showSlotMenu
|
||||
} from '@/renderer/extensions/vueNodes/composables/useSlotContextMenu'
|
||||
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
|
||||
@@ -141,7 +141,7 @@ const { onPointerDown } = useSlotLinkInteraction({
|
||||
function onSlotContextMenu(event: MouseEvent) {
|
||||
if (!props.nodeId) return
|
||||
const ctx = { nodeId: props.nodeId, slotIndex: props.index, isInput: false }
|
||||
if (!canRenameSlot(ctx)) return
|
||||
if (!hasAnySlotAction(ctx)) return
|
||||
showSlotMenu(event, ctx)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,29 +1,57 @@
|
||||
<template>
|
||||
<ContextMenu
|
||||
ref="contextMenu"
|
||||
:model="menuItems"
|
||||
@show="onMenuShow"
|
||||
@hide="onMenuHide"
|
||||
>
|
||||
<template #item="{ item, props: itemProps }">
|
||||
<a v-bind="itemProps.action" class="flex items-center gap-2 px-3 py-1.5">
|
||||
<span class="flex-1">{{ item.label }}</span>
|
||||
</a>
|
||||
</template>
|
||||
</ContextMenu>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="menuEl"
|
||||
:style="{
|
||||
left: `${screenPosition.x}px`,
|
||||
top: `${screenPosition.y}px`
|
||||
}"
|
||||
class="fixed z-1000 min-w-40 rounded-lg border border-border-subtle bg-base-background py-1 shadow-interface"
|
||||
role="menu"
|
||||
@keydown.escape="hide"
|
||||
>
|
||||
<button
|
||||
v-if="showDisconnect"
|
||||
class="hover:bg-surface-hover flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm"
|
||||
role="menuitem"
|
||||
@click="handleDisconnect"
|
||||
>
|
||||
{{ t('g.disconnectLinks') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="showRename"
|
||||
class="hover:bg-surface-hover flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm"
|
||||
role="menuitem"
|
||||
@click="handleRename"
|
||||
>
|
||||
{{ t('g.rename') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="showRemove"
|
||||
class="hover:bg-surface-hover flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-error"
|
||||
role="menuitem"
|
||||
@click="handleRemove"
|
||||
>
|
||||
{{ t('g.removeSlot') }}
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementBounding, useRafFn } from '@vueuse/core'
|
||||
import ContextMenu from 'primevue/contextmenu'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue'
|
||||
import { onClickOutside, useEventListener } from '@vueuse/core'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCanvasAnchoredPosition } from '@/composables/graph/useCanvasAnchoredPosition'
|
||||
import {
|
||||
canDisconnectSlot,
|
||||
canRemoveSlot,
|
||||
canRenameSlot,
|
||||
disconnectSlotLinks,
|
||||
registerSlotMenuInstance,
|
||||
removeSlot,
|
||||
renameSlot
|
||||
} from '@/renderer/extensions/vueNodes/composables/useSlotContextMenu'
|
||||
import type { SlotMenuContext } from '@/renderer/extensions/vueNodes/composables/useSlotContextMenu'
|
||||
@@ -32,79 +60,43 @@ import { useDialogService } from '@/services/dialogService'
|
||||
const { t } = useI18n()
|
||||
const dialogService = useDialogService()
|
||||
|
||||
const contextMenu = ref<InstanceType<typeof ContextMenu>>()
|
||||
const isOpen = ref(false)
|
||||
const activeContext = ref<SlotMenuContext | null>(null)
|
||||
const menuEl = ref<HTMLElement | null>(null)
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const lgCanvas = canvasStore.getCanvas()
|
||||
const { left: canvasLeft, top: canvasTop } = useElementBounding(lgCanvas.canvas)
|
||||
const { screenPosition, anchorToEvent } = useCanvasAnchoredPosition(isOpen)
|
||||
|
||||
const worldPosition = ref({ x: 0, y: 0 })
|
||||
let lastScale = 0
|
||||
let lastOffsetX = 0
|
||||
let lastOffsetY = 0
|
||||
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 updateMenuPosition() {
|
||||
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
|
||||
|
||||
if (
|
||||
scale === lastScale &&
|
||||
offset[0] === lastOffsetX &&
|
||||
offset[1] === lastOffsetY
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
lastScale = scale
|
||||
lastOffsetX = offset[0]
|
||||
lastOffsetY = offset[1]
|
||||
|
||||
const screenX = (worldPosition.value.x + offset[0]) * scale + canvasLeft.value
|
||||
const screenY = (worldPosition.value.y + offset[1]) * scale + canvasTop.value
|
||||
|
||||
menuEl.style.left = `${screenX}px`
|
||||
menuEl.style.top = `${screenY}px`
|
||||
function show(event: MouseEvent, context: SlotMenuContext) {
|
||||
activeContext.value = context
|
||||
anchorToEvent(event)
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
const { resume: startSync, pause: stopSync } = useRafFn(updateMenuPosition, {
|
||||
immediate: false
|
||||
})
|
||||
function hide() {
|
||||
isOpen.value = false
|
||||
activeContext.value = null
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (isOpen.value) {
|
||||
startSync()
|
||||
} else {
|
||||
stopSync()
|
||||
}
|
||||
})
|
||||
function handleDisconnect() {
|
||||
if (!activeContext.value) return
|
||||
disconnectSlotLinks(activeContext.value)
|
||||
hide()
|
||||
}
|
||||
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
async function handleRename() {
|
||||
const ctx = activeContext.value
|
||||
if (!ctx) return []
|
||||
|
||||
if (!canRenameSlot(ctx)) return []
|
||||
|
||||
return [
|
||||
{
|
||||
label: t('g.rename'),
|
||||
command: () => {
|
||||
hide()
|
||||
void handleRename(ctx)
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
async function handleRename(ctx: SlotMenuContext) {
|
||||
if (!ctx) return
|
||||
hide()
|
||||
const newLabel = await dialogService.prompt({
|
||||
title: t('g.rename'),
|
||||
message: t('g.enterNewNamePrompt')
|
||||
@@ -113,37 +105,19 @@ async function handleRename(ctx: SlotMenuContext) {
|
||||
renameSlot(ctx, newLabel)
|
||||
}
|
||||
|
||||
function show(event: MouseEvent, context: SlotMenuContext) {
|
||||
activeContext.value = context
|
||||
|
||||
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]
|
||||
|
||||
isOpen.value = true
|
||||
contextMenu.value?.show(event)
|
||||
function handleRemove() {
|
||||
if (!activeContext.value) return
|
||||
removeSlot(activeContext.value)
|
||||
hide()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
contextMenu.value?.hide()
|
||||
}
|
||||
onClickOutside(menuEl, () => {
|
||||
if (isOpen.value) hide()
|
||||
})
|
||||
|
||||
function onMenuShow() {
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
function onMenuHide() {
|
||||
isOpen.value = false
|
||||
activeContext.value = null
|
||||
}
|
||||
useEventListener(window, 'keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen.value) hide()
|
||||
})
|
||||
|
||||
defineExpose({ show, hide, isOpen })
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Ref } from 'vue'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
interface SlotMenuContext {
|
||||
export interface SlotMenuContext {
|
||||
nodeId: NodeId
|
||||
slotIndex: number
|
||||
isInput: boolean
|
||||
@@ -30,15 +30,77 @@ export function showSlotMenu(
|
||||
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 graph = app.canvas?.graph
|
||||
if (!graph) return
|
||||
|
||||
const node = graph.getNodeById(context.nodeId)
|
||||
if (!node) return
|
||||
const result = getSlotInfo(context)
|
||||
if (!result) return
|
||||
|
||||
const { graph, node } = result
|
||||
const slotInfo = context.isInput
|
||||
? node.getInputInfo(context.slotIndex)
|
||||
: node.getOutputInfo(context.slotIndex)
|
||||
@@ -48,24 +110,43 @@ export function renameSlot(context: SlotMenuContext, newLabel: string): void {
|
||||
slotInfo.label = newLabel
|
||||
app.canvas?.setDirty(true, true)
|
||||
graph.afterChange()
|
||||
triggerSlotRefresh(context)
|
||||
}
|
||||
|
||||
export function canRenameSlot(context: SlotMenuContext): boolean {
|
||||
const graph = app.canvas?.graph
|
||||
if (!graph) return false
|
||||
export function disconnectSlotLinks(context: SlotMenuContext): void {
|
||||
if (!canDisconnectSlot(context)) return
|
||||
|
||||
const node = graph.getNodeById(context.nodeId)
|
||||
if (!node) return false
|
||||
const result = getSlotInfo(context)
|
||||
if (!result) return
|
||||
|
||||
const slotInfo = context.isInput
|
||||
? node.inputs?.[context.slotIndex]
|
||||
: node.outputs?.[context.slotIndex]
|
||||
if (!slotInfo) return false
|
||||
const { graph, node } = result
|
||||
|
||||
if (slotInfo.nameLocked) return false
|
||||
if (context.isInput && 'widget' in slotInfo && slotInfo.widget) return false
|
||||
|
||||
return true
|
||||
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 type { SlotMenuContext }
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user