mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-07 00:20:07 +00:00
This pull request refactors and improves the "More Options" popover functionality for graph nodes in the UI. The main change is a rename and redesign of the menu component from `MoreOptions` to `NodeOptions`, introducing a global singleton pattern for popover control and enabling context menu support on node right-click. This results in better maintainability, more flexible triggering, and improved user experience. **Node Options popover refactor and global control:** * Renamed and refactored `MoreOptions.vue` to `NodeOptions.vue`, removing the embedded button and exposing imperative methods (`toggle`, `hide`, `isOpen`) for external control. The component now registers/unregisters itself globally via `registerNodeOptionsInstance`. [[1]](diffhunk://#diff-e0dbd5e37efd2c79e7317415455340b0dd150b758077b170a663f67d2453605eL2-R2) [[2]](diffhunk://#diff-e0dbd5e37efd2c79e7317415455340b0dd150b758077b170a663f67d2453605eL203-R197) [[3]](diffhunk://#diff-e0dbd5e37efd2c79e7317415455340b0dd150b758077b170a663f67d2453605eR294-R309) * Added `NodeOptionsButton.vue` as a dedicated button component for triggering the popover, decoupling the button UI from the popover logic. * Implemented a global singleton pattern in `useMoreOptionsMenu.ts` for controlling the `NodeOptions` popover from anywhere, with `toggleNodeOptions` and `registerNodeOptionsInstance` functions. [[1]](diffhunk://#diff-ae87bdb1e06725eb19b8d0fc82ec40a5f8ca831a6632767cc5d214fc903b89b6R35-R65) [[2]](diffhunk://#diff-ae87bdb1e06725eb19b8d0fc82ec40a5f8ca831a6632767cc5d214fc903b89b6L184-R216) **UI integration and event handling improvements:** * Updated `SelectionToolbox.vue` to use the new `NodeOptionsButton` instead of the previous embedded `MoreOptions` button, and added the `NodeOptions` popover to the main `GraphCanvas.vue` template for global accessibility. [[1]](diffhunk://#diff-05d80ee1e28e634dc758394ddf1bfaa8e5ec72a186a6ea2e2b6f5dfba867b264L41-R41) [[2]](diffhunk://#diff-05d80ee1e28e634dc758394ddf1bfaa8e5ec72a186a6ea2e2b6f5dfba867b264L71-R71) [[3]](diffhunk://#diff-aaf17c713f29c6db8ea03efe7fc3483a858982e818a324b23cff89859e71559cR65) [[4]](diffhunk://#diff-aaf17c713f29c6db8ea03efe7fc3483a858982e818a324b23cff89859e71559cR91) * Added right-click context menu support to `LGraphNode.vue`, triggering the node options popover at the cursor position and integrating with node selection logic. [[1]](diffhunk://#diff-a7744614cf842e54416047326db79ad81f7c7ab7bfb66ae2b46f5c73ac7d47f2R45) [[2]](diffhunk://#diff-a7744614cf842e54416047326db79ad81f7c7ab7bfb66ae2b46f5c73ac7d47f2R141) [[3]](diffhunk://#diff-a7744614cf842e54416047326db79ad81f7c7ab7bfb66ae2b46f5c73ac7d47f2L180-R187) [[4]](diffhunk://#diff-a7744614cf842e54416047326db79ad81f7c7ab7bfb66ae2b46f5c73ac7d47f2R249-R263) **Minor improvements and cleanup:** * Updated references and variable names throughout the codebase to reflect the new `NodeOptions` naming and logic. [[1]](diffhunk://#diff-e0dbd5e37efd2c79e7317415455340b0dd150b758077b170a663f67d2453605eL53) [[2]](diffhunk://#diff-e0dbd5e37efd2c79e7317415455340b0dd150b758077b170a663f67d2453605eR50) [[3]](diffhunk://#diff-e0dbd5e37efd2c79e7317415455340b0dd150b758077b170a663f67d2453605eL75-R60) [[4]](diffhunk://#diff-e0dbd5e37efd2c79e7317415455340b0dd150b758077b170a663f67d2453605eL91-L95) [[5]](diffhunk://#diff-e0dbd5e37efd2c79e7317415455340b0dd150b758077b170a663f67d2453605eL110-R90) [[6]](diffhunk://#diff-e0dbd5e37efd2c79e7317415455340b0dd150b758077b170a663f67d2453605eL133-R113) [[7]](diffhunk://#diff-e0dbd5e37efd2c79e7317415455340b0dd150b758077b170a663f67d2453605eL146-R126) [[8]](diffhunk://#diff-e0dbd5e37efd2c79e7317415455340b0dd150b758077b170a663f67d2453605eL157-R140) This refactor makes the node options menu more modular, easier to maintain, and more flexible for future UI improvements. https://github.com/user-attachments/assets/9c2f2556-4544-4e20-9f22-8f485b0ceadc ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5790-Right-click-vue-nodes-27a6d73d365081a98263c88d2e09e629) by [Unito](https://www.unito.io)
378 lines
12 KiB
Vue
378 lines
12 KiB
Vue
<template>
|
|
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
|
|
{{ $t('Node Render Error') }}
|
|
</div>
|
|
<div
|
|
v-else
|
|
ref="nodeContainerRef"
|
|
:data-node-id="nodeData.id"
|
|
:class="
|
|
cn(
|
|
'bg-white dark-theme:bg-charcoal-800',
|
|
'lg-node absolute rounded-2xl',
|
|
'border border-solid border-sand-100 dark-theme:border-charcoal-600',
|
|
// hover (only when node should handle events)
|
|
shouldHandleNodePointerEvents &&
|
|
'hover:ring-7 ring-gray-500/50 dark-theme:ring-gray-500/20',
|
|
'outline-transparent -outline-offset-2 outline-2',
|
|
borderClass,
|
|
outlineClass,
|
|
{
|
|
'animate-pulse': executing,
|
|
'opacity-50 before:rounded-2xl before:pointer-events-none before:absolute before:bg-bypass/60 before:inset-0':
|
|
bypassed,
|
|
'opacity-50 before:rounded-2xl before:pointer-events-none before:absolute before:inset-0':
|
|
muted,
|
|
'will-change-transform': isDragging
|
|
},
|
|
|
|
shouldHandleNodePointerEvents
|
|
? 'pointer-events-auto'
|
|
: 'pointer-events-none'
|
|
)
|
|
"
|
|
:style="[
|
|
{
|
|
transform: `translate(${position.x ?? 0}px, ${(position.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
|
|
zIndex: zIndex
|
|
},
|
|
dragStyle
|
|
]"
|
|
v-bind="pointerHandlers"
|
|
@wheel="handleWheel"
|
|
@contextmenu="handleContextMenu"
|
|
>
|
|
<div class="flex items-center">
|
|
<template v-if="isCollapsed">
|
|
<SlotConnectionDot multi class="absolute left-0 -translate-x-1/2" />
|
|
<SlotConnectionDot multi class="absolute right-0 translate-x-1/2" />
|
|
</template>
|
|
<!-- Header only updates on title/color changes -->
|
|
<NodeHeader
|
|
v-memo="[nodeData.title, isCollapsed]"
|
|
:node-data="nodeData"
|
|
:readonly="readonly"
|
|
:collapsed="isCollapsed"
|
|
@collapse="handleCollapse"
|
|
@update:title="handleHeaderTitleUpdate"
|
|
@enter-subgraph="handleEnterSubgraph"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
v-if="isCollapsed && executing && progress !== undefined"
|
|
:class="
|
|
cn(
|
|
'absolute inset-x-4 -bottom-[1px] translate-y-1/2 rounded-full',
|
|
progressClasses
|
|
)
|
|
"
|
|
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
|
|
/>
|
|
|
|
<template v-if="!isCollapsed">
|
|
<div class="mb-4 relative">
|
|
<div :class="separatorClasses" />
|
|
<!-- Progress bar for executing state -->
|
|
<div
|
|
v-if="executing && progress !== undefined"
|
|
:class="
|
|
cn(
|
|
'absolute inset-x-0 top-1/2 -translate-y-1/2',
|
|
!!(progress < 1) && 'rounded-r-full',
|
|
progressClasses
|
|
)
|
|
"
|
|
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Node Body - rendered based on LOD level and collapsed state -->
|
|
<div
|
|
class="flex flex-col gap-4 pb-4"
|
|
:data-testid="`node-body-${nodeData.id}`"
|
|
>
|
|
<!-- Slots only rendered at full detail -->
|
|
<NodeSlots
|
|
v-memo="[nodeData.inputs?.length, nodeData.outputs?.length]"
|
|
:node-data="nodeData"
|
|
:readonly="readonly"
|
|
/>
|
|
|
|
<!-- Widgets rendered at reduced+ detail -->
|
|
<NodeWidgets
|
|
v-if="nodeData.widgets?.length"
|
|
v-memo="[nodeData.widgets?.length]"
|
|
:node-data="nodeData"
|
|
:readonly="readonly"
|
|
/>
|
|
|
|
<!-- Custom content at reduced+ detail -->
|
|
<NodeContent
|
|
v-if="hasCustomContent"
|
|
:node-data="nodeData"
|
|
:readonly="readonly"
|
|
:image-urls="nodeImageUrls"
|
|
/>
|
|
<!-- Live preview image -->
|
|
<div
|
|
v-if="shouldShowPreviewImg"
|
|
v-memo="[latestPreviewUrl]"
|
|
class="px-4"
|
|
>
|
|
<img
|
|
:src="latestPreviewUrl"
|
|
alt="preview"
|
|
class="w-full max-h-64 object-contain"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { storeToRefs } from 'pinia'
|
|
import { computed, inject, onErrorCaptured, onMounted, provide, ref } from 'vue'
|
|
|
|
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
|
import { toggleNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
|
|
import { useErrorHandling } from '@/composables/useErrorHandling'
|
|
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
|
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
|
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
|
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
|
|
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
|
|
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
|
|
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
|
|
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
|
|
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
|
|
import { useNodePreviewState } from '@/renderer/extensions/vueNodes/preview/useNodePreviewState'
|
|
import { app } from '@/scripts/app'
|
|
import { useExecutionStore } from '@/stores/executionStore'
|
|
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
|
import {
|
|
getLocatorIdFromNodeData,
|
|
getNodeByLocatorId
|
|
} from '@/utils/graphTraversalUtil'
|
|
import { cn } from '@/utils/tailwindUtil'
|
|
|
|
import NodeContent from './NodeContent.vue'
|
|
import NodeHeader from './NodeHeader.vue'
|
|
import NodeSlots from './NodeSlots.vue'
|
|
import NodeWidgets from './NodeWidgets.vue'
|
|
import SlotConnectionDot from './SlotConnectionDot.vue'
|
|
|
|
// Extended props for main node component
|
|
interface LGraphNodeProps {
|
|
nodeData: VueNodeData
|
|
readonly?: boolean
|
|
error?: string | null
|
|
zoomLevel?: number
|
|
}
|
|
|
|
const {
|
|
nodeData,
|
|
error = null,
|
|
readonly = false
|
|
} = defineProps<LGraphNodeProps>()
|
|
|
|
const {
|
|
handleNodeCollapse,
|
|
handleNodeTitleUpdate,
|
|
handleNodeSelect,
|
|
handleNodeRightClick
|
|
} = useNodeEventHandlers()
|
|
|
|
useVueElementTracking(() => nodeData.id, 'node')
|
|
|
|
const { selectedNodeIds } = storeToRefs(useCanvasStore())
|
|
|
|
// Inject transform state for coordinate conversion
|
|
const transformState = inject(TransformStateKey)
|
|
|
|
// Computed selection state - only this node re-evaluates when its selection changes
|
|
const isSelected = computed(() => {
|
|
return selectedNodeIds.value.has(nodeData.id)
|
|
})
|
|
|
|
// Use execution state composable
|
|
const { executing, progress } = useNodeExecutionState(() => nodeData.id)
|
|
|
|
// Direct access to execution store for error state
|
|
const executionStore = useExecutionStore()
|
|
const hasExecutionError = computed(
|
|
() => executionStore.lastExecutionErrorNodeId === nodeData.id
|
|
)
|
|
|
|
// Computed error states for styling
|
|
const hasAnyError = computed((): boolean => {
|
|
return !!(
|
|
hasExecutionError.value ||
|
|
nodeData.hasErrors ||
|
|
error ||
|
|
// Type assertions needed because VueNodeData.inputs/outputs are typed as unknown[]
|
|
// but at runtime they contain INodeInputSlot/INodeOutputSlot objects
|
|
nodeData.inputs?.some((slot) => slot?.hasErrors) ||
|
|
nodeData.outputs?.some((slot) => slot?.hasErrors)
|
|
)
|
|
})
|
|
|
|
const bypassed = computed((): boolean => nodeData.mode === 4)
|
|
const muted = computed((): boolean => nodeData.mode === 2) // NEVER mode
|
|
|
|
// Use canvas interactions for proper wheel event handling and pointer event capture control
|
|
const { handleWheel, shouldHandleNodePointerEvents } = useCanvasInteractions()
|
|
|
|
// Error boundary implementation
|
|
const renderError = ref<string | null>(null)
|
|
const { toastErrorHandler } = useErrorHandling()
|
|
|
|
onErrorCaptured((error) => {
|
|
renderError.value = error.message
|
|
toastErrorHandler(error)
|
|
return false // Prevent error propagation
|
|
})
|
|
|
|
// Use layout system for node position and dragging
|
|
const { position, size, zIndex, resize } = useNodeLayout(() => nodeData.id)
|
|
const { pointerHandlers, isDragging, dragStyle } = useNodePointerInteractions(
|
|
() => nodeData,
|
|
handleNodeSelect
|
|
)
|
|
|
|
// Handle right-click context menu
|
|
const handleContextMenu = (event: MouseEvent) => {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
|
|
// First handle the standard right-click behavior (selection)
|
|
handleNodeRightClick(event as PointerEvent, nodeData)
|
|
|
|
// Show the node options menu at the cursor position
|
|
const targetElement = event.currentTarget as HTMLElement
|
|
if (targetElement) {
|
|
toggleNodeOptions(event, targetElement, false)
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
if (size.value && transformState?.camera) {
|
|
const scale = transformState.camera.z
|
|
const screenSize = {
|
|
width: size.value.width * scale,
|
|
height: size.value.height * scale
|
|
}
|
|
resize(screenSize)
|
|
}
|
|
})
|
|
|
|
// Track collapsed state
|
|
const isCollapsed = computed(() => nodeData.flags?.collapsed ?? false)
|
|
|
|
// Check if node has custom content (like image outputs)
|
|
const hasCustomContent = computed(() => {
|
|
// Show custom content if node has image outputs
|
|
return nodeImageUrls.value.length > 0
|
|
})
|
|
|
|
// Computed classes and conditions for better reusability
|
|
const separatorClasses =
|
|
'bg-sand-100 dark-theme:bg-charcoal-600 h-px mx-0 w-full lod-toggle'
|
|
const progressClasses = 'h-2 bg-primary-500 transition-all duration-300'
|
|
|
|
const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
|
|
() => nodeData.id,
|
|
{
|
|
isCollapsed
|
|
}
|
|
)
|
|
|
|
const borderClass = computed(() => {
|
|
if (hasAnyError.value) {
|
|
return 'border-error dark-theme:border-error'
|
|
}
|
|
if (executing.value) {
|
|
return 'border-blue-500'
|
|
}
|
|
return undefined
|
|
})
|
|
|
|
const outlineClass = computed(() => {
|
|
if (!isSelected.value) {
|
|
return undefined
|
|
}
|
|
if (hasAnyError.value) {
|
|
return 'outline-error dark-theme:outline-error'
|
|
}
|
|
if (executing.value) {
|
|
return 'outline-blue-500 dark-theme:outline-blue-500'
|
|
}
|
|
return 'outline-black dark-theme:outline-white'
|
|
})
|
|
|
|
// Event handlers
|
|
const handleCollapse = () => {
|
|
handleNodeCollapse(nodeData.id, !isCollapsed.value)
|
|
}
|
|
|
|
const handleHeaderTitleUpdate = (newTitle: string) => {
|
|
handleNodeTitleUpdate(nodeData.id, newTitle)
|
|
}
|
|
|
|
const handleEnterSubgraph = () => {
|
|
const graph = app.graph?.rootGraph || app.graph
|
|
if (!graph) {
|
|
console.warn('LGraphNode: No graph available for subgraph navigation')
|
|
return
|
|
}
|
|
|
|
const locatorId = getLocatorIdFromNodeData(nodeData)
|
|
|
|
const litegraphNode = getNodeByLocatorId(graph, locatorId)
|
|
|
|
if (!litegraphNode?.isSubgraphNode() || !('subgraph' in litegraphNode)) {
|
|
console.warn('LGraphNode: Node is not a valid subgraph node', litegraphNode)
|
|
return
|
|
}
|
|
|
|
const canvas = app.canvas
|
|
if (!canvas || typeof canvas.openSubgraph !== 'function') {
|
|
console.warn('LGraphNode: Canvas or openSubgraph method not available')
|
|
return
|
|
}
|
|
|
|
canvas.openSubgraph(litegraphNode.subgraph)
|
|
}
|
|
|
|
const nodeOutputs = useNodeOutputStore()
|
|
|
|
const nodeOutputLocatorId = computed(() =>
|
|
nodeData.subgraphId ? `${nodeData.subgraphId}:${nodeData.id}` : nodeData.id
|
|
)
|
|
const nodeImageUrls = computed(() => {
|
|
const newOutputs = nodeOutputs.nodeOutputs[nodeOutputLocatorId.value]
|
|
const locatorId = getLocatorIdFromNodeData(nodeData)
|
|
|
|
// Use root graph for getNodeByLocatorId since it needs to traverse from root
|
|
const rootGraph = app.graph?.rootGraph || app.graph
|
|
if (!rootGraph) {
|
|
return []
|
|
}
|
|
|
|
const node = getNodeByLocatorId(rootGraph, locatorId)
|
|
|
|
if (node && newOutputs?.images?.length) {
|
|
const urls = nodeOutputs.getNodeImageUrls(node)
|
|
if (urls) {
|
|
return urls
|
|
}
|
|
}
|
|
// Clear URLs if no outputs or no images
|
|
return []
|
|
})
|
|
|
|
const nodeContainerRef = ref()
|
|
provide('tooltipContainer', nodeContainerRef)
|
|
</script>
|