Skip to content

Commit 3b4c0b5

Browse files
[feat] Add node title buttons with icon-only rendering (#1186)
1 parent a568c06 commit 3b4c0b5

15 files changed

+1323
-16
lines changed

src/LGraphBadge.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export class LGraphBadge {
5757
}
5858

5959
get visible() {
60-
return this.text.length > 0 || !!this.icon
60+
return (this.text?.length ?? 0) > 0 || !!this.icon
6161
}
6262

6363
getWidth(ctx: CanvasRenderingContext2D) {

src/LGraphButton.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { Rectangle } from "./infrastructure/Rectangle"
2+
import { LGraphBadge, type LGraphBadgeOptions } from "./LGraphBadge"
3+
4+
export interface LGraphButtonOptions extends LGraphBadgeOptions {
5+
name?: string // To identify the button
6+
}
7+
8+
export class LGraphButton extends LGraphBadge {
9+
name?: string
10+
_last_area: Rectangle = new Rectangle()
11+
12+
constructor(options: LGraphButtonOptions) {
13+
super(options)
14+
this.name = options.name
15+
}
16+
17+
override getWidth(ctx: CanvasRenderingContext2D): number {
18+
if (!this.visible) return 0
19+
20+
const { font } = ctx
21+
ctx.font = `${this.fontSize}px 'PrimeIcons'`
22+
23+
// For icon buttons, just measure the text width without padding
24+
const textWidth = this.text ? ctx.measureText(this.text).width : 0
25+
26+
ctx.font = font
27+
return textWidth
28+
}
29+
30+
/**
31+
* @internal
32+
*
33+
* Draws the button and updates its last rendered area for hit detection.
34+
* @param ctx The canvas rendering context.
35+
* @param x The x-coordinate to draw the button at.
36+
* @param y The y-coordinate to draw the button at.
37+
*/
38+
override draw(ctx: CanvasRenderingContext2D, x: number, y: number): void {
39+
if (!this.visible) {
40+
return
41+
}
42+
43+
const width = this.getWidth(ctx)
44+
45+
// Update the hit area
46+
this._last_area[0] = x + this.xOffset
47+
this._last_area[1] = y + this.yOffset
48+
this._last_area[2] = width
49+
this._last_area[3] = this.height
50+
51+
// Custom drawing for buttons - no background, just icon/text
52+
const adjustedX = x + this.xOffset
53+
const adjustedY = y + this.yOffset
54+
55+
const { font, fillStyle, textBaseline, textAlign } = ctx
56+
57+
// Use the same color as the title text (usually white)
58+
const titleTextColor = ctx.fillStyle || "white"
59+
60+
// Draw as icon-only without background
61+
ctx.font = `${this.fontSize}px 'PrimeIcons'`
62+
ctx.fillStyle = titleTextColor
63+
ctx.textBaseline = "middle"
64+
ctx.textAlign = "center"
65+
66+
const centerX = adjustedX + width / 2
67+
const centerY = adjustedY + this.height / 2
68+
69+
if (this.text) {
70+
ctx.fillText(this.text, centerX, centerY)
71+
}
72+
73+
// Restore context
74+
ctx.font = font
75+
ctx.fillStyle = fillStyle
76+
ctx.textBaseline = textBaseline
77+
ctx.textAlign = textAlign
78+
}
79+
80+
/**
81+
* Checks if a point is inside the button's last rendered area.
82+
* @param x The x-coordinate of the point.
83+
* @param y The y-coordinate of the point.
84+
* @returns `true` if the point is inside the button, otherwise `false`.
85+
*/
86+
isPointInside(x: number, y: number): boolean {
87+
return this._last_area.containsPoint([x, y])
88+
}
89+
}

src/LGraphCanvas.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4679,6 +4679,28 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
46794679
!!node.selected,
46804680
)
46814681

4682+
// Render title buttons (if not collapsed)
4683+
if (node.title_buttons && !node.flags.collapsed) {
4684+
const title_height = LiteGraph.NODE_TITLE_HEIGHT
4685+
let current_x = size[0] // Start flush with right edge
4686+
4687+
for (let i = 0; i < node.title_buttons.length; i++) {
4688+
const button = node.title_buttons[i]
4689+
if (!button.visible) {
4690+
continue
4691+
}
4692+
4693+
const button_width = button.getWidth(ctx)
4694+
current_x -= button_width
4695+
4696+
// Center button vertically in title bar
4697+
const button_y = -title_height + (title_height - button.height) / 2
4698+
4699+
button.draw(ctx, current_x, button_y)
4700+
current_x -= 2
4701+
}
4702+
}
4703+
46824704
if (!low_quality) {
46834705
node.drawBadges(ctx)
46844706
}

src/LGraphNode.ts

Lines changed: 73 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { getNodeInputOnPos, getNodeOutputOnPos } from "./canvas/measureSlots"
3636
import { NullGraphError } from "./infrastructure/NullGraphError"
3737
import { Rectangle } from "./infrastructure/Rectangle"
3838
import { BadgePosition, LGraphBadge } from "./LGraphBadge"
39+
import { LGraphButton, type LGraphButtonOptions } from "./LGraphButton"
3940
import { LGraphCanvas } from "./LGraphCanvas"
4041
import { type LGraphNodeConstructor, LiteGraph, type Subgraph, type SubgraphNode } from "./litegraph"
4142
import { LLink } from "./LLink"
@@ -52,6 +53,7 @@ import {
5253
import { findFreeSlotOfType } from "./utils/collections"
5354
import { warnDeprecated } from "./utils/feedback"
5455
import { distributeSpace } from "./utils/spaceDistribution"
56+
import { truncateText } from "./utils/textUtils"
5557
import { toClass } from "./utils/type"
5658
import { BaseWidget } from "./widgets/BaseWidget"
5759
import { toConcreteWidget, type WidgetTypeMap } from "./widgets/widgetMap"
@@ -326,6 +328,7 @@ export class LGraphNode implements NodeLike, Positionable, IPinnable, IColorable
326328
lostFocusAt?: number
327329
gotFocusAt?: number
328330
badges: (LGraphBadge | (() => LGraphBadge))[] = []
331+
title_buttons: LGraphButton[] = []
329332
badgePosition: BadgePosition = BadgePosition.TopLeft
330333
onOutputRemoved?(this: LGraphNode, slot: number): void
331334
onInputRemoved?(this: LGraphNode, slot: number, input: INodeInputSlot): void
@@ -687,6 +690,26 @@ export class LGraphNode implements NodeLike, Positionable, IPinnable, IColorable
687690
error: this.#getErrorStrokeStyle,
688691
selected: this.#getSelectedStrokeStyle,
689692
}
693+
694+
// Assign onMouseDown implementation
695+
this.onMouseDown = (e: CanvasPointerEvent, pos: Point, canvas: LGraphCanvas): boolean => {
696+
// Check for title button clicks (only if not collapsed)
697+
if (this.title_buttons?.length && !this.flags.collapsed) {
698+
// pos contains the offset from the node's position, so we need to use node-relative coordinates
699+
const nodeRelativeX = pos[0]
700+
const nodeRelativeY = pos[1]
701+
702+
for (let i = 0; i < this.title_buttons.length; i++) {
703+
const button = this.title_buttons[i]
704+
if (button.visible && button.isPointInside(nodeRelativeX, nodeRelativeY)) {
705+
this.onTitleButtonClick(button, canvas)
706+
return true // Prevent default behavior
707+
}
708+
}
709+
}
710+
711+
return false // Allow default behavior
712+
}
690713
}
691714

