From 3b4c0b5d2bbb14442e2f4b43580d331074dd3b54 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Sat, 2 Aug 2025 17:35:38 -0700 Subject: [PATCH] [feat] Add node title buttons with icon-only rendering (#1186) --- src/LGraphBadge.ts | 2 +- src/LGraphButton.ts | 89 ++++++ src/LGraphCanvas.ts | 22 ++ src/LGraphNode.ts | 90 +++++- src/infrastructure/LGraphCanvasEventMap.ts | 7 + src/subgraph/SubgraphNode.ts | 18 ++ src/utils/textUtils.ts | 47 +++ test/LGraphButton.test.ts | 185 ++++++++++++ test/LGraphCanvas.titleButtons.test.ts | 274 +++++++++++++++++ test/LGraphNode.titleButtons.test.ts | 276 ++++++++++++++++++ .../__snapshots__/ConfigureGraph.test.ts.snap | 6 + test/__snapshots__/LGraph.test.ts.snap | 6 + .../LGraph_constructor.test.ts.snap | 6 + .../subgraph/SubgraphNode.titleButton.test.ts | 231 +++++++++++++++ test/utils/textUtils.test.ts | 82 ++++++ 15 files changed, 1324 insertions(+), 17 deletions(-) create mode 100644 src/LGraphButton.ts create mode 100644 src/utils/textUtils.ts create mode 100644 test/LGraphButton.test.ts create mode 100644 test/LGraphCanvas.titleButtons.test.ts create mode 100644 test/LGraphNode.titleButtons.test.ts create mode 100644 test/subgraph/SubgraphNode.titleButton.test.ts create mode 100644 test/utils/textUtils.test.ts diff --git a/src/LGraphBadge.ts b/src/LGraphBadge.ts index 48ac2a7cc..8e613c2af 100644 --- a/src/LGraphBadge.ts +++ b/src/LGraphBadge.ts @@ -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) { diff --git a/src/LGraphButton.ts b/src/LGraphButton.ts new file mode 100644 index 000000000..1ee27c6f8 --- /dev/null +++ b/src/LGraphButton.ts @@ -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]) + } +} diff --git a/src/LGraphCanvas.ts b/src/LGraphCanvas.ts index 6fdd8ca24..d5cc1d150 100644 --- a/src/LGraphCanvas.ts +++ b/src/LGraphCanvas.ts @@ -4679,6 +4679,28 @@ export class LGraphCanvas implements CustomEventDispatcher !!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) } diff --git a/src/LGraphNode.ts b/src/LGraphNode.ts index dd81cb7cc..9a2803a01 100644 --- a/src/LGraphNode.ts +++ b/src/LGraphNode.ts @@ -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, + ) } } diff --git a/src/infrastructure/LGraphCanvasEventMap.ts b/src/infrastructure/LGraphCanvasEventMap.ts index 79d360e25..d431e90ff 100644 --- a/src/infrastructure/LGraphCanvasEventMap.ts +++ b/src/infrastructure/LGraphCanvasEventMap.ts @@ -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 + } } diff --git a/src/subgraph/SubgraphNode.ts b/src/subgraph/SubgraphNode.ts index 28719904e..c14a5347b 100644 --- a/src/subgraph/SubgraphNode.ts +++ b/src/subgraph/SubgraphNode.ts @@ -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) { diff --git a/src/utils/textUtils.ts b/src/utils/textUtils.ts new file mode 100644 index 000000000..d75c3b7e7 --- /dev/null +++ b/src/utils/textUtils.ts @@ -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 +} diff --git a/test/LGraphButton.test.ts b/test/LGraphButton.test.ts new file mode 100644 index 000000000..d20fea6e4 --- /dev/null +++ b/test/LGraphButton.test.ts @@ -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 + }) + }) +}) diff --git a/test/LGraphCanvas.titleButtons.test.ts b/test/LGraphCanvas.titleButtons.test.ts new file mode 100644 index 000000000..2d7e2e188 --- /dev/null +++ b/test/LGraphCanvas.titleButtons.test.ts @@ -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() + }) + }) +}) diff --git a/test/LGraphNode.titleButtons.test.ts b/test/LGraphNode.titleButtons.test.ts new file mode 100644 index 000000000..161d88e64 --- /dev/null +++ b/test/LGraphNode.titleButtons.test.ts @@ -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, + }) + }) + }) +}) diff --git a/test/__snapshots__/ConfigureGraph.test.ts.snap b/test/__snapshots__/ConfigureGraph.test.ts.snap index 37d51774f..7e9cd555b 100644 --- a/test/__snapshots__/ConfigureGraph.test.ts.snap +++ b/test/__snapshots__/ConfigureGraph.test.ts.snap @@ -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, diff --git a/test/__snapshots__/LGraph.test.ts.snap b/test/__snapshots__/LGraph.test.ts.snap index d1f520005..cc09d850d 100644 --- a/test/__snapshots__/LGraph.test.ts.snap +++ b/test/__snapshots__/LGraph.test.ts.snap @@ -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, diff --git a/test/__snapshots__/LGraph_constructor.test.ts.snap b/test/__snapshots__/LGraph_constructor.test.ts.snap index a0fea6132..cd54aa094 100644 --- a/test/__snapshots__/LGraph_constructor.test.ts.snap +++ b/test/__snapshots__/LGraph_constructor.test.ts.snap @@ -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, diff --git a/test/subgraph/SubgraphNode.titleButton.test.ts b/test/subgraph/SubgraphNode.titleButton.test.ts new file mode 100644 index 000000000..7dcc0ebbc --- /dev/null +++ b/test/subgraph/SubgraphNode.titleButton.test.ts @@ -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) + }) + }) +}) diff --git a/test/utils/textUtils.test.ts b/test/utils/textUtils.test.ts new file mode 100644 index 000000000..da3db053d --- /dev/null +++ b/test/utils/textUtils.test.ts @@ -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 + }) +})