Upstream frontend widgets layout logic (#531)

* Upstream frontend widgets layout logic

* Add back LGraphNode.freeWidgetSpace

* nit

* update expectations
This commit is contained in:
Chenlei Hu
2025-02-14 19:22:37 -05:00
committed by GitHub
parent 5bed4fbb70
commit d23a320f9f
6 changed files with 268 additions and 25 deletions

View File

@@ -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,

View File

@@ -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
}
}
}

View File

@@ -122,8 +122,25 @@ export interface IBaseWidget<TElement extends HTMLElement = HTMLElement> {
/** 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<TElement extends HTMLElement = HTMLElement> {
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.

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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])
})
})