diff --git a/browser_tests/assets/every_node_color.json b/browser_tests/assets/every_node_color.json new file mode 100644 index 000000000..79c067382 --- /dev/null +++ b/browser_tests/assets/every_node_color.json @@ -0,0 +1,504 @@ +{ + "last_node_id": 13, + "last_link_id": 9, + "nodes": [ + { + "id": 3, + "type": "KSampler", + "pos": { + "0": 863, + "1": 186 + }, + "size": { + "0": 315, + "1": 262 + }, + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 1 + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 4 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 6 + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 2 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 7 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 156680208700286, + "randomize", + 20, + 8, + "euler", + "normal", + 1 + ], + "color": "#432", + "bgcolor": "#653" + }, + { + "id": 4, + "type": "CheckpointLoaderSimple", + "pos": { + "0": 36, + "1": 172 + }, + "size": { + "0": 315, + "1": 98 + }, + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": [ + 1 + ], + "slot_index": 0 + }, + { + "name": "CLIP", + "type": "CLIP", + "links": [ + 3, + 5 + ], + "slot_index": 1 + }, + { + "name": "VAE", + "type": "VAE", + "links": [ + 8 + ], + "slot_index": 2 + } + ], + "properties": { + "Node name for S&R": "CheckpointLoaderSimple" + }, + "widgets_values": [ + "Stable-diffusion/v1-5-pruned-emaonly.safetensors" + ], + "color": "#322", + "bgcolor": "#533" + }, + { + "id": 5, + "type": "EmptyLatentImage", + "pos": { + "0": 473, + "1": 609 + }, + "size": { + "0": 315, + "1": 106 + }, + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 2 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "EmptyLatentImage" + }, + "widgets_values": [ + 512, + 512, + 1 + ], + "color": "#323", + "bgcolor": "#535" + }, + { + "id": 6, + "type": "CLIPTextEncode", + "pos": { + "0": 415, + "1": 186 + }, + "size": { + "0": 422.84503173828125, + "1": 164.31304931640625 + }, + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 3 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 4 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "beautiful scenery nature glass bottle landscape, , purple galaxy bottle," + ], + "color": "#233", + "bgcolor": "#355" + }, + { + "id": 7, + "type": "CLIPTextEncode", + "pos": { + "0": 413, + "1": 389 + }, + "size": { + "0": 425.27801513671875, + "1": 180.6060791015625 + }, + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 5 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 6 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "text, watermark" + ], + "color": "#323", + "bgcolor": "#535" + }, + { + "id": 8, + "type": "VAEDecode", + "pos": { + "0": 866, + "1": 502 + }, + "size": { + "0": 210, + "1": 46 + }, + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 7 + }, + { + "name": "vae", + "type": "VAE", + "link": 8 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 9 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "VAEDecode" + }, + "widgets_values": [], + "color": "#222", + "bgcolor": "#000" + }, + { + "id": 9, + "type": "SaveImage", + "pos": { + "0": 857, + "1": 611 + }, + "size": [ + 214.2000732421875, + 59.4000244140625 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 9 + } + ], + "outputs": [], + "properties": {}, + "widgets_values": [ + "ComfyUI" + ] + }, + { + "id": 10, + "type": "CheckpointLoaderSimple", + "pos": { + "0": 42, + "1": 329 + }, + "size": { + "0": 315, + "1": 98 + }, + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": null + }, + { + "name": "CLIP", + "type": "CLIP", + "links": null + }, + { + "name": "VAE", + "type": "VAE", + "links": null + } + ], + "properties": { + "Node name for S&R": "CheckpointLoaderSimple" + }, + "widgets_values": [ + "Stable-diffusion/v1-5-pruned-emaonly.safetensors" + ], + "color": "#332922", + "bgcolor": "#593930" + }, + { + "id": 11, + "type": "CheckpointLoaderSimple", + "pos": { + "0": 40, + "1": 494 + }, + "size": { + "0": 315, + "1": 98 + }, + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": null + }, + { + "name": "CLIP", + "type": "CLIP", + "links": null + }, + { + "name": "VAE", + "type": "VAE", + "links": null + } + ], + "properties": { + "Node name for S&R": "CheckpointLoaderSimple" + }, + "widgets_values": [ + "Stable-diffusion/v1-5-pruned-emaonly.safetensors" + ], + "color": "#223", + "bgcolor": "#335" + }, + { + "id": 13, + "type": "ImageScale", + "pos": { + "0": 42, + "1": 650 + }, + "size": { + "0": 315, + "1": 130 + }, + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": null + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": null + } + ], + "properties": { + "Node name for S&R": "ImageScale" + }, + "widgets_values": [ + "nearest-exact", + 512, + 512, + "disabled" + ], + "color": "#2a363b", + "bgcolor": "#3f5159" + } + ], + "links": [ + [ + 1, + 4, + 0, + 3, + 0, + "MODEL" + ], + [ + 2, + 5, + 0, + 3, + 3, + "LATENT" + ], + [ + 3, + 4, + 1, + 6, + 0, + "CLIP" + ], + [ + 4, + 6, + 0, + 3, + 1, + "CONDITIONING" + ], + [ + 5, + 4, + 1, + 7, + 0, + "CLIP" + ], + [ + 6, + 7, + 0, + 3, + 2, + "CONDITIONING" + ], + [ + 7, + 3, + 0, + 8, + 0, + "LATENT" + ], + [ + 8, + 4, + 2, + 8, + 1, + "VAE" + ], + [ + 9, + 8, + 0, + 9, + 0, + "IMAGE" + ] + ], + "groups": [], + "config": {}, + "extra": {}, + "version": 0.4 +} \ No newline at end of file diff --git a/browser_tests/colorPalette.spec.ts b/browser_tests/colorPalette.spec.ts index 8caca6600..26065c546 100644 --- a/browser_tests/colorPalette.spec.ts +++ b/browser_tests/colorPalette.spec.ts @@ -150,8 +150,21 @@ test.describe('Color Palette', () => { await comfyPage.nextFrame() await expect(comfyPage.canvas).toHaveScreenshot('default-color-palette.png') }) +}) - test('Can change node opacity setting', async ({ comfyPage }) => { +test.describe('Node Color Adjustments', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.loadWorkflow('every_node_color') + }) + + test.afterEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.Node.Opacity', 1.0) + await comfyPage.setSetting('Comfy.ColorPalette', 'dark') + }) + + test('should adjust opacity via node opacity setting', async ({ + comfyPage + }) => { await comfyPage.setSetting('Comfy.Node.Opacity', 0.5) await comfyPage.page.waitForTimeout(128) @@ -166,4 +179,71 @@ test.describe('Color Palette', () => { await comfyPage.page.mouse.move(8, 8) await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-1.png') }) + + test('should persist color adjustments when changing themes', async ({ + comfyPage + }) => { + await comfyPage.setSetting('Comfy.Node.Opacity', 0.2) + await comfyPage.setSetting('Comfy.ColorPalette', 'arc') + await comfyPage.nextFrame() + await comfyPage.page.mouse.move(0, 0) + await expect(comfyPage.canvas).toHaveScreenshot( + 'node-opacity-0.2-arc-theme.png' + ) + }) + + test('should not serialize color adjustments in workflow', async ({ + comfyPage + }) => { + await comfyPage.setSetting('Comfy.Node.Opacity', 0.5) + await comfyPage.setSetting('Comfy.ColorPalette', 'light') + const saveWorkflowInterval = 1000 + await comfyPage.page.waitForTimeout(saveWorkflowInterval) + const workflow = await comfyPage.page.evaluate(() => { + return localStorage.getItem('workflow') + }) + for (const node of JSON.parse(workflow).nodes) { + if (node.bgcolor) expect(node.bgcolor).not.toMatch(/hsla/) + if (node.color) expect(node.color).not.toMatch(/hsla/) + } + }) + + test('should lighten node colors when switching to light theme', async ({ + comfyPage + }) => { + await comfyPage.setSetting('Comfy.ColorPalette', 'light') + await comfyPage.nextFrame() + await expect(comfyPage.canvas).toHaveScreenshot('node-lightened-colors.png') + }) + + test.describe('Context menu color adjustments', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.ColorPalette', 'light') + await comfyPage.setSetting('Comfy.Node.Opacity', 0.3) + const node = await comfyPage.getFirstNodeRef() + await node.clickContextMenuOption('Colors') + }) + + test('should persist color adjustments when changing custom node colors', async ({ + comfyPage + }) => { + await comfyPage.page + .locator('.litemenu-entry.submenu span:has-text("red")') + .click() + await expect(comfyPage.canvas).toHaveScreenshot( + 'node-opacity-0.3-color-changed.png' + ) + }) + + test('should persist color adjustments when removing custom node color', async ({ + comfyPage + }) => { + await comfyPage.page + .locator('.litemenu-entry.submenu span:has-text("No color")') + .click() + await expect(comfyPage.canvas).toHaveScreenshot( + 'node-opacity-0.3-color-removed.png' + ) + }) + }) }) diff --git a/browser_tests/colorPalette.spec.ts-snapshots/node-lightened-colors-chromium-2x-linux.png b/browser_tests/colorPalette.spec.ts-snapshots/node-lightened-colors-chromium-2x-linux.png new file mode 100644 index 000000000..b40738a63 Binary files /dev/null and b/browser_tests/colorPalette.spec.ts-snapshots/node-lightened-colors-chromium-2x-linux.png differ diff --git a/browser_tests/colorPalette.spec.ts-snapshots/node-lightened-colors-chromium-linux.png b/browser_tests/colorPalette.spec.ts-snapshots/node-lightened-colors-chromium-linux.png new file mode 100644 index 000000000..f06dc5bd0 Binary files /dev/null and b/browser_tests/colorPalette.spec.ts-snapshots/node-lightened-colors-chromium-linux.png differ diff --git a/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-0-2-arc-theme-chromium-2x-linux.png b/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-0-2-arc-theme-chromium-2x-linux.png new file mode 100644 index 000000000..eee9c7661 Binary files /dev/null and b/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-0-2-arc-theme-chromium-2x-linux.png differ diff --git a/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-0-2-arc-theme-chromium-linux.png b/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-0-2-arc-theme-chromium-linux.png new file mode 100644 index 000000000..d5600023d Binary files /dev/null and b/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-0-2-arc-theme-chromium-linux.png differ diff --git a/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-0-3-color-changed-chromium-2x-linux.png b/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-0-3-color-changed-chromium-2x-linux.png new file mode 100644 index 000000000..50df11757 Binary files /dev/null and b/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-0-3-color-changed-chromium-2x-linux.png differ diff --git a/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-0-3-color-changed-chromium-linux.png b/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-0-3-color-changed-chromium-linux.png new file mode 100644 index 000000000..df216e255 Binary files /dev/null and b/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-0-3-color-changed-chromium-linux.png differ diff --git a/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-0-3-color-removed-chromium-2x-linux.png b/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-0-3-color-removed-chromium-2x-linux.png new file mode 100644 index 000000000..7f8a35e52 Binary files /dev/null and b/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-0-3-color-removed-chromium-2x-linux.png differ diff --git a/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-0-3-color-removed-chromium-linux.png b/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-0-3-color-removed-chromium-linux.png new file mode 100644 index 000000000..15b5b4783 Binary files /dev/null and b/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-0-3-color-removed-chromium-linux.png differ diff --git a/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-0-5-chromium-2x-linux.png b/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-0-5-chromium-2x-linux.png index 22d252522..a3da195e4 100644 Binary files a/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-0-5-chromium-2x-linux.png and b/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-0-5-chromium-2x-linux.png differ diff --git a/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-0-5-chromium-linux.png b/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-0-5-chromium-linux.png index 03bd2fba0..1810439de 100644 Binary files a/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-0-5-chromium-linux.png and b/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-0-5-chromium-linux.png differ diff --git a/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-1-chromium-2x-linux.png b/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-1-chromium-2x-linux.png index 242929e52..ecf612d35 100644 Binary files a/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-1-chromium-2x-linux.png and b/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-1-chromium-2x-linux.png differ diff --git a/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-1-chromium-linux.png b/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-1-chromium-linux.png index f6d7b3540..8f30b77a3 100644 Binary files a/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-1-chromium-linux.png and b/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-1-chromium-linux.png differ diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue index cac9d9342..6cacc803f 100644 --- a/src/components/graph/GraphCanvas.vue +++ b/src/components/graph/GraphCanvas.vue @@ -42,9 +42,6 @@ import { import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes' import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore' import { useCanvasStore } from '@/stores/graphStore' -import { applyOpacity } from '@/utils/colorUtil' -import { getColorPalette } from '@/extensions/core/colorPalette' -import { debounce } from 'lodash' const emit = defineEmits(['ready']) const canvasRef = ref(null) @@ -93,24 +90,6 @@ watchEffect(() => { }) }) -const updateNodeOpacity = (nodeOpacity: number) => { - const colorPalette = getColorPalette() - - if (!canvasStore.canvas) return - - const nodeBgColor = colorPalette?.colors?.litegraph_base?.NODE_DEFAULT_BGCOLOR - if (nodeBgColor) { - LiteGraph.NODE_DEFAULT_BGCOLOR = applyOpacity(nodeBgColor, nodeOpacity) - } -} - -const debouncedUpdateNodeOpacity = debounce(updateNodeOpacity, 128) - -watchEffect(() => { - const nodeOpacity = settingStore.get('Comfy.Node.Opacity') - debouncedUpdateNodeOpacity(nodeOpacity) -}) - let dropTargetCleanup = () => {} onMounted(async () => { diff --git a/src/extensions/core/colorPalette.ts b/src/extensions/core/colorPalette.ts index 52bdc90a0..fa67fca87 100644 --- a/src/extensions/core/colorPalette.ts +++ b/src/extensions/core/colorPalette.ts @@ -2,8 +2,6 @@ import { app } from '../../scripts/app' import { $el } from '../../scripts/ui' import type { ColorPalettes, Palette } from '@/types/colorPalette' import { LGraphCanvas, LiteGraph } from '@comfyorg/litegraph' -import { applyOpacity } from '@/utils/colorUtil' -import { useSettingStore } from '@/stores/settingStore' // Manage color palettes @@ -684,13 +682,7 @@ app.registerExtension({ colorPalette.colors.litegraph_base.hasOwnProperty(key) && LiteGraph.hasOwnProperty(key) ) { - LiteGraph[key] = - key === 'NODE_DEFAULT_BGCOLOR' - ? applyOpacity( - colorPalette.colors.litegraph_base[key], - useSettingStore().get('Comfy.Node.Opacity') - ) - : colorPalette.colors.litegraph_base[key] + LiteGraph[key] = colorPalette.colors.litegraph_base[key] } } } diff --git a/src/scripts/app.ts b/src/scripts/app.ts index 8deb58d4b..6c5b73f9e 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -21,7 +21,7 @@ import { validateComfyWorkflow } from '../types/comfyWorkflow' import { ComfyNodeDef, StatusWsMessageStatus } from '@/types/apiTypes' -import { lightenColor } from '@/utils/colorUtil' +import { adjustColor, ColorAdjustOptions } from '@/utils/colorUtil' import { ComfyAppMenu } from './ui/menu/index' import { getStorageValue } from './utils' import { ComfyWorkflowManager, ComfyWorkflow } from './workflows' @@ -1568,14 +1568,25 @@ export class ComfyApp { this.editor_alpha = 0.2 } - const adjustColor = (color?: string) => { - return color ? lightenColor(color, 0.5) : color - } - if (app.ui.settings.getSettingValue('Comfy.ColorPalette') === 'light') { - node.bgcolor = adjustColor(node.bgcolor) - node.color = adjustColor(node.color) + const adjustments: ColorAdjustOptions = {} + + const opacity = useSettingStore().get('Comfy.Node.Opacity') + if (opacity) adjustments.opacity = opacity + + if (useSettingStore().get('Comfy.ColorPalette') === 'light') { + adjustments.lightness = 0.5 + + // Lighten title bar of colored nodes on light theme + if (old_color) { + node.color = adjustColor(old_color, { lightness: 0.5 }) + } } + node.bgcolor = adjustColor( + old_bgcolor || LiteGraph.NODE_DEFAULT_BGCOLOR, + adjustments + ) + const res = origDrawNode.apply(this, arguments) this.editor_alpha = editor_alpha diff --git a/src/utils/colorUtil.ts b/src/utils/colorUtil.ts index 12d0b188f..d5586103f 100644 --- a/src/utils/colorUtil.ts +++ b/src/utils/colorUtil.ts @@ -1,7 +1,15 @@ +import { memoize } from 'lodash' + type RGB = { r: number; g: number; b: number } type HSL = { h: number; s: number; l: number } +type HSLA = { h: number; s: number; l: number; a: number } type ColorFormat = 'hex' | 'rgb' | 'rgba' | 'hsl' | 'hsla' +export interface ColorAdjustOptions { + lightness?: number + opacity?: number +} + function rgbToHsl({ r, g, b }: RGB): HSL { r /= 255 g /= 255 @@ -33,35 +41,6 @@ function rgbToHsl({ r, g, b }: RGB): HSL { return { h, s, l } } -function hslToRgb({ h, s, l }: HSL): RGB { - let r: number, g: number, b: number - - if (s === 0) { - r = g = b = l // achromatic - } else { - const hue2rgb = (p: number, q: number, t: number) => { - if (t < 0) t += 1 - if (t > 1) t -= 1 - if (t < 1 / 6) return p + (q - p) * 6 * t - if (t < 1 / 2) return q - if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6 - return p - } - - const q = l < 0.5 ? l * (1 + s) : l + s - l * s - const p = 2 * l - q - r = hue2rgb(p, q, h + 1 / 3) - g = hue2rgb(p, q, h) - b = hue2rgb(p, q, h - 1 / 3) - } - - return { - r: Math.round(r * 255), - g: Math.round(g * 255), - b: Math.round(b * 255) - } -} - function hexToRgb(hex: string): RGB { let r = 0, g = 0, @@ -81,75 +60,105 @@ function hexToRgb(hex: string): RGB { return { r, g, b } } -function rgbToHex({ r, g, b }: RGB): string { - return ( - '#' + - [r, g, b] - .map((x) => { - const hex = x.toString(16) - return hex.length === 1 ? '0' + hex : hex - }) - .join('') - ) -} - -function identifyColorFormat(color: string): ColorFormat | null { +const identifyColorFormat = (color: string): ColorFormat | null => { if (!color) return null - if (color.startsWith('#')) return 'hex' - if (/^rgba?\(\d+,\s*\d+,\s*\d+/.test(color)) + if (color.startsWith('#') && (color.length === 4 || color.length === 7)) + return 'hex' + if (/rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*/.test(color)) return color.includes('rgba') ? 'rgba' : 'rgb' - if (/^hsla?\(\d+(\.\d+)?,\s*\d+(\.\d+)?%,\s*\d+(\.\d+)?%/.test(color)) + if (/hsla?\(\s*\d+(\.\d+)?\s*,\s*\d+(\.\d+)?%\s*,\s*\d+(\.\d+)?%/.test(color)) return color.includes('hsla') ? 'hsla' : 'hsl' return null } -export function lightenColor(hex: string, amount: number): string { - let rgb = hexToRgb(hex) - const hsl = rgbToHsl(rgb) - hsl.l = Math.min(1, hsl.l + amount) - rgb = hslToRgb(hsl) - return rgbToHex(rgb) +const isHSLA = (color: unknown): color is HSLA => { + if (typeof color !== 'object' || color === null) return false + + return ['h', 's', 'l', 'a'].every( + (key) => + typeof (color as Record)[key] === 'number' && + !isNaN((color as Record)[key]) + ) } -export function applyOpacity(color: string, opacity: number): string { - const colorFormat = identifyColorFormat(color) +function parseToHSLA(color: string, format: ColorFormat): HSLA | null { + let match: RegExpMatchArray | null - if (!colorFormat) { - console.warn( - `Unsupported color format in user color palette for color: ${color}` - ) + switch (format) { + case 'hex': { + const hsl = rgbToHsl(hexToRgb(color)) + return { + h: Math.round(hsl.h * 360), + s: +(hsl.s * 100).toFixed(1), + l: +(hsl.l * 100).toFixed(1), + a: 1 + } + } + + case 'rgb': + case 'rgba': { + match = color.match(/\d+(\.\d+)?/g) + if (!match || match.length < 3) return null + const [r, g, b] = match.map(Number) + const hsl = rgbToHsl({ r, g, b }) + + const a = format === 'rgba' && match[3] ? parseFloat(match[3]) : 1 + + return { + h: Math.round(hsl.h * 360), + s: +(hsl.s * 100).toFixed(1), + l: +(hsl.l * 100).toFixed(1), + a + } + } + + case 'hsl': + case 'hsla': { + match = color.match(/\d+(\.\d+)?/g) + if (!match || match.length < 3) return null + const [h, s, l] = match.map(Number) + const a = format === 'hsla' && match[3] ? parseFloat(match[3]) : 1 + return { h, s, l, a } + } + default: + return null + } +} + +const applyColorAdjustments = ( + color: string, + options: ColorAdjustOptions +): string => { + if (!Object.keys(options).length) return color + + const format = identifyColorFormat(color) + if (!format) { + console.warn(`Unsupported color format in color palette: ${color}`) return color } - const clampedOpacity = Math.max(0, Math.min(1, opacity)) - - switch (colorFormat) { - case 'hex': { - const { r, g, b } = hexToRgb(color) - if (isNaN(r) || isNaN(g) || isNaN(b)) { - return color - } - return `rgba(${r}, ${g}, ${b}, ${clampedOpacity})` - } - case 'rgb': { - return color.replace('rgb', 'rgba').replace(')', `, ${clampedOpacity})`) - } - case 'rgba': { - return color.replace( - /rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,[^)]+\)/, - `rgba($1, $2, $3, ${clampedOpacity})` - ) - } - case 'hsl': { - return color.replace('hsl', 'hsla').replace(')', `, ${clampedOpacity})`) - } - case 'hsla': { - return color.replace( - /hsla\(\s*(\d+)\s*,\s*(\d+(?:\.\d+)?)%\s*,\s*(\d+(?:\.\d+)?)%\s*,[^)]+\)/, - `hsla($1, $2%, $3%, ${clampedOpacity})` - ) - } - default: - return color + const hsla = parseToHSLA(color, format) + if (!isHSLA(hsla)) { + console.warn(`Invalid color values in color palette: ${color}`) + return color } + + if (options.lightness) { + hsla.l = Math.max(0, Math.min(100, hsla.l + options.lightness * 100.0)) + } + + if (options.opacity) { + hsla.a = Math.max(0, Math.min(1, options.opacity)) + } + + return `hsla(${hsla.h}, ${hsla.s}%, ${hsla.l}%, ${hsla.a})` } + +export const adjustColor: ( + color: string, + options: ColorAdjustOptions +) => string = memoize( + applyColorAdjustments, + (color: string, options: ColorAdjustOptions): string => + `${color}-${JSON.stringify(options)}` +) diff --git a/tests-ui/tests/colorUtil.test.ts b/tests-ui/tests/colorUtil.test.ts index 5c8fd5522..00de93ea4 100644 --- a/tests-ui/tests/colorUtil.test.ts +++ b/tests-ui/tests/colorUtil.test.ts @@ -1,50 +1,170 @@ -import { applyOpacity } from '@/utils/colorUtil' +import { adjustColor } from '@/utils/colorUtil' -describe('colorUtil - applyOpacity', () => { - // Same color in various formats - const solarized = { +interface ColorTestCase { + hex: string + rgb: string + rgba: string + hsl: string + hsla: string + lightExpected: string + transparentExpected: string + lightTransparentExpected: string +} + +type ColorFormat = 'hex' | 'rgb' | 'rgba' | 'hsl' | 'hsla' + +jest.mock('lodash', () => ({ + memoize: (fn: any) => fn +})) + +const targetOpacity = 0.5 +const targetLightness = 0.5 + +const assertColorVariationsMatch = (variations: string[], adjustment: any) => { + for (let i = 0; i < variations.length - 1; i++) { + expect(adjustColor(variations[i], adjustment)).toBe( + adjustColor(variations[i + 1], adjustment) + ) + } +} + +const colors: Record = { + green: { hex: '#073642', rgb: 'rgb(7, 54, 66)', rgba: 'rgba(7, 54, 66, 1)', - hsl: 'hsl(192, 80.80%, 14.30%)', - hsla: 'hsla(192, 80.80%, 14.30%, 1)' + hsl: 'hsl(192, 80.8%, 14.3%)', + hsla: 'hsla(192, 80.8%, 14.3%, 1)', + lightExpected: 'hsla(192, 80.8%, 64.3%, 1)', + transparentExpected: 'hsla(192, 80.8%, 14.3%, 0.5)', + lightTransparentExpected: 'hsla(192, 80.8%, 64.3%, 0.5)' + }, + blue: { + hex: '#00008B', + rgb: 'rgb(0,0,139)', + rgba: 'rgba(0,0,139,1)', + hsl: 'hsl(240,100%,27.3%)', + hsla: 'hsl(240,100%,27.3%,1)', + lightExpected: 'hsla(240, 100%, 77.3%, 1)', + transparentExpected: 'hsla(240, 100%, 27.3%, 0.5)', + lightTransparentExpected: 'hsla(240, 100%, 77.3%, 0.5)' + } +} + +const formats: ColorFormat[] = ['hex', 'rgb', 'rgba', 'hsl', 'hsla'] + +describe('colorUtil - adjustColor', () => { + const runAdjustColorTests = ( + color: ColorTestCase, + format: ColorFormat + ): void => { + it('converts lightness', () => { + const result = adjustColor(color[format], { lightness: targetLightness }) + expect(result).toBe(color.lightExpected) + }) + + it('applies opacity', () => { + const result = adjustColor(color[format], { opacity: targetOpacity }) + expect(result).toBe(color.transparentExpected) + }) + + it('applies lightness and opacity jointly', () => { + const result = adjustColor(color[format], { + lightness: targetLightness, + opacity: targetOpacity + }) + expect(result).toBe(color.lightTransparentExpected) + }) } - const opacity = 0.5 - - it('applies opacity consistently to hex, rgb, and rgba formats', () => { - const hexResult = applyOpacity(solarized.hex, opacity) - const rgbResult = applyOpacity(solarized.rgb, opacity) - const rgbaResult = applyOpacity(solarized.rgba, opacity) - - expect(hexResult).toBe(rgbResult) - expect(rgbResult).toBe(rgbaResult) - }) - - it('applies opacity consistently to hsl and hsla formats', () => { - const hslResult = applyOpacity(solarized.hsl, opacity) - const hslaResult = applyOpacity(solarized.hsla, opacity) - - expect(hslResult).toBe(hslaResult) + describe.each(Object.entries(colors))('%s color', (colorName, color) => { + describe.each(formats)('%s format', (format) => { + runAdjustColorTests(color, format as ColorFormat) + }) }) it('returns the original value for invalid color formats', () => { const invalidColors = [ - '#GGGGGG', // Invalid hex code (non-hex characters) - 'rgb(300, -10, 256)', // Invalid RGB values (out of range) - 'xyz(255, 255, 255)', // Unsupported format - 'rgba(255, 255, 255)', // Missing alpha in RGBA - 'hsl(100, 50, 50%)' // Missing percentage sign for saturation + 'cmky(100, 50, 50, 0.5)', + 'rgb(300, -10, 256)', + 'xyz(255, 255, 255)', + 'hsl(100, 50, 50%)', + 'hsl(100, 50%, 50)', + '#GGGGGG', + '#3333' ] invalidColors.forEach((color) => { - const result = applyOpacity(color, opacity) + const result = adjustColor(color, { + lightness: targetLightness, + opacity: targetOpacity + }) expect(result).toBe(color) }) }) it('returns the original value for null or undefined inputs', () => { - expect(applyOpacity(null, opacity)).toBe(null) - expect(applyOpacity(undefined, opacity)).toBe(undefined) + expect(adjustColor(null, { opacity: targetOpacity })).toBe(null) + expect(adjustColor(undefined, { opacity: targetOpacity })).toBe(undefined) + }) + + describe('handles input variations', () => { + it('handles spaces in rgb input', () => { + const variations = [ + 'rgb(0, 0, 0)', + 'rgb(0,0,0)', + 'rgb(0, 0,0)', + 'rgb(0,0, 0)' + ] + assertColorVariationsMatch(variations, { lightness: 0.5 }) + }) + + it('handles spaces in hsl input', () => { + const variations = [ + 'hsl(0, 0%, 0%)', + 'hsl(0,0%,0%)', + 'hsl(0, 0%,0%)', + 'hsl(0,0%, 0%)' + ] + assertColorVariationsMatch(variations, { lightness: 0.5 }) + }) + + it('handles different decimal places in rgba input', () => { + const variations = [ + 'rgba(0, 0, 0, 0.5)', + 'rgba(0, 0, 0, 0.50)', + 'rgba(0, 0, 0, 0.500)' + ] + assertColorVariationsMatch(variations, { opacity: 0.5 }) + }) + + it('handles different decimal places in hsla input', () => { + const variations = [ + 'hsla(0, 0%, 0%, 0.5)', + 'hsla(0, 0%, 0%, 0.50)', + 'hsla(0, 0%, 0%, 0.500)' + ] + assertColorVariationsMatch(variations, { opacity: 0.5 }) + }) + }) + + describe('clamps values correctly', () => { + it('clamps lightness to 0 and 100', () => { + expect(adjustColor('hsl(0, 100%, 50%)', { lightness: -1 })).toBe( + 'hsla(0, 100%, 0%, 1)' + ) + expect(adjustColor('hsl(0, 100%, 50%)', { lightness: 1.5 })).toBe( + 'hsla(0, 100%, 100%, 1)' + ) + }) + + it('clamps opacity to 0 and 1', () => { + expect(adjustColor('rgba(0, 0, 0, 0.5)', { opacity: -0.5 })).toBe( + 'hsla(0, 0%, 0%, 0)' + ) + expect(adjustColor('rgba(0, 0, 0, 0.5)', { opacity: 1.5 })).toBe( + 'hsla(0, 0%, 0%, 1)' + ) + }) }) })