mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-11 02:20:08 +00:00
Widget overhaul (#1010)
### 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
This commit is contained in:
@@ -292,6 +292,22 @@ export class LiteGraphGlobal {
|
||||
*/
|
||||
macGesturesRequireMac: boolean = true
|
||||
|
||||
/**
|
||||
* If `true`, widget labels and values will both be truncated (proportionally to size),
|
||||
* until they fit within the widget.
|
||||
*
|
||||
* Otherwise, the label will be truncated completely before the value is truncated.
|
||||
* @default false
|
||||
*/
|
||||
truncateWidgetTextEvenly: boolean = false
|
||||
|
||||
/**
|
||||
* If `true`, widget values will be completely truncated when shrinking a widget,
|
||||
* before truncating widget labels. {@link truncateWidgetTextEvenly} must be `false`.
|
||||
* @default false
|
||||
*/
|
||||
truncateWidgetValuesFirst: boolean = false
|
||||
|
||||
// TODO: Remove legacy accessors
|
||||
LGraph = LGraph
|
||||
LLink = LLink
|
||||
|
||||
103
src/draw.ts
103
src/draw.ts
@@ -1,8 +1,13 @@
|
||||
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,
|
||||
@@ -49,6 +54,17 @@ export interface IDrawBoundingOptions {
|
||||
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".
|
||||
@@ -135,3 +151,90 @@ export function strokeShape(
|
||||
// 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)
|
||||
}
|
||||
|
||||
270
src/infrastructure/Rectangle.ts
Normal file
270
src/infrastructure/Rectangle.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import type { Point, ReadOnlyPoint, ReadOnlyRect, ReadOnlySize, Size } from "@/interfaces"
|
||||
|
||||
export class Rectangle extends Float64Array {
|
||||
#pos: Point | undefined
|
||||
#size: Size | undefined
|
||||
|
||||
constructor(x: number = 0, y: number = 0, width: number = 0, height: number = 0) {
|
||||
super(4)
|
||||
|
||||
this[0] = x
|
||||
this[1] = y
|
||||
this[2] = width
|
||||
this[3] = height
|
||||
}
|
||||
|
||||
override subarray(begin?: number, end?: number): Float64Array<ArrayBuffer> {
|
||||
return new Float64Array(this.buffer, begin, end)
|
||||
}
|
||||
|
||||
/**
|
||||
* A reference to the position of the top-left corner of this rectangle.
|
||||
*
|
||||
* Updating the values of the returned object will update this rectangle.
|
||||
*/
|
||||
get pos(): Point {
|
||||
this.#pos ??= this.subarray(0, 2)
|
||||
return this.#pos
|
||||
}
|
||||
|
||||
set pos(value: ReadOnlyPoint) {
|
||||
this[0] = value[0]
|
||||
this[1] = value[1]
|
||||
}
|
||||
|
||||
/**
|
||||
* A reference to the size of this rectangle.
|
||||
*
|
||||
* Updating the values of the returned object will update this rectangle.
|
||||
*/
|
||||
get size(): Size {
|
||||
this.#size ??= this.subarray(2, 4)
|
||||
return this.#size
|
||||
}
|
||||
|
||||
set size(value: ReadOnlySize) {
|
||||
this[2] = value[0]
|
||||
this[3] = value[1]
|
||||
}
|
||||
|
||||
// #region Property accessors
|
||||
/** The x co-ordinate of the top-left corner of this rectangle. */
|
||||
get x() {
|
||||
return this[0]
|
||||
}
|
||||
|
||||
set x(value: number) {
|
||||
this[0] = value
|
||||
}
|
||||
|
||||
/** The y co-ordinate of the top-left corner of this rectangle. */
|
||||
get y() {
|
||||
return this[1]
|
||||
}
|
||||
|
||||
set y(value: number) {
|
||||
this[1] = value
|
||||
}
|
||||
|
||||
/** The width of this rectangle. */
|
||||
get width() {
|
||||
return this[2]
|
||||
}
|
||||
|
||||
set width(value: number) {
|
||||
this[2] = value
|
||||
}
|
||||
|
||||
/** The height of this rectangle. */
|
||||
get height() {
|
||||
return this[3]
|
||||
}
|
||||
|
||||
set height(value: number) {
|
||||
this[3] = value
|
||||
}
|
||||
|
||||
/** The x co-ordinate of the left edge of this rectangle. */
|
||||
get left() {
|
||||
return this[0]
|
||||
}
|
||||
|
||||
set left(value: number) {
|
||||
this[0] = value
|
||||
}
|
||||
|
||||
/** The y co-ordinate of the top edge of this rectangle. */
|
||||
get top() {
|
||||
return this[1]
|
||||
}
|
||||
|
||||
set top(value: number) {
|
||||
this[1] = value
|
||||
}
|
||||
|
||||
/** The x co-ordinate of the right edge of this rectangle. */
|
||||
get right() {
|
||||
return this[0] + this[2]
|
||||
}
|
||||
|
||||
set right(value: number) {
|
||||
this[0] = value - this[2]
|
||||
}
|
||||
|
||||
/** The y co-ordinate of the bottom edge of this rectangle. */
|
||||
get bottom() {
|
||||
return this[1] + this[3]
|
||||
}
|
||||
|
||||
set bottom(value: number) {
|
||||
this[1] = value - this[3]
|
||||
}
|
||||
|
||||
/** The x co-ordinate of the centre of this rectangle. */
|
||||
get centreX() {
|
||||
return this[0] + (this[2] * 0.5)
|
||||
}
|
||||
|
||||
/** The y co-ordinate of the centre of this rectangle. */
|
||||
get centreY() {
|
||||
return this[1] + (this[3] * 0.5)
|
||||
}
|
||||
// #endregion Property accessors
|
||||
|
||||
/**
|
||||
* Updates the rectangle to the values of {@link rect}.
|
||||
* @param rect The rectangle to update to.
|
||||
*/
|
||||
updateTo(rect: ReadOnlyRect) {
|
||||
this[0] = rect[0]
|
||||
this[1] = rect[1]
|
||||
this[2] = rect[2]
|
||||
this[3] = rect[3]
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the point [{@link x}, {@link y}] is inside this rectangle.
|
||||
* @param x The x-coordinate to check
|
||||
* @param y The y-coordinate to check
|
||||
* @returns `true` if the point is inside this rectangle, otherwise `false`.
|
||||
*/
|
||||
containsXy(x: number, y: number): boolean {
|
||||
const { x: left, y: top, width, height } = this
|
||||
return left <= x &&
|
||||
top <= y &&
|
||||
left + width >= x &&
|
||||
top + height >= y
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if {@link point} is inside this rectangle.
|
||||
* @param point The point to check
|
||||
* @returns `true` if {@link point} is inside this rectangle, otherwise `false`.
|
||||
*/
|
||||
containsPoint(point: ReadOnlyPoint): boolean {
|
||||
return this.x <= point[0] &&
|
||||
this.y <= point[1] &&
|
||||
this.x + this.width >= point[0] &&
|
||||
this.y + this.height >= point[1]
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if {@link rect} is inside this rectangle.
|
||||
* @param rect The rectangle to check
|
||||
* @returns `true` if {@link rect} is inside this rectangle, otherwise `false`.
|
||||
*/
|
||||
containsRect(rect: ReadOnlyRect): boolean {
|
||||
return this.x <= rect[0] &&
|
||||
this.y <= rect[1] &&
|
||||
this.x + this.width >= rect[0] + rect[2] &&
|
||||
this.y + this.height >= rect[1] + rect[3]
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if {@link rect} overlaps with this rectangle.
|
||||
* @param rect The rectangle to check
|
||||
* @returns `true` if {@link rect} overlaps with this rectangle, otherwise `false`.
|
||||
*/
|
||||
overlaps(rect: ReadOnlyRect): boolean {
|
||||
return this.x < rect[0] + rect[2] &&
|
||||
this.y < rect[1] + rect[3] &&
|
||||
this.x + this.width > rect[0] &&
|
||||
this.y + this.height > rect[1]
|
||||
}
|
||||
|
||||
/** @returns The centre point of this rectangle, as a new {@link Point}. */
|
||||
getCentre(): Point {
|
||||
return [this.centreX, this.centreY]
|
||||
}
|
||||
|
||||
/** @returns The area of this rectangle. */
|
||||
getArea(): number {
|
||||
return this.width * this.height
|
||||
}
|
||||
|
||||
/** @returns The perimeter of this rectangle. */
|
||||
getPerimeter(): number {
|
||||
return 2 * (this.width + this.height)
|
||||
}
|
||||
|
||||
/** @returns The top-left corner of this rectangle, as a new {@link Point}. */
|
||||
getTopLeft(): Point {
|
||||
return [this[0], this[1]]
|
||||
}
|
||||
|
||||
/** @returns The bottom-right corner of this rectangle, as a new {@link Point}. */
|
||||
getBottomRight(): Point {
|
||||
return [this.right, this.bottom]
|
||||
}
|
||||
|
||||
/** @returns The width and height of this rectangle, as a new {@link Size}. */
|
||||
getSize(): Size {
|
||||
return [this[2], this[3]]
|
||||
}
|
||||
|
||||
/** @returns The offset from the top-left of this rectangle to the point [{@link x}, {@link y}], as a new {@link Point}. */
|
||||
getOffsetTo([x, y]: ReadOnlyPoint): Point {
|
||||
return [x - this[0], y - this[1]]
|
||||
}
|
||||
|
||||
/** @returns The offset from the point [{@link x}, {@link y}] to the top-left of this rectangle, as a new {@link Point}. */
|
||||
getOffsetFrom([x, y]: ReadOnlyPoint): Point {
|
||||
return [this[0] - x, this[1] - y]
|
||||
}
|
||||
|
||||
/** Sets the width without moving the right edge (changes position) */
|
||||
setWidthRightAnchored(width: number) {
|
||||
const currentWidth = this[2]
|
||||
this[2] = width
|
||||
this[0] += currentWidth - width
|
||||
}
|
||||
|
||||
/** Sets the height without moving the bottom edge (changes position) */
|
||||
setHeightBottomAnchored(height: number) {
|
||||
const currentHeight = this[3]
|
||||
this[3] = height
|
||||
this[1] += currentHeight - height
|
||||
}
|
||||
|
||||
/** Alias of {@link export}. */
|
||||
toArray() { return this.export() }
|
||||
|
||||
/** @returns A new, untyped array (serializable) containing the values of this rectangle. */
|
||||
export(): [number, number, number, number] {
|
||||
return [this[0], this[1], this[2], this[3]]
|
||||
}
|
||||
|
||||
/** Draws a debug outline of this rectangle. */
|
||||
_drawDebug(ctx: CanvasRenderingContext2D, colour = "red") {
|
||||
const { strokeStyle, lineWidth } = ctx
|
||||
try {
|
||||
ctx.strokeStyle = colour
|
||||
ctx.lineWidth = 0.5
|
||||
ctx.beginPath()
|
||||
ctx.strokeRect(this[0], this[1], this[2], this[3])
|
||||
} finally {
|
||||
ctx.strokeStyle = strokeStyle
|
||||
ctx.lineWidth = lineWidth
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -214,6 +214,12 @@ export type ReadOnlyPoint =
|
||||
| ReadOnlyTypedArray<Float32Array>
|
||||
| ReadOnlyTypedArray<Float64Array>
|
||||
|
||||
/** A size represented as `[width, height]` that will not be modified */
|
||||
export type ReadOnlySize =
|
||||
| readonly [width: number, height: number]
|
||||
| ReadOnlyTypedArray<Float32Array>
|
||||
| ReadOnlyTypedArray<Float64Array>
|
||||
|
||||
/** A rectangle starting at top-left coordinates `[x, y, width, height]` that will not be modified */
|
||||
export type ReadOnlyRect =
|
||||
| readonly [x: number, y: number, width: number, height: number]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BaseWidget, type WidgetEventOptions } from "./BaseWidget"
|
||||
import { BaseWidget, type DrawWidgetOptions, type WidgetEventOptions } from "./BaseWidget"
|
||||
|
||||
/**
|
||||
* Base class for widgets that have increment and decrement buttons.
|
||||
@@ -28,13 +28,11 @@ export abstract class BaseSteppedWidget extends BaseWidget {
|
||||
/**
|
||||
* Draw the arrow buttons for the widget
|
||||
* @param ctx The canvas rendering context
|
||||
* @param margin The margin of the widget
|
||||
* @param y The y position of the widget
|
||||
* @param width The width of the widget
|
||||
*/
|
||||
drawArrowButtons(ctx: CanvasRenderingContext2D, margin: number, y: number, width: number) {
|
||||
const { height, text_color, disabledTextColor } = this
|
||||
const { arrowMargin, arrowWidth } = BaseWidget
|
||||
drawArrowButtons(ctx: CanvasRenderingContext2D, width: number) {
|
||||
const { height, text_color, disabledTextColor, y } = this
|
||||
const { arrowMargin, arrowWidth, margin } = BaseWidget
|
||||
const arrowTipX = margin + arrowMargin
|
||||
const arrowInnerX = arrowTipX + arrowWidth
|
||||
|
||||
@@ -54,4 +52,19 @@ export abstract class BaseSteppedWidget extends BaseWidget {
|
||||
ctx.lineTo(width - arrowInnerX, y + height - 5)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
override drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions) {
|
||||
// Store original context attributes
|
||||
const { fillStyle, strokeStyle, textAlign } = ctx
|
||||
|
||||
this.drawWidgetShape(ctx, options)
|
||||
if (options.showText) {
|
||||
if (!this.computedDisabled) this.drawArrowButtons(ctx, options.width)
|
||||
|
||||
this.drawTruncatingText({ ctx, width: options.width })
|
||||
}
|
||||
|
||||
// Restore original context attributes
|
||||
Object.assign(ctx, { textAlign, strokeStyle, fillStyle })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,27 @@ import type { CanvasPointer, LGraphCanvas, LGraphNode, Size } from "@/litegraph"
|
||||
import type { CanvasMouseEvent, CanvasPointerEvent } from "@/types/events"
|
||||
import type { IBaseWidget, IWidget, IWidgetOptions, TWidgetType, TWidgetValue } from "@/types/widgets"
|
||||
|
||||
import { drawTextInArea } from "@/draw"
|
||||
import { Rectangle } from "@/infrastructure/Rectangle"
|
||||
import { Point } from "@/interfaces"
|
||||
import { LiteGraph } from "@/litegraph"
|
||||
|
||||
export interface DrawWidgetOptions {
|
||||
/** The width of the node where this widget will be displayed. */
|
||||
width: number
|
||||
/** Synonym for "low quality". */
|
||||
showText?: boolean
|
||||
}
|
||||
|
||||
export interface DrawTruncatingTextOptions extends DrawWidgetOptions {
|
||||
/** The canvas context to draw the text on. */
|
||||
ctx: CanvasRenderingContext2D
|
||||
/** The amount of padding to add to the left of the text. */
|
||||
leftPadding?: number
|
||||
/** The amount of padding to add to the right of the text. */
|
||||
rightPadding?: number
|
||||
}
|
||||
|
||||
export interface WidgetEventOptions {
|
||||
e: CanvasMouseEvent
|
||||
node: LGraphNode
|
||||
@@ -25,6 +38,8 @@ export abstract class BaseWidget implements IBaseWidget {
|
||||
static arrowWidth = 10
|
||||
/** Absolute minimum display width of widget values */
|
||||
static minValueWidth = 42
|
||||
/** Minimum gap between label and value */
|
||||
static labelValueGap = 5
|
||||
|
||||
linkedWidgets?: IWidget[]
|
||||
name: string
|
||||
@@ -90,6 +105,18 @@ export abstract class BaseWidget implements IBaseWidget {
|
||||
return LiteGraph.WIDGET_DISABLED_TEXT_COLOR
|
||||
}
|
||||
|
||||
get displayName() {
|
||||
return this.label || this.name
|
||||
}
|
||||
|
||||
get displayValue(): string {
|
||||
return String(this.value)
|
||||
}
|
||||
|
||||
get labelBaseline() {
|
||||
return this.y + this.height * 0.7
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the widget
|
||||
* @param ctx The canvas context
|
||||
@@ -99,6 +126,95 @@ export abstract class BaseWidget implements IBaseWidget {
|
||||
*/
|
||||
abstract drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void
|
||||
|
||||
/**
|
||||
* Draws the standard widget shape - elongated capsule. The path of the widget shape is not
|
||||
* cleared, and may be used for further drawing.
|
||||
* @param ctx The canvas context
|
||||
* @param options The options for drawing the widget
|
||||
* @remarks Leaves {@link ctx} dirty.
|
||||
*/
|
||||
protected drawWidgetShape(ctx: CanvasRenderingContext2D, { width, showText }: DrawWidgetOptions) {
|
||||
const { height, y } = this
|
||||
const { margin } = BaseWidget
|
||||
|
||||
ctx.textAlign = "left"
|
||||
ctx.strokeStyle = this.outline_color
|
||||
ctx.fillStyle = this.background_color
|
||||
ctx.beginPath()
|
||||
|
||||
if (showText) {
|
||||
ctx.roundRect(margin, y, width - margin * 2, height, [height * 0.5])
|
||||
} else {
|
||||
ctx.rect(margin, y, width - margin * 2, height)
|
||||
}
|
||||
ctx.fill()
|
||||
if (showText && !this.computedDisabled) ctx.stroke()
|
||||
}
|
||||
|
||||
/**
|
||||
* A shared routine for drawing a label and value as text, truncated
|
||||
* if they exceed the available width.
|
||||
*/
|
||||
protected drawTruncatingText({
|
||||
ctx,
|
||||
width,
|
||||
leftPadding = 5,
|
||||
rightPadding = 20,
|
||||
}: DrawTruncatingTextOptions) {
|
||||
const { height, y } = this
|
||||
const { margin } = BaseWidget
|
||||
|
||||
// Measure label and value
|
||||
const { displayName, displayValue } = this
|
||||
const labelWidth = ctx.measureText(displayName).width
|
||||
const valueWidth = ctx.measureText(displayValue).width
|
||||
|
||||
const gap = BaseWidget.labelValueGap
|
||||
const x = margin * 2 + leftPadding
|
||||
|
||||
const totalWidth = width - x - 2 * margin - rightPadding
|
||||
const requiredWidth = labelWidth + gap + valueWidth
|
||||
|
||||
const area = new Rectangle(x, y, totalWidth, height * 0.7)
|
||||
|
||||
ctx.fillStyle = this.secondary_text_color
|
||||
|
||||
if (requiredWidth <= totalWidth) {
|
||||
// Draw label & value normally
|
||||
drawTextInArea({ ctx, text: displayName, area, align: "left" })
|
||||
} else if (LiteGraph.truncateWidgetTextEvenly) {
|
||||
// Label + value will not fit - scale evenly to fit
|
||||
const scale = (totalWidth - gap) / (requiredWidth - gap)
|
||||
area.width = labelWidth * scale
|
||||
|
||||
drawTextInArea({ ctx, text: displayName, area, align: "left" })
|
||||
|
||||
// Move the area to the right to render the value
|
||||
area.right = x + totalWidth
|
||||
area.setWidthRightAnchored(valueWidth * scale)
|
||||
} else if (LiteGraph.truncateWidgetValuesFirst) {
|
||||
// Label + value will not fit - use legacy scaling of value first
|
||||
const cappedLabelWidth = Math.min(labelWidth, totalWidth)
|
||||
|
||||
area.width = cappedLabelWidth
|
||||
drawTextInArea({ ctx, text: displayName, area, align: "left" })
|
||||
|
||||
area.right = x + totalWidth
|
||||
area.setWidthRightAnchored(Math.max(totalWidth - gap - cappedLabelWidth, 0))
|
||||
} else {
|
||||
// Label + value will not fit - scale label first
|
||||
const cappedValueWidth = Math.min(valueWidth, totalWidth)
|
||||
|
||||
area.width = Math.max(totalWidth - gap - cappedValueWidth, 0)
|
||||
drawTextInArea({ ctx, text: displayName, area, align: "left" })
|
||||
|
||||
area.right = x + totalWidth
|
||||
area.setWidthRightAnchored(cappedValueWidth)
|
||||
}
|
||||
ctx.fillStyle = this.text_color
|
||||
drawTextInArea({ ctx, text: displayValue, area, align: "right" })
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the click event for the widget
|
||||
* @param options The options for handling the click event
|
||||
|
||||
@@ -20,16 +20,8 @@ export class BooleanWidget extends BaseWidget implements IBooleanWidget {
|
||||
const { height, y } = this
|
||||
const { margin } = BaseWidget
|
||||
|
||||
ctx.textAlign = "left"
|
||||
ctx.strokeStyle = this.outline_color
|
||||
ctx.fillStyle = this.background_color
|
||||
ctx.beginPath()
|
||||
this.drawWidgetShape(ctx, { width, showText })
|
||||
|
||||
if (showText)
|
||||
ctx.roundRect(margin, y, width - margin * 2, height, [height * 0.5])
|
||||
else ctx.rect(margin, y, width - margin * 2, height)
|
||||
ctx.fill()
|
||||
if (showText && !this.computedDisabled) ctx.stroke()
|
||||
ctx.fillStyle = this.value ? "#89A" : "#333"
|
||||
ctx.beginPath()
|
||||
ctx.arc(
|
||||
@@ -40,22 +32,28 @@ export class BooleanWidget extends BaseWidget implements IBooleanWidget {
|
||||
Math.PI * 2,
|
||||
)
|
||||
ctx.fill()
|
||||
|
||||
if (showText) {
|
||||
ctx.fillStyle = this.secondary_text_color
|
||||
const label = this.label || this.name
|
||||
if (label != null) {
|
||||
ctx.fillText(label, margin * 2, y + height * 0.7)
|
||||
}
|
||||
ctx.fillStyle = this.value ? this.text_color : this.secondary_text_color
|
||||
ctx.textAlign = "right"
|
||||
ctx.fillText(
|
||||
this.value ? this.options.on || "true" : this.options.off || "false",
|
||||
width - 40,
|
||||
y + height * 0.7,
|
||||
)
|
||||
this.drawLabel(ctx, margin * 2)
|
||||
this.drawValue(ctx, width - 40)
|
||||
}
|
||||
}
|
||||
|
||||
drawLabel(ctx: CanvasRenderingContext2D, x: number): void {
|
||||
// Draw label
|
||||
ctx.fillStyle = this.secondary_text_color
|
||||
const { displayName } = this
|
||||
if (displayName) ctx.fillText(displayName, x, this.labelBaseline)
|
||||
}
|
||||
|
||||
drawValue(ctx: CanvasRenderingContext2D, x: number): void {
|
||||
// Draw value
|
||||
ctx.fillStyle = this.value ? this.text_color : this.secondary_text_color
|
||||
ctx.textAlign = "right"
|
||||
const value = this.value ? this.options.on || "true" : this.options.off || "false"
|
||||
ctx.fillText(value, x, this.labelBaseline)
|
||||
}
|
||||
|
||||
override onClick(options: WidgetEventOptions) {
|
||||
this.setValue(!this.value, options)
|
||||
}
|
||||
|
||||
@@ -25,9 +25,7 @@ export class ButtonWidget extends BaseWidget implements IButtonWidget {
|
||||
showText = true,
|
||||
}: DrawWidgetOptions) {
|
||||
// Store original context attributes
|
||||
const originalTextAlign = ctx.textAlign
|
||||
const originalStrokeStyle = ctx.strokeStyle
|
||||
const originalFillStyle = ctx.fillStyle
|
||||
const { fillStyle, strokeStyle, textAlign } = ctx
|
||||
|
||||
const { height, y } = this
|
||||
const { margin } = BaseWidget
|
||||
@@ -47,20 +45,16 @@ export class ButtonWidget extends BaseWidget implements IButtonWidget {
|
||||
}
|
||||
|
||||
// Draw button text
|
||||
if (showText) {
|
||||
ctx.textAlign = "center"
|
||||
ctx.fillStyle = this.text_color
|
||||
ctx.fillText(
|
||||
this.label || this.name || "",
|
||||
width * 0.5,
|
||||
y + height * 0.7,
|
||||
)
|
||||
}
|
||||
if (showText) this.drawLabel(ctx, width * 0.5)
|
||||
|
||||
// Restore original context attributes
|
||||
ctx.textAlign = originalTextAlign
|
||||
ctx.strokeStyle = originalStrokeStyle
|
||||
ctx.fillStyle = originalFillStyle
|
||||
Object.assign(ctx, { textAlign, strokeStyle, fillStyle })
|
||||
}
|
||||
|
||||
drawLabel(ctx: CanvasRenderingContext2D, x: number): void {
|
||||
ctx.textAlign = "center"
|
||||
ctx.fillStyle = this.text_color
|
||||
ctx.fillText(this.displayName, x, this.y + this.height * 0.7)
|
||||
}
|
||||
|
||||
override onClick({ e, node, canvas }: WidgetEventOptions) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { WidgetEventOptions } from "./BaseWidget"
|
||||
import type { LGraphNode } from "@/LGraphNode"
|
||||
import type { IComboWidget, IWidgetOptions } from "@/types/widgets"
|
||||
|
||||
@@ -5,7 +6,6 @@ import { clamp, LiteGraph } from "@/litegraph"
|
||||
import { warnDeprecated } from "@/utils/feedback"
|
||||
|
||||
import { BaseSteppedWidget } from "./BaseSteppedWidget"
|
||||
import { BaseWidget, type DrawWidgetOptions, type WidgetEventOptions } from "./BaseWidget"
|
||||
|
||||
/**
|
||||
* This is used as an (invalid) assertion to resolve issues with legacy duck-typed values.
|
||||
@@ -13,7 +13,7 @@ import { BaseWidget, type DrawWidgetOptions, type WidgetEventOptions } from "./B
|
||||
* Function style in use by:
|
||||
* https://github.com/kijai/ComfyUI-KJNodes/blob/c3dc82108a2a86c17094107ead61d63f8c76200e/web/js/setgetnodes.js#L401-L404
|
||||
*/
|
||||
type Values = string[] | Record<string, string> | ((widget: ComboWidget, node: LGraphNode) => string[])
|
||||
type Values = string[] | Record<string, string> | ((widget?: ComboWidget, node?: LGraphNode) => string[])
|
||||
|
||||
function toArray(values: Values): string[] {
|
||||
return Array.isArray(values) ? values : Object.keys(values)
|
||||
@@ -26,6 +26,18 @@ export class ComboWidget extends BaseSteppedWidget implements IComboWidget {
|
||||
// @ts-expect-error Workaround for Record<string, string> not being typed in IWidgetOptions
|
||||
declare options: Omit<IWidgetOptions<string>, "values"> & { values: Values }
|
||||
|
||||
override get displayValue() {
|
||||
const { values: rawValues } = this.options
|
||||
if (rawValues) {
|
||||
const values = typeof rawValues === "function" ? rawValues() : rawValues
|
||||
|
||||
if (values && !Array.isArray(values)) {
|
||||
return values[this.value]
|
||||
}
|
||||
}
|
||||
return typeof this.value === "number" ? String(this.value) : this.value
|
||||
}
|
||||
|
||||
constructor(widget: IComboWidget) {
|
||||
super(widget)
|
||||
this.type = "combo"
|
||||
@@ -102,107 +114,6 @@ export class ComboWidget extends BaseSteppedWidget implements IComboWidget {
|
||||
this.setValue(value, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the widget
|
||||
* @param ctx The canvas context
|
||||
* @param options The options for drawing the widget
|
||||
*/
|
||||
override drawWidget(ctx: CanvasRenderingContext2D, {
|
||||
width,
|
||||
showText = true,
|
||||
}: DrawWidgetOptions) {
|
||||
// Store original context attributes
|
||||
const originalTextAlign = ctx.textAlign
|
||||
const originalStrokeStyle = ctx.strokeStyle
|
||||
const originalFillStyle = ctx.fillStyle
|
||||
|
||||
const { height, y } = this
|
||||
const { margin } = BaseWidget
|
||||
|
||||
ctx.textAlign = "left"
|
||||
ctx.strokeStyle = this.outline_color
|
||||
ctx.fillStyle = this.background_color
|
||||
ctx.beginPath()
|
||||
|
||||
if (showText)
|
||||
ctx.roundRect(margin, y, width - margin * 2, height, [height * 0.5])
|
||||
else
|
||||
ctx.rect(margin, y, width - margin * 2, height)
|
||||
ctx.fill()
|
||||
|
||||
if (showText) {
|
||||
if (!this.computedDisabled) {
|
||||
ctx.stroke()
|
||||
this.drawArrowButtons(ctx, margin, y, width)
|
||||
}
|
||||
|
||||
// Draw label
|
||||
ctx.fillStyle = this.secondary_text_color
|
||||
const label = this.label || this.name
|
||||
if (label != null) {
|
||||
ctx.fillText(label, margin * 2 + 5, y + height * 0.7)
|
||||
}
|
||||
|
||||
// Draw value
|
||||
ctx.fillStyle = this.text_color
|
||||
ctx.textAlign = "right"
|
||||
|
||||
let displayValue = typeof this.value === "number" ? String(this.value) : this.value
|
||||
if (this.options.values) {
|
||||
let values = this.options.values
|
||||
if (typeof values === "function") {
|
||||
// @ts-expect-error handle () => string[] type that is not typed in IWidgetOptions
|
||||
values = values()
|
||||
}
|
||||
if (values && !Array.isArray(values)) {
|
||||
displayValue = values[this.value]
|
||||
}
|
||||
}
|
||||
|
||||
const labelWidth = ctx.measureText(label || "").width + margin * 2
|
||||
const inputWidth = width - margin * 4
|
||||
const availableWidth = inputWidth - labelWidth
|
||||
const textWidth = ctx.measureText(displayValue).width
|
||||
|
||||
if (textWidth > availableWidth) {
|
||||
const ELLIPSIS = "\u2026"
|
||||
const ellipsisWidth = ctx.measureText(ELLIPSIS).width
|
||||
const charWidthAvg = ctx.measureText("a").width
|
||||
|
||||
if (availableWidth <= ellipsisWidth) {
|
||||
// One dot leader
|
||||
displayValue = "\u2024"
|
||||
} else {
|
||||
displayValue = `${displayValue}`
|
||||
const overflowWidth = (textWidth + ellipsisWidth) - availableWidth
|
||||
|
||||
// Only first 3 characters need to be measured precisely
|
||||
if (overflowWidth + charWidthAvg * 3 > availableWidth) {
|
||||
const preciseRange = availableWidth + charWidthAvg * 3
|
||||
const preTruncateCt = Math.floor((preciseRange - ellipsisWidth) / charWidthAvg)
|
||||
displayValue = displayValue.substr(0, preTruncateCt)
|
||||
}
|
||||
|
||||
while (ctx.measureText(displayValue).width + ellipsisWidth > availableWidth) {
|
||||
displayValue = displayValue.substr(0, displayValue.length - 1)
|
||||
}
|
||||
displayValue += ELLIPSIS
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillText(
|
||||
displayValue,
|
||||
width - margin * 2 - 20,
|
||||
y + height * 0.7,
|
||||
)
|
||||
}
|
||||
|
||||
// Restore original context attributes
|
||||
ctx.textAlign = originalTextAlign
|
||||
ctx.strokeStyle = originalStrokeStyle
|
||||
ctx.fillStyle = originalFillStyle
|
||||
}
|
||||
|
||||
override onClick({ e, node, canvas }: WidgetEventOptions) {
|
||||
const x = e.canvasX - node.pos[0]
|
||||
const width = this.width || node.size[0]
|
||||
|
||||
@@ -48,9 +48,7 @@ export class KnobWidget extends BaseWidget implements IKnobWidget {
|
||||
}: DrawWidgetOptions,
|
||||
): void {
|
||||
// Store original context attributes
|
||||
const originalTextAlign = ctx.textAlign
|
||||
const originalStrokeStyle = ctx.strokeStyle
|
||||
const originalFillStyle = ctx.fillStyle
|
||||
const { fillStyle, strokeStyle, textAlign } = ctx
|
||||
|
||||
const { y } = this
|
||||
const { margin } = BaseWidget
|
||||
@@ -190,9 +188,7 @@ export class KnobWidget extends BaseWidget implements IKnobWidget {
|
||||
}
|
||||
|
||||
// Restore original context attributes
|
||||
ctx.textAlign = originalTextAlign
|
||||
ctx.strokeStyle = originalStrokeStyle
|
||||
ctx.fillStyle = originalFillStyle
|
||||
Object.assign(ctx, { textAlign, strokeStyle, fillStyle })
|
||||
}
|
||||
|
||||
onClick(): void {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { WidgetEventOptions } from "./BaseWidget"
|
||||
import type { INumericWidget, IWidgetOptions } from "@/types/widgets"
|
||||
|
||||
import { getWidgetStep } from "@/utils/widget"
|
||||
|
||||
import { BaseSteppedWidget } from "./BaseSteppedWidget"
|
||||
import { BaseWidget, type DrawWidgetOptions, type WidgetEventOptions } from "./BaseWidget"
|
||||
|
||||
export class NumberWidget extends BaseSteppedWidget implements INumericWidget {
|
||||
// INumberWidget properties
|
||||
@@ -11,6 +11,14 @@ export class NumberWidget extends BaseSteppedWidget implements INumericWidget {
|
||||
declare value: number
|
||||
declare options: IWidgetOptions<number>
|
||||
|
||||
override get displayValue() {
|
||||
return Number(this.value).toFixed(
|
||||
this.options.precision !== undefined
|
||||
? this.options.precision
|
||||
: 3,
|
||||
)
|
||||
}
|
||||
|
||||
constructor(widget: INumericWidget) {
|
||||
super(widget)
|
||||
this.type = "number"
|
||||
@@ -46,67 +54,6 @@ export class NumberWidget extends BaseSteppedWidget implements INumericWidget {
|
||||
super.setValue(newValue, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the widget
|
||||
* @param ctx The canvas context
|
||||
* @param options The options for drawing the widget
|
||||
*/
|
||||
override drawWidget(ctx: CanvasRenderingContext2D, {
|
||||
width,
|
||||
showText = true,
|
||||
}: DrawWidgetOptions) {
|
||||
// Store original context attributes
|
||||
const originalTextAlign = ctx.textAlign
|
||||
const originalStrokeStyle = ctx.strokeStyle
|
||||
const originalFillStyle = ctx.fillStyle
|
||||
|
||||
const { height, y } = this
|
||||
const { margin } = BaseWidget
|
||||
|
||||
ctx.textAlign = "left"
|
||||
ctx.strokeStyle = this.outline_color
|
||||
ctx.fillStyle = this.background_color
|
||||
ctx.beginPath()
|
||||
|
||||
if (showText)
|
||||
ctx.roundRect(margin, y, width - margin * 2, height, [height * 0.5])
|
||||
else
|
||||
ctx.rect(margin, y, width - margin * 2, height)
|
||||
ctx.fill()
|
||||
|
||||
if (showText) {
|
||||
if (!this.computedDisabled) {
|
||||
ctx.stroke()
|
||||
this.drawArrowButtons(ctx, margin, y, width)
|
||||
}
|
||||
|
||||
// Draw label
|
||||
ctx.fillStyle = this.secondary_text_color
|
||||
const label = this.label || this.name
|
||||
if (label != null) {
|
||||
ctx.fillText(label, margin * 2 + 5, y + height * 0.7)
|
||||
}
|
||||
|
||||
// Draw value
|
||||
ctx.fillStyle = this.text_color
|
||||
ctx.textAlign = "right"
|
||||
ctx.fillText(
|
||||
Number(this.value).toFixed(
|
||||
this.options.precision !== undefined
|
||||
? this.options.precision
|
||||
: 3,
|
||||
),
|
||||
width - margin * 2 - 20,
|
||||
y + height * 0.7,
|
||||
)
|
||||
}
|
||||
|
||||
// Restore original context attributes
|
||||
ctx.textAlign = originalTextAlign
|
||||
ctx.strokeStyle = originalStrokeStyle
|
||||
ctx.fillStyle = originalFillStyle
|
||||
}
|
||||
|
||||
override onClick({ e, node, canvas }: WidgetEventOptions) {
|
||||
const x = e.canvasX - node.pos[0]
|
||||
const width = this.width || node.size[0]
|
||||
|
||||
@@ -29,9 +29,7 @@ export class SliderWidget extends BaseWidget implements ISliderWidget {
|
||||
showText = true,
|
||||
}: DrawWidgetOptions) {
|
||||
// Store original context attributes
|
||||
const originalTextAlign = ctx.textAlign
|
||||
const originalStrokeStyle = ctx.strokeStyle
|
||||
const originalFillStyle = ctx.fillStyle
|
||||
const { fillStyle, strokeStyle, textAlign } = ctx
|
||||
|
||||
const { height, y } = this
|
||||
const { margin } = BaseWidget
|
||||
@@ -81,9 +79,7 @@ export class SliderWidget extends BaseWidget implements ISliderWidget {
|
||||
}
|
||||
|
||||
// Restore original context attributes
|
||||
ctx.textAlign = originalTextAlign
|
||||
ctx.strokeStyle = originalStrokeStyle
|
||||
ctx.fillStyle = originalFillStyle
|
||||
Object.assign(ctx, { textAlign, strokeStyle, fillStyle })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,54 +24,16 @@ export class TextWidget extends BaseWidget implements IStringWidget {
|
||||
showText = true,
|
||||
}: DrawWidgetOptions) {
|
||||
// Store original context attributes
|
||||
const originalTextAlign = ctx.textAlign
|
||||
const originalStrokeStyle = ctx.strokeStyle
|
||||
const originalFillStyle = ctx.fillStyle
|
||||
const { fillStyle, strokeStyle, textAlign } = ctx
|
||||
|
||||
const { height, y } = this
|
||||
const { margin } = BaseWidget
|
||||
|
||||
ctx.textAlign = "left"
|
||||
ctx.strokeStyle = this.outline_color
|
||||
ctx.fillStyle = this.background_color
|
||||
ctx.beginPath()
|
||||
|
||||
if (showText)
|
||||
ctx.roundRect(margin, y, width - margin * 2, height, [height * 0.5])
|
||||
else
|
||||
ctx.rect(margin, y, width - margin * 2, height)
|
||||
ctx.fill()
|
||||
this.drawWidgetShape(ctx, { width, showText })
|
||||
|
||||
if (showText) {
|
||||
if (!this.computedDisabled) ctx.stroke()
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.rect(margin, y, width - margin * 2, height)
|
||||
ctx.clip()
|
||||
|
||||
// Draw label
|
||||
ctx.fillStyle = this.secondary_text_color
|
||||
const label = this.label || this.name
|
||||
if (label != null) {
|
||||
ctx.fillText(label, margin * 2, y + height * 0.7)
|
||||
}
|
||||
|
||||
// Draw value
|
||||
ctx.fillStyle = this.text_color
|
||||
ctx.textAlign = "right"
|
||||
ctx.fillText(
|
||||
// 30 chars max
|
||||
String(this.value).substr(0, 30),
|
||||
width - margin * 2,
|
||||
y + height * 0.7,
|
||||
)
|
||||
ctx.restore()
|
||||
this.drawTruncatingText({ ctx, width, leftPadding: 0, rightPadding: 0 })
|
||||
}
|
||||
|
||||
// Restore original context attributes
|
||||
ctx.textAlign = originalTextAlign
|
||||
ctx.strokeStyle = originalStrokeStyle
|
||||
ctx.fillStyle = originalFillStyle
|
||||
Object.assign(ctx, { textAlign, strokeStyle, fillStyle })
|
||||
}
|
||||
|
||||
override onClick({ e, node, canvas }: WidgetEventOptions) {
|
||||
|
||||
@@ -188,6 +188,8 @@ LiteGraphGlobal {
|
||||
"snap_highlights_node": true,
|
||||
"snaps_for_comfy": true,
|
||||
"throw_errors": true,
|
||||
"truncateWidgetTextEvenly": false,
|
||||
"truncateWidgetValuesFirst": false,
|
||||
"use_uuids": false,
|
||||
"uuidv4": [Function],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user