[feat] Add node title buttons with icon-only rendering (#1186)

This commit is contained in:
Christian Byrne
2025-08-02 17:35:38 -07:00
committed by GitHub
parent a568c0651f
commit 3b4c0b5d2b
15 changed files with 1324 additions and 17 deletions

View File

@@ -57,7 +57,7 @@ export class LGraphBadge {
}
get visible() {
return this.text.length > 0 || !!this.icon
return (this.text?.length ?? 0) > 0 || !!this.icon
}
getWidth(ctx: CanvasRenderingContext2D) {

89
src/LGraphButton.ts Normal file
View File

@@ -0,0 +1,89 @@
import { Rectangle } from "./infrastructure/Rectangle"
import { LGraphBadge, type LGraphBadgeOptions } from "./LGraphBadge"
export interface LGraphButtonOptions extends LGraphBadgeOptions {
name?: string // To identify the button
}
export class LGraphButton extends LGraphBadge {
name?: string
_last_area: Rectangle = new Rectangle()
constructor(options: LGraphButtonOptions) {
super(options)
this.name = options.name
}
override getWidth(ctx: CanvasRenderingContext2D): number {
if (!this.visible) return 0
const { font } = ctx
ctx.font = `${this.fontSize}px 'PrimeIcons'`
// For icon buttons, just measure the text width without padding
const textWidth = this.text ? ctx.measureText(this.text).width : 0
ctx.font = font
return textWidth
}
/**
* @internal
*
* Draws the button and updates its last rendered area for hit detection.
* @param ctx The canvas rendering context.
* @param x The x-coordinate to draw the button at.
* @param y The y-coordinate to draw the button at.
*/
override draw(ctx: CanvasRenderingContext2D, x: number, y: number): void {
if (!this.visible) {
return
}
const width = this.getWidth(ctx)
// Update the hit area
this._last_area[0] = x + this.xOffset
this._last_area[1] = y + this.yOffset
this._last_area[2] = width
this._last_area[3] = this.height
// Custom drawing for buttons - no background, just icon/text
const adjustedX = x + this.xOffset
const adjustedY = y + this.yOffset
const { font, fillStyle, textBaseline, textAlign } = ctx
// Use the same color as the title text (usually white)
const titleTextColor = ctx.fillStyle || "white"
// Draw as icon-only without background
ctx.font = `${this.fontSize}px 'PrimeIcons'`
ctx.fillStyle = titleTextColor
ctx.textBaseline = "middle"
ctx.textAlign = "center"
const centerX = adjustedX + width / 2
const centerY = adjustedY + this.height / 2
if (this.text) {
ctx.fillText(this.text, centerX, centerY)
}
// Restore context
ctx.font = font
ctx.fillStyle = fillStyle
ctx.textBaseline = textBaseline
ctx.textAlign = textAlign
}
/**
* Checks if a point is inside the button's last rendered area.
* @param x The x-coordinate of the point.
* @param y The y-coordinate of the point.
* @returns `true` if the point is inside the button, otherwise `false`.
*/
isPointInside(x: number, y: number): boolean {
return this._last_area.containsPoint([x, y])
}
}

View File

@@ -4679,6 +4679,28 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
!!node.selected,
)
// Render title buttons (if not collapsed)
if (node.title_buttons && !node.flags.collapsed) {
const title_height = LiteGraph.NODE_TITLE_HEIGHT
let current_x = size[0] // Start flush with right edge
for (let i = 0; i < node.title_buttons.length; i++) {
const button = node.title_buttons[i]
if (!button.visible) {
continue
}
const button_width = button.getWidth(ctx)
current_x -= button_width
// Center button vertically in title bar
const button_y = -title_height + (title_height - button.height) / 2
button.draw(ctx, current_x, button_y)
current_x -= 2
}
}
if (!low_quality) {
node.drawBadges(ctx)
}

View File

@@ -36,6 +36,7 @@ import { getNodeInputOnPos, getNodeOutputOnPos } from "./canvas/measureSlots"
import { NullGraphError } from "./infrastructure/NullGraphError"
import { Rectangle } from "./infrastructure/Rectangle"
import { BadgePosition, LGraphBadge } from "./LGraphBadge"
import { LGraphButton, type LGraphButtonOptions } from "./LGraphButton"
import { LGraphCanvas } from "./LGraphCanvas"
import { type LGraphNodeConstructor, LiteGraph, type Subgraph, type SubgraphNode } from "./litegraph"
import { LLink } from "./LLink"
@@ -52,6 +53,7 @@ import {
import { findFreeSlotOfType } from "./utils/collections"
import { warnDeprecated } from "./utils/feedback"
import { distributeSpace } from "./utils/spaceDistribution"
import { truncateText } from "./utils/textUtils"
import { toClass } from "./utils/type"
import { BaseWidget } from "./widgets/BaseWidget"
import { toConcreteWidget, type WidgetTypeMap } from "./widgets/widgetMap"
@@ -326,6 +328,7 @@ export class LGraphNode implements NodeLike, Positionable, IPinnable, IColorable
lostFocusAt?: number
gotFocusAt?: number
badges: (LGraphBadge | (() => LGraphBadge))[] = []
title_buttons: LGraphButton[] = []
badgePosition: BadgePosition = BadgePosition.TopLeft
onOutputRemoved?(this: LGraphNode, slot: number): void
onInputRemoved?(this: LGraphNode, slot: number, input: INodeInputSlot): void
@@ -687,6 +690,26 @@ export class LGraphNode implements NodeLike, Positionable, IPinnable, IColorable
error: this.#getErrorStrokeStyle,
selected: this.#getSelectedStrokeStyle,
}
// Assign onMouseDown implementation
this.onMouseDown = (e: CanvasPointerEvent, pos: Point, canvas: LGraphCanvas): boolean => {
// Check for title button clicks (only if not collapsed)
if (this.title_buttons?.length && !this.flags.collapsed) {
// pos contains the offset from the node's position, so we need to use node-relative coordinates
const nodeRelativeX = pos[0]
const nodeRelativeY = pos[1]
for (let i = 0; i < this.title_buttons.length; i++) {
const button = this.title_buttons[i]
if (button.visible && button.isPointInside(nodeRelativeX, nodeRelativeY)) {
this.onTitleButtonClick(button, canvas)
return true // Prevent default behavior
}
}
}
return false // Allow default behavior
}
}
/** Internal callback for subgraph nodes. Do not implement externally. */
@@ -1794,6 +1817,21 @@ export class LGraphNode implements NodeLike, Positionable, IPinnable, IColorable
return widget
}
addTitleButton(options: LGraphButtonOptions): LGraphButton {
this.title_buttons ||= []
const button = new LGraphButton(options)
this.title_buttons.push(button)
return button
}
onTitleButtonClick(button: LGraphButton, canvas: LGraphCanvas): void {
// Dispatch event for button click
canvas.dispatch("litegraph:node-title-button-clicked", {
node: this,
button: button,
})
}
removeWidgetByName(name: string): void {
const widget = this.widgets?.find(x => x.name === name)
if (widget) this.removeWidget(widget)
@@ -3372,23 +3410,43 @@ export class LGraphNode implements NodeLike, Positionable, IPinnable, IColorable
} else {
ctx.fillStyle = this.constructor.title_text_color || default_title_color
}
if (this.collapsed) {
ctx.textAlign = "left"
ctx.fillText(
// avoid urls too long
title.substr(0, 20),
title_height,
LiteGraph.NODE_TITLE_TEXT_Y - title_height,
)
ctx.textAlign = "left"
} else {
ctx.textAlign = "left"
ctx.fillText(
title,
title_height,
LiteGraph.NODE_TITLE_TEXT_Y - title_height,
)
// Calculate available width for title
let availableWidth = size[0] - title_height * 2 // Basic margins
// Subtract space for title buttons
if (this.title_buttons?.length > 0) {
let buttonsWidth = 0
const savedFont = ctx.font // Save current font
for (const button of this.title_buttons) {
if (button.visible) {
buttonsWidth += button.getWidth(ctx) + 2 // button width + gap
}
}
ctx.font = savedFont // Restore font after button measurements
if (buttonsWidth > 0) {
buttonsWidth += 10 // Extra margin before buttons
availableWidth -= buttonsWidth
}
}
// Truncate title if needed
let displayTitle = title
if (this.collapsed) {
// For collapsed nodes, limit to 20 chars as before
displayTitle = title.substr(0, 20)
} else if (availableWidth > 0) {
// For regular nodes, truncate based on available width
displayTitle = truncateText(ctx, title, availableWidth)
}
ctx.textAlign = "left"
ctx.fillText(
displayTitle,
title_height,
LiteGraph.NODE_TITLE_TEXT_Y - title_height,
)
}
}

