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()) } }