mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
[feat] Add tooltip support for Vue nodes (#5577)
## Summary Added tooltip support for Vue node components using PrimeVue's v-tooltip directive with proper data integration and container scoping. https://github.com/user-attachments/assets/d1af31e6-ef6a-4df8-8de4-5098aa4490a1 ## Changes - **What**: Implemented tooltip functionality for Vue node headers, input/output slots, and widgets using [PrimeVue v-tooltip](https://primevue.org/tooltip/) directive - **Dependencies**: Leverages existing PrimeVue tooltip system, no new dependencies ## Review Focus Container scoping implementation via provide/inject pattern for tooltip positioning, proper TypeScript interfaces eliminating `as any` casts, and integration with existing settings store for tooltip delays and enable/disable functionality. ```mermaid graph TD A[LGraphNode Container] --> B[provide tooltipContainer] B --> C[NodeHeader inject] B --> D[InputSlot inject] B --> E[OutputSlot inject] B --> F[NodeWidgets inject] G[useNodeTooltips composable] --> H[NodeDefStore lookup] G --> I[Settings integration] G --> J[i18n fallback] C --> G D --> G E --> G F --> G style A fill:#f9f9f9,stroke:#333,color:#000 style G fill:#e8f4fd,stroke:#0066cc,color:#000 ``` --------- Co-authored-by: DrJKL <DrJKL@users.noreply.github.com> Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div v-if="renderError" class="node-error p-1 text-red-500 text-xs">⚠️</div>
|
||||
<div v-else :class="slotWrapperClass">
|
||||
<div v-else v-tooltip.left="tooltipConfig" :class="slotWrapperClass">
|
||||
<!-- Connection Dot -->
|
||||
<SlotConnectionDot
|
||||
ref="connectionDotRef"
|
||||
@@ -22,7 +22,9 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
type ComponentPublicInstance,
|
||||
type Ref,
|
||||
computed,
|
||||
inject,
|
||||
onErrorCaptured,
|
||||
ref,
|
||||
watchEffect
|
||||
@@ -30,7 +32,8 @@ import {
|
||||
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { getSlotColor } from '@/constants/slotColors'
|
||||
import type { INodeSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
|
||||
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
||||
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
|
||||
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
@@ -38,7 +41,7 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
import SlotConnectionDot from './SlotConnectionDot.vue'
|
||||
|
||||
interface InputSlotProps {
|
||||
node?: LGraphNode
|
||||
nodeType?: string
|
||||
nodeId?: string
|
||||
slotData: INodeSlot
|
||||
index: number
|
||||
@@ -54,6 +57,20 @@ const props = defineProps<InputSlotProps>()
|
||||
const renderError = ref<string | null>(null)
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
|
||||
const tooltipContainer =
|
||||
inject<Ref<HTMLElement | undefined>>('tooltipContainer')
|
||||
const { getInputSlotTooltip, createTooltipConfig } = useNodeTooltips(
|
||||
props.nodeType || '',
|
||||
tooltipContainer
|
||||
)
|
||||
|
||||
const tooltipConfig = computed(() => {
|
||||
const slotName = props.slotData.localized_name || props.slotData.name || ''
|
||||
const tooltipText = getInputSlotTooltip(slotName)
|
||||
const fallbackText = tooltipText || `Input: ${slotName}`
|
||||
return createTooltipConfig(fallbackText)
|
||||
})
|
||||
|
||||
onErrorCaptured((error) => {
|
||||
renderError.value = error.message
|
||||
toastErrorHandler(error)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
ref="nodeContainerRef"
|
||||
:data-node-id="nodeData.id"
|
||||
:class="
|
||||
cn(
|
||||
@@ -493,6 +494,10 @@ watch(
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// Provide nodeImageUrls to child components
|
||||
// Template ref for tooltip positioning
|
||||
const nodeContainerRef = ref<HTMLElement>()
|
||||
|
||||
// Provide nodeImageUrls and tooltip container to child components
|
||||
provide('nodeImageUrls', nodeImageUrls)
|
||||
provide('tooltipContainer', nodeContainerRef)
|
||||
</script>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { Settings } from '@/schemas/apiSchema'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
import NodeHeader from './NodeHeader.vue'
|
||||
|
||||
@@ -24,19 +28,94 @@ const makeNodeData = (overrides: Partial<VueNodeData> = {}): VueNodeData => ({
|
||||
...overrides
|
||||
})
|
||||
|
||||
const mountHeader = (
|
||||
props?: Partial<InstanceType<typeof NodeHeader>['$props']>
|
||||
) => {
|
||||
const setupMockStores = () => {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
// Mock tooltip delay setting
|
||||
vi.spyOn(settingStore, 'get').mockImplementation(
|
||||
<K extends keyof Settings>(key: K): Settings[K] => {
|
||||
switch (key) {
|
||||
case 'Comfy.EnableTooltips':
|
||||
return true as Settings[K]
|
||||
case 'LiteGraph.Node.TooltipDelay':
|
||||
return 500 as Settings[K]
|
||||
default:
|
||||
return undefined as Settings[K]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Mock node definition store
|
||||
const baseMockNodeDef: ComfyNodeDef = {
|
||||
name: 'KSampler',
|
||||
display_name: 'KSampler',
|
||||
category: 'sampling',
|
||||
python_module: 'test_module',
|
||||
description: 'Advanced sampling node for diffusion models',
|
||||
input: {
|
||||
required: {
|
||||
model: ['MODEL', {}],
|
||||
positive: ['CONDITIONING', {}],
|
||||
negative: ['CONDITIONING', {}]
|
||||
},
|
||||
optional: {},
|
||||
hidden: {}
|
||||
},
|
||||
output: ['LATENT'],
|
||||
output_is_list: [false],
|
||||
output_name: ['samples'],
|
||||
output_node: false,
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
}
|
||||
|
||||
const mockNodeDef = new ComfyNodeDefImpl(baseMockNodeDef)
|
||||
|
||||
vi.spyOn(nodeDefStore, 'nodeDefsByName', 'get').mockReturnValue({
|
||||
KSampler: mockNodeDef
|
||||
})
|
||||
|
||||
return { settingStore, nodeDefStore, pinia }
|
||||
}
|
||||
|
||||
const createMountConfig = () => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
return mount(NodeHeader, {
|
||||
|
||||
const { pinia } = setupMockStores()
|
||||
|
||||
return {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n, createPinia()],
|
||||
components: { InputText }
|
||||
},
|
||||
plugins: [PrimeVue, i18n, pinia],
|
||||
components: { InputText },
|
||||
directives: {
|
||||
tooltip: {
|
||||
mounted: vi.fn(),
|
||||
updated: vi.fn(),
|
||||
unmounted: vi.fn()
|
||||
}
|
||||
},
|
||||
provide: {
|
||||
tooltipContainer: { value: document.createElement('div') }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mountHeader = (
|
||||
props?: Partial<InstanceType<typeof NodeHeader>['$props']>
|
||||
) => {
|
||||
const config = createMountConfig()
|
||||
|
||||
return mount(NodeHeader, {
|
||||
...config,
|
||||
props: {
|
||||
nodeData: makeNodeData(),
|
||||
readonly: false,
|
||||
@@ -126,4 +205,68 @@ describe('NodeHeader.vue', () => {
|
||||
const collapsedIcon = wrapper.get('i')
|
||||
expect(collapsedIcon.classes()).toContain('pi-chevron-right')
|
||||
})
|
||||
|
||||
describe('Tooltips', () => {
|
||||
it('applies tooltip directive to node title with correct configuration', () => {
|
||||
const wrapper = mountHeader({
|
||||
nodeData: makeNodeData({ type: 'KSampler' })
|
||||
})
|
||||
|
||||
const titleElement = wrapper.find('[data-testid="node-title"]')
|
||||
expect(titleElement.exists()).toBe(true)
|
||||
|
||||
// Check that v-tooltip directive was applied
|
||||
const directive = wrapper.vm.$el.querySelector(
|
||||
'[data-testid="node-title"]'
|
||||
)
|
||||
expect(directive).toBeTruthy()
|
||||
})
|
||||
|
||||
it('disables tooltip when in readonly mode', () => {
|
||||
const wrapper = mountHeader({
|
||||
readonly: true,
|
||||
nodeData: makeNodeData({ type: 'KSampler' })
|
||||
})
|
||||
|
||||
const titleElement = wrapper.find('[data-testid="node-title"]')
|
||||
expect(titleElement.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('disables tooltip when editing is active', async () => {
|
||||
const wrapper = mountHeader({
|
||||
nodeData: makeNodeData({ type: 'KSampler' })
|
||||
})
|
||||
|
||||
// Enter edit mode
|
||||
await wrapper.get('[data-testid="node-header-1"]').trigger('dblclick')
|
||||
|
||||
// Tooltip should be disabled during editing
|
||||
const titleElement = wrapper.find('[data-testid="node-title"]')
|
||||
expect(titleElement.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('creates tooltip configuration when component mounts', () => {
|
||||
const wrapper = mountHeader({
|
||||
nodeData: makeNodeData({ type: 'KSampler' })
|
||||
})
|
||||
|
||||
// Verify tooltip directive is applied to the title element
|
||||
const titleElement = wrapper.find('[data-testid="node-title"]')
|
||||
expect(titleElement.exists()).toBe(true)
|
||||
|
||||
// The tooltip composable should be initialized
|
||||
expect(wrapper.vm).toBeDefined()
|
||||
})
|
||||
|
||||
it('uses tooltip container from provide/inject', () => {
|
||||
const wrapper = mountHeader({
|
||||
nodeData: makeNodeData({ type: 'KSampler' })
|
||||
})
|
||||
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
// Container should be provided through inject
|
||||
const titleElement = wrapper.find('[data-testid="node-title"]')
|
||||
expect(titleElement.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div
|
||||
v-else
|
||||
class="lg-node-header flex items-center justify-between p-4 rounded-t-2xl cursor-move"
|
||||
:data-testid="`node-header-${nodeInfo?.id || ''}`"
|
||||
:data-testid="`node-header-${nodeData?.id || ''}`"
|
||||
@dblclick="handleDoubleClick"
|
||||
>
|
||||
<!-- Collapse/Expand Button -->
|
||||
@@ -23,7 +23,11 @@
|
||||
</button>
|
||||
|
||||
<!-- Node Title -->
|
||||
<div class="text-sm font-bold truncate flex-1" data-testid="node-title">
|
||||
<div
|
||||
v-tooltip.top="tooltipConfig"
|
||||
class="text-sm font-bold truncate flex-1"
|
||||
data-testid="node-title"
|
||||
>
|
||||
<EditableText
|
||||
:model-value="displayTitle"
|
||||
:is-editing="isEditing"
|
||||
@@ -36,23 +40,22 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onErrorCaptured, ref, watch } from 'vue'
|
||||
import { type Ref, computed, inject, onErrorCaptured, ref, watch } from 'vue'
|
||||
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
||||
import type { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
|
||||
interface NodeHeaderProps {
|
||||
node?: LGraphNode // For backwards compatibility
|
||||
nodeData?: VueNodeData // New clean data structure
|
||||
nodeData?: VueNodeData
|
||||
readonly?: boolean
|
||||
lodLevel?: LODLevel
|
||||
collapsed?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<NodeHeaderProps>()
|
||||
const { nodeData, readonly, collapsed } = defineProps<NodeHeaderProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
collapse: []
|
||||
@@ -72,9 +75,22 @@ onErrorCaptured((error) => {
|
||||
// Editing state
|
||||
const isEditing = ref(false)
|
||||
|
||||
const nodeInfo = computed(() => props.nodeData || props.node)
|
||||
const tooltipContainer =
|
||||
inject<Ref<HTMLElement | undefined>>('tooltipContainer')
|
||||
const { getNodeDescription, createTooltipConfig } = useNodeTooltips(
|
||||
nodeData?.type || '',
|
||||
tooltipContainer
|
||||
)
|
||||
|
||||
const resolveTitle = (info: LGraphNode | VueNodeData | undefined) => {
|
||||
const tooltipConfig = computed(() => {
|
||||
if (readonly || isEditing.value) {
|
||||
return { value: '', disabled: true }
|
||||
}
|
||||
const description = getNodeDescription.value
|
||||
return createTooltipConfig(description)
|
||||
})
|
||||
|
||||
const resolveTitle = (info: VueNodeData | undefined) => {
|
||||
const title = (info?.title ?? '').trim()
|
||||
if (title.length > 0) return title
|
||||
const type = (info?.type ?? '').trim()
|
||||
@@ -82,13 +98,13 @@ const resolveTitle = (info: LGraphNode | VueNodeData | undefined) => {
|
||||
}
|
||||
|
||||
// Local state for title to provide immediate feedback
|
||||
const displayTitle = ref(resolveTitle(nodeInfo.value))
|
||||
const displayTitle = ref(resolveTitle(nodeData))
|
||||
|
||||
// Watch for external changes to the node title or type
|
||||
watch(
|
||||
() => [nodeInfo.value?.title, nodeInfo.value?.type] as const,
|
||||
() => [nodeData?.title, nodeData?.type] as const,
|
||||
() => {
|
||||
const next = resolveTitle(nodeInfo.value)
|
||||
const next = resolveTitle(nodeData)
|
||||
if (next !== displayTitle.value) {
|
||||
displayTitle.value = next
|
||||
}
|
||||
@@ -101,7 +117,7 @@ const handleCollapse = () => {
|
||||
}
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
if (!props.readonly) {
|
||||
if (!readonly) {
|
||||
isEditing.value = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
v-for="(input, index) in filteredInputs"
|
||||
:key="`input-${index}`"
|
||||
:slot-data="input"
|
||||
:node-id="nodeInfo?.id != null ? String(nodeInfo.id) : ''"
|
||||
:node-type="nodeData?.type || ''"
|
||||
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
|
||||
:index="getActualInputIndex(input, index)"
|
||||
:readonly="readonly"
|
||||
/>
|
||||
@@ -19,7 +20,8 @@
|
||||
v-for="(output, index) in filteredOutputs"
|
||||
:key="`output-${index}`"
|
||||
:slot-data="output"
|
||||
:node-id="nodeInfo?.id != null ? String(nodeInfo.id) : ''"
|
||||
:node-type="nodeData?.type || ''"
|
||||
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
|
||||
:index="index"
|
||||
:readonly="readonly"
|
||||
/>
|
||||
@@ -32,7 +34,7 @@ import { computed, onErrorCaptured, ref } from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import type { INodeSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
import { isSlotObject } from '@/utils/typeGuardUtil'
|
||||
|
||||
@@ -40,21 +42,18 @@ import InputSlot from './InputSlot.vue'
|
||||
import OutputSlot from './OutputSlot.vue'
|
||||
|
||||
interface NodeSlotsProps {
|
||||
node?: LGraphNode // For backwards compatibility
|
||||
nodeData?: VueNodeData // New clean data structure
|
||||
nodeData?: VueNodeData
|
||||
readonly?: boolean
|
||||
lodLevel?: LODLevel
|
||||
}
|
||||
|
||||
const props = defineProps<NodeSlotsProps>()
|
||||
|
||||
const nodeInfo = computed(() => props.nodeData || props.node || null)
|
||||
const { nodeData = null, readonly } = defineProps<NodeSlotsProps>()
|
||||
|
||||
// Filter out input slots that have corresponding widgets
|
||||
const filteredInputs = computed(() => {
|
||||
if (!nodeInfo.value?.inputs) return []
|
||||
if (!nodeData?.inputs) return []
|
||||
|
||||
return nodeInfo.value.inputs
|
||||
return nodeData.inputs
|
||||
.filter((input) => {
|
||||
// Check if this slot has a widget property (indicating it has a corresponding widget)
|
||||
if (isSlotObject(input) && 'widget' in input && input.widget) {
|
||||
@@ -76,7 +75,7 @@ const filteredInputs = computed(() => {
|
||||
|
||||
// Outputs don't have widgets, so we don't need to filter them
|
||||
const filteredOutputs = computed(() => {
|
||||
const outputs = nodeInfo.value?.outputs || []
|
||||
const outputs = nodeData?.outputs || []
|
||||
return outputs.map((output) =>
|
||||
isSlotObject(output)
|
||||
? output
|
||||
@@ -94,10 +93,10 @@ const getActualInputIndex = (
|
||||
input: INodeSlot,
|
||||
filteredIndex: number
|
||||
): number => {
|
||||
if (!nodeInfo.value?.inputs) return filteredIndex
|
||||
if (!nodeData?.inputs) return filteredIndex
|
||||
|
||||
// Find the actual index in the unfiltered inputs array
|
||||
const actualIndex = nodeInfo.value.inputs.findIndex((i) => i === input)
|
||||
const actualIndex = nodeData.inputs.findIndex((i) => i === input)
|
||||
return actualIndex !== -1 ? actualIndex : filteredIndex
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
type: widget.type,
|
||||
boundingRect: [0, 0, 0, 0]
|
||||
}"
|
||||
:node-id="nodeInfo?.id != null ? String(nodeInfo.id) : ''"
|
||||
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
|
||||
:index="getWidgetInputIndex(widget)"
|
||||
:readonly="readonly"
|
||||
:dot-only="true"
|
||||
@@ -40,6 +40,7 @@
|
||||
<!-- Widget Component -->
|
||||
<component
|
||||
:is="widget.vueComponent"
|
||||
v-tooltip.left="widget.tooltipConfig"
|
||||
:widget="widget.simplified"
|
||||
:model-value="widget.value"
|
||||
:readonly="readonly"
|
||||
@@ -51,15 +52,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onErrorCaptured, ref } from 'vue'
|
||||
import { type Ref, computed, inject, onErrorCaptured, ref } from 'vue'
|
||||
|
||||
import type {
|
||||
SafeWidgetData,
|
||||
VueNodeData
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
||||
import { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
// Import widget components directly
|
||||
import WidgetInputText from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue'
|
||||
@@ -74,13 +75,12 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
import InputSlot from './InputSlot.vue'
|
||||
|
||||
interface NodeWidgetsProps {
|
||||
node?: LGraphNode
|
||||
nodeData?: VueNodeData
|
||||
readonly?: boolean
|
||||
lodLevel?: LODLevel
|
||||
}
|
||||
|
||||
const props = defineProps<NodeWidgetsProps>()
|
||||
const { nodeData, readonly, lodLevel } = defineProps<NodeWidgetsProps>()
|
||||
|
||||
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
|
||||
useCanvasInteractions()
|
||||
@@ -101,7 +101,13 @@ onErrorCaptured((error) => {
|
||||
return false
|
||||
})
|
||||
|
||||
const nodeInfo = computed(() => props.nodeData || props.node)
|
||||
const nodeType = computed(() => nodeData?.type || '')
|
||||
const tooltipContainer =
|
||||
inject<Ref<HTMLElement | undefined>>('tooltipContainer')
|
||||
const { getWidgetTooltip, createTooltipConfig } = useNodeTooltips(
|
||||
nodeType.value,
|
||||
tooltipContainer
|
||||
)
|
||||
|
||||
interface ProcessedWidget {
|
||||
name: string
|
||||
@@ -110,14 +116,13 @@ interface ProcessedWidget {
|
||||
simplified: SimplifiedWidget
|
||||
value: WidgetValue
|
||||
updateHandler: (value: unknown) => void
|
||||
tooltipConfig: any
|
||||
}
|
||||
|
||||
const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
const info = nodeInfo.value
|
||||
if (!info?.widgets) return []
|
||||
if (!nodeData?.widgets) return []
|
||||
|
||||
const widgets = info.widgets as SafeWidgetData[]
|
||||
const lodLevel = props.lodLevel
|
||||
const widgets = nodeData.widgets as SafeWidgetData[]
|
||||
const result: ProcessedWidget[] = []
|
||||
|
||||
if (lodLevel === LODLevel.MINIMAL) {
|
||||
@@ -148,13 +153,17 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
}
|
||||
}
|
||||
|
||||
const tooltipText = getWidgetTooltip(widget)
|
||||
const tooltipConfig = createTooltipConfig(tooltipText)
|
||||
|
||||
result.push({
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
vueComponent,
|
||||
simplified,
|
||||
value: widget.value,
|
||||
updateHandler
|
||||
updateHandler,
|
||||
tooltipConfig
|
||||
})
|
||||
}
|
||||
|
||||
@@ -165,7 +174,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
// or restructuring data model to unify widgets and inputs
|
||||
// Map a widget to its corresponding input slot index
|
||||
const getWidgetInputIndex = (widget: ProcessedWidget): number => {
|
||||
const inputs = nodeInfo.value?.inputs
|
||||
const inputs = nodeData?.inputs
|
||||
if (!inputs) return 0
|
||||
|
||||
const idx = inputs.findIndex((input: any) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div v-if="renderError" class="node-error p-1 text-red-500 text-xs">⚠️</div>
|
||||
<div v-else :class="slotWrapperClass">
|
||||
<div v-else v-tooltip.right="tooltipConfig" :class="slotWrapperClass">
|
||||
<!-- Slot Name -->
|
||||
<span
|
||||
v-if="!dotOnly"
|
||||
@@ -22,7 +22,9 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
type ComponentPublicInstance,
|
||||
type Ref,
|
||||
computed,
|
||||
inject,
|
||||
onErrorCaptured,
|
||||
ref,
|
||||
watchEffect
|
||||
@@ -30,7 +32,8 @@ import {
|
||||
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { getSlotColor } from '@/constants/slotColors'
|
||||
import type { INodeSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
|
||||
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
||||
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
|
||||
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
@@ -38,7 +41,7 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
import SlotConnectionDot from './SlotConnectionDot.vue'
|
||||
|
||||
interface OutputSlotProps {
|
||||
node?: LGraphNode
|
||||
nodeType?: string
|
||||
nodeId?: string
|
||||
slotData: INodeSlot
|
||||
index: number
|
||||
@@ -55,6 +58,20 @@ const renderError = ref<string | null>(null)
|
||||
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
|
||||
const tooltipContainer =
|
||||
inject<Ref<HTMLElement | undefined>>('tooltipContainer')
|
||||
const { getOutputSlotTooltip, createTooltipConfig } = useNodeTooltips(
|
||||
props.nodeType || '',
|
||||
tooltipContainer
|
||||
)
|
||||
|
||||
const tooltipConfig = computed(() => {
|
||||
const slotName = props.slotData.name || ''
|
||||
const tooltipText = getOutputSlotTooltip(props.index)
|
||||
const fallbackText = tooltipText || `Output: ${slotName}`
|
||||
return createTooltipConfig(fallbackText)
|
||||
})
|
||||
|
||||
onErrorCaptured((error) => {
|
||||
renderError.value = error.message
|
||||
toastErrorHandler(error)
|
||||
|
||||
120
src/renderer/extensions/vueNodes/composables/useNodeTooltips.ts
Normal file
120
src/renderer/extensions/vueNodes/composables/useNodeTooltips.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { type MaybeRef, type Ref, computed, unref } from 'vue'
|
||||
|
||||
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { st } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
/**
|
||||
* Composable for managing Vue node tooltips
|
||||
* Provides tooltip text for node headers, slots, and widgets
|
||||
*/
|
||||
export function useNodeTooltips(
|
||||
nodeType: MaybeRef<string>,
|
||||
containerRef?: Ref<HTMLElement | undefined>
|
||||
) {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const settingsStore = useSettingStore()
|
||||
|
||||
// Check if tooltips are globally enabled
|
||||
const tooltipsEnabled = computed(() =>
|
||||
settingsStore.get('Comfy.EnableTooltips')
|
||||
)
|
||||
|
||||
// Get node definition for tooltip data
|
||||
const nodeDef = computed(() => nodeDefStore.nodeDefsByName[unref(nodeType)])
|
||||
|
||||
/**
|
||||
* Get tooltip text for node description (header hover)
|
||||
*/
|
||||
const getNodeDescription = computed(() => {
|
||||
if (!tooltipsEnabled.value || !nodeDef.value) return ''
|
||||
|
||||
const key = `nodeDefs.${normalizeI18nKey(unref(nodeType))}.description`
|
||||
return st(key, nodeDef.value.description || '')
|
||||
})
|
||||
|
||||
/**
|
||||
* Get tooltip text for input slots
|
||||
*/
|
||||
const getInputSlotTooltip = (slotName: string) => {
|
||||
if (!tooltipsEnabled.value || !nodeDef.value) return ''
|
||||
|
||||
const key = `nodeDefs.${normalizeI18nKey(unref(nodeType))}.inputs.${normalizeI18nKey(slotName)}.tooltip`
|
||||
const inputTooltip = nodeDef.value.inputs?.[slotName]?.tooltip ?? ''
|
||||
return st(key, inputTooltip)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tooltip text for output slots
|
||||
*/
|
||||
const getOutputSlotTooltip = (slotIndex: number) => {
|
||||
if (!tooltipsEnabled.value || !nodeDef.value) return ''
|
||||
|
||||
const key = `nodeDefs.${normalizeI18nKey(unref(nodeType))}.outputs.${slotIndex}.tooltip`
|
||||
const outputTooltip = nodeDef.value.outputs?.[slotIndex]?.tooltip ?? ''
|
||||
return st(key, outputTooltip)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tooltip text for widgets
|
||||
*/
|
||||
const getWidgetTooltip = (widget: SafeWidgetData) => {
|
||||
if (!tooltipsEnabled.value || !nodeDef.value) return ''
|
||||
|
||||
// First try widget-specific tooltip
|
||||
const widgetTooltip = (widget as { tooltip?: string }).tooltip
|
||||
if (widgetTooltip) return widgetTooltip
|
||||
|
||||
// Then try input-based tooltip lookup
|
||||
const key = `nodeDefs.${normalizeI18nKey(unref(nodeType))}.inputs.${normalizeI18nKey(widget.name)}.tooltip`
|
||||
const inputTooltip = nodeDef.value.inputs?.[widget.name]?.tooltip ?? ''
|
||||
return st(key, inputTooltip)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create tooltip configuration object for v-tooltip directive
|
||||
*/
|
||||
const createTooltipConfig = (text: string) => {
|
||||
const tooltipDelay = settingsStore.get('LiteGraph.Node.TooltipDelay')
|
||||
const tooltipText = text || ''
|
||||
|
||||
const config: {
|
||||
value: string
|
||||
showDelay: number
|
||||
disabled: boolean
|
||||
appendTo?: HTMLElement
|
||||
pt?: any
|
||||
} = {
|
||||
value: tooltipText,
|
||||
showDelay: tooltipDelay as number,
|
||||
disabled: !tooltipsEnabled.value || !tooltipText,
|
||||
pt: {
|
||||
text: {
|
||||
class:
|
||||
'bg-charcoal-100 border border-slate-300 rounded-md px-4 py-2 text-white text-sm font-normal leading-tight max-w-75 shadow-none'
|
||||
},
|
||||
arrow: {
|
||||
class: 'before:border-charcoal-100'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a container reference, append tooltips to it
|
||||
if (containerRef?.value) {
|
||||
config.appendTo = containerRef.value
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
return {
|
||||
tooltipsEnabled,
|
||||
getNodeDescription,
|
||||
getInputSlotTooltip,
|
||||
getOutputSlotTooltip,
|
||||
getWidgetTooltip,
|
||||
createTooltipConfig
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user