View File

@@ -1,5 +1,6 @@
import type { ConnectingLink } from "@/interfaces"
import type { LGraph } from "@/LGraph"
import type { LGraphButton } from "@/LGraphButton"
import type { LGraphGroup } from "@/LGraphGroup"
import type { LGraphNode } from "@/LGraphNode"
import type { Subgraph } from "@/subgraph/Subgraph"
@@ -35,4 +36,10 @@ export interface LGraphCanvasEventMap {
originalEvent?: CanvasPointerEvent
node: LGraphNode
}
/** A title button on a node was clicked. */
"litegraph:node-title-button-clicked": {
node: LGraphNode
button: LGraphButton
}
}

View File

@@ -7,6 +7,8 @@ import type { IBaseWidget } from "@/types/widgets"
import type { UUID } from "@/utils/uuid"
import { RecursionError } from "@/infrastructure/RecursionError"
import { LGraphButton } from "@/LGraphButton"
import { LGraphCanvas } from "@/LGraphCanvas"
import { LGraphNode } from "@/LGraphNode"
import { type INodeInputSlot, type ISlotType, type NodeId } from "@/litegraph"
import { LLink, type ResolvedConnection } from "@/LLink"
@@ -99,6 +101,22 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
this.type = subgraph.id
this.configure(instanceData)
this.addTitleButton({
name: "enter_subgraph",
text: "\uE93B", // Unicode for pi-window-maximize
yOffset: 0, // No vertical offset needed, button is centered
xOffset: -10,
fontSize: 16,
})
}
override onTitleButtonClick(button: LGraphButton, canvas: LGraphCanvas): void {
if (button.name === "enter_subgraph") {
canvas.openSubgraph(this.subgraph)
} else {
super.onTitleButtonClick(button, canvas)
}
}
#addSubgraphInputListeners(subgraphInput: SubgraphInput, input: INodeInputSlot & Partial<ISubgraphInput>) {

47
src/utils/textUtils.ts Normal file
View File

@@ -0,0 +1,47 @@
/**
* Truncates text to fit within a given width using binary search for optimal performance.
* @param ctx The canvas rendering context used for text measurement
* @param text The text to truncate
* @param maxWidth The maximum width the text should occupy
* @param ellipsis The ellipsis string to append (default: "...")
* @returns The truncated text with ellipsis if needed
*/
export function truncateText(
ctx: CanvasRenderingContext2D,
text: string,
maxWidth: number,
ellipsis: string = "...",
): string {
const textWidth = ctx.measureText(text).width
if (textWidth <= maxWidth || maxWidth <= 0) {
return text
}
const ellipsisWidth = ctx.measureText(ellipsis).width
const availableWidth = maxWidth - ellipsisWidth
if (availableWidth <= 0) {
return ellipsis
}
// Binary search for the right length
let low = 0
let high = text.length
let bestFit = 0
while (low <= high) {
const mid = Math.floor((low + high) / 2)
const testText = text.substring(0, mid)
const testWidth = ctx.measureText(testText).width
if (testWidth <= availableWidth) {
bestFit = mid
low = mid + 1
} else {
high = mid - 1
}
}
return text.substring(0, bestFit) + ellipsis
}

185
test/LGraphButton.test.ts Normal file
View File

