Apply node opacity setting to all node colors (#947)
* Apply opacity to node colors. Resolves #928 * Handle default and custom colors all in draw handler * Add colorUtil unit tests * Add Playwright test * Remove comment * Revert colorPalette.ts changes * Remove unused imports * Fix typo * Update test expectations [skip ci] --------- Co-authored-by: github-actions <github-actions@github.com>
504
browser_tests/assets/every_node_color.json
Normal file
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 134 KiB |
@@ -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<HTMLCanvasElement | null>(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 () => {
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown>)[key] === 'number' &&
|
||||
!isNaN((color as Record<string, number>)[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)}`
|
||||
)
|
||||
|
||||
@@ -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<string, ColorTestCase> = {
|
||||
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)'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||