mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-29 10:42:44 +00:00
refactor: color handling in node customization and selection tools
This commit is contained in:
@@ -28,7 +28,11 @@
|
|||||||
>
|
>
|
||||||
<template #option="{ option }">
|
<template #option="{ option }">
|
||||||
<i
|
<i
|
||||||
v-tooltip.top="option.localizedName"
|
v-tooltip.top="
|
||||||
|
typeof option.localizedName === 'function'
|
||||||
|
? option.localizedName()
|
||||||
|
: option.localizedName
|
||||||
|
"
|
||||||
class="pi pi-circle-fill"
|
class="pi pi-circle-fill"
|
||||||
:style="{
|
:style="{
|
||||||
color: isLightTheme ? option.value.light : option.value.dark
|
color: isLightTheme ? option.value.light : option.value.dark
|
||||||
@@ -48,78 +52,38 @@ import { computed, ref, watch } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
|
import type { NodeColorOption } from '@/composables/graph/useNodeColorOptions'
|
||||||
|
import { useNodeColorOptions } from '@/composables/graph/useNodeColorOptions'
|
||||||
|
import type { IColorable } from '@/lib/litegraph/src/interfaces'
|
||||||
import type {
|
import type {
|
||||||
ColorOption as CanvasColorOption,
|
ColorOption as CanvasColorOption,
|
||||||
Positionable
|
Positionable
|
||||||
} from '@/lib/litegraph/src/litegraph'
|
} from '@/lib/litegraph/src/litegraph'
|
||||||
import {
|
import { isColorable } from '@/lib/litegraph/src/litegraph'
|
||||||
LGraphCanvas,
|
|
||||||
LiteGraph,
|
|
||||||
isColorable
|
|
||||||
} from '@/lib/litegraph/src/litegraph'
|
|
||||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
|
||||||
import { adjustColor } from '@/utils/colorUtil'
|
|
||||||
import { getItemsColorOption } from '@/utils/litegraphUtil'
|
import { getItemsColorOption } from '@/utils/litegraphUtil'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const canvasStore = useCanvasStore()
|
const canvasStore = useCanvasStore()
|
||||||
const colorPaletteStore = useColorPaletteStore()
|
|
||||||
const workflowStore = useWorkflowStore()
|
const workflowStore = useWorkflowStore()
|
||||||
const isLightTheme = computed(
|
|
||||||
() => colorPaletteStore.completedActivePalette.light_theme
|
const { colorOptions, NO_COLOR_OPTION, applyColorToItems, isLightTheme } =
|
||||||
)
|
useNodeColorOptions()
|
||||||
const toLightThemeColor = (color: string) =>
|
|
||||||
adjustColor(color, { lightness: 0.5 })
|
|
||||||
|
|
||||||
const showColorPicker = ref(false)
|
const showColorPicker = ref(false)
|
||||||
|
|
||||||
type ColorOption = {
|
const selectedColorOption = ref<NodeColorOption | null>(null)
|
||||||
name: string
|
const applyColor = (colorOption: NodeColorOption | null) => {
|
||||||
localizedName: string
|
const colorName = colorOption?.name ?? NO_COLOR_OPTION.value.name
|
||||||
value: {
|
|
||||||
dark: string
|
|
||||||
light: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const NO_COLOR_OPTION: ColorOption = {
|
const colorableItems = canvasStore.selectedItems
|
||||||
name: 'noColor',
|
.filter(isColorable)
|
||||||
localizedName: t('color.noColor'),
|
.map((item) => item as unknown as IColorable)
|
||||||
value: {
|
applyColorToItems(colorableItems, colorName)
|
||||||
dark: LiteGraph.NODE_DEFAULT_BGCOLOR,
|
|
||||||
light: toLightThemeColor(LiteGraph.NODE_DEFAULT_BGCOLOR)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const colorOptions: ColorOption[] = [
|
|
||||||
NO_COLOR_OPTION,
|
|
||||||
...Object.entries(LGraphCanvas.node_colors).map(([name, color]) => ({
|
|
||||||
name,
|
|
||||||
localizedName: t(`color.${name}`),
|
|
||||||
value: {
|
|
||||||
dark: color.bgcolor,
|
|
||||||
light: toLightThemeColor(color.bgcolor)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
]
|
|
||||||
|
|
||||||
const selectedColorOption = ref<ColorOption | null>(null)
|
|
||||||
const applyColor = (colorOption: ColorOption | null) => {
|
|
||||||
const colorName = colorOption?.name ?? NO_COLOR_OPTION.name
|
|
||||||
const canvasColorOption =
|
|
||||||
colorName === NO_COLOR_OPTION.name
|
|
||||||
? null
|
|
||||||
: LGraphCanvas.node_colors[colorName]
|
|
||||||
|
|
||||||
for (const item of canvasStore.selectedItems) {
|
|
||||||
if (isColorable(item)) {
|
|
||||||
item.setColorOption(canvasColorOption)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
canvasStore.canvas?.setDirty(true, true)
|
canvasStore.canvas?.setDirty(true, true)
|
||||||
currentColorOption.value = canvasColorOption
|
currentColorOption.value = getItemsColorOption(canvasStore.selectedItems)
|
||||||
showColorPicker.value = false
|
showColorPicker.value = false
|
||||||
workflowStore.activeWorkflow?.changeTracker.checkState()
|
workflowStore.activeWorkflow?.changeTracker.checkState()
|
||||||
}
|
}
|
||||||
@@ -128,20 +92,24 @@ const currentColorOption = ref<CanvasColorOption | null>(null)
|
|||||||
const currentColor = computed(() =>
|
const currentColor = computed(() =>
|
||||||
currentColorOption.value
|
currentColorOption.value
|
||||||
? isLightTheme.value
|
? isLightTheme.value
|
||||||
? toLightThemeColor(currentColorOption.value?.bgcolor)
|
? colorOptions.value.find(
|
||||||
|
(option) => option.value.dark === currentColorOption.value?.bgcolor
|
||||||
|
)?.value.light
|
||||||
: currentColorOption.value?.bgcolor
|
: currentColorOption.value?.bgcolor
|
||||||
: null
|
: null
|
||||||
)
|
)
|
||||||
|
|
||||||
const localizedCurrentColorName = computed(() => {
|
const localizedCurrentColorName = computed(() => {
|
||||||
if (!currentColorOption.value?.bgcolor) return null
|
if (!currentColorOption.value?.bgcolor) return null
|
||||||
const colorOption = colorOptions.find(
|
const colorOption = colorOptions.value.find(
|
||||||
(option) =>
|
(option) =>
|
||||||
option.value.dark === currentColorOption.value?.bgcolor ||
|
option.value.dark === currentColorOption.value?.bgcolor ||
|
||||||
option.value.light === currentColorOption.value?.bgcolor
|
option.value.light === currentColorOption.value?.bgcolor
|
||||||
)
|
)
|
||||||
return colorOption?.localizedName ?? NO_COLOR_OPTION.localizedName
|
const name = colorOption?.localizedName ?? NO_COLOR_OPTION.value.localizedName
|
||||||
|
return typeof name === 'function' ? name() : name
|
||||||
})
|
})
|
||||||
|
|
||||||
const updateColorSelectionFromNode = (
|
const updateColorSelectionFromNode = (
|
||||||
newSelectedItems: Raw<Positionable[]>
|
newSelectedItems: Raw<Positionable[]>
|
||||||
) => {
|
) => {
|
||||||
@@ -149,6 +117,7 @@ const updateColorSelectionFromNode = (
|
|||||||
selectedColorOption.value = null
|
selectedColorOption.value = null
|
||||||
currentColorOption.value = getItemsColorOption(newSelectedItems)
|
currentColorOption.value = getItemsColorOption(newSelectedItems)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => canvasStore.selectedItems,
|
() => canvasStore.selectedItems,
|
||||||
(newSelectedItems) => {
|
(newSelectedItems) => {
|
||||||
|
|||||||
@@ -2,103 +2,37 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
import { useNodeColorOptions } from '@/composables/graph/useNodeColorOptions'
|
||||||
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
import type { IColorable } from '@/lib/litegraph/src/interfaces'
|
||||||
import type { ColorOption } from '@/lib/litegraph/src/litegraph'
|
|
||||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
|
||||||
import { adjustColor } from '@/utils/colorUtil'
|
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
import LayoutField from './LayoutField.vue'
|
import LayoutField from './LayoutField.vue'
|
||||||
|
|
||||||
/**
|
const { nodes } = defineProps<{ nodes: IColorable[] }>()
|
||||||
* Good design limits dependencies and simplifies the interface of the abstraction layer.
|
|
||||||
* Here, we only care about the getColorOption and setColorOption methods,
|
|
||||||
* and do not concern ourselves with other methods.
|
|
||||||
*/
|
|
||||||
type PickedNode = Pick<LGraphNode, 'getColorOption' | 'setColorOption'>
|
|
||||||
|
|
||||||
const { nodes } = defineProps<{ nodes: PickedNode[] }>()
|
|
||||||
const emit = defineEmits<{ (e: 'changed'): void }>()
|
const emit = defineEmits<{ (e: 'changed'): void }>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const colorPaletteStore = useColorPaletteStore()
|
const { colorOptions, applyColorToItems, getCurrentColorName, isLightTheme } =
|
||||||
|
useNodeColorOptions({
|
||||||
|
includeRingColors: true,
|
||||||
|
lightnessAdjustments: {
|
||||||
|
dark: 0.3,
|
||||||
|
light: 0.4,
|
||||||
|
ringDark: 0.5,
|
||||||
|
ringLight: 0.1
|
||||||
|
},
|
||||||
|
localizedNameAsFunction: true
|
||||||
|
})
|
||||||
|
|
||||||
type NodeColorOption = {
|
const nodeColor = computed<string | null>({
|
||||||
name: string
|
|
||||||
localizedName: () => string
|
|
||||||
value: {
|
|
||||||
dark: string
|
|
||||||
light: string
|
|
||||||
ringDark: string
|
|
||||||
ringLight: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodeColorEntries = Object.entries(LGraphCanvas.node_colors)
|
|
||||||
|
|
||||||
function getColorValue(color: string): NodeColorOption['value'] {
|
|
||||||
return {
|
|
||||||
dark: adjustColor(color, { lightness: 0.3 }),
|
|
||||||
light: adjustColor(color, { lightness: 0.4 }),
|
|
||||||
ringDark: adjustColor(color, { lightness: 0.5 }),
|
|
||||||
ringLight: adjustColor(color, { lightness: 0.1 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const NO_COLOR_OPTION: NodeColorOption = {
|
|
||||||
name: 'noColor',
|
|
||||||
localizedName: () => t('color.noColor'),
|
|
||||||
value: getColorValue(LiteGraph.NODE_DEFAULT_BGCOLOR)
|
|
||||||
}
|
|
||||||
|
|
||||||
const colorOptions: NodeColorOption[] = [
|
|
||||||
NO_COLOR_OPTION,
|
|
||||||
...nodeColorEntries.map(([name, color]) => ({
|
|
||||||
name,
|
|
||||||
localizedName: () => t(`color.${name}`),
|
|
||||||
value: getColorValue(color.bgcolor)
|
|
||||||
}))
|
|
||||||
]
|
|
||||||
|
|
||||||
const isLightTheme = computed(
|
|
||||||
() => colorPaletteStore.completedActivePalette.light_theme
|
|
||||||
)
|
|
||||||
|
|
||||||
const nodeColor = computed<NodeColorOption['name'] | null>({
|
|
||||||
get() {
|
get() {
|
||||||
if (nodes.length === 0) return null
|
return getCurrentColorName(nodes)
|
||||||
const theColorOptions = nodes.map((item) => item.getColorOption())
|
|
||||||
|
|
||||||
let colorOption: ColorOption | null | false = theColorOptions[0]
|
|
||||||
if (!theColorOptions.every((option) => option === colorOption)) {
|
|
||||||
colorOption = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (colorOption === false) return null
|
|
||||||
if (colorOption == null || (!colorOption.bgcolor && !colorOption.color))
|
|
||||||
return NO_COLOR_OPTION.name
|
|
||||||
return (
|
|
||||||
nodeColorEntries.find(
|
|
||||||
([_, color]) =>
|
|
||||||
color.bgcolor === colorOption.bgcolor &&
|
|
||||||
color.color === colorOption.color
|
|
||||||
)?.[0] ?? null
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
set(colorName) {
|
set(colorName) {
|
||||||
if (colorName === null) return
|
if (colorName === null) return
|
||||||
|
|
||||||
const canvasColorOption =
|
applyColorToItems(nodes, colorName)
|
||||||
colorName === NO_COLOR_OPTION.name
|
|
||||||
? null
|
|
||||||
: LGraphCanvas.node_colors[colorName]
|
|
||||||
|
|
||||||
for (const item of nodes) {
|
|
||||||
item.setColorOption(canvasColorOption)
|
|
||||||
}
|
|
||||||
|
|
||||||
emit('changed')
|
emit('changed')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -123,7 +57,11 @@ const nodeColor = computed<NodeColorOption['name'] | null>({
|
|||||||
@click="nodeColor = option.name"
|
@click="nodeColor = option.name"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-tooltip.top="option.localizedName()"
|
v-tooltip.top="
|
||||||
|
typeof option.localizedName === 'function'
|
||||||
|
? option.localizedName()
|
||||||
|
: option.localizedName
|
||||||
|
"
|
||||||
:class="cn('size-4 rounded-full ring-2 ring-gray-500/10')"
|
:class="cn('size-4 rounded-full ring-2 ring-gray-500/10')"
|
||||||
:style="{
|
:style="{
|
||||||
backgroundColor: isLightTheme
|
backgroundColor: isLightTheme
|
||||||
|
|||||||
@@ -65,8 +65,11 @@ export function useGroupMenuOptions() {
|
|||||||
label: t('contextMenu.Color'),
|
label: t('contextMenu.Color'),
|
||||||
icon: 'icon-[lucide--palette]',
|
icon: 'icon-[lucide--palette]',
|
||||||
hasSubmenu: true,
|
hasSubmenu: true,
|
||||||
submenu: colorOptions.map((colorOption) => ({
|
submenu: colorOptions.value.map((colorOption) => ({
|
||||||
label: colorOption.localizedName,
|
label:
|
||||||
|
typeof colorOption.localizedName === 'function'
|
||||||
|
? colorOption.localizedName()
|
||||||
|
: colorOption.localizedName,
|
||||||
color: isLightTheme.value
|
color: isLightTheme.value
|
||||||
? colorOption.value.light
|
? colorOption.value.light
|
||||||
: colorOption.value.dark,
|
: colorOption.value.dark,
|
||||||
|
|||||||
269
src/composables/graph/useNodeColorOptions.test.ts
Normal file
269
src/composables/graph/useNodeColorOptions.test.ts
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||||
|
import type * as VueI18n from 'vue-i18n'
|
||||||
|
|
||||||
|
import { useNodeColorOptions } from '@/composables/graph/useNodeColorOptions'
|
||||||
|
import type { IColorable } from '@/lib/litegraph/src/interfaces'
|
||||||
|
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||||
|
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof VueI18n>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useI18n: () => ({
|
||||||
|
t: (key: string) => key
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
|
||||||
|
useColorPaletteStore: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/utils/colorUtil', () => ({
|
||||||
|
adjustColor: (color: string, options: { lightness?: number }) => {
|
||||||
|
const lightness = options.lightness ?? 0
|
||||||
|
return `adjusted(${color}, ${lightness})`
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockColorPaletteStore = {
|
||||||
|
completedActivePalette: {
|
||||||
|
light_theme: false
|
||||||
|
},
|
||||||
|
$id: 'colorPalette',
|
||||||
|
$state: {} as any,
|
||||||
|
$patch: vi.fn(),
|
||||||
|
$reset: vi.fn(),
|
||||||
|
$subscribe: vi.fn(),
|
||||||
|
$onAction: vi.fn(),
|
||||||
|
$dispose: vi.fn(),
|
||||||
|
_customProperties: new Set(),
|
||||||
|
_p: {} as any
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useNodeColorOptions', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.mocked(useColorPaletteStore).mockReturnValue(
|
||||||
|
mockColorPaletteStore as any
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mock LGraphCanvas.node_colors
|
||||||
|
vi.spyOn(LGraphCanvas, 'node_colors', 'get').mockReturnValue({
|
||||||
|
red: { bgcolor: '#ff0000', color: '#ffffff' },
|
||||||
|
blue: { bgcolor: '#0000ff', color: '#ffffff' }
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
// Mock LiteGraph.NODE_DEFAULT_BGCOLOR
|
||||||
|
vi.spyOn(LiteGraph, 'NODE_DEFAULT_BGCOLOR', 'get').mockReturnValue(
|
||||||
|
'#999999'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Basic Configuration', () => {
|
||||||
|
test('should generate color options with default config', () => {
|
||||||
|
const { colorOptions } = useNodeColorOptions()
|
||||||
|
|
||||||
|
expect(colorOptions.value).toHaveLength(3) // NO_COLOR + red + blue
|
||||||
|
expect(colorOptions.value[0].name).toBe('noColor')
|
||||||
|
expect(colorOptions.value[1].name).toBe('red')
|
||||||
|
expect(colorOptions.value[2].name).toBe('blue')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should include ring colors when configured', () => {
|
||||||
|
const { colorOptions } = useNodeColorOptions({
|
||||||
|
includeRingColors: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const firstOption = colorOptions.value[0]
|
||||||
|
expect(firstOption.value).toHaveProperty('dark')
|
||||||
|
expect(firstOption.value).toHaveProperty('light')
|
||||||
|
expect(firstOption.value).toHaveProperty('ringDark')
|
||||||
|
expect(firstOption.value).toHaveProperty('ringLight')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should not include ring colors by default', () => {
|
||||||
|
const { colorOptions } = useNodeColorOptions()
|
||||||
|
|
||||||
|
const firstOption = colorOptions.value[0]
|
||||||
|
expect(firstOption.value).toHaveProperty('dark')
|
||||||
|
expect(firstOption.value).toHaveProperty('light')
|
||||||
|
expect(firstOption.value).not.toHaveProperty('ringDark')
|
||||||
|
expect(firstOption.value).not.toHaveProperty('ringLight')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should use localizedName as function when configured', () => {
|
||||||
|
const { colorOptions } = useNodeColorOptions({
|
||||||
|
localizedNameAsFunction: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const firstOption = colorOptions.value[0]
|
||||||
|
expect(typeof firstOption.localizedName).toBe('function')
|
||||||
|
if (typeof firstOption.localizedName === 'function') {
|
||||||
|
expect(firstOption.localizedName()).toBe('color.noColor')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should use localizedName as string by default', () => {
|
||||||
|
const { colorOptions } = useNodeColorOptions()
|
||||||
|
|
||||||
|
const firstOption = colorOptions.value[0]
|
||||||
|
expect(typeof firstOption.localizedName).toBe('string')
|
||||||
|
expect(firstOption.localizedName).toBe('color.noColor')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('NO_COLOR_OPTION', () => {
|
||||||
|
test('should provide NO_COLOR_OPTION', () => {
|
||||||
|
const { NO_COLOR_OPTION } = useNodeColorOptions()
|
||||||
|
|
||||||
|
expect(NO_COLOR_OPTION.value.name).toBe('noColor')
|
||||||
|
expect(NO_COLOR_OPTION.value.localizedName).toBe('color.noColor')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getColorValue', () => {
|
||||||
|
test('should generate color variants with default adjustments', () => {
|
||||||
|
const { getColorValue } = useNodeColorOptions()
|
||||||
|
|
||||||
|
const variants = getColorValue('#ff0000')
|
||||||
|
|
||||||
|
expect(variants.dark).toBe('adjusted(#ff0000, 0)')
|
||||||
|
expect(variants.light).toBe('adjusted(#ff0000, 0.5)')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should generate color variants with custom adjustments', () => {
|
||||||
|
const { getColorValue } = useNodeColorOptions({
|
||||||
|
lightnessAdjustments: {
|
||||||
|
dark: 0.3,
|
||||||
|
light: 0.4
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const variants = getColorValue('#ff0000')
|
||||||
|
|
||||||
|
expect(variants.dark).toBe('adjusted(#ff0000, 0.3)')
|
||||||
|
expect(variants.light).toBe('adjusted(#ff0000, 0.4)')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should include ring colors when configured', () => {
|
||||||
|
const { getColorValue } = useNodeColorOptions({
|
||||||
|
includeRingColors: true,
|
||||||
|
lightnessAdjustments: {
|
||||||
|
ringDark: 0.5,
|
||||||
|
ringLight: 0.1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const variants = getColorValue('#ff0000')
|
||||||
|
|
||||||
|
expect(variants.ringDark).toBe('adjusted(#ff0000, 0.5)')
|
||||||
|
expect(variants.ringLight).toBe('adjusted(#ff0000, 0.1)')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('applyColorToItems', () => {
|
||||||
|
test('should apply color to items', () => {
|
||||||
|
const { applyColorToItems } = useNodeColorOptions()
|
||||||
|
|
||||||
|
const mockItem1 = {
|
||||||
|
setColorOption: vi.fn()
|
||||||
|
} as unknown as IColorable
|
||||||
|
|
||||||
|
const mockItem2 = {
|
||||||
|
setColorOption: vi.fn()
|
||||||
|
} as unknown as IColorable
|
||||||
|
|
||||||
|
applyColorToItems([mockItem1, mockItem2], 'red')
|
||||||
|
|
||||||
|
expect(mockItem1.setColorOption).toHaveBeenCalledWith({
|
||||||
|
bgcolor: '#ff0000',
|
||||||
|
color: '#ffffff'
|
||||||
|
})
|
||||||
|
expect(mockItem2.setColorOption).toHaveBeenCalledWith({
|
||||||
|
bgcolor: '#ff0000',
|
||||||
|
color: '#ffffff'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should reset color when noColor is selected', () => {
|
||||||
|
const { applyColorToItems } = useNodeColorOptions()
|
||||||
|
|
||||||
|
const mockItem = {
|
||||||
|
setColorOption: vi.fn()
|
||||||
|
} as unknown as IColorable
|
||||||
|
|
||||||
|
applyColorToItems([mockItem], 'noColor')
|
||||||
|
|
||||||
|
expect(mockItem.setColorOption).toHaveBeenCalledWith(null)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getCurrentColorName', () => {
|
||||||
|
test('should return null for empty items', () => {
|
||||||
|
const { getCurrentColorName } = useNodeColorOptions()
|
||||||
|
|
||||||
|
expect(getCurrentColorName([])).toBe(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return noColor for items with no color', () => {
|
||||||
|
const { getCurrentColorName } = useNodeColorOptions()
|
||||||
|
|
||||||
|
const mockItem = {
|
||||||
|
getColorOption: vi.fn(() => null)
|
||||||
|
} as unknown as IColorable
|
||||||
|
|
||||||
|
expect(getCurrentColorName([mockItem])).toBe('noColor')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return color name for items with matching color', () => {
|
||||||
|
const { getCurrentColorName } = useNodeColorOptions()
|
||||||
|
|
||||||
|
const mockItem = {
|
||||||
|
getColorOption: vi.fn(() => ({
|
||||||
|
bgcolor: '#ff0000',
|
||||||
|
color: '#ffffff'
|
||||||
|
}))
|
||||||
|
} as unknown as IColorable
|
||||||
|
|
||||||
|
expect(getCurrentColorName([mockItem])).toBe('red')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return null when items have different colors', () => {
|
||||||
|
const { getCurrentColorName } = useNodeColorOptions()
|
||||||
|
|
||||||
|
const mockItem1 = {
|
||||||
|
getColorOption: vi.fn(() => ({
|
||||||
|
bgcolor: '#ff0000',
|
||||||
|
color: '#ffffff'
|
||||||
|
}))
|
||||||
|
} as unknown as IColorable
|
||||||
|
|
||||||
|
const mockItem2 = {
|
||||||
|
getColorOption: vi.fn(() => ({
|
||||||
|
bgcolor: '#0000ff',
|
||||||
|
color: '#ffffff'
|
||||||
|
}))
|
||||||
|
} as unknown as IColorable
|
||||||
|
|
||||||
|
expect(getCurrentColorName([mockItem1, mockItem2])).toBe(null)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isLightTheme', () => {
|
||||||
|
test('should reflect color palette store light theme setting', () => {
|
||||||
|
mockColorPaletteStore.completedActivePalette.light_theme = false
|
||||||
|
const { isLightTheme } = useNodeColorOptions()
|
||||||
|
|
||||||
|
expect(isLightTheme.value).toBe(false)
|
||||||
|
|
||||||
|
mockColorPaletteStore.completedActivePalette.light_theme = true
|
||||||
|
const { isLightTheme: isLightTheme2 } = useNodeColorOptions()
|
||||||
|
|
||||||
|
expect(isLightTheme2.value).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
240
src/composables/graph/useNodeColorOptions.ts
Normal file
240
src/composables/graph/useNodeColorOptions.ts
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import type { ComputedRef } from 'vue'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import type { ColorOption as CanvasColorOption } from '@/lib/litegraph/src/litegraph'
|
||||||
|
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||||
|
import type { IColorable } from '@/lib/litegraph/src/interfaces'
|
||||||
|
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||||
|
import { adjustColor } from '@/utils/colorUtil'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Color variants for different themes and display purposes
|
||||||
|
*/
|
||||||
|
interface ColorVariants {
|
||||||
|
dark: string
|
||||||
|
light: string
|
||||||
|
ringDark?: string
|
||||||
|
ringLight?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node color option with localized name and theme variants
|
||||||
|
*/
|
||||||
|
export interface NodeColorOption {
|
||||||
|
name: string
|
||||||
|
localizedName: string | (() => string)
|
||||||
|
value: ColorVariants
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for useNodeColorOptions composable
|
||||||
|
*/
|
||||||
|
export interface UseNodeColorOptionsConfig {
|
||||||
|
/**
|
||||||
|
* Whether to include ring color variants for UI elements like borders
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
includeRingColors?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightness adjustments for color variants
|
||||||
|
* @default { dark: 0, light: 0.5, ringDark: 0.5, ringLight: 0.1 }
|
||||||
|
*/
|
||||||
|
lightnessAdjustments?: {
|
||||||
|
dark?: number
|
||||||
|
light?: number
|
||||||
|
ringDark?: number
|
||||||
|
ringLight?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether localizedName should be a function instead of a string
|
||||||
|
* Useful when you need reactive i18n updates
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
localizedNameAsFunction?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return type for useNodeColorOptions composable
|
||||||
|
*/
|
||||||
|
export interface UseNodeColorOptionsReturn {
|
||||||
|
colorOptions: ComputedRef<NodeColorOption[]>
|
||||||
|
NO_COLOR_OPTION: ComputedRef<NodeColorOption>
|
||||||
|
getColorValue: (color: string) => ColorVariants
|
||||||
|
applyColorToItems: (items: IColorable[], colorName: string) => void
|
||||||
|
getCurrentColorName: (items: IColorable[]) => string | null
|
||||||
|
isLightTheme: ComputedRef<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for managing node color options with flexible configuration.
|
||||||
|
* Consolidates color picker logic across SetNodeColor, ColorPickerButton, and useNodeCustomization.
|
||||||
|
*
|
||||||
|
* @param config - Configuration options for color variants and localization
|
||||||
|
* @returns Color options, helper functions, and theme information
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Basic usage (2 color variants)
|
||||||
|
* const { colorOptions, applyColorToItems } = useNodeColorOptions()
|
||||||
|
*
|
||||||
|
* // With ring colors (4 color variants)
|
||||||
|
* const { colorOptions, NO_COLOR_OPTION } = useNodeColorOptions({
|
||||||
|
* includeRingColors: true,
|
||||||
|
* lightnessAdjustments: { dark: 0.3, light: 0.4, ringDark: 0.5, ringLight: 0.1 }
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useNodeColorOptions(
|
||||||
|
config: UseNodeColorOptionsConfig = {}
|
||||||
|
): UseNodeColorOptionsReturn {
|
||||||
|
const {
|
||||||
|
includeRingColors = false,
|
||||||
|
lightnessAdjustments = {},
|
||||||
|
localizedNameAsFunction = false
|
||||||
|
} = config
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const colorPaletteStore = useColorPaletteStore()
|
||||||
|
|
||||||
|
const isLightTheme = computed<boolean>(() =>
|
||||||
|
Boolean(colorPaletteStore.completedActivePalette.light_theme)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Default lightness adjustments
|
||||||
|
const defaultAdjustments = {
|
||||||
|
dark: 0,
|
||||||
|
light: 0.5,
|
||||||
|
ringDark: 0.5,
|
||||||
|
ringLight: 0.1
|
||||||
|
}
|
||||||
|
|
||||||
|
const adjustments = {
|
||||||
|
...defaultAdjustments,
|
||||||
|
...lightnessAdjustments
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate color variants for a given base color
|
||||||
|
*/
|
||||||
|
const getColorValue = (color: string): ColorVariants => {
|
||||||
|
const variants: ColorVariants = {
|
||||||
|
dark: adjustColor(color, { lightness: adjustments.dark }),
|
||||||
|
light: adjustColor(color, { lightness: adjustments.light })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeRingColors) {
|
||||||
|
variants.ringDark = adjustColor(color, {
|
||||||
|
lightness: adjustments.ringDark
|
||||||
|
})
|
||||||
|
variants.ringLight = adjustColor(color, {
|
||||||
|
lightness: adjustments.ringLight
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return variants
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeColorEntries = Object.entries(LGraphCanvas.node_colors)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "no color" option that resets nodes to default color
|
||||||
|
*/
|
||||||
|
const NO_COLOR_OPTION = computed<NodeColorOption>(() => {
|
||||||
|
const localizedName = localizedNameAsFunction
|
||||||
|
? () => t('color.noColor')
|
||||||
|
: t('color.noColor')
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'noColor',
|
||||||
|
localizedName,
|
||||||
|
value: getColorValue(LiteGraph.NODE_DEFAULT_BGCOLOR)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All available color options including the "no color" option
|
||||||
|
*/
|
||||||
|
const colorOptions = computed<NodeColorOption[]>(() => {
|
||||||
|
const options: NodeColorOption[] = [
|
||||||
|
NO_COLOR_OPTION.value,
|
||||||
|
...nodeColorEntries.map(([name, color]) => {
|
||||||
|
const localizedName = localizedNameAsFunction
|
||||||
|
? () => t(`color.${name}`)
|
||||||
|
: t(`color.${name}`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
localizedName,
|
||||||
|
value: getColorValue(color.bgcolor)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]
|
||||||
|
|
||||||
|
return options
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a color to multiple colorable items
|
||||||
|
*
|
||||||
|
* @param items - Items that implement IColorable interface
|
||||||
|
* @param colorName - Name of the color to apply (or 'noColor' to reset)
|
||||||
|
*/
|
||||||
|
const applyColorToItems = (items: IColorable[], colorName: string): void => {
|
||||||
|
const canvasColorOption =
|
||||||
|
colorName === NO_COLOR_OPTION.value.name
|
||||||
|
? null
|
||||||
|
: LGraphCanvas.node_colors[colorName]
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
item.setColorOption(canvasColorOption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current color name from a list of items
|
||||||
|
* Returns null if items have different colors or no color is set
|
||||||
|
*
|
||||||
|
* @param items - Items to check color from
|
||||||
|
* @returns Color name or null
|
||||||
|
*/
|
||||||
|
const getCurrentColorName = (items: IColorable[]): string | null => {
|
||||||
|
if (items.length === 0) return null
|
||||||
|
|
||||||
|
const colorOptions = items.map((item) => item.getColorOption())
|
||||||
|
|
||||||
|
// Check if all items have the same color
|
||||||
|
let colorOption: CanvasColorOption | null | false = colorOptions[0]
|
||||||
|
if (!colorOptions.every((option) => option === colorOption)) {
|
||||||
|
colorOption = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different colors
|
||||||
|
if (colorOption === false) return null
|
||||||
|
|
||||||
|
// No color or default color
|
||||||
|
if (colorOption == null || (!colorOption.bgcolor && !colorOption.color)) {
|
||||||
|
return NO_COLOR_OPTION.value.name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find matching color name
|
||||||
|
return (
|
||||||
|
nodeColorEntries.find(
|
||||||
|
([_, color]) =>
|
||||||
|
color.bgcolor === colorOption.bgcolor &&
|
||||||
|
color.color === colorOption.color
|
||||||
|
)?.[0] ?? null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
colorOptions,
|
||||||
|
NO_COLOR_OPTION,
|
||||||
|
getColorValue,
|
||||||
|
applyColorToItems,
|
||||||
|
getCurrentColorName,
|
||||||
|
isLightTheme
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,28 +1,18 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import { useNodeColorOptions } from '@/composables/graph/useNodeColorOptions'
|
||||||
|
import type { NodeColorOption } from '@/composables/graph/useNodeColorOptions'
|
||||||
|
import type { IColorable } from '@/lib/litegraph/src/interfaces'
|
||||||
import {
|
import {
|
||||||
LGraphCanvas,
|
|
||||||
LGraphNode,
|
LGraphNode,
|
||||||
LiteGraph,
|
|
||||||
RenderShape,
|
RenderShape,
|
||||||
isColorable
|
isColorable
|
||||||
} from '@/lib/litegraph/src/litegraph'
|
} from '@/lib/litegraph/src/litegraph'
|
||||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
|
||||||
import { adjustColor } from '@/utils/colorUtil'
|
|
||||||
|
|
||||||
import { useCanvasRefresh } from './useCanvasRefresh'
|
import { useCanvasRefresh } from './useCanvasRefresh'
|
||||||
|
|
||||||
interface ColorOption {
|
|
||||||
name: string
|
|
||||||
localizedName: string
|
|
||||||
value: {
|
|
||||||
dark: string
|
|
||||||
light: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ShapeOption {
|
interface ShapeOption {
|
||||||
name: string
|
name: string
|
||||||
localizedName: string
|
localizedName: string
|
||||||
@@ -35,36 +25,16 @@ interface ShapeOption {
|
|||||||
export function useNodeCustomization() {
|
export function useNodeCustomization() {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const canvasStore = useCanvasStore()
|
const canvasStore = useCanvasStore()
|
||||||
const colorPaletteStore = useColorPaletteStore()
|
|
||||||
const canvasRefresh = useCanvasRefresh()
|
const canvasRefresh = useCanvasRefresh()
|
||||||
const isLightTheme = computed(
|
|
||||||
() => colorPaletteStore.completedActivePalette.light_theme
|
|
||||||
)
|
|
||||||
|
|
||||||
const toLightThemeColor = (color: string) =>
|
// Use shared color options logic
|
||||||
adjustColor(color, { lightness: 0.5 })
|
const {
|
||||||
|
colorOptions,
|
||||||
// Color options
|
|
||||||
const NO_COLOR_OPTION: ColorOption = {
|
|
||||||
name: 'noColor',
|
|
||||||
localizedName: t('color.noColor'),
|
|
||||||
value: {
|
|
||||||
dark: LiteGraph.NODE_DEFAULT_BGCOLOR,
|
|
||||||
light: toLightThemeColor(LiteGraph.NODE_DEFAULT_BGCOLOR)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const colorOptions: ColorOption[] = [
|
|
||||||
NO_COLOR_OPTION,
|
NO_COLOR_OPTION,
|
||||||
...Object.entries(LGraphCanvas.node_colors).map(([name, color]) => ({
|
applyColorToItems,
|
||||||
name,
|
getCurrentColorName,
|
||||||
localizedName: t(`color.${name}`),
|
isLightTheme
|
||||||
value: {
|
} = useNodeColorOptions()
|
||||||
dark: color.bgcolor,
|
|
||||||
light: toLightThemeColor(color.bgcolor)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
]
|
|
||||||
|
|
||||||
// Shape options
|
// Shape options
|
||||||
const shapeOptions: ShapeOption[] = [
|
const shapeOptions: ShapeOption[] = [
|
||||||
@@ -85,18 +55,13 @@ export function useNodeCustomization() {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
const applyColor = (colorOption: ColorOption | null) => {
|
const applyColor = (colorOption: NodeColorOption | null) => {
|
||||||
const colorName = colorOption?.name ?? NO_COLOR_OPTION.name
|
const colorName = colorOption?.name ?? NO_COLOR_OPTION.value.name
|
||||||
const canvasColorOption =
|
|
||||||
colorName === NO_COLOR_OPTION.name
|
|
||||||
? null
|
|
||||||
: LGraphCanvas.node_colors[colorName]
|
|
||||||
|
|
||||||
for (const item of canvasStore.selectedItems) {
|
const colorableItems = Array.from(canvasStore.selectedItems)
|
||||||
if (isColorable(item)) {
|
.filter(isColorable)
|
||||||
item.setColorOption(canvasColorOption)
|
.map((item) => item as unknown as IColorable)
|
||||||
}
|
applyColorToItems(colorableItems, colorName)
|
||||||
}
|
|
||||||
|
|
||||||
canvasRefresh.refreshCanvas()
|
canvasRefresh.refreshCanvas()
|
||||||
}
|
}
|
||||||
@@ -117,25 +82,21 @@ export function useNodeCustomization() {
|
|||||||
canvasRefresh.refreshCanvas()
|
canvasRefresh.refreshCanvas()
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCurrentColor = (): ColorOption | null => {
|
const getCurrentColor = (): NodeColorOption | null => {
|
||||||
const selectedItems = Array.from(canvasStore.selectedItems)
|
const selectedItems = Array.from(canvasStore.selectedItems)
|
||||||
if (selectedItems.length === 0) return null
|
if (selectedItems.length === 0) return null
|
||||||
|
|
||||||
// Get color from first colorable item
|
const colorableItems = selectedItems
|
||||||
const firstColorableItem = selectedItems.find((item) => isColorable(item))
|
.filter(isColorable)
|
||||||
if (!firstColorableItem || !isColorable(firstColorableItem)) return null
|
.map((item) => item as unknown as IColorable)
|
||||||
|
if (colorableItems.length === 0) return null
|
||||||
|
|
||||||
// Get the current color option from the colorable item
|
const currentColorName = getCurrentColorName(colorableItems)
|
||||||
const currentColorOption = firstColorableItem.getColorOption()
|
if (!currentColorName) return null
|
||||||
const currentBgColor = currentColorOption?.bgcolor ?? null
|
|
||||||
|
|
||||||
// Find matching color option
|
|
||||||
return (
|
return (
|
||||||
colorOptions.find(
|
colorOptions.value.find((option) => option.name === currentColorName) ??
|
||||||
(option) =>
|
NO_COLOR_OPTION.value
|
||||||
option.value.dark === currentBgColor ||
|
|
||||||
option.value.light === currentBgColor
|
|
||||||
) ?? NO_COLOR_OPTION
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +117,7 @@ export function useNodeCustomization() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
colorOptions,
|
colorOptions: computed(() => colorOptions.value),
|
||||||
shapeOptions,
|
shapeOptions,
|
||||||
applyColor,
|
applyColor,
|
||||||
applyShape,
|
applyShape,
|
||||||
|
|||||||
@@ -29,8 +29,11 @@ export function useNodeMenuOptions() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const colorSubmenu = computed(() => {
|
const colorSubmenu = computed(() => {
|
||||||
return colorOptions.map((colorOption) => ({
|
return colorOptions.value.map((colorOption) => ({
|
||||||
label: colorOption.localizedName,
|
label:
|
||||||
|
typeof colorOption.localizedName === 'function'
|
||||||
|
? colorOption.localizedName()
|
||||||
|
: colorOption.localizedName,
|
||||||
color: isLightTheme.value
|
color: isLightTheme.value
|
||||||
? colorOption.value.light
|
? colorOption.value.light
|
||||||
: colorOption.value.dark,
|
: colorOption.value.dark,
|
||||||
|
|||||||
Reference in New Issue
Block a user