mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-02 06:19:58 +00:00
Add color picker widget using native HTML5 input element (#8384)
## 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: <img width="743" height="493" alt="image" src="https://github.com/user-attachments/assets/b2e421e0-3a1e-4b72-8856-ae5e40ac0661" /> Firefox Windows: <img width="606" height="447" alt="image" src="https://github.com/user-attachments/assets/27db5552-6ba2-4de0-af26-ea1727808b4b" /> Nodes 2.0 (unchanged): <img width="597" height="258" alt="image" src="https://github.com/user-attachments/assets/8bfcf408-e11b-481e-b78f-208b4db80f05" /> ┆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)
This commit is contained in:
261
src/lib/litegraph/src/widgets/ColorWidget.test.ts
Normal file
261
src/lib/litegraph/src/widgets/ColorWidget.test.ts
Normal file
@@ -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<typeof LGraphCanvas>
|
||||
|
||||
function createMockWidgetConfig(
|
||||
overrides: Partial<IColorWidget> = {}
|
||||
): IColorWidget {
|
||||
return {
|
||||
type: 'color',
|
||||
name: 'test_color',
|
||||
value: '#ff0000',
|
||||
options: {},
|
||||
y: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function createMockCanvas(): LGraphCanvasType {
|
||||
return {
|
||||
setDirty: vi.fn()
|
||||
} as Partial<LGraphCanvasType> 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<IColorWidget>
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user