mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-22 15:54:09 +00:00
### Widget text overhaul #### Current - Numbers and text overlap - Combo boxes truncate the value before the label  #### Proposed **By default, widgets will now truncate their labels before their values.** https://github.com/user-attachments/assets/296ea5ab-d2ff-44f2-9139-5d97789e4f12 - Changes the way widget text is rendered, calculated, and truncated - Truncation now applies in a standard way to the following widgets: - Text - Combo - Number - Centralises widget draw routines in base class ### Config ```ts // Truncate **both** widgets and labels evenly LiteGraph.truncateWidgetTextEvenly = true // Swap the default from truncating labels before values, to truncating values first (restores legacy behaviour) // truncateWidgetTextEvenly **must** be `false`. LiteGraph.truncateWidgetValuesFirst = true ``` ### API / interfaces - Adds rich `Rectangle` concrete impl., with many methods and helpful accessors (e.g. `right`, `bottom`) - Actually _improves_ performance due to switch from Float32Array to Float64Array - Impact vs plain Float64Array was not detectable outside of a 2M+ instantiation-loop with random data - Lazy `pos` & `size` `subarray` properties - Adds `ReadOnlySize` - Adds higher-level text draw functions to abstract the nitty gritty in a performant way (binary search) - Resolves Comfy-Org/ComfyUI_frontend/issues/457
241 lines
6.8 KiB
TypeScript
241 lines
6.8 KiB
TypeScript
import type { Rectangle } from "./infrastructure/Rectangle"
|
|
import type { CanvasColour, Rect } from "./interfaces"
|
|
|
|
import { LiteGraph } from "./litegraph"
|
|
import { LinkDirection, RenderShape, TitleMode } from "./types/globalEnums"
|
|
|
|
const ELLIPSIS = "\u2026"
|
|
const TWO_DOT_LEADER = "\u2025"
|
|
const ONE_DOT_LEADER = "\u2024"
|
|
|
|
export enum SlotType {
|
|
Array = "array",
|
|
Event = -1,
|
|
}
|
|
|
|
/** @see RenderShape */
|
|
export enum SlotShape {
|
|
Box = RenderShape.BOX,
|
|
Arrow = RenderShape.ARROW,
|
|
Grid = RenderShape.GRID,
|
|
Circle = RenderShape.CIRCLE,
|
|
HollowCircle = RenderShape.HollowCircle,
|
|
}
|
|
|
|
/** @see LinkDirection */
|
|
export enum SlotDirection {
|
|
Up = LinkDirection.UP,
|
|
Right = LinkDirection.RIGHT,
|
|
Down = LinkDirection.DOWN,
|
|
Left = LinkDirection.LEFT,
|
|
}
|
|
|
|
export enum LabelPosition {
|
|
Left = "left",
|
|
Right = "right",
|
|
}
|
|
|
|
export interface IDrawBoundingOptions {
|
|
/** The shape to render */
|
|
shape?: RenderShape
|
|
/** The radius of the rounded corners for {@link RenderShape.ROUND} and {@link RenderShape.CARD} */
|
|
round_radius?: number
|
|
/** Shape will extend above the Y-axis 0 by this amount @deprecated This is node-specific: it should be removed entirely, and behaviour defined by the caller more explicitly */
|
|
title_height?: number
|
|
/** @deprecated This is node-specific: it should be removed entirely, and behaviour defined by the caller more explicitly */
|
|
title_mode?: TitleMode
|
|
/** The color that should be drawn */
|
|
color?: CanvasColour
|
|
/** The distance between the edge of the {@link area} and the middle of the line */
|
|
padding?: number
|
|
/** @deprecated This is node-specific: it should be removed entirely, and behaviour defined by the caller more explicitly */
|
|
collapsed?: boolean
|
|
/** Thickness of the line drawn (`lineWidth`) */
|
|
lineWidth?: number
|
|
}
|
|
|
|
export interface IDrawTextInAreaOptions {
|
|
/** The canvas to draw the text on. */
|
|
ctx: CanvasRenderingContext2D
|
|
/** The text to draw. */
|
|
text: string
|
|
/** The area the text will be drawn in. */
|
|
area: Rectangle
|
|
/** The alignment of the text. */
|
|
align?: "left" | "right" | "center"
|
|
}
|
|
|
|
/**
|
|
* Draws only the path of a shape on the canvas, without filling.
|
|
* Used to draw indicators for node status, e.g. "selected".
|
|
* @param ctx The 2D context to draw on
|
|
* @param area The position and size of the shape to render
|
|
*/
|
|
export function strokeShape(
|
|
ctx: CanvasRenderingContext2D,
|
|
area: Rect,
|
|
{
|
|
shape = RenderShape.BOX,
|
|
round_radius,
|
|
title_height,
|
|
title_mode = TitleMode.NORMAL_TITLE,
|
|
color,
|
|
padding = 6,
|
|
collapsed = false,
|
|
lineWidth: thickness = 1,
|
|
}: IDrawBoundingOptions = {},
|
|
): void {
|
|
// These param defaults are not compile-time static, and must be re-evaluated at runtime
|
|
round_radius ??= LiteGraph.ROUND_RADIUS
|
|
color ??= LiteGraph.NODE_BOX_OUTLINE_COLOR
|
|
|
|
// Adjust area if title is transparent
|
|
if (title_mode === TitleMode.TRANSPARENT_TITLE) {
|
|
const height = title_height ?? LiteGraph.NODE_TITLE_HEIGHT
|
|
area[1] -= height
|
|
area[3] += height
|
|
}
|
|
|
|
// Set up context
|
|
const { lineWidth, strokeStyle } = ctx
|
|
ctx.lineWidth = thickness
|
|
ctx.globalAlpha = 0.8
|
|
ctx.strokeStyle = color
|
|
ctx.beginPath()
|
|
|
|
// Draw shape based on type
|
|
const [x, y, width, height] = area
|
|
switch (shape) {
|
|
case RenderShape.BOX: {
|
|
ctx.rect(
|
|
x - padding,
|
|
y - padding,
|
|
width + 2 * padding,
|
|
height + 2 * padding,
|
|
)
|
|
break
|
|
}
|
|
case RenderShape.ROUND:
|
|
case RenderShape.CARD: {
|
|
const radius = round_radius + padding
|
|
const isCollapsed = shape === RenderShape.CARD && collapsed
|
|
const cornerRadii =
|
|
isCollapsed || shape === RenderShape.ROUND
|
|
? [radius]
|
|
: [radius, 2, radius, 2]
|
|
ctx.roundRect(
|
|
x - padding,
|
|
y - padding,
|
|
width + 2 * padding,
|
|
height + 2 * padding,
|
|
cornerRadii,
|
|
)
|
|
break
|
|
}
|
|
case RenderShape.CIRCLE: {
|
|
const centerX = x + width / 2
|
|
const centerY = y + height / 2
|
|
const radius = Math.max(width, height) / 2 + padding
|
|
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2)
|
|
break
|
|
}
|
|
}
|
|
|
|
// Stroke the shape
|
|
ctx.stroke()
|
|
|
|
// Reset context
|
|
ctx.lineWidth = lineWidth
|
|
ctx.strokeStyle = strokeStyle
|
|
|
|
// TODO: Store and reset value properly. Callers currently expect this behaviour (e.g. muted nodes).
|
|
ctx.globalAlpha = 1
|
|
}
|
|
|
|
/**
|
|
* Truncates text using binary search to fit within a given width, appending an ellipsis if needed.
|
|
* @param ctx The canvas rendering context.
|
|
* @param text The text to truncate.
|
|
* @param maxWidth The maximum width the text (plus ellipsis) can occupy.
|
|
* @returns The truncated text, or the original text if it fits.
|
|
*/
|
|
function truncateTextToWidth(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string {
|
|
if (!(maxWidth > 0)) return ""
|
|
|
|
// Text fits
|
|
const fullWidth = ctx.measureText(text).width
|
|
if (fullWidth <= maxWidth) return text
|
|
|
|
const ellipsisWidth = ctx.measureText(ELLIPSIS).width * 0.75
|
|
|
|
// Can't even fit ellipsis
|
|
if (ellipsisWidth > maxWidth) {
|
|
const twoDotsWidth = ctx.measureText(TWO_DOT_LEADER).width * 0.75
|
|
if (twoDotsWidth < maxWidth) return TWO_DOT_LEADER
|
|
|
|
const oneDotWidth = ctx.measureText(ONE_DOT_LEADER).width * 0.75
|
|
return oneDotWidth < maxWidth ? ONE_DOT_LEADER : ""
|
|
}
|
|
|
|
let min = 0
|
|
let max = text.length
|
|
let bestLen = 0
|
|
|
|
// Binary search for the longest substring that fits with the ellipsis
|
|
while (min <= max) {
|
|
const mid = Math.floor((min + max) * 0.5)
|
|
|
|
// Avoid measuring empty string + ellipsis
|
|
if (mid === 0) {
|
|
min = mid + 1
|
|
continue
|
|
}
|
|
|
|
const sub = text.substring(0, mid)
|
|
const currentWidth = ctx.measureText(sub).width + ellipsisWidth
|
|
|
|
if (currentWidth <= maxWidth) {
|
|
// This length fits, try potentially longer
|
|
bestLen = mid
|
|
min = mid + 1
|
|
} else {
|
|
// Too long, try shorter
|
|
max = mid - 1
|
|
}
|
|
}
|
|
|
|
return bestLen === 0
|
|
? ELLIPSIS
|
|
: text.substring(0, bestLen) + ELLIPSIS
|
|
}
|
|
|
|
/**
|
|
* Draws text within an area, truncating it and adding an ellipsis if necessary.
|
|
*/
|
|
export function drawTextInArea({ ctx, text, area, align = "left" }: IDrawTextInAreaOptions) {
|
|
const { left, right, bottom, width, centreX } = area
|
|
|
|
// Text already fits
|
|
const fullWidth = ctx.measureText(text).width
|
|
if (fullWidth <= width) {
|
|
ctx.textAlign = align
|
|
const x = align === "left" ? left : (align === "right" ? right : centreX)
|
|
ctx.fillText(text, x, bottom)
|
|
return
|
|
}
|
|
|
|
// Need to truncate text
|
|
const truncated = truncateTextToWidth(ctx, text, width)
|
|
if (truncated.length === 0) return
|
|
|
|
// Draw text - left-aligned to prevent bouncing during resize
|
|
ctx.textAlign = "left"
|
|
ctx.fillText(truncated.slice(0, -1), left, bottom)
|
|
ctx.rect(left, bottom, width, 1)
|
|
|
|
// Draw the ellipsis, right-aligned to the button
|
|
ctx.textAlign = "right"
|
|
const ellipsis = truncated.at(-1)!
|
|
ctx.fillText(ellipsis, right, bottom, ctx.measureText(ellipsis).width * 0.75)
|
|
}
|