@@ -0,0 +1,185 @@
import { describe, expect, it, vi } from "vitest"
import { Rectangle } from "@/infrastructure/Rectangle"
import { LGraphButton } from "@/LGraphButton"
describe("LGraphButton", () => {
describe("Constructor", () => {
it("should create a button with default options", () => {
const button = new LGraphButton({})
expect(button).toBeInstanceOf(LGraphButton)
expect(button.name).toBeUndefined()
expect(button._last_area).toBeInstanceOf(Rectangle)
})
it("should create a button with custom name", () => {
const button = new LGraphButton({ name: "test_button" })
expect(button.name).toBe("test_button")
})
it("should inherit badge properties", () => {
const button = new LGraphButton({
text: "Test",
fgColor: "#FF0000",
bgColor: "#0000FF",
fontSize: 16,
})
expect(button.text).toBe("Test")
expect(button.fgColor).toBe("#FF0000")
expect(button.bgColor).toBe("#0000FF")
expect(button.fontSize).toBe(16)
expect(button.visible).toBe(true) // visible is computed based on text length
})
})
describe("draw", () => {
it("should not draw if not visible", () => {
const button = new LGraphButton({ text: "" }) // Empty text makes it invisible
const ctx = {
measureText: vi.fn().mockReturnValue({ width: 100 }),
} as unknown as CanvasRenderingContext2D
const superDrawSpy = vi.spyOn(Object.getPrototypeOf(Object.getPrototypeOf(button)), "draw")
button.draw(ctx, 50, 100)
expect(superDrawSpy).not.toHaveBeenCalled()
expect(button._last_area.width).toBe(0) // Rectangle default width
})
it("should draw and update last area when visible", () => {
const button = new LGraphButton({
text: "Click",
xOffset: 5,
yOffset: 10,
})
const ctx = {
measureText: vi.fn().mockReturnValue({ width: 60 }),
fillRect: vi.fn(),
fillText: vi.fn(),
beginPath: vi.fn(),
roundRect: vi.fn(),
fill: vi.fn(),
font: "",
fillStyle: "",
globalAlpha: 1,
} as unknown as CanvasRenderingContext2D
const mockGetWidth = vi.fn().mockReturnValue(80)
button.getWidth = mockGetWidth
const x = 100
const y = 50
button.draw(ctx, x, y)
// Check that last area was updated correctly
expect(button._last_area[0]).toBe(x + button.xOffset) // 100 + 5 = 105
expect(button._last_area[1]).toBe(y + button.yOffset) // 50 + 10 = 60
expect(button._last_area[2]).toBe(80)
expect(button._last_area[3]).toBe(button.height)
})
it("should calculate last area without offsets", () => {
const button = new LGraphButton({
text: "Test",
})
const ctx = {
measureText: vi.fn().mockReturnValue({ width: 40 }),
fillRect: vi.fn(),
fillText: vi.fn(),
beginPath: vi.fn(),
roundRect: vi.fn(),
fill: vi.fn(),
font: "",
fillStyle: "",
globalAlpha: 1,
} as unknown as CanvasRenderingContext2D
const mockGetWidth = vi.fn().mockReturnValue(50)
button.getWidth = mockGetWidth
button.draw(ctx, 200, 100)
expect(button._last_area[0]).toBe(200)
expect(button._last_area[1]).toBe(100)
expect(button._last_area[2]).toBe(50)
})
})
describe("isPointInside", () => {
it("should return true when point is inside button area", () => {
const button = new LGraphButton({ text: "Test" })
// Set the last area manually
button._last_area[0] = 100
button._last_area[1] = 50
button._last_area[2] = 80
button._last_area[3] = 20
// Test various points inside
expect(button.isPointInside(100, 50)).toBe(true) // Top-left corner
expect(button.isPointInside(179, 69)).toBe(true) // Bottom-right corner
expect(button.isPointInside(140, 60)).toBe(true) // Center
})
it("should return false when point is outside button area", () => {
const button = new LGraphButton({ text: "Test" })
// Set the last area manually
button._last_area[0] = 100
button._last_area[1] = 50
button._last_area[2] = 80
button._last_area[3] = 20
// Test various points outside
expect(button.isPointInside(99, 50)).toBe(false) // Just left
expect(button.isPointInside(181, 50)).toBe(false) // Just right
expect(button.isPointInside(100, 49)).toBe(false) // Just above
expect(button.isPointInside(100, 71)).toBe(false) // Just below
expect(button.isPointInside(0, 0)).toBe(false) // Far away
})
it("should work with buttons that have not been drawn yet", () => {
const button = new LGraphButton({ text: "Test" })
// _last_area has default values (0, 0, 0, 0)
expect(button.isPointInside(10, 10)).toBe(false)
expect(button.isPointInside(0, 0)).toBe(false)
})
})
describe("Integration with LGraphBadge", () => {
it("should properly inherit and use badge functionality", () => {
const button = new LGraphButton({
text: "→",
fontSize: 20,
color: "#FFFFFF",
backgroundColor: "#333333",
xOffset: -10,
yOffset: 5,
})
const ctx = {
measureText: vi.fn().mockReturnValue({ width: 20 }),
fillRect: vi.fn(),
fillText: vi.fn(),
beginPath: vi.fn(),
roundRect: vi.fn(),
fill: vi.fn(),
font: "",
fillStyle: "",
globalAlpha: 1,
} as unknown as CanvasRenderingContext2D
// Draw the button
button.draw(ctx, 100, 50)
// Verify button draws text without background
expect(ctx.beginPath).not.toHaveBeenCalled() // No background
expect(ctx.roundRect).not.toHaveBeenCalled() // No background
expect(ctx.fill).not.toHaveBeenCalled() // No background
expect(ctx.fillText).toHaveBeenCalledWith("→", expect.any(Number), expect.any(Number)) // Just text
})
})
})

View File

