refactor: migrate node tooltips from PrimeVue to BaseTooltip

Replace PrimeVue v-tooltip directive with Reka UI BaseTooltip component
in NodeHeader, InputSlot, OutputSlot, and NodeWidgets. Remove
PrimeVue-specific tooltip config (createTooltipConfig, hideTooltipsGlobally,
tooltipsTemporarilyDisabled, global event listeners) from useNodeTooltips
composable, keeping only text-resolution functions and tooltipsEnabled.
Add tooltipDelay computed from LiteGraph.Node.TooltipDelay setting.
This commit is contained in:
dante01yoon
2026-03-24 10:59:32 +09:00
parent f16214a719
commit 82a0b59367
7 changed files with 150 additions and 282 deletions

View File

@@ -1,63 +1,71 @@
<template>
<div v-if="renderError" class="node-error p-1 text-xs text-red-500"></div>
<div
<BaseTooltip
v-else
v-tooltip.left="tooltipConfig"
:class="
cn(
'lg-slot lg-slot--input group m-0 flex items-center rounded-r-lg',
'cursor-crosshair',
dotOnly ? 'lg-slot--dot-only' : 'pr-6',
{
'lg-slot--connected': props.connected,
'lg-slot--compatible': props.compatible,
'opacity-40': shouldDim
},
props.socketless && 'pointer-events-none invisible'
)
"
:text="inputTooltipText"
side="left"
size="large"
:delay-duration="tooltipDelay"
:disabled="!tooltipsEnabled"
>
<!-- Connection Dot -->
<SlotConnectionDot
ref="connectionDotRef"
<div
:class="
cn(
'w-3 -translate-x-1/2',
hasError &&
'before:pointer-events-none before:absolute before:size-4 before:rounded-full before:ring-2 before:ring-error before:ring-offset-0'
'lg-slot lg-slot--input group m-0 flex items-center rounded-r-lg',
'cursor-crosshair',
dotOnly ? 'lg-slot--dot-only' : 'pr-6',
{
'lg-slot--connected': props.connected,
'lg-slot--compatible': props.compatible,
'opacity-40': shouldDim
},
props.socketless && 'pointer-events-none invisible'
)
"
:slot-data
@click="onClick"
@dblclick="onDoubleClick"
@pointerdown="onPointerDown"
/>
<!-- Slot Name -->
<div class="flex h-full min-w-0 items-center">
<span
v-if="!props.dotOnly && !hasNoLabel"
>
<!-- Connection Dot -->
<SlotConnectionDot
ref="connectionDotRef"
:class="
cn(
'truncate text-node-component-slot-text',
hasError && 'font-medium text-error'
'w-3 -translate-x-1/2',
hasError &&
'before:pointer-events-none before:absolute before:size-4 before:rounded-full before:ring-2 before:ring-error before:ring-offset-0'
)
"
>
{{
slotData.label ||
slotData.localized_name ||
(slotData.name ?? `Input ${index}`)
}}
</span>
:slot-data
@click="onClick"
@dblclick="onDoubleClick"
@pointerdown="onPointerDown"
/>
<!-- Slot Name -->
<div class="flex h-full min-w-0 items-center">
<span
v-if="!props.dotOnly && !hasNoLabel"
:class="
cn(
'truncate text-node-component-slot-text',
hasError && 'font-medium text-error'
)
"
>
{{
slotData.label ||
slotData.localized_name ||
(slotData.name ?? `Input ${index}`)
}}
</span>
</div>
</div>
</div>
</BaseTooltip>
</template>
<script setup lang="ts">
import { computed, onErrorCaptured, ref, watchEffect } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import BaseTooltip from '@/components/ui/tooltip/BaseTooltip.vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState'
@@ -94,15 +102,13 @@ const dotOnly = computed(() => props.dotOnly || hasNoLabel.value)
const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()
const { getInputSlotTooltip, createTooltipConfig } = useNodeTooltips(
const { getInputSlotTooltip, tooltipsEnabled, tooltipDelay } = useNodeTooltips(
props.nodeType || ''
)
const tooltipConfig = computed(() => {
const inputTooltipText = computed(() => {
const slotName = props.slotData.localized_name || props.slotData.name || ''
const tooltipText = getInputSlotTooltip(slotName)
const fallbackText = tooltipText || `Input: ${slotName}`
return createTooltipConfig(fallbackText)
return getInputSlotTooltip(slotName) || `Input: ${slotName}`
})
onErrorCaptured((error) => {

View File

@@ -192,6 +192,7 @@ describe('LGraphNode', () => {
global: {
plugins: [pinia, i18n],
stubs: {
BaseTooltip: { template: '<div><slot /></div>' },
NodeSlots: true,
NodeWidgets: true,
NodeContent: true,

View File

@@ -96,11 +96,9 @@ const createMountConfig = () => {
global: {
plugins: [PrimeVue, i18n, pinia],
components: { InputText },
directives: {
tooltip: {
mounted: vi.fn(),
updated: vi.fn(),
unmounted: vi.fn()
stubs: {
BaseTooltip: {
template: '<div><slot /></div>'
}
}
}
@@ -190,56 +188,13 @@ describe('NodeHeader.vue', () => {
})
describe('Tooltips', () => {
it('applies tooltip directive to node title with correct configuration', () => {
it('renders node title inside a tooltip wrapper', () => {
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 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)
})
})
})

View File

@@ -38,21 +38,28 @@
</Button>
</div>
<!-- Node Title -->
<div
v-tooltip.top="tooltipConfig"
class="flex min-w-0 flex-1 items-center gap-2"
data-testid="node-title"
<BaseTooltip
:text="isEditing ? '' : getNodeDescription"
side="top"
size="large"
:delay-duration="tooltipDelay"
:disabled="!tooltipsEnabled"
>
<div class="flex-1 truncate">
<EditableText
:model-value="displayTitle"
:is-editing="isEditing"
:input-attrs="{ 'data-testid': 'node-title-input' }"
@edit="handleTitleEdit"
@cancel="handleTitleCancel"
/>
<div
class="flex min-w-0 flex-1 items-center gap-2"
data-testid="node-title"
>
<div class="flex-1 truncate">
<EditableText
:model-value="displayTitle"
:is-editing="isEditing"
:input-attrs="{ 'data-testid': 'node-title-input' }"
@edit="handleTitleEdit"
@cancel="handleTitleCancel"
/>
</div>
</div>
</div>
</BaseTooltip>
</div>
<template v-for="badge in priceBadges ?? []" :key="badge.required">
@@ -89,6 +96,7 @@ import { computed, onErrorCaptured, ref, watch } from 'vue'
import EditableText from '@/components/common/EditableText.vue'
import Button from '@/components/ui/button/Button.vue'
import BaseTooltip from '@/components/ui/tooltip/BaseTooltip.vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { st } from '@/i18n'
@@ -126,18 +134,10 @@ onErrorCaptured((error) => {
// Editing state
const isEditing = ref(false)
const { getNodeDescription, createTooltipConfig } = useNodeTooltips(
const { getNodeDescription, tooltipsEnabled, tooltipDelay } = useNodeTooltips(
nodeData?.type || ''
)
const tooltipConfig = computed(() => {
if (isEditing.value) {
return { value: '', disabled: true }
}
const description = getNodeDescription.value
return createTooltipConfig(description)
})
const resolveTitle = (info: VueNodeData | undefined) => {
const untitledLabel = st('g.untitled', 'Untitled')
return resolveNodeDisplayName(info ?? null, {

View File

@@ -55,22 +55,29 @@
:name="widget.name"
:enable="canSelectInputs && !widget.simplified.options?.disabled"
>
<component
:is="widget.vueComponent"
v-model="widget.value"
v-tooltip.left="widget.tooltipConfig"
:widget="widget.simplified"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:node-type="nodeType"
:class="
cn(
'col-span-2',
widget.hasError && 'font-bold text-node-stroke-error'
)
"
@update:model-value="widget.updateHandler"
@contextmenu="widget.handleContextMenu"
/>
<BaseTooltip
:text="widget.tooltipText"
side="left"
size="large"
:delay-duration="tooltipDelay"
:disabled="!tooltipsEnabled"
>
<component
:is="widget.vueComponent"
v-model="widget.value"
:widget="widget.simplified"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:node-type="nodeType"
:class="
cn(
'col-span-2',
widget.hasError && 'font-bold text-node-stroke-error'
)
"
@update:model-value="widget.updateHandler"
@contextmenu="widget.handleContextMenu"
/>
</BaseTooltip>
</AppInput>
</div>
</template>
@@ -78,10 +85,10 @@
</template>
<script setup lang="ts">
import type { TooltipOptions } from 'primevue'
import { computed, onErrorCaptured, ref, toValue } from 'vue'
import type { Component } from 'vue'
import BaseTooltip from '@/components/ui/tooltip/BaseTooltip.vue'
import type {
SafeWidgetData,
VueNodeData,
@@ -182,7 +189,7 @@ const showAdvanced = computed(
nodeData?.showAdvanced ||
settingStore.get('Comfy.Node.AlwaysShowAdvancedWidgets')
)
const { getWidgetTooltip, createTooltipConfig } = useNodeTooltips(
const { getWidgetTooltip, tooltipsEnabled, tooltipDelay } = useNodeTooltips(
nodeType.value
)
const widgetValueStore = useWidgetValueStore()
@@ -217,7 +224,7 @@ interface ProcessedWidget {
name: string
renderKey: string
simplified: SimplifiedWidget
tooltipConfig: TooltipOptions
tooltipText: string
type: string
updateHandler: (value: WidgetValue) => void
value: WidgetValue
@@ -426,7 +433,6 @@ const processedWidgets = computed((): ProcessedWidget[] => {
)
const tooltipText = getWidgetTooltip(widget)
const tooltipConfig = createTooltipConfig(tooltipText)
const handleContextMenu = (e: PointerEvent) => {
e.preventDefault()
e.stopPropagation()
@@ -454,7 +460,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
simplified,
value,
updateHandler,
tooltipConfig,
tooltipText,
slotMetadata
})
}

View File

@@ -1,27 +1,36 @@
<template>
<div v-if="renderError" class="node-error p-1 text-xs text-red-500"></div>
<div v-else v-tooltip.right="tooltipConfig" :class="slotWrapperClass">
<div class="relative flex h-full min-w-0 items-center">
<!-- Slot Name -->
<span
v-if="!props.dotOnly && !hasNoLabel"
class="truncate text-node-component-slot-text"
>
{{
slotData.label ||
slotData.localized_name ||
(slotData.name ?? `Output ${index}`)
}}
</span>
<BaseTooltip
v-else
:text="outputTooltipText"
side="right"
size="large"
:delay-duration="tooltipDelay"
:disabled="!tooltipsEnabled"
>
<div :class="slotWrapperClass">
<div class="relative flex h-full min-w-0 items-center">
<!-- Slot Name -->
<span
v-if="!props.dotOnly && !hasNoLabel"
class="truncate text-node-component-slot-text"
>
{{
slotData.label ||
slotData.localized_name ||
(slotData.name ?? `Output ${index}`)
}}
</span>
</div>
<!-- Connection Dot -->
<SlotConnectionDot
ref="connectionDotRef"
class="w-3 translate-x-1/2"
:slot-data
@pointerdown="onPointerDown"
/>
</div>
<!-- Connection Dot -->
<SlotConnectionDot
ref="connectionDotRef"
class="w-3 translate-x-1/2"
:slot-data
@pointerdown="onPointerDown"
/>
</div>
</BaseTooltip>
</template>
<script setup lang="ts">
@@ -29,6 +38,7 @@ import { computed, onErrorCaptured, ref, watchEffect } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseTooltip from '@/components/ui/tooltip/BaseTooltip.vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
import { RenderShape } from '@/lib/litegraph/src/types/globalEnums'
@@ -65,11 +75,11 @@ const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()
const { getOutputSlotTooltip, createTooltipConfig } = useNodeTooltips(
const { getOutputSlotTooltip, tooltipsEnabled, tooltipDelay } = useNodeTooltips(
props.nodeType || ''
)
const tooltipConfig = computed(() => {
const outputTooltipText = computed(() => {
const slotName = props.slotData.name || ''
const tooltipText = getOutputSlotTooltip(props.index)
const fallbackText = tooltipText || `Output: ${slotName}`
@@ -77,7 +87,7 @@ const tooltipConfig = computed(() => {
props.slotData.shape === RenderShape.GRID
? ` ${t('vueNodesSlot.iterative')}`
: ''
return createTooltipConfig(fallbackText + iterativeSuffix)
return fallbackText + iterativeSuffix
})
onErrorCaptured((error) => {

View File

@@ -1,8 +1,4 @@
import type {
TooltipOptions,
TooltipPassThroughMethodOptions
} from 'primevue/tooltip'
import { computed, ref, unref } from 'vue'
import { computed, unref } from 'vue'
import type { MaybeRef } from 'vue'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
@@ -10,77 +6,6 @@ import { st } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
// PrimeVue adds this internal property to elements with tooltips
interface PrimeVueTooltipElement extends Element {
$_ptooltipId?: string
}
/**
* Hide all visible tooltips by dispatching mouseleave events
*
*
* IMPORTANT: this escape is needed for many reason due to primevue's directive tooltip system.
* We cannot use PT to conditionally render the tooltips because the entire PT object only run
* once during the initialization of the directive not every mount/unmount.
* Once the directive is constructed its no longer reactive in the traditional sense.
* We have to use something non destructive like mouseevents to dismiss the tooltip.
*
* TODO: use a better tooltip component like RekaUI for vue nodes specifically.
*/
const tooltipsTemporarilyDisabled = ref(false)
const hideTooltipsGlobally = () => {
// Get all visible tooltip elements
const tooltips = document.querySelectorAll('.p-tooltip')
// Early return if no tooltips are visible
if (tooltips.length === 0) return
tooltips.forEach((tooltipEl) => {
const tooltipId = tooltipEl.id
if (!tooltipId) return
// Find the target element that owns this tooltip
const targetElements = document.querySelectorAll('[data-pd-tooltip="true"]')
for (const targetEl of targetElements) {
if ((targetEl as PrimeVueTooltipElement).$_ptooltipId === tooltipId) {
;(targetEl as HTMLElement).dispatchEvent(
new MouseEvent('mouseleave', { bubbles: true })
)
break
}
}
})
// Disable tooltips temporarily after hiding (for drag operations)
tooltipsTemporarilyDisabled.value = true
}
/**
* Re-enable tooltips after pointer interaction ends
*/
const handlePointerUp = () => {
tooltipsTemporarilyDisabled.value = false
}
// Global tooltip hiding system
const globalTooltipState = { listenersSetup: false }
function setupGlobalTooltipHiding() {
if (globalTooltipState.listenersSetup) return
document.addEventListener('pointerdown', hideTooltipsGlobally)
document.addEventListener('pointerup', handlePointerUp)
window.addEventListener('wheel', hideTooltipsGlobally, {
capture: true, //Need this to bypass the event layer from Litegraph
passive: true
})
globalTooltipState.listenersSetup = true
}
/**
* Composable for managing Vue node tooltips
@@ -90,15 +15,14 @@ export function useNodeTooltips(nodeType: MaybeRef<string>) {
const nodeDefStore = useNodeDefStore()
const settingsStore = useSettingStore()
// Setup global pointerdown listener once
setupGlobalTooltipHiding()
// Check if tooltips are globally enabled
const tooltipsEnabled = computed(() =>
settingsStore.get('Comfy.EnableTooltips')
)
// Get node definition for tooltip data
const tooltipDelay = computed(
() => settingsStore.get('LiteGraph.Node.TooltipDelay') as number
)
const nodeDef = computed(() => nodeDefStore.nodeDefsByName[unref(nodeType)])
/**
@@ -114,7 +38,7 @@ export function useNodeTooltips(nodeType: MaybeRef<string>) {
/**
* Get tooltip text for input slots
*/
const getInputSlotTooltip = (slotName: string) => {
function getInputSlotTooltip(slotName: string) {
if (!tooltipsEnabled.value || !nodeDef.value) return ''
const key = `nodeDefs.${normalizeI18nKey(unref(nodeType))}.inputs.${normalizeI18nKey(slotName)}.tooltip`
@@ -125,7 +49,7 @@ export function useNodeTooltips(nodeType: MaybeRef<string>) {
/**
* Get tooltip text for output slots
*/
const getOutputSlotTooltip = (slotIndex: number) => {
function getOutputSlotTooltip(slotIndex: number) {
if (!tooltipsEnabled.value || !nodeDef.value) return ''
const key = `nodeDefs.${normalizeI18nKey(unref(nodeType))}.outputs.${slotIndex}.tooltip`
@@ -136,7 +60,7 @@ export function useNodeTooltips(nodeType: MaybeRef<string>) {
/**
* Get tooltip text for widgets
*/
const getWidgetTooltip = (widget: SafeWidgetData) => {
function getWidgetTooltip(widget: SafeWidgetData) {
if (!tooltipsEnabled.value || !nodeDef.value) return ''
// First try widget-specific tooltip
@@ -149,46 +73,12 @@ export function useNodeTooltips(nodeType: MaybeRef<string>) {
return st(key, inputTooltip)
}
/**
* Create tooltip configuration object for v-tooltip directive
* Components wrap this in computed() for reactivity
*/
const createTooltipConfig = (text: string): TooltipOptions => {
const tooltipDelay = settingsStore.get('LiteGraph.Node.TooltipDelay')
const tooltipText = text || ''
return {
value: tooltipText,
showDelay: tooltipDelay as number,
hideDelay: 0, // Immediate hiding
disabled:
!tooltipsEnabled.value ||
!tooltipText ||
tooltipsTemporarilyDisabled.value, // this reactive value works but only on next mount,
// so if the tooltip is already visible changing this will not hide it
pt: {
text: {
class:
'border-node-component-tooltip-border bg-node-component-tooltip-surface border rounded-md px-4 py-2 text-node-component-tooltip text-sm font-normal leading-tight max-w-75 shadow-none'
},
arrow: ({ context }: TooltipPassThroughMethodOptions) => ({
class: cn(
context.top && 'border-t-node-component-tooltip-border',
context.bottom && 'border-b-node-component-tooltip-border',
context.left && 'border-l-node-component-tooltip-border',
context.right && 'border-r-node-component-tooltip-border'
)
})
}
}
}
return {
tooltipsEnabled,
tooltipDelay,
getNodeDescription,
getInputSlotTooltip,
getOutputSlotTooltip,
getWidgetTooltip,
createTooltipConfig
getWidgetTooltip
}
}