Skip to content

Commit 7a980f4

Browse files
Add support for node/input/output tooltips (#287)
* Add support for node/input/output tooltips * pr feedback * Remove
1 parent c48f68e commit 7a980f4

File tree

10 files changed

+227
-10
lines changed

10 files changed

+227
-10
lines changed

src/components/graph/GraphCanvas.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@
88
<canvas ref="canvasRef" id="graph-canvas" tabindex="1" />
99
</teleport>
1010
<NodeSearchboxPopover v-if="nodeSearchEnabled" />
11+
<NodeTooltip />
1112
</template>
1213

1314
<script setup lang="ts">
1415
import SideToolBar from '@/components/sidebar/SideToolBar.vue'
1516
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
1617
import NodeSearchboxPopover from '@/components/NodeSearchBoxPopover.vue'
18+
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
1719
import { ref, computed, onUnmounted, watch, onMounted } from 'vue'
1820
import { app as comfyApp } from '@/scripts/app'
1921
import { useSettingStore } from '@/stores/settingStore'
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
<template>
2+
<div
3+
v-if="tooltipText"
4+
ref="tooltipRef"
5+
class="node-tooltip"
6+
:style="{ left, top }"
7+
>
8+
{{ tooltipText }}
9+
</div>
10+
</template>
11+
12+
<script setup lang="ts">
13+
import { nextTick, ref, onBeforeUnmount, watch } from 'vue'
14+
import { LiteGraph } from '@comfyorg/litegraph'
15+
import { app as comfyApp } from '@/scripts/app'
16+
import { useNodeDefStore } from '@/stores/nodeDefStore'
17+
import { useSettingStore } from '@/stores/settingStore'
18+
19+
let idleTimeout: number
20+
const nodeDefStore = useNodeDefStore()
21+
const settingStore = useSettingStore()
22+
const tooltipRef = ref<HTMLDivElement>()
23+
const tooltipText = ref('')
24+
const left = ref<string>()
25+
const top = ref<string>()
26+
27+
const getHoveredWidget = () => {
28+
const node = comfyApp.canvas.node_over
29+
if (!node.widgets) return
30+
31+
const graphPos = comfyApp.canvas.graph_mouse
32+
const x = graphPos[0] - node.pos[0]
33+
const y = graphPos[1] - node.pos[1]
34+
35+
for (const w of node.widgets) {
36+
let widgetWidth: number, widgetHeight: number
37+
if (w.computeSize) {
38+
;[widgetWidth, widgetHeight] = w.computeSize(node.size[0])
39+
} else {
40+
widgetWidth = (w as { width?: number }).width || node.size[0]
41+
widgetHeight = LiteGraph.NODE_WIDGET_HEIGHT
42+
}
43+
44+
if (
45+
w.last_y !== undefined &&
46+
x >= 6 &&
47+
x <= widgetWidth - 12 &&
48+
y >= w.last_y &&
49+
y <= w.last_y + widgetHeight
50+
) {
51+
return w
52+
}
53+
}
54+
}
55+
56+
const hideTooltip = () => (tooltipText.value = null)
57+
58+
const showTooltip = async (tooltip: string | null | undefined) => {
59+
if (!tooltip) return
60+
61+
left.value = comfyApp.canvas.mouse[0] + 'px'
62+
top.value = comfyApp.canvas.mouse[1] + 'px'
63+
tooltipText.value = tooltip
64+
65+
await nextTick()
66+
67+
const rect = tooltipRef.value.getBoundingClientRect()
68+
if (rect.right > window.innerWidth) {
69+
left.value = comfyApp.canvas.mouse[0] - rect.width + 'px'
70+
}
71+
72+
if (rect.top < 0) {
73+
top.value = comfyApp.canvas.mouse[1] + rect.height + 'px'
74+
}
75+
}
76+
77+
const onIdle = () => {
78+
const { canvas } = comfyApp
79+
const node = canvas.node_over
80+
if (!node) return
81+
82+
const ctor = node.constructor as { title_mode?: 0 | 1 | 2 | 3 }
83+
const nodeDef = nodeDefStore.nodeDefsByName[node.type]
84+
85+
if (
86+
ctor.title_mode !== LiteGraph.NO_TITLE &&
87+
canvas.graph_mouse[1] < node.pos[1] // If we are over a node, but not within the node then we are on its title
88+
) {
89+
return showTooltip(nodeDef.description)
90+
}
91+
92+
if (node.flags?.collapsed) return
93+
94+
const inputSlot = canvas.isOverNodeInput(
95+
node,
96+
canvas.graph_mouse[0],
97+
canvas.graph_mouse[1],
98+
[0, 0]
99+
)
100+
if (inputSlot !== -1) {
101+
const inputName = node.inputs[inputSlot].name
102+
return showTooltip(nodeDef.input.getInput(inputName)?.tooltip)
103+
}
104+
105+
const outputSlot = canvas.isOverNodeOutput(
106+
node,
107+
canvas.graph_mouse[0],
108+
canvas.graph_mouse[1],
109+
[0, 0]
110+
)
111+
if (outputSlot !== -1) {
112+
return showTooltip(nodeDef.output.all?.[outputSlot].tooltip)
113+
}
114+
115+
const widget = getHoveredWidget()
116+
// Dont show for DOM widgets, these use native browser tooltips as we dont get proper mouse events on these
117+
if (widget && !widget.element) {
118+
return showTooltip(
119+
widget.tooltip ?? nodeDef.input.getInput(widget.name)?.tooltip
120+
)
121+
}
122+
}
123+
124+
const onMouseMove = (e: MouseEvent) => {
125+
hideTooltip()
126+
clearTimeout(idleTimeout)
127+
128+
if ((e.target as Node).nodeName !== 'CANVAS') return
129+
idleTimeout = window.setTimeout(onIdle, 500)
130+
}
131+
132+
watch(
133+
() => settingStore.get<boolean>('Comfy.EnableTooltips'),
134+
(enabled) => {
135+
if (enabled) {
136+
window.addEventListener('mousemove', onMouseMove)
137+
window.addEventListener('click', hideTooltip)
138+
} else {
139+
window.removeEventListener('mousemove', onMouseMove)
140+
window.removeEventListener('click', hideTooltip)
141+
}
142+
},
143+
{ immediate: true }
144+
)
145+
146+
onBeforeUnmount(() => {
147+
window.removeEventListener('mousemove', onMouseMove)
148+
window.removeEventListener('click', hideTooltip)
149+
})
150+
</script>
151+
152+
<style lang="css" scoped>
153+
.node-tooltip {
154+
background: var(--comfy-input-bg);
155+
border-radius: 5px;
156+
box-shadow: 0 0 5px rgba(0, 0, 0, 0.4);
157+
color: var(--input-text);
158+
font-family: sans-serif;
159+
left: 0;
160+
max-width: 30vw;
161+
padding: 4px 8px;
162+
position: absolute;
163+
top: 0;
164+
transform: translate(5px, calc(-100% - 5px));
165+
white-space: pre-wrap;
166+
z-index: 99999;
167+
}
168+
</style>

src/extensions/core/widgetInputs.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -571,7 +571,8 @@ export function mergeIfValid(
571571
k !== 'forceInput' &&
572572
k !== 'defaultInput' &&
573573
k !== 'control_after_generate' &&
574-
k !== 'multiline'
574+
k !== 'multiline' &&
575+
k !== 'tooltip'
575576
) {
576577
let v1 = config1[1][k]
577578
let v2 = config2[1]?.[k]

src/scripts/domWidget.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -226,11 +226,8 @@ LGraphCanvas.prototype.computeVisibleNodes = function (): LGraphNode[] {
226226
if (elementWidgets.has(node)) {
227227
const hidden = visibleNodes.indexOf(node) === -1
228228
for (const w of node.widgets) {
229-
// @ts-expect-error
230229
if (w.element) {
231-
// @ts-expect-error
232230
w.element.hidden = hidden
233-
// @ts-expect-error
234231
w.element.style.display = hidden ? 'none' : undefined
235232
if (hidden) {
236233
w.options.onHide?.(w)
@@ -282,6 +279,13 @@ LGraphNode.prototype.addDOMWidget = function (
282279
document.addEventListener('mousedown', mouseDownHandler)
283280
}
284281

282+
const { nodeData } = this.constructor
283+
const tooltip = (nodeData?.input.required?.[name] ??
284+
nodeData?.input.optional?.[name])?.[1]?.tooltip
285+
if (tooltip && !element.title) {
286+
element.title = tooltip
287+
}
288+
285289
const widget: DOMWidget = {
286290
type,
287291
name,

src/scripts/ui.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,13 @@ export class ComfyUI {
428428
defaultValue: 'default'
429429
})
430430

431+
this.settings.addSetting({
432+
id: 'Comfy.EnableTooltips',
433+
name: 'Enable Tooltips',
434+
type: 'boolean',
435+
defaultValue: true
436+
})
437+
431438
const fileInput = $el('input', {
432439
id: 'comfy-file-input',
433440
type: 'file',
@@ -437,7 +444,7 @@ export class ComfyUI {
437444
onchange: () => {
438445
app.handleFile(fileInput.files[0])
439446
}
440-
}) as HTMLInputElement
447+
})
441448

442449
this.loadFile = () => fileInput.click()
443450

src/scripts/widgets.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ export function addValueControlWidgets(
113113
serialize: false // Don't include this in prompt.
114114
}
115115
)
116+
valueControl.tooltip =
117+
'Allows the linked widget to be changed automatically, for example randomizing the noise seed.'
116118
valueControl[IS_CONTROL_WIDGET] = true
117119
updateControlWidgetLabel(valueControl)
118120
widgets.push(valueControl)
@@ -133,6 +135,8 @@ export function addValueControlWidgets(
133135
}
134136
)
135137
updateControlWidgetLabel(comboFilter)
138+
comboFilter.tooltip =
139+
"Allows for filtering the list of values when changing the value via the control generate mode. Allows for RegEx matches in the format /abc/ to only filter to values containing 'abc'."
136140

137141
widgets.push(comboFilter)
138142
}

src/stores/nodeDefStore.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { TreeNode } from 'primevue/treenode'
88
export class BaseInputSpec<T = any> {
99
name: string
1010
type: string
11-
11+
tooltip?: string
1212
default?: T
1313

1414
@Type(() => Boolean)
@@ -131,6 +131,10 @@ export class ComfyInputsSpec {
131131
get all() {
132132
return [...Object.values(this.required), ...Object.values(this.optional)]
133133
}
134+
135+
getInput(name: string): BaseInputSpec | undefined {
136+
return this.required[name] ?? this.optional[name]
137+
}
134138
}
135139

136140
export class ComfyOutputSpec {
@@ -140,7 +144,8 @@ export class ComfyOutputSpec {
140144
public name: string,
141145
public type: string,
142146
public is_list: boolean,
143-
public comboOptions?: any[]
147+
public comboOptions?: any[],
148+
public tooltip?: string
144149
) {}
145150
}
146151

@@ -166,7 +171,7 @@ export class ComfyNodeDefImpl {
166171
output: ComfyOutputsSpec
167172

168173
private static transformOutputSpec(obj: any): ComfyOutputsSpec {
169-
const { output, output_is_list, output_name } = obj
174+
const { output, output_is_list, output_name, output_tooltips } = obj
170175
const result = output.map((type: string | any[], index: number) => {
171176
const typeString = Array.isArray(type) ? 'COMBO' : type
172177

@@ -175,7 +180,8 @@ export class ComfyNodeDefImpl {
175180
output_name[index],
176181
typeString,
177182
output_is_list[index],
178-
Array.isArray(type) ? type : undefined
183+
Array.isArray(type) ? type : undefined,
184+
output_tooltips?.[index]
179185
)
180186
})
181187
return new ComfyOutputsSpec(result)

src/stores/settingStore.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export const useSettingStore = defineStore('setting', {
3232
app.ui.settings.setSettingValue(key, value)
3333
},
3434

35-
get(key: string) {
35+
get<T = any>(key: string): T {
3636
return (
3737
this.settingValues[key] ?? app.ui.settings.getSettingDefaultValue(key)
3838
)

src/types/apiTypes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ const zComfyNodeDef = z.object({
272272
output: zComfyOutputTypesSpec,
273273
output_is_list: z.array(z.boolean()),
274274
output_name: z.array(z.string()),
275+
output_tooltips: z.array(z.string()).optional(),
275276
name: z.string(),
276277
display_name: z.string(),
277278
description: z.string(),

src/types/litegraph-augmentation.d.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ declare module '@comfyorg/litegraph' {
2121
* Allows for additional cleanup when removing a widget when converting to input.
2222
*/
2323
onRemove?(): void
24+
25+
/**
26+
* DOM element used for the widget
27+
*/
28+
element?: HTMLElement
29+
30+
tooltip?: string
2431
}
2532

2633
interface INodeOutputSlot {
@@ -43,4 +50,21 @@ declare module '@comfyorg/litegraph' {
4350
interface LGraphNode {
4451
widgets_values?: unknown[]
4552
}
53+
54+
interface LGraphCanvas {
55+
/** This is in the litegraph types but has incorrect return type */
56+
isOverNodeInput(
57+
node: LGraphNode,
58+
canvasX: number,
59+
canvasY: number,
60+
slotPos: Vector2
61+
): number
62+
63+
isOverNodeOutput(
64+
node: LGraphNode,
65+
canvasX: number,
66+
canvasY: number,
67+
slotPos: Vector2
68+
): number
69+
}
4670
}

0 commit comments

Comments
 (0)