@@ -0,0 +1,274 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { LGraphCanvas } from "@/LGraphCanvas"
import { LGraphNode, LiteGraph } from "@/litegraph"
describe("LGraphCanvas Title Button Rendering", () => {
let canvas: LGraphCanvas
let ctx: CanvasRenderingContext2D
let node: LGraphNode
beforeEach(() => {
// Create a mock canvas element
const canvasElement = document.createElement("canvas")
ctx = {
save: vi.fn(),
restore: vi.fn(),
translate: vi.fn(),
scale: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
measureText: vi.fn().mockReturnValue({ width: 50 }),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fill: vi.fn(),
closePath: vi.fn(),
arc: vi.fn(),
rect: vi.fn(),
clip: vi.fn(),
clearRect: vi.fn(),
setTransform: vi.fn(),
roundRect: vi.fn(),
font: "",
fillStyle: "",
strokeStyle: "",
lineWidth: 1,
globalAlpha: 1,
textAlign: "left" as CanvasTextAlign,
textBaseline: "alphabetic" as CanvasTextBaseline,
} as unknown as CanvasRenderingContext2D
canvasElement.getContext = vi.fn().mockReturnValue(ctx)
canvas = new LGraphCanvas(canvasElement, null, {
skip_render: true,
skip_events: true,
})
node = new LGraphNode("Test Node")
node.pos = [100, 200]
node.size = [200, 100]
// Mock required methods
node.drawTitleBarBackground = vi.fn()
node.drawTitleBarText = vi.fn()
node.drawBadges = vi.fn()
node.drawToggles = vi.fn()
node.drawNodeShape = vi.fn()
node.drawSlots = vi.fn()
node.drawContent = vi.fn()
node.drawWidgets = vi.fn()
node.drawCollapsedSlots = vi.fn()
node.drawTitleBox = vi.fn()
node.drawTitleText = vi.fn()
node.drawProgressBar = vi.fn()
node._setConcreteSlots = vi.fn()
node.arrange = vi.fn()
node.isSelectable = vi.fn().mockReturnValue(true)
})
describe("drawNode title button rendering", () => {
it("should render visible title buttons", () => {
const button1 = node.addTitleButton({
name: "button1",
text: "A",
visible: true,
})
const button2 = node.addTitleButton({
name: "button2",
text: "B",
visible: true,
})
// Mock button methods
const getWidth1 = vi.fn().mockReturnValue(20)
const getWidth2 = vi.fn().mockReturnValue(25)
const draw1 = vi.spyOn(button1, "draw")
const draw2 = vi.spyOn(button2, "draw")
button1.getWidth = getWidth1
button2.getWidth = getWidth2
// Draw the node (this is a simplified version of what drawNode does)
canvas.drawNode(node, ctx)
// Verify both buttons' getWidth was called
expect(getWidth1).toHaveBeenCalledWith(ctx)
expect(getWidth2).toHaveBeenCalledWith(ctx)
// Verify both buttons were drawn
expect(draw1).toHaveBeenCalled()
expect(draw2).toHaveBeenCalled()
// Check draw positions (right-aligned from node width)
// First button (rightmost): 200 - 5 = 195, then subtract width
// Second button: first button position - 5 - button width
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT
const buttonY = -titleHeight + (titleHeight - 20) / 2 // Centered
expect(draw1).toHaveBeenCalledWith(ctx, 180, buttonY) // 200 - 20
expect(draw2).toHaveBeenCalledWith(ctx, 153, buttonY) // 180 - 2 - 25
})
it("should skip invisible title buttons", () => {
const visibleButton = node.addTitleButton({
name: "visible",
text: "V",
visible: true,
})
const invisibleButton = node.addTitleButton({
name: "invisible",
text: "", // Empty text makes it invisible
})
const getWidthVisible = vi.fn().mockReturnValue(30)
const getWidthInvisible = vi.fn().mockReturnValue(30)
const drawVisible = vi.spyOn(visibleButton, "draw")
const drawInvisible = vi.spyOn(invisibleButton, "draw")
visibleButton.getWidth = getWidthVisible
invisibleButton.getWidth = getWidthInvisible
canvas.drawNode(node, ctx)
// Only visible button should be measured and drawn
expect(getWidthVisible).toHaveBeenCalledWith(ctx)
expect(getWidthInvisible).not.toHaveBeenCalled()
expect(drawVisible).toHaveBeenCalled()
expect(drawInvisible).not.toHaveBeenCalled()
})
it("should handle nodes without title buttons", () => {
// Node has no title buttons
expect(node.title_buttons).toHaveLength(0)
// Should draw without errors
expect(() => canvas.drawNode(node, ctx)).not.toThrow()
})
it("should position multiple buttons with correct spacing", () => {
const buttons = []
const drawSpies = []
// Add 3 buttons
for (let i = 0; i < 3; i++) {
const button = node.addTitleButton({
name: `button${i}`,
text: String(i),
visible: true,
})
button.getWidth = vi.fn().mockReturnValue(15) // All same width for simplicity
const spy = vi.spyOn(button, "draw")
buttons.push(button)
drawSpies.push(spy)
}
canvas.drawNode(node, ctx)
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT
// Check positions are correctly spaced (right to left)
// Starting position: 200
const buttonY = -titleHeight + (titleHeight - 20) / 2 // Button height is 20 (default)
expect(drawSpies[0]).toHaveBeenCalledWith(ctx, 185, buttonY) // 200 - 15
expect(drawSpies[1]).toHaveBeenCalledWith(ctx, 168, buttonY) // 185 - 2 - 15
expect(drawSpies[2]).toHaveBeenCalledWith(ctx, 151, buttonY) // 168 - 2 - 15
})
it("should render buttons in low quality mode", () => {
const button = node.addTitleButton({
name: "test",
text: "T",
visible: true,
})
button.getWidth = vi.fn().mockReturnValue(20)
const drawSpy = vi.spyOn(button, "draw")
// Set low quality rendering
canvas.lowQualityRenderingRequired = true
canvas.drawNode(node, ctx)
// Buttons should still be rendered in low quality mode
const buttonY = -LiteGraph.NODE_TITLE_HEIGHT + (LiteGraph.NODE_TITLE_HEIGHT - 20) / 2
expect(drawSpy).toHaveBeenCalledWith(ctx, 180, buttonY)
})
it("should handle buttons with different widths", () => {
const smallButton = node.addTitleButton({
name: "small",
text: "S",
visible: true,
})
const largeButton = node.addTitleButton({
name: "large",
text: "LARGE",
visible: true,
})
smallButton.getWidth = vi.fn().mockReturnValue(15)
largeButton.getWidth = vi.fn().mockReturnValue(50)
const drawSmall = vi.spyOn(smallButton, "draw")
const drawLarge = vi.spyOn(largeButton, "draw")
canvas.drawNode(node, ctx)
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT
// Small button (rightmost): 200 - 15 = 185
const buttonY = -titleHeight + (titleHeight - 20) / 2
expect(drawSmall).toHaveBeenCalledWith(ctx, 185, buttonY)
// Large button: 185 - 2 - 50 = 133
expect(drawLarge).toHaveBeenCalledWith(ctx, 133, buttonY)
})
})
describe("Integration with node properties", () => {
it("should respect node size for button positioning", () => {
node.size = [300, 150] // Wider node
const button = node.addTitleButton({
name: "test",
text: "X",
visible: true,
})
button.getWidth = vi.fn().mockReturnValue(20)
const drawSpy = vi.spyOn(button, "draw")
canvas.drawNode(node, ctx)
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT
// Should use new width: 300 - 20 = 280
const buttonY = -titleHeight + (titleHeight - 20) / 2
expect(drawSpy).toHaveBeenCalledWith(ctx, 280, buttonY)
})
it("should NOT render buttons on collapsed nodes", () => {
node.flags.collapsed = true
const button = node.addTitleButton({
name: "test",
text: "C",
})
button.getWidth = vi.fn().mockReturnValue(20)
const drawSpy = vi.spyOn(button, "draw")
canvas.drawNode(node, ctx)
// Title buttons should NOT be rendered on collapsed nodes
expect(drawSpy).not.toHaveBeenCalled()
expect(button.getWidth).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,276 @@
import { describe, expect, it, vi } from "vitest"
import { LGraphButton } from "@/LGraphButton"
import { LGraphCanvas } from "@/LGraphCanvas"
import { LGraphNode } from "@/LGraphNode"
describe("LGraphNode Title Buttons", () => {
describe("addTitleButton", () => {
it("should add a title button to the node", () => {
const node = new LGraphNode("Test Node")
const button = node.addTitleButton({
name: "test_button",
text: "X",
fgColor: "#FF0000",
})
expect(button).toBeInstanceOf(LGraphButton)
expect(button.name).toBe("test_button")
expect(button.text).toBe("X")
expect(button.fgColor).toBe("#FF0000")
expect(node.title_buttons).toHaveLength(1)
expect(node.title_buttons[0]).toBe(button)
})
it("should add multiple title buttons", () => {
const node = new LGraphNode("Test Node")
const button1 = node.addTitleButton({ name: "button1", text: "A" })
const button2 = node.addTitleButton({ name: "button2", text: "B" })
const button3 = node.addTitleButton({ name: "button3", text: "C" })
expect(node.title_buttons).toHaveLength(3)
expect(node.title_buttons[0]).toBe(button1)
expect(node.title_buttons[1]).toBe(button2)
expect(node.title_buttons[2]).toBe(button3)
})
it("should create buttons with default options", () => {
const node = new LGraphNode("Test Node")
const button = node.addTitleButton({})
expect(button).toBeInstanceOf(LGraphButton)
expect(button.name).toBeUndefined()
expect(node.title_buttons).toHaveLength(1)
})
})
describe("onMouseDown with title buttons", () => {
it("should handle click on title button", () => {
const node = new LGraphNode("Test Node")
node.pos = [100, 200]
node.size = [180, 60]
const button = node.addTitleButton({
name: "close_button",
text: "X",
visible: true,
})
// Mock button dimensions
button.getWidth = vi.fn().mockReturnValue(20)
button.height = 16
// Simulate button being drawn to populate _last_area
// Button is drawn at node-relative coordinates
// Button x: node.size[0] - 5 - button_width = 180 - 5 - 20 = 155
// Button y: -LiteGraph.NODE_TITLE_HEIGHT = -30
button._last_area[0] = 155
button._last_area[1] = -30
button._last_area[2] = 20
button._last_area[3] = 16
const canvas = {
ctx: {} as CanvasRenderingContext2D,
dispatch: vi.fn(),
} as unknown as LGraphCanvas
const event = {
canvasX: 265, // node.pos[0] + node.size[0] - 5 - button_width = 100 + 180 - 5 - 20 = 255, click in middle = 265
canvasY: 178, // node.pos[1] - LiteGraph.NODE_TITLE_HEIGHT + 8 = 200 - 30 + 8 = 178
} as any
// Calculate node-relative position for the click
const clickPosRelativeToNode: [number, number] = [
265 - node.pos[0], // 265 - 100 = 165
178 - node.pos[1], // 178 - 200 = -22
]
// Simulate the click - onMouseDown should detect button click
const handled = node.onMouseDown(event, clickPosRelativeToNode, canvas)
expect(handled).toBe(true)
expect(canvas.dispatch).toHaveBeenCalledWith("litegraph:node-title-button-clicked", {
node: node,
button: button,
})
})
it("should not handle click outside title buttons", () => {
const node = new LGraphNode("Test Node")
node.pos = [100, 200]
node.size = [180, 60]
const button = node.addTitleButton({
name: "test_button",
text: "T",
visible: true,
})
button.getWidth = vi.fn().mockReturnValue(20)
button.height = 16
// Simulate button being drawn at node-relative coordinates
button._last_area[0] = 155 // 180 - 5 - 20
button._last_area[1] = -30 // -NODE_TITLE_HEIGHT
button._last_area[2] = 20
button._last_area[3] = 16
const canvas = {
ctx: {} as CanvasRenderingContext2D,
dispatch: vi.fn(),
} as unknown as LGraphCanvas
const event = {
canvasX: 150, // Click in the middle of the node, not on button
canvasY: 180,
} as any
// Calculate node-relative position
const clickPosRelativeToNode: [number, number] = [
150 - node.pos[0], // 150 - 100 = 50
180 - node.pos[1], // 180 - 200 = -20
]
const handled = node.onMouseDown(event, clickPosRelativeToNode, canvas)
expect(handled).toBe(false)
expect(canvas.dispatch).not.toHaveBeenCalled()
})
it("should handle multiple buttons correctly", () => {
const node = new LGraphNode("Test Node")
node.pos = [100, 200]
node.size = [200, 60]
const button1 = node.addTitleButton({
name: "button1",
text: "A",
visible: true,
})
const button2 = node.addTitleButton({
name: "button2",
text: "B",
visible: true,
})
// Mock button dimensions
button1.getWidth = vi.fn().mockReturnValue(20)
button2.getWidth = vi.fn().mockReturnValue(20)
button1.height = button2.height = 16
// Simulate buttons being drawn at node-relative coordinates
// First button (rightmost): 200 - 5 - 20 = 175
button1._last_area[0] = 175
button1._last_area[1] = -30 // -NODE_TITLE_HEIGHT
button1._last_area[2] = 20
button1._last_area[3] = 16
// Second button: 175 - 5 - 20 = 150
button2._last_area[0] = 150
button2._last_area[1] = -30 // -NODE_TITLE_HEIGHT
button2._last_area[2] = 20
button2._last_area[3] = 16
const canvas = {
ctx: {} as CanvasRenderingContext2D,
dispatch: vi.fn(),
} as unknown as LGraphCanvas
// Click on second button (leftmost, since they're right-aligned)
const titleY = 170 + 8 // node.pos[1] - NODE_TITLE_HEIGHT + 8 = 200 - 30 + 8 = 178
const event = {
canvasX: 255, // First button at: 100 + 200 - 5 - 20 = 275, Second button at: 275 - 5 - 20 = 250, click in middle = 255
canvasY: titleY,
} as any
// Calculate node-relative position
const clickPosRelativeToNode: [number, number] = [
255 - node.pos[0], // 255 - 100 = 155
titleY - node.pos[1], // 178 - 200 = -22
]
const handled = node.onMouseDown(event, clickPosRelativeToNode, canvas)
expect(handled).toBe(true)
expect(canvas.dispatch).toHaveBeenCalledWith("litegraph:node-title-button-clicked", {
node: node,
button: button2,
})
})
it("should skip invisible buttons", () => {
const node = new LGraphNode("Test Node")
node.pos = [100, 200]
node.size = [180, 60]
const button1 = node.addTitleButton({
name: "invisible_button",
text: "", // Empty text makes it invisible
})
const button2 = node.addTitleButton({
name: "visible_button",
text: "V",
})
button1.getWidth = vi.fn().mockReturnValue(20)
button2.getWidth = vi.fn().mockReturnValue(20)
button1.height = button2.height = 16
// Simulate buttons being drawn at node-relative coordinates
// Only visible button gets drawn area
button2._last_area[0] = 155 // 180 - 5 - 20
button2._last_area[1] = -30 // -NODE_TITLE_HEIGHT
button2._last_area[2] = 20
button2._last_area[3] = 16
const canvas = {
ctx: {} as CanvasRenderingContext2D,
dispatch: vi.fn(),
} as unknown as LGraphCanvas
// Click where the visible button is (invisible button is skipped)
const titleY = 178 // node.pos[1] - NODE_TITLE_HEIGHT + 8 = 200 - 30 + 8 = 178
const event = {
canvasX: 265, // Visible button at: 100 + 180 - 5 - 20 = 255, click in middle = 265
canvasY: titleY,
} as any
// Calculate node-relative position
const clickPosRelativeToNode: [number, number] = [
265 - node.pos[0], // 265 - 100 = 165
titleY - node.pos[1], // 178 - 200 = -22
]
const handled = node.onMouseDown(event, clickPosRelativeToNode, canvas)
expect(handled).toBe(true)
expect(canvas.dispatch).toHaveBeenCalledWith("litegraph:node-title-button-clicked", {
node: node,
button: button2, // Should click visible button, not invisible
})
})
})
describe("onTitleButtonClick", () => {
it("should dispatch litegraph:node-title-button-clicked event", () => {
const node = new LGraphNode("Test Node")
const button = new LGraphButton({ name: "test_button" })
const canvas = {
dispatch: vi.fn(),
} as unknown as LGraphCanvas
node.onTitleButtonClick(button, canvas)
expect(canvas.dispatch).toHaveBeenCalledWith("litegraph:node-title-button-clicked", {
node: node,
button: button,
})
})
})
})

View File

@@ -81,6 +81,7 @@ LGraph {
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"onMouseDown": [Function],
"order": 0,
"outputs": [],
"progress": undefined,
@@ -97,6 +98,7 @@ LGraph {
"selected": [Function],
},
"title": "LGraphNode",
"title_buttons": [],
"type": "mustBeSet",
"widgets": undefined,
"widgets_start_y": undefined,
@@ -149,6 +151,7 @@ LGraph {
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"onMouseDown": [Function],
"order": 0,
"outputs": [],
"progress": undefined,
@@ -165,6 +168,7 @@ LGraph {
"selected": [Function],
},
"title": "LGraphNode",
"title_buttons": [],
"type": "mustBeSet",
"widgets": undefined,
"widgets_start_y": undefined,
@@ -218,6 +222,7 @@ LGraph {
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"onMouseDown": [Function],
"order": 0,
"outputs": [],
"progress": undefined,
@@ -234,6 +239,7 @@ LGraph {
"selected": [Function],
},
"title": "LGraphNode",
"title_buttons": [],
"type": "mustBeSet",
"widgets": undefined,
"widgets_start_y": undefined,

View File

@@ -83,6 +83,7 @@ LGraph {
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"onMouseDown": [Function],
"order": 0,
"outputs": [],
"progress": undefined,
@@ -99,6 +100,7 @@ LGraph {
"selected": [Function],
},
"title": undefined,
"title_buttons": [],
"type": "",
"widgets": undefined,
"widgets_start_y": undefined,
@@ -153,6 +155,7 @@ LGraph {
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"onMouseDown": [Function],
"order": 0,
"outputs": [],
"progress": undefined,
@@ -169,6 +172,7 @@ LGraph {
"selected": [Function],
},
"title": undefined,
"title_buttons": [],
"type": "",
"widgets": undefined,
"widgets_start_y": undefined,
@@ -224,6 +228,7 @@ LGraph {
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"onMouseDown": [Function],
"order": 0,
"outputs": [],
"progress": undefined,
@@ -240,6 +245,7 @@ LGraph {
"selected": [Function],
},
"title": undefined,
"title_buttons": [],
"type": "",
"widgets": undefined,
"widgets_start_y": undefined,

View File

@@ -81,6 +81,7 @@ LGraph {
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"onMouseDown": [Function],
"order": 0,
"outputs": [],
"progress": undefined,
@@ -97,6 +98,7 @@ LGraph {
"selected": [Function],
},
"title": "LGraphNode",
"title_buttons": [],
"type": "mustBeSet",
"widgets": undefined,
"widgets_start_y": undefined,
@@ -149,6 +151,7 @@ LGraph {
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"onMouseDown": [Function],
"order": 0,
"outputs": [],
"progress": undefined,
@@ -165,6 +168,7 @@ LGraph {
"selected": [Function],
},
"title": "LGraphNode",
"title_buttons": [],
"type": "mustBeSet",
"widgets": undefined,
"widgets_start_y": undefined,
@@ -218,6 +222,7 @@ LGraph {
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"onMouseDown": [Function],
"order": 0,
"outputs": [],
"progress": undefined,
@@ -234,6 +239,7 @@ LGraph {
"selected": [Function],
},
"title": "LGraphNode",
"title_buttons": [],
"type": "mustBeSet",
"widgets": undefined,
"widgets_start_y": undefined,

View File

@@ -0,0 +1,231 @@
import { describe, expect, it, vi } from "vitest"
import { LGraphButton } from "@/LGraphButton"
import { LGraphCanvas } from "@/LGraphCanvas"
import { createTestSubgraph, createTestSubgraphNode } from "./fixtures/subgraphHelpers"
describe("SubgraphNode Title Button", () => {
describe("Constructor", () => {
it("should automatically add enter_subgraph button", () => {
const subgraph = createTestSubgraph({
name: "Test Subgraph",
inputs: [{ name: "input", type: "number" }],
})
const subgraphNode = createTestSubgraphNode(subgraph)
expect(subgraphNode.title_buttons).toHaveLength(1)
const button = subgraphNode.title_buttons[0]
expect(button).toBeInstanceOf(LGraphButton)
expect(button.name).toBe("enter_subgraph")
expect(button.text).toBe("\uE93B") // pi-window-maximize
expect(button.xOffset).toBe(-10)
expect(button.yOffset).toBe(0)
expect(button.fontSize).toBe(16)
})
it("should preserve enter_subgraph button when adding more buttons", () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
// Add another button
const customButton = subgraphNode.addTitleButton({
name: "custom_button",
text: "C",
})
expect(subgraphNode.title_buttons).toHaveLength(2)
expect(subgraphNode.title_buttons[0].name).toBe("enter_subgraph")
expect(subgraphNode.title_buttons[1]).toBe(customButton)
})
})
describe("onTitleButtonClick", () => {
it("should open subgraph when enter_subgraph button is clicked", () => {
const subgraph = createTestSubgraph({
name: "Test Subgraph",
})
const subgraphNode = createTestSubgraphNode(subgraph)
const enterButton = subgraphNode.title_buttons[0]
const canvas = {
openSubgraph: vi.fn(),
dispatch: vi.fn(),
} as unknown as LGraphCanvas
subgraphNode.onTitleButtonClick(enterButton, canvas)
expect(canvas.openSubgraph).toHaveBeenCalledWith(subgraph)
expect(canvas.dispatch).not.toHaveBeenCalled() // Should not call parent implementation
})
it("should call parent implementation for other buttons", () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const customButton = subgraphNode.addTitleButton({
name: "custom_button",
text: "X",
})
const canvas = {
openSubgraph: vi.fn(),
dispatch: vi.fn(),
} as unknown as LGraphCanvas
subgraphNode.onTitleButtonClick(customButton, canvas)
expect(canvas.openSubgraph).not.toHaveBeenCalled()
expect(canvas.dispatch).toHaveBeenCalledWith("litegraph:node-title-button-clicked", {
node: subgraphNode,
button: customButton,
})
})
})
describe("Integration with node click handling", () => {
it("should handle clicks on enter_subgraph button", () => {
const subgraph = createTestSubgraph({
name: "Nested Subgraph",
nodeCount: 3,
})
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode.pos = [100, 100]
subgraphNode.size = [200, 100]
const enterButton = subgraphNode.title_buttons[0]
enterButton.getWidth = vi.fn().mockReturnValue(25)
enterButton.height = 20
// Simulate button being drawn at node-relative coordinates
// Button x: 200 - 5 - 25 = 170
// Button y: -30 (title height)
enterButton._last_area[0] = 170
enterButton._last_area[1] = -30
enterButton._last_area[2] = 25
enterButton._last_area[3] = 20
const canvas = {
ctx: {
measureText: vi.fn().mockReturnValue({ width: 25 }),
} as unknown as CanvasRenderingContext2D,
openSubgraph: vi.fn(),
dispatch: vi.fn(),
} as unknown as LGraphCanvas
// Simulate click on the enter button
const event = {
canvasX: 275, // Near right edge where button should be
canvasY: 80, // In title area
} as any
// Calculate node-relative position
const clickPosRelativeToNode: [number, number] = [
275 - subgraphNode.pos[0], // 275 - 100 = 175
80 - subgraphNode.pos[1], // 80 - 100 = -20
]
const handled = subgraphNode.onMouseDown(event, clickPosRelativeToNode, canvas)
expect(handled).toBe(true)
expect(canvas.openSubgraph).toHaveBeenCalledWith(subgraph)
})
it("should not interfere with normal node operations", () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode.pos = [100, 100]
subgraphNode.size = [200, 100]
const canvas = {
ctx: {
measureText: vi.fn().mockReturnValue({ width: 25 }),
} as unknown as CanvasRenderingContext2D,
openSubgraph: vi.fn(),
dispatch: vi.fn(),
} as unknown as LGraphCanvas
// Click in the body of the node, not on button
const event = {
canvasX: 200, // Middle of node
canvasY: 150, // Body area
} as any
// Calculate node-relative position
const clickPosRelativeToNode: [number, number] = [
200 - subgraphNode.pos[0], // 200 - 100 = 100
150 - subgraphNode.pos[1], // 150 - 100 = 50
]
const handled = subgraphNode.onMouseDown(event, clickPosRelativeToNode, canvas)
expect(handled).toBe(false)
expect(canvas.openSubgraph).not.toHaveBeenCalled()
})
it("should not process button clicks when node is collapsed", () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode.pos = [100, 100]
subgraphNode.size = [200, 100]
subgraphNode.flags.collapsed = true
const enterButton = subgraphNode.title_buttons[0]
enterButton.getWidth = vi.fn().mockReturnValue(25)
enterButton.height = 20
// Set button area as if it was drawn
enterButton._last_area[0] = 170
enterButton._last_area[1] = -30
enterButton._last_area[2] = 25
enterButton._last_area[3] = 20
const canvas = {
ctx: {
measureText: vi.fn().mockReturnValue({ width: 25 }),
} as unknown as CanvasRenderingContext2D,
openSubgraph: vi.fn(),
dispatch: vi.fn(),
} as unknown as LGraphCanvas
// Try to click on where the button would be
const event = {
canvasX: 275,
canvasY: 80,
} as any
const clickPosRelativeToNode: [number, number] = [
275 - subgraphNode.pos[0], // 175
80 - subgraphNode.pos[1], // -20
]
const handled = subgraphNode.onMouseDown(event, clickPosRelativeToNode, canvas)
// Should not handle the click when collapsed
expect(handled).toBe(false)
expect(canvas.openSubgraph).not.toHaveBeenCalled()
})
})
describe("Visual properties", () => {
it("should have appropriate visual properties for enter button", () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const enterButton = subgraphNode.title_buttons[0]
// Check visual properties
expect(enterButton.text).toBe("\uE93B") // pi-window-maximize
expect(enterButton.fontSize).toBe(16) // Icon size
expect(enterButton.xOffset).toBe(-10) // Positioned from right edge
expect(enterButton.yOffset).toBe(0) // Centered vertically
// Should be visible by default
expect(enterButton.visible).toBe(true)
})
})
})

View File

@@ -0,0 +1,82 @@
import { describe, expect, it, vi } from "vitest"
import { truncateText } from "@/utils/textUtils"
describe("truncateText", () => {
const createMockContext = (charWidth: number = 10) => {
return {
measureText: vi.fn((text: string) => ({ width: text.length * charWidth })),
} as unknown as CanvasRenderingContext2D
}
it("should return original text if it fits within maxWidth", () => {
const ctx = createMockContext()
const result = truncateText(ctx, "Short", 100)
expect(result).toBe("Short")
})
it("should return original text if maxWidth is 0 or negative", () => {
const ctx = createMockContext()
expect(truncateText(ctx, "Text", 0)).toBe("Text")
expect(truncateText(ctx, "Text", -10)).toBe("Text")
})
it("should truncate text and add ellipsis when text is too long", () => {
const ctx = createMockContext(10) // 10 pixels per character
const result = truncateText(ctx, "This is a very long text", 100)
// 100px total, "..." takes 30px, leaving 70px for text (7 chars)
expect(result).toBe("This is...")
})
it("should use custom ellipsis when provided", () => {
const ctx = createMockContext(10)
const result = truncateText(ctx, "This is a very long text", 100, "…")
// 100px total, "…" takes 10px, leaving 90px for text (9 chars)
expect(result).toBe("This is a…")
})
it("should return only ellipsis if available width is too small", () => {
const ctx = createMockContext(10)
const result = truncateText(ctx, "Text", 20) // Only room for 2 chars, but "..." needs 3
expect(result).toBe("...")
})
it("should handle empty text", () => {
const ctx = createMockContext()
const result = truncateText(ctx, "", 100)
expect(result).toBe("")
})
it("should use binary search efficiently", () => {
const ctx = createMockContext(10)
const longText = "A".repeat(100) // 100 characters
const result = truncateText(ctx, longText, 200) // Room for 20 chars total
expect(result).toBe(`${"A".repeat(17)}...`) // 17 chars + "..." = 20 chars = 200px
// Verify binary search efficiency - should not measure every possible substring
// Binary search for 100 chars should take around log2(100) ≈ 7 iterations
// Plus a few extra calls for measuring the full text and ellipsis
const callCount = (ctx.measureText as any).mock.calls.length
expect(callCount).toBeLessThan(20)
expect(callCount).toBeGreaterThan(5)
})
it("should handle unicode characters correctly", () => {
const ctx = createMockContext(10)
const result = truncateText(ctx, "Hello 👋 World", 80)
// Assuming each char (including emoji) is 10px, total is 130px
// 80px total, "..." takes 30px, leaving 50px for text (5 chars)
expect(result).toBe("Hello...")
})
it("should handle exact boundary cases", () => {
const ctx = createMockContext(10)
// Text exactly fits
expect(truncateText(ctx, "Exact", 50)).toBe("Exact") // 5 chars = 50px
// Text is exactly 1 pixel too long
expect(truncateText(ctx, "Exact!", 50)).toBe("Ex...") // 6 chars = 60px, truncated
})
})