From e4f43d5cc4e1b3e31b1ade96749351f7773cfff4 Mon Sep 17 00:00:00 2001
From: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
Date: Wed, 28 Jan 2026 17:55:12 -0800
Subject: [PATCH] Add color picker widget using native HTML5 input element
(#8384)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Adds support for the color picker widget when using Litegraph nodes, it
is already supported in Nodes 2.0
## Changes
- **What**: Add custom drawing of color picker widget using HTML 5
native color input element
- This enables us to add a core node using the COLOR type that works on
both legacy and Nodes 2.0
## Screenshots (if applicable)
Chrome Windows:
Firefox Windows:
Nodes 2.0 (unchanged):
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8384-Add-color-picker-widget-using-native-HTML5-input-element-2f76d73d365081c69fe2f39f01fff539)
by [Unito](https://www.unito.io)
---
.../litegraph/src/widgets/ColorWidget.test.ts | 261 ++++++++++++++++++
src/lib/litegraph/src/widgets/ColorWidget.ts | 90 ++++--
2 files changed, 325 insertions(+), 26 deletions(-)
create mode 100644 src/lib/litegraph/src/widgets/ColorWidget.test.ts
diff --git a/src/lib/litegraph/src/widgets/ColorWidget.test.ts b/src/lib/litegraph/src/widgets/ColorWidget.test.ts
new file mode 100644
index 000000000..f06d059a0
--- /dev/null
+++ b/src/lib/litegraph/src/widgets/ColorWidget.test.ts
@@ -0,0 +1,261 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
+import type { LGraphNode as LGraphNodeType } from '@/lib/litegraph/src/litegraph'
+import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
+import type { IColorWidget } from '@/lib/litegraph/src/types/widgets'
+import type { ColorWidget as ColorWidgetType } from '@/lib/litegraph/src/widgets/ColorWidget'
+
+type LGraphCanvasType = InstanceType
+
+function createMockWidgetConfig(
+ overrides: Partial = {}
+): IColorWidget {
+ return {
+ type: 'color',
+ name: 'test_color',
+ value: '#ff0000',
+ options: {},
+ y: 0,
+ ...overrides
+ }
+}
+
+function createMockCanvas(): LGraphCanvasType {
+ return {
+ setDirty: vi.fn()
+ } as Partial as LGraphCanvasType
+}
+
+function createMockEvent(clientX = 100, clientY = 200): CanvasPointerEvent {
+ return { clientX, clientY } as CanvasPointerEvent
+}
+
+describe('ColorWidget', () => {
+ let node: LGraphNodeType
+ let widget: ColorWidgetType
+ let mockCanvas: LGraphCanvasType
+ let mockEvent: CanvasPointerEvent
+ let ColorWidget: typeof ColorWidgetType
+ let LGraphNode: typeof LGraphNodeType
+
+ beforeEach(async () => {
+ vi.clearAllMocks()
+ vi.useFakeTimers()
+ // Reset modules to get fresh globalColorInput state
+ vi.resetModules()
+
+ const litegraph = await import('@/lib/litegraph/src/litegraph')
+ LGraphNode = litegraph.LGraphNode
+
+ const colorWidgetModule =
+ await import('@/lib/litegraph/src/widgets/ColorWidget')
+ ColorWidget = colorWidgetModule.ColorWidget
+
+ node = new LGraphNode('TestNode')
+ mockCanvas = createMockCanvas()
+ mockEvent = createMockEvent()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ document
+ .querySelectorAll('input[type="color"]')
+ .forEach((el) => el.remove())
+ })
+
+ describe('onClick', () => {
+ it('should create a color input and append it to document body', () => {
+ widget = new ColorWidget(createMockWidgetConfig(), node)
+
+ widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
+
+ const input = document.querySelector(
+ 'input[type="color"]'
+ ) as HTMLInputElement
+ expect(input).toBeTruthy()
+ expect(input.parentElement).toBe(document.body)
+ })
+
+ it('should set input value from widget value', () => {
+ widget = new ColorWidget(
+ createMockWidgetConfig({ value: '#00ff00' }),
+ node
+ )
+
+ widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
+
+ const input = document.querySelector(
+ 'input[type="color"]'
+ ) as HTMLInputElement
+ expect(input.value).toBe('#00ff00')
+ })
+
+ it('should default to #000000 when widget value is empty', () => {
+ widget = new ColorWidget(createMockWidgetConfig({ value: '' }), node)
+
+ widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
+
+ const input = document.querySelector(
+ 'input[type="color"]'
+ ) as HTMLInputElement
+ expect(input.value).toBe('#000000')
+ })
+
+ it('should position input at click coordinates', () => {
+ widget = new ColorWidget(createMockWidgetConfig(), node)
+ const event = createMockEvent(150, 250)
+
+ widget.onClick({ e: event, node, canvas: mockCanvas })
+
+ const input = document.querySelector(
+ 'input[type="color"]'
+ ) as HTMLInputElement
+ expect(input.style.left).toBe('150px')
+ expect(input.style.top).toBe('250px')
+ })
+
+ it('should click the input on next animation frame', () => {
+ widget = new ColorWidget(createMockWidgetConfig(), node)
+
+ widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
+
+ const input = document.querySelector(
+ 'input[type="color"]'
+ ) as HTMLInputElement
+ const clickSpy = vi.spyOn(input, 'click')
+
+ expect(clickSpy).not.toHaveBeenCalled()
+ vi.runAllTimers()
+ expect(clickSpy).toHaveBeenCalled()
+ })
+
+ it('should reuse the same input element on subsequent clicks', () => {
+ widget = new ColorWidget(createMockWidgetConfig(), node)
+
+ widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
+ const firstInput = document.querySelector('input[type="color"]')
+
+ widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
+ const secondInput = document.querySelector('input[type="color"]')
+
+ expect(firstInput).toBe(secondInput)
+ expect(document.querySelectorAll('input[type="color"]').length).toBe(1)
+ })
+
+ it('should update input value when clicking with different widget values', () => {
+ const widget1 = new ColorWidget(
+ createMockWidgetConfig({ value: '#ff0000' }),
+ node
+ )
+ const widget2 = new ColorWidget(
+ createMockWidgetConfig({ value: '#0000ff' }),
+ node
+ )
+
+ widget1.onClick({ e: mockEvent, node, canvas: mockCanvas })
+ const input = document.querySelector(
+ 'input[type="color"]'
+ ) as HTMLInputElement
+ expect(input.value).toBe('#ff0000')
+
+ widget2.onClick({ e: mockEvent, node, canvas: mockCanvas })
+ expect(input.value).toBe('#0000ff')
+ })
+ })
+
+ describe('onChange', () => {
+ it('should call setValue when color input changes', () => {
+ widget = new ColorWidget(
+ createMockWidgetConfig({ value: '#ff0000' }),
+ node
+ )
+ const setValueSpy = vi.spyOn(widget, 'setValue')
+
+ widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
+
+ const input = document.querySelector(
+ 'input[type="color"]'
+ ) as HTMLInputElement
+ input.value = '#00ff00'
+ input.dispatchEvent(new Event('change'))
+
+ expect(setValueSpy).toHaveBeenCalledWith('#00ff00', {
+ e: mockEvent,
+ node,
+ canvas: mockCanvas
+ })
+ })
+
+ it('should call canvas.setDirty after value change', () => {
+ widget = new ColorWidget(createMockWidgetConfig(), node)
+
+ widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
+
+ const input = document.querySelector(
+ 'input[type="color"]'
+ ) as HTMLInputElement
+ input.value = '#00ff00'
+ input.dispatchEvent(new Event('change'))
+
+ expect(mockCanvas.setDirty).toHaveBeenCalledWith(true)
+ })
+
+ it('should remove change listener after firing once', () => {
+ widget = new ColorWidget(createMockWidgetConfig(), node)
+ const setValueSpy = vi.spyOn(widget, 'setValue')
+
+ widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
+
+ const input = document.querySelector(
+ 'input[type="color"]'
+ ) as HTMLInputElement
+
+ input.value = '#00ff00'
+ input.dispatchEvent(new Event('change'))
+ input.value = '#0000ff'
+ input.dispatchEvent(new Event('change'))
+
+ // Should only be called once despite two change events
+ expect(setValueSpy).toHaveBeenCalledTimes(1)
+ expect(setValueSpy).toHaveBeenCalledWith('#00ff00', expect.any(Object))
+ })
+
+ it('should register new change listener on subsequent onClick', () => {
+ widget = new ColorWidget(createMockWidgetConfig(), node)
+ const setValueSpy = vi.spyOn(widget, 'setValue')
+
+ // First click and change
+ widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
+ const input = document.querySelector(
+ 'input[type="color"]'
+ ) as HTMLInputElement
+ input.value = '#00ff00'
+ input.dispatchEvent(new Event('change'))
+
+ // Second click and change
+ widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
+ input.value = '#0000ff'
+ input.dispatchEvent(new Event('change'))
+
+ expect(setValueSpy).toHaveBeenCalledTimes(2)
+ expect(setValueSpy).toHaveBeenNthCalledWith(
+ 1,
+ '#00ff00',
+ expect.any(Object)
+ )
+ expect(setValueSpy).toHaveBeenNthCalledWith(
+ 2,
+ '#0000ff',
+ expect.any(Object)
+ )
+ })
+ })
+
+ describe('type', () => {
+ it('should have type "color"', () => {
+ widget = new ColorWidget(createMockWidgetConfig(), node)
+ expect(widget.type).toBe('color')
+ })
+ })
+})
diff --git a/src/lib/litegraph/src/widgets/ColorWidget.ts b/src/lib/litegraph/src/widgets/ColorWidget.ts
index f2c50083a..7752706fa 100644
--- a/src/lib/litegraph/src/widgets/ColorWidget.ts
+++ b/src/lib/litegraph/src/widgets/ColorWidget.ts
@@ -1,12 +1,26 @@
-import { t } from '@/i18n'
-
import type { IColorWidget } from '../types/widgets'
-import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
+import { BaseWidget } from './BaseWidget'
+
+// Have one color input to prevent leaking instances
+// Browsers don't seem to fire any events when the color picker is cancelled
+let colorInput: HTMLInputElement | null = null
+
+function getColorInput(): HTMLInputElement {
+ if (!colorInput) {
+ colorInput = document.createElement('input')
+ colorInput.type = 'color'
+ colorInput.style.position = 'absolute'
+ colorInput.style.opacity = '0'
+ colorInput.style.pointerEvents = 'none'
+ colorInput.style.zIndex = '-999'
+ document.body.appendChild(colorInput)
+ }
+ return colorInput
+}
/**
- * Widget for displaying a color picker
- * This is a widget that only has a Vue widgets implementation
+ * Widget for displaying a color picker using native HTML color input
*/
export class ColorWidget
extends BaseWidget
@@ -15,35 +29,59 @@ export class ColorWidget
override type = 'color' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
+ const { fillStyle, strokeStyle, textAlign } = ctx
+
+ this.drawWidgetShape(ctx, options)
+
const { width } = options
- const { y, height } = this
+ const { height, y } = this
+ const { margin } = BaseWidget
- const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
+ const swatchWidth = 40
+ const swatchHeight = height - 6
+ const swatchRadius = swatchHeight / 2
+ const rightPadding = 10
- ctx.fillStyle = this.background_color
- ctx.fillRect(15, y, width - 30, height)
+ // Swatch fixed on the right
+ const swatchX = width - margin - rightPadding - swatchWidth
+ const swatchY = y + 3
- ctx.strokeStyle = this.outline_color
- ctx.strokeRect(15, y, width - 30, height)
+ // Draw color swatch as rounded pill
+ ctx.beginPath()
+ ctx.roundRect(swatchX, swatchY, swatchWidth, swatchHeight, swatchRadius)
+ ctx.fillStyle = this.value || '#000000'
+ ctx.fill()
+ // Draw label on the left
+ ctx.fillStyle = this.secondary_text_color
+ ctx.textAlign = 'left'
+ ctx.fillText(this.displayName, margin * 2 + 5, y + height * 0.7)
+
+ // Draw hex value to the left of swatch
ctx.fillStyle = this.text_color
- ctx.font = '11px monospace'
- ctx.textAlign = 'center'
- ctx.textBaseline = 'middle'
+ ctx.textAlign = 'right'
+ ctx.fillText(this.value || '#000000', swatchX - 8, y + height * 0.7)
- const text = `Color: ${t('widgets.node2only')}`
- ctx.fillText(text, width / 2, y + height / 2)
-
- Object.assign(ctx, {
- fillStyle,
- strokeStyle,
- textAlign,
- textBaseline,
- font
- })
+ Object.assign(ctx, { textAlign, strokeStyle, fillStyle })
}
- onClick(_options: WidgetEventOptions): void {
- // This is a widget that only has a Vue widgets implementation
+ onClick({ e, node, canvas }: WidgetEventOptions): void {
+ const input = getColorInput()
+ input.value = this.value || '#000000'
+ input.style.left = `${e.clientX}px`
+ input.style.top = `${e.clientY}px`
+
+ input.addEventListener(
+ 'change',
+ () => {
+ this.setValue(input.value, { e, node, canvas })
+ canvas.setDirty(true)
+ },
+ { once: true }
+ )
+
+ // Wait for next frame else Chrome doesn't render the color picker at the mouse
+ // Firefox always opens it in top left of window on Windows
+ requestAnimationFrame(() => input.click())
}
}