mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-23 08:14:06 +00:00
Upstream frontend widgets layout logic (#531)
* Upstream frontend widgets layout logic * Add back LGraphNode.freeWidgetSpace * nit * update expectations
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
77
src/utils/spaceDistribution.ts
Normal file
77
src/utils/spaceDistribution.ts
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
40
test/utils/spaceDistribution.test.ts
Normal file
40
test/utils/spaceDistribution.test.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user