diff --git a/src/LGraphCanvas.ts b/src/LGraphCanvas.ts index 69437f3d9..440c854f9 100644 --- a/src/LGraphCanvas.ts +++ b/src/LGraphCanvas.ts @@ -4663,6 +4663,13 @@ export class LGraphCanvas implements ConnectionColorContext { // render inputs and outputs if (!node.collapsed) { node.layoutSlots() + const slotsBounds = createBounds( + node.slots.map(slot => slot._layoutElement), + /** padding= */ 0, + ) + const widgetStartY = slotsBounds ? slotsBounds[1] + slotsBounds[3] : 0 + node.layoutWidgets({ widgetStartY }) + node.drawSlots(ctx, { connectingLink: this.connecting_links?.[0], colorContext: this, @@ -4673,12 +4680,7 @@ export class LGraphCanvas implements ConnectionColorContext { ctx.textAlign = "left" ctx.globalAlpha = 1 - const slotsBounds = createBounds( - node.slots.map(slot => slot._layoutElement), - /** padding= */ 0, - ) - const max_y = slotsBounds ? slotsBounds[1] + slotsBounds[3] : 0 - this.drawNodeWidgets(node, max_y, ctx) + this.drawNodeWidgets(node, widgetStartY, ctx) } else if (this.render_collapsed_slots) { node.drawCollapsedSlots(ctx) } @@ -5527,7 +5529,6 @@ export class LGraphCanvas implements ConnectionColorContext { ctx: CanvasRenderingContext2D, ): void { node.drawWidgets(ctx, { - y: posY, colorContext: this, linkOverWidget: this.link_over_widget, linkOverWidgetType: this.link_over_widget_type, diff --git a/src/LGraphNode.ts b/src/LGraphNode.ts index feca8af3f..f2842ddf4 100644 --- a/src/LGraphNode.ts +++ b/src/LGraphNode.ts @@ -19,7 +19,7 @@ import type { Size, } from "./interfaces" import type { LGraph } from "./LGraph" -import type { IWidget, TWidgetValue } from "./types/widgets" +import type { IBaseWidget, IWidget, TWidgetValue } from "./types/widgets" import type { ISerialisedNode } from "./types/serialisation" import type { LGraphCanvas } from "./LGraphCanvas" import type { CanvasMouseEvent } from "./types/events" @@ -39,6 +39,8 @@ import { ConnectionColorContext, isINodeInputSlot, NodeInputSlot, NodeOutputSlot import { WIDGET_TYPE_MAP } from "./widgets/widgetMap" import { toClass } from "./utils/type" import { LayoutElement } from "./utils/layout" +import { distributeSpace } from "./utils/spaceDistribution" + export type NodeId = number | string export interface INodePropertyInfo { @@ -176,6 +178,12 @@ export class LGraphNode implements Positionable, IPinnable { properties_info: INodePropertyInfo[] = [] flags: INodeFlags = {} widgets?: IWidget[] + /** + * The amount of space available for widgets to grow into. + * @see {@link layoutWidgets} + */ + freeWidgetSpace?: number + locked?: boolean // Execution order, automatically computed during run @@ -1517,13 +1525,18 @@ export class LGraphNode implements Positionable, IPinnable { let widgets_height = 0 if (this.widgets?.length) { - for (let i = 0, l = this.widgets.length; i < l; ++i) { - const widget = this.widgets[i] + for (const widget of this.widgets) { if (widget.hidden || (widget.advanced && !this.showAdvanced)) continue - widgets_height += widget.computeSize - ? widget.computeSize(size[0])[1] + 4 - : LiteGraph.NODE_WIDGET_HEIGHT + 4 + let widget_height = 0 + if (widget.computeLayoutSize) { + widget_height += widget.computeLayoutSize(this).minHeight + } else if (widget.computeSize) { + widget_height += widget.computeSize(size[0])[1] + } else { + widget_height += LiteGraph.NODE_WIDGET_HEIGHT + } + widgets_height += widget_height + 4 } widgets_height += 8 } @@ -2792,8 +2805,14 @@ export class LGraphNode implements Positionable, IPinnable { * Returns the height of the node, including the title bar. */ get height() { - const bodyHeight = this.collapsed ? 0 : this.size[1] - return LiteGraph.NODE_TITLE_HEIGHT + bodyHeight + return LiteGraph.NODE_TITLE_HEIGHT + this.bodyHeight + } + + /** + * Returns the height of the node, excluding the title bar. + */ + get bodyHeight() { + return this.collapsed ? 0 : this.size[1] } drawBadges(ctx: CanvasRenderingContext2D, { gap = 2 } = {}): void { @@ -3082,7 +3101,6 @@ export class LGraphNode implements Positionable, IPinnable { } drawWidgets(ctx: CanvasRenderingContext2D, options: { - y: number colorContext: ConnectionColorContext linkOverWidget: IWidget linkOverWidgetType: ISlotType @@ -3091,16 +3109,10 @@ export class LGraphNode implements Positionable, IPinnable { }): void { if (!this.widgets) return - const { y, colorContext, linkOverWidget, linkOverWidgetType, lowQuality = false, editorAlpha = 1 } = options - let posY = y - if (this.widgets_up) { - posY = 2 - } - if (this.widgets_start_y != null) posY = this.widgets_start_y + const { colorContext, linkOverWidget, linkOverWidgetType, lowQuality = false, editorAlpha = 1 } = options const width = this.size[0] const widgets = this.widgets - posY += 2 const H = LiteGraph.NODE_WIDGET_HEIGHT const show_text = !lowQuality ctx.save() @@ -3109,7 +3121,7 @@ export class LGraphNode implements Positionable, IPinnable { for (const w of widgets) { if (w.hidden || (w.advanced && !this.showAdvanced)) continue - const y = w.y || posY + const y = w.y const outline_color = w.advanced ? LiteGraph.WIDGET_ADVANCED_OUTLINE_COLOR : LiteGraph.WIDGET_OUTLINE_COLOR if (w === linkOverWidget) { @@ -3134,7 +3146,6 @@ export class LGraphNode implements Positionable, IPinnable { } else { w.draw?.(ctx, this, widget_width, y, H) } - posY += (w.computeSize ? w.computeSize(widget_width)[1] : H) + 4 ctx.globalAlpha = editorAlpha } ctx.restore() @@ -3265,4 +3276,73 @@ export class LGraphNode implements Positionable, IPinnable { }) } } + + /** + * Lays out the node's widgets vertically. + * Sets following properties on each widget: + * - {@link IBaseWidget.computedHeight} + * - {@link IBaseWidget.y} + */ + layoutWidgets(options: { widgetStartY: number }): void { + if (!this.widgets || !this.widgets.length) return + + const bodyHeight = this.bodyHeight + const widgetStartY = this.widgets_start_y ?? ( + (this.widgets_up ? 0 : options.widgetStartY) + 2 + ) + + let freeSpace = bodyHeight - widgetStartY + + // Collect fixed height widgets first + let fixedWidgetHeight = 0 + const growableWidgets: { + minHeight: number + prefHeight: number + w: IBaseWidget + }[] = [] + + for (const w of this.widgets) { + if (w.computeLayoutSize) { + const { minHeight, maxHeight } = w.computeLayoutSize(this) + growableWidgets.push({ + minHeight, + prefHeight: maxHeight, + w, + }) + } else if (w.computeSize) { + const height = w.computeSize()[1] + 4 + w.computedHeight = height + fixedWidgetHeight += height + } else { + const height = LiteGraph.NODE_WIDGET_HEIGHT + 4 + w.computedHeight = height + fixedWidgetHeight += height + } + } + + // Calculate remaining space for DOM widgets + freeSpace -= fixedWidgetHeight + this.freeWidgetSpace = freeSpace + + // Prepare space requests for distribution + const spaceRequests = growableWidgets.map(d => ({ + minSize: d.minHeight, + maxSize: d.prefHeight, + })) + + // Distribute space among DOM widgets + const allocations = distributeSpace(Math.max(0, freeSpace), spaceRequests) + + // Apply computed heights + growableWidgets.forEach((d, i) => { + d.w.computedHeight = allocations[i] + }) + + // Position widgets + let y = widgetStartY + for (const w of this.widgets) { + w.y = y + y += w.computedHeight + } + } } diff --git a/src/types/widgets.ts b/src/types/widgets.ts index d1bc83dc2..ab5fbc205 100644 --- a/src/types/widgets.ts +++ b/src/types/widgets.ts @@ -122,8 +122,25 @@ export interface IBaseWidget { /** Widget type (see {@link TWidgetType}) */ type?: TWidgetType value?: TWidgetValue + + /** + * The computed height of the widget. Used by customized node resize logic. + * See scripts/domWidget.ts for more details. + */ + computedHeight?: number + + /** + * The starting y position of the widget after layout. + */ y?: number + + /** + * The y position of the widget after drawing (rendering). + * @deprecated There is no longer dynamic y adjustment on rendering anymore. + * Use {@link IBaseWidget.y} instead. + */ last_y?: number + width?: number disabled?: boolean @@ -159,8 +176,30 @@ export interface IBaseWidget { y: number, H: number, ): void + + /** + * Compute the size of the widget. + * @param width The width of the widget. + * @deprecated Use {@link IBaseWidget.computeLayoutSize} instead. + * @returns The size of the widget. + */ computeSize?(width?: number): Size + /** + * Compute the layout size of the widget. Overrides {@link IBaseWidget.computeSize}. + * @param node The node this widget belongs to. + * @returns The layout size of the widget. + */ + computeLayoutSize?: ( + this: IBaseWidget, + node: LGraphNode + ) => { + minHeight: number + maxHeight?: number + minWidth: number + maxWidth?: number + } + /** * Callback for pointerdown events, allowing custom widgets to register callbacks to occur * for all {@link CanvasPointer} events. diff --git a/src/utils/spaceDistribution.ts b/src/utils/spaceDistribution.ts new file mode 100644 index 000000000..b30733190 --- /dev/null +++ b/src/utils/spaceDistribution.ts @@ -0,0 +1,77 @@ +export interface SpaceRequest { + minSize: number + maxSize?: number +} + +/** + * Distributes available space among items with min/max size constraints + * @param totalSpace Total space available to distribute + * @param requests Array of space requests with size constraints + * @returns Array of space allocations + */ +export function distributeSpace( + totalSpace: number, + requests: SpaceRequest[], +): number[] { + // Handle edge cases + if (requests.length === 0) return [] + + // Calculate total minimum space needed + const totalMinSize = requests.reduce((sum, req) => sum + req.minSize, 0) + + // If we can't meet minimum requirements, return the minimum sizes + if (totalSpace < totalMinSize) { + return requests.map(req => req.minSize) + } + + // Initialize allocations with minimum sizes + let allocations = requests.map(req => ({ + computedSize: req.minSize, + maxSize: req.maxSize ?? Infinity, + remaining: (req.maxSize ?? Infinity) - req.minSize, + })) + + // Calculate remaining space to distribute + let remainingSpace = totalSpace - totalMinSize + + // Distribute remaining space iteratively + while ( + remainingSpace > 0 && + allocations.some(alloc => alloc.remaining > 0) + ) { + // Count items that can still grow + const growableItems = allocations.filter( + alloc => alloc.remaining > 0, + ).length + + if (growableItems === 0) break + + // Calculate fair share per item + const sharePerItem = remainingSpace / growableItems + + // Track how much space was actually used in this iteration + let spaceUsedThisRound = 0 + + // Distribute space + allocations = allocations.map((alloc) => { + if (alloc.remaining <= 0) return alloc + + const growth = Math.min(sharePerItem, alloc.remaining) + spaceUsedThisRound += growth + + return { + ...alloc, + computedSize: alloc.computedSize + growth, + remaining: alloc.remaining - growth, + } + }) + + remainingSpace -= spaceUsedThisRound + + // Break if we couldn't distribute any more space + if (spaceUsedThisRound === 0) break + } + + // Return only the computed sizes + return allocations.map(({ computedSize }) => computedSize) +} diff --git a/test/__snapshots__/LGraph.test.ts.snap b/test/__snapshots__/LGraph.test.ts.snap index 54ed4a377..d941f203d 100644 --- a/test/__snapshots__/LGraph.test.ts.snap +++ b/test/__snapshots__/LGraph.test.ts.snap @@ -70,6 +70,7 @@ LGraph { "exec_version": undefined, "execute_triggered": undefined, "flags": {}, + "freeWidgetSpace": undefined, "gotFocusAt": undefined, "graph": [Circular], "has_errors": true, @@ -137,6 +138,7 @@ LGraph { "exec_version": undefined, "execute_triggered": undefined, "flags": {}, + "freeWidgetSpace": undefined, "gotFocusAt": undefined, "graph": [Circular], "has_errors": true, @@ -205,6 +207,7 @@ LGraph { "exec_version": undefined, "execute_triggered": undefined, "flags": {}, + "freeWidgetSpace": undefined, "gotFocusAt": undefined, "graph": [Circular], "has_errors": true, @@ -387,6 +390,7 @@ LGraph { "exec_version": undefined, "execute_triggered": undefined, "flags": {}, + "freeWidgetSpace": undefined, "gotFocusAt": undefined, "graph": [Circular], "has_errors": true, @@ -454,6 +458,7 @@ LGraph { "exec_version": undefined, "execute_triggered": undefined, "flags": {}, + "freeWidgetSpace": undefined, "gotFocusAt": undefined, "graph": [Circular], "has_errors": true, @@ -522,6 +527,7 @@ LGraph { "exec_version": undefined, "execute_triggered": undefined, "flags": {}, + "freeWidgetSpace": undefined, "gotFocusAt": undefined, "graph": [Circular], "has_errors": true, diff --git a/test/utils/spaceDistribution.test.ts b/test/utils/spaceDistribution.test.ts new file mode 100644 index 000000000..b7b09a6bc --- /dev/null +++ b/test/utils/spaceDistribution.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest" +import { type SpaceRequest, distributeSpace } from "@/utils/spaceDistribution" + +describe("distributeSpace", () => { + it("should distribute space according to minimum sizes when space is limited", () => { + const requests: SpaceRequest[] = [ + { minSize: 100 }, + { minSize: 100 }, + { minSize: 100 }, + ] + expect(distributeSpace(300, requests)).toEqual([100, 100, 100]) + }) + + it("should distribute extra space equally when no maxSize", () => { + const requests: SpaceRequest[] = [{ minSize: 100 }, { minSize: 100 }] + expect(distributeSpace(400, requests)).toEqual([200, 200]) + }) + + it("should respect maximum sizes", () => { + const requests: SpaceRequest[] = [ + { minSize: 100, maxSize: 150 }, + { minSize: 100 }, + ] + expect(distributeSpace(400, requests)).toEqual([150, 250]) + }) + + it("should handle empty requests array", () => { + expect(distributeSpace(1000, [])).toEqual([]) + }) + + it("should handle negative total space", () => { + const requests: SpaceRequest[] = [{ minSize: 100 }, { minSize: 100 }] + expect(distributeSpace(-100, requests)).toEqual([100, 100]) + }) + + it("should handle total space smaller than minimum sizes", () => { + const requests: SpaceRequest[] = [{ minSize: 100 }, { minSize: 100 }] + expect(distributeSpace(100, requests)).toEqual([100, 100]) + }) +})