692715
/** Internal callback for subgraph nodes. Do not implement externally. */
@@ -1794,6 +1817,21 @@ export class LGraphNode implements NodeLike, Positionable, IPinnable, IColorable
17941817
return widget
17951818
}
17961819

1820+
addTitleButton(options: LGraphButtonOptions): LGraphButton {
1821+
this.title_buttons ||= []
1822+
const button = new LGraphButton(options)
1823+
this.title_buttons.push(button)
1824+
return button
1825+
}
1826+
1827+
onTitleButtonClick(button: LGraphButton, canvas: LGraphCanvas): void {
1828+
// Dispatch event for button click
1829+
canvas.dispatch("litegraph:node-title-button-clicked", {
1830+
node: this,
1831+
button: button,
1832+
})
1833+
}
1834+
17971835
removeWidgetByName(name: string): void {
17981836
const widget = this.widgets?.find(x => x.name === name)
17991837
if (widget) this.removeWidget(widget)
@@ -3372,23 +3410,43 @@ export class LGraphNode implements NodeLike, Positionable, IPinnable, IColorable
33723410
} else {
33733411
ctx.fillStyle = this.constructor.title_text_color || default_title_color
33743412
}
3413+
3414+
// Calculate available width for title
3415+
let availableWidth = size[0] - title_height * 2 // Basic margins
3416+
3417+
// Subtract space for title buttons
3418+
if (this.title_buttons?.length > 0) {
3419+
let buttonsWidth = 0
3420+
const savedFont = ctx.font // Save current font
3421+
for (const button of this.title_buttons) {
3422+
if (button.visible) {
3423+
buttonsWidth += button.getWidth(ctx) + 2 // button width + gap
3424+
}
3425+
}
3426+
ctx.font = savedFont // Restore font after button measurements
3427+
if (buttonsWidth > 0) {
3428+
buttonsWidth += 10 // Extra margin before buttons
3429+
availableWidth -= buttonsWidth
3430+
}
3431+
}
3432+
3433+
// Truncate title if needed
3434+
let displayTitle = title
3435+
33753436
if (this.collapsed) {
3376-
ctx.textAlign = "left"
3377-
ctx.fillText(
3378-
// avoid urls too long
3379-
title.substr(0, 20),
3380-
title_height,
3381-
LiteGraph.NODE_TITLE_TEXT_Y - title_height,
3382-
)
3383-
ctx.textAlign = "left"
3384-
} else {
3385-
ctx.textAlign = "left"
3386-
ctx.fillText(
3387-
title,
3388-
title_height,
3389-
LiteGraph.NODE_TITLE_TEXT_Y - title_height,
3390-
)
3437+
// For collapsed nodes, limit to 20 chars as before
3438+
displayTitle = title.substr(0, 20)
3439+
} else if (availableWidth > 0) {
3440+
// For regular nodes, truncate based on available width
3441+
displayTitle = truncateText(ctx, title, availableWidth)
33913442
}
3443+
3444+
ctx.textAlign = "left"
3445+
ctx.fillText(
3446+
displayTitle,
3447+
title_height,
3448+
LiteGraph.NODE_TITLE_TEXT_Y - title_height,
3449+
)
33923450
}
33933451
}
33943452

