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:
bymyself
2026-03-13 09:38:24 -07:00
parent fa9cbdb3ab
commit e04453c359
9 changed files with 293 additions and 206 deletions

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,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 }
}

View File

@@ -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)

View File

@@ -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

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

@@ -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>

View File

@@ -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>

View File

@@ -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 })

View File

@@ -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)
}