src/infrastructure/LGraphCanvasEventMap.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ConnectingLink } from "@/interfaces"
22
import type { LGraph } from "@/LGraph"
3+
import type { LGraphButton } from "@/LGraphButton"
34
import type { LGraphGroup } from "@/LGraphGroup"
45
import type { LGraphNode } from "@/LGraphNode"
56
import type { Subgraph } from "@/subgraph/Subgraph"
@@ -35,4 +36,10 @@ export interface LGraphCanvasEventMap {
3536
originalEvent?: CanvasPointerEvent
3637
node: LGraphNode
3738
}
39+
40+
/** A title button on a node was clicked. */
41+
"litegraph:node-title-button-clicked": {
42+
node: LGraphNode
43+
button: LGraphButton
44+
}
3845
}

src/subgraph/SubgraphNode.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import type { IBaseWidget } from "@/types/widgets"
77
import type { UUID } from "@/utils/uuid"
88

99
import { RecursionError } from "@/infrastructure/RecursionError"
10+
import { LGraphButton } from "@/LGraphButton"
11+
import { LGraphCanvas } from "@/LGraphCanvas"
1012
import { LGraphNode } from "@/LGraphNode"
1113
import { type INodeInputSlot, type ISlotType, type NodeId } from "@/litegraph"
1214
import { LLink, type ResolvedConnection } from "@/LLink"
@@ -99,6 +101,22 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
99101

100102
this.type = subgraph.id
101103
this.configure(instanceData)
104+
105+
this.addTitleButton({
106+
name: "enter_subgraph",
107+
text: "\uE93B", // Unicode for pi-window-maximize
108+
yOffset: 0, // No vertical offset needed, button is centered
109+
xOffset: -10,
110+
fontSize: 16,
111+
})
112+
}
113+
114+
override onTitleButtonClick(button: LGraphButton, canvas: LGraphCanvas): void {
115+
if (button.name === "enter_subgraph") {
116+
canvas.openSubgraph(this.subgraph)
117+
} else {
118+
super.onTitleButtonClick(button, canvas)
119+
}
102120
}
103121

104122
#addSubgraphInputListeners(subgraphInput: SubgraphInput, input: INodeInputSlot & Partial<ISubgraphInput>) {

src/utils/textUtils.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* Truncates text to fit within a given width using binary search for optimal performance.
3+
* @param ctx The canvas rendering context used for text measurement
4+
* @param text The text to truncate
5+
* @param maxWidth The maximum width the text should occupy
6+
* @param ellipsis The ellipsis string to append (default: "...")
7+
* @returns The truncated text with ellipsis if needed
8+
*/
9+
export function truncateText(
10+
ctx: CanvasRenderingContext2D,
11+
text: string,
12+
maxWidth: number,
13+
ellipsis: string = "...",
14+
): string {
15+
const textWidth = ctx.measureText(text).width
16+
17+
if (textWidth <= maxWidth || maxWidth <= 0) {
18+
return text
19+
}
20+
21+
const ellipsisWidth = ctx.measureText(ellipsis).width
22+
const availableWidth = maxWidth - ellipsisWidth
23+
24+
if (availableWidth <= 0) {
25+
return ellipsis
26+
}
27+
28+
// Binary search for the right length
29+
let low = 0
30+
let high = text.length
31+
let bestFit = 0
32+
33+
while (low <= high) {
34+
const mid = Math.floor((low + high) / 2)
35+
const testText = text.substring(0, mid)
36+
const testWidth = ctx.measureText(testText).width
37+
38+
if (testWidth <= availableWidth) {
39+
bestFit = mid
40+
low = mid + 1
41+
} else {
42+
high = mid - 1
43+
}
44+
}
45+
46+
return text.substring(0, bestFit) + ellipsis
47+
}

0 commit comments

Comments
 (0)